diff --git a/Cargo.lock b/Cargo.lock index b40cdb65..07b86d58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9911,7 +9911,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "orbinum-node" -version = "0.2.0" +version = "0.3.0" dependencies = [ "async-trait", "clap", @@ -9994,7 +9994,7 @@ dependencies = [ [[package]] name = "orbinum-runtime" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "ethereum", @@ -10082,7 +10082,7 @@ dependencies = [ [[package]] name = "orbinum-zk-verifier" -version = "2.0.0" +version = "2.1.0" dependencies = [ "ark-bn254", "ark-ec 0.5.0", @@ -11338,7 +11338,7 @@ dependencies = [ [[package]] name = "pallet-evm-precompile-shielded-pool" -version = "0.7.0" +version = "0.8.0" dependencies = [ "fp-evm", "frame-support", @@ -12407,7 +12407,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.20.0" +version = "0.21.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", @@ -12980,7 +12980,7 @@ dependencies = [ [[package]] name = "pallet-zk-verifier" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ark-bn254", "ark-ec 0.5.0", diff --git a/frame/evm/precompile/shielded-pool/CHANGELOG.md b/frame/evm/precompile/shielded-pool/CHANGELOG.md index 60d3ec4d..db32edd8 100644 --- a/frame/evm/precompile/shielded-pool/CHANGELOG.md +++ b/frame/evm/precompile/shielded-pool/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to `pallet-evm-precompile-shielded-pool` will be documented in this file. +## [Unreleased] + +## [0.8.0] - 2026-10-05 + +**Breaking** — the shield ABI changes. Requires the matching `pallet-shielded-pool`. + +### Changed + +- `shield(uint32,bytes32,bytes,bytes,uint32)` — `0xf25897e0`, was + `shield(uint32,bytes32,bytes)` `0x9feb22ea`. Adds the shield `proof` and its + `circuitVersion`; the amount is still `msg.value`. Old three-slot calldata + reverts with `shield: input too short`, an empty proof with + `shield: proof must be non-empty`. +- The proof bound is `pallet_shielded_pool::Proof`; the precompile no longer + keeps its own copy of the 512-byte limit. + ## [0.7.0] - 2026-09-29 **Breaking** — `claimShieldedFees` is gone. Requires the matching diff --git a/frame/evm/precompile/shielded-pool/Cargo.toml b/frame/evm/precompile/shielded-pool/Cargo.toml index dda2fd2d..f198e253 100644 --- a/frame/evm/precompile/shielded-pool/Cargo.toml +++ b/frame/evm/precompile/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-evm-precompile-shielded-pool" -version = "0.7.0" +version = "0.8.0" authors = { workspace = true } edition = "2021" description = "EVM Precompile for Orbinum Shielded Pool Pallet." diff --git a/frame/evm/precompile/shielded-pool/src/calls/mod.rs b/frame/evm/precompile/shielded-pool/src/calls/mod.rs index 4b45aac7..997ae23a 100644 --- a/frame/evm/precompile/shielded-pool/src/calls/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/calls/mod.rs @@ -24,18 +24,11 @@ pub mod shield; pub mod unshield; use fp_evm::PrecompileFailure; -use frame_support::{traits::ConstU32, BoundedVec}; -use pallet_shielded_pool::BalanceOf; +use pallet_shielded_pool::{BalanceOf, Proof}; use sp_core::U256; use crate::{abi, revert}; -/// Maximum byte length of a serialised Groth16 proof accepted by the pallet. -const MAX_PROOF_LEN: u32 = 512; - -/// A spend's proof, bounded as the pallet call expects it. -type Proof = BoundedVec>; - /// The parameters after the selector, once they hold the whole `head_len`-byte /// head. Offsets in the head point into the tail, which each dynamic decoder /// bounds-checks on its own. diff --git a/frame/evm/precompile/shielded-pool/src/calls/shield.rs b/frame/evm/precompile/shielded-pool/src/calls/shield.rs index ca4e272a..7bc98644 100644 --- a/frame/evm/precompile/shielded-pool/src/calls/shield.rs +++ b/frame/evm/precompile/shielded-pool/src/calls/shield.rs @@ -1,31 +1,34 @@ -//! ABI decoding and call construction for `shield(uint32,bytes32,bytes)`. +//! ABI decoding and call construction for `shield(uint32,bytes32,bytes,bytes,uint32)`. //! //! ## Selector -//! `keccak256("shield(uint32,bytes32,bytes)")[0..4]` = `0x9feb22ea` +//! `keccak256("shield(uint32,bytes32,bytes,bytes,uint32)")[0..4]` = `0xf25897e0` //! //! ## ABI layout (`input[4..]`) -//! | Slot (bytes) | Type | Field | -//! |--------------|-----------|------------------| -//! | 0..32 | `uint32` | `asset_id` | -//! | 32..64 | `bytes32` | `commitment` | -//! | 64..96 | `uint256` | offset → memo | +//! | Slot (bytes) | Type | Field | +//! |--------------|-----------|-------------------| +//! | 0..32 | `uint32` | `asset_id` | +//! | 32..64 | `bytes32` | `commitment` | +//! | 64..96 | `uint256` | offset → memo | +//! | 96..128 | `uint256` | offset → proof | +//! | 128..160 | `uint32` | `circuit_version` | //! //! ## Notes //! `amount` is not in the ABI: it is `msg.value`, which the EVM executor has -//! already transferred to the precompile's address before `execute` runs. +//! already transferred to the precompile's address before `execute` runs. The +//! proof binds the commitment to that amount. use fp_evm::{PrecompileFailure, PrecompileHandle}; -use super::{balance, params}; +use super::{balance, params, proof}; use crate::{abi, revert}; /// Selector for the signature in this module's header. -pub const SELECTOR: [u8; 4] = [0x9f, 0xeb, 0x22, 0xea]; +pub const SELECTOR: [u8; 4] = [0xf2, 0x58, 0x97, 0xe0]; /// Decodes `input` into a ready-to-dispatch `shield` call. /// /// `handle` supplies the amount via `apparent_value` (`msg.value`); the calldata -/// carries only the asset, the commitment, and the memo. +/// carries the asset, the commitment, the memo and the shield proof. pub fn decode( handle: &impl PrecompileHandle, input: &[u8], @@ -34,9 +37,9 @@ where T: pallet_shielded_pool::Config, pallet_shielded_pool::BalanceOf: TryFrom, { - // Step 1: require the three-slot head. The memo offset it carries is bounds - // checked by the tail decoder in step 5. - let params = params(input, 96, "shield: input too short")?; + // Step 1: require the five-slot head. The memo and proof offsets it carries + // are bounds checked by their tail decoders. + let params = params(input, 160, "shield: input too short")?; // Step 2: asset_id. let asset_id = abi::read_u32(params, 0)?; @@ -63,10 +66,23 @@ where let encrypted_memo = pallet_shielded_pool::FrameEncryptedMemo::new(memo_bytes) .map_err(|_| revert("shield: memo too long or wrong size"))?; + // Step 6: proof — dynamic, offset at slot 96. + let proof = proof( + params, + 96, + "shield: proof too long", + "shield: proof must be non-empty", + )?; + + // Step 7: circuit_version. + let circuit_version = abi::read_u32(params, 128)?; + Ok(pallet_shielded_pool::Call::::shield { asset_id, amount, commitment, encrypted_memo, + proof, + circuit_version, }) } diff --git a/frame/evm/precompile/shielded-pool/src/lib.rs b/frame/evm/precompile/shielded-pool/src/lib.rs index 8a85dafc..c33ad327 100644 --- a/frame/evm/precompile/shielded-pool/src/lib.rs +++ b/frame/evm/precompile/shielded-pool/src/lib.rs @@ -43,7 +43,7 @@ pub use calls::private_transfer::MAX_NOTES as MAX_SPEND_INPUTS; /// /// | Selector | Solidity signature | /// |--------------|---------------------------------------------------------------------------------------| -/// | `0x9feb22ea` | `shield(uint32,bytes32,bytes)` — payable, amount = `msg.value` | +/// | `0xf25897e0` | `shield(uint32,bytes32,bytes,bytes,uint32)` — payable, amount = `msg.value` | /// | `0x66ed2cd4` | `privateTransfer(bytes,bytes32,bytes32[],bytes32[],bytes[],uint32,uint256,uint32)` | /// | `0x4e505348` | `unshield(bytes,bytes32,bytes32,uint32,uint256,bytes32,uint256,bytes32,bytes,uint32)` | /// | `0xc9b235ff` | `commitRelay(bytes32[])` — registered relayers | diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs index e51648af..dd1fadf5 100644 --- a/frame/evm/precompile/shielded-pool/src/mock.rs +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -5,7 +5,7 @@ use frame_support::{derive_impl, parameter_types, traits::Get, weights::Weight, use pallet_evm::{ AddressMapping, Context, EnsureAddressNever, EnsureAddressRoot, FeeCalculator, PrecompileHandle, }; -use pallet_zk_verifier::{TransferStatement, UnshieldStatement, ZkVerifierPort}; +use pallet_zk_verifier::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort}; use sp_core::{H160, H256, U256}; use sp_runtime::{ traits::{BlakeTwo256, IdentityLookup}, @@ -145,6 +145,14 @@ impl ZkVerifierPort for MockZkVerifier { non_empty(proof) } + fn verify_shield_proof( + proof: &[u8], + _statement: &ShieldStatement, + _version: Option, + ) -> Result { + non_empty(proof) + } + fn is_supported_version(_circuit_id: u32, version: u32) -> bool { version != 0 } diff --git a/frame/evm/precompile/shielded-pool/src/tests/abi.rs b/frame/evm/precompile/shielded-pool/src/tests/abi.rs index 13dabd7b..05dba8e6 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/abi.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/abi.rs @@ -15,7 +15,7 @@ fn router_rejects_empty_input() { #[test] fn router_rejects_3_byte_selector() { new_test_ext().execute_with(|| { - let mut h = MockHandle::new(vec![0x9f, 0xeb, 0x22]); + let mut h = MockHandle::new(crate::calls::shield::SELECTOR[..3].to_vec()); expect_error(ShieldedPoolPrecompile::::execute(&mut h)); }); } diff --git a/frame/evm/precompile/shielded-pool/src/tests/mod.rs b/frame/evm/precompile/shielded-pool/src/tests/mod.rs index c086e1b0..5a347923 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/mod.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/mod.rs @@ -129,15 +129,32 @@ fn encode_bytes_array(items: &[Vec]) -> Vec { // ─── Call encoders ─────────────────────────────────────────────────────────── -/// `shield(uint32,bytes32,bytes)`, selector `0x9feb22ea`. +/// `shield(uint32,bytes32,bytes,bytes,uint32)` with a well-formed proof at version 1. fn encode_shield(asset_id: u32, commitment: [u8; 32], memo: &[u8]) -> Vec { - let mut input = vec![0x9f, 0xeb, 0x22, 0xea]; - let mut head = vec![0u8; 96]; + encode_shield_with(asset_id, commitment, memo, &[0x01; 128], 1) +} + +/// `shield(uint32,bytes32,bytes,bytes,uint32)`, selector `0xf25897e0`. +fn encode_shield_with( + asset_id: u32, + commitment: [u8; 32], + memo: &[u8], + proof: &[u8], + circuit_version: u32, +) -> Vec { + let memo_enc = encode_bytes(memo); + let head_size = 160usize; + + let mut input = crate::calls::shield::SELECTOR.to_vec(); + let mut head = vec![0u8; head_size]; head[28..32].copy_from_slice(&asset_id.to_be_bytes()); head[32..64].copy_from_slice(&commitment); - head[64..96].copy_from_slice(&u256_word(96)); + head[64..96].copy_from_slice(&u256_word(head_size)); + head[96..128].copy_from_slice(&u256_word(head_size + memo_enc.len())); + head[156..160].copy_from_slice(&circuit_version.to_be_bytes()); input.extend_from_slice(&head); - input.extend_from_slice(&encode_bytes(memo)); + input.extend_from_slice(&memo_enc); + input.extend_from_slice(&encode_bytes(proof)); input } diff --git a/frame/evm/precompile/shielded-pool/src/tests/shield.rs b/frame/evm/precompile/shielded-pool/src/tests/shield.rs index 2d2618bb..08bf0f94 100644 --- a/frame/evm/precompile/shielded-pool/src/tests/shield.rs +++ b/frame/evm/precompile/shielded-pool/src/tests/shield.rs @@ -14,6 +14,68 @@ fn shield_rejects_truncated_input() { }); } +/// The pre-proof ABI (`asset_id, commitment, memo`) is three slots: too short now, +/// so an outdated client fails loudly instead of shielding without a proof. +#[test] +fn shield_rejects_the_old_three_slot_head() { + new_test_ext().execute_with(|| { + let mut input = crate::calls::shield::SELECTOR.to_vec(); + input.extend_from_slice(&[0u8; 96]); + let mut h = MockHandle::with_value(input, 1_000); + expect_error_msg( + ShieldedPoolPrecompile::::execute(&mut h), + "shield: input too short", + ); + }); +} + +#[test] +fn shield_rejects_an_empty_proof() { + new_test_ext().execute_with(|| { + let input = encode_shield_with(0, canon(0x12), &[0xAB; 180], &[], 1); + let mut h = MockHandle::with_value(input, 1_000); + expect_error_msg( + ShieldedPoolPrecompile::::execute(&mut h), + "shield: proof must be non-empty", + ); + assert_eq!(pallet_shielded_pool::MerkleTreeSize::::get(), 0); + }); +} + +/// The amount reaches the pallet from `msg.value`, and the proof and version +/// from the calldata, unchanged. +#[test] +fn shield_decodes_proof_and_version_into_the_call() { + new_test_ext().execute_with(|| { + let proof = [0x5Au8; 96]; + let input = encode_shield_with(3, canon(0x13), &[0xAB; 180], &proof, 7); + let h = MockHandle::with_value(input.clone(), 1_000); + let call = crate::calls::shield::decode::(&h, &input) + .ok() + .unwrap(); + match call { + pallet_shielded_pool::Call::shield { + asset_id, + amount, + commitment, + proof: decoded, + circuit_version, + .. + } => { + assert_eq!(asset_id, 3); + assert_eq!(amount, 1_000); + assert_eq!( + commitment, + pallet_shielded_pool::Commitment::from(canon(0x13)) + ); + assert_eq!(decoded.into_inner(), proof.to_vec()); + assert_eq!(circuit_version, 7); + } + other => panic!("decoded to {other:?}"), + } + }); +} + #[test] fn shield_accepts_smallest_non_zero_amount() { // There is no minimum shield amount: msg.value = 1 must go through. diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 0038695e..ffcadd4d 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -2,6 +2,41 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. +## [Unreleased] + +## [0.21.0] - 2026-10-05 + +A shield must prove its note is worth exactly the deposit. Closes AL-1: a deposit +of 1 could insert a note worth 1000. **Breaking** — `shield` and `shield_batch` +change signature; `transaction_version` must move. + +### Changed + +- **`shield`** takes `proof: Proof` and `circuit_version: u32`. The + commitment must open to `amount` of `asset_id` under circuit 3, checked through + `ZkVerifierPort::verify_shield_proof` before any funds move; + `ProofVerificationFailed` otherwise. +- **`shield_batch`** entries are `ShieldBatchItem`: `shield`'s arguments, + proof and version included. One bad proof reverts the whole batch. +- Both weights add one proof verification per deposit (`verification_weight`). +- **Only the native asset moves.** `shield`, `unshield`, `private_transfer` and + `claim_relay_fees` refuse a verified non-native asset with the new + `AssetNotSupported` (appended to `Error`, no index shift): every transfer in + and out of the pool moves `T::Currency`, so a note of another asset was backed + by native funds. Checked through `AssetOperation::ensure_movable`, after + `InvalidAssetId` / `AssetNotVerified`. `NATIVE_ASSET_ID` (0) names the genesis + asset. +- `ShieldOperation::execute(depositor, &proof, ShieldRequest)`, shaped like the + unshield and transfer operations; `ShieldRequest::from_batch_item` splits a + batch entry. Everything checkable without the proof runs first, so a bad + request costs no verification. + +### Added + +- `Proof` and `MAX_PROOF_SIZE` (512): the one proof bound for every call, shared + with the EVM precompile. `shield`, `shield_batch`, `unshield` and + `private_transfer` take it; the encoding is unchanged. + ## [0.20.0] - 2026-09-29 Relay fees follow the relay commit, and are claimed publicly. Closes RL-6: copying diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 803b97e4..0bdadc45 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.20.0" +version = "0.21.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/src/benchmarking.rs b/frame/shielded-pool/src/benchmarking.rs index 97cd00e9..e0aacd2d 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -136,6 +136,8 @@ mod benchmarks { // Memo must be exactly 180 bytes (MAX_ENCRYPTED_MEMO_SIZE): nonce(12) + data(120) + MAC(16) + ephPk(32) let memo_bytes = vec![0u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; let encrypted_memo = FrameEncryptedMemo(memo_bytes.try_into().unwrap()); + // Verification is weighed separately (`verification_weight`), so a stub proof. + let proof: Proof = vec![0u8; 128].try_into().unwrap(); #[extrinsic_call] shield( @@ -144,6 +146,8 @@ mod benchmarks { amount, commitment, encrypted_memo, + proof, + 1u32, ); } @@ -157,7 +161,8 @@ mod benchmarks { let commitment = Commitment(canonical_bytes(i as u8)); let memo_bytes = vec![0u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; let encrypted_memo = FrameEncryptedMemo(memo_bytes.try_into().unwrap()); - operations.push((asset_id, amount, commitment, encrypted_memo)); + let proof: Proof = vec![0u8; 128].try_into().unwrap(); + operations.push((asset_id, amount, commitment, encrypted_memo, proof, 1u32)); } let operations_vec: BoundedVec<_, ConstU32<20>> = operations.try_into().unwrap(); @@ -173,7 +178,7 @@ mod benchmarks { // Setup valid root in storage crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); - let proof: BoundedVec> = vec![0u8; 128].try_into().unwrap(); + let proof: Proof = vec![0u8; 128].try_into().unwrap(); // Two real inputs and two outputs: the most reads and leaf insertions. let memo = || { @@ -243,7 +248,7 @@ mod benchmarks { amount * 100u32.into(), ); - let proof: BoundedVec> = vec![0u8; 128].try_into().unwrap(); + let proof: Proof = vec![0u8; 128].try_into().unwrap(); let nullifier = Nullifier(canonical_bytes(4)); // Must be >= T::Relayer::min_relay_fee() to pass the FeeTooLow check. diff --git a/frame/shielded-pool/src/genesis.rs b/frame/shielded-pool/src/genesis.rs index 5c1e1d49..dead1d99 100644 --- a/frame/shielded-pool/src/genesis.rs +++ b/frame/shielded-pool/src/genesis.rs @@ -5,8 +5,7 @@ use crate::{ Assets, Config, HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, MerkleTreeFrontier, NextAssetId, PoseidonRoot, }, - types::AssetMetadata, - types::Hash, + types::{AssetMetadata, Hash, NATIVE_ASSET_ID}, }; use frame_support::traits::Get; use sp_runtime::traits::AccountIdConversion; @@ -30,9 +29,9 @@ pub fn initialize_genesis(initial_root: Hash) { HistoricRootsHead::::put(1u64); HistoricRootsTail::::put(0u64); - // Register native asset (asset_id = 0) at genesis + // Register the native asset at genesis let native_asset = AssetMetadata { - id: 0, + id: NATIVE_ASSET_ID, name: b"Orbinum Native Token" .to_vec() .try_into() @@ -47,8 +46,8 @@ pub fn initialize_genesis(initial_root: Hash) { created_at: 0u32.into(), creator: T::PalletId::get().into_account_truncating(), }; - Assets::::insert(0, native_asset); - NextAssetId::::put(1); + Assets::::insert(NATIVE_ASSET_ID, native_asset); + NextAssetId::::put(NATIVE_ASSET_ID + 1); } #[cfg(test)] diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 088fc7df..bed93910 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -84,8 +84,8 @@ pub use origin::{RelayCaller, ensure_relay_caller, ensure_spend_origin}; pub use pallet::*; pub use types::{ AssetId, AssetMetadata, Commitment, DEFAULT_TREE_DEPTH, DefaultMerklePath, - EncryptedMemo as FrameEncryptedMemo, Hash, MAX_ENCRYPTED_MEMO_SIZE, MAX_TREE_DEPTH, MerklePath, - Note, Nullifier, + EncryptedMemo as FrameEncryptedMemo, Hash, MAX_ENCRYPTED_MEMO_SIZE, MAX_PROOF_SIZE, + MAX_TREE_DEPTH, MerklePath, Note, Nullifier, Proof, }; pub use weights::WeightInfo; @@ -138,6 +138,17 @@ pub mod pallet { pub type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + /// One `shield_batch` entry: `(asset_id, amount, commitment, encrypted_memo, proof, + /// circuit_version)`, the arguments of a single `shield`. + pub type ShieldBatchItem = ( + u32, + BalanceOf, + Commitment, + FrameEncryptedMemo, + Proof, + u32, + ); + /// Storage version history: /// - v1: `MerkleNodes` (internal Merkle tree nodes), backfilled from `MerkleLeaves`. /// Migration removed once every live chain reached v2 — see git history. @@ -738,6 +749,10 @@ pub mod pallet { EmptyBatch, /// Asset id counter collided with an existing asset (would overwrite it). AssetIdAlreadyExists, + /// The asset is registered and verified but not backed: the pool moves + /// the native currency for every asset, so only the native asset can be + /// shielded, spent, withdrawn or claimed. + AssetNotSupported, } // ======================================================================== @@ -757,30 +772,38 @@ pub mod pallet { /// * `amount` - Amount of tokens to shield /// * `commitment` - The commitment for the new note (computed off-chain) /// * `encrypted_memo` - Encrypted metadata for note recovery and audit + /// * `proof` - Shield proof: `commitment` opens to `amount` of `asset_id` + /// * `circuit_version` - Shield circuit version the proof was built for /// /// # Errors /// * `InvalidAmount` - Amount is zero /// * `MerkleTreeFull` - No more space in the tree /// * `CommitmentAlreadyExists` - Duplicate commitment /// * `InvalidMemoSize` - Encrypted memo is not exactly 180 bytes + /// * `ProofVerificationFailed` - The proof does not bind the commitment to the deposit #[pallet::call_index(0)] - #[pallet::weight(T::WeightInfo::shield())] + #[pallet::weight(T::WeightInfo::shield().saturating_add(T::ZkVerifier::verification_weight()))] pub fn shield( origin: OriginFor, asset_id: u32, amount: BalanceOf, commitment: Commitment, encrypted_memo: FrameEncryptedMemo, + proof: Proof, + circuit_version: u32, ) -> DispatchResult { let who = ensure_signed(origin)?; - - // Delegate to business operation - crate::operations::shield::ShieldOperation::execute::( + use crate::operations::shield::{ShieldOperation, ShieldRequest}; + ShieldOperation::execute::( who, - asset_id, - amount, - commitment, - encrypted_memo, + &proof, + ShieldRequest { + asset_id, + amount, + commitment, + encrypted_memo, + circuit_version, + }, ) } @@ -792,7 +815,8 @@ pub mod pallet { /// /// # Arguments /// * `origin` - The account depositing tokens - /// * `operations` - Up to 20 (asset_id, amount, commitment, encrypted_memo) tuples + /// * `operations` - Up to 20 (asset_id, amount, commitment, encrypted_memo, proof, + /// circuit_version) tuples, one shield proof each /// /// # Errors /// * Same as `shield()` for any individual operation @@ -804,28 +828,21 @@ pub mod pallet { /// # Weight /// Benchmarked per operation count via `shield_batch(n)`. #[pallet::call_index(12)] - #[pallet::weight(T::WeightInfo::shield_batch(operations.len() as u32))] + #[pallet::weight( + T::WeightInfo::shield_batch(operations.len() as u32) + .saturating_add(T::ZkVerifier::verification_weight().saturating_mul(operations.len() as u64)) + )] pub fn shield_batch( origin: OriginFor, - operations: BoundedVec< - (u32, BalanceOf, Commitment, FrameEncryptedMemo), - ConstU32<20>, - >, + operations: BoundedVec, ConstU32<20>>, ) -> DispatchResult { let who = ensure_signed(origin)?; ensure!(!operations.is_empty(), Error::::EmptyBatch); - - // Process each shield operation - for (asset_id, amount, commitment, encrypted_memo) in operations.into_iter() { - crate::operations::shield::ShieldOperation::execute::( - who.clone(), - asset_id, - amount, - commitment, - encrypted_memo, - )?; + use crate::operations::shield::{ShieldOperation, ShieldRequest}; + for item in operations { + let (proof, req) = ShieldRequest::from_batch_item(item); + ShieldOperation::execute::(who.clone(), &proof, req)?; } - Ok(()) } @@ -863,7 +880,7 @@ pub mod pallet { #[allow(clippy::too_many_arguments)] pub fn private_transfer( origin: OriginFor, - proof: BoundedVec>, + proof: Proof, merkle_root: Hash, nullifiers: BoundedVec>, commitments: BoundedVec>, @@ -921,7 +938,7 @@ pub mod pallet { #[allow(clippy::too_many_arguments)] pub fn unshield( origin: OriginFor, - proof: BoundedVec>, + proof: Proof, merkle_root: Hash, nullifier: Nullifier, asset_id: u32, diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index 26f37156..15a35936 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -6,7 +6,7 @@ use frame_support::{ traits::{ConstU32, ConstU64, ConstU128}, }; use frame_system::EnsureRoot; -use pallet_zk_verifier::{TransferStatement, UnshieldStatement, ZkVerifierPort}; +use pallet_zk_verifier::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort}; use sp_runtime::{AccountId32, BuildStorage, traits::IdentityLookup}; type Block = frame_system::mocking::MockBlock; @@ -80,6 +80,7 @@ pub struct MockZkVerifier; pub enum VerifiedStatement { Transfer(TransferStatement, Option), Unshield(UnshieldStatement, Option), + Shield(ShieldStatement, Option), } std::thread_local! { @@ -126,6 +127,14 @@ impl ZkVerifierPort for MockZkVerifier { record(proof, VerifiedStatement::Unshield(*statement, version)) } + fn verify_shield_proof( + proof: &[u8], + statement: &ShieldStatement, + version: Option, + ) -> Result { + record(proof, VerifiedStatement::Shield(*statement, version)) + } + fn is_supported_version(_circuit_id: u32, version: u32) -> bool { version != 0 } diff --git a/frame/shielded-pool/src/operations/assets.rs b/frame/shielded-pool/src/operations/assets.rs index 1bfe01df..b742e0a7 100644 --- a/frame/shielded-pool/src/operations/assets.rs +++ b/frame/shielded-pool/src/operations/assets.rs @@ -1,7 +1,7 @@ use crate::{ pallet::{Config, Error, Event, Pallet}, storage::AssetRepository, - types::AssetMetadata, + types::{AssetMetadata, NATIVE_ASSET_ID}, }; use frame_support::{BoundedVec, ensure, pallet_prelude::*}; use frame_system::pallet_prelude::BlockNumberFor; @@ -100,6 +100,17 @@ impl AssetOperation { Ok(()) } + /// The asset a pool operation may move: registered, verified, and backed. + /// Every transfer into or out of the pool moves `T::Currency`, so a note of + /// any other asset would be backed by native funds; such ids are refused + /// until they have their own backend. + pub fn ensure_movable(asset_id: u32) -> Result<(), Error> { + let asset = AssetRepository::get_asset::(asset_id).ok_or(Error::::InvalidAssetId)?; + ensure!(asset.is_verified, Error::::AssetNotVerified); + ensure!(asset_id == NATIVE_ASSET_ID, Error::::AssetNotSupported); + Ok(()) + } + pub fn get_next_asset_id() -> u32 { AssetRepository::get_next_asset_id::() } diff --git a/frame/shielded-pool/src/operations/fees.rs b/frame/shielded-pool/src/operations/fees.rs index 68a7734f..a72c3373 100644 --- a/frame/shielded-pool/src/operations/fees.rs +++ b/frame/shielded-pool/src/operations/fees.rs @@ -5,9 +5,9 @@ //! spend; without one it goes to the block author. use crate::{ - operations::SpendRequest, + operations::{SpendRequest, assets::AssetOperation}, origin::RelayCaller, - pallet::{Assets, BalanceOf, Call, Config, Error, Event, Pallet}, + pallet::{BalanceOf, Call, Config, Error, Event, Pallet}, storage::PoolBalanceRepository, }; use frame_support::{ @@ -146,8 +146,7 @@ impl FeeOperation { amount: BalanceOf, ) -> DispatchResult { // An unverified asset is frozen: no outflow at all, relay fees included. - let asset = Assets::::get(asset_id).ok_or(Error::::InvalidAssetId)?; - ensure!(asset.is_verified, Error::::AssetNotVerified); + AssetOperation::ensure_movable::(asset_id)?; ensure!(!amount.is_zero(), Error::::InvalidAmount); ensure!( PoolBalanceRepository::get_asset_balance::(asset_id) >= amount, diff --git a/frame/shielded-pool/src/operations/private_transfer.rs b/frame/shielded-pool/src/operations/private_transfer.rs index 993c87c3..7f2fd375 100644 --- a/frame/shielded-pool/src/operations/private_transfer.rs +++ b/frame/shielded-pool/src/operations/private_transfer.rs @@ -2,9 +2,9 @@ use crate::{ merkle::MerkleTreeService, - operations::{ensure_valid_proof, fees, statement}, + operations::{assets::AssetOperation, ensure_valid_proof, fees, statement}, pallet::{BalanceOf, Config, Error, Event, Pallet}, - storage::{AssetRepository, CommitmentRepository, MerkleRepository, NullifierRepository}, + storage::{CommitmentRepository, MerkleRepository, NullifierRepository}, types::{Commitment, EncryptedMemo, Nullifier}, }; use frame_support::{ @@ -127,9 +127,7 @@ impl PrivateTransferOperation { Error::::InvalidMemoSize ); - let asset = - AssetRepository::get_asset::(req.asset_id).ok_or(Error::::InvalidAssetId)?; - ensure!(asset.is_verified, Error::::AssetNotVerified); + AssetOperation::ensure_movable::(req.asset_id)?; for commitment in req.commitments.iter() { ensure!( diff --git a/frame/shielded-pool/src/operations/shield.rs b/frame/shielded-pool/src/operations/shield.rs index 79bb81a0..abb52140 100644 --- a/frame/shielded-pool/src/operations/shield.rs +++ b/frame/shielded-pool/src/operations/shield.rs @@ -4,40 +4,77 @@ use frame_support::{ pallet_prelude::*, traits::{Currency, ExistenceRequirement}, }; -use sp_runtime::traits::Zero; +use pallet_zk_verifier::{ShieldStatement, ZkVerifierPort as _}; +use sp_runtime::{SaturatedConversion, traits::Zero}; use crate::{ merkle::MerkleTreeService, - pallet::{Config, Error, Event, Pallet}, + operations::{assets::AssetOperation, ensure_valid_proof}, + pallet::{BalanceOf, Config, Error, Event, Pallet, ShieldBatchItem}, storage::{AssetRepository, CommitmentRepository, PoolBalanceRepository}, - types::{Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}, + types::{Commitment, EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE, Proof}, }; +/// A shield as submitted: the deposit and the note it creates. +#[derive(CloneNoBound, PartialEqNoBound, EqNoBound, DebugNoBound)] +pub struct ShieldRequest { + pub asset_id: u32, + pub amount: BalanceOf, + pub commitment: Commitment, + pub encrypted_memo: EncryptedMemo, + /// Version whose key the proof is checked against. + pub circuit_version: u32, +} + +impl ShieldRequest { + /// A `shield_batch` entry, split into its proof and the request it proves. + pub fn from_batch_item(item: ShieldBatchItem) -> (Proof, Self) { + let (asset_id, amount, commitment, encrypted_memo, proof, circuit_version) = item; + ( + proof, + Self { + asset_id, + amount, + commitment, + encrypted_memo, + circuit_version, + }, + ) + } + + /// What the proof attests to: the commitment opens to exactly this deposit. + fn statement(&self) -> ShieldStatement { + ShieldStatement { + commitment: self.commitment.0, + value: self.amount.saturated_into(), + asset_id: self.asset_id, + } + } +} + pub struct ShieldOperation; impl ShieldOperation { + /// Deposits `req.amount` as the note `req.commitment`. The proof is checked + /// before any funds move. pub fn execute( - depositor: ::AccountId, - asset_id: u32, - amount: <::Currency as Currency<::AccountId>>::Balance, - commitment: Commitment, - encrypted_memo: EncryptedMemo, + depositor: T::AccountId, + proof: &[u8], + req: ShieldRequest, ) -> DispatchResult { - let asset = AssetRepository::get_asset::(asset_id).ok_or(Error::::InvalidAssetId)?; - ensure!(asset.is_verified, Error::::AssetNotVerified); - ensure!(!amount.is_zero(), Error::::InvalidAmount); - ensure!( - encrypted_memo.0.len() == MAX_ENCRYPTED_MEMO_SIZE as usize, - Error::::InvalidMemoSize - ); - ensure!(commitment.is_canonical(), Error::::InvalidPublicSignals); - ensure!(commitment.is_valid(), Error::::InvalidPublicSignals); - - ensure!( - !CommitmentRepository::exists::(&commitment), - Error::::CommitmentAlreadyExists - ); - + Self::validate(&req)?; + let statement = req.statement(); + ensure_valid_proof::(|| { + T::ZkVerifier::verify_shield_proof(proof, &statement, Some(req.circuit_version)) + })?; + + let ShieldRequest { + asset_id, + amount, + commitment, + encrypted_memo, + .. + } = req; T::Currency::transfer( &depositor, &Pallet::::pool_account_id(), @@ -60,6 +97,26 @@ impl ShieldOperation { Ok(()) } + /// Everything checkable without the proof, so a bad request costs no verification. + fn validate(req: &ShieldRequest) -> DispatchResult { + AssetOperation::ensure_movable::(req.asset_id)?; + ensure!(!req.amount.is_zero(), Error::::InvalidAmount); + ensure!( + req.encrypted_memo.0.len() == MAX_ENCRYPTED_MEMO_SIZE as usize, + Error::::InvalidMemoSize + ); + ensure!( + req.commitment.is_canonical(), + Error::::InvalidPublicSignals + ); + ensure!(req.commitment.is_valid(), Error::::InvalidPublicSignals); + ensure!( + !CommitmentRepository::exists::(&req.commitment), + Error::::CommitmentAlreadyExists + ); + Ok(()) + } + pub fn commitment_exists(commitment: &Commitment) -> bool { CommitmentRepository::exists::(commitment) } @@ -93,6 +150,36 @@ mod tests { memo(0x01) } + fn request( + asset_id: u32, + amount: u128, + commitment: Commitment, + encrypted_memo: EncryptedMemo, + ) -> ShieldRequest { + ShieldRequest { + asset_id, + amount, + commitment, + encrypted_memo, + circuit_version: 1, + } + } + + /// `ShieldOperation::execute` with a well-formed shield proof at version 1. + fn execute( + depositor: AccountId32, + asset_id: u32, + amount: u128, + commitment: Commitment, + encrypted_memo: EncryptedMemo, + ) -> DispatchResult { + ShieldOperation::execute::( + depositor, + &[0x01; 128], + request(asset_id, amount, commitment, encrypted_memo), + ) + } + // ── execute ────────────────────────────────────────────────────────────── #[test] @@ -101,13 +188,7 @@ mod tests { let asset_id = setup_asset(); let c = commitment(0x01); - assert_ok!(ShieldOperation::execute::( - acc(1), - asset_id, - 500u128, - c, - memo_valid(), - )); + assert_ok!(execute(acc(1), asset_id, 500u128, c, memo_valid(),)); }); } @@ -115,25 +196,32 @@ mod tests { fn execute_invalid_asset_fails() { new_test_ext().execute_with(|| { assert_noop!( - ShieldOperation::execute::( - acc(1), - 99u32, - 500u128, - commitment(1), - memo_valid() - ), + execute(acc(1), 99u32, 500u128, commitment(1), memo_valid()), crate::pallet::Error::::InvalidAssetId ); }); } + /// A verified non-native asset would be shielded out of the native balance. + #[test] + fn execute_non_native_asset_fails() { + new_test_ext().execute_with(|| { + let id = register_asset(); + AssetOperation::verify::(id).unwrap(); + assert_noop!( + execute(acc(1), id, 500u128, commitment(1), memo_valid()), + crate::pallet::Error::::AssetNotSupported + ); + }); + } + #[test] fn execute_asset_not_verified_fails() { new_test_ext().execute_with(|| { let id = register_asset(); assert_noop!( - ShieldOperation::execute::(acc(1), id, 500u128, commitment(1), memo_valid()), + execute(acc(1), id, 500u128, commitment(1), memo_valid()), crate::pallet::Error::::AssetNotVerified ); }); @@ -154,7 +242,7 @@ mod tests { assert!(zero.is_canonical(), "zero is canonical — that is the trap"); assert_noop!( - ShieldOperation::execute::(acc(1), asset_id, 500u128, zero, memo_valid()), + execute(acc(1), asset_id, 500u128, zero, memo_valid()), crate::pallet::Error::::InvalidPublicSignals ); }); @@ -191,7 +279,7 @@ mod tests { "the pair must reduce to one element, or this proves nothing" ); - assert_ok!(ShieldOperation::execute::( + assert_ok!(execute( acc(1), asset_id, 500u128, @@ -199,7 +287,7 @@ mod tests { memo_valid(), )); assert_noop!( - ShieldOperation::execute::( + execute( acc(1), asset_id, 500u128, @@ -216,13 +304,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); assert_noop!( - ShieldOperation::execute::( - acc(1), - asset_id, - 0u128, - commitment(1), - memo_valid() - ), + execute(acc(1), asset_id, 0u128, commitment(1), memo_valid()), crate::pallet::Error::::InvalidAmount ); }); @@ -233,7 +315,7 @@ mod tests { fn execute_accepts_smallest_non_zero_amount() { new_test_ext().execute_with(|| { let asset_id = setup_asset(); - assert_ok!(ShieldOperation::execute::( + assert_ok!(execute( acc(1), asset_id, 1u128, @@ -248,13 +330,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); assert_noop!( - ShieldOperation::execute::( - acc(1), - asset_id, - 500u128, - commitment(1), - short_memo() - ), + execute(acc(1), asset_id, 500u128, commitment(1), short_memo()), crate::pallet::Error::::InvalidMemoSize ); }); @@ -265,7 +341,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); let before = PoolBalanceRepository::get_asset_balance::(asset_id); - assert_ok!(ShieldOperation::execute::( + assert_ok!(execute( acc(1), asset_id, 500u128, @@ -282,13 +358,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); let c = commitment(0x03); - assert_ok!(ShieldOperation::execute::( - acc(1), - asset_id, - 200u128, - c, - memo_valid(), - )); + assert_ok!(execute(acc(1), asset_id, 200u128, c, memo_valid(),)); assert!(CommitmentRepository::exists::(&c)); }); } @@ -298,13 +368,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); let c = commitment(0x04); - assert_ok!(ShieldOperation::execute::( - acc(1), - asset_id, - 300u128, - c, - memo_valid(), - )); + assert_ok!(execute(acc(1), asset_id, 300u128, c, memo_valid(),)); let events = frame_system::Pallet::::events(); let found = events.iter().any(|r| { matches!( @@ -330,7 +394,7 @@ mod tests { let balance_before = as frame_support::traits::Currency>::free_balance(&sender); - assert_ok!(ShieldOperation::execute::( + assert_ok!(execute( sender.clone(), asset_id, 1_000u128, @@ -365,13 +429,7 @@ mod tests { new_test_ext().execute_with(|| { let asset_id = setup_asset(); let c = commitment(0xBB); - assert_ok!(ShieldOperation::execute::( - acc(1), - asset_id, - 200u128, - c, - memo_valid(), - )); + assert_ok!(execute(acc(1), asset_id, 200u128, c, memo_valid(),)); assert!(ShieldOperation::commitment_exists::(&c)); }); } @@ -408,21 +466,147 @@ mod tests { let asset_id = setup_asset(); let c = commitment(0xDE); - assert_ok!(ShieldOperation::execute::( - acc(1), - asset_id, - 500u128, - c, - memo_valid(), - )); + assert_ok!(execute(acc(1), asset_id, 500u128, c, memo_valid(),)); assert_noop!( - ShieldOperation::execute::(acc(1), asset_id, 500u128, c, memo_valid()), + execute(acc(1), asset_id, 500u128, c, memo_valid()), crate::pallet::Error::::CommitmentAlreadyExists ); }); } + // ── proof ──────────────────────────────────────────────────────────────── + + /// The verifier is asked about the deposit as the call states it: the + /// inserted commitment, the amount actually transferred and its asset. + #[cfg(not(feature = "skip-proof-verification"))] + #[test] + fn the_proof_is_checked_against_the_deposited_amount_and_asset() { + use crate::mock::{VerifiedStatement, verified_statements}; + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + let c = commitment(0x07); + assert_ok!(execute(acc(1), asset_id, 750u128, c, memo_valid())); + assert_eq!( + verified_statements(), + vec![VerifiedStatement::Shield( + ShieldStatement { + commitment: c.0, + value: 750, + asset_id + }, + Some(1), + )] + ); + }); + } + + /// A commitment worth more than the deposit fails the proof, and nothing moves: + /// no transfer, no leaf, no ledger credit. + #[cfg(not(feature = "skip-proof-verification"))] + #[test] + fn an_invalid_proof_moves_no_funds() { + use crate::mock::set_mock_proof_valid; + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + set_mock_proof_valid(false); + assert_noop!( + execute(acc(1), asset_id, 1u128, commitment(0x08), memo_valid()), + crate::pallet::Error::::ProofVerificationFailed + ); + }); + } + + #[cfg(not(feature = "skip-proof-verification"))] + #[test] + fn an_empty_proof_is_rejected() { + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + assert!( + ShieldOperation::execute::( + acc(1), + &[], + request(asset_id, 500u128, commitment(0x09), memo_valid()), + ) + .is_err() + ); + assert!(!CommitmentRepository::exists::(&commitment(0x09))); + }); + } + + /// Each batch entry is proved on its own: its commitment, amount, asset and + /// version, in order. + #[cfg(not(feature = "skip-proof-verification"))] + #[test] + fn shield_batch_checks_each_entry_against_its_own_deposit() { + use crate::mock::{RuntimeOrigin, ShieldedPool, VerifiedStatement, verified_statements}; + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + let entry = |c: u8, amount: u128, version: u32| { + ( + asset_id, + amount, + commitment(c), + memo_valid(), + frame_support::BoundedVec::try_from(vec![0x01; 128]).unwrap(), + version, + ) + }; + let batch = + frame_support::BoundedVec::try_from(vec![entry(0x0C, 100, 1), entry(0x0D, 250, 2)]) + .unwrap(); + assert_ok!(ShieldedPool::shield_batch( + RuntimeOrigin::signed(acc(1)), + batch + )); + let deposit = |c: u8, value: u128, version: u32| { + VerifiedStatement::Shield( + ShieldStatement { + commitment: commitment(c).0, + value, + asset_id, + }, + Some(version), + ) + }; + assert_eq!( + verified_statements(), + vec![deposit(0x0C, 100, 1), deposit(0x0D, 250, 2)] + ); + }); + } + + /// One unprovable entry reverts the whole batch, the valid ones included. + #[cfg(not(feature = "skip-proof-verification"))] + #[test] + fn shield_batch_with_one_bad_proof_reverts_everything() { + use crate::mock::{RuntimeOrigin, ShieldedPool}; + new_test_ext().execute_with(|| { + let asset_id = setup_asset(); + let entry = |c: u8, proof: &[u8]| { + ( + asset_id, + 500u128, + commitment(c), + memo_valid(), + frame_support::BoundedVec::try_from(proof.to_vec()).unwrap(), + 1u32, + ) + }; + let batch = frame_support::BoundedVec::try_from(vec![ + entry(0x0A, &[0x01; 128]), + entry(0x0B, &[]), + ]) + .unwrap(); + assert!(ShieldedPool::shield_batch(RuntimeOrigin::signed(acc(1)), batch).is_err()); + assert!(!CommitmentRepository::exists::(&commitment(0x0A))); + assert_eq!( + PoolBalanceRepository::get_asset_balance::(asset_id), + 0 + ); + }); + } + // ── batch guard ────────────────────────────────────────────────────────── /// An empty `shield_batch` is rejected instead of dispatching at zero weight. diff --git a/frame/shielded-pool/src/operations/unshield.rs b/frame/shielded-pool/src/operations/unshield.rs index c831edd2..377e7455 100644 --- a/frame/shielded-pool/src/operations/unshield.rs +++ b/frame/shielded-pool/src/operations/unshield.rs @@ -2,12 +2,9 @@ use crate::{ merkle::MerkleTreeService, - operations::{ensure_valid_proof, fees, statement}, + operations::{assets::AssetOperation, ensure_valid_proof, fees, statement}, pallet::{BalanceOf, Config, Error, Event, Pallet}, - storage::{ - AssetRepository, CommitmentRepository, MerkleRepository, NullifierRepository, - PoolBalanceRepository, - }, + storage::{CommitmentRepository, MerkleRepository, NullifierRepository, PoolBalanceRepository}, types::{Commitment, EncryptedMemo, Nullifier}, }; use frame_support::{ @@ -136,9 +133,7 @@ impl UnshieldOperation { }; ensure!(memo_ok, Error::::InvalidMemoSize); - let asset = - AssetRepository::get_asset::(req.asset_id).ok_or(Error::::InvalidAssetId)?; - ensure!(asset.is_verified, Error::::AssetNotVerified); + AssetOperation::ensure_movable::(req.asset_id)?; // The zero account has no known key: paying it burns the withdrawal. ensure!( req.recipient != Pallet::::pool_account_id() diff --git a/frame/shielded-pool/src/tests/fees.rs b/frame/shielded-pool/src/tests/fees.rs index 2c19509d..2d3ba858 100644 --- a/frame/shielded-pool/src/tests/fees.rs +++ b/frame/shielded-pool/src/tests/fees.rs @@ -488,9 +488,10 @@ fn claimant_and_payee_follow_the_caller() { #[test] fn claims_of_a_frozen_asset_are_refused() { new_test_ext().execute_with(|| { - let asset_id = register_asset(); + let asset_id = setup_asset(); fund_pool(asset_id, 500); mock_pending_fees_set(acc(7), asset_id, 100); + AssetOperation::unverify::(asset_id).unwrap(); assert_noop!( FeeOperation::claim::(acc(7), acc(7), asset_id, 10), Error::::AssetNotVerified @@ -499,3 +500,18 @@ fn claims_of_a_frozen_asset_are_refused() { assert_ok!(FeeOperation::claim::(acc(7), acc(7), asset_id, 10)); }); } + +/// A verified non-native asset is not backed: claiming it would pay native funds. +#[test] +fn claims_of_a_non_native_asset_are_refused() { + new_test_ext().execute_with(|| { + let asset_id = register_asset(); + AssetOperation::verify::(asset_id).unwrap(); + fund_pool(asset_id, 500); + mock_pending_fees_set(acc(7), asset_id, 100); + assert_noop!( + FeeOperation::claim::(acc(7), acc(7), asset_id, 10), + Error::::AssetNotSupported + ); + }); +} diff --git a/frame/shielded-pool/src/tests/mod.rs b/frame/shielded-pool/src/tests/mod.rs index 0ecd768d..792fa7a7 100644 --- a/frame/shielded-pool/src/tests/mod.rs +++ b/frame/shielded-pool/src/tests/mod.rs @@ -96,11 +96,9 @@ pub(crate) fn register_asset() -> u32 { AssetOperation::register_asset::(name, symbol, 18, None, acc(1)).unwrap() } -/// Register and verify an asset, returning its id. +/// The asset pool operations can move: the native one, verified at genesis. pub(crate) fn setup_asset() -> u32 { - let id = register_asset(); - AssetOperation::verify::(id).unwrap(); - id + crate::types::NATIVE_ASSET_ID } /// Fund the pool both physically and in the ledger, as a shield would. diff --git a/frame/shielded-pool/src/tests/private_transfer.rs b/frame/shielded-pool/src/tests/private_transfer.rs index 04af5a28..e997a2a6 100644 --- a/frame/shielded-pool/src/tests/private_transfer.rs +++ b/frame/shielded-pool/src/tests/private_transfer.rs @@ -1316,3 +1316,23 @@ fn an_invalid_proof_fails_at_dispatch_and_changes_nothing() { ); }); } + +/// A verified non-native asset is not backed, so its notes cannot move. +#[test] +fn transfer_of_a_non_native_asset_is_refused() { + new_test_ext().execute_with(|| { + MerkleRepository::add_historic_poseidon_root::(KNOWN_ROOT); + let id = crate::tests::register_asset(); + crate::operations::assets::AssetOperation::verify::(id).unwrap(); + assert_noop!( + PrivateTransferOperation::execute::( + &proof(), + TransferRequest { + asset_id: id, + ..request([0x72, 0x74]) + }, + ), + Error::::AssetNotSupported + ); + }); +} diff --git a/frame/shielded-pool/src/tests/unshield.rs b/frame/shielded-pool/src/tests/unshield.rs index cf9c6b1c..14143358 100644 --- a/frame/shielded-pool/src/tests/unshield.rs +++ b/frame/shielded-pool/src/tests/unshield.rs @@ -664,7 +664,10 @@ fn unshield_guard_requires_amount_plus_fee() { /// shield → unshield(with fee) → claim_relay_fees (fee paid out publicly). #[test] fn fee_lifecycle_preserves_ledger_invariant() { - use crate::operations::{fees::FeeOperation, shield::ShieldOperation}; + use crate::operations::{ + fees::FeeOperation, + shield::{ShieldOperation, ShieldRequest}, + }; new_test_ext().execute_with(|| { let asset_id = setup_asset(); @@ -678,10 +681,14 @@ fn fee_lifecycle_preserves_ledger_invariant() { // Step 1: shield 1000. Ledger and physical both +1000. assert_ok!(ShieldOperation::execute::( depositor, - asset_id, - 1000u128, - Commitment::new(canonical_bytes(0x31)), - memo(0x00), + &proof(), + ShieldRequest { + asset_id, + amount: 1000u128, + commitment: Commitment::new(canonical_bytes(0x31)), + encrypted_memo: memo(0x00), + circuit_version: 1, + }, )); assert_eq!(tracked(asset_id), pool_physical()); assert_eq!(tracked(asset_id), 1000); @@ -1174,3 +1181,18 @@ fn an_invalid_proof_fails_at_dispatch_and_changes_nothing() { ); }); } + +/// A verified non-native asset would be withdrawn as native funds. +#[test] +fn unshield_of_a_non_native_asset_is_refused() { + new_test_ext().execute_with(|| { + setup_spend(); + let id = register_asset(); + AssetOperation::verify::(id).unwrap(); + fund_pool(id, AMOUNT + FEE); + assert_noop!( + UnshieldOperation::execute::(&proof(), request(0x71, id)), + Error::::AssetNotSupported + ); + }); +} diff --git a/frame/shielded-pool/src/types/asset.rs b/frame/shielded-pool/src/types/asset.rs index 19bb02e3..5b19e84a 100644 --- a/frame/shielded-pool/src/types/asset.rs +++ b/frame/shielded-pool/src/types/asset.rs @@ -2,12 +2,18 @@ //! //! An asset must be both registered and verified before it can be shielded or //! unshielded; `is_verified` doubles as a governance kill-switch for an asset -//! found to be compromised. +//! found to be compromised. Only the native asset is backed today: see +//! [`NATIVE_ASSET_ID`]. use frame_support::{BoundedVec, pallet_prelude::*}; use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; use scale_info::TypeInfo; +/// The native asset, registered and verified at genesis. Every transfer into or +/// out of the pool moves `T::Currency`, so this is the only asset whose notes +/// are backed by what they claim. +pub const NATIVE_ASSET_ID: u32 = 0; + // AssetMetadata /// Asset metadata for multi-asset shielded pool. diff --git a/frame/shielded-pool/src/types/mod.rs b/frame/shielded-pool/src/types/mod.rs index b3e6f075..a1528679 100644 --- a/frame/shielded-pool/src/types/mod.rs +++ b/frame/shielded-pool/src/types/mod.rs @@ -8,18 +8,21 @@ //! - [`merkle`] — Merkle path plus the tree-depth constants. //! - [`memo`] — the fixed-size encrypted memo. //! - [`asset`] — registered asset metadata. +//! - [`proof`] — the serialized proof a call carries. pub mod asset; pub mod ids; pub mod memo; pub mod merkle; pub mod note; +pub mod proof; -pub use asset::AssetMetadata; +pub use asset::{AssetMetadata, NATIVE_ASSET_ID}; pub use ids::{AssetId, Commitment, Nullifier}; pub use memo::{EncryptedMemo, MAX_ENCRYPTED_MEMO_SIZE}; pub use merkle::{DEFAULT_TREE_DEPTH, DefaultMerklePath, MAX_TREE_DEPTH, MerklePath}; pub use note::Note; +pub use proof::{MAX_PROOF_SIZE, Proof}; /// A 32-byte hash used for Merkle roots, cryptographic hashes and identifiers. /// diff --git a/frame/shielded-pool/src/types/proof.rs b/frame/shielded-pool/src/types/proof.rs new file mode 100644 index 00000000..c2569748 --- /dev/null +++ b/frame/shielded-pool/src/types/proof.rs @@ -0,0 +1,10 @@ +//! The serialized Groth16 proof a pool call carries. + +use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + +/// Maximum byte length of a serialized proof. A compressed BN254 Groth16 proof +/// is 128 bytes; the bound leaves room without admitting unbounded input. +pub const MAX_PROOF_SIZE: u32 = 512; + +/// A proof as a call carries it. +pub type Proof = BoundedVec>; diff --git a/frame/zk-verifier/CHANGELOG.md b/frame/zk-verifier/CHANGELOG.md index 7f0f43a6..cd566ba3 100644 --- a/frame/zk-verifier/CHANGELOG.md +++ b/frame/zk-verifier/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this pallet are documented here. --- +## [Unreleased] + +## [0.14.0] - 2026-10-05 + +### Added + +- `CircuitId::SHIELD` (3), `ShieldStatement` and + `ZkVerifierPort::verify_shield_proof`. Inputs are `commitment | value | asset_id`, + `value` as a `u128` field element. + +### Changed + +- **Registration's base-layout rule applies to spend circuits only.** A shield key + takes its 3-input arity at every version; a 4-input shield key is refused. + ## [0.13.0] - 2026-09-29 ### Added diff --git a/frame/zk-verifier/Cargo.toml b/frame/zk-verifier/Cargo.toml index 464b0336..34c5746d 100644 --- a/frame/zk-verifier/Cargo.toml +++ b/frame/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-zk-verifier" -version = "0.13.0" +version = "0.14.0" description = "Zero-Knowledge proof verification pallet for Orbinum" authors = ["Orbinum Team"] license = "GPL-3.0-or-later" diff --git a/frame/zk-verifier/src/encoding.rs b/frame/zk-verifier/src/encoding.rs index 115b1bb3..d60fcade 100644 --- a/frame/zk-verifier/src/encoding.rs +++ b/frame/zk-verifier/src/encoding.rs @@ -6,7 +6,7 @@ //! [`InputLayout`] comes from the key being verified against, so a v1 and a //! memo-bound key each get the inputs they were built for. -use crate::port::{TransferStatement, UnshieldStatement}; +use crate::port::{ShieldStatement, TransferStatement, UnshieldStatement}; use alloc::vec::Vec; use orbinum_zk_verifier::{InputLayout, to_field_le}; @@ -53,6 +53,11 @@ pub fn encode_unshield(s: &UnshieldStatement, layout: InputLayout) -> Vec<[u8; 3 raw } +/// Shield inputs, in circuit order: `commitment | value | asset_id`. +pub fn encode_shield(s: &ShieldStatement) -> Vec<[u8; 32]> { + alloc::vec![s.commitment, u128_field(s.value), u32_field(s.asset_id)] +} + fn u32_field(v: u32) -> [u8; 32] { let mut out = [0u8; 32]; out[..4].copy_from_slice(&v.to_le_bytes()); @@ -246,4 +251,117 @@ mod tests { ); } } + + #[test] + fn shield_encodes_commitment_value_and_asset_in_circuit_order() { + let s = ShieldStatement { + commitment: [0x09; 32], + value: u128::MAX, + asset_id: 7, + }; + let raw = encode_shield(&s); + assert_eq!(raw.len(), orbinum_zk_verifier::SHIELD_PUBLIC_INPUTS); + assert_eq!(raw[0], [0x09; 32]); + // u128 little-endian in the low 16 bytes; the high half stays zero. + assert_eq!(raw[1][..16], [0xFF; 16]); + assert_eq!(raw[1][16..], [0; 16]); + assert_eq!(raw[2], u32_field(7)); + } + + // ── shield against a real proof ─────────────────────────────────────────── + + /// A real shield proof of `@orbinum/circuits` 0.16.0 (`fixtures/shield.input.json`: + /// a 1000-unit note of asset 0), checked through `encode_shield` against the + /// published key (`vk_hash 0x4835d34f…5cb7`). The pallet's own verifier is + /// stubbed under `cfg(test)`, so this is where the encoding meets the circuit. + mod shield_real_proof { + use super::*; + use orbinum_zk_verifier::{ + Groth16Verifier, PublicInputs, SnarkjsProofPoints, VerifyingKey, + parse_proof_from_snarkjs, + }; + + const VK: &[u8] = include_bytes!("test_fixtures/shield_vk.bin"); + + /// `Poseidon4(1000, 0, ownerPk, blinding)`, little-endian. + const COMMITMENT: [u8; 32] = [ + 0x69, 0x75, 0xce, 0xc7, 0x9d, 0xcd, 0x25, 0x7a, 0xc6, 0x99, 0x18, 0x2b, 0x9f, 0xc6, + 0x2d, 0xf6, 0x92, 0x3e, 0x50, 0xc7, 0x51, 0x82, 0xff, 0x0b, 0x69, 0x1f, 0xb1, 0x8c, + 0x16, 0xe2, 0x88, 0x17, + ]; + + fn verifies(statement: ShieldStatement) -> bool { + let proof = parse_proof_from_snarkjs(SnarkjsProofPoints { + a_x: "20582419949357486020948756662654742944819106688442895228932566507097686181492", + a_y: "11046279096408876553973745195746033188489133537652590673138219726350432190690", + b_x0: "838573338912504267236782093804520743622125922969862721536857124431906106618", + b_x1: "2429257530267642355656915667588525092022338138648113768996919794768393138344", + b_y0: "8916514610009504152135293951898251181004123961747103831149158072089283668368", + b_y1: "19652615522895145510932891878583235492762167007808681662006766972958083269857", + c_x: "21486029233340201890864616292482025659316043756526048371921004920306878820982", + c_y: "10093061124683382138290144938555180922737004347330625633977406280723892224584", + }) + .unwrap(); + let inputs = PublicInputs::new(encode_shield(&statement)); + Groth16Verifier::verify(&VerifyingKey::new(VK.to_vec()), &inputs, &proof).is_ok() + } + + fn deposit(value: u128, asset_id: u32) -> ShieldStatement { + ShieldStatement { + commitment: COMMITMENT, + value, + asset_id, + } + } + + #[test] + fn verifies_the_deposit_the_note_encodes() { + assert!(verifies(deposit(1000, 0))); + } + + #[test] + fn rejects_a_smaller_deposit_for_the_same_note() { + assert!(!verifies(deposit(1, 0))); + } + + #[test] + fn rejects_another_asset() { + assert!(!verifies(deposit(1000, 5))); + } + + /// The same field element, as `commitment + r`: a verifier that reduced + /// inputs mod r would accept it, and the commitment would then have two + /// encodings. The pallet refuses it as non-canonical before verifying; + /// this shows the verifier refuses it too. + #[test] + fn rejects_a_non_canonical_encoding_of_the_commitment() { + // BN254 scalar modulus r, little-endian. + const R: [u8; 32] = [ + 0x01, 0x00, 0x00, 0xf0, 0x93, 0xf5, 0xe1, 0x43, 0x91, 0x70, 0xb9, 0x79, 0x48, 0xe8, + 0x33, 0x28, 0x5d, 0x58, 0x81, 0x81, 0xb6, 0x45, 0x50, 0xb8, 0x29, 0xa0, 0x31, 0xe1, + 0x72, 0x4e, 0x64, 0x30, + ]; + let mut shifted = [0u8; 32]; + let mut carry = 0u16; + for i in 0..32 { + let sum = COMMITMENT[i] as u16 + R[i] as u16 + carry; + shifted[i] = sum as u8; + carry = sum >> 8; + } + assert_eq!(carry, 0, "commitment + r fits 256 bits"); + assert_eq!(to_field_le(&shifted), COMMITMENT, "same field element"); + assert!(!verifies(ShieldStatement { + commitment: shifted, + value: 1000, + asset_id: 0 + })); + } + + #[test] + fn rejects_another_commitment() { + let mut other = deposit(1000, 0); + other.commitment[0] ^= 1; + assert!(!verifies(other)); + } + } } diff --git a/frame/zk-verifier/src/lib.rs b/frame/zk-verifier/src/lib.rs index 07385cc7..987019b3 100644 --- a/frame/zk-verifier/src/lib.rs +++ b/frame/zk-verifier/src/lib.rs @@ -43,7 +43,7 @@ mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; -pub use port::{TransferStatement, UnshieldStatement, ZkVerifierPort}; +pub use port::{ShieldStatement, TransferStatement, UnshieldStatement, ZkVerifierPort}; pub use types::{ CircuitId, CircuitVersionInfo, ProofSystem, VerificationKeyInfo, VerificationStatistics, VkBytes, VkEntry, VkVersionHash, @@ -605,10 +605,11 @@ pub mod pallet { /// Verify the VK deserializes as a BN254 Groth16 key and that its arity /// fits one of the circuit's input layouts (base or memo-bound). /// - /// For a known circuit, only [`Self::FIRST_VERSION`] may use the base + /// For a spend circuit, only [`Self::FIRST_VERSION`] may use the base /// layout: every later version must bind its memos and the full recipient. /// That keeps a v1 key from being registered again as "v2" — a rotation - /// that would retire nothing but the version number. + /// that would retire nothing but the version number. Shield has one layout + /// at every version. /// /// Only ids in the known table carry an expected arity; the rest are /// checked to deserialize and nothing more. An id that does not fit a @@ -616,9 +617,7 @@ pub mod pallet { /// 257 onto 1 and silently validate a key against the wrong circuit's /// arity. `purge_circuit` guards the same lookup the same way. fn ensure_vk_arity(circuit_id: CircuitId, version: u32, key_data: &[u8]) -> DispatchResult { - use orbinum_zk_verifier::{ - InputLayout, VerifyingKey, expected_public_inputs, input_layout, - }; + use orbinum_zk_verifier::{InputLayout, VerifyingKey, has_memo_layout, input_layout}; let id = u8::try_from(circuit_id.0).map_err(|_| Error::::InvalidVerificationKey)?; @@ -628,9 +627,10 @@ pub mod pallet { .map_err(|_| Error::::InvalidVerificationKey)?; let layout = input_layout(id, arity).ok_or(Error::::InvalidVerificationKey)?; - let known = expected_public_inputs(id).is_some(); ensure!( - !(known && version != Self::FIRST_VERSION && layout == InputLayout::Base), + !(has_memo_layout(id) + && version != Self::FIRST_VERSION + && layout == InputLayout::Base), Error::::InvalidVerificationKey ); Ok(()) diff --git a/frame/zk-verifier/src/port.rs b/frame/zk-verifier/src/port.rs index 8a3d903c..8cef6bd5 100644 --- a/frame/zk-verifier/src/port.rs +++ b/frame/zk-verifier/src/port.rs @@ -45,6 +45,15 @@ pub struct UnshieldStatement { pub memo_digest: [u8; 32], } +/// What a shield proof attests to: the inserted commitment opens to the +/// deposited value and asset. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct ShieldStatement { + pub commitment: [u8; 32], + pub value: u128, + pub asset_id: u32, +} + // ─── Trait ──────────────────────────────────────────────────────────────────── /// Cross-pallet interface for zero-knowledge proof verification. @@ -66,6 +75,13 @@ pub trait ZkVerifierPort { version: Option, ) -> Result; + /// Verify a shield (pool deposit) proof. + fn verify_shield_proof( + proof: &[u8], + statement: &ShieldStatement, + version: Option, + ) -> Result; + /// Whether `version` is registered for `circuit_id` and not retired. fn is_supported_version(circuit_id: u32, version: u32) -> bool; @@ -116,6 +132,18 @@ impl ZkVerifierPort for Pallet { .map(|(ok, _)| ok) } + fn verify_shield_proof( + proof: &[u8], + statement: &ShieldStatement, + version: Option, + ) -> Result { + // Shield has a single layout; a key of another arity fails the length check. + verifier::verify_statement::(CircuitId::SHIELD, version, proof, |_| { + encoding::encode_shield(statement) + }) + .map(|(ok, _)| ok) + } + fn is_supported_version(circuit_id: u32, version: u32) -> bool { let cid = CircuitId(circuit_id); VerificationKeys::::contains_key(cid, version) @@ -135,7 +163,9 @@ mod tests { use super::*; use crate::mock::{Test, activate, insert_vk, new_test_ext}; use frame_support::assert_err; - use orbinum_zk_verifier::{MEMO_HASH_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS}; + use orbinum_zk_verifier::{ + MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, + }; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -184,6 +214,21 @@ mod tests { as ZkVerifierPort>::verify_unshield_proof(&proof(), s, version) } + fn shield() -> ShieldStatement { + ShieldStatement { + commitment: [0x05; 32], + value: 1000, + asset_id: 0, + } + } + + fn verify_shield( + s: &ShieldStatement, + version: Option, + ) -> Result { + as ZkVerifierPort>::verify_shield_proof(&proof(), s, version) + } + // ── verify_transfer_proof ───────────────────────────────────────────────── #[test] @@ -334,6 +379,71 @@ mod tests { }); } + // ── verify_shield_proof ─────────────────────────────────────────────────── + + #[test] + fn shield_empty_proof_is_rejected() { + new_test_ext().execute_with(|| { + assert_err!( + as ZkVerifierPort>::verify_shield_proof(&[], &shield(), Some(1)), + Error::::EmptyProof + ); + }); + } + + #[test] + fn shield_without_an_active_version_is_circuit_not_found() { + new_test_ext().execute_with(|| { + assert_err!( + verify_shield(&shield(), None), + Error::::CircuitNotFound + ); + }); + } + + #[test] + fn shield_verifies_under_its_three_input_key() { + new_test_ext().execute_with(|| { + insert_vk(CircuitId::SHIELD, 1, SHIELD_PUBLIC_INPUTS); + activate(CircuitId::SHIELD, 1); + assert_eq!(verify_shield(&shield(), None), Ok(true)); + }); + } + + #[test] + fn shield_under_a_key_of_foreign_arity_fails() { + new_test_ext().execute_with(|| { + insert_vk( + CircuitId::SHIELD, + 1, + SHIELD_PUBLIC_INPUTS + MEMO_HASH_INPUTS, + ); + activate(CircuitId::SHIELD, 1); + assert_eq!(verify_shield(&shield(), None), Ok(false)); + }); + } + + #[test] + fn shield_under_a_retired_or_unregistered_version_is_unsupported() { + new_test_ext().execute_with(|| { + insert_vk(CircuitId::SHIELD, 1, SHIELD_PUBLIC_INPUTS); + insert_vk(CircuitId::SHIELD, 2, SHIELD_PUBLIC_INPUTS); + activate(CircuitId::SHIELD, 2); + RetiredVersions::::insert(CircuitId::SHIELD, 1, ()); + + // A retired version does not fall back to the active one. + assert_err!( + verify_shield(&shield(), Some(1)), + Error::::UnsupportedCircuitVersion + ); + assert_err!( + verify_shield(&shield(), Some(3)), + Error::::UnsupportedCircuitVersion + ); + assert_eq!(verify_shield(&shield(), Some(2)), Ok(true)); + }); + } + // ── is_supported_version ────────────────────────────────────────────────── #[test] diff --git a/frame/zk-verifier/src/test_fixtures/shield_vk.bin b/frame/zk-verifier/src/test_fixtures/shield_vk.bin new file mode 100644 index 00000000..f817390f Binary files /dev/null and b/frame/zk-verifier/src/test_fixtures/shield_vk.bin differ diff --git a/frame/zk-verifier/src/tests.rs b/frame/zk-verifier/src/tests.rs index 684ebbaf..b65c4461 100644 --- a/frame/zk-verifier/src/tests.rs +++ b/frame/zk-verifier/src/tests.rs @@ -12,7 +12,9 @@ use crate::{ types::VkEntry, }; use frame_support::{BoundedVec, assert_err, assert_noop, assert_ok, traits::ConstU32}; -use orbinum_zk_verifier::{MEMO_HASH_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS}; +use orbinum_zk_verifier::{ + MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, +}; use sp_io::TestExternalities; use sp_runtime::{BuildStorage, DispatchResult}; @@ -54,11 +56,16 @@ fn vk_empty() -> VkBytes { } /// A key registration accepts for `(circuit_id, version)`: the base layout for -/// the first version, the memo-bound one for any later version of a known circuit. +/// the first version, the memo-bound one for any later version of a spend circuit. fn vk_for(circuit_id: CircuitId, version: u32) -> VkBytes { - let base = orbinum_zk_verifier::expected_public_inputs(circuit_id.0 as u8); - let arity = match base { - Some(base) if version > Pallet::::FIRST_VERSION => base + MEMO_HASH_INPUTS, + let id = circuit_id.0 as u8; + let arity = match orbinum_zk_verifier::expected_public_inputs(id) { + Some(base) + if version > Pallet::::FIRST_VERSION + && orbinum_zk_verifier::has_memo_layout(id) => + { + base + MEMO_HASH_INPUTS + } Some(base) => base, None => TRANSFER_PUBLIC_INPUTS, }; @@ -205,6 +212,34 @@ fn register_vk_refuses_the_base_layout_past_version_one() { }); } +/// Shield has no memo-bound layout: every version takes the base arity, and one +/// input more — memo-bound for a spend circuit — is refused. +#[test] +fn register_vk_takes_shield_at_its_base_arity_for_every_version() { + new_test_ext().execute_with(|| { + assert_ok!(register( + CircuitId::SHIELD, + 1, + real_vk(SHIELD_PUBLIC_INPUTS) + )); + assert_ok!(register( + CircuitId::SHIELD, + 2, + real_vk(SHIELD_PUBLIC_INPUTS) + )); + for version in [1, 3] { + assert_noop!( + register( + CircuitId::SHIELD, + version, + real_vk(SHIELD_PUBLIC_INPUTS + MEMO_HASH_INPUTS) + ), + Error::::InvalidVerificationKey + ); + } + }); +} + /// The v1 → v2 rotation, done by Root extrinsics after the upgrade: register /// the memo-bound keys as version 2, make them active, retire version 1. #[test] diff --git a/frame/zk-verifier/src/types.rs b/frame/zk-verifier/src/types.rs index 4cee6cdc..579121d0 100644 --- a/frame/zk-verifier/src/types.rs +++ b/frame/zk-verifier/src/types.rs @@ -30,6 +30,8 @@ impl CircuitId { pub const TRANSFER: Self = Self(1); /// Unshield circuit ID pub const UNSHIELD: Self = Self(2); + /// Shield circuit ID + pub const SHIELD: Self = Self(3); } /// Supported proof systems diff --git a/primitives/zk-verifier/CHANGELOG.md b/primitives/zk-verifier/CHANGELOG.md index 760783ae..e94bec22 100644 --- a/primitives/zk-verifier/CHANGELOG.md +++ b/primitives/zk-verifier/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this crate are documented here. --- +## [Unreleased] + +## [2.1.0] - 2026-10-05 + +### Added + +- `CIRCUIT_ID_SHIELD` (3) and `SHIELD_PUBLIC_INPUTS` (3): the shield circuit is + known, with arity `commitment, value, asset_id`. +- `has_memo_layout(circuit_id)`: only transfer and unshield have a memo-bound + layout. + +### Changed + +- `input_layout` reads base + `MEMO_HASH_INPUTS` as `MemoBound` only for a circuit + with a memo-bound layout; for shield that arity is unusable. + ## [2.0.0] - 2026-09-29 ### Added diff --git a/primitives/zk-verifier/Cargo.toml b/primitives/zk-verifier/Cargo.toml index f3c1d029..ed8b0125 100644 --- a/primitives/zk-verifier/Cargo.toml +++ b/primitives/zk-verifier/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-zk-verifier" -version = "2.0.0" +version = "2.1.0" authors = ["Orbinum Network "] edition = "2021" publish = false diff --git a/primitives/zk-verifier/src/circuits.rs b/primitives/zk-verifier/src/circuits.rs index 9b2d7b57..6125fcdf 100644 --- a/primitives/zk-verifier/src/circuits.rs +++ b/primitives/zk-verifier/src/circuits.rs @@ -7,6 +7,8 @@ pub const CIRCUIT_ID_TRANSFER: u8 = 1; /// Circuit identifier for unshield (withdraw) operations. pub const CIRCUIT_ID_UNSHIELD: u8 = 2; +/// Circuit identifier for shield (deposit) operations. +pub const CIRCUIT_ID_SHIELD: u8 = 3; /// Number of public inputs for the transfer circuit. /// Public inputs: [merkle_root, nullifier1, nullifier2, commitment1, commitment2, asset_id, fee] @@ -14,6 +16,9 @@ pub const TRANSFER_PUBLIC_INPUTS: usize = 7; /// Number of public inputs for the unshield circuit. /// 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 shield circuit. +/// Public inputs: [commitment, value, asset_id] +pub const SHIELD_PUBLIC_INPUTS: usize = 3; /// Public inputs a memo-bound version adds to its circuit's base layout: /// `memo_hash`, appended last. @@ -28,10 +33,17 @@ pub const fn expected_public_inputs(circuit_id: u8) -> Option { match circuit_id { CIRCUIT_ID_TRANSFER => Some(TRANSFER_PUBLIC_INPUTS), CIRCUIT_ID_UNSHIELD => Some(UNSHIELD_PUBLIC_INPUTS), + CIRCUIT_ID_SHIELD => Some(SHIELD_PUBLIC_INPUTS), _ => None, } } +/// Whether `circuit_id` has a memo-bound layout: only the spend circuits carry +/// memos. A shield is a signed deposit, so its memo needs no binding. +pub const fn has_memo_layout(circuit_id: u8) -> bool { + matches!(circuit_id, CIRCUIT_ID_TRANSFER | CIRCUIT_ID_UNSHIELD) +} + /// How a circuit version lays out its public inputs, read off its key's arity. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum InputLayout { @@ -43,13 +55,15 @@ pub enum InputLayout { } /// The layout a key of `arity` public inputs implies for `circuit_id`, or `None` -/// when the arity fits neither. Unknown ids carry no expected arity: any key is +/// when the arity fits none. Unknown ids carry no expected arity: any key is /// taken as [`InputLayout::Base`]. pub const fn input_layout(circuit_id: u8, arity: usize) -> Option { match expected_public_inputs(circuit_id) { None => Some(InputLayout::Base), Some(base) if arity == base => Some(InputLayout::Base), - Some(base) if arity == base + MEMO_HASH_INPUTS => Some(InputLayout::MemoBound), + Some(base) if arity == base + MEMO_HASH_INPUTS && has_memo_layout(circuit_id) => { + Some(InputLayout::MemoBound) + } Some(_) => None, } } @@ -65,22 +79,23 @@ mod tests { fn test_circuit_ids_are_stable() { assert_eq!(CIRCUIT_ID_TRANSFER, 1); assert_eq!(CIRCUIT_ID_UNSHIELD, 2); + assert_eq!(CIRCUIT_ID_SHIELD, 3); } #[test] fn test_public_input_counts_are_expected() { assert_eq!(TRANSFER_PUBLIC_INPUTS, 7); assert_eq!(UNSHIELD_PUBLIC_INPUTS, 7); + assert_eq!(SHIELD_PUBLIC_INPUTS, 3); } #[test] fn expected_public_inputs_maps_known_circuits() { assert_eq!(expected_public_inputs(CIRCUIT_ID_TRANSFER), Some(7)); assert_eq!(expected_public_inputs(CIRCUIT_ID_UNSHIELD), Some(7)); + assert_eq!(expected_public_inputs(CIRCUIT_ID_SHIELD), Some(3)); // Circuit 6 (retired value_proof) is unknown, so its keys can be purged. assert_eq!(expected_public_inputs(6), None); - // Unknown / unmapped circuit ids (e.g. shield=3) return None. - assert_eq!(expected_public_inputs(3), None); // Circuit 5 (retired private-link) is unknown: a VK registered under that // id gets no arity check. assert_eq!(expected_public_inputs(5), None); @@ -102,6 +117,15 @@ mod tests { assert_eq!(input_layout(200, 3), Some(InputLayout::Base)); } + #[test] + fn shield_has_only_the_base_layout() { + assert!(!has_memo_layout(CIRCUIT_ID_SHIELD)); + assert_eq!(input_layout(CIRCUIT_ID_SHIELD, 3), Some(InputLayout::Base)); + // One input more is memo-bound for a spend circuit, nothing for shield. + assert_eq!(input_layout(CIRCUIT_ID_SHIELD, 4), None); + assert_eq!(input_layout(CIRCUIT_ID_SHIELD, 2), None); + } + /// Every circuit in use sits far below the cap, so enforcing it cannot break /// a real verification. /// @@ -117,5 +141,9 @@ mod tests { UNSHIELD_PUBLIC_INPUTS * 4 < MAX_PUBLIC_INPUTS, "unshield arity is too close to MAX_PUBLIC_INPUTS" ); + assert!( + SHIELD_PUBLIC_INPUTS * 4 < MAX_PUBLIC_INPUTS, + "shield arity is too close to MAX_PUBLIC_INPUTS" + ); }; } diff --git a/primitives/zk-verifier/src/lib.rs b/primitives/zk-verifier/src/lib.rs index c8578dfb..c041471e 100644 --- a/primitives/zk-verifier/src/lib.rs +++ b/primitives/zk-verifier/src/lib.rs @@ -28,8 +28,9 @@ pub use ark_groth16::PreparedVerifyingKey; // ─── Public API ─────────────────────────────────────────────────────────────── pub use circuits::{ - expected_public_inputs, input_layout, InputLayout, CIRCUIT_ID_TRANSFER, CIRCUIT_ID_UNSHIELD, - MEMO_HASH_INPUTS, TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, + expected_public_inputs, has_memo_layout, input_layout, InputLayout, CIRCUIT_ID_SHIELD, + CIRCUIT_ID_TRANSFER, CIRCUIT_ID_UNSHIELD, MEMO_HASH_INPUTS, SHIELD_PUBLIC_INPUTS, + TRANSFER_PUBLIC_INPUTS, UNSHIELD_PUBLIC_INPUTS, }; pub use snarkjs::SnarkjsProofPoints; #[cfg(feature = "std")] diff --git a/scripts/vk/README.md b/scripts/vk/README.md index 9f338010..1dc6d034 100644 --- a/scripts/vk/README.md +++ b/scripts/vk/README.md @@ -10,7 +10,7 @@ This folder centralizes all Verification Key (VK) operations for development and - `set-active` - `remove` - `workflows/setup-dev.sh` - - DEV bootstrap for fixed circuits `1,2`. + - DEV bootstrap: circuits `1,2` at one version, and shield `3` at its own active version. - `workflows/rotate-dev.sh` - Version rotation `old -> new` with RPC validation. The old version is **retired** as soon as the new one is active (the submitter picks the version a spend is verified under, so a live older key lets a copier resubmit under it); `--keep-old` skips that, `--remove-old` also removes it. - `policy/verify-window-dev.sh` @@ -33,6 +33,8 @@ This folder centralizes all Verification Key (VK) operations for development and - `bash scripts/vk/workflows/rotate-dev.sh 2 ws://127.0.0.1:9944 "//Alice" 1 --remove-old` - Verify window: - `bash scripts/vk/policy/verify-window-dev.sh 2 http://127.0.0.1:9944 "1,2" true` +- Add a new circuit (e.g. shield after the spec 17 upgrade), registered and activated: + - `bash scripts/vk/workflows/vk.sh add shield 3 1 wss://rpc-1.testnet.orbinum.io ""` - Purge a retired circuit: - `bash scripts/vk/lib/registry.sh purge 5 ws://127.0.0.1:9944 "//Alice"` diff --git a/scripts/vk/workflows/setup-dev.sh b/scripts/vk/workflows/setup-dev.sh index dd5a0c13..37afc059 100755 --- a/scripts/vk/workflows/setup-dev.sh +++ b/scripts/vk/workflows/setup-dev.sh @@ -5,9 +5,9 @@ set -euo pipefail # scripts/vk/workflows/setup-dev.sh # DEV setup for on-chain VKs (main entrypoint for developers) # -# Registers + activates ONLY these 2 circuits: -# 1 transfer -# 2 unshield +# Registers + activates these circuits: +# 1 transfer, 2 unshield — at the manifest's active version (or the one given) +# 3 shield — at its own active version, when the package ships it # # VK artifacts are resolved from the @orbinum/circuits npm package via unpkg: # 1. Fetches manifest.json to determine the latest package_version @@ -85,8 +85,8 @@ log "Artifact base URL: $CDN_BASE" # The vk_json filename of $VERSION for a circuit, from the manifest: the key # registered must be the one the manifest publishes under that version. get_vk_filename() { - local circuit="$1" - echo "$MANIFEST_JSON" | jq -r ".circuits[\"$circuit\"].versions[\"$VERSION\"].artifacts.vk_json.file" + local circuit="$1" version="${2:-$VERSION}" + echo "$MANIFEST_JSON" | jq -r ".circuits[\"$circuit\"].versions[\"$version\"].artifacts.vk_json.file" } TEMP_DIR="" @@ -94,9 +94,9 @@ TEMP_DIR="" # Resolves a VK JSON path (local artifact or CDN download), then converts it # to arkworks compressed binary format (.bin) required by the on-chain verifier. resolve_vk() { - local circuit="$1" + local circuit="$1" version="${2:-$VERSION}" local filename - filename=$(get_vk_filename "$circuit") + filename=$(get_vk_filename "$circuit" "$version") [[ "$filename" != "null" && -n "$filename" ]] \ || err "Cannot resolve vk_json filename for circuit '$circuit' in manifest" @@ -138,6 +138,17 @@ bash "$VK_REGISTRY_SCRIPT" batch-register \ "$VK_TRANSFER" "$VK_UNSHIELD" \ "$RPC_WS" "$SUDO_SEED" +# Shield versions on its own axis, so it registers at its own active version. The +# first key of a circuit is activated on registration. +SHIELD_VERSION=$(echo "$MANIFEST_JSON" | jq -r '.circuits["shield"].active_version // empty') +if [[ -n "$SHIELD_VERSION" ]]; then + VK_SHIELD=$(resolve_vk "shield" "$SHIELD_VERSION") + log "Registering shield(3) v$SHIELD_VERSION..." + bash "$VK_REGISTRY_SCRIPT" register 3 "$SHIELD_VERSION" "$VK_SHIELD" "$RPC_WS" "$SUDO_SEED" +else + log "WARN: circuits@$PKG_VERSION ships no shield circuit — shield stays unusable on this chain" +fi + # ─── Cleanup ───────────────────────────────────────────────────────────────── if [[ -n "$TEMP_DIR" ]]; then @@ -147,7 +158,7 @@ fi # Remove any .bin files produced from local artifacts (they are generated # artefacts and are excluded from version control via .gitignore) -for circuit in transfer unshield; do +for circuit in transfer unshield shield; do rm -f "$ARTIFACTS_DIR/verification_key_${circuit}.bin" done diff --git a/template/node/Cargo.toml b/template/node/Cargo.toml index 70598042..14aea4fe 100644 --- a/template/node/Cargo.toml +++ b/template/node/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-node" -version = "0.2.0" +version = "0.3.0" license = "Unlicense" build = "build.rs" description = "Orbinum Network Node - Privacy-focused blockchain with ZK proofs and EVM compatibility." diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index 87abbffb..f37efa0e 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "orbinum-runtime" -version = "0.2.0" +version = "0.3.0" license = "Apache-2.0 OR GPL-3.0-or-later" description = "Orbinum Privacy Chain Runtime" publish = false diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 1ad2c5ab..fc74c71d 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -20,6 +20,48 @@ to `spec_version` / `transaction_version` must add a row here in the same PR. The genesis reset (`69d1b837`) set `spec_version` back to 1 and `transaction_version` to 1 for the public testnet launch. +### spec 17 — tx 5 — [Unreleased] + +A shield must prove its note is worth exactly the deposit. `transaction_version` +moves: `shield` (call 0) gains `proof` and `circuit_version`, and each +`shield_batch` (call 12) entry gains the same two fields. + +#### 1 · Shield bound to its amount (AL-1) + +`shield` checked the asset, the amount, the memo and the commitment's form, but +nothing tied the commitment to the amount: a deposit of 1 could insert a note +worth 1000, and unshielding it drained other depositors. The commitment must now +open to the deposited amount and asset under circuit 3 (`shield`, public inputs +`commitment, value, asset_id`), verified before any funds move. A batch with one +bad proof reverts entirely. The EVM precompile's shield becomes +`shield(uint32,bytes32,bytes,bytes,uint32)` (selector `0xf25897e0`, was +`0x9feb22ea`); the old three-slot calldata reverts as too short. + +Only the native asset moves through the pool: shield, unshield, private +transfer and relay-fee claims refuse any other asset with `AssetNotSupported`. +The pool always moved `T::Currency`, so a note of a verified non-native asset was +backed by ORB. Only asset 0 exists on testnet, so nothing on-chain changes. + +zk-verifier knows circuit 3 with 3 public inputs. Memo-bound layouts are now +specific to the spend circuits: shield takes its base arity at every version, +and a 4-input shield key is refused. + +#### 2 · Registering circuit 3 by Root, after the upgrade + +No keys are embedded. Between `setCode` and registration every shield fails +(`UnsupportedCircuitVersion`), so the app pauses shielding first. Root then +registers the shield key of `@orbinum/circuits` 0.16.0 (`vk_hash` +`0x4835d34f…5cb7`) as circuit 3 version 1 with `set_active`. There is no previous +version to retire. Unshield and transfer are unaffected. + +Old clients stop working at the upgrade: their shield extrinsics no longer +decode, and the new transaction version invalidates them anyway. App, +wallet-sdk and `@orbinum/protocol` ship together with it. + +Shield weights add one proof verification per deposit; `shield` and +`shield_batch(n)` weights are provisional until re-benchmarked on the reference +machine. + ### spec 16 — tx 4 — [Unreleased] Relay fees follow the relay commit and are claimed publicly. `transaction_version` diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 205da0a6..17b9c053 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -160,10 +160,10 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 16, + spec_version: 17, impl_version: 1, apis: RUNTIME_API_VERSIONS, - transaction_version: 4, + transaction_version: 5, system_version: 1, };