diff --git a/Cargo.lock b/Cargo.lock index f4e9d399..a7e0b856 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3426,10 +3426,12 @@ dependencies = [ "hex", "jsonrpsee", "pallet-shielded-pool", + "pallet-shielded-pool-runtime-api", "parity-scale-codec", "sc-client-api", "serde", "serde_json", + "sp-api", "sp-blockchain", "sp-core", "sp-runtime", @@ -7332,6 +7334,7 @@ dependencies = [ "frame-system", "frame-system-benchmarking", "frame-system-rpc-runtime-api", + "frame-try-runtime", "hex-literal", "libsecp256k1", "pallet-account-mapping", @@ -8171,7 +8174,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.10.1" +version = "0.11.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", diff --git a/Cargo.toml b/Cargo.toml index 9896da65..94bbce89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,7 @@ frame-support = { git = "https://github.com/paritytech/polkadot-sdk", branch = " frame-system = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } frame-system-benchmarking = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } frame-system-rpc-runtime-api = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } +frame-try-runtime = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } pallet-aura = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } pallet-authorship = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } pallet-balances = { git = "https://github.com/paritytech/polkadot-sdk", branch = "stable2512", default-features = false } diff --git a/client/rpc-v2/Cargo.toml b/client/rpc-v2/Cargo.toml index e9b052fa..5f6e329a 100644 --- a/client/rpc-v2/Cargo.toml +++ b/client/rpc-v2/Cargo.toml @@ -13,6 +13,7 @@ targets = ["x86_64-unknown-linux-gnu"] [dependencies] # Substrate sc-client-api = { workspace = true } +sp-api = { workspace = true, features = ["std"] } sp-blockchain = { workspace = true } sp-core = { workspace = true, features = ["default"] } sp-runtime = { workspace = true, features = ["default"] } @@ -29,6 +30,7 @@ serde = { workspace = true } # Orbinum Primitives pallet-shielded-pool = { workspace = true, features = ["std"] } +pallet-shielded-pool-runtime-api = { workspace = true, features = ["std"] } [dev-dependencies] serde_json = { workspace = true } diff --git a/client/rpc-v2/src/privacy.rs b/client/rpc-v2/src/privacy.rs index 1cea882f..f5c2586f 100644 --- a/client/rpc-v2/src/privacy.rs +++ b/client/rpc-v2/src/privacy.rs @@ -11,13 +11,12 @@ use jsonrpsee::{ proc_macros::rpc, types::error::{ErrorCode, ErrorObject}, }; -use pallet_shielded_pool::{ - merkle::{get_zero_hash_cached, hash_pair_poseidon}, - DEFAULT_TREE_DEPTH, -}; +use pallet_shielded_pool::DEFAULT_TREE_DEPTH; +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; /// `u128` serde helper: serialises as a decimal string so JavaScript clients /// can parse values larger than `Number.MAX_SAFE_INTEGER` without precision loss. @@ -49,12 +48,6 @@ use std::{marker::PhantomData, sync::Arc}; const PALLET: &[u8] = b"ShieldedPool"; -/// Maximum number of Merkle leaves the RPC will load per request. -/// Prevents DoS via unbounded O(n) storage reads on deep trees. -/// At tree depth 20 the theoretical maximum is 2^20 ≈ 1M leaves; -/// we cap far below that to keep RPC latency bounded. -const MAX_RPC_LEAVES: u32 = 100_000; - /// Builds `twox_128(pallet) ++ twox_128(item)` (32 bytes — `StorageValue` key). fn value_key(item: &[u8]) -> Vec { [twox_128(PALLET), twox_128(item)].concat() @@ -133,7 +126,8 @@ pub trait PrivacyApi { fn get_merkle_proof(&self, leaf_index: u32) -> RpcResult; /// Returns a Merkle sibling-path proof for the given commitment (`0x`-prefixed hex, 32 bytes). - /// Scans `MerkleLeaves` linearly to resolve the leaf index. + /// Resolves the leaf index via the on-chain reverse index (O(1)) and reads the + /// stored sibling path (O(depth)). Root and path come from the same block. /// Returns an error if the commitment is not found in the tree. #[method(name = "privacy_getMerkleProofByCommitment")] fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult; @@ -202,17 +196,6 @@ fn pool_is_empty() -> ErrorObject<'static> { ) } -fn too_many_leaves(size: u32) -> ErrorObject<'static> { - ErrorObject::owned( - ErrorCode::InternalError.code(), - format!( - "tree_size {size} exceeds RPC limit {MAX_RPC_LEAVES}; \ - use an archive node or paginated access" - ), - None::<()>, - ) -} - // ============================================================================ // Storage helper — shared across all handler methods // ============================================================================ @@ -227,56 +210,14 @@ fn read_storage, BE: sc_client_api::Backe .map(|opt| opt.map(|data| data.0)) } -// ============================================================================ -// Pure Merkle path builder — extracted for testability -// -// Mirrors `IncrementalMerkleTree::generate_proof` in the pallet exactly. -// Returns a `DEFAULT_TREE_DEPTH`-element sibling path (raw bytes). -// ============================================================================ - -fn build_merkle_path(leaves: &[[u8; 32]], leaf_index: usize) -> Vec<[u8; 32]> { - let mut current_level = leaves.to_vec(); - let mut path = Vec::with_capacity(DEFAULT_TREE_DEPTH); - let mut target = leaf_index; - - for level in 0..DEFAULT_TREE_DEPTH { - // Pad odd levels with the canonical zero hash for this level. - if current_level.len() % 2 != 0 { - current_level.push(get_zero_hash_cached(level)); - } - - let sibling_idx = target ^ 1; - let sibling = if sibling_idx < current_level.len() { - current_level[sibling_idx] - } else { - get_zero_hash_cached(level) - }; - path.push(sibling); - - // Compute the parent level. - let mut next: Vec<[u8; 32]> = Vec::with_capacity(current_level.len().div_ceil(2)); - for chunk in current_level.chunks(2) { - let left = chunk[0]; - let right = chunk - .get(1) - .copied() - .unwrap_or_else(|| get_zero_hash_cached(level)); - next.push(hash_pair_poseidon(&left, &right)); - } - current_level = next; - target /= 2; - } - - path -} - // ============================================================================ // PrivacyApiServer implementation // ============================================================================ impl PrivacyApiServer for PrivacyRpc where - C: HeaderBackend + ScStorageProvider + Send + Sync + 'static, + C: HeaderBackend + ScStorageProvider + ProvideRuntimeApi + Send + Sync + 'static, + C::Api: ShieldedPoolRuntimeApi, B: BlockT, BE: sc_client_api::Backend + Send + Sync + 'static, { @@ -291,61 +232,44 @@ where } fn get_merkle_proof(&self, leaf_index: u32) -> RpcResult { + // Both runtime-API calls execute at the same block, so root and path + // can never mismatch. let best_hash = self.client.info().best_hash; + let api = self.client.runtime_api(); - // Tree size - let size_data = read_storage(&*self.client, best_hash, value_key(b"MerkleTreeSize"))? - .ok_or_else(pool_not_initialized)?; - let tree_size = u32::decode(&mut &size_data[..]).map_err(internal_error)?; + let (root, tree_size, tree_depth) = api + .get_merkle_tree_info(best_hash) + .map_err(internal_error)?; if tree_size == 0 { return Err(pool_is_empty()); } - if tree_size > MAX_RPC_LEAVES { - return Err(too_many_leaves(tree_size)); - } if leaf_index >= tree_size { return Err(invalid_params(format!( "leaf_index {leaf_index} >= tree_size {tree_size}" ))); } - // Load all leaves - let current_level: Vec<[u8; 32]> = (0..tree_size) - .map(|i| { - let data = read_storage( - &*self.client, - best_hash, - map_key(b"MerkleLeaves", &i.to_le_bytes()), - )? - .ok_or_else(|| internal_error(format!("leaf {i} missing from storage")))?; - let h256 = H256::decode(&mut &data[..]).map_err(internal_error)?; - Ok::<[u8; 32], ErrorObject<'static>>(h256.into()) - }) - .collect::>()?; - - // Read root from same block (atomic — no root/path mismatch) - let root_data = read_storage(&*self.client, best_hash, value_key(b"PoseidonRoot"))? - .ok_or_else(pool_not_initialized)?; - let root = H256::decode(&mut &root_data[..]).map_err(internal_error)?; - - // Build sibling path using the shared pure function. - let raw_path = build_merkle_path(¤t_level, leaf_index as usize); - let path: Vec = raw_path - .iter() - .map(|s| format!("0x{}", hex::encode(s))) - .collect(); + let proof = api + .get_merkle_proof(best_hash, leaf_index) + .map_err(internal_error)? + .ok_or_else(|| internal_error(format!("no proof for leaf_index {leaf_index}")))?; Ok(MerkleProofResponse { - root: format!("0x{}", hex::encode(root.as_bytes())), - path, + root: format!("0x{}", hex::encode(root)), + path: proof + .siblings + .iter() + .map(|s| format!("0x{}", hex::encode(s))) + .collect(), leaf_index, - tree_depth: DEFAULT_TREE_DEPTH as u32, + tree_depth, }) } fn get_merkle_proof_by_commitment(&self, commitment: String) -> RpcResult { let best_hash = self.client.info().best_hash; + let api = self.client.runtime_api(); // Parse commitment hex let hex_str = commitment.trim_start_matches("0x"); @@ -358,58 +282,32 @@ where } let target = H256::from_slice(&bytes); - // Tree size - let size_data = read_storage(&*self.client, best_hash, value_key(b"MerkleTreeSize"))? - .ok_or_else(pool_not_initialized)?; - let tree_size = u32::decode(&mut &size_data[..]).map_err(internal_error)?; + let (root, tree_size, tree_depth) = api + .get_merkle_tree_info(best_hash) + .map_err(internal_error)?; if tree_size == 0 { return Err(pool_is_empty()); } - if tree_size > MAX_RPC_LEAVES { - return Err(too_many_leaves(tree_size)); - } - - // Load all leaves; find the matching commitment - let mut leaf_index: Option = None; - let mut current_level: Vec<[u8; 32]> = Vec::with_capacity(tree_size as usize); - - for i in 0..tree_size { - let data = read_storage( - &*self.client, - best_hash, - map_key(b"MerkleLeaves", &i.to_le_bytes()), - )? - .ok_or_else(|| internal_error(format!("leaf {i} missing from storage")))?; - let h256 = H256::decode(&mut &data[..]).map_err(internal_error)?; - if leaf_index.is_none() && h256 == target { - leaf_index = Some(i); - } - current_level.push(h256.into()); - } - - let leaf_index = leaf_index.ok_or_else(|| { - invalid_params(format!( - "commitment 0x{hex_str} not found in the Merkle tree" - )) - })?; - - // Read root from same block (atomic — no root/path mismatch) - let root_data = read_storage(&*self.client, best_hash, value_key(b"PoseidonRoot"))? - .ok_or_else(pool_not_initialized)?; - let root = H256::decode(&mut &root_data[..]).map_err(internal_error)?; - let raw_path = build_merkle_path(¤t_level, leaf_index as usize); - let path: Vec = raw_path - .iter() - .map(|s| format!("0x{}", hex::encode(s))) - .collect(); + let (leaf_index, proof) = api + .get_merkle_proof_for_commitment(best_hash, target.into()) + .map_err(internal_error)? + .ok_or_else(|| { + invalid_params(format!( + "commitment 0x{hex_str} not found in the Merkle tree" + )) + })?; Ok(MerkleProofResponse { - root: format!("0x{}", hex::encode(root.as_bytes())), - path, + root: format!("0x{}", hex::encode(root)), + path: proof + .siblings + .iter() + .map(|s| format!("0x{}", hex::encode(s))) + .collect(), leaf_index, - tree_depth: DEFAULT_TREE_DEPTH as u32, + tree_depth, }) } @@ -507,7 +405,6 @@ where #[cfg(test)] mod tests { use super::*; - use pallet_shielded_pool::merkle::{get_zero_hash_cached, hash_pair_poseidon}; use sp_core::hashing::twox_128; // ------------------------------------------------------------------------- @@ -581,95 +478,6 @@ mod tests { } } - // ------------------------------------------------------------------------- - // build_merkle_path algorithm - // ------------------------------------------------------------------------- - - mod merkle_path { - use super::*; - - fn leaf(byte: u8) -> [u8; 32] { - let mut arr = [0u8; 32]; - arr[0] = byte; - arr - } - - #[test] - fn path_is_always_tree_depth_elements() { - for n in [1u32, 2, 3, 5, 10, 20] { - let leaves: Vec<[u8; 32]> = (0..n).map(|i| leaf(i as u8)).collect(); - let path = build_merkle_path(&leaves, 0); - assert_eq!( - path.len(), - DEFAULT_TREE_DEPTH, - "path len must be {DEFAULT_TREE_DEPTH} for n={n}" - ); - } - } - - #[test] - fn single_leaf_sibling_at_level0_is_zero_hash() { - let path = build_merkle_path(&[leaf(0xAA)], 0); - assert_eq!(path[0], get_zero_hash_cached(0)); - } - - #[test] - fn two_leaves_sibling_is_the_other_leaf() { - let l0 = leaf(0xAA); - let l1 = leaf(0xBB); - let path0 = build_merkle_path(&[l0, l1], 0); - let path1 = build_merkle_path(&[l0, l1], 1); - assert_eq!(path0[0], l1, "sibling of l0 must be l1"); - assert_eq!(path1[0], l0, "sibling of l1 must be l0"); - } - - #[test] - fn last_leaf_in_odd_tree_gets_zero_hash_sibling() { - // Tree of 3: leaf index 2 is paired with a padded zero hash. - let leaves = vec![leaf(1), leaf(2), leaf(3)]; - let path = build_merkle_path(&leaves, 2); - assert_eq!(path[0], get_zero_hash_cached(0)); - } - - #[test] - fn two_leaves_in_same_pair_share_upper_path() { - let l0 = leaf(0x11); - let l1 = leaf(0x22); - let path0 = build_merkle_path(&[l0, l1], 0); - let path1 = build_merkle_path(&[l0, l1], 1); - // Both leaves produce the same parent node, so levels 1..DEPTH must match. - assert_eq!(&path0[1..], &path1[1..]); - } - - #[test] - fn level1_sibling_is_hash_of_sibling_pair() { - let l = [leaf(0x11), leaf(0x22), leaf(0x33), leaf(0x44)]; - let path0 = build_merkle_path(&l, 0); - // l0 is in pair (l0, l1). The sibling at level 1 is hash(l2, l3). - assert_eq!(path0[0], l[1]); - assert_eq!(path0[1], hash_pair_poseidon(&l[2], &l[3])); - } - - #[test] - fn leaf_index_1_in_4_leaf_tree_correct_siblings() { - let l = [leaf(0x11), leaf(0x22), leaf(0x33), leaf(0x44)]; - let path1 = build_merkle_path(&l, 1); - assert_eq!(path1[0], l[0]); - assert_eq!(path1[1], hash_pair_poseidon(&l[2], &l[3])); - } - - #[test] - fn upper_path_levels_use_progressive_zero_hashes_for_single_leaf() { - let path = build_merkle_path(&[leaf(0xFF)], 0); - // After level 0 the single leaf is hashed with zero_hash[0] to form the - // level-1 node. The sibling at level 1 must be zero_hash[1], and so on. - #[allow(clippy::needless_range_loop)] - for level in 1..DEFAULT_TREE_DEPTH { - assert_eq!(path[level], get_zero_hash_cached(level)); - } - } - } - // ------------------------------------------------------------------------- // Response type serialization // ------------------------------------------------------------------------- @@ -948,53 +756,5 @@ mod tests { // Must produce distinct messages so callers can distinguish the two states. assert_ne!(empty.message(), uninit.message()); } - - #[test] - fn too_many_leaves_includes_tree_size_in_message() { - let e = too_many_leaves(999_999); - assert!( - e.message().contains("999999"), - "message must contain the tree_size" - ); - } - - #[test] - fn too_many_leaves_includes_limit_in_message() { - let e = too_many_leaves(1); - assert!( - e.message().contains(&MAX_RPC_LEAVES.to_string()), - "message must contain MAX_RPC_LEAVES" - ); - } - - #[test] - fn too_many_leaves_uses_internal_error_code() { - let e = too_many_leaves(1); - assert_eq!(e.code(), ErrorCode::InternalError.code()); - } - } - - // ------------------------------------------------------------------------- - // MAX_RPC_LEAVES constant sanity - // ------------------------------------------------------------------------- - - mod rpc_leaf_cap { - use super::*; - - #[test] - fn max_rpc_leaves_is_below_tree_capacity() { - // Tree capacity = 2^DEFAULT_TREE_DEPTH. Cap must be strictly less - // to provide meaningful DoS protection. - let capacity: u64 = 1u64 << DEFAULT_TREE_DEPTH; - assert!( - (MAX_RPC_LEAVES as u64) < capacity, - "MAX_RPC_LEAVES {MAX_RPC_LEAVES} must be < tree capacity {capacity}" - ); - } - - #[test] - fn max_rpc_leaves_is_nonzero() { - const _: () = assert!(MAX_RPC_LEAVES > 0); - } } } diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 20b1230c..165b4c5b 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. +## [0.11.0] - 2026-07-30 + +### Added +- **`MerkleNodes` storage: internal tree nodes persisted on insert.** The + frontier walk in `insert_leaf` already computed every node along the + insertion path and discarded them; they are now written to + `MerkleNodes: StorageNMap<(tree_id, level, index), Hash>` (levels 1..=19 — + level 0 is `MerkleLeaves`, level 20 is `PoseidonRoot`). The key includes + `tree_id` (fixed at 0 on the current single tree) so the planned multi-tree + forest needs no storage remapping. +- **`MigrateToV1`** (`src/migrations.rs`): one-shot backfill of `MerkleNodes` + from existing leaves, idempotent, guarded by the new pallet + `STORAGE_VERSION(1)`. try-runtime `post_upgrade` verifies the backfilled + level-19 nodes derive `PoseidonRoot`. Rehearsed against a live testnet + snapshot (~block 202k): migration weight is ~3s of ref_time at ~90k leaves — + the upgrade block runs overweight once; re-evaluate (or move to MBM) if + deployed above ~150k leaves. + +### Changed +- **`get_merkle_path` is O(depth): 20 point reads, zero hashing** — it + previously loaded *every* leaf and rebuilt all 20 levels per call (O(n) + reads + O(n) Poseidon hashes). Missing siblings resolve to the canonical + zero hash. `MerkleRepository::get_all_leaves` removed with it. +- **Weights regenerated** on the reference host (`ubuntu-32gb-hel1-1`, AMD + EPYC-Genoa, `--steps=50 --repeat=20`): every leaf-inserting extrinsic pays + the node writes (`shield` 13 → 32 writes; batched inserts dedupe shared + ancestors, e.g. 20-leaf `shield_batch` touches ~35 distinct node keys, not + 19×20). Known pre-existing gap: the `unshield` benchmark exercises the + no-change-note path. +- Consensus-affecting (new storage writes on every insert): requires a + runtime `spec_version` bump at release. `transaction_version` unchanged — + no extrinsic signature changes. + +### Related (outside this crate) +- `privacy_getMerkleProof` / `privacy_getMerkleProofByCommitment` (fc-rpc-v2) + now route through the runtime API: O(1) index lookup via + `CommitmentToLeafIndex` instead of a linear leaf scan, and the + `MAX_RPC_LEAVES = 100_000` cap is removed. Response shape unchanged. +- The runtime now implements the `TryRuntime` API, and its `build.rs` skips + the Poseidon host-function feature on try-runtime builds so the stock + try-runtime CLI can execute the wasm. + ## [0.10.1] - 2026-07-17 ### Fixed diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index 5e931a51..9fad19d6 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.10.1" +version = "0.11.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/src/lib.rs b/frame/shielded-pool/runtime-api/src/lib.rs index 79b00817..bc7a5ec6 100644 --- a/frame/shielded-pool/runtime-api/src/lib.rs +++ b/frame/shielded-pool/runtime-api/src/lib.rs @@ -33,8 +33,8 @@ sp_api::decl_runtime_apis! { /// Get the Merkle proof for a given leaf index fn get_merkle_proof(leaf_index: u32) -> Option; - /// Get the Merkle proof for a given commitment - /// (This requires scanning the leaves in the runtime, which is expensive but convenient) + /// Get the Merkle proof for a given commitment. + /// O(1) index lookup plus O(depth) sibling reads from stored nodes. fn get_merkle_proof_for_commitment(commitment: Hash) -> Option<(u32, DefaultMerklePath)>; /// Return relay configuration sourced from the runtime. diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 6fe0a901..7640a884 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -65,6 +65,7 @@ mod benchmarking; pub mod genesis; pub mod helpers; pub mod merkle; +pub mod migrations; pub mod operations; pub mod storage; pub mod types; @@ -101,7 +102,12 @@ pub mod pallet { pub type BalanceOf = <::Currency as Currency<::AccountId>>::Balance; + /// Storage version. v1 adds `MerkleNodes` (internal Merkle tree nodes), + /// backfilled from `MerkleLeaves` by `migrations::v1::MigrateToV1`. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1); + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); /// Configuration trait for the pallet @@ -174,6 +180,28 @@ pub mod pallet { pub type CommitmentToLeafIndex = StorageMap<_, Blake2_128Concat, Commitment, u32, OptionQuery>; + /// Internal Merkle tree nodes: `(tree_id, level, index) -> node hash`. + /// + /// Written during the frontier walk of `insert_leaf` (the values were already + /// computed there and formerly discarded). Turns Merkle proof generation into + /// O(depth) point reads instead of an O(n) recomputation from all leaves. + /// + /// Levels run 1..=19: level 0 is `MerkleLeaves`, level 20 is `PoseidonRoot`. + /// A missing entry means the subtree below it is empty (zero hash). + /// `tree_id` is fixed at 0 while the pool runs a single tree; the key shape + /// is `(tree_id, level, index)` so a multi-tree forest needs no remapping. + #[pallet::storage] + pub type MerkleNodes = StorageNMap< + _, + ( + NMapKey, // tree_id + NMapKey, // level (1..=19) + NMapKey, // node index within the level + ), + Hash, + OptionQuery, + >; + /// Set of used nullifiers (nullifier -> block number when used) #[pallet::storage] pub type NullifierSet = diff --git a/frame/shielded-pool/src/merkle.rs b/frame/shielded-pool/src/merkle.rs index 441d8318..cba0963a 100644 --- a/frame/shielded-pool/src/merkle.rs +++ b/frame/shielded-pool/src/merkle.rs @@ -326,6 +326,9 @@ impl MerkleTreeService { let mut frontier = MerkleRepository::get_frontier::(); let mut current_hash = commitment.0; let mut current_index = index; + // tree_id is 0 while the pool runs a single tree (index < 2^MAX_TREE_DEPTH + // is enforced above); MerkleNodes is keyed by tree_id for forest readiness. + let tree_id = index >> crate::types::MAX_TREE_DEPTH; for (level, frontier_slot) in frontier.iter_mut().enumerate() { if current_index % 2 == 0 { @@ -338,6 +341,16 @@ impl MerkleTreeService { current_hash = hash_pair(frontier_slot, ¤t_hash); } current_index /= 2; + // current_hash is now the node at (level + 1, current_index). Persist + // levels 1..=19 so proof reads are O(depth); level 20 is PoseidonRoot. + if level + 1 < crate::types::DEFAULT_TREE_DEPTH { + MerkleRepository::set_node::( + tree_id, + (level + 1) as u8, + current_index, + current_hash, + ); + } } let new_poseidon_root = current_hash; @@ -379,49 +392,32 @@ impl MerkleTreeService { MerkleRepository::is_known_root::(root) } + /// Build the sibling path for `leaf_index` from stored nodes. + /// + /// O(depth) point reads: level-0 siblings come from `MerkleLeaves`, upper + /// siblings from `MerkleNodes`. A missing entry means an empty subtree, so + /// the canonical zero hash for that level is used. pub fn get_merkle_path(leaf_index: u32) -> Option { let size = MerkleRepository::get_tree_size::(); if leaf_index >= size { return None; } - let leaves = MerkleRepository::get_all_leaves::(); - if leaves.is_empty() { - return None; - } - let mut siblings = [[0u8; 32]; 20]; - let mut indices = [0u8; 20]; - let mut current_level: sp_std::vec::Vec = leaves; - let mut target_index = leaf_index as usize; - - for level in 0..20 { - if current_level.len() % 2 != 0 { - current_level.push(zero_hash_at_level(level)); - } - let sibling_index = if target_index % 2 == 0 { - indices[level] = 0; - target_index + 1 - } else { - indices[level] = 1; - target_index - 1 - }; - siblings[level] = if sibling_index < current_level.len() { - current_level[sibling_index] + let depth = crate::types::DEFAULT_TREE_DEPTH; + let tree_id = leaf_index >> crate::types::MAX_TREE_DEPTH; + let mut siblings = [[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]; + let mut indices = [0u8; crate::types::DEFAULT_TREE_DEPTH]; + + for level in 0..depth { + let node_index = leaf_index >> level; + indices[level] = (node_index & 1) as u8; + let sibling_index = node_index ^ 1; + let sibling = if level == 0 { + MerkleRepository::get_leaf::(sibling_index).map(|c| c.0) } else { - zero_hash_at_level(level) + MerkleRepository::get_node::(tree_id, level as u8, sibling_index) }; - let mut next_level = sp_std::vec::Vec::new(); - for i in (0..current_level.len()).step_by(2) { - let left = current_level[i]; - let right = if i + 1 < current_level.len() { - current_level[i + 1] - } else { - zero_hash_at_level(level) - }; - next_level.push(hash_pair_poseidon(&left, &right)); - } - current_level = next_level; - target_index /= 2; + siblings[level] = sibling.unwrap_or_else(|| get_zero_hash_cached(level)); } Some(DefaultMerklePath { siblings, indices }) } @@ -792,6 +788,114 @@ mod tests { }); } + // ── Stored-node path reads vs recomputed reference ─────────────────────── + + /// Reference sibling-path builder: recomputes every level from the full + /// leaf set. Oracle for the O(depth) stored-node read path. + fn reference_path(leaves: &[[u8; 32]], leaf_index: usize) -> Vec<[u8; 32]> { + let mut current_level = leaves.to_vec(); + let mut path = Vec::with_capacity(20); + let mut target = leaf_index; + for level in 0..20 { + if current_level.len() % 2 != 0 { + current_level.push(get_zero_hash_cached(level)); + } + let sibling_idx = target ^ 1; + path.push(if sibling_idx < current_level.len() { + current_level[sibling_idx] + } else { + get_zero_hash_cached(level) + }); + let mut next = Vec::with_capacity(current_level.len().div_ceil(2)); + for chunk in current_level.chunks(2) { + let right = chunk + .get(1) + .copied() + .unwrap_or_else(|| get_zero_hash_cached(level)); + next.push(hash_pair_poseidon(&chunk[0], &right)); + } + current_level = next; + target /= 2; + } + path + } + + #[test] + fn stored_node_paths_match_recomputed_reference_for_every_leaf() { + new_test_ext().execute_with(|| { + // 37 leaves: odd count exercises zero-hash padding at several levels. + let leaves: Vec<[u8; 32]> = (0..37u8).map(|i| [i + 1; 32]).collect(); + for leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + for (i, leaf) in leaves.iter().enumerate() { + let path = MerkleTreeService::get_merkle_path::(i as u32).unwrap(); + let expected = reference_path(&leaves, i); + assert_eq!( + path.siblings.to_vec(), + expected, + "stored-node path for leaf {i} must equal recomputed path" + ); + assert!( + MerkleTreeService::verify_merkle_proof(&root, leaf, &path), + "leaf {i} proof must verify against the current root" + ); + } + }); + } + + #[test] + fn first_and_last_leaf_paths_verify() { + new_test_ext().execute_with(|| { + let leaves: Vec<[u8; 32]> = (0..8u8).map(|i| [0xA0 + i; 32]).collect(); + for leaf in &leaves { + MerkleTreeService::insert_leaf::(Commitment::new(*leaf)).unwrap(); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + for i in [0u32, 7] { + let path = MerkleTreeService::get_merkle_path::(i).unwrap(); + assert!(MerkleTreeService::verify_merkle_proof( + &root, + &leaves[i as usize], + &path + )); + } + }); + } + + #[test] + fn single_leaf_tree_path_is_all_zero_hashes() { + new_test_ext().execute_with(|| { + let leaf = [0x77u8; 32]; + MerkleTreeService::insert_leaf::(Commitment::new(leaf)).unwrap(); + let path = MerkleTreeService::get_merkle_path::(0).unwrap(); + for (level, sibling) in path.siblings.iter().enumerate() { + assert_eq!(*sibling, get_zero_hash_cached(level)); + } + let root = crate::storage::MerkleRepository::get_poseidon_root::(); + assert!(MerkleTreeService::verify_merkle_proof(&root, &leaf, &path)); + }); + } + + #[test] + fn stored_top_nodes_derive_poseidon_root() { + use crate::storage::MerkleRepository; + new_test_ext().execute_with(|| { + for i in 0..5u8 { + MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); + } + let left = MerkleRepository::get_node::(0, 19, 0).expect("top-left node stored"); + let right = MerkleRepository::get_node::(0, 19, 1) + .unwrap_or_else(|| get_zero_hash_cached(19)); + assert_eq!( + hash_pair_poseidon(&left, &right), + MerkleRepository::get_poseidon_root::(), + "level-19 nodes must hash to the stored root" + ); + }); + } + // ── Incremental frontier vs batch consistency ──────────────────────────── #[test] diff --git a/frame/shielded-pool/src/migrations.rs b/frame/shielded-pool/src/migrations.rs new file mode 100644 index 00000000..369db960 --- /dev/null +++ b/frame/shielded-pool/src/migrations.rs @@ -0,0 +1,163 @@ +//! Storage migrations for pallet-shielded-pool. + +use crate::{ + merkle::{get_zero_hash_cached, hash_pair_poseidon}, + pallet::{Config, Pallet}, + storage::MerkleRepository, +}; +use frame_support::{ + pallet_prelude::*, + traits::{GetStorageVersion, OnRuntimeUpgrade}, + weights::Weight, +}; +use sp_std::vec::Vec; + +pub mod v1 { + use super::*; + + /// Backfill `MerkleNodes` from `MerkleLeaves` (storage v0 -> v1). + /// + /// Rebuilds every internal node (levels 1..=19) that `insert_leaf` would + /// have written had `MerkleNodes` existed from genesis. One-shot cost: + /// O(n) Poseidon hashes plus O(n) storage writes for n existing leaves — + /// sized for the runtime-upgrade block, which tolerates overweight. + pub struct MigrateToV1(core::marker::PhantomData); + + impl OnRuntimeUpgrade for MigrateToV1 { + fn on_runtime_upgrade() -> Weight { + let onchain = Pallet::::on_chain_storage_version(); + if onchain >= 1 { + return T::DbWeight::get().reads(1); + } + + let size = MerkleRepository::get_tree_size::(); + let mut reads: u64 = 2; // storage version + tree size + let mut writes: u64 = 1; // storage version bump + + let mut level_nodes: Vec<[u8; 32]> = (0..size) + .filter_map(|i| MerkleRepository::get_leaf::(i).map(|c| c.0)) + .collect(); + reads = reads.saturating_add(size as u64); + + // Pair-hash upward, mirroring the frontier walk: a missing right + // sibling is the zero hash of the child level. Every level up to 19 + // gets written, matching what per-insert writes would have produced. + for level in 0..(crate::types::DEFAULT_TREE_DEPTH - 1) { + let mut next: Vec<[u8; 32]> = Vec::with_capacity(level_nodes.len().div_ceil(2)); + for pair in level_nodes.chunks(2) { + let left = pair[0]; + let right = pair + .get(1) + .copied() + .unwrap_or_else(|| get_zero_hash_cached(level)); + next.push(hash_pair_poseidon(&left, &right)); + } + for (i, node) in next.iter().enumerate() { + MerkleRepository::set_node::(0, (level + 1) as u8, i as u32, *node); + } + writes = writes.saturating_add(next.len() as u64); + level_nodes = next; + if level_nodes.is_empty() { + break; + } + } + + StorageVersion::new(1).put::>(); + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + Ok(MerkleRepository::get_tree_size::().encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + let size = u32::decode(&mut &state[..]) + .map_err(|_| sp_runtime::TryRuntimeError::Other("pre_upgrade state must decode"))?; + frame_support::ensure!( + Pallet::::on_chain_storage_version() >= 1, + sp_runtime::TryRuntimeError::Other("storage version not bumped") + ); + if size > 0 { + let top = (crate::types::DEFAULT_TREE_DEPTH - 1) as u8; // 19 + let left = MerkleRepository::get_node::(0, top, 0).ok_or( + sp_runtime::TryRuntimeError::Other("top-left node missing after backfill"), + )?; + let right = MerkleRepository::get_node::(0, top, 1) + .unwrap_or_else(|| get_zero_hash_cached(top as usize)); + frame_support::ensure!( + hash_pair_poseidon(&left, &right) == MerkleRepository::get_poseidon_root::(), + sp_runtime::TryRuntimeError::Other( + "backfilled nodes do not derive PoseidonRoot" + ) + ); + } + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::v1::MigrateToV1; + use crate::{ + merkle::MerkleTreeService, + mock::{Test, new_test_ext}, + pallet::{MerkleNodes, Pallet}, + storage::MerkleRepository, + types::Commitment, + }; + use frame_support::traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion}; + + fn insert_leaves(n: u8) { + for i in 0..n { + MerkleTreeService::insert_leaf::(Commitment::new([i + 1; 32])).unwrap(); + } + } + + #[test] + fn backfill_rebuilds_nodes_and_proofs_verify() { + new_test_ext().execute_with(|| { + insert_leaves(37); + let root = MerkleRepository::get_poseidon_root::(); + + // Simulate a v0 chain: leaves exist but internal nodes were never stored. + let _ = MerkleNodes::::clear(u32::MAX, None); + StorageVersion::new(0).put::>(); + + MigrateToV1::::on_runtime_upgrade(); + + assert_eq!(Pallet::::on_chain_storage_version(), 1); + for i in 0..37u32 { + let leaf = MerkleRepository::get_leaf::(i).unwrap(); + let path = MerkleTreeService::get_merkle_path::(i).unwrap(); + assert!( + MerkleTreeService::verify_merkle_proof(&root, &leaf.0, &path), + "leaf {i} proof must verify after backfill" + ); + } + }); + } + + #[test] + fn migration_is_idempotent_once_versioned() { + new_test_ext().execute_with(|| { + insert_leaves(4); + StorageVersion::new(1).put::>(); + let node_before = MerkleRepository::get_node::(0, 1, 0); + MigrateToV1::::on_runtime_upgrade(); + assert_eq!(MerkleRepository::get_node::(0, 1, 0), node_before); + }); + } + + #[test] + fn empty_tree_migration_only_bumps_version() { + new_test_ext().execute_with(|| { + StorageVersion::new(0).put::>(); + MigrateToV1::::on_runtime_upgrade(); + assert_eq!(Pallet::::on_chain_storage_version(), 1); + assert_eq!(MerkleNodes::::iter().count(), 0); + }); + } +} diff --git a/frame/shielded-pool/src/runtime_api_impl.rs b/frame/shielded-pool/src/runtime_api_impl.rs index e9c2012d..fb837d1f 100644 --- a/frame/shielded-pool/src/runtime_api_impl.rs +++ b/frame/shielded-pool/src/runtime_api_impl.rs @@ -30,13 +30,10 @@ impl Pallet { crate::merkle::MerkleTreeService::get_merkle_path::(leaf_index) } - /// Get Merkle proof for a given commitment + /// Get Merkle proof for a given commitment. /// - /// This scans all leaves in the tree to find the commitment. - /// Returns (leaf_index, proof) if found, None otherwise. - /// - /// Note: This is expensive as it requires scanning all leaves. - /// Should be used sparingly or cached off-chain. + /// O(1) reverse-index lookup plus an O(depth) sibling-path read from + /// `MerkleNodes`. Returns (leaf_index, proof) if found, None otherwise. pub fn get_merkle_proof_for_commitment(commitment: Hash) -> Option<(u32, DefaultMerklePath)> { let commitment_wrapped = Commitment(commitment); diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage.rs index 85e1e3e3..6b27b99c 100644 --- a/frame/shielded-pool/src/storage.rs +++ b/frame/shielded-pool/src/storage.rs @@ -6,8 +6,8 @@ use crate::{ pallet::{ Assets, BalanceOf, CommitmentMemos, CommitmentToLeafIndex, Config, HistoricPoseidonRoots, - HistoricRootsOrder, MerkleLeaves, MerkleTreeFrontier, MerkleTreeSize, NextAssetId, - NullifierSet, PoolBalancePerAsset, PoseidonRoot, TotalCommitmentsInserted, + HistoricRootsOrder, MerkleLeaves, MerkleNodes, MerkleTreeFrontier, MerkleTreeSize, + NextAssetId, NullifierSet, PoolBalancePerAsset, PoseidonRoot, TotalCommitmentsInserted, TotalNullifiersSpent, }, types::{AssetMetadata, Commitment, EncryptedMemo, Hash}, @@ -133,11 +133,11 @@ impl MerkleRepository { pub fn find_leaf_index(commitment: &Commitment) -> Option { Self::get_commitment_leaf_index::(commitment) } - pub fn get_all_leaves() -> sp_std::vec::Vec { - let size = Self::get_tree_size::(); - (0..size) - .filter_map(|i| Self::get_leaf::(i).map(|c| c.0)) - .collect() + pub fn get_node(tree_id: u32, level: u8, index: u32) -> Option { + MerkleNodes::::get((tree_id, level, index)) + } + pub fn set_node(tree_id: u32, level: u8, index: u32, node: Hash) { + MerkleNodes::::insert((tree_id, level, index), node); } } @@ -443,17 +443,16 @@ mod tests { } #[test] - fn merkle_repo_get_all_leaves_returns_all() { + fn merkle_repo_node_get_set_and_missing() { new_test_ext().execute_with(|| { - let c0 = test_commitment(0xB1); - let c1 = test_commitment(0xB2); - MerkleRepository::insert_leaf::(0, c0); - MerkleRepository::insert_leaf::(1, c1); - MerkleRepository::set_tree_size::(2); - let leaves = MerkleRepository::get_all_leaves::(); - assert_eq!(leaves.len(), 2); - assert!(leaves.contains(&c0.0)); - assert!(leaves.contains(&c1.0)); + assert_eq!(MerkleRepository::get_node::(0, 1, 0), None); + let node = [0xB1u8; 32]; + MerkleRepository::set_node::(0, 1, 0, node); + assert_eq!(MerkleRepository::get_node::(0, 1, 0), Some(node)); + // Distinct coordinates are independent + assert_eq!(MerkleRepository::get_node::(0, 1, 1), None); + assert_eq!(MerkleRepository::get_node::(0, 2, 0), None); + assert_eq!(MerkleRepository::get_node::(1, 1, 0), None); }); } diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index 072ca688..f8704906 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -2,9 +2,9 @@ //! Autogenerated weights for pallet_shielded_pool //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-07-11, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-07-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `ubuntu-32gb-fsn1-1`, CPU: `AMD EPYC-Genoa Processor` +//! HOSTNAME: `ubuntu-32gb-hel1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 // Executed Command: @@ -80,6 +80,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) @@ -90,10 +92,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1127` // Estimated: `4687` - // Minimum execution time: 748_837_000 picoseconds. - Weight::from_parts(763_347_000, 4687) + // Minimum execution time: 1_014_974_000 picoseconds. + Weight::from_parts(1_024_433_000, 4687) .saturating_add(T::DbWeight::get().reads(14_u64)) - .saturating_add(T::DbWeight::get().writes(13_u64)) + .saturating_add(T::DbWeight::get().writes(32_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -123,6 +125,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:35) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:20) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:20) @@ -134,14 +138,14 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1127` // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 770_687_000 picoseconds. - Weight::from_parts(7_926_673, 4687) - // Standard Error: 1_395_437 - .saturating_add(Weight::from_parts(760_833_028, 0).saturating_mul(n.into())) + // Minimum execution time: 1_007_933_000 picoseconds. + Weight::from_parts(63_329_265, 4687) + // Standard Error: 303_679 + .saturating_add(Weight::from_parts(966_398_277, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(13_u64)) .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(9_u64)) - .saturating_add(T::DbWeight::get().writes((4_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(26_u64)) + .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) @@ -178,6 +182,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:2) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:2) @@ -187,13 +193,13 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1182` // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 725_705_000 picoseconds. - Weight::from_parts(33_902_291, 4687) - // Standard Error: 2_472_578 - .saturating_add(Weight::from_parts(728_680_304, 0).saturating_mul(n.into())) + // Minimum execution time: 969_972_000 picoseconds. + Weight::from_parts(53_550_853, 4687) + // Standard Error: 4_924_563 + .saturating_add(Weight::from_parts(956_310_573, 0).saturating_mul(n.into())) .saturating_add(T::DbWeight::get().reads(15_u64)) .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(n.into()))) - .saturating_add(T::DbWeight::get().writes(9_u64)) + .saturating_add(T::DbWeight::get().writes(28_u64)) .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } @@ -229,8 +235,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `903` // Estimated: `6196` - // Minimum execution time: 101_489_000 picoseconds. - Weight::from_parts(104_063_000, 6196) + // Minimum execution time: 113_240_000 picoseconds. + Weight::from_parts(118_970_000, 6196) .saturating_add(T::DbWeight::get().reads(15_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } @@ -250,8 +256,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `239` // Estimated: `3631` - // Minimum execution time: 16_063_000 picoseconds. - Weight::from_parts(17_194_000, 3631) + // Minimum execution time: 18_560_000 picoseconds. + Weight::from_parts(20_220_000, 3631) .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } @@ -269,8 +275,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `328` // Estimated: `3631` - // Minimum execution time: 16_723_000 picoseconds. - Weight::from_parts(17_503_000, 3631) + // Minimum execution time: 21_810_000 picoseconds. + Weight::from_parts(24_540_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -288,8 +294,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `328` // Estimated: `3631` - // Minimum execution time: 16_253_000 picoseconds. - Weight::from_parts(16_853_000, 3631) + // Minimum execution time: 17_850_000 picoseconds. + Weight::from_parts(19_700_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -317,6 +323,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) @@ -327,10 +335,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1085` // Estimated: `4687` - // Minimum execution time: 740_796_000 picoseconds. - Weight::from_parts(745_853_000, 4687) + // Minimum execution time: 963_314_000 picoseconds. + Weight::from_parts(1_003_013_000, 4687) .saturating_add(T::DbWeight::get().reads(12_u64)) - .saturating_add(T::DbWeight::get().writes(12_u64)) + .saturating_add(T::DbWeight::get().writes(31_u64)) } } @@ -364,6 +372,8 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) @@ -374,10 +384,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1127` // Estimated: `4687` - // Minimum execution time: 748_837_000 picoseconds. - Weight::from_parts(763_347_000, 4687) + // Minimum execution time: 1_014_974_000 picoseconds. + Weight::from_parts(1_024_433_000, 4687) .saturating_add(RocksDbWeight::get().reads(14_u64)) - .saturating_add(RocksDbWeight::get().writes(13_u64)) + .saturating_add(RocksDbWeight::get().writes(32_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -407,6 +417,8 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) /// Proof: `ShieldedPool::PoolBalancePerAsset` (`max_values`: None, `max_size`: Some(36), added: 2511, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:35) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:20) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:20) @@ -418,14 +430,14 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1127` // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 770_687_000 picoseconds. - Weight::from_parts(7_926_673, 4687) - // Standard Error: 1_395_437 - .saturating_add(Weight::from_parts(760_833_028, 0).saturating_mul(n.into())) + // Minimum execution time: 1_007_933_000 picoseconds. + Weight::from_parts(63_329_265, 4687) + // Standard Error: 303_679 + .saturating_add(Weight::from_parts(966_398_277, 0).saturating_mul(n.into())) .saturating_add(RocksDbWeight::get().reads(13_u64)) .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(9_u64)) - .saturating_add(RocksDbWeight::get().writes((4_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(26_u64)) + .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) @@ -462,6 +474,8 @@ impl WeightInfo for () { /// Proof: `Relayer::RelayerRegistry` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) /// Storage: `Relayer::PendingRelayerFees` (r:1 w:1) /// Proof: `Relayer::PendingRelayerFees` (`max_values`: None, `max_size`: Some(84), added: 2559, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:2) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:2) @@ -471,13 +485,13 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1182` // Estimated: `4687 + n * (2705 ±0)` - // Minimum execution time: 725_705_000 picoseconds. - Weight::from_parts(33_902_291, 4687) - // Standard Error: 2_472_578 - .saturating_add(Weight::from_parts(728_680_304, 0).saturating_mul(n.into())) + // Minimum execution time: 969_972_000 picoseconds. + Weight::from_parts(53_550_853, 4687) + // Standard Error: 4_924_563 + .saturating_add(Weight::from_parts(956_310_573, 0).saturating_mul(n.into())) .saturating_add(RocksDbWeight::get().reads(15_u64)) .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(n.into()))) - .saturating_add(RocksDbWeight::get().writes(9_u64)) + .saturating_add(RocksDbWeight::get().writes(28_u64)) .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } @@ -513,8 +527,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `903` // Estimated: `6196` - // Minimum execution time: 101_489_000 picoseconds. - Weight::from_parts(104_063_000, 6196) + // Minimum execution time: 113_240_000 picoseconds. + Weight::from_parts(118_970_000, 6196) .saturating_add(RocksDbWeight::get().reads(15_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } @@ -534,8 +548,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `239` // Estimated: `3631` - // Minimum execution time: 16_063_000 picoseconds. - Weight::from_parts(17_194_000, 3631) + // Minimum execution time: 18_560_000 picoseconds. + Weight::from_parts(20_220_000, 3631) .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } @@ -553,8 +567,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `328` // Estimated: `3631` - // Minimum execution time: 16_723_000 picoseconds. - Weight::from_parts(17_503_000, 3631) + // Minimum execution time: 21_810_000 picoseconds. + Weight::from_parts(24_540_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -572,8 +586,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `328` // Estimated: `3631` - // Minimum execution time: 16_253_000 picoseconds. - Weight::from_parts(16_853_000, 3631) + // Minimum execution time: 17_850_000 picoseconds. + Weight::from_parts(19_700_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -601,6 +615,8 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::TotalCommitmentsInserted` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricRootsOrder` (r:1 w:1) /// Proof: `ShieldedPool::HistoricRootsOrder` (`max_values`: Some(1), `max_size`: Some(3202), added: 3697, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:0 w:19) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:0 w:1) /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleLeaves` (r:0 w:1) @@ -611,9 +627,9 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1085` // Estimated: `4687` - // Minimum execution time: 740_796_000 picoseconds. - Weight::from_parts(745_853_000, 4687) + // Minimum execution time: 963_314_000 picoseconds. + Weight::from_parts(1_003_013_000, 4687) .saturating_add(RocksDbWeight::get().reads(12_u64)) - .saturating_add(RocksDbWeight::get().writes(12_u64)) + .saturating_add(RocksDbWeight::get().writes(31_u64)) } } diff --git a/template/runtime/Cargo.toml b/template/runtime/Cargo.toml index 65ed0a64..7a41fe41 100644 --- a/template/runtime/Cargo.toml +++ b/template/runtime/Cargo.toml @@ -44,6 +44,7 @@ frame-support = { workspace = true, features = ["experimental"] } frame-system = { workspace = true } frame-system-benchmarking = { workspace = true, optional = true } frame-system-rpc-runtime-api = { workspace = true } +frame-try-runtime = { workspace = true, optional = true } pallet-account-mapping = { workspace = true } pallet-account-mapping-runtime-api = { workspace = true } pallet-aura = { workspace = true } @@ -128,6 +129,7 @@ std = [ "frame-executive/std", "frame-metadata-hash-extension/std", "frame-support/std", + "frame-try-runtime?/std", "frame-system/std", "frame-system-rpc-runtime-api/std", "frame-system-benchmarking?/std", @@ -167,7 +169,6 @@ std = [ "pallet-relayer/std", "pallet-relayer-runtime-api/std", "pallet-shielded-pool/std", - "pallet-shielded-pool/poseidon-native", "pallet-shielded-pool-runtime-api/std", "pallet-zk-verifier-runtime-api/std", # Polkadot @@ -202,6 +203,7 @@ skip-proof-verification = [ ] try-runtime = [ + "frame-try-runtime/try-runtime", "cumulus-pallet-weight-reclaim/try-runtime", "fp-self-contained/try-runtime", "frame-executive/try-runtime", diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md new file mode 100644 index 00000000..b7d2b113 --- /dev/null +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -0,0 +1,40 @@ +# `RuntimeVersion` history + +Log of every change to `VERSION` (`template/runtime/src/lib.rs`). Any change +to `spec_version` / `transaction_version` must add a row here in the same PR. + +## Bump rules + +- **`spec_version`**: any consensus-affecting change — execution logic, + storage, migrations, proof verification. Nodes reject blocks from a runtime + with a different `spec_version`; forkless upgrades (`system.setCode`) + require the new value to be greater. +- **`transaction_version`**: only when the SCALE encoding of extrinsics + changes (dispatch signatures, argument order/types). Invalidates + offline-signed extrinsics. +- **`impl_version`**: implementation-only changes with no consensus effect + (equivalent optimizations). Rarely touched. + +## Current era (testnet, post genesis reset 2026-07-15) + +The genesis reset (`69d1b837`) set `spec_version` back to 1 and +`transaction_version` to 1 for the public testnet launch. + +| spec | tx | Date | Commit | Change | +|------|----|------|--------|--------| +| 4 | 1 | 2026-07-30 | `63caafca` | shielded-pool 0.11.0: `MerkleNodes` storage (internal nodes written on every insert) + `MigrateToV1` migration (backfill, `STORAGE_VERSION` 1). O(depth) Merkle proofs. Weights re-benchmarked. The upgrade block runs the one-shot migration (~3s at ~90k leaves). | +| 3 | 1 | 2026-07-27 | `d9d40244` | Build with the metadata hash `CheckMetadataHash` requires. | +| 2 | 1 | 2026-07-26 | `c285ac3f` | shielded-pool 0.10.1: `recipient_to_field` reduces mod BN254 r — fixes rejection of most Substrate recipients in `unshield` (consensus halt). | +| 1 | 1 | 2026-07-15 | `69d1b837` | Testnet genesis. Version reset (came from spec 6 in the dev era). | + +## Previous era (dev, pre-reset) + +History prior to the reset — does not correspond to any live chain. + +| spec | tx | Date | Commit | Change | +|------|----|------|--------|--------| +| 6 | 2 | 2026-07-10 | `28decfe4` | `circuit_version` carried in the memo (`MAX_ENCRYPTED_MEMO_SIZE` 176→180). | +| 5 | 2 | 2026-07-09 | `aafb5989` | Per-note circuit versioning with version retirement; `private_transfer`/`unshield`/`claim_shielded_fees` gain a `circuit_version` arg (tx 1→2). | +| 3 | 1 | 2026-07-06 | `2ffd2fce` | Shielded pool hardening. | +| 2 | 1 | 2026-07-04 | `8930d626` | zk-verifier hardening. | +| 1 | 1 | — | `ce10bb01` | Initial Substrate template. | diff --git a/template/runtime/build.rs b/template/runtime/build.rs index 8ce0cbac..ccc1f0c2 100644 --- a/template/runtime/build.rs +++ b/template/runtime/build.rs @@ -1,12 +1,18 @@ fn main() { #[cfg(feature = "std")] { - substrate_wasm_builder::WasmBuilder::new() + let mut builder = substrate_wasm_builder::WasmBuilder::new() .with_current_project() .export_heap_base() - .import_memory() - .enable_feature("poseidon-native-runtime") - .enable_metadata_hash("ORB", 18) - .build(); + .import_memory(); + + // Poseidon via host functions (~3× faster). Skipped for try-runtime + // builds: the stock try-runtime CLI executor lacks the custom host + // interface, so those builds fall back to the pure-wasm hasher. + if std::env::var("CARGO_FEATURE_TRY_RUNTIME").is_err() { + builder = builder.enable_feature("poseidon-native-runtime"); + } + + builder.enable_metadata_hash("ORB", 18).build(); } } diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index 2dc939b7..306cc7c7 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -152,6 +152,9 @@ pub type CheckedExtrinsic = /// The payload being signed in transactions. pub type SignedPayload = generic::SignedPayload; +/// Storage migrations run on runtime upgrade, oldest first. +pub type Migrations = (pallet_shielded_pool::migrations::v1::MigrateToV1,); + /// Executive: handles dispatch to the various modules. pub type Executive = frame_executive::Executive< Runtime, @@ -159,6 +162,7 @@ pub type Executive = frame_executive::Executive< frame_system::ChainContext, Runtime, AllPalletsWithSystem, + Migrations, >; // Time is measured by number of blocks. @@ -197,7 +201,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 3, + spec_version: 4, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, @@ -920,6 +924,25 @@ impl_runtime_apis! { } } + #[cfg(feature = "try-runtime")] + impl frame_try_runtime::TryRuntime for Runtime { + fn on_runtime_upgrade(checks: frame_try_runtime::UpgradeCheckSelect) -> (Weight, Weight) { + let weight = Executive::try_runtime_upgrade(checks) + .expect("try_runtime_upgrade must not fail"); + (weight, BlockWeights::get().max_block) + } + + fn execute_block( + block: ::LazyBlock, + state_root_check: bool, + signature_check: bool, + select: frame_try_runtime::TryStateSelect, + ) -> Weight { + Executive::try_execute_block(block, state_root_check, signature_check, select) + .expect("try_execute_block must not fail") + } + } + impl sp_genesis_builder::GenesisBuilder for Runtime { fn build_state(config: Vec) -> sp_genesis_builder::Result { build_state::(config)