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.

35 changes: 32 additions & 3 deletions client/rpc-v2/src/privacy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,18 @@ fn map_key(item: &[u8], k: &[u8]) -> Vec<u8> {
/// Response for `privacy_getMerkleProof`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MerkleProofResponse {
/// Current Merkle root read from the same block as the proof (`0x`-prefixed hex).
/// Root the leaf's tree anchors to, read at the same block as the proof
/// (`0x`-prefixed hex): the permanent sealed root for completed trees,
/// the live root for the active tree.
pub root: String,
/// Sibling path — 20 `0x`-prefixed hex strings.
pub path: Vec<String>,
/// Leaf index used to generate this proof.
pub leaf_index: u32,
/// Tree depth (always 20 for circuit compatibility).
pub tree_depth: u32,
/// Forest tree the leaf belongs to (0 until the first tree seals).
pub tree_id: u32,
}

/// Response for `privacy_getNullifierStatus`.
Expand Down Expand Up @@ -237,7 +241,7 @@ where
let best_hash = self.client.info().best_hash;
let api = self.client.runtime_api();

let (root, tree_size, tree_depth) = api
let (_, tree_size, tree_depth) = api
.get_merkle_tree_info(best_hash)
.map_err(internal_error)?;

Expand All @@ -255,6 +259,16 @@ where
.map_err(internal_error)?
.ok_or_else(|| internal_error(format!("no proof for leaf_index {leaf_index}")))?;

// Sealed trees anchor to their permanent root, not the active one.
// A missing entry for an existing leaf means broken sealed-root state;
// erroring beats silently serving the wrong anchor.
let (root, tree_id) = api
.get_root_for_leaf(best_hash, leaf_index)
.map_err(internal_error)?
.ok_or_else(|| {
internal_error(format!("no anchoring root for leaf_index {leaf_index}"))
})?;

Ok(MerkleProofResponse {
root: format!("0x{}", hex::encode(root)),
path: proof
Expand All @@ -264,6 +278,7 @@ where
.collect(),
leaf_index,
tree_depth,
tree_id,
})
}

Expand All @@ -282,7 +297,7 @@ where
}
let target = H256::from_slice(&bytes);

let (root, tree_size, tree_depth) = api
let (_, tree_size, tree_depth) = api
.get_merkle_tree_info(best_hash)
.map_err(internal_error)?;

Expand All @@ -299,6 +314,16 @@ where
))
})?;

// Sealed trees anchor to their permanent root, not the active one.
// A missing entry for an existing leaf means broken sealed-root state;
// erroring beats silently serving the wrong anchor.
let (root, tree_id) = api
.get_root_for_leaf(best_hash, leaf_index)
.map_err(internal_error)?
.ok_or_else(|| {
internal_error(format!("no anchoring root for leaf_index {leaf_index}"))
})?;

Ok(MerkleProofResponse {
root: format!("0x{}", hex::encode(root)),
path: proof
Expand All @@ -308,6 +333,7 @@ where
.collect(),
leaf_index,
tree_depth,
tree_id,
})
}

Expand Down Expand Up @@ -492,11 +518,13 @@ mod tests {
path: vec!["0x1111".to_string(), "0x2222".to_string()],
leaf_index: 7,
tree_depth: 20,
tree_id: 0,
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["root"], "0xaabb");
assert_eq!(json["leaf_index"], 7);
assert_eq!(json["tree_depth"], 20);
assert_eq!(json["tree_id"], 0);
assert_eq!(json["path"].as_array().unwrap().len(), 2);
}

Expand All @@ -507,6 +535,7 @@ mod tests {
path: vec!["0xaa".to_string()],
leaf_index: 3,
tree_depth: 20,
tree_id: 0,
};
let back: MerkleProofResponse =
serde_json::from_str(&serde_json::to_string(&orig).unwrap()).unwrap();
Expand Down
2 changes: 2 additions & 0 deletions frame/evm/precompile/shielded-pool/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 MaxLeavesPerTree: u32 = 8;
pub const MinShieldAmount: u128 = 100;
}

