Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions frame/evm/precompile/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion frame/evm/precompile/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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."
Expand Down
3 changes: 3 additions & 0 deletions frame/evm/precompile/shielded-pool/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
Expand Down
75 changes: 75 additions & 0 deletions frame/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,81 @@

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` 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
`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
Expand Down
2 changes: 1 addition & 1 deletion frame/shielded-pool/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
68 changes: 62 additions & 6 deletions frame/shielded-pool/src/benchmarking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,31 @@ mod benchmarks {
}

// 2. Fund caller
let amount: BalanceOf<T> = 1_000_000u32.into();
let _ = <T::Currency as Currency<T::AccountId>>::make_free_balance_be(&caller, amount);
let _ = <T::Currency as Currency<T::AccountId>>::make_free_balance_be(
&caller,
bench_amount::<T>() * 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<T: Config>() -> BalanceOf<T> {
let fee: BalanceOf<T> = T::Relayer::min_relay_fee().saturated_into();
let scaled = fee * 1_000u32.into();
let floor: BalanceOf<T> = 1_000_000u32.into();
if scaled > floor { scaled } else { floor }
}

#[benchmark]
fn shield() {
let (caller, asset_id) = setup_benchmark_env::<T>();
let amount: BalanceOf<T> = 10_000u32.into();
let amount: BalanceOf<T> = bench_amount::<T>();
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];
Expand All @@ -86,7 +101,7 @@ mod benchmarks {
#[benchmark]
fn shield_batch(n: Linear<1, 20>) {
let (caller, asset_id) = setup_benchmark_env::<T>();
let amount: BalanceOf<T> = 10_000u32.into();
let amount: BalanceOf<T> = bench_amount::<T>();

let mut operations = Vec::new();
for i in 0..n {
Expand Down Expand Up @@ -151,7 +166,7 @@ mod benchmarks {
let (_caller, asset_id) = setup_benchmark_env::<T>();
let recipient: T::AccountId = account("recipient", 0, 0);
let merkle_root = [1u8; 32];
let amount: BalanceOf<T> = 10_000u32.into();
let amount: BalanceOf<T> = bench_amount::<T>();

// Setup valid state: root and pool balance
crate::storage::MerkleRepository::add_historic_poseidon_root::<T>(merkle_root);
Expand Down Expand Up @@ -222,7 +237,7 @@ mod benchmarks {
#[benchmark]
fn claim_shielded_fees() {
let (caller, asset_id) = setup_benchmark_env::<T>();
let amount: BalanceOf<T> = 10_000u32.into();
let amount: BalanceOf<T> = bench_amount::<T>();
let amount_u128: u128 = amount.saturated_into();

// Accumulate relay fees for the validator.
Expand Down Expand Up @@ -253,5 +268,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::<T>(cap);
crate::storage::MerkleRepository::insert_sealed_root::<T>(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::<T>(0, level, index, node);
placed = placed.saturating_add(1);
}
}

#[block]
{
crate::merkle::MerkleTreeService::prune_sealed_nodes::<T>(n);
}
}

impl_benchmark_test_suite!(Pallet, crate::mock::new_test_ext(), crate::mock::Test,);
}
83 changes: 82 additions & 1 deletion frame/shielded-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(_);
Expand Down Expand Up @@ -163,6 +170,24 @@ pub mod pallet {
#[pallet::constant]
type RootRetentionBlocks: Get<BlockNumberFor<Self>>;

/// 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<u8>;

/// Weight information for extrinsics in this pallet
type WeightInfo: WeightInfo;
}
Expand Down Expand Up @@ -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<T> = 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<T> = StorageValue<_, u32, OptionQuery>;

/// Set of used nullifiers (nullifier -> block number when used)
#[pallet::storage]
pub type NullifierSet<T: Config> =
Expand Down Expand Up @@ -366,6 +407,37 @@ pub mod pallet {

#[pallet::hooks]
impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> {
/// 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<T>, 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::<T>(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"),
Expand All @@ -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::<u64>::unique_saturated_into(
T::RootRetentionBlocks::get(),
Expand All @@ -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),
Expand Down
Loading
Loading