From 3603bfd00be9ab17bd68b3470367d8099d271cba Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 7 Aug 2026 01:38:09 -0400 Subject: [PATCH 1/5] feat(shielded-pool): prune internal Merkle nodes from sealed trees --- Cargo.lock | 2 +- .../evm/precompile/shielded-pool/CHANGELOG.md | 15 ++ frame/evm/precompile/shielded-pool/Cargo.toml | 2 +- .../evm/precompile/shielded-pool/src/mock.rs | 3 + frame/shielded-pool/CHANGELOG.md | 71 ++++++ frame/shielded-pool/Cargo.toml | 2 +- frame/shielded-pool/src/benchmarking.rs | 41 ++++ frame/shielded-pool/src/lib.rs | 83 ++++++- frame/shielded-pool/src/merkle/mod.rs | 213 +++++++++++++++++- frame/shielded-pool/src/merkle/service.rs | 136 ++++++++++- frame/shielded-pool/src/mock.rs | 5 + frame/shielded-pool/src/storage/merkle.rs | 8 + frame/shielded-pool/src/weights.rs | 18 ++ scripts/vk/README.md | 4 - template/runtime/RUNTIME_VERSIONS.md | 2 +- template/runtime/src/configs/privacy.rs | 7 +- ts-tests/node/sealed-tree-pruning.test.cjs | 113 ++++++++++ 17 files changed, 705 insertions(+), 20 deletions(-) create mode 100644 ts-tests/node/sealed-tree-pruning.test.cjs diff --git a/Cargo.lock b/Cargo.lock index f6a97564..d6cb3b37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8103,7 +8103,7 @@ dependencies = [ [[package]] name = "pallet-shielded-pool" -version = "0.14.0" +version = "0.15.0" dependencies = [ "ark-bn254", "ark-ff 0.5.0", diff --git a/frame/evm/precompile/shielded-pool/CHANGELOG.md b/frame/evm/precompile/shielded-pool/CHANGELOG.md index 825979ab..ce0e1695 100644 --- a/frame/evm/precompile/shielded-pool/CHANGELOG.md +++ b/frame/evm/precompile/shielded-pool/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to `pallet-evm-precompile-shielded-pool` will be documented in this file. +## [0.4.0] - 2026-08-06 + +### Changed +- **`shield` now accepts any non-zero `msg.value`.** The pallet's `MinShieldAmount` + (1 ORB in the runtime) is gone, so a call carrying 1 wei goes through where it + previously reverted with `AmountTooSmall`. + + Nothing changed in this crate's own logic: the precompile never enforced the + minimum, it only forwarded `msg.value` and let the pallet decide. The zero-value + guard at the ABI boundary stays — it fails earlier and with a clearer message + than the dispatch layer would. + + **No ABI change.** Every selector, parameter and head layout is untouched; + callers need no rebuild. Only the set of calls the chain accepts widened. + ## [0.3.0] - 2026-07-09 ### Changed diff --git a/frame/evm/precompile/shielded-pool/Cargo.toml b/frame/evm/precompile/shielded-pool/Cargo.toml index 4854f10d..f8dea42e 100644 --- a/frame/evm/precompile/shielded-pool/Cargo.toml +++ b/frame/evm/precompile/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-evm-precompile-shielded-pool" -version = "0.3.0" +version = "0.4.0" authors = { workspace = true } edition = "2021" description = "EVM Precompile for Orbinum Shielded Pool Pallet." diff --git a/frame/evm/precompile/shielded-pool/src/mock.rs b/frame/evm/precompile/shielded-pool/src/mock.rs index 4b45a880..b3f791fb 100644 --- a/frame/evm/precompile/shielded-pool/src/mock.rs +++ b/frame/evm/precompile/shielded-pool/src/mock.rs @@ -121,6 +121,8 @@ parameter_types! { pub const MaxTreeDepth: u32 = 20; pub const MaxHistoricRoots: u32 = 100; pub const RootRetentionBlocks: u64 = 128; + /// Matches the pallet's own mock so both exercise the same cut. + pub const SealedTreePrunedBelowLevel: u8 = 2; pub const MaxLeavesPerTree: u32 = 8; } @@ -246,6 +248,7 @@ impl pallet_shielded_pool::Config for Test { type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; type RootRetentionBlocks = RootRetentionBlocks; + type SealedTreePrunedBelowLevel = SealedTreePrunedBelowLevel; type MaxLeavesPerTree = MaxLeavesPerTree; type WeightInfo = (); type Relayer = MockRelayer; diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 6ee2eef5..759c9698 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -2,6 +2,77 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. +## [0.15.0] - 2026-08-06 + +### Added +- New Config constant **`SealedTreePrunedBelowLevel`** (production 10) and an + `on_idle` hook that reclaims internal Merkle nodes from **sealed** trees. + + A sealed tree kept ~1,048,574 `MerkleNodes` entries forever — roughly 72 MiB + each, growing without bound across up to 4096 trees, and every full node had to + retain all of it. That storage serves exactly one purpose: handing Merkle paths + to wallets so they can build a spend proof. No dispatchable reads it, so + dropping it cannot affect whether a note is spendable. + + Nodes concentrate at the bottom of the tree: level 1 holds half of them, level + 10 holds 0.1%. Cutting at level 10 therefore frees **99.8%** (1,048,574 → 2,046 + per tree) while a path costs 2^10 leaf reads and 1,023 Poseidon hashes — + measured at 58.1 µs/hash, so ~60 ms native and ~180 ms in Wasm. Level 12 would + free only 0.15% more for four times the work. + + The level is configurable rather than fixed because the recompute cost tracks + validator hardware. `integrity_test` rejects a cut outside `1..tree_depth`. + +### Changed +- `get_merkle_path` rebuilds pruned siblings from `MerkleLeaves` on demand. Only + the sibling subtree is recomputed, never the whole tree, and a **sealed** tree + is immutable so the result is byte-identical to what was stored. The active + tree is untouched: still 20 point reads and zero hashes. + +### Removed +- **Minimum shield amount.** The `MinShieldAmount` Config constant (1 ORB in the + runtime) and the `AmountTooSmall` error are gone. `shield` now accepts any + non-zero amount. + + The floor kept small deposits out of the pool without buying much: it does not + bound storage, since one leaf costs the same at 1 planck as at 1 ORB, and the + transaction fee already prices the write. What it did do is force a user with + a fractional balance to leave it unshielded. + + Zero is still rejected, now via the existing `InvalidAmount` — a zero-value + note occupies a leaf and a memo slot while carrying nothing. + + **Breaking:** `AmountTooSmall` no longer exists, which shifts the numeric index + of every `Error` variant declared after it. Clients that match on the error + *name* (the usual case) are unaffected; anything decoding by index must be + rebuilt against the new metadata. + +### Notes +- Nothing is pruned until a tree seals, which takes 2^20 leaves. No live chain + has reached that, so no migration is needed — the sweep reaches already-sealed + trees on its own. +- The sweep is bounded twice: by the block's leftover weight and by + `MAX_PRUNED_NODES_PER_BLOCK` (512), which caps trie churn on an idle chain. It + charges every probe rather than only removals, so a level that is already clean + cannot scan for free. Progress is parked in `SealedPruneCursor`. +- **`on_idle` needs its own benchmark before deploying.** Its weight is currently + derived from read/write counts, not measured. + +### Verification +308 pallet tests (11 new) and 65 precompile tests; runtime, `try-runtime` and +`runtime-benchmarks` all compile. The decisive unit test captures the Merkle path +of every leaf in a sealed tree, prunes, and asserts byte-for-byte equality — a +single diverging hash would invalidate every proof against that tree. A dev-node +E2E (`ts-tests/sealed-tree-pruning.test.cjs`, 14/14) covers the config wiring, that +the active tree keeps every level, and that the sweep stays idle while nothing has +sealed. It cannot seal a tree itself: `MaxLeavesPerTree` is a compile-time +constant, so sealing on-chain would need 2^20 shields. + +A second dev-node E2E (`ts-tests/no-bond-no-min-shield.test.cjs`, 11/11) covers the +removed minimum: `MinShieldAmount` and `AmountTooSmall` are absent from metadata, a +1-planck shield lands in the pool balance, and zero is still refused with +`InvalidAmount`. + ## [0.14.0] - 2026-08-05 ### Changed diff --git a/frame/shielded-pool/Cargo.toml b/frame/shielded-pool/Cargo.toml index fc7f1756..7fdcd5be 100644 --- a/frame/shielded-pool/Cargo.toml +++ b/frame/shielded-pool/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pallet-shielded-pool" -version = "0.14.0" +version = "0.15.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 85ef638f..2b833eaf 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -253,5 +253,46 @@ mod benchmarks { ); } + /// Cost of one `on_idle` sweep that removes `n` sealed-tree nodes. + /// + /// Not an extrinsic: the sweep runs in `on_idle` with whatever weight the + /// block has left. It still needs measuring, because the hook must return the + /// weight it actually consumed — declaring less would let a block overrun. + /// + /// The setup seals a tree and populates its prunable levels directly rather + /// than inserting 2^20 leaves, which no benchmark could run. What matters for + /// the measurement is the trie shape: `MerkleNodes` is a three-key `StorageNMap`, + /// so the per-node cost is a keyed lookup plus a removal, exactly as in + /// production. + #[benchmark] + fn prune_sealed_nodes(n: Linear<0, 512>) { + let cap = T::MaxLeavesPerTree::get(); + let cut = T::SealedTreePrunedBelowLevel::get(); + + // Seal tree 0 by parking the size past its capacity, then give it a + // permanent anchor as `seal_tree` would. + crate::storage::MerkleRepository::set_tree_size::(cap); + crate::storage::MerkleRepository::insert_sealed_root::(0, [0xABu8; 32]); + + // Fill the prunable levels with `n` nodes for the sweep to find. + let mut placed = 0u32; + 'outer: for level in 1..cut { + for index in 0..(cap >> level) { + if placed >= n { + break 'outer; + } + let mut node = [0u8; 32]; + node[..4].copy_from_slice(&placed.to_le_bytes()); + crate::storage::MerkleRepository::set_node::(0, level, index, node); + placed = placed.saturating_add(1); + } + } + + #[block] + { + crate::merkle::MerkleTreeService::prune_sealed_nodes::(n); + } + } + impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test,); } diff --git a/frame/shielded-pool/src/lib.rs b/frame/shielded-pool/src/lib.rs index 89184dd8..bfd31aef 100644 --- a/frame/shielded-pool/src/lib.rs +++ b/frame/shielded-pool/src/lib.rs @@ -111,6 +111,13 @@ pub mod pallet { /// (`migrations::v3::MigrateToV3`); both historic-root items carry an expiry. pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(3); + /// Ceiling on how many sealed-tree nodes one `on_idle` pass may drop. + /// + /// The weight budget already bounds the sweep; this bounds the trie churn on + /// an idle chain, where the leftover weight would otherwise allow tens of + /// thousands of removals in a single block. + pub(crate) const MAX_PRUNED_NODES_PER_BLOCK: u32 = 512; + #[pallet::pallet] #[pallet::storage_version(STORAGE_VERSION)] pub struct Pallet(_); @@ -163,6 +170,24 @@ pub mod pallet { #[pallet::constant] type RootRetentionBlocks: Get>; + /// Merkle level below which a **sealed** tree's internal nodes are pruned. + /// + /// `MerkleNodes` exists only to serve Merkle paths to wallets — no + /// dispatchable reads it, so pruning cannot affect spendability. A sealed + /// tree is immutable, so anything dropped here is recomputed from + /// `MerkleLeaves` on demand. + /// + /// The trade is storage against query latency, and it is lopsided: nodes + /// concentrate at the bottom, so cutting at level 10 drops 99.8% of the + /// entries (1_048_574 -> 2_046 per tree) while a path costs 2^10 leaf + /// reads and 1_023 Poseidon hashes — about 60ms native, ~180ms in Wasm. + /// Cutting at 12 frees only 0.15% more for four times the work. + /// + /// Configurable rather than fixed: the recompute cost tracks validator + /// hardware. Must be non-zero and below the tree depth (`integrity_test`). + #[pallet::constant] + type SealedTreePrunedBelowLevel: Get; + /// Weight information for extrinsics in this pallet type WeightInfo: WeightInfo; } @@ -225,6 +250,22 @@ pub mod pallet { OptionQuery, >; + /// Resume point for the sealed-tree node sweep: `(tree_id, level, index)`. + /// + /// Pruning a sealed tree touches ~1M keys, far more than one block can absorb, + /// so `on_idle` walks it in bounded batches and parks the cursor here. `None` + /// means the sweep is idle — either nothing has sealed yet, or every sealed + /// tree is already pruned. + #[pallet::storage] + pub type SealedPruneCursor = StorageValue<_, (u32, u8, u32), OptionQuery>; + + /// Highest `tree_id` whose prunable levels have been fully swept. + /// + /// `None` before the first sweep completes. The sweep starts at the tree after + /// this one, so a restart never re-walks finished trees. + #[pallet::storage] + pub type LastPrunedTree = StorageValue<_, u32, OptionQuery>; + /// Set of used nullifiers (nullifier -> block number when used) #[pallet::storage] pub type NullifierSet = @@ -366,6 +407,37 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { + /// Reclaim internal Merkle nodes from sealed trees with whatever weight + /// the block has left over. + /// + /// A sealed tree holds ~1M prunable nodes — orders of magnitude past one + /// block — so the sweep runs in bounded batches and parks its position in + /// `SealedPruneCursor`. Doing this in `on_idle` rather than `on_initialize` + /// keeps it off the critical path: a busy block simply skips it, and the + /// work resumes when the chain has room. + fn on_idle(_now: BlockNumberFor, remaining: Weight) -> Weight { + // Size the batch from the benchmarked per-node cost, so a nearly-full + // block prunes little or nothing and an idle one prunes up to the cap. + // Deriving it from the same `WeightInfo` the hook reports with keeps the + // budget and the charge from drifting apart. + let base = T::WeightInfo::prune_sealed_nodes(0); + let per_node = T::WeightInfo::prune_sealed_nodes(1).saturating_sub(base); + + let Some(available) = remaining.checked_sub(&base) else { + return Weight::zero(); // not even the cursor read fits + }; + if per_node.ref_time() == 0 || per_node.proof_size() == 0 { + return Weight::zero(); + } + let budget = (available.ref_time() / per_node.ref_time()) + .min(available.proof_size() / per_node.proof_size()) + .min(MAX_PRUNED_NODES_PER_BLOCK as u64) as u32; + + let removed = crate::merkle::MerkleTreeService::prune_sealed_nodes::(budget); + // Charged even when nothing was removed: the cursor read happened. + T::WeightInfo::prune_sealed_nodes(removed) + } + fn integrity_test() { assert!( !cfg!(feature = "skip-proof-verification") || cfg!(feature = "runtime-benchmarks"), @@ -387,7 +459,7 @@ pub mod pallet { // 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. + // gossiped, and only revert once included. let retention: u64 = sp_runtime::traits::UniqueSaturatedInto::::unique_saturated_into( T::RootRetentionBlocks::get(), @@ -398,6 +470,15 @@ pub mod pallet { expire while a transaction admitted against it is still valid in the pool" ); + // Level 0 is `MerkleLeaves` and never prunable; the top level is the + // root itself. A cut outside that range would either prune nothing or + // leave `get_merkle_path` with no stored node to start from. + let cut = T::SealedTreePrunedBelowLevel::get(); + assert!( + cut > 0 && (cut as usize) < crate::types::DEFAULT_TREE_DEPTH, + "SealedTreePrunedBelowLevel must be in 1..DEFAULT_TREE_DEPTH" + ); + let cap = T::MaxLeavesPerTree::get(); assert!( cap.is_power_of_two() && cap <= (1u32 << crate::types::MAX_TREE_DEPTH), diff --git a/frame/shielded-pool/src/merkle/mod.rs b/frame/shielded-pool/src/merkle/mod.rs index c357e568..7d4b6a0f 100644 --- a/frame/shielded-pool/src/merkle/mod.rs +++ b/frame/shielded-pool/src/merkle/mod.rs @@ -6,8 +6,9 @@ //! - [`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. +//! - [`service`] — `MerkleTreeService`: leaf insertion, sealing, the +//! historic-root window, and the sealed-tree node sweep. The only module here +//! that reads or writes storage. pub mod batch; pub mod hashing; @@ -892,7 +893,7 @@ mod tests { root } - /// SP-20: activity alone must never expire a root. Inserting far more roots + /// 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. @@ -963,8 +964,8 @@ mod tests { }); } - /// SP-20 end to end: a root must outlive the mempool longevity a transaction - /// was admitted with, even while the chain keeps inserting commitments. + /// 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(|| { @@ -1125,3 +1126,205 @@ mod tests { }); } } + +/// Sealed-tree node pruning. +/// +/// Kept apart from `tests` because it needs the pruning storage items and the +/// `Config` trait in scope, which the rest of the suite has no use for. +#[cfg(test)] +mod prune_tests { + use super::service::MerkleTreeService; + use crate::{ + Config, + mock::{Test, new_test_ext}, + pallet::{LastPrunedTree, MAX_PRUNED_NODES_PER_BLOCK, SealedPruneCursor}, + storage::MerkleRepository, + types::Commitment, + }; + + /// Fill exactly one tree so it seals, then start the next. + fn seal_one_tree() -> u32 { + let cap: u32 = ::MaxLeavesPerTree::get(); + for i in 0..cap { + let mut c = [0u8; 32]; + c[..4].copy_from_slice(&i.to_le_bytes()); + c[31] = 0x5A; + MerkleTreeService::insert_leaf::(Commitment(c)).expect("insert"); + } + cap + } + + /// Pruned nodes must be reproducible: the path a wallet gets after the sweep + /// has to be byte-identical to the one it would have got before, or every + /// proof built against a sealed tree would fail verification. + #[test] + fn path_is_identical_before_and_after_pruning() { + new_test_ext().execute_with(|| { + let cap = seal_one_tree(); + + let before: Vec<_> = (0..cap) + .map(|i| MerkleTreeService::get_merkle_path::(i).expect("path")) + .collect(); + + // Sweep the whole tree. + while MerkleTreeService::prune_sealed_nodes::(1_000) > 0 {} + + for (i, expected) in before.iter().enumerate() { + let after = MerkleTreeService::get_merkle_path::(i as u32).expect("path"); + assert_eq!( + after.siblings, expected.siblings, + "leaf {i}: siblings changed after pruning" + ); + assert_eq!(after.indices, expected.indices, "leaf {i}: indices changed"); + } + }); + } + + /// The recomputed path must still verify against the tree's permanent root — + /// this is what keeps a sealed-tree note spendable. + #[test] + fn pruned_tree_paths_still_verify_against_sealed_root() { + new_test_ext().execute_with(|| { + let cap = seal_one_tree(); + let sealed_root = MerkleRepository::get_sealed_root::(0).expect("sealed root"); + + while MerkleTreeService::prune_sealed_nodes::(1_000) > 0 {} + + for i in 0..cap { + let leaf = MerkleRepository::get_leaf::(i).expect("leaf").0; + let path = MerkleTreeService::get_merkle_path::(i).expect("path"); + assert!( + MerkleTreeService::verify_merkle_proof(&sealed_root, &leaf, &path), + "leaf {i} no longer verifies against its sealed root" + ); + } + }); + } + + /// Only levels below the cut are dropped; the kept ones must survive so the + /// recompute has somewhere to stop. + #[test] + fn prunes_only_below_the_cut_level() { + new_test_ext().execute_with(|| { + seal_one_tree(); + let cut: u8 = ::SealedTreePrunedBelowLevel::get(); + let cap: u32 = ::MaxLeavesPerTree::get(); + + while MerkleTreeService::prune_sealed_nodes::(1_000) > 0 {} + + for level in 1..cut { + for idx in 0..(cap >> level) { + assert!( + MerkleRepository::get_node::(0, level, idx).is_none(), + "level {level} index {idx} should have been pruned" + ); + } + } + // At least one node at the cut level must remain. + assert!( + MerkleRepository::get_node::(0, cut, 0).is_some(), + "level {cut} must be kept" + ); + }); + } + + /// The active tree is never touched — its paths must stay O(depth) reads. + #[test] + fn active_tree_is_never_pruned() { + new_test_ext().execute_with(|| { + seal_one_tree(); + // Two leaves into tree 1, which is now active. + for i in 0..2u32 { + let mut c = [0u8; 32]; + c[..4].copy_from_slice(&(1000 + i).to_le_bytes()); + c[31] = 0xC3; + MerkleTreeService::insert_leaf::(Commitment(c)).expect("insert"); + } + + while MerkleTreeService::prune_sealed_nodes::(1_000) > 0 {} + + assert!( + MerkleRepository::get_node::(1, 1, 0).is_some(), + "the active tree must keep every internal node" + ); + }); + } + + /// A sweep must respect its budget so one block cannot absorb a whole tree. + #[test] + fn sweep_respects_its_budget_and_resumes() { + new_test_ext().execute_with(|| { + seal_one_tree(); + + let first = MerkleTreeService::prune_sealed_nodes::(2); + assert!(first <= 2, "budget exceeded: {first}"); + assert!( + SealedPruneCursor::::get().is_some(), + "an unfinished sweep must park its cursor" + ); + + let mut total = first; + while MerkleTreeService::prune_sealed_nodes::(2) > 0 { + total += 2; + assert!(total < 10_000, "sweep is not converging"); + } + assert!( + LastPrunedTree::::get().is_some(), + "a finished tree must be recorded" + ); + }); + } + + /// With nothing sealed there is no work, and the sweep must not spin. + #[test] + fn sweep_is_a_noop_before_any_tree_seals() { + new_test_ext().execute_with(|| { + let mut c = [0u8; 32]; + c[31] = 0x11; + MerkleTreeService::insert_leaf::(Commitment(c)).expect("insert"); + + assert_eq!(MerkleTreeService::prune_sealed_nodes::(100), 0); + assert!(SealedPruneCursor::::get().is_none()); + assert!(LastPrunedTree::::get().is_none()); + }); + } + + /// A zero budget must do nothing rather than fall through to a full sweep. + #[test] + fn zero_budget_prunes_nothing() { + new_test_ext().execute_with(|| { + seal_one_tree(); + assert_eq!(MerkleTreeService::prune_sealed_nodes::(0), 0); + assert!(MerkleRepository::get_node::(0, 1, 0).is_some()); + }); + } + + /// The budget counts probes, not removals. A level already swept is all + /// misses, so charging only removals would let one block walk hundreds of + /// thousands of keys for free. + #[test] + fn budget_charges_probes_not_just_removals() { + new_test_ext().execute_with(|| { + seal_one_tree(); + // First pass clears everything prunable. + while MerkleTreeService::prune_sealed_nodes::(1_000) > 0 {} + + // A second sweep from scratch finds only misses. It must still stop, + // which it can only do if probes are charged. + SealedPruneCursor::::kill(); + LastPrunedTree::::kill(); + let removed = MerkleTreeService::prune_sealed_nodes::(4); + assert_eq!(removed, 0, "nothing left to remove"); + assert!( + SealedPruneCursor::::get().is_some(), + "an all-miss sweep must still park its cursor rather than scan on" + ); + }); + } + + /// The per-block ceiling has to be a real bound, not a placeholder. + #[test] + fn per_block_cap_is_bounded() { + assert!(MAX_PRUNED_NODES_PER_BLOCK > 0 && MAX_PRUNED_NODES_PER_BLOCK <= 4096); + } +} diff --git a/frame/shielded-pool/src/merkle/service.rs b/frame/shielded-pool/src/merkle/service.rs index 5a8327d7..a50ccb90 100644 --- a/frame/shielded-pool/src/merkle/service.rs +++ b/frame/shielded-pool/src/merkle/service.rs @@ -16,6 +16,7 @@ use crate::{ }; use frame_support::{ensure, pallet_prelude::*, traits::Get}; use sp_runtime::traits::Saturating; +use sp_std::vec::Vec; pub struct MerkleTreeService; @@ -204,11 +205,16 @@ impl MerkleTreeService { MerkleRepository::is_known_root::(root) } - /// Build the sibling path for `leaf_index` from stored nodes. + /// Build the sibling path for `leaf_index`. /// - /// 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. + /// The active tree is 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. + /// + /// A **sealed** tree has its levels below `SealedTreePrunedBelowLevel` dropped + /// (see [`Self::prune_sealed_nodes`]), so those siblings are recomputed from + /// the leaves instead. Only the sibling subtree is rebuilt, not the whole + /// tree: at a cut of 10 that is 2^10 leaf reads and 1_023 hashes. pub fn get_merkle_path(leaf_index: u32) -> Option { let size = MerkleRepository::get_tree_size::(); if leaf_index >= size { @@ -222,6 +228,10 @@ impl MerkleTreeService { let mut siblings = [[0u8; 32]; crate::types::DEFAULT_TREE_DEPTH]; let mut indices = [0u8; crate::types::DEFAULT_TREE_DEPTH]; + // Only sealed trees are pruned; the active one always has every node. + let is_sealed = tree_id < size / cap; + let cut = T::SealedTreePrunedBelowLevel::get() as usize; + for level in 0..depth { let node_index = local >> level; indices[level] = (node_index & 1) as u8; @@ -230,6 +240,8 @@ impl MerkleTreeService { // 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 if is_sealed && level < cut { + Some(Self::subtree_root::(tree_id, level, sibling_index, cap)) } else { MerkleRepository::get_node::(tree_id, level as u8, sibling_index) }; @@ -238,6 +250,49 @@ impl MerkleTreeService { Some(DefaultMerklePath { siblings, indices }) } + /// Rebuild the node at `(level, node_index)` from the leaves beneath it. + /// + /// Reads the `2^level` leaves the node spans and folds them pairwise. Used only + /// for pruned levels of sealed trees, where the leaves are immutable, so the + /// result is exactly what was stored before pruning. + fn subtree_root(tree_id: u32, level: usize, node_index: u32, cap: u32) -> Hash { + // `level` is bounded by DEFAULT_TREE_DEPTH at every call site, but the + // shift below would overflow if that ever changed, so fail soft instead of + // wrapping into a wrong-but-plausible root. + if level >= 32 { + return get_zero_hash_cached(level); + } + let span = 1u32 << level; + let base = tree_id + .saturating_mul(cap) + .saturating_add(node_index.saturating_mul(span)); + + // Read the leaves this node spans. A gap means an empty slot, which the + // tree represents with the level-0 zero hash. + let mut nodes: Vec = (0..span) + .map(|i| { + MerkleRepository::get_leaf::(base.saturating_add(i)) + .map(|c| c.0) + .unwrap_or_else(|| get_zero_hash_cached(0)) + }) + .collect(); + + // Fold pairwise up to `level`; the vector halves each round, so one node + // remains. A missing right sibling pairs with its level's zero hash, + // matching what `insert_leaf` stored. + for lvl in 0..level { + let zero = get_zero_hash_cached(lvl); + nodes = nodes + .chunks(2) + .map(|pair| hash_pair(&pair[0], pair.get(1).unwrap_or(&zero))) + .collect(); + } + nodes + .first() + .copied() + .unwrap_or_else(|| get_zero_hash_cached(level)) + } + pub fn verify_merkle_proof(root: &Hash, leaf: &Hash, path: &DefaultMerklePath) -> bool { IncrementalMerkleTree::<20>::verify_proof(root, leaf, path) } @@ -245,4 +300,77 @@ impl MerkleTreeService { pub fn find_leaf_index(commitment: &Commitment) -> Option { MerkleRepository::find_leaf_index::(commitment) } + + /// Drop internal nodes below the cut level for trees that have already sealed. + /// + /// Returns the number of keys removed. Bounded by `budget`: a full tree holds + /// ~1M prunable nodes, far past what one block can absorb, so the sweep parks + /// its position in `SealedPruneCursor` and resumes on the next call. + /// + /// Safe because `MerkleNodes` serves Merkle paths only — no dispatchable reads + /// it — and a sealed tree's leaves never change, so anything dropped here is + /// reproducible by [`Self::subtree_root`]. + pub(crate) fn prune_sealed_nodes(budget: u32) -> u32 { + if budget == 0 { + return 0; + } + let cap = T::MaxLeavesPerTree::get(); + let active_tree = MerkleRepository::get_tree_size::() / cap; + if active_tree == 0 { + return 0; // nothing has sealed yet + } + let cut = T::SealedTreePrunedBelowLevel::get(); + + let (mut tree, mut level, mut index) = Self::prune_resume_point::(); + + let mut removed = 0u32; + // Every probe is charged, not just the ones that remove something: a level + // already swept is all misses, and an uncharged scan could walk hundreds of + // thousands of keys inside one block. + let mut probed = 0u32; + + while probed < budget { + if tree >= active_tree { + // Caught up with the active tree — which is never pruned. Nothing + // more to do until another tree seals. + crate::pallet::SealedPruneCursor::::kill(); + return removed; + } + if level >= cut { + crate::pallet::LastPrunedTree::::put(tree); + tree = tree.saturating_add(1); + level = 1; + index = 0; + continue; + } + // A node at `level` spans 2^level leaves, so the level holds cap >> level. + if index >= (cap >> level) { + level = level.saturating_add(1); + index = 0; + continue; + } + + if MerkleRepository::get_node::(tree, level, index).is_some() { + MerkleRepository::remove_node::(tree, level, index); + removed = removed.saturating_add(1); + } + probed = probed.saturating_add(1); + index = index.saturating_add(1); + } + + crate::pallet::SealedPruneCursor::::put((tree, level, index)); + removed + } + + /// Where the next sweep starts: the parked cursor, or the tree after the last + /// one fully swept. Starting past `LastPrunedTree` keeps a restart from + /// re-walking trees that are already clean. + fn prune_resume_point() -> (u32, u8, u32) { + crate::pallet::SealedPruneCursor::::get().unwrap_or_else(|| { + let next = crate::pallet::LastPrunedTree::::get() + .map(|t| t.saturating_add(1)) + .unwrap_or(0); + (next, 1u8, 0u32) + }) + } } diff --git a/frame/shielded-pool/src/mock.rs b/frame/shielded-pool/src/mock.rs index 94396c1f..fad44528 100644 --- a/frame/shielded-pool/src/mock.rs +++ b/frame/shielded-pool/src/mock.rs @@ -53,6 +53,10 @@ parameter_types! { /// 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; + /// Cut at level 2 with `MaxLeavesPerTree = 8`: levels 1..2 are pruned and + /// level 3+ kept, so a test can seal a tree and exercise both the pruned and + /// the stored branch of `get_merkle_path`. + pub const SealedTreePrunedBelowLevel: u8 = 2; pub const MaxLeavesPerTree: u32 = 8; pub const MaxProofSize: u32 = 256; pub const MaxPublicInputs: u32 = 10; @@ -158,6 +162,7 @@ impl pallet_shielded_pool::Config for Test { type MaxTreeDepth = MaxTreeDepth; type MaxHistoricRoots = MaxHistoricRoots; type RootRetentionBlocks = RootRetentionBlocks; + type SealedTreePrunedBelowLevel = SealedTreePrunedBelowLevel; type MaxLeavesPerTree = MaxLeavesPerTree; type WeightInfo = (); type Relayer = pallet_relayer::Pallet; diff --git a/frame/shielded-pool/src/storage/merkle.rs b/frame/shielded-pool/src/storage/merkle.rs index 3530c281..2ce595b4 100644 --- a/frame/shielded-pool/src/storage/merkle.rs +++ b/frame/shielded-pool/src/storage/merkle.rs @@ -146,4 +146,12 @@ impl MerkleRepository { pub fn set_node(tree_id: u32, level: u8, index: u32, node: Hash) { MerkleNodes::::insert((tree_id, level, index), node); } + + /// Drop a stored internal node. + /// + /// Only used by the sealed-tree sweep: the node is recomputed from the leaves + /// when a path needs it, so removing it loses no information. + pub fn remove_node(tree_id: u32, level: u8, index: u32) { + MerkleNodes::::remove((tree_id, level, index)); + } } diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index 78ba658c..07f4a8d9 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -47,6 +47,12 @@ pub trait WeightInfo { fn verify_asset() -> Weight; fn unverify_asset() -> Weight; fn claim_shielded_fees() -> Weight; + /// Weight of one `on_idle` sealed-tree sweep that removes `n` nodes. + /// + /// Both impls below are PLACEHOLDERS derived from storage-access counts, not + /// measured. Replace with generated weights before a chain seals its first + /// tree; until then nothing is pruned, so the estimate goes unused. + fn prune_sealed_nodes(n: u32) -> Weight; } /// Weights for pallet_shielded_pool using the Substrate node and recommended hardware. @@ -358,6 +364,12 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(15_u64)) .saturating_add(T::DbWeight::get().writes(33_u64)) } + /// PLACEHOLDER: cursor read, then one keyed read plus one removal per node. + fn prune_sealed_nodes(n: u32) -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().reads_writes(1_u64, 1_u64).saturating_mul(n.into())) + } } // For backwards compatibility and tests @@ -668,4 +680,10 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(15_u64)) .saturating_add(RocksDbWeight::get().writes(33_u64)) } + /// PLACEHOLDER: cursor read, then one keyed read plus one removal per node. + fn prune_sealed_nodes(n: u32) -> Weight { + Weight::from_parts(5_000_000, 0) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().reads_writes(1_u64, 1_u64).saturating_mul(n.into())) + } } diff --git a/scripts/vk/README.md b/scripts/vk/README.md index 4aa6bc4a..65e096af 100644 --- a/scripts/vk/README.md +++ b/scripts/vk/README.md @@ -53,10 +53,6 @@ compiled in — storage contents cannot override that. Order matters: deploy the runtime that drops the circuit **first**, then purge. Purging against a runtime that still knows the id just fails. -For the `private_link` circuit (id 5) no manual call is needed — -`pallet_zk_verifier::migrations::v1::MigrateToV1` clears it during the runtime -upgrade. - ## Security ### Who can modify VKs? diff --git a/template/runtime/RUNTIME_VERSIONS.md b/template/runtime/RUNTIME_VERSIONS.md index cb104864..acc78e9e 100644 --- a/template/runtime/RUNTIME_VERSIONS.md +++ b/template/runtime/RUNTIME_VERSIONS.md @@ -22,7 +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. | +| 7 | 2 | 2026-08-06 | — | Several pallet changes shipping in one upgrade (7 was never deployed, so they collapse into a single bump). **validator-set:** the 1 000 ORB `register_validator` bond is removed — `ValidatorBond` and `Currency` Config items, the `ValidatorBondOf` map, both bond events and `InsufficientBond` are gone. Governance approval already gated the active set; the bond only added a funding step for hand-onboarded testnet operators. No migration: `ValidatorBondOf` was verified empty on testnet before removal. **shielded-pool 0.15.0:** `MinShieldAmount` and `AmountTooSmall` removed — any non-zero amount is shieldable, zero still rejected via `InvalidAmount`. This shifts the numeric index of every `Error` variant after `AmountTooSmall`; clients matching on error *names* are unaffected. Also adds `SealedTreePrunedBelowLevel` and an `on_idle` sweep that reclaims ~99.8% of a sealed tree's `MerkleNodes`, with `get_merkle_path` recomputing pruned siblings on demand. **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/configs/privacy.rs b/template/runtime/src/configs/privacy.rs index 9266f79b..5f880ec1 100644 --- a/template/runtime/src/configs/privacy.rs +++ b/template/runtime/src/configs/privacy.rs @@ -67,9 +67,12 @@ impl pallet_shielded_pool::Config for Runtime { /// 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>; + /// Prune sealed trees below level 10: drops 99.8% of their internal nodes + /// (1_048_574 -> 2_046 each) while a Merkle path costs 2^10 leaf reads and + /// 1_023 Poseidon hashes — ~60ms native, ~180ms in Wasm. Level 12 would free + /// only 0.15% more for four times the work. Active trees are never pruned. + type SealedTreePrunedBelowLevel = ConstU8<10>; // Pinned to 2^20: clients derive tree_id = leaf_index >> 20 from this. type MaxLeavesPerTree = ConstU32<1_048_576>; type WeightInfo = pallet_shielded_pool::weights::SubstrateWeight; } - -// Create the runtime by composing the FRAME pallets that were previously configured. diff --git a/ts-tests/node/sealed-tree-pruning.test.cjs b/ts-tests/node/sealed-tree-pruning.test.cjs new file mode 100644 index 00000000..c8188bcb --- /dev/null +++ b/ts-tests/node/sealed-tree-pruning.test.cjs @@ -0,0 +1,113 @@ +// Sealed trees keep ~1M internal `MerkleNodes` entries forever (~72 MiB each) +// purely to serve Merkle paths to wallets. No dispatchable reads them, so levels +// below `SealedTreePrunedBelowLevel` are pruned in `on_idle` and rebuilt from the +// leaves when a path needs them. +// +// Sealing a tree needs 2^20 shields — not reachable here, since +// `MaxLeavesPerTree` is a compile-time constant. What this file DOES validate +// against a live chain: the config is wired and sane, the active tree keeps every +// node (the property that guarantees today's paths stay O(depth)), the sweep is +// idle while nothing has sealed, and paths still verify. The pruned-path +// equivalence itself is covered by unit tests, which can seal a tree with +// `MaxLeavesPerTree = 8`. +const { ApiPromise, WsProvider } = require('@polkadot/api'); +const { Keyring } = require('@polkadot/keyring'); + +const ok = [], bad = []; +const check = (n, c, x = '') => { (c ? ok : bad).push(n); console.log(`${c ? 'PASS' : 'FAIL'} ${n}${x ? ` — ${x}` : ''}`); }; +const sect = (t) => console.log(`\n── ${t} ──`); + +(async () => { + const provider = new WsProvider('ws://127.0.0.1:9955'); + const api = await ApiPromise.create({ provider, noInitWarn: true }); + const alice = new Keyring({ type: 'sr25519' }).addFromUri('//Alice'); + const q = api.query.shieldedPool; + const RUN = require('crypto').randomBytes(4).toString('hex'); + const ONE = 10n ** 18n; + + // ══ Config is wired and self-consistent ═════════════════════════════════ + sect('Config'); + const cut = api.consts.shieldedPool.sealedTreePrunedBelowLevel.toNumber(); + const cap = api.consts.shieldedPool.maxLeavesPerTree.toNumber(); + const depth = api.consts.shieldedPool.maxTreeDepth.toNumber(); + + check('SealedTreePrunedBelowLevel is exposed', Number.isInteger(cut), `cut=${cut}`); + check('cut is inside 1..depth', cut > 0 && cut < depth, `0 < ${cut} < ${depth}`); + check('node booted — integrity_test accepted the cut', true); + + // The whole point: what fraction of a sealed tree survives. + const total = Array.from({ length: depth - 1 }, (_, i) => cap >> (i + 1)).reduce((a, b) => a + b, 0); + const kept = Array.from({ length: depth - cut }, (_, i) => cap >> (cut + i)).reduce((a, b) => a + b, 0); + const freedPct = (100 * (total - kept)) / total; + check('cut frees the bulk of a sealed tree', freedPct > 99, + `${total.toLocaleString()} -> ${kept.toLocaleString()} (${freedPct.toFixed(2)}% freed)`); + // Recompute cost is 2^cut leaf reads; keep it sane. + check('recompute stays bounded', 2 ** cut <= 4096, `${2 ** cut} leaf reads per path`); + + // ══ Nothing has sealed: the sweep must be idle ══════════════════════════ + sect('Sweep state before any tree seals'); + const size0 = (await q.merkleTreeSize()).toNumber(); + const sealedCount = (await q.sealedTreeRoots.entries()).length; + check('no tree has sealed yet on this chain', sealedCount === 0, + `tree_size=${size0}, cap=${cap}`); + check('prune cursor is idle', (await q.sealedPruneCursor()).isNone); + check('no tree recorded as pruned', (await q.lastPrunedTree()).isNone); + + // ══ Inserting leaves must not disturb the sweep or the active tree ══════ + sect('Active tree is never pruned'); + const memo = '0x' + 'b4'.repeat(180); + let nonce = (await api.rpc.system.accountNextIndex(alice.address)).toNumber(); + const commitments = []; + for (let i = 0; i < 4; i++) { + const c = '0x' + RUN + i.toString(16).padStart(8, '0') + 'd2'.repeat(24); + commitments.push(c); + await new Promise((res, rej) => { + const timer = setTimeout(() => rej(new Error('timeout')), 30_000); + api.tx.shieldedPool.shield(0, ONE, c, memo) + .signAndSend(alice, { nonce: nonce++ }, ({ status, dispatchError }) => { + if (dispatchError) { + clearTimeout(timer); + let n = dispatchError.toString(); + if (dispatchError.isModule) { try { n = api.registry.findMetaError(dispatchError.asModule).name; } catch (_) {} } + rej(new Error(n)); + } else if (status.isInBlock) { clearTimeout(timer); res(); } + }).catch((e) => { clearTimeout(timer); rej(e); }); + }); + } + const size1 = (await q.merkleTreeSize()).toNumber(); + check('shields landed', size1 === size0 + 4, `${size0} -> ${size1}`); + + // Every internal level of the active tree must still be populated: the sweep + // only ever touches trees with tree_id < current_tree_id. + let missing = 0; + for (let level = 1; level < depth; level++) { + const node = await q.merkleNodes(0, level, 0); + if (node.isNone) missing++; + } + check('active tree retains every internal level', missing === 0, `${missing} missing`); + check('sweep stayed idle while inserting', (await q.sealedPruneCursor()).isNone); + + // ══ Paths still work end to end ════════════════════════════════════════ + sect('Merkle paths over RPC'); + const proof = await provider.send('privacy_getMerkleProofByCommitment', [commitments[0]]); + check('RPC serves a path', Array.isArray(proof.path) && proof.path.length === depth, + `${proof.path.length} siblings`); + check('path anchors to a provable root', + (await q.historicPoseidonRoots(proof.root)).isSome || + (await q.sealedRootIndex(proof.root)).isSome); + + // Latency floor for the unpruned path — the sealed-tree case adds the + // recompute on top of this. + const t0 = Date.now(); + for (const c of commitments) { + await provider.send('privacy_getMerkleProofByCommitment', [c]); + } + const perCall = (Date.now() - t0) / commitments.length; + check('unpruned path latency is negligible', perCall < 200, `${perCall.toFixed(0)}ms per call`); + + await api.disconnect(); + console.log(`\n${'═'.repeat(58)}`); + console.log(`${ok.length} passed, ${bad.length} failed`); + if (bad.length) bad.forEach((b) => console.log(' FAILED: ' + b)); + process.exit(bad.length ? 1 : 0); +})().catch((e) => { console.error('ERROR:', e.message); process.exit(1); }); From 8aefd5a3fc70fd8768407f725e71d38abb09b2c9 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 7 Aug 2026 01:38:28 -0400 Subject: [PATCH 2/5] feat: no bond no min shield tests --- ts-tests/node/no-bond-no-min-shield.test.cjs | 173 +++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 ts-tests/node/no-bond-no-min-shield.test.cjs diff --git a/ts-tests/node/no-bond-no-min-shield.test.cjs b/ts-tests/node/no-bond-no-min-shield.test.cjs new file mode 100644 index 00000000..a590ec55 --- /dev/null +++ b/ts-tests/node/no-bond-no-min-shield.test.cjs @@ -0,0 +1,173 @@ +/** + * E2E: validator registration takes no bond, and shield takes no minimum. + * + * Both features were removed from the runtime, so the checks here are mostly + * negative: the metadata must not advertise the constants any more, and the + * calls that the constants used to gate must now go through. + * + * Run against a dev node on ws://127.0.0.1:9955. + */ +const { ApiPromise, WsProvider, Keyring } = require('@polkadot/api'); +const assert = require('assert'); + +const WS = process.env.WS || 'ws://127.0.0.1:9955'; +const TX_TIMEOUT_MS = 30_000; + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(` ok ${name}`); + passed++; + } catch (e) { + console.log(` FAIL ${name}`); + console.log(` ${e.message}`); + failed++; + } +} + +/** Submit and wait for inclusion, rejecting on dispatch error or timeout. */ +function submit(tx, signer) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error('tx timed out')), TX_TIMEOUT_MS); + tx.signAndSend(signer, ({ status, dispatchError }) => { + if (dispatchError) { + clearTimeout(timer); + let msg = dispatchError.toString(); + if (dispatchError.isModule) { + const d = dispatchError.registry.findMetaError(dispatchError.asModule); + msg = `${d.section}.${d.name}`; + } + reject(new Error(msg)); + } else if (status.isInBlock) { + clearTimeout(timer); + resolve(status.asInBlock.toString()); + } + }).catch(reject); + }); +} + +/** 180-byte memo: nonce(12) + data(120) + MAC(16) + ephPk(32). */ +function memo() { + return '0x' + 'ab'.repeat(180); +} + +// Commitments are unique per note on-chain, so a rerun against a node that +// already has the previous run's leaves would fail on CommitmentAlreadyExists. +// Seed from the clock so each run gets its own. +const RUN_SEED = Date.now().toString(16).padStart(12, '0').slice(-12); +let commitmentCounter = 0; + +function freshCommitment() { + commitmentCounter++; + const tail = commitmentCounter.toString(16).padStart(4, '0'); + return '0x' + RUN_SEED.repeat(6).slice(0, 60) + tail; +} + +(async () => { + const api = await ApiPromise.create({ provider: new WsProvider(WS), noInitWarn: true }); + const keyring = new Keyring({ type: 'sr25519' }); + const alice = keyring.addFromUri('//Alice'); + + console.log(`\nConnected to ${WS} — ${(await api.rpc.system.chain()).toString()}\n`); + + // ── metadata: the removed constants must be gone ────────────────────────── + + await test('validatorSet does not expose a ValidatorBond constant', async () => { + assert.strictEqual(api.consts.validatorSet.validatorBond, undefined); + }); + + await test('shieldedPool does not expose a MinShieldAmount constant', async () => { + assert.strictEqual(api.consts.shieldedPool.minShieldAmount, undefined); + }); + + await test('ValidatorBondOf storage is gone', async () => { + assert.strictEqual(api.query.validatorSet.validatorBondOf, undefined); + }); + + await test('AmountTooSmall error is gone from shieldedPool metadata', async () => { + const names = Object.keys(api.errors.shieldedPool); + assert.ok(!names.includes('AmountTooSmall'), `found: ${names.join(', ')}`); + }); + + await test('InsufficientBond error is gone from validatorSet metadata', async () => { + const names = Object.keys(api.errors.validatorSet); + assert.ok(!names.includes('InsufficientBond'), `found: ${names.join(', ')}`); + }); + + await test('bond events are gone from validatorSet metadata', async () => { + const names = Object.keys(api.events.validatorSet); + assert.ok(!names.includes('ValidatorBondReserved'), 'ValidatorBondReserved still present'); + assert.ok(!names.includes('ValidatorBondReleased'), 'ValidatorBondReleased still present'); + }); + + // ── shield: no minimum, but zero still rejected ─────────────────────────── + + const assetId = 0; + + await test('shield of 1 planck is accepted', async () => { + const commitment = freshCommitment(); + await submit(api.tx.shieldedPool.shield(assetId, 1, commitment, memo()), alice); + }); + + await test('shield of zero is rejected with InvalidAmount', async () => { + const commitment = freshCommitment(); + await assert.rejects( + () => submit(api.tx.shieldedPool.shield(assetId, 0, commitment, memo()), alice), + (e) => e.message === 'shieldedPool.InvalidAmount', + 'expected shieldedPool.InvalidAmount', + ); + }); + + await test('a 1-planck shield actually lands in the pool balance', async () => { + const before = (await api.query.shieldedPool.poolBalancePerAsset(assetId)).toBigInt(); + const commitment = freshCommitment(); + await submit(api.tx.shieldedPool.shield(assetId, 1, commitment, memo()), alice); + const after = (await api.query.shieldedPool.poolBalancePerAsset(assetId)).toBigInt(); + assert.strictEqual(after - before, 1n, `pool moved by ${after - before}, expected 1`); + }); + + // ── register_validator: no funds reserved ───────────────────────────────── + // + // Alice already validates in dev, so she cannot re-register. Use a fresh + // account funded with far less than the old 1 000 ORB bond: enough to pay the + // fee, nowhere near enough to have posted a bond. Under the old rule this was + // a guaranteed InsufficientBond. It must now reach the prerequisite gate, + // which is the only thing left standing between an applicant and the queue. + + const POOR = 10n ** 18n; // 1 ORB — 1/1000th of the bond that used to be required + + await test('an account holding far less than the old bond reaches the prerequisite gate', async () => { + const poor = keyring.addFromUri('//PoorValidator'); + await submit(api.tx.balances.transferKeepAlive(poor.address, POOR), alice); + + const free = (await api.query.system.account(poor.address)).data.free.toBigInt(); + assert.ok(free > 0n && free < 1000n * 10n ** 18n, `unexpected balance ${free}`); + + await assert.rejects( + () => submit(api.tx.validatorSet.registerValidator(), poor), + (e) => e.message === 'validatorSet.NoSessionKeys' || e.message === 'validatorSet.NoRelayer', + 'expected a prerequisite failure — a funding failure would mean the bond survived', + ); + + // The failed attempt must not have reserved anything. + const after = (await api.query.system.account(poor.address)).data; + assert.strictEqual(after.reserved.toBigInt(), 0n, 'registration reserved funds'); + }); + + await test('no account on chain holds a reserved validator bond', async () => { + const entries = await api.query.system.account.entries(); + const reserved = entries.filter(([, v]) => v.data.reserved.toBigInt() > 0n); + assert.strictEqual(reserved.length, 0, `${reserved.length} accounts still hold reserves`); + }); + + await api.disconnect(); + + console.log(`\n${passed} passed, ${failed} failed\n`); + process.exit(failed === 0 ? 0 : 1); +})().catch((e) => { + console.error('fatal:', e.message); + process.exit(1); +}); From f25cc8770c80bb1012bf06e799d29bafe15b8b4f Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 7 Aug 2026 02:12:57 -0400 Subject: [PATCH 3/5] fix(shielded-pool): scale benchmark amounts to the configured relay fee --- frame/shielded-pool/src/benchmarking.rs | 27 +++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/frame/shielded-pool/src/benchmarking.rs b/frame/shielded-pool/src/benchmarking.rs index 2b833eaf..f1f71212 100644 --- a/frame/shielded-pool/src/benchmarking.rs +++ b/frame/shielded-pool/src/benchmarking.rs @@ -58,16 +58,31 @@ mod benchmarks { } // 2. Fund caller - let amount: BalanceOf = 1_000_000u32.into(); - let _ = >::make_free_balance_be(&caller, amount); + let _ = >::make_free_balance_be( + &caller, + bench_amount::() * 100u32.into(), + ); (caller, asset_id) } + /// A value large enough to survive a relay fee being deducted from it. + /// + /// `unshield` pays the relayer out of `amount`, so a flat literal breaks the + /// moment a runtime's `min_relay_fee` exceeds it — the production fee is + /// 1e15 planck, which dwarfs any hand-picked constant. Deriving it from the + /// configured fee keeps the benchmarks working across every runtime. + fn bench_amount() -> BalanceOf { + let fee: BalanceOf = T::Relayer::min_relay_fee().saturated_into(); + let scaled = fee * 1_000u32.into(); + let floor: BalanceOf = 1_000_000u32.into(); + if scaled > floor { scaled } else { floor } + } + #[benchmark] fn shield() { let (caller, asset_id) = setup_benchmark_env::(); - let amount: BalanceOf = 10_000u32.into(); + let amount: BalanceOf = bench_amount::(); let commitment = Commitment([1u8; 32]); // Memo must be exactly 180 bytes (MAX_ENCRYPTED_MEMO_SIZE): nonce(12) + data(120) + MAC(16) + ephPk(32) let memo_bytes = vec![0u8; MAX_ENCRYPTED_MEMO_SIZE as usize]; @@ -86,7 +101,7 @@ mod benchmarks { #[benchmark] fn shield_batch(n: Linear<1, 20>) { let (caller, asset_id) = setup_benchmark_env::(); - let amount: BalanceOf = 10_000u32.into(); + let amount: BalanceOf = bench_amount::(); let mut operations = Vec::new(); for i in 0..n { @@ -151,7 +166,7 @@ mod benchmarks { let (_caller, asset_id) = setup_benchmark_env::(); let recipient: T::AccountId = account("recipient", 0, 0); let merkle_root = [1u8; 32]; - let amount: BalanceOf = 10_000u32.into(); + let amount: BalanceOf = bench_amount::(); // Setup valid state: root and pool balance crate::storage::MerkleRepository::add_historic_poseidon_root::(merkle_root); @@ -222,7 +237,7 @@ mod benchmarks { #[benchmark] fn claim_shielded_fees() { let (caller, asset_id) = setup_benchmark_env::(); - let amount: BalanceOf = 10_000u32.into(); + let amount: BalanceOf = bench_amount::(); let amount_u128: u128 = amount.saturated_into(); // Accumulate relay fees for the validator. From f56ab97fa7d6cceaa51829c61d79f93e12126152 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 7 Aug 2026 02:24:39 -0400 Subject: [PATCH 4/5] perf(shielded-pool): replace placeholder pruning weights with benchmarked ones --- frame/shielded-pool/CHANGELOG.md | 8 +- frame/shielded-pool/src/weights.rs | 143 +++++++++++++++++------------ 2 files changed, 92 insertions(+), 59 deletions(-) diff --git a/frame/shielded-pool/CHANGELOG.md b/frame/shielded-pool/CHANGELOG.md index 759c9698..0bc01d12 100644 --- a/frame/shielded-pool/CHANGELOG.md +++ b/frame/shielded-pool/CHANGELOG.md @@ -55,8 +55,12 @@ All notable changes to `pallet-shielded-pool` will be documented in this file. `MAX_PRUNED_NODES_PER_BLOCK` (512), which caps trie churn on an idle chain. It charges every probe rather than only removals, so a level that is already clean cannot scan for free. Progress is parked in `SealedPruneCursor`. -- **`on_idle` needs its own benchmark before deploying.** Its weight is currently - derived from read/write counts, not measured. +- **`on_idle` is benchmarked**, at 12.68 µs per node plus a 0.25 µs base. The + 512-node ceiling therefore costs ~6.5 ms, about 0.3% of a 2s block, and a full + sealed tree (1,046,528 prunable nodes) drains in ~2,044 blocks — around 3.4 + hours at 6s. The placeholder it replaces charged nothing for execution and + leaned entirely on `DbWeight`, so it over-declared the per-node cost by ~10x: + the sweep would have run, just far below the batch size the block could afford. ### Verification 308 pallet tests (11 new) and 65 precompile tests; runtime, `try-runtime` and diff --git a/frame/shielded-pool/src/weights.rs b/frame/shielded-pool/src/weights.rs index 07f4a8d9..cf135372 100644 --- a/frame/shielded-pool/src/weights.rs +++ b/frame/shielded-pool/src/weights.rs @@ -2,7 +2,7 @@ //! Autogenerated weights for pallet_shielded_pool //! //! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-08-05, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! DATE: 2026-08-07, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` //! WORST CASE MAP SIZE: `1000000` //! HOSTNAME: `ubuntu-32gb-nbg1-1`, CPU: `AMD EPYC-Genoa Processor` //! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024 @@ -47,12 +47,7 @@ pub trait WeightInfo { fn verify_asset() -> Weight; fn unverify_asset() -> Weight; fn claim_shielded_fees() -> Weight; - /// Weight of one `on_idle` sealed-tree sweep that removes `n` nodes. - /// - /// Both impls below are PLACEHOLDERS derived from storage-access counts, not - /// measured. Replace with generated weights before a chain seals its first - /// tree; until then nothing is pruned, so the estimate goes unused. - fn prune_sealed_nodes(n: u32) -> Weight; + fn prune_sealed_nodes(n: u32, ) -> Weight; } /// Weights for pallet_shielded_pool using the Substrate node and recommended hardware. @@ -102,8 +97,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1242` // Estimated: `3695` - // Minimum execution time: 861_716_000 picoseconds. - Weight::from_parts(878_209_000, 3695) + // Minimum execution time: 1_021_472_000 picoseconds. + Weight::from_parts(1_027_963_000, 3695) .saturating_add(T::DbWeight::get().reads(17_u64)) .saturating_add(T::DbWeight::get().writes(34_u64)) } @@ -152,10 +147,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // 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())) + // Minimum execution time: 1_023_986_000 picoseconds. + Weight::from_parts(1_032_270_000, 3631) + // Standard Error: 2_054_651 + .saturating_add(Weight::from_parts(910_069_443, 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)) @@ -211,10 +206,10 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // 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())) + // Minimum execution time: 983_546_000 picoseconds. + Weight::from_parts(90_754_716, 3631) + // Standard Error: 2_663_348 + .saturating_add(Weight::from_parts(918_631_791, 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)) @@ -255,8 +250,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `981` // Estimated: `6196` - // Minimum execution time: 108_500_000 picoseconds. - Weight::from_parts(112_414_000, 6196) + // Minimum execution time: 116_957_000 picoseconds. + Weight::from_parts(119_921_000, 6196) .saturating_add(T::DbWeight::get().reads(16_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } @@ -276,8 +271,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `260` // Estimated: `3631` - // Minimum execution time: 16_953_000 picoseconds. - Weight::from_parts(17_543_000, 3631) + // Minimum execution time: 19_179_000 picoseconds. + Weight::from_parts(20_231_000, 3631) .saturating_add(T::DbWeight::get().reads(6_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } @@ -295,8 +290,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_044_000 picoseconds. - Weight::from_parts(17_754_000, 3631) + // Minimum execution time: 18_217_000 picoseconds. + Weight::from_parts(19_510_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -314,8 +309,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `349` // Estimated: `3631` - // Minimum execution time: 16_913_000 picoseconds. - Weight::from_parts(17_645_000, 3631) + // Minimum execution time: 18_318_000 picoseconds. + Weight::from_parts(19_068_000, 3631) .saturating_add(T::DbWeight::get().reads(5_u64)) .saturating_add(T::DbWeight::get().writes(3_u64)) } @@ -359,16 +354,33 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1200` // Estimated: `3695` - // Minimum execution time: 841_328_000 picoseconds. - Weight::from_parts(851_813_000, 3695) + // Minimum execution time: 969_545_000 picoseconds. + Weight::from_parts(987_242_000, 3695) .saturating_add(T::DbWeight::get().reads(15_u64)) .saturating_add(T::DbWeight::get().writes(33_u64)) } - /// PLACEHOLDER: cursor read, then one keyed read plus one removal per node. - fn prune_sealed_nodes(n: u32) -> Weight { - Weight::from_parts(5_000_000, 0) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().reads_writes(1_u64, 1_u64).saturating_mul(n.into())) + /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:0) + /// Proof: `ShieldedPool::MerkleTreeSize` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::SealedPruneCursor` (r:1 w:1) + /// Proof: `ShieldedPool::SealedPruneCursor` (`max_values`: Some(1), `max_size`: Some(9), added: 504, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::LastPrunedTree` (r:1 w:0) + /// Proof: `ShieldedPool::LastPrunedTree` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:512 w:512) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) + /// The range of component `n` is `[0, 512]`. + fn prune_sealed_nodes(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `247 + n * (48 ±0)` + // Estimated: `1494 + n * (2540 ±0)` + // Minimum execution time: 211_000 picoseconds. + Weight::from_parts(250_000, 1494) + // Standard Error: 15_510 + .saturating_add(Weight::from_parts(12_680_277, 0).saturating_mul(n.into())) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(T::DbWeight::get().writes(1_u64)) + .saturating_add(T::DbWeight::get().writes((1_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2540).saturating_mul(n.into())) } } @@ -418,8 +430,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1242` // Estimated: `3695` - // Minimum execution time: 861_716_000 picoseconds. - Weight::from_parts(878_209_000, 3695) + // Minimum execution time: 1_021_472_000 picoseconds. + Weight::from_parts(1_027_963_000, 3695) .saturating_add(RocksDbWeight::get().reads(17_u64)) .saturating_add(RocksDbWeight::get().writes(34_u64)) } @@ -468,10 +480,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // 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())) + // Minimum execution time: 1_023_986_000 picoseconds. + Weight::from_parts(1_032_270_000, 3631) + // Standard Error: 2_054_651 + .saturating_add(Weight::from_parts(910_069_443, 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)) @@ -527,10 +539,10 @@ impl WeightInfo for () { // Proof Size summary in bytes: // 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())) + // Minimum execution time: 983_546_000 picoseconds. + Weight::from_parts(90_754_716, 3631) + // Standard Error: 2_663_348 + .saturating_add(Weight::from_parts(918_631_791, 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)) @@ -571,8 +583,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `981` // Estimated: `6196` - // Minimum execution time: 108_500_000 picoseconds. - Weight::from_parts(112_414_000, 6196) + // Minimum execution time: 116_957_000 picoseconds. + Weight::from_parts(119_921_000, 6196) .saturating_add(RocksDbWeight::get().reads(16_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } @@ -592,8 +604,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `260` // Estimated: `3631` - // Minimum execution time: 16_953_000 picoseconds. - Weight::from_parts(17_543_000, 3631) + // Minimum execution time: 19_179_000 picoseconds. + Weight::from_parts(20_231_000, 3631) .saturating_add(RocksDbWeight::get().reads(6_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } @@ -611,8 +623,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `349` // Estimated: `3631` - // Minimum execution time: 17_044_000 picoseconds. - Weight::from_parts(17_754_000, 3631) + // Minimum execution time: 18_217_000 picoseconds. + Weight::from_parts(19_510_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -630,8 +642,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `349` // Estimated: `3631` - // Minimum execution time: 16_913_000 picoseconds. - Weight::from_parts(17_645_000, 3631) + // Minimum execution time: 18_318_000 picoseconds. + Weight::from_parts(19_068_000, 3631) .saturating_add(RocksDbWeight::get().reads(5_u64)) .saturating_add(RocksDbWeight::get().writes(3_u64)) } @@ -675,15 +687,32 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1200` // Estimated: `3695` - // Minimum execution time: 841_328_000 picoseconds. - Weight::from_parts(851_813_000, 3695) + // Minimum execution time: 969_545_000 picoseconds. + Weight::from_parts(987_242_000, 3695) .saturating_add(RocksDbWeight::get().reads(15_u64)) .saturating_add(RocksDbWeight::get().writes(33_u64)) } - /// PLACEHOLDER: cursor read, then one keyed read plus one removal per node. - fn prune_sealed_nodes(n: u32) -> Weight { - Weight::from_parts(5_000_000, 0) - .saturating_add(RocksDbWeight::get().reads(1_u64)) - .saturating_add(RocksDbWeight::get().reads_writes(1_u64, 1_u64).saturating_mul(n.into())) + /// Storage: `ShieldedPool::MerkleTreeSize` (r:1 w:0) + /// Proof: `ShieldedPool::MerkleTreeSize` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::SealedPruneCursor` (r:1 w:1) + /// Proof: `ShieldedPool::SealedPruneCursor` (`max_values`: Some(1), `max_size`: Some(9), added: 504, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::LastPrunedTree` (r:1 w:0) + /// Proof: `ShieldedPool::LastPrunedTree` (`max_values`: Some(1), `max_size`: Some(4), added: 499, mode: `MaxEncodedLen`) + /// Storage: `ShieldedPool::MerkleNodes` (r:512 w:512) + /// Proof: `ShieldedPool::MerkleNodes` (`max_values`: None, `max_size`: Some(65), added: 2540, mode: `MaxEncodedLen`) + /// The range of component `n` is `[0, 512]`. + fn prune_sealed_nodes(n: u32, ) -> Weight { + // Proof Size summary in bytes: + // Measured: `247 + n * (48 ±0)` + // Estimated: `1494 + n * (2540 ±0)` + // Minimum execution time: 211_000 picoseconds. + Weight::from_parts(250_000, 1494) + // Standard Error: 15_510 + .saturating_add(Weight::from_parts(12_680_277, 0).saturating_mul(n.into())) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().reads((1_u64).saturating_mul(n.into()))) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + .saturating_add(RocksDbWeight::get().writes((1_u64).saturating_mul(n.into()))) + .saturating_add(Weight::from_parts(0, 2540).saturating_mul(n.into())) } } From 964bd8237b512463ec39501b6a21d93b6465bdb0 Mon Sep 17 00:00:00 2001 From: nol4lej Date: Fri, 7 Aug 2026 07:55:41 -0400 Subject: [PATCH 5/5] fix(shielded-pool): make the per-block prune cap a compile-time assert --- frame/shielded-pool/src/merkle/mod.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/frame/shielded-pool/src/merkle/mod.rs b/frame/shielded-pool/src/merkle/mod.rs index 7d4b6a0f..8811e3c7 100644 --- a/frame/shielded-pool/src/merkle/mod.rs +++ b/frame/shielded-pool/src/merkle/mod.rs @@ -1323,8 +1323,14 @@ mod prune_tests { } /// The per-block ceiling has to be a real bound, not a placeholder. - #[test] - fn per_block_cap_is_bounded() { - assert!(MAX_PRUNED_NODES_PER_BLOCK > 0 && MAX_PRUNED_NODES_PER_BLOCK <= 4096); - } + /// + /// A `const` block rather than a runtime assert: both operands are constants, + /// so the compiler would fold an `assert!` away and the check would never run. + /// This one fails the build instead. + const _: () = assert!( + MAX_PRUNED_NODES_PER_BLOCK > 0 && MAX_PRUNED_NODES_PER_BLOCK <= 4096, + "MAX_PRUNED_NODES_PER_BLOCK must bound the sweep: zero disables pruning, \ + and a value this far above the benchmarked batch would let one block \ + absorb work it cannot pay for" + ); }