From c0ef9cbe8b33673990d5858ab8a4fcd0a5de888c Mon Sep 17 00:00:00 2001 From: nol4lej Date: Sun, 12 Apr 2026 19:40:35 -0400 Subject: [PATCH 1/2] feat(circuits): add gasless fee signal to unshield and transfer --- CHANGELOG.md | 31 +- circuits/transfer.circom | 24 +- circuits/unshield.circom | 21 +- manifest.json | 86 ++-- package.json | 2 +- test/transfer.test.ts | 963 +++++++++++++++++++++++---------------- test/unshield.test.ts | 662 +++++++++++++-------------- 7 files changed, 992 insertions(+), 797 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c006d91..2e41e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,36 @@ All notable changes to Orbinum Circuits 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). -## [0.4.4] - 2026-03-19 +## [0.5.0] - 2026-04-12 + +### Added + +- **Gasless fee signal in `unshield` and `transfer` circuits**: + - `circuits/unshield.circom`: new public input `fee`. Constraint 1 changed from `note_value === amount` to `note_value === amount + fee`, allowing the validator (block author) to collect a fee from the note value without requiring a signed extrinsic. + - `circuits/transfer.circom`: new public input `fee`. Conservation constraint changed from `input_sum === output_sum` to `input_sum === output_sum + fee`. + - Both circuits expose `fee` in their `main` component public signals. +- **Fee range checks (defense-in-depth)**: + - `circuits/unshield.circom` (Constraint 3): `Num2Bits(128)` on `fee` prevents field-wraparound attacks where an out-of-range fee could satisfy conservation while output values stay in u128. + - `circuits/transfer.circom` (Constraint 6b): same `Num2Bits(128)` guard on `fee`. +- **Distinct nullifiers check in `transfer`** (Constraint 9): + - Added `IsZero(nullifiers[0] - nullifiers[1]).out === 0` to prevent a prover from spending the same note twice in a single transaction. Without this constraint, setting `input[0] = input[1]` satisfies conservation and both pallet `Nullifiers::contains_key` checks pass before the first insert. + - Added `comparators.circom` include. +- **New tests** (`test/unshield.test.ts`, `test/transfer.test.ts`): + - `unshield`: fee = 0, fee > 0, realistic 0.001 ORB fee, fee = full note value, rejects old pre-gasless witness, rejects amount + fee > note_value, accepts/rejects u128 max fee, rejects fee = 2^128. + - `transfer`: fee = 0, fee > 0, full-fee edge case, rejects pre-gasless balance, accepts/rejects u128 max fee, rejects fee = 2^128, accepts/rejects duplicate nullifiers. + +### Changed + +- **`circuits/unshield.circom`**: constraint numbering updated (3 through 7 shifted +1 due to new fee range check at position 3). +- **`circuits/transfer.circom`**: constraint 6 split into 6 (values) + 6b (fee); constraint 9 added for distinct nullifiers. +- **Artifacts recompiled** (`build/unshield_js/unshield.wasm`, `build/transfer_js/transfer.wasm`, `build/unshield.r1cs`, `build/transfer.r1cs`, `keys/unshield_pk.zkey`, `keys/transfer_pk.zkey`, `build/verification_key_unshield.json`, `build/verification_key_transfer.json`) to reflect new R1CS. +- **`manifest.json`** regenerated with updated SHA-256 checksums and `package_version: "0.5.0"`. +- **`package.json`**: version bump `0.4.4` → `0.5.0`. + +### Security + +- Fee range check (`Num2Bits(128)`) closes a field-arithmetic attack vector specific to the conservation constraint when `fee` is a public input. +- Distinct-nullifiers constraint closes a double-spend vector in `transfer` where a single note could be consumed twice in one transaction. ### Added diff --git a/circuits/transfer.circom b/circuits/transfer.circom index 5c25a77..ab85a1a 100644 --- a/circuits/transfer.circom +++ b/circuits/transfer.circom @@ -4,15 +4,19 @@ include "./note.circom"; include "./merkle_tree.circom"; include "../node_modules/circomlib/circuits/eddsaposeidon.circom"; include "../node_modules/circomlib/circuits/bitify.circom"; +include "../node_modules/circomlib/circuits/comparators.circom"; // Private transfer: 2 input notes → 2 output notes. // Proves Merkle membership, nullifier correctness, EdDSA ownership, // output commitment correctness, value conservation, and range bounds. +// The fee is paid to the block author (validator) by the pallet runtime. template Transfer(tree_depth) { // Public inputs signal input merkle_root; signal input nullifiers[2]; signal input commitments[2]; + signal input asset_id; // asset being transferred (must match input notes) + signal input fee; // gasless fee deducted from input sum; paid to block author // Private inputs — input notes (being spent) signal input input_values[2]; @@ -107,14 +111,14 @@ template Transfer(tree_depth) { output_commitment_computers[i].commitment === commitments[i]; } - // Constraint 5: sum(input values) == sum(output values) + // Constraint 5: sum(input values) == sum(output values) + fee signal input_sum; signal output_sum; input_sum <== input_values[0] + input_values[1]; output_sum <== output_values[0] + output_values[1]; - input_sum === output_sum; + input_sum === output_sum + fee; // Constraint 6: all values must fit in u128 (matches runtime Balance type) component input_range_checks[2]; @@ -128,11 +132,25 @@ template Transfer(tree_depth) { output_range_checks[i].in <== output_values[i]; } + // Constraint 6b: fee must fit in u128 (defense-in-depth; circuit is self-contained) + component fee_range_check = Num2Bits(128); + fee_range_check.in <== fee; + // Constraint 7: all input and output notes must use the same asset_id input_asset_ids[0] === input_asset_ids[1]; input_asset_ids[0] === output_asset_ids[0]; input_asset_ids[0] === output_asset_ids[1]; + + // Constraint 8: public asset_id must match the notes' asset_id + asset_id === input_asset_ids[0]; + + // Constraint 9: input nullifiers must be distinct (prevents double-spending the same note + // in a single transaction; without this a prover can set input[0]=input[1] and get + // 2×value from one note while conservation holds and both pallet checks pass before insert) + component nullifiers_distinct = IsZero(); + nullifiers_distinct.in <== nullifiers[0] - nullifiers[1]; + nullifiers_distinct.out === 0; } // 2 inputs, 2 outputs, 20-level tree (matches pallet MAX_TREE_DEPTH) -component main {public [merkle_root, nullifiers, commitments]} = Transfer(20); +component main {public [merkle_root, nullifiers, commitments, asset_id, fee]} = Transfer(20); diff --git a/circuits/unshield.circom b/circuits/unshield.circom index 335f5fc..b560820 100644 --- a/circuits/unshield.circom +++ b/circuits/unshield.circom @@ -12,9 +12,10 @@ template Unshield(tree_depth) { // Public inputs signal input merkle_root; signal input nullifier; - signal input amount; // revealed withdrawal amount + signal input amount; // net withdrawal amount (recipient receives this) signal input recipient; // recipient address (validated non-zero in runtime) signal input asset_id; // asset being unshielded + signal input fee; // gasless fee deducted from note value // Private inputs signal input note_value; @@ -27,14 +28,18 @@ template Unshield(tree_depth) { signal input path_elements[tree_depth]; signal input path_indices[tree_depth]; // 0 = left, 1 = right - // Constraint 1: revealed amount must equal note value - amount === note_value; + // Constraint 1: note value must cover amount + fee + note_value === amount + fee; // Constraint 2: note_value must fit in u128 (matches runtime Balance type) component value_range_check = Num2Bits(128); value_range_check.in <== note_value; - // Constraint 3: compute note commitment + // Constraint 3: fee must fit in u128 (defense-in-depth; circuit is self-contained) + component fee_range_check = Num2Bits(128); + fee_range_check.in <== fee; + + // Constraint 4: compute note commitment component commitment_computer = NoteCommitment(); commitment_computer.value <== note_value; commitment_computer.asset_id <== note_asset_id; @@ -44,7 +49,7 @@ template Unshield(tree_depth) { signal computed_commitment; computed_commitment <== commitment_computer.commitment; - // Constraint 4: commitment must exist in the Merkle tree + // Constraint 5: commitment must exist in the Merkle tree component merkle_verifier = MerkleTreeVerifier(tree_depth); merkle_verifier.leaf <== computed_commitment; @@ -55,16 +60,16 @@ template Unshield(tree_depth) { merkle_verifier.root === merkle_root; - // Constraint 5: nullifier must equal Poseidon(commitment, spending_key) + // Constraint 6: nullifier must equal Poseidon(commitment, spending_key) component nullifier_computer = Nullifier(); nullifier_computer.commitment <== computed_commitment; nullifier_computer.spending_key <== spending_key; nullifier_computer.nullifier === nullifier; - // Constraint 6: note asset_id must match the declared public asset_id + // Constraint 7: note asset_id must match the declared public asset_id note_asset_id === asset_id; } // Tree depth 20 matches pallet MAX_TREE_DEPTH -component main {public [merkle_root, nullifier, amount, recipient, asset_id]} = Unshield(20); +component main {public [merkle_root, nullifier, amount, recipient, asset_id, fee]} = Unshield(20); diff --git a/manifest.json b/manifest.json index af74284..ea6084a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,8 +1,8 @@ { "schema_version": "1.0.0", "package_name": "orbinum-circuits", - "package_version": "0.4.3", - "generated_at": "2026-03-08T18:05:52.859Z", + "package_version": "0.5.0", + "generated_at": "2026-04-12T23:38:48.562Z", "circuits": { "disclosure": { "active_version": 1, @@ -12,7 +12,7 @@ "versions": { "1": { "version": 1, - "vk_hash": "0x9f597dab944eacdcaaea2ddf11f1885786a2efe53ab856a19b29c978834c060a", + "vk_hash": "0x4c46d88196fd2445e4e708f63ea54afdaa67d47fde0d5a308b628db8c5c28472", "artifacts": { "wasm": { "file": "disclosure.wasm", @@ -24,19 +24,19 @@ "file": "disclosure_pk.zkey", "localPath": "keys/disclosure_pk.zkey", "bytes": 554204, - "sha256": "02d29a8a08b5815ac958e7cab60e47b85d063bc3432a62ff2f6cdc070374caa2" + "sha256": "49864c02897956146163757eb1e5dfbb1d80a26f3d855871eff98e8915d986d1" }, "vk_json": { "file": "verification_key_disclosure.json", "localPath": "build/verification_key_disclosure.json", - "bytes": 3470, - "sha256": "9f597dab944eacdcaaea2ddf11f1885786a2efe53ab856a19b29c978834c060a" + "bytes": 3469, + "sha256": "4c46d88196fd2445e4e708f63ea54afdaa67d47fde0d5a308b628db8c5c28472" }, - "ark": { - "file": "disclosure_pk.ark", - "localPath": "keys/disclosure_pk.ark", - "bytes": 253232, - "sha256": "7cb195502a659a73357895128c430a01730665e1019338693ee54bbe355f323b" + "r1cs": { + "file": "disclosure.r1cs", + "localPath": "build/disclosure.r1cs", + "bytes": 159840, + "sha256": "c8dc1ba76e133ebd39db020ce52b1d8587103209d83dbf02d7eb7545557042f0" } } } @@ -50,31 +50,37 @@ "versions": { "1": { "version": 1, - "vk_hash": "0x767efb38c3e08df88991e4c1cd244d1600d825d6f87b02b517d01b37b5905f82", + "vk_hash": "0x2404623e0c306575a18d0509379622d05bbffe43c0e3082ae0a45154db8ce8b3", "artifacts": { "wasm": { "file": "transfer.wasm", "localPath": "build/transfer_js/transfer.wasm", - "bytes": 3359868, - "sha256": "97cae6deed6c526ed0a287da3d3ab4b8f2cc71ccc956278d262f850ec435b7e5" + "bytes": 3360765, + "sha256": "5523070bc3f0eb828447a71460f16e32ec950f2d73c9166701a5876d4fbbde87" }, "zkey": { "file": "transfer_pk.zkey", "localPath": "keys/transfer_pk.zkey", - "bytes": 20484784, - "sha256": "c2e3178d580b9cb37108e1e7d733877b7a161ce4ce20675bf39f9fe9365042be" + "bytes": 20544096, + "sha256": "371f5ac49a38fba76ac6e67d59c4ebd0456f1a238f84778055cf41bb784218c1" }, "vk_json": { "file": "verification_key_transfer.json", "localPath": "build/verification_key_transfer.json", - "bytes": 3656, - "sha256": "767efb38c3e08df88991e4c1cd244d1600d825d6f87b02b517d01b37b5905f82" + "bytes": 4023, + "sha256": "2404623e0c306575a18d0509379622d05bbffe43c0e3082ae0a45154db8ce8b3" }, "ark": { "file": "transfer_pk.ark", "localPath": "keys/transfer_pk.ark", - "bytes": 8739088, - "sha256": "f811ddf980f1eb42faa9258ec2e3981b4de11bc5fbbe4d3fec0cbc09572d8647" + "bytes": 8739408, + "sha256": "410448a20ddd82a05bffa0cf6d881babf9e0e38dc20099615e652cbada0b3d70" + }, + "r1cs": { + "file": "transfer.r1cs", + "localPath": "build/transfer.r1cs", + "bytes": 6651056, + "sha256": "05f3f983ae3bbb8255c4ee5fbd4e02b9228177ff8323b4b04d0dc9ab5a5dddc8" } } } @@ -88,31 +94,37 @@ "versions": { "1": { "version": 1, - "vk_hash": "0xc28dc81278492a2b04866a3b13e4f72abca05a9563587cc3e7a86cf4a68c4585", + "vk_hash": "0x71452516126e47e9831a37633da7f5e2b20210cdbe0aee7c0d0b90734e8aad03", "artifacts": { "wasm": { "file": "unshield.wasm", "localPath": "build/unshield_js/unshield.wasm", - "bytes": 2396830, - "sha256": "aebf93eb6d7544742eb8bffdbedc82101ff524efdfb649839e3f5b3960c3f06e" + "bytes": 2397657, + "sha256": "5a47445fac226c1e38e50f7576b3545adebc795c3f393ad046508cc4785f0134" }, "zkey": { "file": "unshield_pk.zkey", "localPath": "keys/unshield_pk.zkey", - "bytes": 5326768, - "sha256": "dcbd7b9c955f7526817b972eb3bf164b1f0fa486527c3bfe019687f278c7fc0d" + "bytes": 5385308, + "sha256": "3755ef5e991ad67241e9ba7f158e5ed66ca9879916783e1b40a7e0549abb97b1" }, "vk_json": { "file": "verification_key_unshield.json", "localPath": "build/verification_key_unshield.json", - "bytes": 3658, - "sha256": "c28dc81278492a2b04866a3b13e4f72abca05a9563587cc3e7a86cf4a68c4585" + "bytes": 3838, + "sha256": "71452516126e47e9831a37633da7f5e2b20210cdbe0aee7c0d0b90734e8aad03" }, "ark": { "file": "unshield_pk.ark", "localPath": "keys/unshield_pk.ark", - "bytes": 2413904, - "sha256": "d9b7103466a2f18352a7e8038c1faff2f79e2c6ffeaba8dd57dc34f486c550cf" + "bytes": 2414224, + "sha256": "2a50790cf794cebca9b8b115bc306089ddd77faf4ae08df89910c05c7861ede5" + }, + "r1cs": { + "file": "unshield.r1cs", + "localPath": "build/unshield.r1cs", + "bytes": 1605588, + "sha256": "981dad31ca0477d641c18c0fbfb2bce66925d3ec34661e1ebb75c91abbf80941" } } } @@ -126,7 +138,7 @@ "versions": { "1": { "version": 1, - "vk_hash": "0xad452933eb017073d8d9106c7dfaf98ad1cb5f2f026b261c5b4345e21b491f1f", + "vk_hash": "0xa2477ab9d7a3a50a66d368d96008d3a8a5902c05e926e712f91c9dea0c5d1612", "artifacts": { "wasm": { "file": "private_link.wasm", @@ -138,19 +150,19 @@ "file": "private_link_pk.zkey", "localPath": "keys/private_link_pk.zkey", "bytes": 508588, - "sha256": "2faf5ea6214a13012a90d8c87e9ea54aaa93a2acf5160ba984fe223d7aa82fa6" + "sha256": "6f49f0fe8920955fa829209a35bc491af87cba026aee2ed53e0b295b9ce6037d" }, "vk_json": { "file": "verification_key_private_link.json", "localPath": "build/verification_key_private_link.json", "bytes": 3109, - "sha256": "ad452933eb017073d8d9106c7dfaf98ad1cb5f2f026b261c5b4345e21b491f1f" + "sha256": "a2477ab9d7a3a50a66d368d96008d3a8a5902c05e926e712f91c9dea0c5d1612" }, - "ark": { - "file": "private_link_pk.ark", - "localPath": "keys/private_link_pk.ark", - "bytes": 232272, - "sha256": "63bda6058bb2a68d5590f1a897aa3b391184b5dead410ceac11909ac3a72be63" + "r1cs": { + "file": "private_link.r1cs", + "localPath": "build/private_link.r1cs", + "bytes": 138248, + "sha256": "62b38b485b501d0423580c5f217f5cc088d46ead3956097cd893358df7b3fa8c" } } } diff --git a/package.json b/package.json index 09d726e..d0efff1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "orbinum-circuits", - "version": "0.4.4", + "version": "0.5.0", "description": "Zero-Knowledge circuits for Orbinum privacy blockchain", "scripts": { "compile": "npm run compile:transfer", diff --git a/test/transfer.test.ts b/test/transfer.test.ts index 7862b88..968919b 100644 --- a/test/transfer.test.ts +++ b/test/transfer.test.ts @@ -1,460 +1,627 @@ -import { buildPoseidon } from "circomlibjs"; -import assert from "assert"; +import path from "path"; +import fs from "fs"; +import { expect } from "chai"; +import { wasm as wasm_tester } from "circom_tester"; +import { buildPoseidon, buildEddsa } from "circomlibjs"; +import type { WasmTester } from "circom_tester"; -describe("Transfer Circuit Logic", function () { - this.timeout(60000); +// ─── Constants ──────────────────────────────────────────────────────────────── - let poseidon: any; - - before(async () => { - poseidon = await buildPoseidon(); - }); +const TREE_DEPTH = 20; - describe("Note Commitment", () => { - it("should compute note commitment correctly", () => { - const value = 100n; - const asset_id = 0n; - const owner_pubkey = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; +describe("Transfer Circuit (gasless)", function () { + this.timeout(180_000); - const commitment: string = poseidon.F.toString( - poseidon([value, asset_id, owner_pubkey, blinding]) - ); - - console.log(" Note Commitment:", commitment); - assert(commitment !== "0", "Commitment should not be zero"); - }); + const circuitPath = path.join(__dirname, "..", "circuits", "transfer.circom"); + const outputDir = path.join(__dirname, "..", "build"); + const precompiledWasm = path.join(outputDir, "transfer_js", "transfer.wasm"); - it("should produce different commitments for different values", () => { - const asset_id = 0n; - const owner_pubkey = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; + let circuit: WasmTester; + let poseidon: any; + let eddsa: any; + let F: any; + + // Two test key pairs (Alice owns note 0, Bob owns note 1) + let alice: { privKey: Buffer; Ax: bigint; Ay: bigint }; + let bob: { privKey: Buffer; Ax: bigint; Ay: bigint }; + + // ── Helpers ───────────────────────────────────────────────────────────── + + function computeCommitment( + value: bigint, + assetId: bigint, + ownerAx: bigint, + blinding: bigint + ): bigint { + return F.toObject(poseidon([value, assetId, ownerAx, blinding])); + } + + function computeNullifier(commitment: bigint, spendingKey: bigint): bigint { + return F.toObject(poseidon([commitment, spendingKey])); + } + + /** Sparse Merkle proof builder. Only materialises the O(N·depth) non-zero nodes, + * keeping runtime proportional to the number of leaves, not 2^depth. */ + function buildMerkleProof( + leaves: bigint[], + leafIndex: number + ): { root: bigint; pathElements: bigint[]; pathIndices: number[] } { + const pathElements: bigint[] = []; + const pathIndices: number[] = []; + let level = new Map(); + for (let i = 0; i < leaves.length; i++) level.set(i, leaves[i]); + for (let d = 0; d < TREE_DEPTH; d++) { + const nodeIdx = leafIndex >> d; + const isRight = nodeIdx % 2 === 1; + pathIndices.push(isRight ? 1 : 0); + pathElements.push(level.get(isRight ? nodeIdx - 1 : nodeIdx + 1) ?? 0n); + const nextLevel = new Map(); + for (const [pos] of level) { + const parentPos = pos >> 1; + if (nextLevel.has(parentPos)) continue; + const l = level.get(parentPos * 2) ?? 0n; + const r = level.get(parentPos * 2 + 1) ?? 0n; + nextLevel.set(parentPos, F.toObject(poseidon([l, r]))); + } + level = nextLevel; + } + return { root: level.get(0) ?? 0n, pathElements, pathIndices }; + } + + /** Generate an EdDSA Poseidon signature over a field element. */ + function sign(privKey: Buffer, commitment: bigint): { R8x: bigint; R8y: bigint; S: bigint } { + const sig = eddsa.signPoseidon(privKey, F.e(commitment)); + return { + R8x: F.toObject(sig.R8[0]), + R8y: F.toObject(sig.R8[1]), + S: sig.S, + }; + } + + /** + * Build a complete valid transfer circuit input. + * note0 (Alice) at index 0, note1 (Bob) at index 1 in the same tree. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function buildInput(opts: { + value0: bigint; + value1: bigint; + outValue0: bigint; + outValue1: bigint; + fee: bigint; + assetId?: bigint; + blinding0?: bigint; + blinding1?: bigint; + spendingKey0?: bigint; + spendingKey1?: bigint; + outBlinding0?: bigint; + outBlinding1?: bigint; + outOwner0?: bigint; + outOwner1?: bigint; + }): any { + const assetId = opts.assetId ?? 0n; + const bl0 = opts.blinding0 ?? 0xaaaaaaaabbbbbbbbaaaaaaaabn; + const bl1 = opts.blinding1 ?? 0xccccccccddddddddccccccccdn; + const sk0 = opts.spendingKey0 ?? 0xdeadbeef0000001n; + const sk1 = opts.spendingKey1 ?? 0xdeadbeef0000002n; + const outBl0 = opts.outBlinding0 ?? 0x1111111100000001n; + const outBl1 = opts.outBlinding1 ?? 0x2222222200000002n; + const outOwner0 = opts.outOwner0 ?? alice.Ax; + const outOwner1 = opts.outOwner1 ?? bob.Ax; + + const comm0 = computeCommitment(opts.value0, assetId, alice.Ax, bl0); + const comm1 = computeCommitment(opts.value1, assetId, bob.Ax, bl1); + + const { root, pathElements: pe0, pathIndices: pi0 } = buildMerkleProof([comm0, comm1], 0); + const { pathElements: pe1, pathIndices: pi1 } = buildMerkleProof([comm0, comm1], 1); + + const null0 = computeNullifier(comm0, sk0); + const null1 = computeNullifier(comm1, sk1); + + const sigA = sign(alice.privKey, comm0); + const sigB = sign(bob.privKey, comm1); + + const outComm0 = computeCommitment(opts.outValue0, assetId, outOwner0, outBl0); + const outComm1 = computeCommitment(opts.outValue1, assetId, outOwner1, outBl1); + + return { + merkle_root: root.toString(), + nullifiers: [null0.toString(), null1.toString()], + commitments: [outComm0.toString(), outComm1.toString()], + asset_id: assetId.toString(), + fee: opts.fee.toString(), + input_values: [opts.value0.toString(), opts.value1.toString()], + input_asset_ids: [assetId.toString(), assetId.toString()], + input_blindings: [bl0.toString(), bl1.toString()], + spending_keys: [sk0.toString(), sk1.toString()], + input_owner_Ax: [alice.Ax.toString(), bob.Ax.toString()], + input_owner_Ay: [alice.Ay.toString(), bob.Ay.toString()], + input_sig_R8x: [sigA.R8x.toString(), sigB.R8x.toString()], + input_sig_R8y: [sigA.R8y.toString(), sigB.R8y.toString()], + input_sig_S: [sigA.S.toString(), sigB.S.toString()], + input_path_elements: [pe0.map(String), pe1.map(String)], + input_path_indices: [pi0, pi1], + output_values: [opts.outValue0.toString(), opts.outValue1.toString()], + output_asset_ids: [assetId.toString(), assetId.toString()], + output_owner_pubkeys: [outOwner0.toString(), outOwner1.toString()], + output_blindings: [outBl0.toString(), outBl1.toString()], + }; + } + + // ── Setup ───────────────────────────────────────────────────────────────── + + before(async function () { + poseidon = await buildPoseidon(); + eddsa = await buildEddsa(); + F = poseidon.F; - const c1: string = poseidon.F.toString( - poseidon([100n, asset_id, owner_pubkey, blinding]) + alice = (() => { + const privKey = Buffer.from( + "0001020304050607080900010203040506070809000102030405060708090001", + "hex" ); - const c2: string = poseidon.F.toString( - poseidon([200n, asset_id, owner_pubkey, blinding]) + const pub = eddsa.prv2pub(privKey); + return { privKey, Ax: F.toObject(pub[0]), Ay: F.toObject(pub[1]) }; + })(); + bob = (() => { + const privKey = Buffer.from( + "0102030405060708090001020304050607080900010203040506070809000102", + "hex" ); + const pub = eddsa.prv2pub(privKey); + return { privKey, Ax: F.toObject(pub[0]), Ay: F.toObject(pub[1]) }; + })(); - assert(c1 !== c2, "Different values should produce different commitments"); - }); - - it("should be deterministic (same inputs = same output)", () => { - const value = 100n; - const asset_id = 0n; - const owner_pubkey = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; - - const c1: string = poseidon.F.toString( - poseidon([value, asset_id, owner_pubkey, blinding]) - ); - const c2: string = poseidon.F.toString( - poseidon([value, asset_id, owner_pubkey, blinding]) + if (!fs.existsSync(precompiledWasm)) { + console.log( + " ⚠ Pre-compiled wasm not found. Run 'pnpm build-all' to enable circuit tests." ); - - assert.strictEqual(c1, c2, "Should be deterministic"); - }); + return; + } + circuit = await wasm_tester(circuitPath, { output: outputDir, recompile: true }); }); - describe("Nullifier", () => { - it("should compute nullifier correctly", () => { - const commitment = 0x123456n; - const spending_key = 0xdeadbeefn; + // ── 1. Commitment arithmetic (no wasm needed) ───────────────────────────── - const nullifier: string = poseidon.F.toString(poseidon([commitment, spending_key])); - - console.log(" Nullifier:", nullifier); - assert(nullifier !== "0", "Nullifier should not be zero"); + describe("Commitment arithmetic", () => { + it("is deterministic", () => { + const c1 = computeCommitment(100n, 0n, 0xabcdn, 0xef01n); + const c2 = computeCommitment(100n, 0n, 0xabcdn, 0xef01n); + expect(c1).to.equal(c2); }); - it("should be unlinkable (different spending keys)", () => { - const commitment = 0x123456n; - const sk1 = 0xdeadbeefn; - const sk2 = 0xcafebaben; + it("changes with each field", () => { + const base = computeCommitment(100n, 0n, 0xabcdn, 0xef01n); + expect(computeCommitment(200n, 0n, 0xabcdn, 0xef01n)).not.to.equal(base); + expect(computeCommitment(100n, 1n, 0xabcdn, 0xef01n)).not.to.equal(base); + expect(computeCommitment(100n, 0n, 0x9999n, 0xef01n)).not.to.equal(base); + expect(computeCommitment(100n, 0n, 0xabcdn, 0x1234n)).not.to.equal(base); + }); - const n1: string = poseidon.F.toString(poseidon([commitment, sk1])); - const n2: string = poseidon.F.toString(poseidon([commitment, sk2])); + it("nullifiers differ per (commitment, spendingKey)", () => { + const c = computeCommitment(100n, 0n, 0xabcdn, 0xef01n); + expect(computeNullifier(c, 0xdeadn)).not.to.equal(computeNullifier(c, 0xbeefn)); + }); - assert(n1 !== n2, "Different spending keys should produce different nullifiers"); + it("supports max u128 value", () => { + const MAX = 2n ** 128n - 1n; + const c = computeCommitment(MAX, 0n, 0xabcdn, 0xef01n); + expect(c).not.to.equal(0n); }); }); - describe("Merkle Tree", () => { - it("should compute merkle root for 2-leaf tree", () => { - const leaf1 = 0x1111n; - const leaf2 = 0x2222n; + // ── 2. Gasless fee constraint: input_sum === output_sum + fee (Constraint 5) ── + + describe("Gasless fee constraint (Constraint 5)", () => { + it("accepts fee = 0 (input_sum = output_sum)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 600n, + outValue1: 400n, + fee: 0n, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - const root: string = poseidon.F.toString(poseidon([leaf1, leaf2])); + it("accepts fee > 0: input_sum = output_sum + fee", async function () { + if (!circuit) return this.skip(); + const fee = 1_000_000_000_000_000n; // 0.001 ORB + const inSum = 10_000_000_000_000_000_000n; // 10 ORB total input + const outA = inSum - fee; + const input = buildInput({ + value0: inSum / 2n, + value1: inSum / 2n, + outValue0: outA, + outValue1: 0n, + fee, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - console.log(" Merkle Root:", root); - assert(root !== "0", "Root should not be zero"); + it("accepts fee = entire input (all to fee, output = 0 + 0)", async function () { + if (!circuit) return this.skip(); + const total = 1000n; + const input = buildInput({ + value0: 600n, + value1: 400n, + outValue0: 0n, + outValue1: 0n, + fee: total, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should verify merkle path (depth 2)", () => { - // Build tree: - // root - // / \ - // h01 h23 - // / \ / \ - // l0 l1 l2 l3 - - const l0 = 0x1111n; - const l1 = 0x2222n; - const l2 = 0x3333n; - const l3 = 0x4444n; - - const h01 = poseidon([l0, l1]); - const h23 = poseidon([l2, l3]); - const root: string = poseidon.F.toString(poseidon([h01, h23])); - - // Verify l0 is in tree - // Path: l0 -> h01 -> root - // Siblings: [l1, h23] - // Indices: [0, 0] (l0 is left child, h01 is left child) - - const computed_h01 = poseidon([l0, l1]); // l0 + sibling[0] - const computed_root: string = poseidon.F.toString( - poseidon([computed_h01, h23]) // h01 + sibling[1] - ); + it("rejects: pre-gasless balance (output = input, fee = 1 → constraint failure)", async function () { + if (!circuit) return this.skip(); + // input_sum=1000, output_sum=1000, fee=1 → 1000 ≠ 1001 + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 600n, + outValue1: 400n, + fee: 1n, + }); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); - assert.strictEqual(computed_root, root, "Should verify merkle path"); + it("rejects: outputs exceed inputs (theft attempt)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 100n, + value1: 100n, + outValue0: 150n, + outValue1: 100n, + fee: 0n, + }); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Balance Conservation", () => { - it("should enforce balance equality", () => { - const input1 = 100n; - const input2 = 50n; - const output1 = 80n; - const output2 = 70n; - - const input_sum = input1 + input2; - const output_sum = output1 + output2; - - assert.strictEqual(input_sum, output_sum, "Balances should be equal"); + // ── 3. EdDSA ownership (Constraint 3) ───────────────────────────────────── + + describe("EdDSA ownership (Constraint 3)", () => { + it("accepts valid signatures from both owners", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 300n, + value1: 200n, + outValue0: 499n, + outValue1: 0n, + fee: 1n, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should reject imbalanced transfer", () => { - const input_sum: bigint = 100n; - const output_sum: bigint = 110n; - - assert(input_sum !== output_sum, "Should detect imbalance"); + it("rejects tampered signature S component", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 300n, + value1: 200n, + outValue0: 499n, + outValue1: 0n, + fee: 1n, + }); + input.input_sig_S[0] = (BigInt(input.input_sig_S[0]) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should support u128 values in transfer inputs and outputs", () => { - console.log("\n === Testing u128 Range for Transfer (Num2Bits(128)) ==="); - - // Test large amounts in transfer circuit - const largeInput1 = 1000n * 10n ** 18n; // 1000 ORB - const largeInput2 = 500n * 10n ** 18n; // 500 ORB - const largeOutput1 = 800n * 10n ** 18n; // 800 ORB - const largeOutput2 = 700n * 10n ** 18n; // 700 ORB - - const input_sum = largeInput1 + largeInput2; - const output_sum = largeOutput1 + largeOutput2; - - console.log(" Input 1: 1000 ORB =", largeInput1.toString(), "wei"); - console.log(" Input 2: 500 ORB =", largeInput2.toString(), "wei"); - console.log(" Output 1: 800 ORB =", largeOutput1.toString(), "wei"); - console.log(" Output 2: 700 ORB =", largeOutput2.toString(), "wei"); - - assert.strictEqual(input_sum, output_sum, "Large u128 balances should be equal"); - console.log(" ✓ u128 values supported in both inputs and outputs"); + it("rejects mismatched public key (wrong owner for note)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 300n, + value1: 200n, + outValue0: 499n, + outValue1: 0n, + fee: 1n, + }); + // Swap Alice's pubkey for Bob's — commitment was built with Alice's Ax, so this mismatches + input.input_owner_Ax[0] = bob.Ax.toString(); + input.input_owner_Ay[0] = bob.Ay.toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); + }); - it("should handle maximum u128 values in transfer", () => { - console.log("\n === Testing Maximum u128 Transfer ==="); - - // Maximum u128 - const maxU128 = 2n ** 128n - 1n; - const halfMax = maxU128 / 2n; - const remainingHalf = maxU128 - halfMax; - - // Transfer max value split into two outputs - const input1 = maxU128; - const output1 = halfMax; - const output2 = remainingHalf; - - console.log(" Max u128:", maxU128.toString()); - console.log(" Input: max u128"); - console.log(" Output 1: half"); - console.log(" Output 2: remaining"); - - assert.strictEqual(input1, output1 + output2, "Should split max u128 correctly"); - console.log(" ✓ Maximum u128 transfer validated"); + // ── 4. Nullifier integrity (Constraint 2) ───────────────────────────────── + + describe("Nullifier integrity (Constraint 2)", () => { + it("rejects tampered public nullifier[0]", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 200n, + value1: 300n, + outValue0: 498n, + outValue1: 0n, + fee: 2n, + }); + input.nullifiers[0] = (BigInt(input.nullifiers[0]) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should create commitments with large u128 values", () => { - const largeValue = 10000n * 10n ** 18n; // 10,000 ORB - const asset_id = 0n; - const owner_pubkey = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; - - const commitment: string = poseidon.F.toString( - poseidon([largeValue, asset_id, owner_pubkey, blinding]) - ); - - console.log("\n Testing large value commitment:"); - console.log(" Value: 10,000 ORB =", largeValue.toString(), "wei"); - console.log(" Commitment:", commitment.slice(0, 20) + "..."); - - assert(commitment !== "0", "Should create commitment with large u128 value"); - console.log(" ✓ Large u128 commitment created successfully"); + it("rejects wrong spending_key[1]", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 200n, + value1: 300n, + outValue0: 498n, + outValue1: 0n, + fee: 2n, + }); + input.spending_keys[1] = "999999999999999999"; + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); + }); - it("should verify u64 limit no longer applies", () => { - const u64_max = 2n ** 64n - 1n; - const exceeds_u64 = u64_max + 1000n * 10n ** 18n; // Much larger than u64 - - console.log("\n === Verifying u64 Limitation Removed ==="); - console.log(" Old u64 max:", u64_max.toString()); - console.log(" Testing with:", exceeds_u64.toString()); - console.log(" Difference:", (exceeds_u64 - u64_max).toString()); - - // Create transfer with amount exceeding old u64 limit - const input_sum = exceeds_u64; - const output_sum = exceeds_u64; + // ── 5. Merkle membership (Constraint 1) ─────────────────────────────────── + + describe("Merkle membership (Constraint 1)", () => { + it("rejects wrong Merkle root", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 200n, + value1: 200n, + outValue0: 399n, + outValue1: 0n, + fee: 1n, + }); + input.merkle_root = (BigInt(input.merkle_root) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); - assert.strictEqual(input_sum, output_sum, "Should handle values >u64"); - console.log(" ✓ Values exceeding u64 (18.4 ORB) now supported"); - console.log(" ✓ u128 range (up to ~340 undecillion) available"); + it("rejects tampered path sibling", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 200n, + value1: 200n, + outValue0: 399n, + outValue1: 0n, + fee: 1n, + }); + input.input_path_elements[0][0] = ( + BigInt(input.input_path_elements[0][0]) + 1n + ).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Complete Transfer Example", () => { - it("should simulate valid transfer", () => { - console.log("\n === Simulating Private Transfer ==="); - - // Alice has 2 notes worth 100 and 50 - const input1_value = 100n; - const input2_value = 50n; - const asset_id = 0n; - const alice_pk = 0x1234567890abcdefn; - - const input1_commitment: string = poseidon.F.toString( - poseidon([input1_value, asset_id, alice_pk, 0x1111n]) - ); - const input2_commitment: string = poseidon.F.toString( - poseidon([input2_value, asset_id, alice_pk, 0x2222n]) - ); - - console.log(" Input 1 commitment:", input1_commitment.slice(0, 20) + "..."); - console.log(" Input 2 commitment:", input2_commitment.slice(0, 20) + "..."); - - // Alice creates 2 output notes for Bob (80) and change (70) - const bob_pk = 0xfedcba0987654321n; - const output1_value = 80n; - const output2_value = 70n; - - const output1_commitment: string = poseidon.F.toString( - poseidon([output1_value, asset_id, bob_pk, 0x3333n]) - ); - const output2_commitment: string = poseidon.F.toString( - poseidon([output2_value, asset_id, alice_pk, 0x4444n]) // Change to Alice - ); - - console.log(" Output 1 commitment:", output1_commitment.slice(0, 20) + "..."); - console.log(" Output 2 commitment:", output2_commitment.slice(0, 20) + "..."); - - // Verify balance - const input_sum = input1_value + input2_value; - const output_sum = output1_value + output2_value; - - assert.strictEqual(input_sum, output_sum, "Balance should be preserved"); - console.log(" ✓ Balance preserved: 150 = 150"); + // ── 6. Output commitment verification (Constraint 4) ───────────────────── + + describe("Output commitment verification (Constraint 4)", () => { + it("rejects tampered public output commitment", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 300n, + value1: 200n, + outValue0: 499n, + outValue1: 0n, + fee: 1n, + }); + input.commitments[0] = (BigInt(input.commitments[0]) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Multi-Asset Support", () => { - it("should allow transfer with asset_id = 1 (USDT)", () => { - console.log("\n === Testing USDT Transfer (asset_id = 1) ==="); - - const asset_id = 1n; // USDT - const alice_pk = 0x1234567890abcdefn; - const bob_pk = 0xfedcba0987654321n; - - // Alice has 2 USDT notes - const input1_value = 500n; - const input2_value = 300n; - - const input1_commitment: string = poseidon.F.toString( - poseidon([input1_value, asset_id, alice_pk, 0x1111n]) - ); - const input2_commitment: string = poseidon.F.toString( - poseidon([input2_value, asset_id, alice_pk, 0x2222n]) - ); - - console.log(" Input 1 (500 USDT):", input1_commitment.slice(0, 20) + "..."); - console.log(" Input 2 (300 USDT):", input2_commitment.slice(0, 20) + "..."); - - // Alice transfers 600 USDT to Bob, 200 USDT change - const output1_value = 600n; - const output2_value = 200n; - - const output1_commitment: string = poseidon.F.toString( - poseidon([output1_value, asset_id, bob_pk, 0x3333n]) - ); - const output2_commitment: string = poseidon.F.toString( - poseidon([output2_value, asset_id, alice_pk, 0x4444n]) - ); - - console.log(" Output 1 (600 USDT to Bob):", output1_commitment.slice(0, 20) + "..."); - console.log(" Output 2 (200 USDT change):", output2_commitment.slice(0, 20) + "..."); - - // Verify balance - const input_sum = input1_value + input2_value; - const output_sum = output1_value + output2_value; - - assert.strictEqual(input_sum, output_sum, "Balance should be preserved"); - console.log(" ✓ Balance preserved: 800 = 800"); - console.log(" ✓ All notes use asset_id = 1 (USDT)"); + // ── 7. Asset ID enforcement (Constraints 7 & 8) ─────────────────────────── + + describe("Asset ID enforcement (Constraints 7 & 8)", () => { + it("accepts non-native asset (USDT, asset_id = 1)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + assetId: 1n, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should allow transfer with asset_id = 42 (Custom Token)", () => { - const asset_id = 42n; // Custom token - const owner_pk = 0xabcdefn; - - const input1_value = 1000n; - const input2_value = 2000n; - const output1_value = 1500n; - const output2_value = 1500n; - - const input1: string = poseidon.F.toString( - poseidon([input1_value, asset_id, owner_pk, 0xaa11n]) - ); - const input2: string = poseidon.F.toString( - poseidon([input2_value, asset_id, owner_pk, 0xbb22n]) - ); - const output1: string = poseidon.F.toString( - poseidon([output1_value, asset_id, owner_pk, 0xcc33n]) - ); - const output2: string = poseidon.F.toString( - poseidon([output2_value, asset_id, owner_pk, 0xdd44n]) - ); - - assert(input1 !== "0", "Input 1 commitment valid"); - assert(input2 !== "0", "Input 2 commitment valid"); - assert(output1 !== "0", "Output 1 commitment valid"); - assert(output2 !== "0", "Output 2 commitment valid"); - - const input_sum = input1_value + input2_value; - const output_sum = output1_value + output2_value; - - assert.strictEqual(input_sum, output_sum, "Balance preserved for asset_id = 42"); - console.log(" ✓ Custom token (asset_id = 42) transfer valid"); + it("rejects public asset_id ≠ input note asset_id (Constraint 8)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + assetId: 0n, + }); + input.asset_id = "1"; // public claims 1, notes have 0 + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should validate asset consistency (all inputs/outputs must match)", () => { - console.log("\n === Testing Asset Consistency ==="); - - const alice_pk = 0x1234n; - - // Valid: all notes use asset_id = 1 - const asset1 = 1n; - const input1_asset1 = poseidon.F.toString(poseidon([100n, asset1, alice_pk, 0x1n])); - const input2_asset1 = poseidon.F.toString(poseidon([50n, asset1, alice_pk, 0x2n])); - const output1_asset1 = poseidon.F.toString(poseidon([80n, asset1, alice_pk, 0x3n])); - const output2_asset1 = poseidon.F.toString(poseidon([70n, asset1, alice_pk, 0x4n])); - - console.log(" ✓ All notes with asset_id = 1: VALID"); - - // Invalid scenario: mixing asset_id = 1 and asset_id = 2 - // This should be rejected by the circuit (would fail constraint check) - const asset2 = 2n; - const input1_asset2 = poseidon.F.toString( - poseidon([100n, asset2, alice_pk, 0x1n]) // Different asset! - ); - - // Verify commitments are different when asset_id changes - assert( - input1_asset1 !== input1_asset2, - "Different asset_ids produce different commitments" - ); - - console.log(" ✓ Mixing assets would be rejected by circuit"); - console.log( - " ✓ Circuit enforces: input[0].asset === input[1].asset === output[0].asset === output[1].asset" - ); + it("rejects mixed-asset inputs: input_asset_ids[1] ≠ input_asset_ids[0] (Constraint 7)", async function () { + if (!circuit) return this.skip(); + // Build a valid 2-note input where note1 has a different asset_id (1 vs 0) + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + assetId: 0n, + }); + // Tamper note1's private asset_id — circuit enforces input_asset_ids[0] === input_asset_ids[1] + input.input_asset_ids[1] = "1"; + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should support high asset_id values (up to 2^32-1)", () => { - const max_asset_id = 4294967295n; // 2^32 - 1 - const owner_pk = 0x999999n; - const value = 12345n; - - const commitment: string = poseidon.F.toString( - poseidon([value, max_asset_id, owner_pk, 0xffffn]) - ); - - assert(commitment !== "0", "Should support max asset_id"); - console.log(" ✓ Max asset_id (2^32-1) supported"); + it("rejects mixed-asset output: output_asset_ids[0] ≠ input_asset_ids[0] (Constraint 7)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + assetId: 0n, + }); + // Tamper one output note's private asset_id + input.output_asset_ids[0] = "1"; + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should produce different commitments for different asset_ids", () => { - const value = 100n; - const owner_pk = 0x1234n; - const blinding = 0x5678n; - - const commit_native: string = poseidon.F.toString( - poseidon([value, 0n, owner_pk, blinding]) - ); - const commit_usdt: string = poseidon.F.toString( - poseidon([value, 1n, owner_pk, blinding]) - ); - const commit_dai: string = poseidon.F.toString( - poseidon([value, 2n, owner_pk, blinding]) - ); - - // All should be different - assert(commit_native !== commit_usdt, "Native vs USDT different"); - assert(commit_native !== commit_dai, "Native vs DAI different"); - assert(commit_usdt !== commit_dai, "USDT vs DAI different"); - - console.log(" ✓ Different assets produce different commitments"); - console.log(" Native (0):", commit_native.slice(0, 16) + "..."); - console.log(" USDT (1): ", commit_usdt.slice(0, 16) + "..."); - console.log(" DAI (2): ", commit_dai.slice(0, 16) + "..."); + it("different asset_ids produce different commitments", () => { + const base = (id: bigint) => computeCommitment(100n, id, alice.Ax, 0x5678n); + expect(base(0n)).not.to.equal(base(1n)); + expect(base(1n)).not.to.equal(base(2n)); }); + }); - it("should simulate multi-asset private payments", () => { - console.log("\n === Multi-Asset Payment Scenarios ==="); - - const alice_pk = 0xaaaaaan; - const bob_pk = 0xbbbbbbn; - const charlie_pk = 0xccccccn; - - // Scenario 1: USDT payment - console.log("\n Scenario 1: Alice sends 100 USDT to Bob"); - const usdt_id = 1n; - const usdt_in = 150n; - const usdt_to_bob = 100n; - const usdt_change = 50n; - - assert.strictEqual(usdt_in, usdt_to_bob + usdt_change); - console.log(" ✓ 150 USDT → 100 (Bob) + 50 (change)"); - - // Scenario 2: DAI payment - console.log("\n Scenario 2: Bob sends 200 DAI to Charlie"); - const dai_id = 2n; - const dai_in1 = 120n; - const dai_in2 = 80n; - const dai_to_charlie = 200n; + // ── 8. Distinct nullifiers (Constraint 9) ─────────────────────────────── + + describe("Distinct nullifiers (Constraint 9)", () => { + it("accepts two different notes (nullifiers always distinct)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - assert.strictEqual(dai_in1 + dai_in2, dai_to_charlie); - console.log(" ✓ 120 + 80 DAI → 200 (Charlie)"); + it("rejects duplicate nullifiers (same note spent twice in one tx)", async function () { + if (!circuit) return this.skip(); + // Both input notes are the same: 500 + 500 = 999 + 0 + 1, conservation holds. + // Without this constraint the circuit accepts and gives 2× value from one note. + const input = buildInput({ + value0: 500n, + value1: 500n, + outValue0: 999n, + outValue1: 0n, + fee: 1n, + }); + input.nullifiers[1] = input.nullifiers[0]; // same nullifier = same note + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); + }); - // Scenario 3: Native token payment - console.log("\n Scenario 3: Charlie sends 500 ORB to Alice"); - const native_id = 0n; - const native_in = 1000n; - const native_to_alice = 500n; - const native_change = 500n; + // ── 9. u128 range check (Constraint 6 & 6b) ───────────────────────────── + + describe("u128 range check (Constraint 6 & 6b)", () => { + it("accepts 1000 ORB input notes", async function () { + if (!circuit) return this.skip(); + const ORB = 10n ** 18n; + const fee = 1_000_000_000_000_000n; + const halfIn = 500n * ORB; + const input = buildInput({ + value0: halfIn, + value1: halfIn, + outValue0: 2n * halfIn - fee, + outValue1: 0n, + fee, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - assert.strictEqual(native_in, native_to_alice + native_change); - console.log(" ✓ 1000 ORB → 500 (Alice) + 500 (change)"); + it("accepts max u128 fee with matching inputs", async function () { + if (!circuit) return this.skip(); + const MAX_FEE = 2n ** 128n - 1n; + const input = buildInput({ + value0: MAX_FEE, + value1: 0n, + outValue0: 0n, + outValue1: 0n, + fee: MAX_FEE, + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - console.log("\n ✓ Multi-asset private payments enabled!"); + it("rejects fee = 2^128 even when input/output values are valid u128", async function () { + if (!circuit) return this.skip(); + const HALF = 2n ** 127n; + const input = buildInput({ + value0: HALF, + value1: HALF, + outValue0: 0n, + outValue1: 0n, + fee: 0n, + }); + input.fee = (2n ** 128n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); }); diff --git a/test/unshield.test.ts b/test/unshield.test.ts index f50fbdc..e05968e 100644 --- a/test/unshield.test.ts +++ b/test/unshield.test.ts @@ -1,426 +1,390 @@ +import path from "path"; +import fs from "fs"; +import { expect } from "chai"; +import { wasm as wasm_tester } from "circom_tester"; import { buildPoseidon } from "circomlibjs"; -import assert from "assert"; +import type { WasmTester } from "circom_tester"; -describe("Unshield Circuit Logic", function () { - this.timeout(60000); +// ─── Constants ──────────────────────────────────────────────────────────────── +const TREE_DEPTH = 20; + +describe("Unshield Circuit (gasless)", function () { + this.timeout(120_000); + + const circuitPath = path.join(__dirname, "..", "circuits", "unshield.circom"); + const outputDir = path.join(__dirname, "..", "build"); + const precompiledWasm = path.join(outputDir, "unshield_js", "unshield.wasm"); + + let circuit: WasmTester; let poseidon: any; let F: any; - before(async () => { - poseidon = await buildPoseidon(); - F = poseidon.F; - }); + // ── Helpers ──────────────────────────────────────────────────────────────── - // Helper: compute note commitment function computeCommitment( value: bigint, assetId: bigint, owner: bigint, blinding: bigint - ): string { - return F.toString(poseidon([value, assetId, owner, blinding])); + ): bigint { + return F.toObject(poseidon([value, assetId, owner, blinding])); } - // Helper: compute nullifier - function computeNullifier(commitment: string, spendingKey: bigint): string { - return F.toString(poseidon([BigInt(commitment), spendingKey])); + function computeNullifier(commitment: bigint, spendingKey: bigint): bigint { + return F.toObject(poseidon([commitment, spendingKey])); } - // Helper: compute merkle root for simple 2-level tree - function computeMerkleRoot(leaves: string[], depth: number = 2): string { - if (leaves.length === 0) return "0"; - - let level: bigint[] = leaves.map((l) => BigInt(l)); - - while (level.length > 1) { - const newLevel: any[] = []; - for (let i = 0; i < level.length; i += 2) { - const left = level[i]; - const right = level[i + 1] || 0n; - newLevel.push(poseidon([left, right])); + /** Sparse Merkle proof builder. Only materialises the O(N·depth) non-zero nodes, + * keeping runtime proportional to the number of leaves, not 2^depth. */ + function buildMerkleProof( + leaves: bigint[], + leafIndex: number + ): { root: bigint; pathElements: bigint[]; pathIndices: number[] } { + const pathElements: bigint[] = []; + const pathIndices: number[] = []; + let level = new Map(); + for (let i = 0; i < leaves.length; i++) level.set(i, leaves[i]); + for (let d = 0; d < TREE_DEPTH; d++) { + const nodeIdx = leafIndex >> d; + const isRight = nodeIdx % 2 === 1; + pathIndices.push(isRight ? 1 : 0); + pathElements.push(level.get(isRight ? nodeIdx - 1 : nodeIdx + 1) ?? 0n); + const nextLevel = new Map(); + for (const [pos] of level) { + const parentPos = pos >> 1; + if (nextLevel.has(parentPos)) continue; + const l = level.get(parentPos * 2) ?? 0n; + const r = level.get(parentPos * 2 + 1) ?? 0n; + nextLevel.set(parentPos, F.toObject(poseidon([l, r]))); } - level = newLevel; + level = nextLevel; } - - return F.toString(level[0]); + return { root: level.get(0) ?? 0n, pathElements, pathIndices }; } - describe("Note Commitment for Unshield", () => { - it("should compute commitment correctly", () => { - const value = 1000n; - const assetId = 0n; - const owner = 0x1234567890abcdef1234567890abcdef12345678n; - const blinding = 0xfedcba0987654321fedcba0987654321fedcba09n; - - const commitment = computeCommitment(value, assetId, owner, blinding); - - console.log(" Commitment:", commitment); - assert(commitment !== "0", "Commitment should not be zero"); - }); - - it("should be deterministic", () => { - const value = 1000n; - const assetId = 0n; - const owner = 0x1234n; - const blinding = 0x5678n; + /** Build a minimal valid circuit input. */ + function buildInput(opts: { + noteValue: bigint; + amount: bigint; + fee: bigint; + assetId?: bigint; + owner?: bigint; + blinding?: bigint; + spendingKey?: bigint; + recipient?: bigint; + leafIndex?: number; + }) { + const assetId = opts.assetId ?? 0n; + const owner = opts.owner ?? 0x1234567890abcdefn; + const blinding = opts.blinding ?? 0xfedcba0987654321n; + const spendingKey = opts.spendingKey ?? 0xdeadbeefcafebaben; + const recipient = opts.recipient ?? 0xaabbccddee112233n; + const leafIndex = opts.leafIndex ?? 0; + + const commitment = computeCommitment(opts.noteValue, assetId, owner, blinding); + const nullifier = computeNullifier(commitment, spendingKey); + // Place the commitment at leafIndex in the tree; fill preceding positions with zero + const leavesArr = new Array(leafIndex + 1).fill(0n); + leavesArr[leafIndex] = commitment; + const { root, pathElements, pathIndices } = buildMerkleProof(leavesArr, leafIndex); + + return { + merkle_root: root.toString(), + nullifier: nullifier.toString(), + amount: opts.amount.toString(), + recipient: recipient.toString(), + asset_id: assetId.toString(), + fee: opts.fee.toString(), + note_value: opts.noteValue.toString(), + note_asset_id: assetId.toString(), + note_owner: owner.toString(), + note_blinding: blinding.toString(), + spending_key: spendingKey.toString(), + path_elements: pathElements.map((e) => e.toString()), + path_indices: pathIndices, + }; + } - const c1 = computeCommitment(value, assetId, owner, blinding); - const c2 = computeCommitment(value, assetId, owner, blinding); + // ── Setup ────────────────────────────────────────────────────────────────── - assert.strictEqual(c1, c2, "Same inputs should produce same commitment"); - }); + before(async function () { + poseidon = await buildPoseidon(); + F = poseidon.F; + if (!fs.existsSync(precompiledWasm)) { + console.log( + " ⚠ Pre-compiled wasm not found. Run 'pnpm build-all' to enable circuit tests." + ); + return; + } + circuit = await wasm_tester(circuitPath, { output: outputDir, recompile: false }); }); - describe("Nullifier for Unshield", () => { - it("should compute nullifier correctly", () => { - const commitment = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); - const spendingKey = 0xdeadbeefcafebaben; + // ── 1. Commitment arithmetic (no wasm needed) ───────────────────────────── - const nullifier = computeNullifier(commitment, spendingKey); - - console.log(" Nullifier:", nullifier); - assert(nullifier !== "0", "Nullifier should not be zero"); + describe("Commitment arithmetic", () => { + it("is deterministic", () => { + const c1 = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); + const c2 = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); + expect(c1).to.equal(c2); }); - it("should be different for different spending keys", () => { - const commitment = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); - - const n1 = computeNullifier(commitment, 0xdeadbeefn); - const n2 = computeNullifier(commitment, 0xcafebaben); - - assert(n1 !== n2, "Different spending keys should produce different nullifiers"); + it("changes with each field", () => { + const base = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); + expect(computeCommitment(2000n, 0n, 0x1234n, 0x5678n)).to.not.equal(base); + expect(computeCommitment(1000n, 1n, 0x1234n, 0x5678n)).to.not.equal(base); + expect(computeCommitment(1000n, 0n, 0x9999n, 0x5678n)).to.not.equal(base); + expect(computeCommitment(1000n, 0n, 0x1234n, 0x9999n)).to.not.equal(base); }); - it("should be different for different commitments", () => { - const spendingKey = 0xdeadbeefn; - - const c1 = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); - const c2 = computeCommitment(2000n, 0n, 0x1234n, 0x5678n); - - const n1 = computeNullifier(c1, spendingKey); - const n2 = computeNullifier(c2, spendingKey); + it("nullifiers differ per (commitment, spendingKey)", () => { + const c = computeCommitment(1000n, 0n, 0x1234n, 0x5678n); + expect(computeNullifier(c, 0xdeadn)).to.not.equal(computeNullifier(c, 0xbeefn)); + }); - assert(n1 !== n2, "Different commitments should produce different nullifiers"); + it("supports max u128 value", () => { + const MAX = 2n ** 128n - 1n; + const c = computeCommitment(MAX, 0n, 0x1234n, 0x5678n); + expect(c).to.not.equal(0n); }); }); - describe("Amount Verification", () => { - it("should match amount to note value", () => { - const noteValue = 1000n; - const withdrawAmount = 1000n; + // ── 2. Gasless fee constraint: note_value === amount + fee ───────────────── - assert.strictEqual(noteValue, withdrawAmount, "Amount should equal note value"); + describe("Gasless fee constraint (Constraint 1)", () => { + it("note_value = amount + fee (fee = 0)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 1000n, fee: 0n }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should reject mismatched amount", () => { - const noteValue: bigint = 1000n; - const withdrawAmount: bigint = 1500n; // Trying to withdraw more - - assert(noteValue !== withdrawAmount, "Should detect amount mismatch"); + it("note_value = amount + fee (fee > 0)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 101n, amount: 100n, fee: 1n }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should support u128 maximum value (2^128 - 1)", () => { - console.log("\n === Testing u128 Range (Num2Bits(128)) ==="); - - // Maximum u128: 340282366920938463463374607431768211455 - const maxU128 = 2n ** 128n - 1n; - const noteValue = maxU128; - const assetId = 0n; - const owner = 0x1234n; - const blinding = 0x5678n; - - const commitment = computeCommitment(noteValue, assetId, owner, blinding); - console.log(" Max u128 value:", maxU128.toString()); - console.log(" Commitment created:", commitment.slice(0, 20) + "..."); - - assert(commitment !== "0", "Should handle max u128 value"); - console.log(" ✓ u128 maximum value supported"); + it("note_value = amount + fee (realistic: 0.001 ORB fee)", async function () { + if (!circuit) return this.skip(); + const FEE = 1_000_000_000_000_000n; + const NOTE = 10_000_000_000_000_000_000n; // 10 ORB + const input = buildInput({ noteValue: NOTE, amount: NOTE - FEE, fee: FEE }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should support large ORB amounts (>18.4 ORB)", () => { - console.log("\n === Testing Large Amounts (>u64 limit) ==="); - - // Previous u64 limit: 2^64 - 1 = 18446744073709551615 wei ≈ 18.4 ORB - const u64_max = 2n ** 64n - 1n; - - // Test with 1000 ORB = 10^21 wei (exceeds old u64 limit) - const largeAmount = 1000n * 10n ** 18n; // 1000 ORB - const assetId = 0n; - const owner = 0xabcdn; - const blinding = 0xef01n; - - console.log(" u64 max (old limit):", u64_max.toString()); - console.log(" Testing with:", largeAmount.toString(), "wei (1000 ORB)"); - console.log(" Exceeds u64 by:", (largeAmount - u64_max).toString(), "wei"); + it("fee = entire note value (amount = 0, edge case)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 500n, amount: 0n, fee: 500n }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - const commitment = computeCommitment(largeAmount, assetId, owner, blinding); - assert(commitment !== "0", "Should handle amounts >18.4 ORB"); + it("rejects: old behaviour amount = note_value when fee > 0", async function () { + if (!circuit) return this.skip(); + // Pre-gasless: amount == note_value. Now: amount + fee == note_value. + // If fee=1 and amount=note_value, amount+fee overflows constraint. + const input = buildInput({ noteValue: 1000n, amount: 1000n, fee: 1n }); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); - console.log(" ✓ 1000 ORB (10^21 wei) supported with u128"); - console.log(" ✓ Old u64 limit (18.4 ORB) no longer applies"); + it("rejects: amount + fee > note_value", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 100n, amount: 90n, fee: 20n }); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); - it("should support realistic large amounts", () => { - // Test various large amounts that would fail with u64 - const amounts = [ - { value: 100n * 10n ** 18n, label: "100 ORB" }, - { value: 10000n * 10n ** 18n, label: "10,000 ORB" }, - { value: 1000000n * 10n ** 18n, label: "1 Million ORB" }, - ]; - - console.log("\n Testing realistic large amounts:"); - amounts.forEach(({ value, label }) => { - const commitment = computeCommitment(value, 0n, 0x1234n, 0x5678n); - assert(commitment !== "0", `Should support ${label}`); - console.log(` ✓ ${label}: ${value.toString()} wei`); - }); + it("rejects: amount > note_value (overspend)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 100n, amount: 200n, fee: 0n }); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Merkle Membership Proof", () => { - it("should verify commitment exists in tree", () => { - // Create a note - const noteValue = 1000n; - const assetId = 0n; - const owner = 0x1234n; - const blinding = 0x5678n; - const commitment = computeCommitment(noteValue, assetId, owner, blinding); - - // Create simple tree with this commitment - const otherCommitment = computeCommitment(2000n, 0n, 0xabcdn, 0xef01n); - - // Build merkle root - const root = computeMerkleRoot([commitment, otherCommitment]); + // ── 3. Merkle membership (Constraint 4) ─────────────────────────────────── - console.log(" Tree root:", root); - - // Verify path: commitment is at index 0 (left) - // Sibling at level 0: otherCommitment - const computedRoot = F.toString( - poseidon([BigInt(commitment), BigInt(otherCommitment)]) - ); - - assert.strictEqual(computedRoot, root, "Should verify merkle path"); + describe("Merkle membership (Constraint 4)", () => { + it("accepts valid proof at index 0", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 999n, fee: 1n }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should build tree with multiple commitments", () => { - const commitments: string[] = [ - computeCommitment(1000n, 0n, 0x1111n, 0xaan), - computeCommitment(2000n, 0n, 0x2222n, 0xbbn), - computeCommitment(3000n, 0n, 0x3333n, 0xccn), - computeCommitment(4000n, 0n, 0x4444n, 0xddn), - ]; + it("accepts valid proof at index 5", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 999n, fee: 1n, leafIndex: 5 }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); - const root = computeMerkleRoot(commitments); + it("rejects wrong Merkle root", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 999n, fee: 1n }); + input.merkle_root = (BigInt(input.merkle_root) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); - console.log(" Multi-leaf tree root:", root); - assert(root !== "0", "Root should not be zero"); + it("rejects wrong commitment (tampered owner)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 999n, fee: 1n }); + // Change owner in the witness private inputs (commitment was built with original owner) + input.note_owner = "99999999999999999"; + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Complete Unshield Example", () => { - it("should simulate valid unshield", () => { - console.log("\n === Simulating Unshield Transaction ==="); - - // Alice has a private note worth 1000 tokens - const noteValue = 1000n; - const assetId = 0n; - const alice_pk = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; - const spending_key = 0xdeadbeefcafebaben; - - // Compute commitment - const commitment = computeCommitment(noteValue, assetId, alice_pk, blinding); - console.log(" Note commitment:", commitment.slice(0, 20) + "..."); - - // Compute nullifier (to prevent double-spending) - const nullifier = computeNullifier(commitment, spending_key); - console.log(" Nullifier:", nullifier.slice(0, 20) + "..."); - - // Add to merkle tree - const otherCommitments = [ - computeCommitment(2000n, 0n, 0x5555n, 0x1111n), - computeCommitment(3000n, 0n, 0x6666n, 0x2222n), - ]; - - const root = computeMerkleRoot([commitment, ...otherCommitments]); - console.log(" Merkle root:", root.slice(0, 20) + "..."); - - // Alice wants to unshield (withdraw) to public balance - const withdrawAmount = 1000n; - const recipientAddress = "0xAlicePublicAddress"; - - assert.strictEqual(noteValue, withdrawAmount, "Withdraw amount must match note value"); + // ── 4. Nullifier integrity (Constraint 5) ───────────────────────────────── + + describe("Nullifier integrity (Constraint 5)", () => { + it("rejects tampered public nullifier", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 1000n, amount: 999n, fee: 1n }); + input.nullifier = (BigInt(input.nullifier) + 1n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } + }); - console.log(" ✓ Unshield valid: 1000 tokens"); - console.log(" ✓ Recipient:", recipientAddress); - console.log(" ✓ Nullifier prevents double-spend"); + it("rejects wrong spending_key", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + noteValue: 500n, + amount: 499n, + fee: 1n, + spendingKey: 0xdeadn, + }); + input.spending_key = "999999999"; // different key → different nullifier + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); }); - describe("Multi-Asset Unshield Support", () => { - it("should unshield USDT (asset_id = 1)", () => { - console.log("\n === Testing USDT Unshield (asset_id = 1) ==="); - - // Alice has a private USDT note - const noteValue = 500n; - const assetId = 1n; // USDT - const alice_pk = 0x1234567890abcdefn; - const blinding = 0xfedcba0987654321n; - const spending_key = 0xdeadbeefcafebaben; - - // Compute commitment for USDT note - const commitment = computeCommitment(noteValue, assetId, alice_pk, blinding); - console.log(" USDT Note commitment:", commitment.slice(0, 20) + "..."); - - // Compute nullifier - const nullifier = computeNullifier(commitment, spending_key); - console.log(" Nullifier:", nullifier.slice(0, 20) + "..."); + // ── 5. Asset ID enforcement (Constraint 6) ──────────────────────────────── - // Add to merkle tree - const otherCommitments = [ - computeCommitment(1000n, 1n, 0x5555n, 0x1111n), - computeCommitment(2000n, 1n, 0x6666n, 0x2222n), - ]; - - const root = computeMerkleRoot([commitment, ...otherCommitments]); - console.log(" Merkle root:", root.slice(0, 20) + "..."); - - // Unshield to public balance - const withdrawAmount = 500n; - const recipientAddress = "0xAlicePublicAddress"; - - assert.strictEqual(noteValue, withdrawAmount, "Withdraw amount must match note value"); - assert.strictEqual(assetId, 1n, "Should be USDT"); - - console.log(" ✓ Unshield valid: 500 USDT"); - console.log(" ✓ Asset ID: 1 (USDT)"); - console.log(" ✓ Recipient:", recipientAddress); + describe("Asset ID enforcement (Constraint 6)", () => { + it("accepts matching public asset_id and note asset_id", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 500n, amount: 499n, fee: 1n, assetId: 1n }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should unshield DAI (asset_id = 2)", () => { - console.log("\n === Testing DAI Unshield (asset_id = 2) ==="); - - const noteValue = 1000n; - const assetId = 2n; // DAI - const owner_pk = 0xabcdefn; - const blinding = 0x123456n; - - const commitment = computeCommitment(noteValue, assetId, owner_pk, blinding); - console.log(" DAI Note commitment:", commitment.slice(0, 20) + "..."); - - assert(commitment !== "0", "Commitment should be valid"); - console.log(" ✓ DAI note created with asset_id = 2"); + it("rejects public asset_id ≠ note asset_id", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 500n, amount: 499n, fee: 1n, assetId: 1n }); + input.asset_id = "2"; // public says 2, note has 1 + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); + }); - it("should unshield custom token (asset_id = 42)", () => { - const noteValue = 12345n; - const assetId = 42n; // Custom token - const owner_pk = 0x9999n; - const blinding = 0xaaaan; - - const commitment = computeCommitment(noteValue, assetId, owner_pk, blinding); + // ── 6. u128 range check (Constraints 2 & 3) ───────────────────────────────── - assert(commitment !== "0", "Custom token commitment valid"); - console.log(" ✓ Custom token (asset_id = 42) unshield supported"); + describe("u128 range check (Constraints 2 & 3)", () => { + it("accepts max u128 note value", async function () { + if (!circuit) return this.skip(); + const MAX = 2n ** 128n - 1n; + const FEE = 1n; + const input = buildInput({ noteValue: MAX, amount: MAX - FEE, fee: FEE }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should support high asset_id values in unshield", () => { - const max_asset_id = 4294967295n; // 2^32 - 1 - const noteValue = 100n; - const owner_pk = 0xffffn; - const blinding = 0xeeeeen; - - const commitment = computeCommitment(noteValue, max_asset_id, owner_pk, blinding); - - assert(commitment !== "0", "Max asset_id should work"); - console.log(" ✓ Max asset_id (2^32-1) supported in unshield"); + it("accepts 1000 ORB (exceeds old u64 limit)", async function () { + if (!circuit) return this.skip(); + const NOTE = 1000n * 10n ** 18n; + const FEE = 1_000_000_000_000_000n; + const input = buildInput({ noteValue: NOTE, amount: NOTE - FEE, fee: FEE }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should produce different nullifiers for different asset notes", () => { - const owner_pk = 0x1234n; - const blinding = 0x5678n; - const spending_key = 0xdeadbeefn; - const value = 100n; - - // Create notes with different asset_ids - const commit_native = computeCommitment(value, 0n, owner_pk, blinding); - const commit_usdt = computeCommitment(value, 1n, owner_pk, blinding); - const commit_dai = computeCommitment(value, 2n, owner_pk, blinding); - - // Compute nullifiers - const null_native = computeNullifier(commit_native, spending_key); - const null_usdt = computeNullifier(commit_usdt, spending_key); - const null_dai = computeNullifier(commit_dai, spending_key); - - // All should be different (different commitments → different nullifiers) - assert(null_native !== null_usdt, "Native vs USDT nullifier different"); - assert(null_native !== null_dai, "Native vs DAI nullifier different"); - assert(null_usdt !== null_dai, "USDT vs DAI nullifier different"); - - console.log(" ✓ Different asset notes produce unique nullifiers"); - console.log(" Native nullifier:", null_native.slice(0, 16) + "..."); - console.log(" USDT nullifier: ", null_usdt.slice(0, 16) + "..."); - console.log(" DAI nullifier: ", null_dai.slice(0, 16) + "..."); + it("accepts max u128 fee (fee = 2^128 - 1)", async function () { + if (!circuit) return this.skip(); + const MAX_FEE = 2n ** 128n - 1n; + const input = buildInput({ noteValue: MAX_FEE, amount: 0n, fee: MAX_FEE }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); }); - it("should simulate multi-asset unshield scenarios", () => { - console.log("\n === Multi-Asset Unshield Scenarios ==="); - - const alice_pk = 0x111111n; - const bob_pk = 0x222222n; - const spending_key = 0xdeadbeefn; - - // Scenario 1: Unshield 100 USDT - console.log("\n Scenario 1: Alice unshields 100 USDT"); - const usdt_note = computeCommitment(100n, 1n, alice_pk, 0xaa11n); - const usdt_nullifier = computeNullifier(usdt_note, spending_key); - console.log(" ✓ USDT note commitment:", usdt_note.slice(0, 20) + "..."); - console.log(" ✓ Nullifier:", usdt_nullifier.slice(0, 20) + "..."); - - // Scenario 2: Unshield 500 DAI - console.log("\n Scenario 2: Bob unshields 500 DAI"); - const dai_note = computeCommitment(500n, 2n, bob_pk, 0xbb22n); - const dai_nullifier = computeNullifier(dai_note, spending_key); - console.log(" ✓ DAI note commitment:", dai_note.slice(0, 20) + "..."); - console.log(" ✓ Nullifier:", dai_nullifier.slice(0, 20) + "..."); - - // Scenario 3: Unshield 1000 Native tokens - console.log("\n Scenario 3: Alice unshields 1000 ORB (native)"); - const native_note = computeCommitment(1000n, 0n, alice_pk, 0xcc33n); - const native_nullifier = computeNullifier(native_note, spending_key); - console.log(" ✓ Native note commitment:", native_note.slice(0, 20) + "..."); - console.log(" ✓ Nullifier:", native_nullifier.slice(0, 20) + "..."); - - // Verify all commitments are unique - assert(usdt_note !== dai_note, "USDT vs DAI commitments different"); - assert(usdt_note !== native_note, "USDT vs Native commitments different"); - assert(dai_note !== native_note, "DAI vs Native commitments different"); - - console.log("\n ✓ Multi-asset unshield fully supported!"); - console.log(" ✓ Each asset type produces unique commitments"); - console.log(" ✓ Nullifiers prevent double-spending across all assets"); + it("rejects fee = 2^128 (exceeds u128)", async function () { + if (!circuit) return this.skip(); + const input = buildInput({ noteValue: 0n, amount: 0n, fee: 0n }); + input.fee = (2n ** 128n).toString(); + input.note_value = (2n ** 128n).toString(); + try { + await circuit.calculateWitness(input); + expect.fail("Should have thrown"); + } catch (err: any) { + expect(err.message).to.include("Assert Failed"); + } }); + }); - it("should validate amount consistency across different assets", () => { - const owner_pk = 0xababn; - const blinding = 0xcdcdn; - - // Same value, different assets should produce different commitments - const value = 1000n; - - const c1 = computeCommitment(value, 0n, owner_pk, blinding); - const c2 = computeCommitment(value, 1n, owner_pk, blinding); - const c3 = computeCommitment(value, 2n, owner_pk, blinding); - - assert( - c1 !== c2 && c2 !== c3 && c1 !== c3, - "Same value with different assets must produce different commitments" - ); + // ── 7. Multi-asset support ───────────────────────────────────────────────── + + describe("Multi-asset support", () => { + for (const [label, assetId] of [ + ["native (0)", 0n], + ["USDT (1)", 1n], + ["max u32 (4294967295)", 4294967295n], + ] as const) { + it(`accepts asset_id ${label}`, async function () { + if (!circuit) return this.skip(); + const input = buildInput({ + noteValue: 1000n, + amount: 999n, + fee: 1n, + assetId: BigInt(assetId), + }); + const w = await circuit.calculateWitness(input); + await circuit.checkConstraints(w); + }); + } - console.log(" ✓ Amount consistency validated across assets"); - console.log(" ✓ Asset ID is part of commitment calculation"); + it("different asset_ids produce different commitments", () => { + const base = (id: bigint) => computeCommitment(100n, id, 0x1234n, 0x5678n); + expect(base(0n)).to.not.equal(base(1n)); + expect(base(1n)).to.not.equal(base(2n)); }); }); }); From c527c924fdee02cbdb2c08f4a86e1608a6d48055 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Sun, 12 Apr 2026 19:40:48 -0400 Subject: [PATCH 2/2] fix(tsconfig): ensure deprecation warnings are ignored --- tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/tsconfig.json b/tsconfig.json index 6aa350b..d554410 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "moduleResolution": "node", + "ignoreDeprecations": "6.0", "types": ["node", "mocha"], "typeRoots": ["./node_modules/@types", "./types"], "allowJs": true,