diff --git a/Cargo.lock b/Cargo.lock index 48533ebc..68f0973c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8104,7 +8104,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.13.0" +version = "0.14.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs index 93aef6c9..0eb444cd 100644 --- a/frame/evm/precompile/shielded-pool/src/mock.rs +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -120,6 +120,7 @@ parameter_types! { pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shldpool"); pub const MaxTreeDepth: u32 = 20; pub const MaxHistoricRoots: u32 = 100; + pub const RootRetentionBlocks: u64 = 128; pub const MaxLeavesPerTree: u32 = 8; pub const MinShieldAmount: u128 = 100; } @@ -245,6 +246,7 @@ impl pallet_shielded_pool::Config for Test { type PalletId = ShieldedPoolPalletId; type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; + type RootRetentionBlocks = RootRetentionBlocks; type MaxLeavesPerTree = MaxLeavesPerTree; type MinShieldAmount = MinShieldAmount; type WeightInfo = (); diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 42f9eeed..6ee2eef5 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -2,6 +2,101 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. +## [0.14.0] - 2026-08-05 + +### Changed +- **Historic-root window is now measured in blocks, not in inserts.** The window + was bounded by `MaxHistoricRoots` (100) counted in *leaf insertions*, while an + unsigned transaction stays valid in the pool for `TX_LONGEVITY` (64) *blocks*. + A `private_transfer` inserts up to 2 commitments, so ~50 transfers rotated the + whole window: under load an honest spend passed `validate_unsigned`, gossiped + across the network, and only then reverted with `UnknownMerkleRoot`, with its + nullifier still unspent. No attacker was required — ordinary throughput did it, + and an attacker could force it cheaply with valid transfers. +- New Config constant **`RootRetentionBlocks`** (production 300 blocks, ~30 min at + 6s) sets the window. `integrity_test` asserts it exceeds `TX_LONGEVITY`, so a + runtime that misaligns the two units refuses to build. +- **`MaxHistoricRoots` changed meaning**: it is no longer the window but a safety + cap on queue length (production 16384, sized for ~54 sustained inserts/block). + Reaching it logs a warning rather than silently shortening the window. +- `HistoricPoseidonRoots` value type `bool` → `BlockNumberFor` (the block at + which a root stops being accepted), `OptionQuery`. +- `HistoricRootsOrder` (a single `BoundedVec`) replaced by a slot-indexed queue: + `HistoricRootsQueue: StorageMap` plus `HistoricRootsHead` + and `HistoricRootsTail`. A `StorageValue` would be read and rewritten in full on + every leaf insert — hundreds of KiB on the hottest path once the window holds + thousands of roots. The map touches one entry plus the few it prunes. +- Pruning is lazy and capped at `MAX_ROOTS_PRUNED_PER_INSERT` (4) per insert, so + one extrinsic never pays for a backlog it did not create. Leftovers drain on + subsequent inserts; a stale queue slot is harmless because spendability is + decided by the expiry stored per root, never by queue membership. +- **Weights re-benchmarked against the v3 layout.** The recorded storage access + now covers `HistoricRootsQueue` / `HistoricRootsHead` / `HistoricRootsTail` and + drops the `HistoricRootsOrder` entry, which no longer exists. The measurement + exercises a queue with expired entries, so the per-insert pruning is charged + rather than free: `shield` 17r/34w, `unshield` 16r/8w, and per-`n` components + on `shield_batch` (2r/6w) and `private_transfer` (3r/6w). +- **Pool-rejection code for `amount + fee` overflow moved from `Custom(2)` to + `Custom(4)`.** Code 2 previously meant two different things depending on which + validator rejected the transaction — "all inputs are dummy" in + `private_transfer`, "amount overflow" in `unshield` — so a `Custom error: 2` in + a node log was ambiguous. The other codes keep their values. Codes are part of + the observable interface, so a test now pins them. +- `is_known_root` now accepts the **active root unconditionally**. Expiries are + only refreshed by a leaf insert and the pallet has no hooks, so on a chain idle + for a full window the current root — the one every wallet proves against — + would expire and wedge the pool: `private_transfer` and `unshield` both need a + known root, and only a funded `shield` could mint a new one. + +### Added +- `migrations::v3::MigrateToV3` (`STORAGE_VERSION` 2 → 3). Both value types + changed, so v2 entries cannot be decoded by the new definitions and are + rewritten here. Every existing root is kept and granted a full window from the + upgrade block rather than dropped: a root on chain backs proofs wallets may be + about to submit. The old map is enumerated by **raw key** rather than + `translate`, which does not delete an entry whose value fails to decode — it + logs, skips, and leaves the bytes in place, which for a 1-byte `bool` under a + 4-byte slot would leave an unreadable, unprunable residue. Map and queue are + written in the same loop so the two can never diverge. + +### Fixed +- Honest spends no longer revert with `UnknownMerkleRoot` after passing pool + admission: a root now outlives every transaction admitted against it. +- Map and queue can no longer diverge, which previously left roots reachable by + neither the pruner nor an eviction path — permanently spendable and impossible + to remove. +- The cap path no longer drops a root whose window has not elapsed; a live root + is re-queued instead of being forgotten. +- Reaching the queue cap logs a warning instead of using `defensive!`, which + expands to `debug_assert!(false)` and would halt a validator running a + debug-assertions build on a reachable operational state. + +### Refactored +- The four largest modules were split into directories, one file per + responsibility. No behaviour change: every type and function keeps its path + through re-exports, so no caller outside the pallet was touched. + - `merkle.rs` (1642 lines) → `merkle/` — `hashing`, `tree`, `batch`, `service`. + The split isolates the only storage-touching layer (`service`) from the pure + maths; `batch` now states in its own docs that it is O(n) and used off-chain + only, which the single file left implicit. + - `storage.rs` (658 lines) → `storage/` — one module per repository: `asset`, + `commitment`, `merkle`, `nullifier`, `stats`, `balance`. + - `validate_unsigned.rs` (683 lines) → `validate_unsigned/` — `transfer`, + `unshield`, and a new `codes` module holding the named rejection codes that + were previously bare literals duplicated across both validators. + - `types.rs` (719 lines) → `types/` — `ids`, `note`, `merkle`, `memo`, `asset`. + +### Verification +298 pallet tests, 65 precompile tests, and 78 end-to-end checks against a running +dev node covering `shield_batch`, same-block bursts, idle chains, the steady-state +plateau (299 → 301 slots after +30 inserts), orphan detection in both directions, +and a spend against a root the chain has churned past. + +### Notes +- `spec_version` moves 6 → 7 in this release: the storage layout changes and a + migration is back in the tuple. `transaction_version` stays — no call + signature changed. + ## [0.13.0] - 2026-08-04 ### Removed diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index b4e5a80b..fc7f1756 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.13.0" +version = "0.14.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 213abf10..acfcfab4 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -23,7 +23,7 @@ use alloc::vec; mod benchmarks { use super::*; use crate::FrameEncryptedMemo; - use crate::pallet::{Assets, HistoricPoseidonRoots, NextAssetId, PoolBalancePerAsset}; + use crate::pallet::{Assets, NextAssetId, PoolBalancePerAsset}; use pallet_relayer::RelayerInterface; use sp_core::H160; use sp_std::vec::Vec; @@ -107,7 +107,7 @@ mod benchmarks { let merkle_root = [1u8; 32]; // Setup valid root in storage - HistoricPoseidonRoots::::insert(merkle_root, true); + crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); let proof: BoundedVec> = vec![0u8; 128].try_into().unwrap(); @@ -154,7 +154,7 @@ mod benchmarks { let amount: BalanceOf = T::MinShieldAmount::get() * 10u32.into(); // Setup valid state: root and pool balance - HistoricPoseidonRoots::::insert(merkle_root, true); + crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); PoolBalancePerAsset::::insert(asset_id, amount * 2u32.into()); // Fund pool account too for actual transfer let _ = >::make_free_balance_be( diff --git a/frame/shielded-pool/src/genesis.rs b/frame/shielded-pool/src/genesis.rs index d3fddb00..5c1e1d49 100644 --- a/frame/shielded-pool/src/genesis.rs +++ b/frame/shielded-pool/src/genesis.rs @@ -2,13 +2,13 @@ use crate::{ pallet::{ - Assets, Config, HistoricPoseidonRoots, HistoricRootsOrder, MerkleTreeFrontier, NextAssetId, - PoseidonRoot, + Assets, Config, HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, + HistoricRootsTail, MerkleTreeFrontier, NextAssetId, PoseidonRoot, }, types::AssetMetadata, types::Hash, }; -use frame_support::{pallet_prelude::*, traits::Get}; +use frame_support::traits::Get; use sp_runtime::traits::AccountIdConversion; /// Helper function to initialize genesis state @@ -22,13 +22,13 @@ pub fn initialize_genesis(initial_root: Hash) { // starts from the correct baseline. MerkleTreeFrontier::::put([[0u8; 32]; 20]); - // Add genesis root to historic roots - HistoricPoseidonRoots::::insert(initial_root, true); - - // Initialize the order list with the genesis root - let mut order = BoundedVec::new(); - let _ = order.try_push(initial_root); - HistoricRootsOrder::::put(order); + // Seed the historic-root window with the genesis root, expiring one full + // retention window from block zero like any other root. + let expires_at = T::RootRetentionBlocks::get(); + HistoricPoseidonRoots::::insert(initial_root, expires_at); + HistoricRootsQueue::::insert(0u64, (initial_root, expires_at)); + HistoricRootsHead::::put(1u64); + HistoricRootsTail::::put(0u64); // Register native asset (asset_id = 0) at genesis let native_asset = AssetMetadata { @@ -83,7 +83,7 @@ mod tests { new_test_ext().execute_with(|| { let root = PoseidonRoot::::get(); assert!( - HistoricPoseidonRoots::::get(root), + HistoricPoseidonRoots::::get(root).is_some(), "Genesis root must be in historic roots" ); }); @@ -104,7 +104,7 @@ mod tests { let custom_root = [0xCDu8; 32]; super::initialize_genesis::(custom_root); assert!( - HistoricPoseidonRoots::::get(custom_root), + HistoricPoseidonRoots::::get(custom_root).is_some(), "Custom root must be in historic roots" ); }); diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 76f6e07e..4c223a1e 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; @@ -103,11 +104,12 @@ pub mod pallet { /// Storage version history: /// - v1: `MerkleNodes` (internal Merkle tree nodes), backfilled from `MerkleLeaves`. + /// Migration removed once every live chain reached v2 — see git history. /// - v2: multi-tree forest — `SealedTreeRoots` / `SealedRootIndex` (start empty). - /// - /// The v1/v2 migration code was removed once every live chain reached v2; a new - /// chain starts here via genesis. See git history if an old chain ever needs it. - pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(2); + /// Migration removed once every live chain reached v2 — see git history. + /// - v3: historic-root window re-anchored from insert counts to block numbers + /// (`migrations::v3::MigrateToV3`); both historic-root items carry an expiry. + pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(3); #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] @@ -143,10 +145,24 @@ pub mod pallet { #[pallet::constant] type MaxLeavesPerTree: Get; - /// Maximum number of historic roots to keep + /// Safety cap on the historic-root queue: the most roots kept at once, + /// and the most a single insert may prune. This bounds worst-case work + /// and storage — the retention *window* is [`Self::RootRetentionBlocks`]. + /// Size it above the roots produced in one window, or it becomes the + /// binding constraint and the window silently shortens. #[pallet::constant] type MaxHistoricRoots: Get; + /// How long a historic root stays spendable, in blocks. + /// + /// Must exceed the mempool longevity of an unsigned transaction + /// (`TX_LONGEVITY`), otherwise a transaction can be admitted against a + /// root that expires before it is included — it would propagate, reach a + /// block, and only then revert with `UnknownMerkleRoot`. Enforced by + /// `integrity_test`. + #[pallet::constant] + type RootRetentionBlocks: Get>; + /// Minimum amount that can be shielded #[pallet::constant] type MinShieldAmount: Get>; @@ -229,15 +245,44 @@ pub mod pallet { #[pallet::storage] pub type SealedRootIndex = StorageMap<_, Blake2_128Concat, Hash, u32, OptionQuery>; - /// Historic Poseidon Merkle roots (for proving against recent states) + /// Historic Poseidon Merkle roots (for proving against recent states), + /// mapped to the block at which each stops being accepted. + /// + /// Expiry is measured in **blocks**, not in insertions, so the window + /// always outlives the mempool longevity a transaction was admitted with. + /// Counting insertions instead made the window rotate faster than + /// transactions expire under load, so honest spends reverted with + /// `UnknownMerkleRoot` after propagating. + #[pallet::storage] + pub type HistoricPoseidonRoots = + StorageMap<_, Blake2_128Concat, Hash, BlockNumberFor, OptionQuery>; + + /// Expiry queue for historic roots: monotonic slot -> `(root, expires_at)`. + /// + /// A map rather than one vector on purpose. The window has to hold a full + /// `RootRetentionBlocks` worth of roots — thousands under load — and a + /// `StorageValue` would be read and rewritten in full on every single leaf + /// insert, turning a hot path into hundreds of KiB of I/O. Keyed by slot, + /// each insert touches exactly one entry plus the few it prunes. + /// + /// Slots are handed out by [`HistoricRootsHead`] and consumed from + /// [`HistoricRootsTail`], so the queue drains in insertion order, which is + /// also expiry order (every insert stores `now + retention` with a + /// non-decreasing `now`). + #[pallet::storage] + pub type HistoricRootsQueue = + StorageMap<_, Twox64Concat, u64, (Hash, BlockNumberFor), OptionQuery>; + + /// Next slot to write in [`HistoricRootsQueue`]. Monotonic; never reset. #[pallet::storage] - pub type HistoricPoseidonRoots = StorageMap<_, Blake2_128Concat, Hash, bool, ValueQuery>; + pub type HistoricRootsHead = StorageValue<_, u64, ValueQuery>; - /// Order of historic roots (FIFO queue for pruning) - /// Stores roots in insertion order, oldest first + /// Oldest slot still queued in [`HistoricRootsQueue`]. Monotonic; never reset. + /// + /// `head - tail` is the number of live entries, bounded in practice by the + /// retention window and hard-capped by `MaxHistoricRoots`. #[pallet::storage] - pub type HistoricRootsOrder = - StorageValue<_, BoundedVec, ValueQuery>; + pub type HistoricRootsTail = StorageValue<_, u64, ValueQuery>; /// Encrypted memos for commitments /// @@ -340,8 +385,20 @@ pub mod pallet { assert!( T::MaxHistoricRoots::get() > 0, - "MaxHistoricRoots must be non-zero, otherwise the historic-root map \ - grows unbounded and the root window never evicts" + "MaxHistoricRoots must be non-zero, otherwise no root can ever be stored" + ); + + // The retention window must outlive the mempool longevity an unsigned + // transaction is admitted with, or a spend can pass validation, get + // gossiped, and only revert once included — the failure SP-20 fixed. + let retention: u64 = + sp_runtime::traits::UniqueSaturatedInto::::unique_saturated_into( + T::RootRetentionBlocks::get(), + ); + assert!( + retention > crate::validate_unsigned::TX_LONGEVITY, + "RootRetentionBlocks must exceed TX_LONGEVITY, otherwise a root can \ + expire while a transaction admitted against it is still valid in the pool" ); let cap = T::MaxLeavesPerTree::get(); diff --git a/frame/shielded-pool/src/merkle/batch.rs b/frame/shielded-pool/src/merkle/batch.rs new file mode 100644 index 00000000..1edd4b92 --- /dev/null +++ b/frame/shielded-pool/src/merkle/batch.rs @@ -0,0 +1,89 @@ +//! Whole-tree root computation from a leaf set. +//! +//! O(n) in the number of leaves, so it is **not** used on any dispatchable path +//! — the on-chain insert walks the frontier in O(depth) instead. Kept for tests +//! and off-chain tooling that need to rebuild a root from scratch. + +use super::hashing::{hash_pair, hash_pair_poseidon}; +use crate::types::Hash; +use sp_std::vec::Vec; + +pub fn compute_root_from_leaves_poseidon(leaves: &[Hash]) -> Hash { + if leaves.is_empty() { + return [0u8; 32]; + } + + let mut zero_hashes = [[0u8; 32]; 21]; + zero_hashes[0] = [0u8; 32]; + for i in 1..=20 { + zero_hashes[i] = hash_pair_poseidon(&zero_hashes[i - 1], &zero_hashes[i - 1]); + } + + let mut current_level: Vec = leaves.to_vec(); + + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + current_level.push(zero_hashes[level]); + } + let mut next_level = Vec::new(); + for i in (0..current_level.len()).step_by(2) { + next_level.push(hash_pair_poseidon(¤t_level[i], ¤t_level[i + 1])); + } + current_level = next_level; + if current_level.len() == 1 && level + 1 < DEPTH { + let mut root = current_level[0]; + for zero_hash in zero_hashes.iter().skip(level + 1).take(DEPTH - level - 1) { + root = hash_pair_poseidon(&root, zero_hash); + } + return root; + } + } + current_level.first().copied().unwrap_or([0u8; 32]) +} + +pub fn compute_root_from_leaves(leaves: &[Hash]) -> Hash { + if leaves.is_empty() { + let mut current = [0u8; 32]; + for _ in 0..DEPTH { + current = hash_pair(¤t, ¤t); + } + return current; + } + let mut current_level: Vec = leaves.to_vec(); + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + let mut zero = [0u8; 32]; + for _ in 0..level { + zero = hash_pair(&zero, &zero); + } + current_level.push(zero); + } + let mut next_level = Vec::new(); + for chunk in current_level.chunks(2) { + let left = chunk[0]; + let right = if chunk.len() > 1 { + chunk[1] + } else { + let mut zero = [0u8; 32]; + for _ in 0..level { + zero = hash_pair(&zero, &zero); + } + zero + }; + next_level.push(hash_pair(&left, &right)); + } + current_level = next_level; + if current_level.len() == 1 && level + 1 < DEPTH { + let mut zero = [0u8; 32]; + for _ in 0..=level { + zero = hash_pair(&zero, &zero); + } + for _ in (level + 1)..DEPTH { + current_level[0] = hash_pair(¤t_level[0], &zero); + zero = hash_pair(&zero, &zero); + } + break; + } + } + current_level.first().copied().unwrap_or([0u8; 32]) +} diff --git a/frame/shielded-pool/src/merkle/hashing.rs b/frame/shielded-pool/src/merkle/hashing.rs new file mode 100644 index 00000000..9568a5c8 --- /dev/null +++ b/frame/shielded-pool/src/merkle/hashing.rs @@ -0,0 +1,67 @@ +//! Poseidon hashing for Merkle nodes. +//! +//! Pure functions over 32-byte field elements — no storage, no `Config`. The +//! zero-hash ladder is cached because every path read and every insert needs the +//! empty-subtree digest for each level. + +use alloc::boxed::Box; +use ark_ff::BigInteger; + +/// Default hash for empty nodes at each level. +pub fn zero_hash_at_level(level: usize) -> [u8; 32] { + if level == 0 { + return [0u8; 32]; + } + let prev = zero_hash_at_level(level - 1); + hash_pair(&prev, &prev) +} + +/// Cached zero hashes for Poseidon (lazy-initialized, thread-safe). +static ZERO_HASHES_POSEIDON: once_cell::race::OnceBox<[[u8; 32]; 21]> = + once_cell::race::OnceBox::new(); + +/// Get precomputed zero hash at level (optimized with cache). +#[inline] +pub fn get_zero_hash_cached(level: usize) -> [u8; 32] { + if level < 21 { + let cache = ZERO_HASHES_POSEIDON.get_or_init(|| { + let mut hashes = [[0u8; 32]; 21]; + hashes[0] = [0u8; 32]; + for i in 1..21 { + hashes[i] = hash_pair_poseidon(&hashes[i - 1], &hashes[i - 1]); + } + Box::new(hashes) + }); + return cache[level]; + } + zero_hash_at_level(level) +} + +/// Hash two nodes together using Poseidon (ZK-friendly, ~300 constraints). +#[inline] +pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + use ark_bn254::Fr as Bn254Fr; + use ark_ff::PrimeField; + use orbinum_zk_core::{FieldElement, PoseidonHasher}; + + let left_fr = Bn254Fr::from_le_bytes_mod_order(left); + let right_fr = Bn254Fr::from_le_bytes_mod_order(right); + + #[cfg(feature = "poseidon-native")] + let hasher = orbinum_zk_core::NativePoseidonHasher; + #[cfg(not(feature = "poseidon-native"))] + let hasher = orbinum_zk_core::LightPoseidonHasher; + + let hash_fr = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]); + + let mut hash_bytes = [0u8; 32]; + let bigint = hash_fr.inner().into_bigint(); + let bytes = bigint.to_bytes_le(); + hash_bytes.copy_from_slice(&bytes[..32]); + hash_bytes +} + +/// Hash pair — always uses Poseidon. +pub fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + hash_pair_poseidon(left, right) +} diff --git a/frame/shielded-pool/src/merkle.rs b/frame/shielded-pool/src/merkle/mod.rs similarity index 62% rename from frame/shielded-pool/src/merkle.rs rename to frame/shielded-pool/src/merkle/mod.rs index cf6544e3..c357e568 100644 --- a/frame/shielded-pool/src/merkle.rs +++ b/frame/shielded-pool/src/merkle/mod.rs @@ -1,477 +1,52 @@ -//! Merkle tree — data structures, Poseidon hashing, and tree service. +//! Merkle tree — hashing, the incremental structure, and the on-chain service. //! -//! Merges the former `infrastructure/merkle_tree.rs` (IncrementalMerkleTree, -//! hash functions) with `infrastructure/services/merkle_tree_service.rs` -//! (on-chain tree management using repositories). - -use crate::{ - pallet::{CommitmentMemos, Config, Error, Event, Pallet}, - storage::{MerkleRepository, PoolStatsRepository}, - types::{Commitment, DefaultMerklePath, Hash, MerklePath}, -}; -use alloc::boxed::Box; -use ark_ff::BigInteger; -use frame_support::{ensure, pallet_prelude::*, traits::Get}; -use sp_std::vec::Vec; - -// ════════════════════════════════════════════════════════════════════════════ -// Hash helpers -// ════════════════════════════════════════════════════════════════════════════ - -/// Default hash for empty nodes at each level. -pub fn zero_hash_at_level(level: usize) -> [u8; 32] { - if level == 0 { - return [0u8; 32]; - } - let prev = zero_hash_at_level(level - 1); - hash_pair(&prev, &prev) -} - -/// Cached zero hashes for Poseidon (lazy-initialized, thread-safe). -static ZERO_HASHES_POSEIDON: once_cell::race::OnceBox<[[u8; 32]; 21]> = - once_cell::race::OnceBox::new(); - -/// Get precomputed zero hash at level (optimized with cache). -#[inline] -pub fn get_zero_hash_cached(level: usize) -> [u8; 32] { - if level < 21 { - let cache = ZERO_HASHES_POSEIDON.get_or_init(|| { - let mut hashes = [[0u8; 32]; 21]; - hashes[0] = [0u8; 32]; - for i in 1..21 { - hashes[i] = hash_pair_poseidon(&hashes[i - 1], &hashes[i - 1]); - } - Box::new(hashes) - }); - return cache[level]; - } - zero_hash_at_level(level) -} - -/// Hash two nodes together using Poseidon (ZK-friendly, ~300 constraints). -#[inline] -pub fn hash_pair_poseidon(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - use ark_bn254::Fr as Bn254Fr; - use ark_ff::PrimeField; - use orbinum_zk_core::{FieldElement, PoseidonHasher}; - - let left_fr = Bn254Fr::from_le_bytes_mod_order(left); - let right_fr = Bn254Fr::from_le_bytes_mod_order(right); - - #[cfg(feature = "poseidon-native")] - let hasher = orbinum_zk_core::NativePoseidonHasher; - #[cfg(not(feature = "poseidon-native"))] - let hasher = orbinum_zk_core::LightPoseidonHasher; - - let hash_fr = hasher.hash_2([FieldElement::new(left_fr), FieldElement::new(right_fr)]); - - let mut hash_bytes = [0u8; 32]; - let bigint = hash_fr.inner().into_bigint(); - let bytes = bigint.to_bytes_le(); - hash_bytes.copy_from_slice(&bytes[..32]); - hash_bytes -} - -/// Hash pair — always uses Poseidon. -pub fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { - hash_pair_poseidon(left, right) -} - -// ════════════════════════════════════════════════════════════════════════════ -// IncrementalMerkleTree (data structure, no storage access) -// ════════════════════════════════════════════════════════════════════════════ - -#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug)] -pub struct IncrementalMerkleTree { - pub frontier: [[u8; 32]; DEPTH], - pub next_index: u32, - pub root: [u8; 32], -} - -impl Default for IncrementalMerkleTree { - fn default() -> Self { - Self::new() - } -} - -impl IncrementalMerkleTree { - pub fn new() -> Self { - let root = Self::compute_empty_root(); - Self { - frontier: [[0u8; 32]; DEPTH], - next_index: 0, - root, - } - } - - fn compute_empty_root() -> [u8; 32] { - let mut current = [0u8; 32]; - for _ in 0..DEPTH { - current = hash_pair(¤t, ¤t); - } - current - } - - fn zero_hash(level: usize) -> [u8; 32] { - get_zero_hash_cached(level) - } - - pub fn capacity(&self) -> u32 { - 1u32 << DEPTH - } - pub fn is_full(&self) -> bool { - self.next_index >= self.capacity() - } - - pub fn insert(&mut self, leaf: [u8; 32]) -> Result { - if self.is_full() { - return Err("Merkle tree is full"); - } - let index = self.next_index; - let mut current_hash = leaf; - let mut current_index = index; - - for level in 0..DEPTH { - if current_index % 2 == 0 { - self.frontier[level] = current_hash; - let zero = Self::zero_hash(level); - current_hash = hash_pair(¤t_hash, &zero); - } else { - current_hash = hash_pair(&self.frontier[level], ¤t_hash); - } - current_index /= 2; - } - - self.root = current_hash; - self.next_index += 1; - Ok(index) - } - - pub fn root(&self) -> [u8; 32] { - self.root - } - pub fn size(&self) -> u32 { - self.next_index - } - - pub fn generate_proof( - &self, - leaf_index: u32, - leaves: &[[u8; 32]], - ) -> Result, &'static str> { - if leaf_index >= self.next_index { - return Err("Leaf index out of bounds"); - } - if leaves.len() != self.next_index as usize { - return Err("Leaves count mismatch"); - } - - let mut siblings = [[0u8; 32]; DEPTH]; - let mut indices = [0u8; DEPTH]; - let mut current_level = leaves.to_vec(); - let mut target_index = leaf_index as usize; - - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - current_level.push(Self::zero_hash(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] - } else { - Self::zero_hash(level) - }; - let mut next_level = Vec::new(); - for chunk in current_level.chunks(2) { - let left = chunk[0]; - let right = if chunk.len() > 1 { - chunk[1] - } else { - Self::zero_hash(level) - }; - next_level.push(hash_pair(&left, &right)); - } - current_level = next_level; - target_index /= 2; - } - Ok(MerklePath { siblings, indices }) - } - - pub fn verify_proof(root: &[u8; 32], leaf: &[u8; 32], path: &MerklePath) -> bool { - let mut current = *leaf; - for level in 0..DEPTH { - let sibling = &path.siblings[level]; - current = if path.indices[level] == 0 { - hash_pair(¤t, sibling) - } else { - hash_pair(sibling, ¤t) - }; - } - ¤t == root - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Root computation helpers -// ════════════════════════════════════════════════════════════════════════════ - -pub fn compute_root_from_leaves_poseidon(leaves: &[Hash]) -> Hash { - if leaves.is_empty() { - return [0u8; 32]; - } - - let mut zero_hashes = [[0u8; 32]; 21]; - zero_hashes[0] = [0u8; 32]; - for i in 1..=20 { - zero_hashes[i] = hash_pair_poseidon(&zero_hashes[i - 1], &zero_hashes[i - 1]); - } - - let mut current_level: Vec = leaves.to_vec(); - - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - current_level.push(zero_hashes[level]); - } - let mut next_level = Vec::new(); - for i in (0..current_level.len()).step_by(2) { - next_level.push(hash_pair_poseidon(¤t_level[i], ¤t_level[i + 1])); - } - current_level = next_level; - if current_level.len() == 1 && level + 1 < DEPTH { - let mut root = current_level[0]; - for zero_hash in zero_hashes.iter().skip(level + 1).take(DEPTH - level - 1) { - root = hash_pair_poseidon(&root, zero_hash); - } - return root; - } - } - current_level.first().copied().unwrap_or([0u8; 32]) -} - -pub fn compute_root_from_leaves(leaves: &[Hash]) -> Hash { - if leaves.is_empty() { - let mut current = [0u8; 32]; - for _ in 0..DEPTH { - current = hash_pair(¤t, ¤t); - } - return current; - } - let mut current_level: Vec = leaves.to_vec(); - for level in 0..DEPTH { - if current_level.len() % 2 != 0 { - let mut zero = [0u8; 32]; - for _ in 0..level { - zero = hash_pair(&zero, &zero); - } - current_level.push(zero); - } - let mut next_level = Vec::new(); - for chunk in current_level.chunks(2) { - let left = chunk[0]; - let right = if chunk.len() > 1 { - chunk[1] - } else { - let mut zero = [0u8; 32]; - for _ in 0..level { - zero = hash_pair(&zero, &zero); - } - zero - }; - next_level.push(hash_pair(&left, &right)); - } - current_level = next_level; - if current_level.len() == 1 && level + 1 < DEPTH { - let mut zero = [0u8; 32]; - for _ in 0..=level { - zero = hash_pair(&zero, &zero); - } - for _ in (level + 1)..DEPTH { - current_level[0] = hash_pair(¤t_level[0], &zero); - zero = hash_pair(&zero, &zero); - } - break; - } - } - current_level.first().copied().unwrap_or([0u8; 32]) -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerkleTreeService (on-chain tree management, uses repositories) -// ════════════════════════════════════════════════════════════════════════════ - -pub struct MerkleTreeService; - -impl MerkleTreeService { - /// Insert a new leaf into the Merkle tree. - /// - /// Uses an incremental frontier algorithm: O(depth) hashes per insert, - /// replacing the former O(n) full recomputation from all leaves. - pub fn insert_leaf(commitment: Commitment) -> Result { - let index = MerkleRepository::get_tree_size::(); - // Absolute forest ceiling: the global u32 leaf index must stay - // representable (4096 trees at depth 20). Per-tree fullness rolls - // over to a fresh tree below instead of erroring. - ensure!(index < u32::MAX, Error::::MerkleTreeFull); - ensure!( - !CommitmentMemos::::contains_key(commitment), - Error::::CommitmentAlreadyExists - ); - - let cap = T::MaxLeavesPerTree::get(); - let tree_id = index / cap; - let local = index % cap; - - // Load frontier from storage and run one incremental update. - // Depth is always DEFAULT_TREE_DEPTH (20) — matches the fixed-size frontier array. - let mut frontier = MerkleRepository::get_frontier::(); - let mut current_hash = commitment.0; - let mut current_index = local; - - for (level, frontier_slot) in frontier.iter_mut().enumerate() { - if current_index % 2 == 0 { - // Left node: save in frontier, pair with zero-sibling - *frontier_slot = current_hash; - let zero = get_zero_hash_cached(level); - current_hash = hash_pair(¤t_hash, &zero); - } else { - // Right node: combine with stored left sibling - 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; - let old_poseidon_root = MerkleRepository::get_poseidon_root::(); - - MerkleRepository::insert_leaf::(index, commitment); - MerkleRepository::set_commitment_leaf_index::(commitment, index); - MerkleRepository::set_tree_size::(index.saturating_add(1)); - PoolStatsRepository::increment_commitments_inserted::(); - MerkleRepository::set_frontier::(frontier); - MerkleRepository::set_poseidon_root::(new_poseidon_root); - Self::add_poseidon_historic_root::(new_poseidon_root); - - // The freshly inserted leaf belongs to `new_poseidon_root`, so this - // event fires before any seal resets the active root. - Pallet::::deposit_event(Event::MerkleRootUpdated { - old_root: old_poseidon_root, - new_root: new_poseidon_root, - tree_size: index.saturating_add(1), - }); - - if local + 1 == cap { - Self::seal_tree::(tree_id, new_poseidon_root, cap); - } - Ok(index) - } - - /// Seal a full tree and open a fresh one, eagerly in the same insert. - /// - /// The final root becomes a permanent anchor (`SealedTreeRoots` / - /// `SealedRootIndex`) — unlike the historic ring it never expires, so - /// notes in sealed trees stay spendable forever. The active tree resets - /// to the empty state; the empty root joins the historic ring to keep - /// the `PoseidonRoot ∈ known roots` invariant. - fn seal_tree(tree_id: u32, final_root: Hash, cap: u32) { - MerkleRepository::insert_sealed_root::(tree_id, final_root); - MerkleRepository::set_frontier::([[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]); - let empty_root = get_zero_hash_cached(crate::types::DEFAULT_TREE_DEPTH); - MerkleRepository::set_poseidon_root::(empty_root); - Self::add_poseidon_historic_root::(empty_root); - - Pallet::::deposit_event(Event::TreeSealed { - tree_id, - final_root, - first_leaf_index: tree_id.saturating_mul(cap), - leaf_count: cap, - }); - } - - pub(crate) fn add_poseidon_historic_root(poseidon_root: Hash) { - let mut order = MerkleRepository::get_historic_roots_order::(); - if order.len() >= T::MaxHistoricRoots::get() as usize { - if let Some(oldest_root) = order.first().copied() { - order.remove(0); - if !order.contains(&oldest_root) { - MerkleRepository::remove_poseidon_historic_root::(&oldest_root); - } - } - } - if order.try_push(poseidon_root).is_ok() { - MerkleRepository::add_historic_poseidon_root::(poseidon_root); - MerkleRepository::set_historic_roots_order::(order); - } - } - - pub fn is_known_root(root: &Hash) -> bool { - 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 depth = crate::types::DEFAULT_TREE_DEPTH; - let cap = T::MaxLeavesPerTree::get(); - let tree_id = leaf_index / cap; - let local = leaf_index % cap; - 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 = local >> level; - indices[level] = (node_index & 1) as u8; - let sibling_index = node_index ^ 1; - let sibling = if level == 0 { - // Level-0 nodes are the leaves; map the tree-local sibling - // back to its global MerkleLeaves index. - MerkleRepository::get_leaf::(tree_id * cap + sibling_index).map(|c| c.0) - } else { - MerkleRepository::get_node::(tree_id, level as u8, sibling_index) - }; - siblings[level] = sibling.unwrap_or_else(|| get_zero_hash_cached(level)); - } - Some(DefaultMerklePath { siblings, indices }) - } - - pub fn verify_merkle_proof(root: &Hash, leaf: &Hash, path: &DefaultMerklePath) -> bool { - IncrementalMerkleTree::<20>::verify_proof(root, leaf, path) - } - - pub fn find_leaf_index(commitment: &Commitment) -> Option { - MerkleRepository::find_leaf_index::(commitment) - } -} +//! Split by responsibility so the storage-touching code is separable from the +//! pure maths: +//! +//! - [`hashing`] — Poseidon over field elements, plus the zero-hash ladder. +//! - [`tree`] — `IncrementalMerkleTree`, the in-memory frontier structure. +//! - [`batch`] — whole-tree root computation, off-chain and test use only. +//! - [`service`] — `MerkleTreeService`: leaf insertion, sealing, and the +//! historic-root window. The only module here that reads or writes storage. + +pub mod batch; +pub mod hashing; +pub mod service; +pub mod tree; + +pub use batch::{compute_root_from_leaves, compute_root_from_leaves_poseidon}; +pub use hashing::{get_zero_hash_cached, hash_pair, hash_pair_poseidon, zero_hash_at_level}; +pub use service::MerkleTreeService; +pub use tree::IncrementalMerkleTree; + +/// Most expired historic roots a single leaf insert may prune. +/// +/// Pruning is amortised across inserts so one extrinsic never pays for a backlog +/// it did not create: after a long idle stretch the whole queue can be expired +/// at once, and clearing it in one call would be storage work outside the +/// benchmarked weight. Leftovers are cleaned by the following inserts, and an +/// expired entry lingering in the queue is harmless — spendability is decided by +/// the expiry stored per root, never by queue membership. +/// +/// Each pruned slot costs at most 2 reads and 2 writes (the queue slot and the +/// map entry), so this bounds the extra work per insert at 8 reads and 8 writes +/// on top of the benchmarked cost. +pub(crate) const MAX_ROOTS_PRUNED_PER_INSERT: usize = 4; #[cfg(test)] mod tests { - use super::*; + use super::{ + MAX_ROOTS_PRUNED_PER_INSERT, + batch::compute_root_from_leaves_poseidon, + hashing::{get_zero_hash_cached, hash_pair, hash_pair_poseidon, zero_hash_at_level}, + service::MerkleTreeService, + tree::IncrementalMerkleTree, + }; use crate::{ - mock::{Test, new_test_ext}, - types::Commitment, + mock::{System, Test, new_test_ext}, + pallet::Event, + storage::MerkleRepository, + types::{Commitment, Hash}, }; // ── hash functions ────────────────────────────────────────────────────── @@ -1303,63 +878,249 @@ mod tests { }); } - /// Once the window is full, the oldest root is evicted from both the order - /// vector and the known-root map, and the map/order stay in sync (SP-15). + fn retention() -> u64 { + ::RootRetentionBlocks::get() + } + + /// Distinct test root. The last byte is set so `root_numbered(0)` can never + /// collide with the all-zero genesis root, which `is_known_root` accepts + /// unconditionally as the active root and would mask a real expiry. + fn root_numbered(i: u32) -> Hash { + let mut root = [0u8; 32]; + root[..4].copy_from_slice(&i.to_le_bytes()); + root[31] = 0xAA; + root + } + + /// SP-20: activity alone must never expire a root. Inserting far more roots + /// than `MaxHistoricRoots` within one retention window leaves the oldest + /// spendable — under the old insert-counted window it was evicted after + /// `MaxHistoricRoots` inserts regardless of how little time had passed. #[test] - fn historic_root_window_evicts_oldest() { + fn historic_root_survives_heavy_activity_within_window() { new_test_ext().execute_with(|| { + let oldest = root_numbered(0); + MerkleTreeService::add_poseidon_historic_root::(oldest); + + // Far more inserts than the cap, all in the same block. let cap = ::MaxHistoricRoots::get(); - // Fill the window with `cap` distinct roots. - for i in 0..cap { - let mut root = [0u8; 32]; - root[..4].copy_from_slice(&i.to_le_bytes()); - MerkleTreeService::add_poseidon_historic_root::(root); + for i in 1..(cap * 2) { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); } - let mut oldest = [0u8; 32]; - oldest[..4].copy_from_slice(&0u32.to_le_bytes()); - assert!(MerkleTreeService::is_known_root::(&oldest)); - // One more root evicts the oldest. - let mut newest = [0u8; 32]; - newest[..4].copy_from_slice(&cap.to_le_bytes()); - MerkleTreeService::add_poseidon_historic_root::(newest); + assert!( + MerkleTreeService::is_known_root::(&oldest), + "a root must not expire from activity alone, only from elapsed blocks" + ); + }); + } + + /// The active root must stay spendable no matter how long the chain idles. + /// + /// Expiries are only refreshed by a leaf insert, so on a quiet chain the + /// current root's window elapses while it is still the root every wallet + /// proves against. Without the active-root exemption the pool wedges: every + /// spend reverts with `UnknownMerkleRoot`, and only a funded `shield` could + /// mint a new root to escape. + #[test] + fn active_root_never_expires_on_an_idle_chain() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let active = MerkleRepository::get_poseidon_root::(); + + // Idle well past a full retention window — no inserts at all. + System::set_block_number(start + retention() * 4); assert!( - !MerkleTreeService::is_known_root::(&oldest), - "oldest must be evicted" + MerkleTreeService::is_known_root::(&active), + "the active root must stay provable on an idle chain" ); + }); + } + + /// A root stays spendable for the whole retention window and stops being + /// accepted once it has elapsed. + #[test] + fn historic_root_expires_only_after_retention_blocks() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let root = root_numbered(0xAA); + MerkleTreeService::add_poseidon_historic_root::(root); + + // Last block of the window: still spendable. + System::set_block_number(start + retention()); + assert!( + MerkleTreeService::is_known_root::(&root), + "root must stay spendable through the final block of its window" + ); + + // One block past the window: gone. + System::set_block_number(start + retention() + 1); assert!( - MerkleTreeService::is_known_root::(&newest), - "newest must be known" + !MerkleTreeService::is_known_root::(&root), + "root must stop being accepted once its window has elapsed" ); - // Order vector holds exactly `cap` entries — no unbounded growth. + }); + } + + /// SP-20 end to end: a root must outlive the mempool longevity a transaction + /// was admitted with, even while the chain keeps inserting commitments. + #[test] + fn root_outlives_tx_longevity_under_load() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let anchor = root_numbered(0xBB); + MerkleTreeService::add_poseidon_historic_root::(anchor); + + // Simulate the chain filling blocks for the whole longevity window, + // two commitments per block, as `private_transfer` does. + for block in 1..=crate::validate_unsigned::TX_LONGEVITY { + System::set_block_number(start + block); + MerkleTreeService::add_poseidon_historic_root::(root_numbered( + block as u32 * 2, + )); + MerkleTreeService::add_poseidon_historic_root::(root_numbered( + block as u32 * 2 + 1, + )); + } + + assert!( + MerkleTreeService::is_known_root::(&anchor), + "a transaction still valid in the pool must find its root on chain" + ); + }); + } + + /// Expired entries drain from the queue as inserts happen, so it does not + /// grow without bound. + #[test] + fn expired_roots_are_pruned_from_the_queue() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + // Genesis seeds one entry; add four more. + let queued_before = MerkleRepository::historic_roots_queued::(); + for i in 0..4u32 { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + } + assert_eq!( + MerkleRepository::historic_roots_queued::(), + queued_before + 4 + ); + + // Past the window every prior entry is expired; one insert drains up + // to MAX_ROOTS_PRUNED_PER_INSERT of them and appends itself. + System::set_block_number(start + retention() + 1); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(999)); + + // Genesis seeds one entry, so the backlog is 5 against a cap of 4: + // one insert drains the cap and appends itself. assert_eq!( - MerkleRepository::get_historic_roots_order::().len(), - cap as usize + MerkleRepository::historic_roots_queued::(), + queued_before + 4 - MAX_ROOTS_PRUNED_PER_INSERT as u64 + 1, + "one insert drains exactly the cap and appends itself" ); + for i in 0..4u32 { + assert!( + !MerkleTreeService::is_known_root::(&root_numbered(i)), + "expired roots must no longer be spendable" + ); + } + assert!(MerkleTreeService::is_known_root::(&root_numbered( + 999 + ))); }); } - /// A duplicate root value is not removed from the map while another copy of it - /// still sits in the window (guards the map/order multiplicity mismatch). + /// Draining is capped per insert so one extrinsic never pays for a whole + /// backlog; the leftovers clear on subsequent inserts. + #[test] + fn queue_drain_is_capped_per_insert() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let backlog = (MAX_ROOTS_PRUNED_PER_INSERT * 3) as u32; + for i in 0..backlog { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + } + + System::set_block_number(start + retention() + 1); + let before = MerkleRepository::historic_roots_queued::(); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(9001)); + + // Drained at most the cap, then appended one — so the queue shrank by + // strictly less than the whole backlog. + let after = MerkleRepository::historic_roots_queued::(); + assert_eq!( + after, + before - MAX_ROOTS_PRUNED_PER_INSERT as u64 + 1, + "one insert drains exactly the cap and appends itself" + ); + + // Enough further inserts clear the rest. + for i in 0..backlog { + MerkleTreeService::add_poseidon_historic_root::(root_numbered(20_000 + i)); + } + assert!( + MerkleRepository::historic_roots_queued::() <= backlog as u64 + 2, + "the backlog must not accumulate once inserts keep coming" + ); + }); + } + + /// Head and tail only ever move forward, and the queue never reports a + /// negative or oversized length. + #[test] + fn queue_head_and_tail_stay_monotonic() { + new_test_ext().execute_with(|| { + let start = System::block_number(); + let mut last_head = MerkleRepository::get_historic_roots_head::(); + let mut last_tail = MerkleRepository::get_historic_roots_tail::(); + + for i in 0..40u32 { + // Advance past the window every so often so draining kicks in. + if i % 10 == 0 { + System::set_block_number(start + retention() * (i as u64 / 10 + 1)); + } + MerkleTreeService::add_poseidon_historic_root::(root_numbered(i)); + + let head = MerkleRepository::get_historic_roots_head::(); + let tail = MerkleRepository::get_historic_roots_tail::(); + assert!(head >= last_head, "head must never move backwards"); + assert!(tail >= last_tail, "tail must never move backwards"); + assert!(head >= tail, "tail must never overtake head"); + last_head = head; + last_tail = tail; + } + }); + } + + /// A duplicate root value stays known while any live copy remains, and its + /// expiry is extended rather than shortened by the later insert. #[test] fn historic_root_duplicate_survives_partial_eviction() { new_test_ext().execute_with(|| { + let start = System::block_number(); let dup = [0x77u8; 32]; - // Two copies of `dup` plus fillers spanning the window. + + // First copy at `start`, second one block later — the later insert + // extends the expiry. MerkleTreeService::add_poseidon_historic_root::(dup); + System::set_block_number(start + 1); MerkleTreeService::add_poseidon_historic_root::(dup); - let cap = ::MaxHistoricRoots::get(); - for i in 0..(cap - 1) { - let mut root = [0u8; 32]; - root[..4].copy_from_slice(&i.to_le_bytes()); - root[31] = 1; // distinct namespace from `dup` - MerkleTreeService::add_poseidon_historic_root::(root); - } - // The first `dup` was evicted, but the second copy keeps it known. + + // Past the first copy's expiry but inside the second's. + System::set_block_number(start + retention() + 1); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(1)); assert!( MerkleTreeService::is_known_root::(&dup), - "duplicate root must stay known while a copy remains in the window" + "duplicate root must stay known while a live copy remains" + ); + + // Past the second copy's expiry too. + System::set_block_number(start + retention() + 2); + MerkleTreeService::add_poseidon_historic_root::(root_numbered(2)); + assert!( + !MerkleTreeService::is_known_root::(&dup), + "duplicate root must expire once every copy has elapsed" ); }); } diff --git a/frame/shielded-pool/src/merkle/service.rs b/frame/shielded-pool/src/merkle/service.rs new file mode 100644 index 00000000..5a8327d7 --- /dev/null +++ b/frame/shielded-pool/src/merkle/service.rs @@ -0,0 +1,248 @@ +//! On-chain Merkle tree: leaf insertion, tree sealing, and the historic-root +//! window. +//! +//! This is the only part of the Merkle code that touches storage. Everything it +//! needs from the pure layers comes through [`super::hashing`]. + +use super::{ + MAX_ROOTS_PRUNED_PER_INSERT, + hashing::{get_zero_hash_cached, hash_pair}, + tree::IncrementalMerkleTree, +}; +use crate::{ + pallet::{CommitmentMemos, Config, Error, Event, Pallet}, + storage::{MerkleRepository, PoolStatsRepository}, + types::{Commitment, DefaultMerklePath, Hash}, +}; +use frame_support::{ensure, pallet_prelude::*, traits::Get}; +use sp_runtime::traits::Saturating; + +pub struct MerkleTreeService; + +impl MerkleTreeService { + /// Insert a new leaf into the Merkle tree. + /// + /// Uses an incremental frontier algorithm: O(depth) hashes per insert, + /// replacing the former O(n) full recomputation from all leaves. + pub fn insert_leaf(commitment: Commitment) -> Result { + let index = MerkleRepository::get_tree_size::(); + // Absolute forest ceiling: the global u32 leaf index must stay + // representable (4096 trees at depth 20). Per-tree fullness rolls + // over to a fresh tree below instead of erroring. + ensure!(index < u32::MAX, Error::::MerkleTreeFull); + ensure!( + !CommitmentMemos::::contains_key(commitment), + Error::::CommitmentAlreadyExists + ); + + let cap = T::MaxLeavesPerTree::get(); + let tree_id = index / cap; + let local = index % cap; + + // Load frontier from storage and run one incremental update. + // Depth is always DEFAULT_TREE_DEPTH (20) — matches the fixed-size frontier array. + let mut frontier = MerkleRepository::get_frontier::(); + let mut current_hash = commitment.0; + let mut current_index = local; + + for (level, frontier_slot) in frontier.iter_mut().enumerate() { + if current_index % 2 == 0 { + // Left node: save in frontier, pair with zero-sibling + *frontier_slot = current_hash; + let zero = get_zero_hash_cached(level); + current_hash = hash_pair(¤t_hash, &zero); + } else { + // Right node: combine with stored left sibling + 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; + let old_poseidon_root = MerkleRepository::get_poseidon_root::(); + + MerkleRepository::insert_leaf::(index, commitment); + MerkleRepository::set_commitment_leaf_index::(commitment, index); + MerkleRepository::set_tree_size::(index.saturating_add(1)); + PoolStatsRepository::increment_commitments_inserted::(); + MerkleRepository::set_frontier::(frontier); + MerkleRepository::set_poseidon_root::(new_poseidon_root); + Self::add_poseidon_historic_root::(new_poseidon_root); + + // The freshly inserted leaf belongs to `new_poseidon_root`, so this + // event fires before any seal resets the active root. + Pallet::::deposit_event(Event::MerkleRootUpdated { + old_root: old_poseidon_root, + new_root: new_poseidon_root, + tree_size: index.saturating_add(1), + }); + + if local + 1 == cap { + Self::seal_tree::(tree_id, new_poseidon_root, cap); + } + Ok(index) + } + + /// Seal a full tree and open a fresh one, eagerly in the same insert. + /// + /// The final root becomes a permanent anchor (`SealedTreeRoots` / + /// `SealedRootIndex`) — unlike the historic ring it never expires, so + /// notes in sealed trees stay spendable forever. The active tree resets + /// to the empty state; the empty root joins the historic ring to keep + /// the `PoseidonRoot ∈ known roots` invariant. + fn seal_tree(tree_id: u32, final_root: Hash, cap: u32) { + MerkleRepository::insert_sealed_root::(tree_id, final_root); + MerkleRepository::set_frontier::([[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]); + let empty_root = get_zero_hash_cached(crate::types::DEFAULT_TREE_DEPTH); + MerkleRepository::set_poseidon_root::(empty_root); + Self::add_poseidon_historic_root::(empty_root); + + Pallet::::deposit_event(Event::TreeSealed { + tree_id, + final_root, + first_leaf_index: tree_id.saturating_mul(cap), + leaf_count: cap, + }); + } + + /// Record the new root and drop the ones whose retention window has passed. + /// + /// Retention is measured in blocks (`RootRetentionBlocks`), so the window + /// always outlives the mempool longevity a transaction was admitted with. + /// `MaxHistoricRoots` is only a safety cap on queue length. + pub(crate) fn add_poseidon_historic_root(poseidon_root: Hash) { + let now = frame_system::Pallet::::block_number(); + let expires_at = now.saturating_add(T::RootRetentionBlocks::get()); + + let mut head = MerkleRepository::get_historic_roots_head::(); + let mut tail = MerkleRepository::get_historic_roots_tail::(); + + // Drain expired slots from the tail. Slots sit in expiry order because + // every insert stores `now + retention` with a non-decreasing `now`, so + // the first live slot ends the scan. + let mut pruned = 0usize; + while tail < head && pruned < MAX_ROOTS_PRUNED_PER_INSERT { + let Some((root, slot_expiry)) = MerkleRepository::get_historic_root_slot::(tail) + else { + // Defensive: this path never leaves holes, but skipping keeps the + // queue draining instead of wedging on one forever. Counted against + // the cap so a long run of holes cannot turn into an unbounded read + // loop inside a dispatchable. + tail = tail.saturating_add(1); + pruned = pruned.saturating_add(1); + continue; + }; + if slot_expiry >= now { + break; // still live — and so is every slot behind it + } + MerkleRepository::remove_historic_root_slot::(tail); + tail = tail.saturating_add(1); + pruned = pruned.saturating_add(1); + + // A root can occupy several slots (re-inserted, or an insert that left + // the root unchanged). The map holds one expiry per root — the latest, + // since `add_historic_poseidon_root_until` only extends it — so it is + // the authority on whether the root is still spendable. O(1) per slot, + // which keeps this loop linear. + match MerkleRepository::get_historic_root_expiry::(&root) { + Some(expiry) if expiry >= now => {} + _ => MerkleRepository::remove_poseidon_historic_root::(&root), + } + } + + // The cap is a backstop, not the window: reaching it means the window + // holds more roots than `MaxHistoricRoots` allows, which would silently + // shorten it. Evict the oldest so inserts keep working, and log the + // misconfiguration rather than failing quietly. + if head.saturating_sub(tail) >= T::MaxHistoricRoots::get() as u64 { + // Not `defensive!`: that expands to `debug_assert!(false)` and panics in + // any debug-assertions build. This branch is a reachable operational + // state, so a validator on a debug build must not halt where a release + // build keeps producing. + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "historic-root cap reached before retention elapsed; \ + MaxHistoricRoots is too small for RootRetentionBlocks", + ); + if let Some((root, slot_expiry)) = MerkleRepository::get_historic_root_slot::(tail) { + MerkleRepository::remove_historic_root_slot::(tail); + match MerkleRepository::get_historic_root_expiry::(&root) { + Some(expiry) if expiry >= now => { + // Still live: re-queue at the head rather than drop it. + // Deleting the slot while keeping the map entry would strand + // that entry outside `[tail, head)` — unreachable by the drain + // loop, so never prunable and permanently spendable. Forgetting + // it instead would reject spends that are still valid. + MerkleRepository::set_historic_root_slot::(head, root, slot_expiry); + head = head.saturating_add(1); + } + _ => MerkleRepository::remove_poseidon_historic_root::(&root), + } + } + tail = tail.saturating_add(1); + } + + MerkleRepository::set_historic_root_slot::(head, poseidon_root, expires_at); + head = head.saturating_add(1); + + MerkleRepository::add_historic_poseidon_root_until::(poseidon_root, expires_at); + MerkleRepository::set_historic_roots_head::(head); + MerkleRepository::set_historic_roots_tail::(tail); + } + + pub fn is_known_root(root: &Hash) -> bool { + 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 depth = crate::types::DEFAULT_TREE_DEPTH; + let cap = T::MaxLeavesPerTree::get(); + let tree_id = leaf_index / cap; + let local = leaf_index % cap; + 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 = local >> level; + indices[level] = (node_index & 1) as u8; + let sibling_index = node_index ^ 1; + let sibling = if level == 0 { + // Level-0 nodes are the leaves; map the tree-local sibling + // back to its global MerkleLeaves index. + MerkleRepository::get_leaf::(tree_id * cap + sibling_index).map(|c| c.0) + } else { + MerkleRepository::get_node::(tree_id, level as u8, sibling_index) + }; + siblings[level] = sibling.unwrap_or_else(|| get_zero_hash_cached(level)); + } + Some(DefaultMerklePath { siblings, indices }) + } + + pub fn verify_merkle_proof(root: &Hash, leaf: &Hash, path: &DefaultMerklePath) -> bool { + IncrementalMerkleTree::<20>::verify_proof(root, leaf, path) + } + + pub fn find_leaf_index(commitment: &Commitment) -> Option { + MerkleRepository::find_leaf_index::(commitment) + } +} diff --git a/frame/shielded-pool/src/merkle/tree.rs b/frame/shielded-pool/src/merkle/tree.rs new file mode 100644 index 00000000..769198ae --- /dev/null +++ b/frame/shielded-pool/src/merkle/tree.rs @@ -0,0 +1,146 @@ +//! `IncrementalMerkleTree` — the in-memory frontier structure. +//! +//! Holds no storage handles and knows nothing about `Config`: it is the pure +//! data structure, used by tests and by off-chain callers. The on-chain tree +//! lives in [`super::service`], which keeps the same frontier in storage. + +use super::hashing::{get_zero_hash_cached, hash_pair}; +use crate::types::MerklePath; +use frame_support::pallet_prelude::*; +use sp_std::vec::Vec; + +#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug)] +pub struct IncrementalMerkleTree { + pub frontier: [[u8; 32]; DEPTH], + pub next_index: u32, + pub root: [u8; 32], +} + +impl Default for IncrementalMerkleTree { + fn default() -> Self { + Self::new() + } +} + +impl IncrementalMerkleTree { + pub fn new() -> Self { + let root = Self::compute_empty_root(); + Self { + frontier: [[0u8; 32]; DEPTH], + next_index: 0, + root, + } + } + + fn compute_empty_root() -> [u8; 32] { + let mut current = [0u8; 32]; + for _ in 0..DEPTH { + current = hash_pair(¤t, ¤t); + } + current + } + + fn zero_hash(level: usize) -> [u8; 32] { + get_zero_hash_cached(level) + } + + pub fn capacity(&self) -> u32 { + 1u32 << DEPTH + } + pub fn is_full(&self) -> bool { + self.next_index >= self.capacity() + } + + pub fn insert(&mut self, leaf: [u8; 32]) -> Result { + if self.is_full() { + return Err("Merkle tree is full"); + } + let index = self.next_index; + let mut current_hash = leaf; + let mut current_index = index; + + for level in 0..DEPTH { + if current_index % 2 == 0 { + self.frontier[level] = current_hash; + let zero = Self::zero_hash(level); + current_hash = hash_pair(¤t_hash, &zero); + } else { + current_hash = hash_pair(&self.frontier[level], ¤t_hash); + } + current_index /= 2; + } + + self.root = current_hash; + self.next_index += 1; + Ok(index) + } + + pub fn root(&self) -> [u8; 32] { + self.root + } + pub fn size(&self) -> u32 { + self.next_index + } + + pub fn generate_proof( + &self, + leaf_index: u32, + leaves: &[[u8; 32]], + ) -> Result, &'static str> { + if leaf_index >= self.next_index { + return Err("Leaf index out of bounds"); + } + if leaves.len() != self.next_index as usize { + return Err("Leaves count mismatch"); + } + + let mut siblings = [[0u8; 32]; DEPTH]; + let mut indices = [0u8; DEPTH]; + let mut current_level = leaves.to_vec(); + let mut target_index = leaf_index as usize; + + for level in 0..DEPTH { + if current_level.len() % 2 != 0 { + current_level.push(Self::zero_hash(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] + } else { + Self::zero_hash(level) + }; + let mut next_level = Vec::new(); + for chunk in current_level.chunks(2) { + let left = chunk[0]; + let right = if chunk.len() > 1 { + chunk[1] + } else { + Self::zero_hash(level) + }; + next_level.push(hash_pair(&left, &right)); + } + current_level = next_level; + target_index /= 2; + } + Ok(MerklePath { siblings, indices }) + } + + pub fn verify_proof(root: &[u8; 32], leaf: &[u8; 32], path: &MerklePath) -> bool { + let mut current = *leaf; + for level in 0..DEPTH { + let sibling = &path.siblings[level]; + current = if path.indices[level] == 0 { + hash_pair(¤t, sibling) + } else { + hash_pair(sibling, ¤t) + }; + } + ¤t == root + } +} diff --git a/frame/shielded-pool/src/migrations.rs b/frame/shielded-pool/src/migrations.rs new file mode 100644 index 00000000..d530d159 --- /dev/null +++ b/frame/shielded-pool/src/migrations.rs @@ -0,0 +1,577 @@ +//! Storage migrations for `pallet-shielded-pool`. +//! +//! Only migrations that still have a chain to run on live here. Once every +//! deployment has passed a version, its code is removed — see the git history +//! for the v1 and v2 migrations. + +use crate::{ + pallet::{ + Config, HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, + Pallet, + }, + types::Hash, +}; +use frame_support::{ + pallet_prelude::*, + traits::{GetStorageVersion, OnRuntimeUpgrade}, + weights::Weight, +}; +use sp_runtime::traits::Saturating; +use sp_std::vec::Vec; + +pub mod v3 { + use super::*; + + /// Storage key of the v2 `HistoricRootsOrder` value, which no longer exists + /// as a type. Read through the raw key so the old `Vec` can be decoded + /// and cleared without keeping a dead definition around. + fn old_order_key() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricRootsOrder").as_slice(), + ] + .concat() + } + + /// Storage prefix of `HistoricPoseidonRoots`. Enumerated by raw key so the + /// old 1-byte `bool` values can be rewritten without `translate`, which + /// leaves undecodable entries in place rather than clearing them. + /// + /// The pallet name must match the `construct_runtime!` identifier + /// (`ShieldedPool`); a mismatch would make every read here return nothing. + fn historic_roots_prefix() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat() + } + + /// Count raw keys under a prefix. Typed `iter()` silently skips entries whose + /// value fails to decode, which is exactly the failure this migration must be + /// able to observe, so the try-runtime checks count keys instead. + #[cfg(feature = "try-runtime")] + fn count_raw_keys(prefix: &[u8]) -> u32 { + let mut count: u32 = 0; + let mut key = prefix.to_vec(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(prefix) { + break; + } + count = count.saturating_add(1); + key = next; + } + count + } + + /// Re-anchor the historic-root window from insert counts to block numbers + /// (storage v2 -> v3). + /// + /// Before: `HistoricPoseidonRoots: Hash -> bool` plus a single + /// `HistoricRootsOrder: BoundedVec`, evicted purely by how many roots + /// had been inserted. After: the map carries the block at which each root + /// stops being accepted, and the queue is a slot-indexed map so a leaf + /// insert touches a couple of entries instead of rewriting the whole vector. + /// + /// Both value types changed, so old entries cannot be decoded by the new + /// definitions and must be rewritten here. Every existing root is kept and + /// given a full window from the upgrade block rather than dropped: a root on + /// chain backs proofs wallets may be about to submit, and voiding them would + /// be a worse failure than the one this fixes. The extra window costs one + /// retention period once and never rejects a spend that used to be valid. + pub struct MigrateToV3(core::marker::PhantomData); + + impl OnRuntimeUpgrade for MigrateToV3 { + fn on_runtime_upgrade() -> Weight { + if Pallet::::on_chain_storage_version() >= 3 { + return T::DbWeight::get().reads(1); + } + + let now = frame_system::Pallet::::block_number(); + let expires_at = now.saturating_add(T::RootRetentionBlocks::get()); + let cap = T::MaxHistoricRoots::get() as u64; + + let mut reads: u64 = 2; // storage version + old order vector + let mut writes: u64 = 4; // version + head + tail + old order kill + + // Collect the surviving roots in their original insertion order. + // + // The old order vector is the source of truth: it preserves the order + // the drain loop relies on. It was a `BoundedVec`, + // which encodes exactly like `Vec`, and is read through its raw key + // because the type no longer exists. Reading it raw also sidesteps the + // bound check a typed read would apply with the *new* constant. + let old_key = old_order_key(); + let ordered: Option> = + sp_io::storage::get(&old_key).and_then(|raw| Decode::decode(&mut &raw[..]).ok()); + + // Enumerate the old map by raw key rather than `translate`. + // + // `translate` does NOT delete an entry whose old value fails to decode: + // it logs, skips, and leaves the bytes in place (see + // `frame_support::storage::generator::map::translate_next`). Here the old + // value was a 1-byte `bool` and the new one is a 4-byte block number, so + // a skipped entry would stay as an undecodable blob under a live key — + // unreadable by `is_known_poseidon_root` and invisible to `iter()`, hence + // impossible to ever prune. Rewriting every key unconditionally leaves no + // such residue. + let map_prefix = historic_roots_prefix(); + let mut raw_roots: Vec = Vec::new(); + let mut key = map_prefix.clone(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(&map_prefix) { + break; + } + reads = reads.saturating_add(1); + // Key layout: prefix ++ Blake2_128Concat(root) = prefix ++ 16-byte + // hash ++ the 32-byte root itself. Recover the root from the concat + // tail; anything of an unexpected shape is not a root we can honour, + // so drop the key rather than leave it dangling. + match next.len().checked_sub(32) { + Some(start) if start >= map_prefix.len().saturating_add(16) => { + let mut root = [0u8; 32]; + root.copy_from_slice(&next[start..]); + raw_roots.push(root); + } + _ => { + sp_io::storage::clear(&next); + writes = writes.saturating_add(1); + } + } + key = next; + } + + // Prefer the recorded order; fall back to key order when the vector is + // unreadable. Both are safe for the drain loop because every entry gets + // the same expiry here — if per-root expiries are ever introduced, this + // fallback must sort by expiry or the loop's early `break` would strand + // live roots behind an expired one. + let roots = match ordered { + Some(mut v) => { + // Anything present in the map but missing from the vector would + // otherwise be written to the map and never queued, so it could + // never be pruned. Append the strays. + for root in &raw_roots { + if !v.contains(root) { + v.push(*root); + } + } + v + } + None => { + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "MigrateToV3: HistoricRootsOrder undecodable; rebuilding the queue from map key order", + ); + raw_roots.clone() + } + }; + + // Clear the whole old map before rewriting: every key is re-created + // below, so nothing survives that the queue does not also track. This is + // what makes map membership and queue membership impossible to diverge. + for root in &raw_roots { + HistoricPoseidonRoots::::remove(root); + writes = writes.saturating_add(1); + } + sp_io::storage::clear(&old_key); + + // Write map and queue together, one slot per root, so the two can never + // disagree. Past the cap a root is dropped from *both* — never left in + // the map alone, which would leak an unprunable spendable root. + let mut slot: u64 = 0; + let mut seen: Vec = Vec::new(); + for root in roots { + if slot >= cap { + frame_support::__private::log::warn!( + target: "runtime::shielded-pool", + "MigrateToV3: historic-root cap reached; dropping the remaining roots", + ); + break; + } + if seen.contains(&root) { + continue; // one slot per distinct root; expiries are identical + } + seen.push(root); + HistoricPoseidonRoots::::insert(root, expires_at); + HistoricRootsQueue::::insert(slot, (root, expires_at)); + slot = slot.saturating_add(1); + writes = writes.saturating_add(2); + } + + // The active root must always be provable against, even if the old state + // was empty or unreadable. Evict the oldest slot if the cap leaves no + // room — an unprovable active root would halt every spend. + let active = crate::storage::MerkleRepository::get_poseidon_root::(); + let mut tail: u64 = 0; + if !seen.contains(&active) { + if slot >= cap { + // Evict the oldest slot to make room. Shift the tail rather than + // rewriting every slot: the drain loop reads from tail forward, + // so a moved tail is all it needs. + if let Some((evicted, _)) = HistoricRootsQueue::::get(tail) { + HistoricPoseidonRoots::::remove(evicted); + } + HistoricRootsQueue::::remove(tail); + tail = tail.saturating_add(1); + writes = writes.saturating_add(2); + } + HistoricPoseidonRoots::::insert(active, expires_at); + HistoricRootsQueue::::insert(slot, (active, expires_at)); + slot = slot.saturating_add(1); + writes = writes.saturating_add(2); + } + + HistoricRootsTail::::put(tail); + HistoricRootsHead::::put(slot); + StorageVersion::new(3).put::>(); + + T::DbWeight::get().reads_writes(reads, writes) + } + + #[cfg(feature = "try-runtime")] + fn pre_upgrade() -> Result, sp_runtime::TryRuntimeError> { + // Count raw keys, not typed entries: the new definition cannot decode + // the old 1-byte `bool` values, so `iter()` would skip every one. + Ok(count_raw_keys(&historic_roots_prefix()).encode()) + } + + #[cfg(feature = "try-runtime")] + fn post_upgrade(state: Vec) -> Result<(), sp_runtime::TryRuntimeError> { + // Propagate a decode failure instead of defaulting to zero, which would + // make the count check below trivially pass. + let before = u32::decode(&mut state.as_slice()).map_err(|_| { + sp_runtime::TryRuntimeError::Other("MigrateToV3: pre_upgrade state must decode") + })?; + + // Compare raw key counts on both sides. A typed count here would hide + // exactly the failure this migration exists to avoid: an entry left with + // an undecodable value is invisible to `iter()` but still occupies a key. + let raw_after = count_raw_keys(&historic_roots_prefix()); + let typed_after = HistoricPoseidonRoots::::iter().count() as u32; + ensure!( + raw_after == typed_after, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left an undecodable entry in HistoricPoseidonRoots" + ) + ); + + // The migration may add the active root, and drops duplicates and + // anything past the cap — so the count can move either way by a bounded + // amount, but must never collapse. + ensure!( + raw_after <= before.saturating_add(1), + sp_runtime::TryRuntimeError::Other("MigrateToV3 created unexpected roots") + ); + ensure!( + before == 0 || raw_after > 0, + sp_runtime::TryRuntimeError::Other("MigrateToV3 lost every historic root") + ); + ensure!( + Pallet::::on_chain_storage_version() == 3, + sp_runtime::TryRuntimeError::Other("MigrateToV3 did not set storage version 3") + ); + + // Every migrated root must still be spendable. + let now = frame_system::Pallet::::block_number(); + for (_root, expiry) in HistoricPoseidonRoots::::iter() { + ensure!( + expiry > now, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 produced an already-expired root" + ) + ); + } + + // Queue and map must agree in BOTH directions. A root in the map but not + // in the queue can never be pruned — a permanent leak of a spendable + // root — and a queued root missing from the map would be drained as if + // it had expired. + let head = HistoricRootsHead::::get(); + let tail = HistoricRootsTail::::get(); + ensure!( + head >= tail, + sp_runtime::TryRuntimeError::Other("MigrateToV3 left head behind tail") + ); + ensure!( + head.saturating_sub(tail) <= T::MaxHistoricRoots::get() as u64, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 overfilled the historic-root queue" + ) + ); + ensure!( + head.saturating_sub(tail) == typed_after as u64, + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left the map and queue with different lengths" + ) + ); + for s in tail..head { + let Some((root, _)) = HistoricRootsQueue::::get(s) else { + return Err(sp_runtime::TryRuntimeError::Other( + "MigrateToV3 left a hole in the queue", + )); + }; + ensure!( + HistoricPoseidonRoots::::contains_key(root), + sp_runtime::TryRuntimeError::Other( + "MigrateToV3 queued a root that is not in the map" + ) + ); + } + + // The old value must be gone, and the active root provable against. + ensure!( + sp_io::storage::get(&old_order_key()).is_none(), + sp_runtime::TryRuntimeError::Other("MigrateToV3 left HistoricRootsOrder behind") + ); + let active = crate::storage::MerkleRepository::get_poseidon_root::(); + ensure!( + crate::storage::MerkleRepository::is_known_root::(&active), + sp_runtime::TryRuntimeError::Other("MigrateToV3 left the active root unprovable") + ); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::v3::MigrateToV3; + use crate::{ + Config, + mock::{Test, new_test_ext}, + pallet::{ + HistoricPoseidonRoots, HistoricRootsHead, HistoricRootsQueue, HistoricRootsTail, + PoseidonRoot, + }, + storage::MerkleRepository, + types::Hash, + }; + use frame_support::traits::{GetStorageVersion, OnRuntimeUpgrade, StorageVersion}; + use parity_scale_codec::Encode; + + fn old_order_key() -> Vec { + [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricRootsOrder").as_slice(), + ] + .concat() + } + + /// Write a `HistoricPoseidonRoots` entry the way storage v2 did: a raw + /// 1-byte `bool` under a `Blake2_128Concat` key. + fn write_v2_root(root: Hash, value: bool) { + let prefix = [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat(); + let mut key = prefix; + key.extend_from_slice(&sp_io::hashing::blake2_128(&root.encode())); + key.extend_from_slice(&root.encode()); + sp_io::storage::set(&key, &value.encode()); + } + + fn seed_v2_state(roots: &[Hash]) { + StorageVersion::new(2).put::>(); + // Genesis already seeded v3-shaped state; clear it so the fixture is + // purely v2. + let _ = HistoricRootsQueue::::clear(u32::MAX, None); + HistoricRootsHead::::kill(); + HistoricRootsTail::::kill(); + let _ = HistoricPoseidonRoots::::clear(u32::MAX, None); + + for root in roots { + write_v2_root(*root, true); + } + sp_io::storage::set(&old_order_key(), &roots.to_vec().encode()); + } + + fn root_of(n: u8) -> Hash { + [n; 32] + } + + #[test] + fn migrates_roots_and_preserves_order() { + new_test_ext().execute_with(|| { + let roots = [root_of(1), root_of(2), root_of(3)]; + PoseidonRoot::::put(root_of(3)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(crate::Pallet::::on_chain_storage_version(), 3); + assert_eq!(HistoricRootsTail::::get(), 0); + assert_eq!(HistoricRootsHead::::get(), 3); + + // Insertion order preserved, map and queue agree. + for (slot, root) in roots.iter().enumerate() { + let (queued, _) = + HistoricRootsQueue::::get(slot as u64).expect("slot filled"); + assert_eq!(queued, *root); + assert!(MerkleRepository::is_known_root::(root)); + } + // The old value is gone. + assert!(sp_io::storage::get(&old_order_key()).is_none()); + }); + } + + /// The value type changed from 1-byte `bool` to a 4-byte block number. + /// `translate` would leave an undecodable entry in place; the raw rewrite + /// must not. + #[test] + fn rewrites_every_entry_leaving_no_undecodable_residue() { + new_test_ext().execute_with(|| { + let roots = [root_of(7), root_of(8)]; + PoseidonRoot::::put(root_of(7)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + + // Every raw key must now decode under the new type: raw count and + // typed count agree. + let prefix = [ + sp_io::hashing::twox_128(b"ShieldedPool").as_slice(), + sp_io::hashing::twox_128(b"HistoricPoseidonRoots").as_slice(), + ] + .concat(); + let mut raw = 0u32; + let mut key = prefix.clone(); + while let Some(next) = sp_io::storage::next_key(&key) { + if !next.starts_with(&prefix) { + break; + } + raw += 1; + key = next; + } + let typed = HistoricPoseidonRoots::::iter().count() as u32; + assert_eq!(raw, typed, "no key may be left with an undecodable value"); + assert_eq!(typed, 2); + }); + } + + /// A root in the map but missing from the order vector must still be queued, + /// or it could never be pruned — a permanent leak of a spendable root. + #[test] + fn strays_missing_from_the_order_vector_are_queued() { + new_test_ext().execute_with(|| { + let ordered = [root_of(1)]; + PoseidonRoot::::put(root_of(1)); + seed_v2_state(&ordered); + // A root the order vector never listed. + write_v2_root(root_of(9), true); + + MigrateToV3::::on_runtime_upgrade(); + + let head = HistoricRootsHead::::get(); + assert_eq!(head, 2, "the stray must occupy a slot too"); + assert_eq!( + HistoricPoseidonRoots::::iter().count() as u64, + head, + "map and queue must have equal length" + ); + assert!(MerkleRepository::is_known_root::(&root_of(9))); + }); + } + + /// An unreadable order vector must still produce a usable window rather than + /// leaving the chain with no provable root. + #[test] + fn falls_back_to_key_order_when_the_vector_is_undecodable() { + new_test_ext().execute_with(|| { + let roots = [root_of(4), root_of(5)]; + PoseidonRoot::::put(root_of(4)); + seed_v2_state(&roots); + sp_io::storage::set(&old_order_key(), &[0xFFu8; 3]); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(HistoricRootsHead::::get(), 2); + for root in &roots { + assert!(MerkleRepository::is_known_root::(root)); + } + }); + } + + /// The active root must always be provable, even from empty v2 state. + #[test] + fn seeds_the_active_root_from_empty_state() { + new_test_ext().execute_with(|| { + PoseidonRoot::::put(root_of(42)); + seed_v2_state(&[]); + + MigrateToV3::::on_runtime_upgrade(); + + assert!(MerkleRepository::is_known_root::(&root_of(42))); + assert_eq!(HistoricRootsHead::::get(), 1); + assert_eq!(HistoricRootsTail::::get(), 0); + }); + } + + /// Migrated roots get a full retention window, so nothing arrives expired. + /// + /// Uses a non-active root: the active one is accepted unconditionally by + /// `is_known_root`, which would mask the expiry this test checks. + #[test] + fn migrated_roots_are_spendable_for_a_full_window() { + new_test_ext().execute_with(|| { + let migrated = root_of(1); + let active = root_of(2); + PoseidonRoot::::put(active); + seed_v2_state(&[migrated, active]); + let start = frame_system::Pallet::::block_number(); + + MigrateToV3::::on_runtime_upgrade(); + + let retention: u64 = ::RootRetentionBlocks::get(); + frame_system::Pallet::::set_block_number(start + retention); + assert!( + MerkleRepository::is_known_root::(&migrated), + "must stay spendable through the final block of the window" + ); + frame_system::Pallet::::set_block_number(start + retention + 1); + assert!( + !MerkleRepository::is_known_root::(&migrated), + "must expire once the window elapses" + ); + assert!( + MerkleRepository::is_known_root::(&active), + "the active root stays provable regardless of its window" + ); + }); + } + + #[test] + fn is_idempotent_and_skips_a_v3_chain() { + new_test_ext().execute_with(|| { + let roots = [root_of(1), root_of(2)]; + PoseidonRoot::::put(root_of(2)); + seed_v2_state(&roots); + + MigrateToV3::::on_runtime_upgrade(); + let head = HistoricRootsHead::::get(); + let tail = HistoricRootsTail::::get(); + + // A second run must change nothing. + MigrateToV3::::on_runtime_upgrade(); + assert_eq!(HistoricRootsHead::::get(), head); + assert_eq!(HistoricRootsTail::::get(), tail); + assert_eq!(crate::Pallet::::on_chain_storage_version(), 3); + }); + } + + /// A chain built from genesis is already at v3; the migration must leave its + /// seeded queue untouched. + #[test] + fn leaves_a_genesis_chain_untouched() { + new_test_ext().execute_with(|| { + let head_before = HistoricRootsHead::::get(); + let tail_before = HistoricRootsTail::::get(); + + MigrateToV3::::on_runtime_upgrade(); + + assert_eq!(HistoricRootsHead::::get(), head_before); + assert_eq!(HistoricRootsTail::::get(), tail_before); + }); + } +} diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index 3085f8ba..a6850d1c 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -46,7 +46,13 @@ impl pallet_balances::Config for Test { parameter_types! { pub const ShieldedPoolPalletId: PalletId = PalletId(*b"shldpool"); pub const MaxTreeDepth: u32 = 20; - pub const MaxHistoricRoots: u32 = 100; + /// Safety cap on queue length, not the retention window. Sized well above + /// what a test inserts within one window so the cap branch stays the + /// exceptional path here, as it is in production. + pub const MaxHistoricRoots: u32 = 4096; + /// Above `TX_LONGEVITY` (64), as `integrity_test` requires. Small enough that + /// a test can advance past it to exercise expiry. + pub const RootRetentionBlocks: u64 = 128; pub const MaxLeavesPerTree: u32 = 8; pub const MinShieldAmount: u128 = 100; pub const MaxProofSize: u32 = 256; @@ -152,6 +158,7 @@ impl pallet_shielded_pool::Config for Test { type PalletId = ShieldedPoolPalletId; type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; + type RootRetentionBlocks = RootRetentionBlocks; type MaxLeavesPerTree = MaxLeavesPerTree; type MinShieldAmount = MinShieldAmount; type WeightInfo = (); diff --git a/frame/shielded-pool/src/storage/asset.rs b/frame/shielded-pool/src/storage/asset.rs new file mode 100644 index 00000000..5340b008 --- /dev/null +++ b/frame/shielded-pool/src/storage/asset.rs @@ -0,0 +1,50 @@ +//! Asset registry storage. +//! +//! Asset metadata plus the monotonic id counter. `increment_asset_id` hands out +//! the next id; the caller is responsible for rejecting a collision, since the +//! counter alone cannot tell a fresh slot from a reused one. + +use crate::{ + pallet::{Assets, Config, NextAssetId}, + types::AssetMetadata, +}; +use frame_system::pallet_prelude::BlockNumberFor; + +// AssetRepository + +pub struct AssetRepository; + +impl AssetRepository { + pub fn get_asset( + asset_id: u32, + ) -> Option>> { + Assets::::get(asset_id) + } + pub fn store_asset( + asset_id: u32, + metadata: AssetMetadata>, + ) { + Assets::::insert(asset_id, metadata); + } + pub fn exists(asset_id: u32) -> bool { + Assets::::contains_key(asset_id) + } + pub fn get_next_asset_id() -> u32 { + NextAssetId::::get() + } + pub fn increment_asset_id() -> u32 { + let current = Self::get_next_asset_id::(); + NextAssetId::::put(current.saturating_add(1)); + current + } + pub fn set_verified(asset_id: u32, is_verified: bool) -> bool { + Assets::::mutate(asset_id, |maybe_asset| { + if let Some(asset) = maybe_asset { + asset.is_verified = is_verified; + true + } else { + false + } + }) + } +} diff --git a/frame/shielded-pool/src/storage/balance.rs b/frame/shielded-pool/src/storage/balance.rs new file mode 100644 index 00000000..66de369d --- /dev/null +++ b/frame/shielded-pool/src/storage/balance.rs @@ -0,0 +1,35 @@ +//! Per-asset pool ledger. +//! +//! Tracks what the pool holds for each asset. Only the native asset is backed by +//! `Currency`; the ledger must stay equal to the pool account's physical balance, +//! an invariant the pallet asserts under `try_state`. + +use crate::pallet::{BalanceOf, Config, PoolBalancePerAsset}; +use sp_runtime::traits::Saturating; + +// PoolBalanceRepository + +pub struct PoolBalanceRepository; + +impl PoolBalanceRepository { + pub fn get_asset_balance(asset_id: u32) -> BalanceOf { + PoolBalancePerAsset::::get(asset_id) + } + pub fn set_asset_balance(asset_id: u32, balance: BalanceOf) { + PoolBalancePerAsset::::insert(asset_id, balance); + } + pub fn increase_balance(asset_id: u32, amount: BalanceOf) { + PoolBalancePerAsset::::mutate(asset_id, |balance| { + *balance = balance.saturating_add(amount); + }); + } + pub fn decrease_balance(asset_id: u32, amount: BalanceOf) { + PoolBalancePerAsset::::mutate(asset_id, |balance| { + // The unshield guard (`>= amount + fee`) makes `balance >= amount` always + // hold here; `defensive!` trips in tests/try-runtime if that invariant is + // ever broken, while `saturating_sub` keeps production safe. + frame_support::defensive_assert!(*balance >= amount); + *balance = balance.saturating_sub(amount); + }); + } +} diff --git a/frame/shielded-pool/src/storage/commitment.rs b/frame/shielded-pool/src/storage/commitment.rs new file mode 100644 index 00000000..c235e253 --- /dev/null +++ b/frame/shielded-pool/src/storage/commitment.rs @@ -0,0 +1,26 @@ +//! Commitment memo storage. +//! +//! Maps a note commitment to its encrypted memo. Membership here doubles as the +//! duplicate-commitment check, so `exists` is what keeps the same commitment +//! from being inserted into the tree twice. + +use crate::{ + pallet::{CommitmentMemos, Config}, + types::{Commitment, EncryptedMemo}, +}; + +// CommitmentRepository + +pub struct CommitmentRepository; + +impl CommitmentRepository { + pub fn get_memo(commitment: &Commitment) -> Option { + CommitmentMemos::::get(commitment) + } + pub fn store_memo(commitment: Commitment, memo: EncryptedMemo) { + CommitmentMemos::::insert(commitment, memo); + } + pub fn exists(commitment: &Commitment) -> bool { + CommitmentMemos::::contains_key(commitment) + } +} diff --git a/frame/shielded-pool/src/storage/merkle.rs b/frame/shielded-pool/src/storage/merkle.rs new file mode 100644 index 00000000..3530c281 --- /dev/null +++ b/frame/shielded-pool/src/storage/merkle.rs @@ -0,0 +1,149 @@ +//! Merkle tree and historic-root storage. +//! +//! Covers the live tree (root, size, leaves, frontier, internal nodes), the +//! sealed-tree anchors, and the historic-root window. +//! +//! The window is the subtle part: a root carries the block at which it stops +//! being accepted, and pruning is lazy, so an expired entry can outlive its +//! window in storage. Spendability is therefore decided by the stored expiry — +//! never by queue membership. + +use crate::{ + pallet::{ + CommitmentToLeafIndex, Config, HistoricPoseidonRoots, HistoricRootsHead, + HistoricRootsQueue, HistoricRootsTail, MerkleLeaves, MerkleNodes, MerkleTreeFrontier, + MerkleTreeSize, PoseidonRoot, SealedRootIndex, SealedTreeRoots, + }, + types::{Commitment, Hash}, +}; +use frame_support::traits::Get; +use frame_system::pallet_prelude::BlockNumberFor; +use sp_runtime::traits::Saturating; + +// MerkleRepository + +pub struct MerkleRepository; + +impl MerkleRepository { + pub fn get_poseidon_root() -> Hash { + PoseidonRoot::::get() + } + pub fn set_poseidon_root(root: Hash) { + PoseidonRoot::::put(root); + } + pub fn get_tree_size() -> u32 { + MerkleTreeSize::::get() + } + pub fn set_tree_size(size: u32) { + MerkleTreeSize::::put(size); + } + pub fn get_leaf(index: u32) -> Option { + MerkleLeaves::::get(index) + } + pub fn insert_leaf(index: u32, commitment: Commitment) { + MerkleLeaves::::insert(index, commitment); + } + /// A historic root is spendable until its expiry block, inclusive. + /// + /// Checked against the expiry stored at insert time rather than pruned + /// eagerly: pruning is lazy (see `MerkleTreeService::add_poseidon_historic_root`), + /// so an expired entry can outlive its window in storage. Reading the + /// expiry here makes that harmless. + pub fn is_known_poseidon_root(root: &Hash) -> bool { + match HistoricPoseidonRoots::::get(root) { + Some(expires_at) => frame_system::Pallet::::block_number() <= expires_at, + None => false, + } + } + /// A root is spendable if it is the active root, is still inside its + /// retention window, or anchors a sealed tree. + /// + /// The active root is accepted unconditionally. Expiries are only refreshed + /// by a leaf insert, so on a chain that goes quiet for a full retention + /// window the current root would otherwise expire while still being the one + /// every wallet proves against — wedging the pool, since `private_transfer` + /// and `unshield` both need a known root and only a funded `shield` could + /// mint a new one. + pub fn is_known_root(root: &Hash) -> bool { + *root == PoseidonRoot::::get() + || Self::is_known_poseidon_root::(root) + || SealedRootIndex::::contains_key(root) + } + pub fn insert_sealed_root(tree_id: u32, root: Hash) { + SealedTreeRoots::::insert(tree_id, root); + SealedRootIndex::::insert(root, tree_id); + } + pub fn get_sealed_root(tree_id: u32) -> Option { + SealedTreeRoots::::get(tree_id) + } + /// Record `root` as spendable for one full retention window from now. + pub fn add_historic_poseidon_root(root: Hash) { + let expires_at = + frame_system::Pallet::::block_number().saturating_add(T::RootRetentionBlocks::get()); + Self::add_historic_poseidon_root_until::(root, expires_at); + } + + /// Record `root` as spendable until `expires_at` (inclusive). + /// + /// A root re-inserted at a later block extends its expiry; it never shortens + /// it, so a duplicate root cannot cut short the window of the earlier entry. + pub fn add_historic_poseidon_root_until(root: Hash, expires_at: BlockNumberFor) { + HistoricPoseidonRoots::::mutate(root, |slot| match slot { + Some(current) if *current >= expires_at => {} + _ => *slot = Some(expires_at), + }); + } + pub fn remove_poseidon_historic_root(root: &Hash) { + HistoricPoseidonRoots::::remove(root); + } + pub fn get_historic_root_expiry(root: &Hash) -> Option> { + HistoricPoseidonRoots::::get(root) + } + pub fn get_historic_root_slot(slot: u64) -> Option<(Hash, BlockNumberFor)> { + HistoricRootsQueue::::get(slot) + } + pub fn set_historic_root_slot(slot: u64, root: Hash, expires_at: BlockNumberFor) { + HistoricRootsQueue::::insert(slot, (root, expires_at)); + } + pub fn remove_historic_root_slot(slot: u64) { + HistoricRootsQueue::::remove(slot); + } + pub fn get_historic_roots_head() -> u64 { + HistoricRootsHead::::get() + } + pub fn set_historic_roots_head(head: u64) { + HistoricRootsHead::::put(head); + } + pub fn get_historic_roots_tail() -> u64 { + HistoricRootsTail::::get() + } + pub fn set_historic_roots_tail(tail: u64) { + HistoricRootsTail::::put(tail); + } + /// Number of slots still queued. Bounded by the retention window in practice + /// and hard-capped by `MaxHistoricRoots`. + pub fn historic_roots_queued() -> u64 { + HistoricRootsHead::::get().saturating_sub(HistoricRootsTail::::get()) + } + pub fn get_frontier() -> [[u8; 32]; 20] { + MerkleTreeFrontier::::get() + } + pub fn set_frontier(frontier: [[u8; 32]; 20]) { + MerkleTreeFrontier::::put(frontier); + } + pub fn get_commitment_leaf_index(commitment: &Commitment) -> Option { + CommitmentToLeafIndex::::get(commitment) + } + pub fn set_commitment_leaf_index(commitment: Commitment, index: u32) { + CommitmentToLeafIndex::::insert(commitment, index); + } + pub fn find_leaf_index(commitment: &Commitment) -> Option { + Self::get_commitment_leaf_index::(commitment) + } + 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); + } +} diff --git a/frame/shielded-pool/src/storage.rs b/frame/shielded-pool/src/storage/mod.rs similarity index 55% rename from frame/shielded-pool/src/storage.rs rename to frame/shielded-pool/src/storage/mod.rs index b7251fb9..28374372 100644 --- a/frame/shielded-pool/src/storage.rs +++ b/frame/shielded-pool/src/storage/mod.rs @@ -1,225 +1,23 @@ //! Storage access — typed wrappers over every `StorageValue` / `StorageMap`. //! -//! One struct per logical domain (Assets, Commitments, Merkle, Nullifiers, -//! PoolBalance, Audit). All functions are static; no instance state. - -use crate::{ - pallet::{ - Assets, BalanceOf, CommitmentMemos, CommitmentToLeafIndex, Config, HistoricPoseidonRoots, - HistoricRootsOrder, MerkleLeaves, MerkleNodes, MerkleTreeFrontier, MerkleTreeSize, - NextAssetId, NullifierSet, PoolBalancePerAsset, PoseidonRoot, SealedRootIndex, - SealedTreeRoots, TotalCommitmentsInserted, TotalNullifiersSpent, - }, - types::{AssetMetadata, Commitment, EncryptedMemo, Hash}, -}; -use frame_support::pallet_prelude::*; -use frame_system::pallet_prelude::BlockNumberFor; -use sp_runtime::traits::Saturating; - -// ════════════════════════════════════════════════════════════════════════════ -// AssetRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct AssetRepository; - -impl AssetRepository { - pub fn get_asset( - asset_id: u32, - ) -> Option>> { - Assets::::get(asset_id) - } - pub fn store_asset( - asset_id: u32, - metadata: AssetMetadata>, - ) { - Assets::::insert(asset_id, metadata); - } - pub fn exists(asset_id: u32) -> bool { - Assets::::contains_key(asset_id) - } - pub fn get_next_asset_id() -> u32 { - NextAssetId::::get() - } - pub fn increment_asset_id() -> u32 { - let current = Self::get_next_asset_id::(); - NextAssetId::::put(current.saturating_add(1)); - current - } - pub fn set_verified(asset_id: u32, is_verified: bool) -> bool { - Assets::::mutate(asset_id, |maybe_asset| { - if let Some(asset) = maybe_asset { - asset.is_verified = is_verified; - true - } else { - false - } - }) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// CommitmentRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct CommitmentRepository; - -impl CommitmentRepository { - pub fn get_memo(commitment: &Commitment) -> Option { - CommitmentMemos::::get(commitment) - } - pub fn store_memo(commitment: Commitment, memo: EncryptedMemo) { - CommitmentMemos::::insert(commitment, memo); - } - pub fn exists(commitment: &Commitment) -> bool { - CommitmentMemos::::contains_key(commitment) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerkleRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct MerkleRepository; - -impl MerkleRepository { - pub fn get_poseidon_root() -> Hash { - PoseidonRoot::::get() - } - pub fn set_poseidon_root(root: Hash) { - PoseidonRoot::::put(root); - } - pub fn get_tree_size() -> u32 { - MerkleTreeSize::::get() - } - pub fn set_tree_size(size: u32) { - MerkleTreeSize::::put(size); - } - pub fn get_leaf(index: u32) -> Option { - MerkleLeaves::::get(index) - } - pub fn insert_leaf(index: u32, commitment: Commitment) { - MerkleLeaves::::insert(index, commitment); - } - pub fn is_known_poseidon_root(root: &Hash) -> bool { - HistoricPoseidonRoots::::get(root) - } - pub fn is_known_root(root: &Hash) -> bool { - Self::is_known_poseidon_root::(root) || SealedRootIndex::::contains_key(root) - } - pub fn insert_sealed_root(tree_id: u32, root: Hash) { - SealedTreeRoots::::insert(tree_id, root); - SealedRootIndex::::insert(root, tree_id); - } - pub fn get_sealed_root(tree_id: u32) -> Option { - SealedTreeRoots::::get(tree_id) - } - pub fn add_historic_poseidon_root(root: Hash) { - HistoricPoseidonRoots::::insert(root, true); - } - pub fn remove_poseidon_historic_root(root: &Hash) { - HistoricPoseidonRoots::::remove(root); - } - pub fn get_historic_roots_order() -> BoundedVec { - HistoricRootsOrder::::get() - } - pub fn set_historic_roots_order(order: BoundedVec) { - HistoricRootsOrder::::put(order); - } - pub fn get_frontier() -> [[u8; 32]; 20] { - MerkleTreeFrontier::::get() - } - pub fn set_frontier(frontier: [[u8; 32]; 20]) { - MerkleTreeFrontier::::put(frontier); - } - pub fn get_commitment_leaf_index(commitment: &Commitment) -> Option { - CommitmentToLeafIndex::::get(commitment) - } - pub fn set_commitment_leaf_index(commitment: Commitment, index: u32) { - CommitmentToLeafIndex::::insert(commitment, index); - } - pub fn find_leaf_index(commitment: &Commitment) -> Option { - Self::get_commitment_leaf_index::(commitment) - } - 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); - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// NullifierRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct NullifierRepository; - -impl NullifierRepository { - pub fn is_used(nullifier: &crate::types::Nullifier) -> bool { - NullifierSet::::contains_key(nullifier) - } - pub fn mark_as_used(nullifier: crate::types::Nullifier, block: BlockNumberFor) { - NullifierSet::::insert(nullifier, block); - PoolStatsRepository::increment_nullifiers_spent::(); - } - pub fn get_usage_block( - nullifier: &crate::types::Nullifier, - ) -> Option> { - NullifierSet::::get(nullifier) - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// PoolStatsRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct PoolStatsRepository; - -impl PoolStatsRepository { - pub fn increment_commitments_inserted() { - TotalCommitmentsInserted::::mutate(|n| *n = n.saturating_add(1)); - } - pub fn get_total_commitments_inserted() -> u64 { - TotalCommitmentsInserted::::get() - } - pub fn increment_nullifiers_spent() { - TotalNullifiersSpent::::mutate(|n| *n = n.saturating_add(1)); - } - pub fn get_total_nullifiers_spent() -> u64 { - TotalNullifiersSpent::::get() - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// PoolBalanceRepository -// ════════════════════════════════════════════════════════════════════════════ - -pub struct PoolBalanceRepository; - -impl PoolBalanceRepository { - pub fn get_asset_balance(asset_id: u32) -> BalanceOf { - PoolBalancePerAsset::::get(asset_id) - } - pub fn set_asset_balance(asset_id: u32, balance: BalanceOf) { - PoolBalancePerAsset::::insert(asset_id, balance); - } - pub fn increase_balance(asset_id: u32, amount: BalanceOf) { - PoolBalancePerAsset::::mutate(asset_id, |balance| { - *balance = balance.saturating_add(amount); - }); - } - pub fn decrease_balance(asset_id: u32, amount: BalanceOf) { - PoolBalancePerAsset::::mutate(asset_id, |balance| { - // The unshield guard (`>= amount + fee`) makes `balance >= amount` always - // hold here; `defensive!` trips in tests/try-runtime if that invariant is - // ever broken, while `saturating_sub` keeps production safe. - frame_support::defensive_assert!(*balance >= amount); - *balance = balance.saturating_sub(amount); - }); - } -} - -// ════════════════════════════════════════════════════════════════════════════ +//! One module per logical domain, one repository struct in each. All functions +//! are static; no instance state. Keeping the raw storage items behind these +//! wrappers means the pallet body never names a `StorageMap` directly, so a +//! layout change stays contained here. + +pub mod asset; +pub mod balance; +pub mod commitment; +pub mod merkle; +pub mod nullifier; +pub mod stats; + +pub use asset::AssetRepository; +pub use balance::PoolBalanceRepository; +pub use commitment::CommitmentRepository; +pub use merkle::MerkleRepository; +pub use nullifier::NullifierRepository; +pub use stats::PoolStatsRepository; #[cfg(test)] mod tests { diff --git a/frame/shielded-pool/src/storage/nullifier.rs b/frame/shielded-pool/src/storage/nullifier.rs new file mode 100644 index 00000000..8cf392e6 --- /dev/null +++ b/frame/shielded-pool/src/storage/nullifier.rs @@ -0,0 +1,28 @@ +//! Nullifier set — the double-spend guard. +//! +//! Insert-only by design: there is no removal path, and a nullifier maps to the +//! block it was spent in. Every spend checks membership before taking effect and +//! inserts within the same dispatch, so a rollback undoes both together. + +use super::stats::PoolStatsRepository; +use crate::pallet::{Config, NullifierSet}; +use frame_system::pallet_prelude::BlockNumberFor; + +// NullifierRepository + +pub struct NullifierRepository; + +impl NullifierRepository { + pub fn is_used(nullifier: &crate::types::Nullifier) -> bool { + NullifierSet::::contains_key(nullifier) + } + pub fn mark_as_used(nullifier: crate::types::Nullifier, block: BlockNumberFor) { + NullifierSet::::insert(nullifier, block); + PoolStatsRepository::increment_nullifiers_spent::(); + } + pub fn get_usage_block( + nullifier: &crate::types::Nullifier, + ) -> Option> { + NullifierSet::::get(nullifier) + } +} diff --git a/frame/shielded-pool/src/storage/stats.rs b/frame/shielded-pool/src/storage/stats.rs new file mode 100644 index 00000000..124f7f33 --- /dev/null +++ b/frame/shielded-pool/src/storage/stats.rs @@ -0,0 +1,26 @@ +//! Pool counters. +//! +//! Monotonic totals for commitments inserted and nullifiers spent. Saturating +//! throughout — a stalled counter is preferable to a wrapped one, and neither is +//! reachable at any realistic chain lifetime. + +use crate::pallet::{Config, TotalCommitmentsInserted, TotalNullifiersSpent}; + +// PoolStatsRepository + +pub struct PoolStatsRepository; + +impl PoolStatsRepository { + pub fn increment_commitments_inserted() { + TotalCommitmentsInserted::::mutate(|n| *n = n.saturating_add(1)); + } + pub fn get_total_commitments_inserted() -> u64 { + TotalCommitmentsInserted::::get() + } + pub fn increment_nullifiers_spent() { + TotalNullifiersSpent::::mutate(|n| *n = n.saturating_add(1)); + } + pub fn get_total_nullifiers_spent() -> u64 { + TotalNullifiersSpent::::get() + } +} diff --git a/frame/shielded-pool/src/types.rs b/frame/shielded-pool/src/types.rs deleted file mode 100644 index 9a6e32f7..00000000 --- a/frame/shielded-pool/src/types.rs +++ /dev/null @@ -1,719 +0,0 @@ -//! Pallet types — all structs, enums, newtypes and type aliases. -//! -//! Concentrates every type defined by `pallet-shielded-pool` in a single file -//! so that any consumer (operations, storage, tests) can reach them with -//! `use crate::types::*`. - -// ── Codec & FRAME imports ────────────────────────────────────────────────── -use frame_support::{BoundedVec, pallet_prelude::*}; -use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; -use scale_info::TypeInfo; -use sp_core::H256; -use sp_runtime::RuntimeDebug; -use sp_std::vec::Vec; - -// ════════════════════════════════════════════════════════════════════════════ -// Primitive value types -// ════════════════════════════════════════════════════════════════════════════ - -/// A 32-byte hash used for Merkle roots, cryptographic hashes and identifiers. -pub type Hash = [u8; 32]; - -// ════════════════════════════════════════════════════════════════════════════ -// Commitment -// ════════════════════════════════════════════════════════════════════════════ - -/// A commitment to a private note. -/// -/// Computed as: `Poseidon(value, asset_id, owner_pubkey, blinding)` -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct Commitment(pub [u8; 32]); - -impl Commitment { - pub fn new(bytes: [u8; 32]) -> Self { - Self(bytes) - } - pub fn is_valid(&self) -> bool { - self.0 != [0u8; 32] - } - pub fn is_zero(&self) -> bool { - self.0 == [0u8; 32] - } - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - pub fn into_bytes(self) -> [u8; 32] { - self.0 - } -} - -impl From<[u8; 32]> for Commitment { - fn from(b: [u8; 32]) -> Self { - Self::new(b) - } -} -impl From for Commitment { - fn from(h: H256) -> Self { - Self::new(h.0) - } -} -impl AsRef<[u8]> for Commitment { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Nullifier -// ════════════════════════════════════════════════════════════════════════════ - -/// A nullifier identifying a spent note. -/// -/// Computed as: `Poseidon(commitment, spending_key)` -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct Nullifier(pub [u8; 32]); - -impl Nullifier { - pub fn new(bytes: [u8; 32]) -> Self { - Self(bytes) - } - pub fn validate(&self) -> bool { - self.0 != [0u8; 32] - } - pub fn as_bytes(&self) -> &[u8; 32] { - &self.0 - } - pub fn into_bytes(self) -> [u8; 32] { - self.0 - } -} - -impl From<[u8; 32]> for Nullifier { - fn from(b: [u8; 32]) -> Self { - Self::new(b) - } -} -impl From for Nullifier { - fn from(h: H256) -> Self { - Self::new(h.0) - } -} -impl AsRef<[u8]> for Nullifier { - fn as_ref(&self) -> &[u8] { - &self.0 - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// AssetId -// ════════════════════════════════════════════════════════════════════════════ - -/// Identifier for an asset in the shielded pool. -/// -/// `0` = native (ORB), `1+` = registered external assets. -#[derive( - Clone, - Copy, - PartialEq, - Eq, - Encode, - Decode, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default, - PartialOrd, - Ord -)] -pub struct AssetId(pub u32); - -impl AssetId { - pub fn new(id: u32) -> Self { - Self(id) - } - pub fn native() -> Self { - Self(0) - } - pub fn is_native(&self) -> bool { - self.0 == 0 - } - pub fn inner(&self) -> u32 { - self.0 - } - pub fn is_valid(&self) -> bool { - true - } -} - -impl From for AssetId { - fn from(id: u32) -> Self { - Self(id) - } -} -impl From for u32 { - fn from(a: AssetId) -> Self { - a.0 - } -} - -impl core::fmt::Display for AssetId { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - if self.is_native() { - write!(f, "Native Asset (0)") - } else { - write!(f, "Asset {}", self.0) - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// Note (off-chain type, used by wallets) -// ════════════════════════════════════════════════════════════════════════════ - -/// A private note in the shielded pool (UTXO). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Note { - value: u128, - owner_pubkey: Hash, - blinding: Hash, - asset_id: u32, -} - -impl Note { - pub fn new(value: u128, owner_pubkey: Hash, blinding: Hash) -> Result { - if value == 0 { - return Err("Note value cannot be zero"); - } - if owner_pubkey == [0u8; 32] { - return Err("Owner public key cannot be zero"); - } - if blinding == [0u8; 32] { - return Err("Blinding factor cannot be zero"); - } - Ok(Self { - value, - owner_pubkey, - blinding, - asset_id: 0, - }) - } - - pub fn new_with_asset( - value: u128, - owner_pubkey: Hash, - blinding: Hash, - asset_id: u32, - ) -> Result { - let mut note = Self::new(value, owner_pubkey, blinding)?; - note.asset_id = asset_id; - Ok(note) - } - - pub fn value(&self) -> u128 { - self.value - } - pub fn owner_pubkey(&self) -> &Hash { - &self.owner_pubkey - } - pub fn blinding(&self) -> &Hash { - &self.blinding - } - pub fn asset_id(&self) -> u32 { - self.asset_id - } - - pub fn to_bytes(&self) -> Vec { - let mut bytes = Vec::new(); - bytes.extend_from_slice(&self.value.to_le_bytes()); - bytes.extend_from_slice(&self.asset_id.to_le_bytes()); - bytes.extend_from_slice(&self.owner_pubkey); - bytes.extend_from_slice(&self.blinding); - bytes - } - - pub fn is_valid(&self) -> bool { - self.value > 0 && self.owner_pubkey != [0u8; 32] && self.blinding != [0u8; 32] - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// MerklePath -// ════════════════════════════════════════════════════════════════════════════ - -pub const DEFAULT_TREE_DEPTH: usize = 20; -pub const MAX_TREE_DEPTH: u32 = 20; - -/// A Merkle path (siblings from leaf to root). -#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug, PartialEq, Eq)] -pub struct MerklePath { - pub siblings: [[u8; 32]; DEPTH], - pub indices: [u8; DEPTH], -} - -impl Default for MerklePath { - fn default() -> Self { - Self { - siblings: [[0u8; 32]; DEPTH], - indices: [0u8; DEPTH], - } - } -} - -pub type DefaultMerklePath = MerklePath; - -// ════════════════════════════════════════════════════════════════════════════ -// EncryptedMemo (concrete, FRAME-compatible — used in storage & extrinsics) -// ════════════════════════════════════════════════════════════════════════════ - -/// Max encrypted memo size: `nonce(12) + ciphertext(120) + MAC(16) + ephPk(32) = 180`. -pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 180; - -/// Encrypted memo attached to a commitment (ChaCha20-Poly1305). -#[derive( - Clone, - PartialEq, - Eq, - Encode, - Decode, - DecodeWithMemTracking, - MaxEncodedLen, - TypeInfo, - RuntimeDebug, - Default -)] -pub struct EncryptedMemo(pub BoundedVec>); - -impl EncryptedMemo { - pub fn new(data: Vec) -> Result { - BoundedVec::try_from(data) - .map(Self) - .map_err(|_| "Memo size exceeds maximum") - } - pub fn as_bytes(&self) -> &[u8] { - &self.0 - } - pub fn is_valid_size(&self) -> bool { - !self.0.is_empty() - } - pub fn len(&self) -> usize { - self.0.len() - } - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - pub fn from_bytes(bytes: &[u8]) -> Result { - if bytes.len() != MAX_ENCRYPTED_MEMO_SIZE as usize { - return Err("Invalid memo size"); - } - Self::new(bytes.to_vec()) - } - pub fn nonce(&self) -> &[u8] { - // Invariant: EncryptedMemo is always exactly MAX_ENCRYPTED_MEMO_SIZE bytes after - // construction. from_bytes() enforces this; the else branch is unreachable in practice. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 12 { - &self.0[..12] - } else { - &[] - } - } - pub fn ciphertext(&self) -> &[u8] { - // Invariant: see nonce(). ciphertext occupies bytes 12..132. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 132 { - &self.0[12..132] - } else { - &[] - } - } - pub fn tag(&self) -> &[u8] { - // Layout: nonce(0..12) | ciphertext(12..132) | tag/MAC(132..148) | ephPk(148..180) - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 148 { - &self.0[132..148] - } else { - &[] - } - } - pub fn eph_pk(&self) -> &[u8] { - // Ephemeral BabyJubJub public key (packed, LE) occupies bytes 148..180. - debug_assert_eq!( - self.0.len(), - MAX_ENCRYPTED_MEMO_SIZE as usize, - "EncryptedMemo invariant violated: expected {} bytes, got {}", - MAX_ENCRYPTED_MEMO_SIZE, - self.0.len() - ); - if self.0.len() >= 180 { - &self.0[148..180] - } else { - &[] - } - } -} - -// ════════════════════════════════════════════════════════════════════════════ -// AssetMetadata -// ════════════════════════════════════════════════════════════════════════════ - -/// Asset metadata for multi-asset shielded pool. -#[derive( - Clone, - PartialEq, - Eq, - Encode, - Decode, - MaxEncodedLen, - TypeInfo, - RuntimeDebug -)] -pub struct AssetMetadata { - pub id: u32, - pub name: BoundedVec>, - pub symbol: BoundedVec>, - pub decimals: u8, - pub is_verified: bool, - pub contract_address: Option<[u8; 20]>, - pub created_at: BlockNumber, - pub creator: AccountId, -} - -impl AssetMetadata { - pub fn new( - id: u32, - name: BoundedVec>, - symbol: BoundedVec>, - decimals: u8, - created_at: BlockNumber, - creator: AccountId, - ) -> Self { - Self { - id, - name, - symbol, - decimals, - is_verified: false, - contract_address: None, - created_at, - creator, - } - } - pub fn verify(&mut self) { - self.is_verified = true; - } - pub fn unverify(&mut self) { - self.is_verified = false; - } - pub fn is_verified(&self) -> bool { - self.is_verified - } - pub fn set_contract_address(&mut self, address: [u8; 20]) { - self.contract_address = Some(address); - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── Commitment ────────────────────────────────────────────────────────── - - #[test] - fn commitment_new_stores_bytes() { - let c = Commitment::new([0x01u8; 32]); - assert_eq!(c.0, [0x01u8; 32]); - } - - #[test] - fn commitment_is_valid_non_zero_only() { - assert!(Commitment::new([0x01u8; 32]).is_valid()); - assert!(!Commitment::new([0x00u8; 32]).is_valid()); - } - - #[test] - fn commitment_is_zero_checks_all_zero_bytes() { - assert!(Commitment::new([0x00u8; 32]).is_zero()); - assert!(!Commitment::new([0x01u8; 32]).is_zero()); - } - - #[test] - fn commitment_as_bytes_and_into_bytes() { - let bytes = [0x42u8; 32]; - let c = Commitment::new(bytes); - assert_eq!(c.as_bytes(), &bytes); - assert_eq!(c.into_bytes(), bytes); - } - - #[test] - fn commitment_from_array() { - let bytes = [0x99u8; 32]; - let c: Commitment = bytes.into(); - assert_eq!(c.0, bytes); - } - - #[test] - fn commitment_default_is_zero() { - assert_eq!(Commitment::default(), Commitment::new([0u8; 32])); - } - - // ── Nullifier ─────────────────────────────────────────────────────────── - - #[test] - fn nullifier_new_and_accessors() { - let bytes = [0x10u8; 32]; - let n = Nullifier::new(bytes); - assert_eq!(n.as_bytes(), &bytes); - assert_eq!(n.into_bytes(), bytes); - } - - #[test] - fn nullifier_validate_non_zero_only() { - assert!(Nullifier::new([0x01u8; 32]).validate()); - assert!(!Nullifier::new([0x00u8; 32]).validate()); - } - - #[test] - fn nullifier_from_array() { - let bytes = [0xAAu8; 32]; - let n: Nullifier = bytes.into(); - assert_eq!(n.0, bytes); - } - - #[test] - fn nullifier_default_is_zero() { - assert_eq!(Nullifier::default(), Nullifier::new([0u8; 32])); - } - - // ── AssetId ───────────────────────────────────────────────────────────── - - #[test] - fn asset_id_new_and_inner() { - let a = AssetId::new(42); - assert_eq!(a.inner(), 42); - assert_eq!(a.0, 42); - } - - #[test] - fn asset_id_native_is_zero() { - let n = AssetId::native(); - assert!(n.is_native()); - assert_eq!(n.inner(), 0); - } - - #[test] - fn asset_id_not_native_when_nonzero() { - assert!(!AssetId::new(1).is_native()); - assert!(!AssetId::new(99).is_native()); - } - - #[test] - fn asset_id_from_and_into_u32() { - let a: AssetId = 7u32.into(); - assert_eq!(a.inner(), 7); - let v: u32 = AssetId::new(13).into(); - assert_eq!(v, 13); - } - - #[test] - fn asset_id_is_valid_always_true() { - assert!(AssetId::new(0).is_valid()); - assert!(AssetId::new(u32::MAX).is_valid()); - } - - // ── Note ──────────────────────────────────────────────────────────────── - - #[test] - fn note_new_valid_sets_all_fields() { - let pk = [0x01u8; 32]; - let bl = [0x02u8; 32]; - let note = Note::new(100, pk, bl).unwrap(); - assert_eq!(note.value(), 100); - assert_eq!(note.owner_pubkey(), &pk); - assert_eq!(note.blinding(), &bl); - assert_eq!(note.asset_id(), 0); - assert!(note.is_valid()); - } - - #[test] - fn note_new_zero_value_fails() { - assert!(Note::new(0, [0x01u8; 32], [0x02u8; 32]).is_err()); - } - - #[test] - fn note_new_zero_pubkey_fails() { - assert!(Note::new(100, [0u8; 32], [0x02u8; 32]).is_err()); - } - - #[test] - fn note_new_zero_blinding_fails() { - assert!(Note::new(100, [0x01u8; 32], [0u8; 32]).is_err()); - } - - #[test] - fn note_new_with_asset_sets_asset_id() { - let note = Note::new_with_asset(50, [0x01u8; 32], [0x02u8; 32], 5).unwrap(); - assert_eq!(note.asset_id(), 5); - assert_eq!(note.value(), 50); - } - - #[test] - fn note_to_bytes_has_correct_length() { - let note = Note::new(100, [0x01u8; 32], [0x02u8; 32]).unwrap(); - // 16 (value u128) + 4 (asset_id u32) + 32 (pubkey) + 32 (blinding) = 84 - assert_eq!(note.to_bytes().len(), 84); - } - - #[test] - fn note_asset_id_serializes_as_4_bytes() { - // asset_id must be 4 LE bytes to match the circuit's public signal - // (commitment[0..32] | value[32..40] | asset_id[40..44] | ...). - let note = Note::new_with_asset(100, [0x01u8; 32], [0x02u8; 32], 0x01020304).unwrap(); - let bytes = note.to_bytes(); - assert_eq!(&bytes[16..20], &0x01020304u32.to_le_bytes()); - } - - // ── MerklePath ────────────────────────────────────────────────────────── - - #[test] - fn merkle_path_default_all_zeros() { - let path = DefaultMerklePath::default(); - assert_eq!(path.siblings, [[0u8; 32]; DEFAULT_TREE_DEPTH]); - assert_eq!(path.indices, [0u8; DEFAULT_TREE_DEPTH]); - } - - #[test] - fn merkle_path_generic_depth() { - let path = MerklePath::<5>::default(); - assert_eq!(path.siblings.len(), 5); - assert_eq!(path.indices.len(), 5); - } - - // ── EncryptedMemo ──────────────────────────────────────────────────────── - - #[test] - fn encrypted_memo_new_valid_size() { - let data = vec![0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::new(data).unwrap(); - assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); - } - - #[test] - fn encrypted_memo_new_exceeds_max_fails() { - let data = vec![0x01u8; (MAX_ENCRYPTED_MEMO_SIZE + 1) as usize]; - assert!(EncryptedMemo::new(data).is_err()); - } - - #[test] - fn encrypted_memo_from_bytes_exact_size() { - let bytes = [0x03u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); - assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); - } - - #[test] - fn encrypted_memo_from_bytes_wrong_size_fails() { - assert!(EncryptedMemo::from_bytes(&[0x01u8; 32]).is_err()); - assert!(EncryptedMemo::from_bytes(&[0x01u8; 50]).is_err()); - } - - #[test] - fn encrypted_memo_nonce_ciphertext_tag_slices() { - let bytes = [0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; - let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); - assert_eq!(memo.nonce().len(), 12); - assert_eq!(memo.ciphertext().len(), 120); - assert_eq!(memo.tag().len(), 16); - assert_eq!(memo.eph_pk().len(), 32); - } - - #[test] - fn encrypted_memo_default_is_empty() { - let empty = EncryptedMemo::default(); - assert!(empty.is_empty()); - assert!(!empty.is_valid_size()); - } - - #[test] - fn encrypted_memo_full_is_not_empty() { - let full = EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(); - assert!(!full.is_empty()); - assert!(full.is_valid_size()); - } - - // ── AssetMetadata ─────────────────────────────────────────────────────── - - #[test] - fn asset_metadata_new_not_verified_by_default() { - let name: BoundedVec> = BoundedVec::try_from(b"Test".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"TST".to_vec()).unwrap(); - let meta: AssetMetadata = AssetMetadata::new(1, name, sym, 18, 0u64, 99u64); - assert!(!meta.is_verified()); - assert_eq!(meta.id, 1); - assert_eq!(meta.decimals, 18); - assert_eq!(meta.contract_address, None); - } - - #[test] - fn asset_metadata_verify_unverify() { - let name: BoundedVec> = BoundedVec::try_from(b"Token".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"TKN".to_vec()).unwrap(); - let mut meta: AssetMetadata = AssetMetadata::new(2, name, sym, 8, 10u64, 1u64); - assert!(!meta.is_verified()); - meta.verify(); - assert!(meta.is_verified()); - meta.unverify(); - assert!(!meta.is_verified()); - } - - #[test] - fn asset_metadata_set_contract_address() { - let name: BoundedVec> = BoundedVec::try_from(b"ERC20".to_vec()).unwrap(); - let sym: BoundedVec> = BoundedVec::try_from(b"ERC".to_vec()).unwrap(); - let mut meta: AssetMetadata = AssetMetadata::new(3, name, sym, 18, 0u64, 1u64); - let addr = [0xABu8; 20]; - meta.set_contract_address(addr); - assert_eq!(meta.contract_address, Some(addr)); - } -} diff --git a/frame/shielded-pool/src/types/asset.rs b/frame/shielded-pool/src/types/asset.rs new file mode 100644 index 00000000..779bbd85 --- /dev/null +++ b/frame/shielded-pool/src/types/asset.rs @@ -0,0 +1,68 @@ +//! Registered asset metadata. +//! +//! 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. + +use frame_support::{BoundedVec, pallet_prelude::*}; +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; + +// AssetMetadata + +/// Asset metadata for multi-asset shielded pool. +#[derive( + Clone, + PartialEq, + Eq, + Encode, + Decode, + MaxEncodedLen, + TypeInfo, + RuntimeDebug +)] +pub struct AssetMetadata { + pub id: u32, + pub name: BoundedVec>, + pub symbol: BoundedVec>, + pub decimals: u8, + pub is_verified: bool, + pub contract_address: Option<[u8; 20]>, + pub created_at: BlockNumber, + pub creator: AccountId, +} + +impl AssetMetadata { + pub fn new( + id: u32, + name: BoundedVec>, + symbol: BoundedVec>, + decimals: u8, + created_at: BlockNumber, + creator: AccountId, + ) -> Self { + Self { + id, + name, + symbol, + decimals, + is_verified: false, + contract_address: None, + created_at, + creator, + } + } + pub fn verify(&mut self) { + self.is_verified = true; + } + pub fn unverify(&mut self) { + self.is_verified = false; + } + pub fn is_verified(&self) -> bool { + self.is_verified + } + pub fn set_contract_address(&mut self, address: [u8; 20]) { + self.contract_address = Some(address); + } +} diff --git a/frame/shielded-pool/src/types/ids.rs b/frame/shielded-pool/src/types/ids.rs new file mode 100644 index 00000000..957de0ca --- /dev/null +++ b/frame/shielded-pool/src/types/ids.rs @@ -0,0 +1,179 @@ +//! Newtypes over the 32-byte field elements and the asset id. +//! +//! `Commitment` and `Nullifier` are both `[u8; 32]` underneath, and confusing +//! one for the other would be a silent correctness bug — the newtypes make that +//! a compile error instead. +//! +//! Both are stored as **raw bytes**, so byte equality is identity. Callers +//! feeding untrusted input must reject non-canonical encodings before they reach +//! storage; the ZK verifier does this today for anything backed by a proof. + +use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_core::H256; +use sp_runtime::RuntimeDebug; + +// Commitment + +/// A commitment to a private note. +/// +/// Computed as: `Poseidon(value, asset_id, owner_pubkey, blinding)` +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct Commitment(pub [u8; 32]); + +impl Commitment { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub fn is_valid(&self) -> bool { + self.0 != [0u8; 32] + } + pub fn is_zero(&self) -> bool { + self.0 == [0u8; 32] + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl From<[u8; 32]> for Commitment { + fn from(b: [u8; 32]) -> Self { + Self::new(b) + } +} +impl From for Commitment { + fn from(h: H256) -> Self { + Self::new(h.0) + } +} +impl AsRef<[u8]> for Commitment { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +// Nullifier + +/// A nullifier identifying a spent note. +/// +/// Computed as: `Poseidon(commitment, spending_key)` +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct Nullifier(pub [u8; 32]); + +impl Nullifier { + pub fn new(bytes: [u8; 32]) -> Self { + Self(bytes) + } + pub fn validate(&self) -> bool { + self.0 != [0u8; 32] + } + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub fn into_bytes(self) -> [u8; 32] { + self.0 + } +} + +impl From<[u8; 32]> for Nullifier { + fn from(b: [u8; 32]) -> Self { + Self::new(b) + } +} +impl From for Nullifier { + fn from(h: H256) -> Self { + Self::new(h.0) + } +} +impl AsRef<[u8]> for Nullifier { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +// AssetId + +/// Identifier for an asset in the shielded pool. +/// +/// `0` = native (ORB), `1+` = registered external assets. +#[derive( + Clone, + Copy, + PartialEq, + Eq, + Encode, + Decode, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default, + PartialOrd, + Ord +)] +pub struct AssetId(pub u32); + +impl AssetId { + pub fn new(id: u32) -> Self { + Self(id) + } + pub fn native() -> Self { + Self(0) + } + pub fn is_native(&self) -> bool { + self.0 == 0 + } + pub fn inner(&self) -> u32 { + self.0 + } + pub fn is_valid(&self) -> bool { + true + } +} + +impl From for AssetId { + fn from(id: u32) -> Self { + Self(id) + } +} +impl From for u32 { + fn from(a: AssetId) -> Self { + a.0 + } +} + +impl core::fmt::Display for AssetId { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + if self.is_native() { + write!(f, "Native Asset (0)") + } else { + write!(f, "Asset {}", self.0) + } + } +} diff --git a/frame/shielded-pool/src/types/memo.rs b/frame/shielded-pool/src/types/memo.rs new file mode 100644 index 00000000..60b97e9f --- /dev/null +++ b/frame/shielded-pool/src/types/memo.rs @@ -0,0 +1,120 @@ +//! Encrypted memo attached to a commitment. +//! +//! Fixed at exactly `MAX_ENCRYPTED_MEMO_SIZE` bytes with a fixed field layout, +//! which is what lets the accessors slice it by offset. Those accessors are +//! defensive anyway — they return an empty slice rather than panicking if the +//! invariant is ever violated, since a panic here would be reachable from a +//! dispatchable. + +use frame_support::{BoundedVec, pallet_prelude::*}; +use parity_scale_codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; +use sp_runtime::RuntimeDebug; +use sp_std::vec::Vec; + +// EncryptedMemo (concrete, FRAME-compatible — used in storage & extrinsics) + +/// Max encrypted memo size: `nonce(12) + ciphertext(120) + MAC(16) + ephPk(32) = 180`. +pub const MAX_ENCRYPTED_MEMO_SIZE: u32 = 180; + +/// Encrypted memo attached to a commitment (ChaCha20-Poly1305). +#[derive( + Clone, + PartialEq, + Eq, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, + RuntimeDebug, + Default +)] +pub struct EncryptedMemo(pub BoundedVec>); + +impl EncryptedMemo { + pub fn new(data: Vec) -> Result { + BoundedVec::try_from(data) + .map(Self) + .map_err(|_| "Memo size exceeds maximum") + } + pub fn as_bytes(&self) -> &[u8] { + &self.0 + } + pub fn is_valid_size(&self) -> bool { + !self.0.is_empty() + } + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != MAX_ENCRYPTED_MEMO_SIZE as usize { + return Err("Invalid memo size"); + } + Self::new(bytes.to_vec()) + } + pub fn nonce(&self) -> &[u8] { + // Invariant: EncryptedMemo is always exactly MAX_ENCRYPTED_MEMO_SIZE bytes after + // construction. from_bytes() enforces this; the else branch is unreachable in practice. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 12 { + &self.0[..12] + } else { + &[] + } + } + pub fn ciphertext(&self) -> &[u8] { + // Invariant: see nonce(). ciphertext occupies bytes 12..132. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 132 { + &self.0[12..132] + } else { + &[] + } + } + pub fn tag(&self) -> &[u8] { + // Layout: nonce(0..12) | ciphertext(12..132) | tag/MAC(132..148) | ephPk(148..180) + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 148 { + &self.0[132..148] + } else { + &[] + } + } + pub fn eph_pk(&self) -> &[u8] { + // Ephemeral BabyJubJub public key (packed, LE) occupies bytes 148..180. + debug_assert_eq!( + self.0.len(), + MAX_ENCRYPTED_MEMO_SIZE as usize, + "EncryptedMemo invariant violated: expected {} bytes, got {}", + MAX_ENCRYPTED_MEMO_SIZE, + self.0.len() + ); + if self.0.len() >= 180 { + &self.0[148..180] + } else { + &[] + } + } +} diff --git a/frame/shielded-pool/src/types/merkle.rs b/frame/shielded-pool/src/types/merkle.rs new file mode 100644 index 00000000..aaad4afc --- /dev/null +++ b/frame/shielded-pool/src/types/merkle.rs @@ -0,0 +1,31 @@ +//! Merkle path type and the tree-depth constants. +//! +//! `DEFAULT_TREE_DEPTH` is fixed at 20 and pinned by `integrity_test`: clients +//! derive a note's `tree_id` from it, so changing it on a live chain would +//! re-map every existing note. + +use parity_scale_codec::{Decode, Encode, MaxEncodedLen}; +use scale_info::TypeInfo; + +// MerklePath + +pub const DEFAULT_TREE_DEPTH: usize = 20; +pub const MAX_TREE_DEPTH: u32 = 20; + +/// A Merkle path (siblings from leaf to root). +#[derive(Clone, Encode, Decode, TypeInfo, MaxEncodedLen, Debug, PartialEq, Eq)] +pub struct MerklePath { + pub siblings: [[u8; 32]; DEPTH], + pub indices: [u8; DEPTH], +} + +impl Default for MerklePath { + fn default() -> Self { + Self { + siblings: [[0u8; 32]; DEPTH], + indices: [0u8; DEPTH], + } + } +} + +pub type DefaultMerklePath = MerklePath; diff --git a/frame/shielded-pool/src/types/mod.rs b/frame/shielded-pool/src/types/mod.rs new file mode 100644 index 00000000..553b8199 --- /dev/null +++ b/frame/shielded-pool/src/types/mod.rs @@ -0,0 +1,294 @@ +//! Pallet types — every struct, newtype and alias defined by the pallet. +//! +//! Split by domain, but re-exported flat so consumers keep reaching them through +//! `crate::types::*` regardless of which module they live in: +//! +//! - [`ids`] — `Commitment`, `Nullifier`, `AssetId`. +//! - [`note`] — the off-chain shielded note. +//! - [`merkle`] — Merkle path plus the tree-depth constants. +//! - [`memo`] — the fixed-size encrypted memo. +//! - [`asset`] — registered asset metadata. + +pub mod asset; +pub mod ids; +pub mod memo; +pub mod merkle; +pub mod note; + +pub use asset::AssetMetadata; +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; + +/// A 32-byte hash used for Merkle roots, cryptographic hashes and identifiers. +/// +/// Deliberately a bare alias, not a newtype: it names a shape, not a role. The +/// roles that must not be interchanged — commitments and nullifiers — get real +/// newtypes in [`ids`]. +pub type Hash = [u8; 32]; + +#[cfg(test)] +mod tests { + use super::*; + use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + + // ── Commitment ────────────────────────────────────────────────────────── + + #[test] + fn commitment_new_stores_bytes() { + let c = Commitment::new([0x01u8; 32]); + assert_eq!(c.0, [0x01u8; 32]); + } + + #[test] + fn commitment_is_valid_non_zero_only() { + assert!(Commitment::new([0x01u8; 32]).is_valid()); + assert!(!Commitment::new([0x00u8; 32]).is_valid()); + } + + #[test] + fn commitment_is_zero_checks_all_zero_bytes() { + assert!(Commitment::new([0x00u8; 32]).is_zero()); + assert!(!Commitment::new([0x01u8; 32]).is_zero()); + } + + #[test] + fn commitment_as_bytes_and_into_bytes() { + let bytes = [0x42u8; 32]; + let c = Commitment::new(bytes); + assert_eq!(c.as_bytes(), &bytes); + assert_eq!(c.into_bytes(), bytes); + } + + #[test] + fn commitment_from_array() { + let bytes = [0x99u8; 32]; + let c: Commitment = bytes.into(); + assert_eq!(c.0, bytes); + } + + #[test] + fn commitment_default_is_zero() { + assert_eq!(Commitment::default(), Commitment::new([0u8; 32])); + } + + // ── Nullifier ─────────────────────────────────────────────────────────── + + #[test] + fn nullifier_new_and_accessors() { + let bytes = [0x10u8; 32]; + let n = Nullifier::new(bytes); + assert_eq!(n.as_bytes(), &bytes); + assert_eq!(n.into_bytes(), bytes); + } + + #[test] + fn nullifier_validate_non_zero_only() { + assert!(Nullifier::new([0x01u8; 32]).validate()); + assert!(!Nullifier::new([0x00u8; 32]).validate()); + } + + #[test] + fn nullifier_from_array() { + let bytes = [0xAAu8; 32]; + let n: Nullifier = bytes.into(); + assert_eq!(n.0, bytes); + } + + #[test] + fn nullifier_default_is_zero() { + assert_eq!(Nullifier::default(), Nullifier::new([0u8; 32])); + } + + // ── AssetId ───────────────────────────────────────────────────────────── + + #[test] + fn asset_id_new_and_inner() { + let a = AssetId::new(42); + assert_eq!(a.inner(), 42); + assert_eq!(a.0, 42); + } + + #[test] + fn asset_id_native_is_zero() { + let n = AssetId::native(); + assert!(n.is_native()); + assert_eq!(n.inner(), 0); + } + + #[test] + fn asset_id_not_native_when_nonzero() { + assert!(!AssetId::new(1).is_native()); + assert!(!AssetId::new(99).is_native()); + } + + #[test] + fn asset_id_from_and_into_u32() { + let a: AssetId = 7u32.into(); + assert_eq!(a.inner(), 7); + let v: u32 = AssetId::new(13).into(); + assert_eq!(v, 13); + } + + #[test] + fn asset_id_is_valid_always_true() { + assert!(AssetId::new(0).is_valid()); + assert!(AssetId::new(u32::MAX).is_valid()); + } + + // ── Note ──────────────────────────────────────────────────────────────── + + #[test] + fn note_new_valid_sets_all_fields() { + let pk = [0x01u8; 32]; + let bl = [0x02u8; 32]; + let note = Note::new(100, pk, bl).unwrap(); + assert_eq!(note.value(), 100); + assert_eq!(note.owner_pubkey(), &pk); + assert_eq!(note.blinding(), &bl); + assert_eq!(note.asset_id(), 0); + assert!(note.is_valid()); + } + + #[test] + fn note_new_zero_value_fails() { + assert!(Note::new(0, [0x01u8; 32], [0x02u8; 32]).is_err()); + } + + #[test] + fn note_new_zero_pubkey_fails() { + assert!(Note::new(100, [0u8; 32], [0x02u8; 32]).is_err()); + } + + #[test] + fn note_new_zero_blinding_fails() { + assert!(Note::new(100, [0x01u8; 32], [0u8; 32]).is_err()); + } + + #[test] + fn note_new_with_asset_sets_asset_id() { + let note = Note::new_with_asset(50, [0x01u8; 32], [0x02u8; 32], 5).unwrap(); + assert_eq!(note.asset_id(), 5); + assert_eq!(note.value(), 50); + } + + #[test] + fn note_to_bytes_has_correct_length() { + let note = Note::new(100, [0x01u8; 32], [0x02u8; 32]).unwrap(); + // 16 (value u128) + 4 (asset_id u32) + 32 (pubkey) + 32 (blinding) = 84 + assert_eq!(note.to_bytes().len(), 84); + } + + #[test] + fn note_asset_id_serializes_as_4_bytes() { + // asset_id must be 4 LE bytes to match the circuit's public signal + // (commitment[0..32] | value[32..40] | asset_id[40..44] | ...). + let note = Note::new_with_asset(100, [0x01u8; 32], [0x02u8; 32], 0x01020304).unwrap(); + let bytes = note.to_bytes(); + assert_eq!(&bytes[16..20], &0x01020304u32.to_le_bytes()); + } + + // ── MerklePath ────────────────────────────────────────────────────────── + + #[test] + fn merkle_path_default_all_zeros() { + let path = DefaultMerklePath::default(); + assert_eq!(path.siblings, [[0u8; 32]; DEFAULT_TREE_DEPTH]); + assert_eq!(path.indices, [0u8; DEFAULT_TREE_DEPTH]); + } + + #[test] + fn merkle_path_generic_depth() { + let path = MerklePath::<5>::default(); + assert_eq!(path.siblings.len(), 5); + assert_eq!(path.indices.len(), 5); + } + + // ── EncryptedMemo ──────────────────────────────────────────────────────── + + #[test] + fn encrypted_memo_new_valid_size() { + let data = vec![0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::new(data).unwrap(); + assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); + } + + #[test] + fn encrypted_memo_new_exceeds_max_fails() { + let data = vec![0x01u8; (MAX_ENCRYPTED_MEMO_SIZE + 1) as usize]; + assert!(EncryptedMemo::new(data).is_err()); + } + + #[test] + fn encrypted_memo_from_bytes_exact_size() { + let bytes = [0x03u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); + assert_eq!(memo.len(), MAX_ENCRYPTED_MEMO_SIZE as usize); + } + + #[test] + fn encrypted_memo_from_bytes_wrong_size_fails() { + assert!(EncryptedMemo::from_bytes(&[0x01u8; 32]).is_err()); + assert!(EncryptedMemo::from_bytes(&[0x01u8; 50]).is_err()); + } + + #[test] + fn encrypted_memo_nonce_ciphertext_tag_slices() { + let bytes = [0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; + let memo = EncryptedMemo::from_bytes(&bytes).unwrap(); + assert_eq!(memo.nonce().len(), 12); + assert_eq!(memo.ciphertext().len(), 120); + assert_eq!(memo.tag().len(), 16); + assert_eq!(memo.eph_pk().len(), 32); + } + + #[test] + fn encrypted_memo_default_is_empty() { + let empty = EncryptedMemo::default(); + assert!(empty.is_empty()); + assert!(!empty.is_valid_size()); + } + + #[test] + fn encrypted_memo_full_is_not_empty() { + let full = EncryptedMemo::from_bytes(&[0x01u8; MAX_ENCRYPTED_MEMO_SIZE as usize]).unwrap(); + assert!(!full.is_empty()); + assert!(full.is_valid_size()); + } + + // ── AssetMetadata ─────────────────────────────────────────────────────── + + #[test] + fn asset_metadata_new_not_verified_by_default() { + let name: BoundedVec> = BoundedVec::try_from(b"Test".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"TST".to_vec()).unwrap(); + let meta: AssetMetadata = AssetMetadata::new(1, name, sym, 18, 0u64, 99u64); + assert!(!meta.is_verified()); + assert_eq!(meta.id, 1); + assert_eq!(meta.decimals, 18); + assert_eq!(meta.contract_address, None); + } + + #[test] + fn asset_metadata_verify_unverify() { + let name: BoundedVec> = BoundedVec::try_from(b"Token".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"TKN".to_vec()).unwrap(); + let mut meta: AssetMetadata = AssetMetadata::new(2, name, sym, 8, 10u64, 1u64); + assert!(!meta.is_verified()); + meta.verify(); + assert!(meta.is_verified()); + meta.unverify(); + assert!(!meta.is_verified()); + } + + #[test] + fn asset_metadata_set_contract_address() { + let name: BoundedVec> = BoundedVec::try_from(b"ERC20".to_vec()).unwrap(); + let sym: BoundedVec> = BoundedVec::try_from(b"ERC".to_vec()).unwrap(); + let mut meta: AssetMetadata = AssetMetadata::new(3, name, sym, 18, 0u64, 1u64); + let addr = [0xABu8; 20]; + meta.set_contract_address(addr); + assert_eq!(meta.contract_address, Some(addr)); + } +} diff --git a/frame/shielded-pool/src/types/note.rs b/frame/shielded-pool/src/types/note.rs new file mode 100644 index 00000000..01dab3e4 --- /dev/null +++ b/frame/shielded-pool/src/types/note.rs @@ -0,0 +1,76 @@ +//! The shielded note — the private-side unit of value. +//! +//! Only ever built and inspected off-chain: the chain sees its commitment, never +//! the note itself. Field widths must match the circuit exactly, since the +//! commitment is computed over this layout on both sides. + +use super::Hash; +use sp_std::vec::Vec; + +// Note (off-chain type, used by wallets) + +/// A private note in the shielded pool (UTXO). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Note { + value: u128, + owner_pubkey: Hash, + blinding: Hash, + asset_id: u32, +} + +impl Note { + pub fn new(value: u128, owner_pubkey: Hash, blinding: Hash) -> Result { + if value == 0 { + return Err("Note value cannot be zero"); + } + if owner_pubkey == [0u8; 32] { + return Err("Owner public key cannot be zero"); + } + if blinding == [0u8; 32] { + return Err("Blinding factor cannot be zero"); + } + Ok(Self { + value, + owner_pubkey, + blinding, + asset_id: 0, + }) + } + + pub fn new_with_asset( + value: u128, + owner_pubkey: Hash, + blinding: Hash, + asset_id: u32, + ) -> Result { + let mut note = Self::new(value, owner_pubkey, blinding)?; + note.asset_id = asset_id; + Ok(note) + } + + pub fn value(&self) -> u128 { + self.value + } + pub fn owner_pubkey(&self) -> &Hash { + &self.owner_pubkey + } + pub fn blinding(&self) -> &Hash { + &self.blinding + } + pub fn asset_id(&self) -> u32 { + self.asset_id + } + + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&self.value.to_le_bytes()); + bytes.extend_from_slice(&self.asset_id.to_le_bytes()); + bytes.extend_from_slice(&self.owner_pubkey); + bytes.extend_from_slice(&self.blinding); + bytes + } + + pub fn is_valid(&self) -> bool { + self.value > 0 && self.owner_pubkey != [0u8; 32] && self.blinding != [0u8; 32] + } +} diff --git a/frame/shielded-pool/src/validate_unsigned/codes.rs b/frame/shielded-pool/src/validate_unsigned/codes.rs new file mode 100644 index 00000000..d5d20177 --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/codes.rs @@ -0,0 +1,39 @@ +//! Pool-rejection codes for unsigned transactions. +//! +//! `InvalidTransaction::Custom` carries a bare `u8`, which reaches operators as +//! `Custom error: N` with no name attached. Naming the codes here keeps the two +//! validators from drifting apart and makes a rejection diagnosable from a log +//! line alone. +//! +//! Codes are part of the observable interface: a wallet or relayer may branch on +//! them, so **never reuse a number for a different meaning** — retire it and add +//! a new one instead. + +use sp_runtime::transaction_validity::InvalidTransaction; + +/// The Merkle root is not in the historic window and does not anchor a sealed +/// tree, so no proof can be verified against it. +pub const UNKNOWN_ROOT: u8 = 1; + +/// Every input nullifier is the dummy sentinel, i.e. the transaction spends +/// nothing. Rejected as anti-spam: it would insert commitments for free. +pub const ALL_INPUTS_DUMMY: u8 = 2; + +/// `amount + fee` overflows the balance type. +/// +/// Deliberately distinct from [`ALL_INPUTS_DUMMY`]: both meanings once shared +/// code 2 across the two validators, which made a rejection ambiguous to anyone +/// reading a node log. +pub const AMOUNT_OVERFLOW: u8 = 4; + +/// The pool does not hold enough of the asset to cover `amount + fee`. +pub const INSUFFICIENT_POOL_BALANCE: u8 = 3; + +/// The circuit version is not registered in the verifier, so the proof could +/// never verify. Checked first — it is the cheapest gate of all. +pub const UNSUPPORTED_CIRCUIT_VERSION: u8 = 10; + +/// Build an `InvalidTransaction` from one of the codes above. +pub fn reject(code: u8) -> InvalidTransaction { + InvalidTransaction::Custom(code) +} diff --git a/frame/shielded-pool/src/validate_unsigned.rs b/frame/shielded-pool/src/validate_unsigned/mod.rs similarity index 76% rename from frame/shielded-pool/src/validate_unsigned.rs rename to frame/shielded-pool/src/validate_unsigned/mod.rs index 06dc5653..96417673 100644 --- a/frame/shielded-pool/src/validate_unsigned.rs +++ b/frame/shielded-pool/src/validate_unsigned/mod.rs @@ -1,153 +1,55 @@ //! Unsigned transaction validation for `private_transfer` and `unshield`. //! -//! These are lightweight anti-spam checks that run before the transaction -//! enters the pool. Full ZK proof verification happens inside each extrinsic. +//! These are lightweight anti-spam checks that run before a transaction enters +//! the pool. Full ZK proof verification happens inside each extrinsic — doing it +//! here would let anyone burn a node's CPU for free, since unsigned submissions +//! cost nothing to make. //! -//! Splitting this out of `lib.rs` makes the validation logic independently -//! testable without needing the full pallet mock environment. - -use crate::{ - pallet::{BalanceOf, Config, NullifierSet, PoolBalancePerAsset}, - storage::MerkleRepository, - types::{Hash, Nullifier}, -}; -use frame_support::pallet_prelude::*; -use pallet_relayer::RelayerInterface as _; -use pallet_zk_verifier::ZkVerifierPort as _; -use parity_scale_codec::Encode; -use sp_runtime::{ - SaturatedConversion, - transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, -}; - -/// On-chain circuit ids used for the version guard (mirror the zk-verifier's -/// `CircuitId` constants: TRANSFER = 1, UNSHIELD = 2). -const CIRCUIT_TRANSFER: u32 = 1; -const CIRCUIT_UNSHIELD: u32 = 2; - -/// How long an unsigned transaction stays valid in the pool, in blocks. Bounded -/// so a transaction that never gets included does not linger indefinitely. -const TX_LONGEVITY: u64 = 64; - -/// Validate an incoming `private_transfer` unsigned transaction. -pub fn validate_private_transfer( - merkle_root: &Hash, - nullifiers: &BoundedVec>, - fee: &BalanceOf, - relayer: &Option, - circuit_version: u32, -) -> TransactionValidity { - // Anti-spam: reject an unsupported circuit version before pool admission. - if !T::ZkVerifier::is_supported_version(CIRCUIT_TRANSFER, circuit_version) { - return InvalidTransaction::Custom(10).into(); - } - - // Anti-spam: fee must meet minimum relay fee - let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - if *fee < min_fee { - return InvalidTransaction::Payment.into(); - } - - // Reject unknown Merkle roots - if !MerkleRepository::is_known_root::(merkle_root) { - return InvalidTransaction::Custom(1).into(); - } - - // Reject already-spent nullifiers (skip dummy nullifiers — value zero, forced by circuit) - for nullifier in nullifiers.iter() { - if nullifier.0 == [0u8; 32] { - continue; // dummy input — never inserted in the set, cannot be stale - } - if NullifierSet::::contains_key(nullifier) { - return InvalidTransaction::Stale.into(); - } - } - - // Reject transactions where all nullifiers are dummy (both inputs value=0). - // This prevents free Merkle tree spam (2 commitments inserted at zero cost). - if nullifiers.iter().all(|n| n.0 == [0u8; 32]) { - return InvalidTransaction::Custom(2).into(); - } - - // Exclude dummy nullifiers (zero) from provides — they carry no identity. - // Bind the fee recipient (`relayer`) into the tag so a variant differing only - // in `relayer` is a distinct pool entry and cannot silently replace the honest - // tx. The shared nullifier tag already makes same-nullifier variants mutually - // exclusive (first-seen wins at equal fee); this hardens that boundary. - let mut provides: alloc::vec::Vec> = nullifiers - .iter() - .filter(|n| n.0 != [0u8; 32]) - .map(|n| n.encode()) - .collect(); - provides.push(relayer.encode()); - - ValidTransaction::with_tag_prefix("ShieldedPoolTransfer") - .priority((*fee).saturated_into()) - .longevity(TX_LONGEVITY) - .and_provides(provides) - .propagate(true) - .build() -} - -/// Validate an incoming `unshield` unsigned transaction. -pub fn validate_unshield( - merkle_root: &Hash, - nullifier: &Nullifier, - asset_id: &u32, - amount: &BalanceOf, - fee: &BalanceOf, - relayer: &Option, - circuit_version: u32, -) -> TransactionValidity { - // Anti-spam: reject an unsupported circuit version before pool admission. - if !T::ZkVerifier::is_supported_version(CIRCUIT_UNSHIELD, circuit_version) { - return InvalidTransaction::Custom(10).into(); - } - - // Anti-spam: fee must meet minimum relay fee - let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); - if *fee < min_fee { - return InvalidTransaction::Payment.into(); - } +//! Every check here is also re-done in the dispatchable. That is deliberate: a +//! check performed only at admission could be skipped by a malicious block +//! author, so admission may reject more than execution but never less. +//! +//! - [`codes`] — named pool-rejection codes shared by both validators. +//! - [`transfer`] — admission for `private_transfer`. +//! - [`unshield`] — admission for `unshield`. - // Reject unknown Merkle roots - if !MerkleRepository::is_known_root::(merkle_root) { - return InvalidTransaction::Custom(1).into(); - } +pub mod codes; +pub mod transfer; +pub mod unshield; - // Reject already-spent nullifier - if NullifierSet::::contains_key(nullifier) { - return InvalidTransaction::Stale.into(); - } +pub use transfer::validate_private_transfer; +pub use unshield::validate_unshield; - // Reject if pool balance is insufficient - let total = amount - .checked_add(fee) - .ok_or(InvalidTransaction::Custom(2))?; - if PoolBalancePerAsset::::get(asset_id) < total { - return InvalidTransaction::Custom(3).into(); - } - - // Bind `relayer` into the tag alongside the nullifier: a variant differing only - // in the fee recipient is a distinct pool entry, so it cannot silently replace - // the honest tx. Same-nullifier variants stay mutually exclusive (first-seen - // wins at equal fee). - ValidTransaction::with_tag_prefix("ShieldedPoolUnshield") - .priority((*fee).saturated_into()) - .longevity(TX_LONGEVITY) - .and_provides([nullifier.encode(), relayer.encode()]) - .propagate(true) - .build() -} +/// How long an unsigned transaction stays valid in the pool, in blocks. Bounded +/// so a transaction that never gets included does not linger indefinitely. +/// +/// `Config::RootRetentionBlocks` must exceed this (checked in `integrity_test`): +/// a root has to outlive every transaction admitted against it, or a spend can +/// pass admission, propagate, and only then revert with `UnknownMerkleRoot`. +pub(crate) const TX_LONGEVITY: u64 = 64; #[cfg(test)] mod tests { - use super::*; + use super::{TX_LONGEVITY, validate_private_transfer, validate_unshield}; use crate::{ mock::{Test, new_test_ext}, storage::{MerkleRepository, NullifierRepository, PoolBalanceRepository}, types::Nullifier, }; + use frame_support::{BoundedVec, pallet_prelude::ConstU32}; + + /// Rejection codes reach wallets and relayers as bare `Custom(N)`, so they are + /// part of the observable interface: pin them here, and retire a number rather + /// than reuse it for a new meaning. + #[test] + fn rejection_codes_are_stable() { + use super::codes; + assert_eq!(codes::UNKNOWN_ROOT, 1); + assert_eq!(codes::ALL_INPUTS_DUMMY, 2); + assert_eq!(codes::INSUFFICIENT_POOL_BALANCE, 3); + assert_eq!(codes::AMOUNT_OVERFLOW, 4); + assert_eq!(codes::UNSUPPORTED_CIRCUIT_VERSION, 10); + } const KNOWN_ROOT: [u8; 32] = [0x11u8; 32]; diff --git a/frame/shielded-pool/src/validate_unsigned/transfer.rs b/frame/shielded-pool/src/validate_unsigned/transfer.rs new file mode 100644 index 00000000..400ae412 --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/transfer.rs @@ -0,0 +1,86 @@ +//! Pool admission for `private_transfer`. +//! +//! Checks run cheapest-first — a version lookup, a fee compare, then two point +//! reads — so flooding the pool with invalid transactions stays cheap to reject. +//! No ZK verification happens here; that is the extrinsic's job. + +use super::{ + TX_LONGEVITY, + codes::{self, reject}, +}; +use crate::{ + pallet::{BalanceOf, Config, NullifierSet}, + storage::MerkleRepository, + types::{Hash, Nullifier}, +}; +use frame_support::pallet_prelude::*; +use pallet_relayer::RelayerInterface as _; +use pallet_zk_verifier::ZkVerifierPort as _; +use parity_scale_codec::Encode; +use sp_runtime::{ + SaturatedConversion, + transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, +}; + +/// On-chain circuit id for the transfer/unshield version guard (mirrors the +/// zk-verifier's `CircuitId` constants). +const CIRCUIT_TRANSFER: u32 = 1; + +pub fn validate_private_transfer( + merkle_root: &Hash, + nullifiers: &BoundedVec>, + fee: &BalanceOf, + relayer: &Option, + circuit_version: u32, +) -> TransactionValidity { + // Anti-spam: reject an unsupported circuit version before pool admission. + if !T::ZkVerifier::is_supported_version(CIRCUIT_TRANSFER, circuit_version) { + return reject(codes::UNSUPPORTED_CIRCUIT_VERSION).into(); + } + + // Anti-spam: fee must meet minimum relay fee + let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + if *fee < min_fee { + return InvalidTransaction::Payment.into(); + } + + // Reject unknown Merkle roots + if !MerkleRepository::is_known_root::(merkle_root) { + return reject(codes::UNKNOWN_ROOT).into(); + } + + // Reject already-spent nullifiers (skip dummy nullifiers — value zero, forced by circuit) + for nullifier in nullifiers.iter() { + if nullifier.0 == [0u8; 32] { + continue; // dummy input — never inserted in the set, cannot be stale + } + if NullifierSet::::contains_key(nullifier) { + return InvalidTransaction::Stale.into(); + } + } + + // Reject transactions where all nullifiers are dummy (both inputs value=0). + // This prevents free Merkle tree spam (2 commitments inserted at zero cost). + if nullifiers.iter().all(|n| n.0 == [0u8; 32]) { + return reject(codes::ALL_INPUTS_DUMMY).into(); + } + + // Exclude dummy nullifiers (zero) from provides — they carry no identity. + // Bind the fee recipient (`relayer`) into the tag so a variant differing only + // in `relayer` is a distinct pool entry and cannot silently replace the honest + // tx. The shared nullifier tag already makes same-nullifier variants mutually + // exclusive (first-seen wins at equal fee); this hardens that boundary. + let mut provides: alloc::vec::Vec> = nullifiers + .iter() + .filter(|n| n.0 != [0u8; 32]) + .map(|n| n.encode()) + .collect(); + provides.push(relayer.encode()); + + ValidTransaction::with_tag_prefix("ShieldedPoolTransfer") + .priority((*fee).saturated_into()) + .longevity(TX_LONGEVITY) + .and_provides(provides) + .propagate(true) + .build() +} diff --git a/frame/shielded-pool/src/validate_unsigned/unshield.rs b/frame/shielded-pool/src/validate_unsigned/unshield.rs new file mode 100644 index 00000000..2cda0cce --- /dev/null +++ b/frame/shielded-pool/src/validate_unsigned/unshield.rs @@ -0,0 +1,79 @@ +//! Pool admission for `unshield`. +//! +//! Mirrors [`super::transfer`], plus a pool-solvency check. That check is +//! advisory only: the balance can move between admission and execution, so the +//! extrinsic re-verifies it. Rejecting early just avoids gossiping a spend the +//! pool cannot cover. + +use super::{ + TX_LONGEVITY, + codes::{self, reject}, +}; +use crate::{ + pallet::{BalanceOf, Config, NullifierSet, PoolBalancePerAsset}, + storage::MerkleRepository, + types::{Hash, Nullifier}, +}; +use frame_support::pallet_prelude::*; +use pallet_relayer::RelayerInterface as _; +use pallet_zk_verifier::ZkVerifierPort as _; +use parity_scale_codec::Encode; +use sp_runtime::{ + SaturatedConversion, + transaction_validity::{InvalidTransaction, TransactionValidity, ValidTransaction}, +}; + +/// On-chain circuit id for the transfer/unshield version guard (mirrors the +/// zk-verifier's `CircuitId` constants). +const CIRCUIT_UNSHIELD: u32 = 2; + +/// Validate an incoming `unshield` unsigned transaction. +pub fn validate_unshield( + merkle_root: &Hash, + nullifier: &Nullifier, + asset_id: &u32, + amount: &BalanceOf, + fee: &BalanceOf, + relayer: &Option, + circuit_version: u32, +) -> TransactionValidity { + // Anti-spam: reject an unsupported circuit version before pool admission. + if !T::ZkVerifier::is_supported_version(CIRCUIT_UNSHIELD, circuit_version) { + return reject(codes::UNSUPPORTED_CIRCUIT_VERSION).into(); + } + + // Anti-spam: fee must meet minimum relay fee + let min_fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + if *fee < min_fee { + return InvalidTransaction::Payment.into(); + } + + // Reject unknown Merkle roots + if !MerkleRepository::is_known_root::(merkle_root) { + return reject(codes::UNKNOWN_ROOT).into(); + } + + // Reject already-spent nullifier + if NullifierSet::::contains_key(nullifier) { + return InvalidTransaction::Stale.into(); + } + + // Reject if pool balance is insufficient + let total = amount + .checked_add(fee) + .ok_or(reject(codes::AMOUNT_OVERFLOW))?; + if PoolBalancePerAsset::::get(asset_id) < total { + return reject(codes::INSUFFICIENT_POOL_BALANCE).into(); + } + + // Bind `relayer` into the tag alongside the nullifier: a variant differing only + // in the fee recipient is a distinct pool entry, so it cannot silently replace + // the honest tx. Same-nullifier variants stay mutually exclusive (first-seen + // wins at equal fee). + ValidTransaction::with_tag_prefix("ShieldedPoolUnshield") + .priority((*fee).saturated_into()) + .longevity(TX_LONGEVITY) + .and_provides([nullifier.encode(), relayer.encode()]) + .propagate(true) + .build() +} diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index f8704906..78ba658c 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-30, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-08-05, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `ubuntu-32gb-hel1-1`, CPU: `AMD EPYC-Genoa Processor` +//! HOSTNAME: `ubuntu-32gb-nbg1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 // Executed Command: @@ -76,26 +76,30 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn shield() -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `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(32_u64)) + // Measured: `1242` + // Estimated: `3695` + // Minimum execution time: 861_716_000 picoseconds. + Weight::from_parts(878_209_000, 3695) + .saturating_add(T::DbWeight::get().reads(17_u64)) + .saturating_add(T::DbWeight::get().writes(34_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -121,14 +125,18 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:20) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:20 w:20) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:20) @@ -136,28 +144,30 @@ impl WeightInfo for SubstrateWeight { /// The range of component `n` is `[1, 20]`. fn shield_batch(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687 + n * (2705 ±0)` - // 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(26_u64)) - .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1242` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 853_004_000 picoseconds. + Weight::from_parts(77_406_360, 3631) + // Standard Error: 2_015_265 + .saturating_add(Weight::from_parts(851_634_513, 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(27_u64)) + .saturating_add(T::DbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:2) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:2 w:2) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `Relayer::MinRelayFee` (r:1 w:0) /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) @@ -166,12 +176,14 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::CommitmentMemos` (`max_values`: None, `max_size`: Some(230), added: 2705, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeFrontier` (r:1 w:1) /// Proof: `ShieldedPool::MerkleTreeFrontier` (`max_values`: Some(1), `max_size`: Some(640), added: 1135, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) - /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:2) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -191,22 +203,26 @@ impl WeightInfo for SubstrateWeight { /// The range of component `n` is `[1, 2]`. fn private_transfer(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1182` - // Estimated: `4687 + n * (2705 ±0)` - // 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(28_u64)) - .saturating_add(T::DbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1260` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 829_842_000 picoseconds. + Weight::from_parts(58_727_597, 3631) + // Standard Error: 3_711_290 + .saturating_add(Weight::from_parts(813_265_051, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(17_u64)) + .saturating_add(T::DbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(29_u64)) + .saturating_add(T::DbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:1 w:1) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) @@ -217,8 +233,6 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -233,11 +247,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) fn unshield() -> Weight { // Proof Size summary in bytes: - // Measured: `903` + // Measured: `981` // Estimated: `6196` - // Minimum execution time: 113_240_000 picoseconds. - Weight::from_parts(118_970_000, 6196) - .saturating_add(T::DbWeight::get().reads(15_u64)) + // Minimum execution time: 108_500_000 picoseconds. + Weight::from_parts(112_414_000, 6196) + .saturating_add(T::DbWeight::get().reads(16_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) @@ -254,10 +268,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn register_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `239` + // Measured: `260` // Estimated: `3631` - // Minimum execution time: 18_560_000 picoseconds. - Weight::from_parts(20_220_000, 3631) + // Minimum execution time: 16_953_000 picoseconds. + Weight::from_parts(17_543_000, 3631) .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } @@ -273,10 +287,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn verify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 21_810_000 picoseconds. - Weight::from_parts(24_540_000, 3631) + // Minimum execution time: 17_044_000 picoseconds. + Weight::from_parts(17_754_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -292,10 +306,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn unverify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_850_000 picoseconds. - Weight::from_parts(19_700_000, 3631) + // Minimum execution time: 16_913_000 picoseconds. + Weight::from_parts(17_645_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -321,24 +335,28 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn claim_shielded_fees() -> Weight { // Proof Size summary in bytes: - // Measured: `1085` - // Estimated: `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(31_u64)) + // Measured: `1200` + // Estimated: `3695` + // Minimum execution time: 841_328_000 picoseconds. + Weight::from_parts(851_813_000, 3695) + .saturating_add(T::DbWeight::get().reads(15_u64)) + .saturating_add(T::DbWeight::get().writes(33_u64)) } } @@ -368,26 +386,30 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn shield() -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `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(32_u64)) + // Measured: `1242` + // Estimated: `3695` + // Minimum execution time: 861_716_000 picoseconds. + Weight::from_parts(878_209_000, 3695) + .saturating_add(RocksDbWeight::get().reads(17_u64)) + .saturating_add(RocksDbWeight::get().writes(34_u64)) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) @@ -413,14 +435,18 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:20) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:20 w:20) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:20) @@ -428,28 +454,30 @@ impl WeightInfo for () { /// The range of component `n` is `[1, 20]`. fn shield_batch(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1127` - // Estimated: `4687 + n * (2705 ±0)` - // 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(26_u64)) - .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1242` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 853_004_000 picoseconds. + Weight::from_parts(77_406_360, 3631) + // Standard Error: 2_015_265 + .saturating_add(Weight::from_parts(851_634_513, 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(27_u64)) + .saturating_add(RocksDbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:2) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:3 w:2) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:2 w:2) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `Relayer::MinRelayFee` (r:1 w:0) /// Proof: `Relayer::MinRelayFee` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalNullifiersSpent` (r:1 w:1) /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:1) @@ -458,12 +486,14 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::CommitmentMemos` (`max_values`: None, `max_size`: Some(230), added: 2705, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::MerkleTreeFrontier` (r:1 w:1) /// Proof: `ShieldedPool::MerkleTreeFrontier` (`max_values`: Some(1), `max_size`: Some(640), added: 1135, mode: `MaxEncodedLen`) - /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:1) - /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:2) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -483,22 +513,26 @@ impl WeightInfo for () { /// The range of component `n` is `[1, 2]`. fn private_transfer(n: u32, ) -> Weight { // Proof Size summary in bytes: - // Measured: `1182` - // Estimated: `4687 + n * (2705 ±0)` - // 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(28_u64)) - .saturating_add(RocksDbWeight::get().writes((5_u64).saturating_mul(n.into()))) + // Measured: `1260` + // Estimated: `3631 + n * (2705 ±0)` + // Minimum execution time: 829_842_000 picoseconds. + Weight::from_parts(58_727_597, 3631) + // Standard Error: 3_711_290 + .saturating_add(Weight::from_parts(813_265_051, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(17_u64)) + .saturating_add(RocksDbWeight::get().reads((3_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(29_u64)) + .saturating_add(RocksDbWeight::get().writes((6_u64).saturating_mul(n.into()))) .saturating_add(Weight::from_parts(0, 2705).saturating_mul(n.into())) } /// Storage: `ShieldedPool::Assets` (r:1 w:0) /// Proof: `ShieldedPool::Assets` (`max_values`: None, `max_size`: Some(166), added: 2641, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::PoseidonRoot` (r:1 w:0) + /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:0) - /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(49), added: 2524, mode: `MaxEncodedLen`) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `System::Number` (r:1 w:0) + /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::NullifierSet` (r:1 w:1) /// Proof: `ShieldedPool::NullifierSet` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::PoolBalancePerAsset` (r:1 w:1) @@ -509,8 +543,6 @@ impl WeightInfo for () { /// Proof: `Balances::TotalIssuance` (`max_values`: Some(1), `max_size`: Some(16), added: 511, mode: `MaxEncodedLen`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `System::Number` (r:1 w:0) - /// Proof: `System::Number` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) /// Storage: `System::ExecutionPhase` (r:1 w:0) /// Proof: `System::ExecutionPhase` (`max_values`: Some(1), `max_size`: Some(5), added: 500, mode: `MaxEncodedLen`) /// Storage: `System::EventCount` (r:1 w:1) @@ -525,11 +557,11 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::TotalNullifiersSpent` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) fn unshield() -> Weight { // Proof Size summary in bytes: - // Measured: `903` + // Measured: `981` // Estimated: `6196` - // Minimum execution time: 113_240_000 picoseconds. - Weight::from_parts(118_970_000, 6196) - .saturating_add(RocksDbWeight::get().reads(15_u64)) + // Minimum execution time: 108_500_000 picoseconds. + Weight::from_parts(112_414_000, 6196) + .saturating_add(RocksDbWeight::get().reads(16_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } /// Storage: `ShieldedPool::NextAssetId` (r:1 w:1) @@ -546,10 +578,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn register_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `239` + // Measured: `260` // Estimated: `3631` - // Minimum execution time: 18_560_000 picoseconds. - Weight::from_parts(20_220_000, 3631) + // Minimum execution time: 16_953_000 picoseconds. + Weight::from_parts(17_543_000, 3631) .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } @@ -565,10 +597,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn verify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 21_810_000 picoseconds. - Weight::from_parts(24_540_000, 3631) + // Minimum execution time: 17_044_000 picoseconds. + Weight::from_parts(17_754_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -584,10 +616,10 @@ impl WeightInfo for () { /// Proof: `System::Events` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) fn unverify_asset() -> Weight { // Proof Size summary in bytes: - // Measured: `328` + // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_850_000 picoseconds. - Weight::from_parts(19_700_000, 3631) + // Minimum execution time: 16_913_000 picoseconds. + Weight::from_parts(17_645_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -613,23 +645,27 @@ impl WeightInfo for () { /// Proof: `ShieldedPool::PoseidonRoot` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::TotalCommitmentsInserted` (r:1 w:1) /// 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::HistoricRootsHead` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsHead` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsTail` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsTail` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricRootsQueue` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricRootsQueue` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::HistoricPoseidonRoots` (r:1 w:1) + /// Proof: `ShieldedPool::HistoricPoseidonRoots` (`max_values`: None, `max_size`: Some(52), added: 2527, 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) /// Proof: `ShieldedPool::MerkleLeaves` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) /// Storage: `ShieldedPool::CommitmentToLeafIndex` (r:0 w:1) /// Proof: `ShieldedPool::CommitmentToLeafIndex` (`max_values`: None, `max_size`: Some(52), added: 2527, mode: `MaxEncodedLen`) fn claim_shielded_fees() -> Weight { // Proof Size summary in bytes: - // Measured: `1085` - // Estimated: `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(31_u64)) + // Measured: `1200` + // Estimated: `3695` + // Minimum execution time: 841_328_000 picoseconds. + Weight::from_parts(851_813_000, 3695) + .saturating_add(RocksDbWeight::get().reads(15_u64)) + .saturating_add(RocksDbWeight::get().writes(33_u64)) } } diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index 14d2298e..cb104864 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -22,6 +22,7 @@ The genesis reset (`69d1b837`) set `spec_version` back to 1 and | spec | tx | Date | Commit | Change | |------|----|------|--------|--------| +| 7 | 2 | 2026-08-05 | — | Two pallet changes shipping in one upgrade (neither 7 nor 8 was ever deployed, so they collapse into a single bump). **shielded-pool 0.14.0:** historic-root window re-anchored from insert counts to block numbers. New Config `RootRetentionBlocks` (300 blocks); `MaxHistoricRoots` (raised to 16384) becomes a queue-length cap rather than the window. `HistoricPoseidonRoots` now stores an expiry block instead of a bool; `HistoricRootsOrder` replaced by the slot-indexed `HistoricRootsQueue` + `Head`/`Tail`. `STORAGE_VERSION` 2 → 3 with `MigrateToV3` in the tuple. Weights re-benchmarked against the v3 layout. The amount-overflow pool rejection moves from `Custom(2)` to `Custom(4)`, which had two meanings. **Applied migrations removed (`72ff7b88`):** the v1/v2 modules are gone from shielded-pool and zk-verifier 0.11.0 — testnet was already past both, so they were no-ops, and `MigrateToV1` rebuilt the whole Merkle tree in one block. `spec_version` moves because storage layout and `on_runtime_upgrade` both change; `transaction_version` stays — no call signature changed. | | 6 | 2 | 2026-08-03 | — | `pallet-account-mapping` and its precompile (index 14, address 0x0800) removed, along with the `private_link` circuit (id 5) and its verification key. Index 14 is retired and must not be reassigned. zk-verifier 0.10.0 gains `purge_circuit` (call index 7, Root) plus `STORAGE_VERSION` 1 and `MigrateToV1`, which drops the stranded circuit-5 key that no extrinsic could reach. `transaction_version` moves because the new call index changes extrinsic encoding. | | 5 | 1 | 2026-07-30 | — | shielded-pool 0.12.0: multi-tree forest. Full trees seal (`TreeSealed`, permanent `SealedTreeRoots` anchors) and inserts roll over to a fresh tree — the 2^20-note network ceiling is gone. New Config `MaxLeavesPerTree` (2^20), `STORAGE_VERSION` 2 (`MigrateToV2`, version-only), runtime API v2 (`get_forest_info`, `get_root_for_leaf`). No circuit/extrinsic/ABI changes. | | 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). | diff --git a/template/runtime/src/lib.rs b/template/runtime/src/lib.rs index b59dfc57..1722be13 100644 --- a/template/runtime/src/lib.rs +++ b/template/runtime/src/lib.rs @@ -150,7 +150,10 @@ pub type CheckedExtrinsic = pub type SignedPayload = generic::SignedPayload; /// Storage migrations run on runtime upgrade, oldest first. -pub type Migrations = (); +/// +/// Drop an entry once every live chain has passed its version — a migration +/// that can no longer run is dead weight that could be re-armed by mistake. +pub type Migrations = (pallet_shielded_pool::migrations::v3::MigrateToV3,); /// Executive: handles dispatch to the various modules. pub type Executive = frame_executive::Executive< @@ -198,7 +201,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { spec_name: Cow::Borrowed("orbinum"), impl_name: Cow::Borrowed("orbinum"), authoring_version: 1, - spec_version: 6, + spec_version: 7, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 2, @@ -629,7 +632,17 @@ impl pallet_shielded_pool::Config for Runtime { /// Merkle tree depth: 2^20 = 1M notes max (see MERKLE_TREE_SCALABILITY.md) type MaxTreeDepth = ConstU32<20>; /// Historic roots: allows proofs against past states (30s window) - type MaxHistoricRoots = ConstU32<100>; + /// Safety cap on the historic-root queue, not the retention window. A root + /// expires by elapsed blocks; this only bounds worst-case storage. + /// + /// Steady state is `RootRetentionBlocks × commitments-per-block`: 1200 at a + /// sustained 2 transfers/block, 6000 at 10. Sized for ~27 transfers/block + /// sustained across a full window, well past the ~127 proof verifications a + /// block can fit, so the window — never this bound — is what expires a root. + type MaxHistoricRoots = ConstU32<16384>; + /// Roots stay spendable for 300 blocks (~30 min at 6s), comfortably above + /// the 64-block mempool longevity of an unsigned transaction. + type RootRetentionBlocks = ConstU32<300>; // Pinned to 2^20: clients derive tree_id = leaf_index >> 20 from this. type MaxLeavesPerTree = ConstU32<1_048_576>; /// Minimum shield amount: prevents spam, 1 ORB = 1e18 wei