diff --git a/Cargo.lock b/Cargo.lock index eaed8b9b..74e40d72 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12411,7 +12411,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.23.0" +version = "0.24.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", @@ -12434,7 +12434,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool-runtime-api" -version = "0.2.0" +version = "0.3.0" dependencies = [ "pallet-shielded-pool", "parity-scale-codec", diff --git a/client/rpc-v2/src/privacy.rs b/client/rpc-v2/src/privacy.rs index 076c5311..94b7de50 100644 --- a/client/rpc-v2/src/privacy.rs +++ b/client/rpc-v2/src/privacy.rs @@ -22,7 +22,7 @@ use pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi; use sc_client_api::StorageProvider as ScStorageProvider; use scale_codec::Decode; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use sp_api::ProvideRuntimeApi; +use sp_api::{ApiExt, ProvideRuntimeApi}; /// `u128` serde helper: serialises as a decimal string so JavaScript clients /// can parse values larger than `Number.MAX_SAFE_INTEGER` without precision loss. @@ -73,6 +73,26 @@ fn map_key(item: &[u8], k: &[u8]) -> Vec { // Response types // ============================================================================ +/// Level-6 subtree roots of one tree (`privacy_getSubtreeRoots`). +/// +/// Every root but the last of an active tree is final. Past the last block the +/// nodes are the zero hash; the response omits them. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SubtreeRootsResponse { + pub tree_id: u32, + /// Level of the roots: each covers `2^level` leaves. + pub level: u8, + /// Leaves in the tree: the capacity once sealed. + pub tree_leaves: u32, + pub sealed: bool, + /// Root the tree anchors to at the same state (`0x`-prefixed hex). + pub root: String, + /// Index of `roots[0]` within the level. + pub start: u32, + /// `0x`-prefixed hex, little-endian field elements. + pub roots: Vec, +} + /// Response for `privacy_getMerkleProof`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct MerkleProofResponse { @@ -144,6 +164,17 @@ pub trait PrivacyApi { #[method(name = "privacy_getMerkleProofByCommitment", blocking)] fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult; + /// Up to `count` (at most 4096) level-6 subtree roots of tree `tree_id` from + /// `start`, with the root the tree anchors to, so a wallet can build Merkle + /// paths itself. Needs runtime API v4. + #[method(name = "privacy_getSubtreeRoots", blocking)] + fn get_subtree_roots( + &self, + tree_id: u32, + start: u32, + count: u32, + ) -> RpcResult; + /// Returns whether the nullifier (`0x`-prefixed hex, 32 bytes) has been spent. #[method(name = "privacy_getNullifierStatus")] fn get_nullifier_status(&self, nullifier: String) -> RpcResult; @@ -461,6 +492,38 @@ where }) } + fn get_subtree_roots( + &self, + tree_id: u32, + start: u32, + count: u32, + ) -> RpcResult { + let _permit = self.proofs.enter().ok_or_else(busy)?; + let best_hash = self.client.info().best_hash; + let api = self.client.runtime_api(); + let version = api + .api_version::>(best_hash) + .map_err(internal_error)? + .unwrap_or(0); + if version < 4 { + return Err(internal_error("subtree roots need runtime API v4")); + } + let found = api + .get_subtree_roots(best_hash, tree_id, start, count) + .map_err(internal_error)? + .ok_or_else(|| invalid_params(format!("tree {tree_id} does not exist")))?; + let hex32 = |h: [u8; 32]| format!("0x{}", hex::encode(h)); + Ok(SubtreeRootsResponse { + tree_id: found.tree_id, + level: pallet_shielded_pool::SUBTREE_LEVEL, + tree_leaves: found.tree_leaves, + sealed: found.sealed, + root: hex32(found.root), + start: found.start, + roots: found.roots.into_iter().map(hex32).collect(), + }) + } + fn get_nullifier_status(&self, nullifier: String) -> RpcResult { let best_hash = self.client.info().best_hash; diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index cff5afcb..bf8f62d2 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. ## [Unreleased] +## [0.24.0] - 2026-10-09 + +### Added + +- `get_subtree_roots(tree_id, start, count)` and `SubtreeRoots`: up to + `MAX_SUBTREE_ROOTS` (4096) level-`SUBTREE_LEVEL` (6) roots of one tree, with + the root it anchors to, read from one state. A wallet builds its own Merkle + path from them and the 64 leaves of its note's block, so no node learns which + note is spent. A sealed tree's node pruned under an earlier, higher cut is + rebuilt from its leaves. Runtime API v4 (`ShieldedPoolRuntimeApi`). + ## [0.23.0] - 2026-10-08 ### Changed diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 06e5d75d..74c0c012 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.23.0" +version = "0.24.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/runtime-api/Cargo.toml b/frame/shielded-pool/runtime-api/Cargo.toml index 5f8dfacb..2a352e25 100644 --- a/frame/shielded-pool/runtime-api/Cargo.toml +++ b/frame/shielded-pool/runtime-api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool-runtime-api" -version = "0.2.0" +version = "0.3.0" authors = ["Orbinum Team"] edition = "2024" license = "GPL-3.0-or-later" diff --git a/frame/shielded-pool/runtime-api/src/lib.rs b/frame/shielded-pool/runtime-api/src/lib.rs index e60c3bba..ef21101e 100644 --- a/frame/shielded-pool/runtime-api/src/lib.rs +++ b/frame/shielded-pool/runtime-api/src/lib.rs @@ -1,6 +1,6 @@ #![cfg_attr(not(feature = "std"), no_std)] -use pallet_shielded_pool::{DefaultMerklePath, Hash}; +use pallet_shielded_pool::{DefaultMerklePath, Hash, SubtreeRoots}; /// Configuration exposed by the runtime for the node-native EVM relay. /// @@ -60,5 +60,11 @@ sp_api::decl_runtime_apis! { /// calldata that is not a relayable spend. #[api_version(3)] fn relay_commit_hash(calldata: sp_std::vec::Vec, relayer: [u8; 20]) -> Option<[u8; 32]>; + + /// Up to `count` level-6 subtree roots of `tree_id` from `start`, with the + /// root the tree anchors to, read from one state, so a wallet can build + /// its own Merkle paths. None for a tree that does not exist yet. + #[api_version(4)] + fn get_subtree_roots(tree_id: u32, start: u32, count: u32) -> Option; } } diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 674343cd..0b52147a 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -85,7 +85,8 @@ pub use pallet::*; pub use types::{ AssetId, AssetMetadata, Commitment, DEFAULT_TREE_DEPTH, DefaultMerklePath, EncryptedMemo as FrameEncryptedMemo, Hash, MAX_ENCRYPTED_MEMO_SIZE, MAX_PROOF_SIZE, - MAX_TREE_DEPTH, MerklePath, Note, Nullifier, Proof, + MAX_SUBTREE_ROOTS, MAX_TREE_DEPTH, MerklePath, Note, Nullifier, Proof, SUBTREE_LEVEL, + SubtreeRoots, }; pub use weights::WeightInfo; diff --git a/frame/shielded-pool/src/merkle/service.rs b/frame/shielded-pool/src/merkle/service.rs index 9e97a58b..fa981802 100644 --- a/frame/shielded-pool/src/merkle/service.rs +++ b/frame/shielded-pool/src/merkle/service.rs @@ -261,6 +261,52 @@ impl MerkleTreeService { Some(DefaultMerklePath { siblings, indices }) } + /// Up to `count` (capped at [`MAX_SUBTREE_ROOTS`]) level-[`SUBTREE_LEVEL`] + /// roots of `tree_id` from `start`, with the tree's anchoring root, all read + /// from the current state. `None` for a tree that does not exist yet. + /// + /// Only blocks holding at least one leaf are returned: past them every node + /// is the zero hash, which the caller knows. A node missing from storage (a + /// sealed tree pruned under an earlier, higher cut) is rebuilt from leaves. + pub fn get_subtree_roots( + tree_id: u32, + start: u32, + count: u32, + ) -> Option { + use crate::types::{MAX_SUBTREE_ROOTS, SUBTREE_LEVEL, SubtreeRoots}; + let size = MerkleRepository::get_tree_size::(); + let cap = T::MaxLeavesPerTree::get(); + let current = size / cap; + if tree_id > current { + return None; + } + let sealed = tree_id < current; + let (tree_leaves, root) = if sealed { + (cap, MerkleRepository::get_sealed_root::(tree_id)?) + } else { + (size % cap, MerkleRepository::get_poseidon_root::()) + }; + let level = SUBTREE_LEVEL as usize; + let blocks = tree_leaves.div_ceil(1 << level); + let end = start + .saturating_add(count.min(MAX_SUBTREE_ROOTS)) + .min(blocks); + let roots = (start..end) + .map(|i| { + MerkleRepository::get_node::(tree_id, SUBTREE_LEVEL, i) + .unwrap_or_else(|| Self::subtree_root::(tree_id, level, i, cap)) + }) + .collect(); + Some(SubtreeRoots { + tree_id, + tree_leaves, + sealed, + root, + start, + roots, + }) + } + /// Rebuild the node at `(level, node_index)` from the leaves beneath it. /// /// Reads the `2^level` leaves the node spans and folds them pairwise. Used only diff --git a/frame/shielded-pool/src/runtime_api_impl.rs b/frame/shielded-pool/src/runtime_api_impl.rs index 70240a1d..bde9bf00 100644 --- a/frame/shielded-pool/src/runtime_api_impl.rs +++ b/frame/shielded-pool/src/runtime_api_impl.rs @@ -56,6 +56,11 @@ impl Pallet { Some((root, tree_id)) } + /// Level-6 subtree roots of `tree_id` from `start`, with its anchoring root. + pub fn get_subtree_roots(tree_id: u32, start: u32, count: u32) -> Option { + crate::merkle::MerkleTreeService::get_subtree_roots::(tree_id, start, count) + } + /// Get Merkle proof for a given commitment. /// /// O(1) reverse-index lookup plus an O(depth) sibling-path read from @@ -219,4 +224,196 @@ mod tests { assert_eq!(idx, 2); }); } + + // ── Subtree roots (runtime API v4) ──────────────────────────────────────── + + mod subtree_roots { + use super::*; + use crate::{ + Pallet, + merkle::{get_zero_hash_cached, hash_pair}, + types::{SUBTREE_LEVEL, SubtreeRoots}, + }; + + const DEPTH: usize = crate::types::DEFAULT_TREE_DEPTH; + const LEVEL: usize = SUBTREE_LEVEL as usize; + + /// `n` leaves: tree 0 seals at the mock's cap of 8. + fn insert(n: u8) { + for i in 0..n { + assert_ok!(Pallet::::insert_leaf(Commitment::new([i + 1; 32]))); + } + } + + /// The node at `level` (>= LEVEL) over the served roots, the zero hash + /// past the last one, as a wallet folds it. + fn upper(roots: &[[u8; 32]], level: usize, index: usize) -> [u8; 32] { + if level == LEVEL { + return roots + .get(index) + .copied() + .unwrap_or_else(|| get_zero_hash_cached(LEVEL)); + } + if (index << (level - LEVEL)) >= roots.len() { + return get_zero_hash_cached(level); + } + hash_pair( + &upper(roots, level - 1, 2 * index), + &upper(roots, level - 1, 2 * index + 1), + ) + } + + /// A leaf's path built as a wallet does: the lower levels from the + /// leaves of its block, the upper ones from the served roots. + fn wallet_path( + served: &SubtreeRoots, + block_leaves: &[[u8; 32]], + local: usize, + ) -> Vec<[u8; 32]> { + let mut layer: Vec<[u8; 32]> = (0..1 << LEVEL) + .map(|i| { + block_leaves + .get(i) + .copied() + .unwrap_or_else(|| get_zero_hash_cached(0)) + }) + .collect(); + let mut path = Vec::new(); + let offset = local & ((1 << LEVEL) - 1); + for lvl in 0..LEVEL { + path.push(layer[(offset >> lvl) ^ 1]); + layer = layer.chunks(2).map(|p| hash_pair(&p[0], &p[1])).collect(); + } + assert_eq!( + layer[0], + served.roots[local >> LEVEL], + "the block root is the served one" + ); + for lvl in LEVEL..DEPTH { + path.push(upper(&served.roots, lvl, (local >> lvl) ^ 1)); + } + path + } + + fn leaves_of(tree_id: u32, n: u32) -> Vec<[u8; 32]> { + (0..n) + .map(|i| { + crate::storage::MerkleRepository::get_leaf::(tree_id * 8 + i) + .unwrap() + .0 + }) + .collect() + } + + #[test] + fn the_served_roots_fold_to_the_anchoring_root() { + new_test_ext().execute_with(|| { + insert(11); + for tree_id in [0, 1] { + let s = Pallet::::get_subtree_roots(tree_id, 0, 4096).unwrap(); + assert_eq!(upper(&s.roots, DEPTH, 0), s.root, "tree {tree_id}"); + } + let sealed = Pallet::::get_subtree_roots(0, 0, 10).unwrap(); + assert!(sealed.sealed); + assert_eq!(sealed.tree_leaves, 8); + assert_eq!( + sealed.root, + crate::storage::MerkleRepository::get_sealed_root::(0).unwrap() + ); + let active = Pallet::::get_subtree_roots(1, 0, 10).unwrap(); + assert!(!active.sealed); + assert_eq!(active.tree_leaves, 3); + assert_eq!( + active.root, + crate::storage::MerkleRepository::get_poseidon_root::() + ); + }); + } + + /// Every leaf's path built from block leaves and served roots is the + /// path the node serves, in a sealed and in the active tree. + #[test] + fn a_wallet_built_path_is_the_node_path() { + new_test_ext().execute_with(|| { + insert(13); + for (tree_id, n) in [(0u32, 8u32), (1, 5)] { + let served = Pallet::::get_subtree_roots(tree_id, 0, 4096).unwrap(); + let leaves = leaves_of(tree_id, n); + for local in 0..n { + let node = Pallet::::get_merkle_proof(tree_id * 8 + local).unwrap(); + assert_eq!( + wallet_path(&served, &leaves, local as usize), + node.siblings.to_vec(), + "tree {tree_id} leaf {local}" + ); + } + } + }); + } + + /// The last block of the active tree changes as leaves land; the root + /// served with it always matches. + #[test] + fn the_partial_block_tracks_the_live_root() { + new_test_ext().execute_with(|| { + for i in 0..7u8 { + assert_ok!(Pallet::::insert_leaf(Commitment::new([i + 1; 32]))); + let s = Pallet::::get_subtree_roots(0, 0, 1).unwrap(); + assert_eq!(upper(&s.roots, DEPTH, 0), s.root, "after {} leaves", i + 1); + } + }); + } + + /// A sealed tree's node missing from storage (pruned under an earlier, + /// higher cut) is rebuilt from its leaves. + #[test] + fn a_pruned_node_of_a_sealed_tree_is_rebuilt() { + new_test_ext().execute_with(|| { + insert(9); + let before = Pallet::::get_subtree_roots(0, 0, 1).unwrap(); + crate::pallet::MerkleNodes::::remove((0u32, SUBTREE_LEVEL, 0u32)); + assert_eq!(Pallet::::get_subtree_roots(0, 0, 1).unwrap(), before); + }); + } + + #[test] + fn windows_and_missing_trees() { + new_test_ext().execute_with(|| { + let empty = Pallet::::get_subtree_roots(0, 0, 10).unwrap(); + assert_eq!( + (empty.tree_leaves, empty.roots.len(), empty.sealed), + (0, 0, false) + ); + assert_eq!( + Pallet::::get_subtree_roots(1, 0, 10), + None, + "no tree 1 yet" + ); + insert(9); + assert_eq!(Pallet::::get_subtree_roots(2, 0, 10), None); + assert_eq!( + Pallet::::get_subtree_roots(0, 1, 10) + .unwrap() + .roots + .len(), + 0, + "past the last block" + ); + assert_eq!( + Pallet::::get_subtree_roots(0, 0, 0) + .unwrap() + .roots + .len(), + 0 + ); + assert_eq!( + Pallet::::get_subtree_roots(0, u32::MAX, u32::MAX) + .unwrap() + .roots + .len(), + 0 + ); + }); + } + } } diff --git a/frame/shielded-pool/src/types/merkle.rs b/frame/shielded-pool/src/types/merkle.rs index aaad4afc..d582fd6c 100644 --- a/frame/shielded-pool/src/types/merkle.rs +++ b/frame/shielded-pool/src/types/merkle.rs @@ -29,3 +29,29 @@ impl Default for MerklePath { } pub type DefaultMerklePath = MerklePath; + +/// Level of the subtree roots a wallet reads to build its own paths: each covers +/// a block of `2^SUBTREE_LEVEL` = 64 leaves. +pub const SUBTREE_LEVEL: u8 = 6; + +/// Subtree roots served per call, at most: a quarter of a full tree's 2^14. +pub const MAX_SUBTREE_ROOTS: u32 = 4096; + +/// The level-[`SUBTREE_LEVEL`] roots of one tree, read from one state. +/// +/// A wallet folds them into the upper 14 levels of its path, and its note's +/// block from leaves it scanned into the lower 6. Every root but the last of an +/// active tree is final: a block's leaves never change once it is full. +#[derive(Clone, Encode, Decode, TypeInfo, Debug, PartialEq, Eq)] +pub struct SubtreeRoots { + pub tree_id: u32, + /// Leaves in this tree: the capacity once sealed. + pub tree_leaves: u32, + pub sealed: bool, + /// The root the tree anchors to: its final root once sealed, else the live + /// root, at the state the subtree roots were read at. + pub root: [u8; 32], + /// Index of `roots[0]` within the level. + pub start: u32, + pub roots: sp_std::vec::Vec<[u8; 32]>, +} diff --git a/frame/shielded-pool/src/types/mod.rs b/frame/shielded-pool/src/types/mod.rs index a1528679..760d8f9e 100644 --- a/frame/shielded-pool/src/types/mod.rs +++ b/frame/shielded-pool/src/types/mod.rs @@ -20,7 +20,10 @@ pub mod proof; 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 merkle::{ + DEFAULT_TREE_DEPTH, DefaultMerklePath, MAX_SUBTREE_ROOTS, MAX_TREE_DEPTH, MerklePath, + SUBTREE_LEVEL, SubtreeRoots, +}; pub use note::Note; pub use proof::{MAX_PROOF_SIZE, Proof}; diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 0234687b..77ec9052 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -102,6 +102,9 @@ active.** - **zk-verifier:** keys are stored prepared (`PreparedKeys`), so a proof no longer prepares its key: verification ~2.2× cheaper. Storage v2; `MigrateToV2` prepares the keys already registered, during the upgrade. +- **Runtime API v4:** `get_subtree_roots`, behind the `privacy_getSubtreeRoots` + RPC: a tree's level-6 subtree roots, from which a wallet builds its own Merkle + paths instead of asking a node for one (which tells the node the note). - **Node 0.4.0** registers the `bn254_groth16_verify` host function (`register_only`): this runtime does not call it, so nodes without it keep working. A later spec turns it on (a transfer validated in ~1.5 ms instead of diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 27d9bb3b..2aff713a 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -925,6 +925,7 @@ impl_runtime_apis! { } // ShieldedPool Runtime API implementation + #[api_version(4)] impl pallet_shielded_pool_runtime_api::ShieldedPoolRuntimeApi for Runtime { fn get_merkle_tree_info() -> (pallet_shielded_pool::Hash, u32, u32) { ShieldedPool::get_merkle_tree_info() @@ -948,6 +949,14 @@ impl_runtime_apis! { ShieldedPool::get_merkle_proof_for_commitment(commitment) } + fn get_subtree_roots( + tree_id: u32, + start: u32, + count: u32, + ) -> Option { + ShieldedPool::get_subtree_roots(tree_id, start, count) + } + fn relay_config() -> pallet_shielded_pool_runtime_api::RelayConfig { use pallet_relayer::RelayerInterface; let min_fee_planck = pallet_relayer::Pallet::::min_relay_fee();