Expand Down Expand Up @@ -256,6 +257,7 @@ impl pallet_shielded_pool::Config for Test {
type PalletId = ShieldedPoolPalletId;
type MaxTreeDepth = MaxTreeDepth;
type MaxHistoricRoots = MaxHistoricRoots;
type MaxLeavesPerTree = MaxLeavesPerTree;
type MinShieldAmount = MinShieldAmount;
type WeightInfo = ();
type Relayer = MockRelayer;
Expand Down
52 changes: 52 additions & 0 deletions frame/shielded-pool/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,58 @@

All notable changes to `pallet-shielded-pool` will be documented in this file.

## [0.12.0] - 2026-07-30

### Added
- **Multi-tree forest: the 2^20-note network ceiling is gone.** When a tree
reaches `MaxLeavesPerTree` the filling insert seals it and inserts continue
in a fresh tree. The global u32 leaf index never resets
(`tree_id = leaf_index / MaxLeavesPerTree`), so event shapes, indexer
chunking and wallet scan cursors are untouched. Zero circuit, VK,
extrinsic-signature or precompile-ABI changes — anchoring stays root-only.
- New storage `SealedTreeRoots` / `SealedRootIndex`: a sealed tree's final
root is a **permanent** anchor (bounded by tree count, max 4096 — never
evicted, unlike the historic ring), so notes in sealed trees stay spendable
forever. `MerkleRepository::is_known_root` = historic ring OR sealed set;
transfer/unshield/validate_unsigned call sites unchanged. Regression test:
a sealed root survives `MaxHistoricRoots + N` later inserts.
- New Config `MaxLeavesPerTree` (production 2^20; `integrity_test` enforces
power-of-two ≤ 2^`MAX_TREE_DEPTH` — clients derive `tree_id` from this
constant, so it must never change on a live chain). Mocks use 8 to make
rollover testable.
- New event `TreeSealed { tree_id, final_root, first_leaf_index, leaf_count }`,
emitted after the `MerkleRootUpdated` that carries the final root.
- Runtime API v2 (`api_version(2)`): `get_forest_info()` and
`get_root_for_leaf(leaf_index)` — sealed trees resolve to their permanent
root, the active tree to the live `PoseidonRoot`.
- `STORAGE_VERSION` 2 with version-only `migrations::v2::MigrateToV2`
(sealed maps start empty). Forest invariants merged into the existing
`try_state` hook: active root always known, one sealed root per completed
tree, sealed maps bijective.

### Changed
- `Error::MerkleTreeFull` now means the absolute forest ceiling (u32
leaf-index space, ~4096 trees) — practically unreachable — instead of the
per-tree 2^20 cap.
- `insert_leaf` / `get_merkle_path` index `MerkleNodes` by tree-local
position (`leaf_index % MaxLeavesPerTree`). Identical to the previous
global indexing for tree 0, so v1-backfilled data needs no migration.
- Consensus-affecting (seal writes new storage on the filling insert):
requires a runtime `spec_version` bump at release. `transaction_version`
unchanged.

### Fixed
- Privacy RPC (`fc-rpc-v2`): proof endpoints no longer fall back to the
active root when a leaf's anchoring root cannot be resolved — that would
have served a sealed-tree path with the wrong anchor; they now return an
explicit error. Both endpoints gain a `tree_id` response field (additive).

### Verified E2E
- Against a local dev node with an 8-leaf cap: 10 shields cross the first
seal; `TreeSealed` fires; sealed leaves anchor to the permanent final root
and active leaves to the live root via `privacy_getMerkleProof*`
(`ts-tests/test-forest-e2e.ts`).

## [0.11.0] - 2026-07-30

### Added
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.11.0"
version = "0.12.0"
description = "Shielded pool pallet for private transactions using ZK proofs"
authors = ["Orbinum Team"]
license = "GPL-3.0-or-later"
Expand Down
10 changes: 10 additions & 0 deletions frame/shielded-pool/runtime-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub struct RelayConfig {
}

sp_api::decl_runtime_apis! {
#[api_version(2)]
pub trait ShieldedPoolRuntimeApi {
/// Get the Merkle tree information (root, size, depth)
fn get_merkle_tree_info() -> (Hash, u32, u32);
Expand All @@ -37,6 +38,15 @@ sp_api::decl_runtime_apis! {
/// O(1) index lookup plus O(depth) sibling reads from stored nodes.
fn get_merkle_proof_for_commitment(commitment: Hash) -> Option<(u32, DefaultMerklePath)>;

/// Forest summary: (active_root, global_size, depth, current_tree_id,
/// sealed_tree_count).
fn get_forest_info() -> (Hash, u32, u32, u32, u32);

/// Root the given leaf's tree anchors to, plus its tree_id: the
/// permanent sealed root for completed trees, the live PoseidonRoot
/// for the active tree. None if the leaf does not exist.
fn get_root_for_leaf(leaf_index: u32) -> Option<(Hash, u32)>;

/// Return relay configuration sourced from the runtime.
///
/// Called by the node-native EVM relay on every `relay_shielded_call` to obtain
Expand Down
78 changes: 73 additions & 5 deletions frame/shielded-pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,12 @@ pub mod pallet {
pub type BalanceOf<T> =
<<T as Config>::Currency as Currency<<T as frame_system::Config>::AccountId>>::Balance;

/// Storage version. v1 adds `MerkleNodes` (internal Merkle tree nodes),
/// backfilled from `MerkleLeaves` by `migrations::v1::MigrateToV1`.
pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(1);
/// Storage version history:
/// - v1: `MerkleNodes` (internal Merkle tree nodes), backfilled by
/// `migrations::v1::MigrateToV1`.
/// - v2: multi-tree forest — `SealedTreeRoots` / `SealedRootIndex`
/// (start empty; version-only bump in `migrations::v2::MigrateToV2`).
pub const STORAGE_VERSION: StorageVersion = StorageVersion::new(2);

#[pallet::pallet]
#[pallet::storage_version(STORAGE_VERSION)]
Expand Down Expand Up @@ -133,6 +136,13 @@ pub mod pallet {
#[pallet::constant]
type MaxTreeDepth: Get<u32>;

/// Leaves per tree before it seals and the forest rolls over to a new
/// tree. Must be a power of two ≤ 2^MaxTreeDepth. Production pins
/// 2^20; changing it on a live chain would re-map every note's
/// tree_id and is forbidden (see `integrity_test`).
#[pallet::constant]
type MaxLeavesPerTree: Get<u32>;

/// Maximum number of historic roots to keep
#[pallet::constant]
type MaxHistoricRoots: Get<u32>;
Expand Down Expand Up @@ -207,6 +217,18 @@ pub mod pallet {
pub type NullifierSet<T: Config> =
StorageMap<_, Blake2_128Concat, Nullifier, BlockNumberFor<T>, OptionQuery>;

/// Final root of each sealed tree, keyed by tree_id. Permanent — a sealed
/// tree is immutable forever, so its root must never expire or every note
/// still inside it would become unspendable. Bounded by tree count
/// (max 4096), not by activity.
#[pallet::storage]
pub type SealedTreeRoots<T> = StorageMap<_, Twox64Concat, u32, Hash, OptionQuery>;

/// Reverse index of `SealedTreeRoots`: sealed root -> tree_id. Gives
/// `is_known_root` an O(1) membership check alongside the historic ring.
#[pallet::storage]
pub type SealedRootIndex<T> = StorageMap<_, Blake2_128Concat, Hash, u32, OptionQuery>;

/// Historic Poseidon Merkle roots (for proving against recent states)
#[pallet::storage]
pub type HistoricPoseidonRoots<T> = StorageMap<_, Blake2_128Concat, Hash, bool, ValueQuery>;
Expand Down Expand Up @@ -321,6 +343,14 @@ pub mod pallet {
"MaxHistoricRoots must be non-zero, otherwise the historic-root map \
grows unbounded and the root window never evicts"
);

let cap = T::MaxLeavesPerTree::get();
assert!(
cap.is_power_of_two() && cap <= (1u32 << crate::types::MAX_TREE_DEPTH),
"MaxLeavesPerTree must be a power of two <= 2^MAX_TREE_DEPTH; \
clients derive tree_id from the global leaf index using this \
constant, so it must never change on a live chain"
);
}

/// Ledger-solvency invariant: the tracked native-asset pool balance must
Expand All @@ -339,6 +369,26 @@ pub mod pallet {
"shielded-pool native ledger drifted from physical pool balance"
)
);

// Forest invariants: the active root must always be provable
// against; one sealed root per completed tree; the sealed maps
// are a bijection.
use crate::storage::MerkleRepository;
frame_support::ensure!(
MerkleRepository::is_known_root::<T>(&MerkleRepository::get_poseidon_root::<T>()),
sp_runtime::TryRuntimeError::Other("PoseidonRoot not in known-roots set")
);
let sealed = SealedTreeRoots::<T>::iter().count() as u32;
frame_support::ensure!(
sealed == MerkleRepository::get_tree_size::<T>() / T::MaxLeavesPerTree::get(),
sp_runtime::TryRuntimeError::Other("sealed-tree count != tree_size / cap")
);
for (tree_id, root) in SealedTreeRoots::<T>::iter() {
frame_support::ensure!(
SealedRootIndex::<T>::get(root) == Some(tree_id),
sp_runtime::TryRuntimeError::Other("SealedRootIndex out of sync")
);
}
Ok(())
}
}
Expand Down Expand Up @@ -404,10 +454,27 @@ pub mod pallet {
old_root: Hash,
/// New root
new_root: Hash,
/// New tree size
/// Total leaves ever inserted, global across the whole forest —
/// never per-tree. Indexer chunking and wallet scan cursors rely
/// on this being dense and monotonic; per-tree size is derivable
/// as `tree_size % MaxLeavesPerTree`.
tree_size: u32,
},

/// A tree reached `MaxLeavesPerTree` and was sealed; inserts continue
/// in a fresh tree. The final root stays valid forever via
/// `SealedTreeRoots`.
TreeSealed {
/// Id of the sealed tree (global_leaf_index >> log2(MaxLeavesPerTree))
tree_id: u32,
/// Final root of the sealed tree — permanently spendable anchor
final_root: Hash,
/// Global index of the sealed tree's first leaf
first_leaf_index: u32,
/// Leaves in the sealed tree (always MaxLeavesPerTree)
leaf_count: u32,
},

/// Asset was registered in the registry
AssetRegistered {
/// The asset ID
Expand Down Expand Up @@ -453,7 +520,8 @@ pub mod pallet {
NullifierAlreadyUsed,
/// The Merkle root is not recognized
UnknownMerkleRoot,
/// The Merkle tree is full
/// Absolute forest capacity reached (u32 leaf-index space exhausted:
/// 4096 trees × MaxLeavesPerTree). Practically unreachable.
MerkleTreeFull,
/// The ZK proof is invalid
InvalidProof,
Expand Down
Loading
Loading