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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 21 additions & 3 deletions circuits/transfer.circom
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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];
Expand All @@ -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);
21 changes: 13 additions & 8 deletions circuits/unshield.circom
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -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);
86 changes: 49 additions & 37 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -12,7 +12,7 @@
"versions": {
"1": {
"version": 1,
"vk_hash": "0x9f597dab944eacdcaaea2ddf11f1885786a2efe53ab856a19b29c978834c060a",
"vk_hash": "0x4c46d88196fd2445e4e708f63ea54afdaa67d47fde0d5a308b628db8c5c28472",
"artifacts": {
"wasm": {
"file": "disclosure.wasm",
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -126,7 +138,7 @@
"versions": {
"1": {
"version": 1,
"vk_hash": "0xad452933eb017073d8d9106c7dfaf98ad1cb5f2f026b261c5b4345e21b491f1f",
"vk_hash": "0xa2477ab9d7a3a50a66d368d96008d3a8a5902c05e926e712f91c9dea0c5d1612",
"artifacts": {
"wasm": {
"file": "private_link.wasm",
Expand All @@ -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"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "orbinum-circuits",
"version": "0.4.4",
"version": "0.5.0",
"description": "Zero-Knowledge circuits for Orbinum privacy blockchain",
"scripts": {
"compile": "npm run compile:transfer",
Expand Down
Loading
Loading