diff --git a/Cargo.lock b/Cargo.lock index f9fca373..35408c87 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8107,7 +8107,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.5.2" +version = "0.6.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", @@ -8314,7 +8314,7 @@ dependencies = [ [[package]] name = "pallet-zk-verifier" -version = "0.5.0" +version = "0.5.1" dependencies = [ "frame-benchmarking", "frame-support", diff --git a/deny.toml b/deny.toml index b02ce687..80557701 100644 --- a/deny.toml +++ b/deny.toml @@ -35,6 +35,8 @@ ignore = [ # Unmaintained (transitive) "RUSTSEC-2025-0161", # libsecp256k1 0.7.2 unmaintained (from fc-rpc, fp-account/pallet-evm) "RUSTSEC-2026-0105", # core2 0.4.0 unmaintained, all versions yanked (from litep2p → cid, no upgrade path) + "RUSTSEC-2026-0119", # hickory-proto 0.24.4 - O(n²) CPU exhaustion in DNS name compression (from libp2p-dns → sc-network) + "RUSTSEC-2026-0118", # hickory-proto 0.25.2 - unbounded loop in NSEC3 validation (from libp2p-dns → sc-network) # Allowed warnings (8 total) "RUSTSEC-2024-0388", # derivative 2.2.0 unmaintained (from ark-r1cs-std) diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 0ce86ac7..69ec815d 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.5.2" +version = "0.6.0" description = "Shielded pool pallet for private transactions using ZK proofs" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/shielded-pool/README.md b/frame/shielded-pool/README.md index 34cba6a2..e6084e58 100644 --- a/frame/shielded-pool/README.md +++ b/frame/shielded-pool/README.md @@ -4,7 +4,7 @@ FRAME pallet for privacy-preserving transactions in Orbinum using ZK-SNARKs. ## Status -MVP in active development. Core shield / transfer / unshield flows are functional. Audit and disclosure features are present but under active review. +MVP in active development. Core shield / transfer / unshield flows are functional, including partial unshield with automatic change note handling. Audit and disclosure features are present but under active review. ## What this pallet does @@ -12,7 +12,7 @@ Implements a UTXO-style shielded pool where: - Public tokens enter via `shield` — converted to on-chain commitments. - Value moves privately via `private_transfer` — only nullifiers and new commitments appear on-chain. -- Tokens exit via `unshield` — revealed when the user chooses. +- Tokens exit via `unshield` — revealed when the user chooses. Supports partial withdrawal: if `change_commitment != [0u8;32]`, the remaining value is wrapped into a new note re-inserted into the Merkle tree. A Poseidon Merkle tree tracks all commitments. A nullifier set prevents double-spending. All state transitions require a valid Groth16 proof verified by `pallet-zk-verifier`. @@ -22,8 +22,8 @@ A Poseidon Merkle tree tracks all commitments. A nullifier set prevents double-s |-----------|--------|-------------| | `shield` | Signed | Deposit tokens; insert one commitment into the Merkle tree | | `shield_batch` | Signed | Deposit and insert multiple commitments in one call | -| `private_transfer` | Signed | ZK-proven private transfer between notes | -| `unshield` | Signed | ZK-proven withdrawal to a public account | +| `private_transfer` | Unsigned | ZK-proven private transfer between notes | +| `unshield` | Unsigned | ZK-proven withdrawal to a public account. Accepts a `change_commitment` for partial unshield | | `disclose` | Signed | Selective disclosure of a note to an auditor | | `register_asset` | Signed | Register a new asset for multi-asset support | @@ -72,6 +72,8 @@ The previous Clean Architecture layers (`domain/`, `application/`, `infrastructu - Double-spend prevention: nullifiers are recorded on first use and rejected thereafter. - Merkle root validation: only the current root and historic roots within `MaxHistoricRoots` are accepted. - ZK proof verification: all state-changing extrinsics require a Groth16 proof validated by `pallet-zk-verifier`. +- Recipient encoding: the `recipient` field is passed as-is (LE field element) to the verifier — consistent with `Bn254Fr::from_le_bytes_mod_order` and the TypeScript SDK convention (`bytesToBigintLE`). +- Change commitment uniqueness: the pallet rejects a `change_commitment` that already exists in the Merkle tree before inserting it. These are design properties of the current MVP. No formal security audit has been performed. diff --git a/frame/shielded-pool/src/benchmarking.rs b/frame/shielded-pool/src/benchmarking.rs index 160968c8..a7616933 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -64,7 +64,7 @@ mod benchmarks { let (caller, asset_id) = setup_benchmark_env::(); let amount: BalanceOf = T::MinShieldAmount::get() * 10u32.into(); let commitment = Commitment([1u8; 32]); - // Memo must be exactly 136 bytes (MAX_ENCRYPTED_MEMO_SIZE): nonce(12) + data(108) + MAC(16) + // Memo must be exactly 168 bytes (MAX_ENCRYPTED_MEMO_SIZE): nonce(12) + data(108) + MAC(16) + ephPk(32) let memo_bytes = vec![0u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; let encrypted_memo = FrameEncryptedMemo(memo_bytes.try_into().unwrap()); @@ -163,7 +163,8 @@ mod benchmarks { amount, recipient, fee, - None, + Hash::default(), // change_commitment: [0u8; 32] for total unshield + None, // relayer ); } diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index aa941aaa..76d12ac4 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -435,6 +435,8 @@ pub mod pallet { amount: BalanceOf, /// Recipient account recipient: T::AccountId, + /// Change note commitment inserted into the Merkle tree (None for total unshield) + change_commitment: Option, }, /// Merkle root was updated @@ -818,6 +820,9 @@ pub mod pallet { amount: BalanceOf, recipient: T::AccountId, fee: BalanceOf, + // Commitment of the change note. Must be [0u8; 32] for total unshield. + // For partial unshield, must equal NoteCommitment(change_value, asset_id, change_owner_pk, change_blinding). + change_commitment: Hash, // EVM address of the relay node that signed the tx (from precompile caller); None for direct Substrate. relayer: Option, ) -> DispatchResult { @@ -832,6 +837,7 @@ pub mod pallet { amount, recipient, fee, + change_commitment, relayer, ) } diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index d69dfb95..794d0c1e 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -79,6 +79,7 @@ impl ZkVerifierPort for MockZkVerifier { _recipient: &[u8; 32], _asset_id: u32, _fee: u128, + _change_commitment: &[u8; 32], _version: Option, ) -> Result { // Validate basic format diff --git a/frame/shielded-pool/src/operations/private_transfer.rs b/frame/shielded-pool/src/operations/private_transfer.rs index ede651f3..924fb013 100644 --- a/frame/shielded-pool/src/operations/private_transfer.rs +++ b/frame/shielded-pool/src/operations/private_transfer.rs @@ -171,7 +171,7 @@ mod tests { } fn short_memo() -> EncryptedMemo { - // 32 bytes — too short (not 104) + // 32 bytes — too short (not 168) EncryptedMemo::new(vec![0x01u8; 32]).unwrap() } diff --git a/frame/shielded-pool/src/operations/unshield.rs b/frame/shielded-pool/src/operations/unshield.rs index d3e12487..c969e702 100644 --- a/frame/shielded-pool/src/operations/unshield.rs +++ b/frame/shielded-pool/src/operations/unshield.rs @@ -1,7 +1,11 @@ use crate::{ + merkle::MerkleTreeService, pallet::{Config, Error, Event, Pallet}, - storage::{AssetRepository, MerkleRepository, NullifierRepository, PoolBalanceRepository}, - types::Nullifier, + storage::{ + AssetRepository, CommitmentRepository, MerkleRepository, NullifierRepository, + PoolBalanceRepository, + }, + types::{Commitment, Nullifier}, }; use frame_support::{ pallet_prelude::*, @@ -26,6 +30,7 @@ impl UnshieldOperation { amount: <::Currency as Currency<::AccountId>>::Balance, recipient: ::AccountId, fee: <::Currency as Currency<::AccountId>>::Balance, + change_commitment: [u8; 32], relayer_evm: Option, ) -> DispatchResult { let asset = AssetRepository::get_asset::(asset_id).ok_or(Error::::InvalidAssetId)?; @@ -44,6 +49,16 @@ impl UnshieldOperation { Error::::NullifierAlreadyUsed ); + // If a change note is present, ensure its commitment is not already in the tree. + let has_change = change_commitment != [0u8; 32]; + if has_change { + let change_comm = Commitment::new(change_commitment); + ensure!( + !CommitmentRepository::exists::(&change_comm), + Error::::CommitmentAlreadyExists + ); + } + let total = amount.checked_add(&fee).ok_or(Error::::InvalidAmount)?; ensure!( PoolBalanceRepository::get_asset_balance::(asset_id) >= total, @@ -67,6 +82,7 @@ impl UnshieldOperation { &recipient_bytes, asset_id, fee_u128, + &change_commitment, None, )?; @@ -97,6 +113,12 @@ impl UnshieldOperation { PoolBalanceRepository::decrease_balance::(asset_id, amount); + // Insert the change note commitment into the Merkle tree (partial unshield). + if has_change { + let change_comm = Commitment::new(change_commitment); + MerkleTreeService::insert_leaf::(change_comm)?; + } + let current_block = frame_system::Pallet::::block_number(); NullifierRepository::mark_as_used::(nullifier, current_block); @@ -104,6 +126,11 @@ impl UnshieldOperation { nullifier, amount, recipient, + change_commitment: if has_change { + Some(change_commitment) + } else { + None + }, }); Ok(()) @@ -135,8 +162,10 @@ mod tests { mock::{Test, new_test_ext}, operations::assets::AssetOperation, pallet::Event as PalletEvent, - storage::{MerkleRepository, NullifierRepository, PoolBalanceRepository}, - types::Nullifier, + storage::{ + CommitmentRepository, MerkleRepository, NullifierRepository, PoolBalanceRepository, + }, + types::{Commitment, Nullifier}, }; use frame_support::{assert_noop, assert_ok, traits::Currency}; @@ -185,7 +214,8 @@ mod tests { asset_id, amount, 2u64, // recipient - fee, + 0u128, + [0u8; 32], None, )); }); @@ -204,6 +234,7 @@ mod tests { 100u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::InvalidAssetId @@ -229,6 +260,7 @@ mod tests { 100u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::AssetNotVerified @@ -255,6 +287,7 @@ mod tests { 0u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::InvalidAmount @@ -279,6 +312,7 @@ mod tests { 100u128, pool, // recipient == pool → rejected 0u128, + [0u8; 32], None, ), crate::pallet::Error::::InvalidRecipient @@ -302,6 +336,7 @@ mod tests { 100u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::UnknownMerkleRoot @@ -328,6 +363,7 @@ mod tests { 500u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::NullifierAlreadyUsed @@ -352,6 +388,7 @@ mod tests { 100u128, 2u64, 0u128, + [0u8; 32], None, ), crate::pallet::Error::::InsufficientPoolBalance @@ -376,6 +413,7 @@ mod tests { 300u128, 2u64, 0u128, + [0u8; 32], None, )); assert!(UnshieldOperation::is_nullifier_used::(&n)); @@ -398,6 +436,7 @@ mod tests { amount, 2u64, 0u128, + [0u8; 32], None, )); @@ -425,6 +464,7 @@ mod tests { amount, recipient, 0u128, + [0u8; 32], None, )); @@ -449,6 +489,7 @@ mod tests { 200u128, 2u64, 0u128, + [0u8; 32], None, )); @@ -460,6 +501,7 @@ mod tests { nullifier: en, amount: 200, recipient: 2, + change_commitment: None, }) if en == n ) }); @@ -484,6 +526,7 @@ mod tests { amount, 2u64, fee, + [0u8; 32], None, )); @@ -493,6 +536,146 @@ mod tests { }); } + // ── partial unshield ───────────────────────────────────────────────────── + + #[test] + fn execute_partial_unshield_creates_change_note() { + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + // Fund pool with the full note value (amount + change_value). + fund_pool(asset_id, 1_000u128); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + + let change_comm_bytes = [0xCCu8; 32]; + let amount = 600u128; + + assert_ok!(UnshieldOperation::execute::( + proof(), + KNOWN_ROOT, + nullifier(0x10), + asset_id, + amount, + 2u64, + 0u128, + change_comm_bytes, + None, + )); + + // The change commitment must now exist as a leaf in the Merkle tree. + assert_eq!( + MerkleRepository::get_tree_size::(), + 1, + "change commitment should have been inserted as a Merkle leaf" + ); + assert!( + MerkleRepository::find_leaf_index::(&Commitment::new(change_comm_bytes)) + .is_some(), + "change commitment not found in Merkle tree leaves" + ); + + // Pool balance must have decreased only by `amount`, not by the full note value. + let pool_bal = PoolBalanceRepository::get_asset_balance::(asset_id); + assert_eq!( + pool_bal, + 1_000u128 - amount, + "pool balance should only decrease by amount" + ); + + // Event must carry the change_commitment. + let events = frame_system::Pallet::::events(); + let found = events.iter().any(|r| { + matches!( + &r.event, + crate::mock::RuntimeEvent::ShieldedPool(PalletEvent::Unshielded { + change_commitment: Some(cc), + .. + }) if cc == &change_comm_bytes + ) + }); + assert!(found, "Unshielded event did not carry change_commitment"); + }); + } + + #[test] + fn execute_total_unshield_with_zero_change_works() { + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + let amount = 800u128; + fund_pool(asset_id, amount); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + + assert_ok!(UnshieldOperation::execute::( + proof(), + KNOWN_ROOT, + nullifier(0x11), + asset_id, + amount, + 2u64, + 0u128, + [0u8; 32], // zero change_commitment = total unshield + None, + )); + + // Pool balance must be zero. + let pool_bal = PoolBalanceRepository::get_asset_balance::(asset_id); + assert_eq!(pool_bal, 0u128, "pool balance should be fully drained"); + + // No change commitment in tree (tree size remains 0 since no insert happened). + assert_eq!( + MerkleRepository::get_tree_size::(), + 0, + "tree should have no leaves for total unshield" + ); + + // Event carries change_commitment: None. + let events = frame_system::Pallet::::events(); + let found = events.iter().any(|r| { + matches!( + r.event, + crate::mock::RuntimeEvent::ShieldedPool(PalletEvent::Unshielded { + change_commitment: None, + .. + }) + ) + }); + assert!( + found, + "Unshielded event should have change_commitment: None" + ); + }); + } + + #[test] + fn execute_change_commitment_duplicate_fails() { + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + fund_pool(asset_id, 2_000u128); + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + + let change_comm_bytes = [0xDDu8; 32]; + let change_comm = Commitment::new(change_comm_bytes); + + // Mark commitment as already existing in the pool. + CommitmentRepository::store_memo::(change_comm, Default::default()); + + // Attempting to reuse the same commitment as a change note must fail. + assert_noop!( + UnshieldOperation::execute::( + proof(), + KNOWN_ROOT, + nullifier(0x12), + asset_id, + 500u128, + 2u64, + 0u128, + change_comm_bytes, + None, + ), + Error::::CommitmentAlreadyExists + ); + }); + } + // ── query helpers ───────────────────────────────────────────────────────── #[test] diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index 3a05491c..01cdf197 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -127,14 +127,23 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ShieldedPool::CommitmentMemos` (r:1 w:0) [partial unshield: duplicate check] + /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::MerkleFrontier` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::CommitmentLeafIndex` (r:0 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) [partial unshield: conditional insert] fn unshield() -> Weight { // Proof Size summary in bytes: // Measured: `497` // Estimated: `6172` // Minimum execution time: 52_000_000 picoseconds. + // Worst-case path: partial unshield — includes CommitmentMemos existence check + // and MerkleTreeService::insert_leaf for the change note (+3 reads, +6 writes). Weight::from_parts(52_000_000, 6172) - .saturating_add(T::DbWeight::get().reads(10_u64)) - .saturating_add(T::DbWeight::get().writes(6_u64)) + .saturating_add(T::DbWeight::get().reads(13_u64)) + .saturating_add(T::DbWeight::get().writes(12_u64)) } fn set_audit_policy() -> Weight { Weight::from_parts(100_000, 0).saturating_add(T::DbWeight::get().writes(1)) @@ -271,14 +280,23 @@ impl WeightInfo for () { /// Proof: `System::EventCount` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::Events` (r:1 w:1) /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `ShieldedPool::CommitmentMemos` (r:1 w:0) [partial unshield: duplicate check] + /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::MerkleFrontier` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::CommitmentLeafIndex` (r:0 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) [partial unshield: conditional insert] + /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) [partial unshield: conditional insert] fn unshield() -> Weight { // Proof Size summary in bytes: // Measured: `497` // Estimated: `6172` // Minimum execution time: 52_000_000 picoseconds. + // Worst-case path: partial unshield — includes CommitmentMemos existence check + // and MerkleTreeService::insert_leaf for the change note (+3 reads, +6 writes). Weight::from_parts(52_000_000, 6172) - .saturating_add(RocksDbWeight::get().reads(10_u64)) - .saturating_add(RocksDbWeight::get().writes(6_u64)) + .saturating_add(RocksDbWeight::get().reads(13_u64)) + .saturating_add(RocksDbWeight::get().writes(12_u64)) } fn set_audit_policy() -> Weight { Weight::from_parts(100_000, 0).saturating_add(RocksDbWeight::get().writes(1)) diff --git a/frame/zk-verifier/Cargo.toml b/frame/zk-verifier/Cargo.toml index 38b56f00..02ddd769 100644 --- a/frame/zk-verifier/Cargo.toml +++ b/frame/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-zk-verifier" -version = "0.5.0" +version = "0.5.1" description = "Zero-Knowledge proof verification pallet for Orbinum" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/zk-verifier/README.md b/frame/zk-verifier/README.md index 41b5365c..06a4d01b 100644 --- a/frame/zk-verifier/README.md +++ b/frame/zk-verifier/README.md @@ -63,9 +63,10 @@ pub trait ZkVerifierPort { merkle_root: &[u8; 32], nullifier: &[u8; 32], amount: u128, - recipient: &[u8; 32], + recipient: &[u8; 32], // LE field element — NOT byte-reversed asset_id: u32, fee: u128, + change_commitment: &[u8; 32], // [0u8;32] for total unshield version: Option, ) -> Result; @@ -115,6 +116,24 @@ The previous Clean Architecture layers (`domain/`, `application/`, `infrastructu cargo test -p pallet-zk-verifier ``` +## Public-input encoding + +`encoding.rs` converts typed domain parameters to `Vec<[u8; 32]>` LE field elements. + +**Unshield** — 7 field elements in order: + +| Index | Field | Encoding | +|-------|-------|----------| +| 0 | `merkle_root` | 32 bytes as-is (LE) | +| 1 | `nullifier` | 32 bytes as-is (LE) | +| 2 | `amount` | u128 LE in first 16 bytes of a 32-byte slot | +| 3 | `recipient` | 32 bytes as-is — LE field element (`bytesToBigintLE` convention) | +| 4 | `asset_id` | u32 LE in first 4 bytes of a 32-byte slot | +| 5 | `fee` | u128 LE in first 16 bytes of a 32-byte slot | +| 6 | `change_commitment` | 32 bytes as-is; `[0u8;32]` for total unshield | + +The `recipient` is passed **without byte-reversal**. `PublicInputs::to_field_elements` calls `Bn254Fr::from_le_bytes_mod_order`, which matches the TypeScript SDK convention `bytesToBigintLE(accountId32Bytes)`. + ## Notes and limitations - Verification behavior differs between `runtime-benchmarks`/test builds (mock VK) and production (real Groth16). diff --git a/frame/zk-verifier/src/encoding.rs b/frame/zk-verifier/src/encoding.rs index 3fd4144c..13e94ec9 100644 --- a/frame/zk-verifier/src/encoding.rs +++ b/frame/zk-verifier/src/encoding.rs @@ -36,10 +36,10 @@ pub fn encode_transfer( /// Encode unshield (pool withdrawal) public inputs. /// -/// Order: `merkle_root | nullifier | amount | recipient_le | asset_id | fee` +/// Order: `merkle_root | nullifier | amount | recipient | asset_id | fee | change_commitment` /// -/// `recipient` is treated as big-endian (AccountId32 form) and reversed to LE -/// for BN254 field encoding. +/// `recipient` is passed as-is (LE field element, same convention used by the +/// TypeScript SDK: `bytesToBigintLE(accountId32Bytes)`). pub fn encode_unshield( merkle_root: &[u8; 32], nullifier: &[u8; 32], @@ -47,15 +47,11 @@ pub fn encode_unshield( recipient: &[u8; 32], asset_id: u32, fee: u128, + change_commitment: &[u8; 32], ) -> Vec<[u8; 32]> { let mut amount_bytes = [0u8; 32]; amount_bytes[..16].copy_from_slice(&amount.to_le_bytes()); - let mut recipient_le = [0u8; 32]; - for (i, b) in recipient.iter().rev().enumerate() { - recipient_le[i] = *b; - } - let mut asset_bytes = [0u8; 32]; asset_bytes[..4].copy_from_slice(&asset_id.to_le_bytes()); @@ -66,9 +62,10 @@ pub fn encode_unshield( *merkle_root, *nullifier, amount_bytes, - recipient_le, + *recipient, asset_bytes, fee_bytes, + *change_commitment, ] } @@ -127,17 +124,30 @@ mod tests { #[test] fn encode_unshield_correct_length() { - let raw = encode_unshield(&[1u8; 32], &[2u8; 32], 100, &[3u8; 32], 1, 5); - assert_eq!(raw.len(), 6); + let raw = encode_unshield(&[1u8; 32], &[2u8; 32], 100, &[3u8; 32], 1, 5, &[6u8; 32]); + assert_eq!(raw.len(), 7); + } + + #[test] + fn encode_unshield_change_commitment_appended() { + let change = [0xABu8; 32]; + let raw = encode_unshield(&[0u8; 32], &[0u8; 32], 0, &[0u8; 32], 0, 0, &change); + assert_eq!(raw[6], change); + } + + #[test] + fn encode_unshield_zero_change_commitment() { + let raw = encode_unshield(&[0u8; 32], &[0u8; 32], 0, &[0u8; 32], 0, 0, &[0u8; 32]); + assert_eq!(raw[6], [0u8; 32]); } #[test] fn encode_unshield_recipient_reversed() { let mut recipient = [0u8; 32]; recipient[31] = 0xFF; - let raw = encode_unshield(&[0u8; 32], &[0u8; 32], 0, &recipient, 0, 0); - // LE reversal: first byte should be 0xFF - assert_eq!(raw[3][0], 0xFF); + let raw = encode_unshield(&[0u8; 32], &[0u8; 32], 0, &recipient, 0, 0, &[0u8; 32]); + // recipient is passed as-is (LE format), so last byte should be 0xFF + assert_eq!(raw[3][31], 0xFF); } #[test] diff --git a/frame/zk-verifier/src/port.rs b/frame/zk-verifier/src/port.rs index b674efb1..a163b4c1 100644 --- a/frame/zk-verifier/src/port.rs +++ b/frame/zk-verifier/src/port.rs @@ -38,6 +38,7 @@ pub trait ZkVerifierPort { recipient: &[u8; 32], asset_id: u32, fee: u128, + change_commitment: &[u8; 32], version: Option, ) -> Result; @@ -102,10 +103,18 @@ impl ZkVerifierPort for Pallet { recipient: &[u8; 32], asset_id: u32, fee: u128, + change_commitment: &[u8; 32], version: Option, ) -> Result { - let raw = - encoding::encode_unshield(merkle_root, nullifier, amount, recipient, asset_id, fee); + let raw = encoding::encode_unshield( + merkle_root, + nullifier, + amount, + recipient, + asset_id, + fee, + change_commitment, + ); verifier::verify::(CircuitId::UNSHIELD, version, proof, raw).map(|(ok, _)| ok) } @@ -483,6 +492,7 @@ mod tests { &[0u8; 32], 0, 0, + &[0u8; 32], Some(1), ), Error::::EmptyProof @@ -502,6 +512,7 @@ mod tests { &[0u8; 32], 0, 0, + &[0u8; 32], None, ), Error::::CircuitNotFound @@ -521,6 +532,7 @@ mod tests { &[0u8; 32], 0, 0, + &[0u8; 32], Some(99), ), Error::::VerificationKeyNotFound @@ -541,6 +553,7 @@ mod tests { &[0xFFu8; 32], 0, 50, + &[0u8; 32], None, ) .unwrap(); @@ -562,6 +575,7 @@ mod tests { &[0u8; 32], 2, 10, + &[0u8; 32], Some(2), ) .unwrap(); @@ -586,6 +600,7 @@ mod tests { &recipient, 2, 10, + &[0u8; 32], None, ) .unwrap(); diff --git a/primitives/zk-verifier/src/types.rs b/primitives/zk-verifier/src/types.rs index b868a102..ca11246b 100644 --- a/primitives/zk-verifier/src/types.rs +++ b/primitives/zk-verifier/src/types.rs @@ -27,8 +27,8 @@ pub const CIRCUIT_ID_PRIVATE_LINK: u8 = 5; /// Public inputs: [merkle_root, nullifier1, nullifier2, commitment1, commitment2] pub const TRANSFER_PUBLIC_INPUTS: usize = 5; /// Number of public inputs for the unshield circuit. -/// Public inputs: [merkle_root, nullifier, recipient, amount, asset_id] -pub const UNSHIELD_PUBLIC_INPUTS: usize = 5; +/// Public inputs: [merkle_root, nullifier, amount, recipient, asset_id, fee, change_commitment] +pub const UNSHIELD_PUBLIC_INPUTS: usize = 7; /// Number of public inputs for the disclosure circuit. /// Public inputs: [commitment, revealed_value, revealed_asset_id, revealed_owner_hash] pub const DISCLOSURE_PUBLIC_INPUTS: usize = 4; @@ -284,7 +284,7 @@ mod tests { #[test] fn test_public_input_counts_are_expected() { assert_eq!(TRANSFER_PUBLIC_INPUTS, 5); - assert_eq!(UNSHIELD_PUBLIC_INPUTS, 5); + assert_eq!(UNSHIELD_PUBLIC_INPUTS, 7); assert_eq!(DISCLOSURE_PUBLIC_INPUTS, 4); assert_eq!(PRIVATE_LINK_PUBLIC_INPUTS, 2); }