Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
903c82e
Security review: pin wormhole exit credits against double recording
illuzen Aug 7, 2026
86612f3
Derive wormhole genesis proofs from balances
illuzen Aug 7, 2026
ca9da89
Security review: pin aggregator rebate address binding for public bat…
illuzen Aug 7, 2026
b570b39
Document the circuit tree-depth limit and planned update path
illuzen Aug 7, 2026
80802ef
Security review: charge ZK-tree Poseidon hashing in leaf-recording we…
illuzen Aug 7, 2026
14d7fbe
Security review: drop zero-amount credits from wormhole proof recording
illuzen Aug 7, 2026
60b2d4a
Document the genesis-builder trust model (no input-size limits by des…
illuzen Aug 7, 2026
84ee98f
Document genesis-build failure semantics (panics are the FRAME channel)
illuzen Aug 7, 2026
0e7f4b6
Security review: record hold-transfers (guardian seizure/recovery) in…
illuzen Aug 7, 2026
1cbc1b4
Security review: record reserve repatriations (recovery-deposit seizu…
illuzen Aug 7, 2026
ab976ca
Document the proof-recorder coverage boundary (hooks vs transactions)
illuzen Aug 7, 2026
cd04542
Security review: require canonical hashes in the zk-tree proof RPC
illuzen Aug 7, 2026
bc66b51
Security review: bound and canonicalize settlement proof bytes
illuzen Aug 7, 2026
50c5f0b
Security review: charge high-security policy reads in wrapper dispatc…
illuzen Aug 7, 2026
c35ec30
Security review: meter the post-dispatch event scan against block weight
illuzen Aug 7, 2026
9dffec2
Security review: stop depositing RuntimeEnvironmentUpdated (QPoW dige…
illuzen Aug 7, 2026
d28616a
fmt
illuzen Aug 7, 2026
be0f487
Merge branch 'main' into illuzen/v12-zk-e2e
illuzen Aug 7, 2026
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
38 changes: 37 additions & 1 deletion docs/zk-trie-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,46 @@ Depth 3 (capacity: 64 leaves)
| 2 | 16 | Grows automatically |
| 3 | 64 | |
| ... | ... | |
| 32 | ~1.8 × 10^19 | Maximum supported depth |
| 16 | ~4.3 × 10^9 | Max depth the **circuits** accept (see below) |
| 32 | ~1.8 × 10^19 | Max depth the on-chain tree may grow to |

The tree grows dynamically -- when the 5th leaf arrives, depth increases from 1 to 2. The old root becomes child[0] of a new root node.

### Circuit depth limit (known, accepted limitation)

The on-chain tree may grow up to depth 32 (`MAX_TREE_DEPTH` in `pallets/zk-tree`), but the
wormhole circuits only accept Merkle paths up to depth 16 (`MAX_DEPTH` in
`qp-zk-circuits-common/src/zk_merkle.rs`). The circuit pads every proof's witness to the
full `MAX_DEPTH` levels, so **every leaf proof pays the proving cost of a depth-16 path
regardless of the tree's actual depth** -- that is why the circuit constant is kept as
small as safely possible instead of matching the on-chain cap.

**What happens at the limit:** once leaf 4^16 + 1 (~4.3 billion) is inserted, the tree
grows to depth 17, all Merkle proofs gain a 17th sibling level, and the prover and
on-chain verifier reject them. Existing funds are never lost and nullifier state is
untouched -- wormhole proof *generation* simply halts until the circuit is updated.

**The plan is to do a circuit update when (long before) that happens.** Rough timeline
to exhaustion at 12-second blocks:

| Sustained leaf rate | Time to 4.3 B leaves |
|---|---|
| 1 leaf/block (mining-reward floor) | ~1,600 years |
| 10 transfers/sec chain-wide | ~13 years |
| ~50 transfers/sec (permanently full blocks) | ~2.5 years |

`LeafCount` is public storage, so the approach is observable years ahead; each +1 of
circuit depth quadruples capacity (e.g. 16 → 20 buys ~256× the runway).

**What the update involves:** bump `MAX_DEPTH` in `qp-zk-circuits-common`, release the
circuit crates, rebuild -- `pallets/wormhole/build.rs` regenerates and embeds the new
verifier binaries automatically -- regenerate the proof test fixtures
(`regenerate_*_fixture` tests), re-benchmark weights, and ship a normal runtime upgrade.
The code change is a one-line constant; the end-to-end effort is on the order of days of
engineering inside a standard release cycle. Proofs built against the old circuit become
invalid at the upgrade (wallets/provers must update in step), but spent nullifiers
persist, so nothing can double-spend across the transition.

### Hashing Strategy

| Layer | Encoding | Felts | Injective? |
Expand Down
110 changes: 103 additions & 7 deletions node/src/zktree_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ pub trait ZkTreeApi {
/// the proving block's hash is still in `frame_system::BlockHash`, a sliding
/// window of `BlockHashCount` blocks. Blocks outside that window are rejected.
///
/// The requested hash must also be the *canonical* hash at its height. The backend
/// resolves numbers for any imported block (side forks included), but settlement
/// verifies the claimed hash against `frame_system::BlockHash`, so a proof built on
/// fork state is unusable by construction — reject it here instead of spending
/// state-execution resources producing it.
///
/// The window is `quantus_runtime::configs::BlockHashCount` from the runtime
/// crate linked into this node binary — a compile-time constant, not a live
/// chain/metadata lookup. After a forkless upgrade that changes
Expand Down Expand Up @@ -104,6 +110,43 @@ where
)),
};

// The backend resolves a number for ANY block it has imported, including
// side-fork blocks — resolvability is not canonicality. On-chain settlement
// compares the proof's claimed hash against `frame_system::BlockHash` (the
// canonical chain), so proof material derived from fork state can never
// settle. Reject anything that is not the canonical hash at its height;
// heights above best (where `best - number` saturates to 0) are rejected
// first for a precise error.
if number > info.best_number {
return Err(jsonrpsee::types::error::ErrorObject::owned(
9007,
format!(
"Block {hash:?} (#{number}) is above the current best block \
(#{best}); it is not on the canonical chain",
best = info.best_number,
),
None::<()>,
));
}

let canonical = client.hash(number).map_err(|e| {
jsonrpsee::types::error::ErrorObject::owned(
9006,
format!("Failed to resolve canonical hash at #{number}: {e}"),
None::<()>,
)
})?;
if canonical != Some(hash) {
return Err(jsonrpsee::types::error::ErrorObject::owned(
9008,
format!(
"Block {hash:?} (#{number}) is not on the canonical chain; proofs \
against fork state cannot be verified on-chain"
),
None::<()>,
));
}

// Compile-time constant from the linked runtime crate — see fn docs.
let window = <quantus_runtime::configs::BlockHashCount as sp_core::Get<u32>>::get();
if info.best_number.saturating_sub(number) > window {
Expand Down Expand Up @@ -207,25 +250,40 @@ mod tests {
H256::from_low_u64_be(u64::from(number) + 1)
}

/// Minimal chain view: a best block and a set of known (hash -> number) blocks.
/// Minimal chain view: a best block, the known (hash -> number) blocks the
/// backend has imported (canonical *and* side-fork), and the canonical
/// (number -> hash) index.
struct MockChain {
best_number: u32,
blocks: HashMap<H256, u32>,
/// Every imported block, like the backend's hash->number index. Includes
/// side-fork blocks, which is exactly why resolvability != canonicality.
known: HashMap<H256, u32>,
/// The canonical chain's number->hash index.
canonical: HashMap<u32, H256>,
/// Hashes for which `number()` simulates a backend/DB failure.
failing: HashSet<H256>,
}

impl MockChain {
fn with_blocks(best_number: u32, numbers: &[u32]) -> Self {
let blocks = numbers.iter().map(|n| (hash_for(*n), *n)).collect();
Self { best_number, blocks, failing: HashSet::new() }
let known = numbers.iter().map(|n| (hash_for(*n), *n)).collect();
let canonical = numbers.iter().map(|n| (*n, hash_for(*n))).collect();
Self { best_number, known, canonical, failing: HashSet::new() }
}

fn with_number_failure(best_number: u32, failing_hash: H256) -> Self {
let mut chain = Self::with_blocks(best_number, &[best_number]);
chain.failing.insert(failing_hash);
chain
}

/// Add a block the backend knows about (imported) that is NOT on the
/// canonical chain, at the given height. Returns its hash.
fn add_fork_block(&mut self, number: u32) -> H256 {
let fork_hash = H256::from_low_u64_be(0xF0_0000 + u64::from(number));
self.known.insert(fork_hash, number);
fork_hash
}
}

impl HeaderBackend<Block> for MockChain {
Expand All @@ -247,7 +305,7 @@ mod tests {
}

fn status(&self, hash: H256) -> BlockchainResult<BlockStatus> {
Ok(if self.blocks.contains_key(&hash) {
Ok(if self.known.contains_key(&hash) {
BlockStatus::InChain
} else {
BlockStatus::Unknown
Expand All @@ -258,11 +316,11 @@ mod tests {
if self.failing.contains(&hash) {
return Err(sp_blockchain::Error::Backend("simulated db failure".into()));
}
Ok(self.blocks.get(&hash).copied())
Ok(self.known.get(&hash).copied())
}

fn hash(&self, number: NumberFor<Block>) -> BlockchainResult<Option<H256>> {
Ok(self.blocks.iter().find(|(_, n)| **n == number).map(|(h, _)| *h))
Ok(self.canonical.get(&number).copied())
}
}

Expand Down Expand Up @@ -305,6 +363,44 @@ mod tests {
assert!(resolve_proof_block(&chain, Some(hash_for(ancient))).is_err());
}

/// The backend resolves a number for ANY imported block, including side-fork
/// blocks — resolvability is not canonicality. A proof generated against fork
/// state can never settle (the wormhole pallet compares the claimed hash to
/// `frame_system::BlockHash`, the canonical chain), so the RPC must reject
/// noncanonical hashes instead of burning state-execution resources on them.
#[test]
fn rejects_noncanonical_hashes_within_the_window() {
let best = 10 * window();
let fork_height = best - 5;
let mut chain = MockChain::with_blocks(best, &[best, fork_height]);
let fork_hash = chain.add_fork_block(fork_height);

let err = resolve_proof_block(&chain, Some(fork_hash))
.expect_err("side-fork hash must be rejected even inside the proof window");
assert_eq!(err.code(), 9008);

// The canonical block at the same height is still accepted.
assert_eq!(
resolve_proof_block(&chain, Some(hash_for(fork_height))).unwrap(),
hash_for(fork_height)
);
}

/// A backend-known block ABOVE the current best (e.g. from a longer side
/// fork that was imported but not chosen) makes `best_number - number`
/// saturate to 0, which the one-sided window check happily accepts. Heights
/// above best have no canonical hash and can never settle.
#[test]
fn rejects_blocks_above_the_best_number() {
let best = 10 * window();
let mut chain = MockChain::with_blocks(best, &[best]);
let ahead_hash = chain.add_fork_block(best + 5);

let err = resolve_proof_block(&chain, Some(ahead_hash))
.expect_err("block above best must be rejected");
assert_eq!(err.code(), 9007);
}

#[test]
fn rejects_unknown_block_hashes() {
let best = 10 * window();
Expand Down
30 changes: 28 additions & 2 deletions pallets/frame-system/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,12 @@ pub mod pallet {
// 65536 pages (4 GiB) is the wasm32 linear-memory hard maximum.
ensure!((64..=65536).contains(&pages), Error::<T>::InvalidHeapPages);
storage::unhashed::put_raw(well_known_keys::HEAP_PAGES, &pages.encode());
Self::deposit_log(generic::DigestItem::RuntimeEnvironmentUpdated);
// NOTE: upstream deposits `DigestItem::RuntimeEnvironmentUpdated` here. This
// fork must not: the QPoW header commits a fixed digest window that the
// pre-runtime item and seal fill exactly, so ANY runtime-deposited digest
// item makes the sealed header unimportable network-wide (see `deposit_log`).
// Nothing in the node stack consumes the item — clients detect environment
// changes from the `:heappages`/`:code` state keys, not the digest.
Ok(().into())
}

Expand Down Expand Up @@ -1622,7 +1627,14 @@ impl<T: Config> Pallet<T> {
/// the storage (for instance in case of parachains).
pub fn update_code_in_storage(code: &[u8]) {
storage::unhashed::put_raw(well_known_keys::CODE, code);
Self::deposit_log(generic::DigestItem::RuntimeEnvironmentUpdated);
// NOTE: upstream deposits `DigestItem::RuntimeEnvironmentUpdated` here. This
// fork must not: the QPoW header commits a fixed digest window that the
// pre-runtime item and seal fill exactly, so ANY runtime-deposited digest
// item makes the sealed header unimportable network-wide (see `deposit_log`).
// A runtime upgrade would then be un-includable through normal block
// production. Clients detect the new code from the `:code` state key (the
// executor's module cache is keyed by code hash); the `CodeUpdated` event
// below remains for observability.
Self::deposit_event(Event::CodeUpdated);
}

Expand Down Expand Up @@ -2102,6 +2114,20 @@ impl<T: Config> Pallet<T> {
}

/// Deposits a log and ensures it matches the block's log data.
///
/// # WARNING: the QPoW digest window has no spare capacity
///
/// `qp_header::Header::hash()` commits the digest through a fixed
/// `DIGEST_LOGS_SIZE` window that the client-injected pre-runtime item plus the
/// PoW seal fill **exactly**, and block import rejects any sealed header whose
/// encoded digest exceeds it (truncating would let distinct headers share a
/// hash). A digest item deposited from runtime code therefore does not fail the
/// call — it makes the finished block **unimportable by the entire network**,
/// silently, after mining. This is why the fork's `set_code` /
/// `set_heap_pages` paths do not deposit `RuntimeEnvironmentUpdated` the way
/// upstream does. Do not deposit digest items from runtime logic unless the
/// header format and the wormhole circuit's digest field are resized in the
/// same release.
pub fn deposit_log(item: generic::DigestItem) {
<Digest<T>>::append(item);
}
Expand Down
29 changes: 16 additions & 13 deletions pallets/frame-system/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,7 +653,8 @@ fn set_code_checks_works() {
ext.execute_with(|| {
let res = System::set_code(RawOrigin::Root.into(), vec![1, 2, 3, 4]);

assert_runtime_updated_digest(if res.is_ok() { 1 } else { 0 });
// Success or failure, no digest item may be deposited (QPoW window).
assert_no_deposited_digest_items();
assert_eq!(expected.map_err(DispatchErrorWithPostInfo::from), res);
});
}
Expand Down Expand Up @@ -755,15 +756,16 @@ fn validate_unsigned_apply_authorized_upgrade_honors_check_version() {
}
}

fn assert_runtime_updated_digest(num: usize) {
/// The QPoW header commits a fixed digest window that the pre-runtime item and
/// seal fill exactly, so a runtime-deposited digest item (like upstream's
/// `RuntimeEnvironmentUpdated`) makes the sealed block unimportable
/// network-wide. Environment-changing calls must deposit NO digest items.
fn assert_no_deposited_digest_items() {
assert_eq!(
System::digest()
.logs
.into_iter()
.filter(|item| *item == generic::DigestItem::RuntimeEnvironmentUpdated)
.count(),
num,
"Incorrect number of Runtime Updated digest items",
System::digest().logs,
alloc::vec::Vec::new(),
"runtime code must not deposit digest items: the QPoW digest window has \
no spare capacity and the sealed block would be rejected at import",
);
}

Expand Down Expand Up @@ -810,12 +812,12 @@ fn extrinsics_root_is_calculated_correctly() {
}

#[test]
fn runtime_updated_digest_emitted_when_heap_pages_changed() {
fn no_digest_item_deposited_when_heap_pages_changed() {
new_test_ext().execute_with(|| {
System::reset_events();
System::initialize(&1, &[0u8; 32].into(), &Default::default());
System::set_heap_pages(RawOrigin::Root.into(), 64).unwrap();
assert_runtime_updated_digest(1);
assert_no_deposited_digest_items();
});
}

Expand All @@ -834,10 +836,11 @@ fn set_heap_pages_validates_range() {
);
}

// Both bounds of the allowed range are accepted and still emit the digest item.
// Both bounds of the allowed range are accepted, without depositing any
// digest item (the QPoW digest window has no spare capacity).
assert_ok!(System::set_heap_pages(RawOrigin::Root.into(), 64));
assert_ok!(System::set_heap_pages(RawOrigin::Root.into(), 65536));
assert_runtime_updated_digest(2);
assert_no_deposited_digest_items();
});
}

Expand Down
7 changes: 5 additions & 2 deletions pallets/multisig/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -613,8 +613,11 @@ pub mod pallet {
// ===== PHASE 4: High-security whitelist check (if applicable) =====
// (additional read: HighSecurityAccounts)
let is_high_security = T::HighSecurity::is_high_security(&multisig_address);
// Use the shared `is_call_allowed` policy so `propose` and `execute` stay consistent.
if !T::HighSecurity::is_call_allowed(&multisig_address, &decoded_call) {
// Apply the shared call policy (the same predicate `execute` consults via
// `is_call_allowed`) using the classification already fetched above for
// weight selection, so the `HighSecurityAccounts` lookup is not repeated —
// the propose weights charge exactly one classification read.
if !T::HighSecurity::is_call_allowed_given(is_high_security, &decoded_call) {
// Don't refund after decode - same reasoning as above.
return Self::err_burn_full(Error::<T>::CallNotAllowedForHighSecurityMultisig);
}
Expand Down
8 changes: 7 additions & 1 deletion pallets/recovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,7 +433,13 @@ pub mod pallet {
#[pallet::weight({
let dispatch_info = call.get_dispatch_info();
(
T::WeightInfo::as_recovered().saturating_add(dispatch_info.call_weight),
T::WeightInfo::as_recovered()
// High-security policy check on the recovered account
// (`is_call_allowed` → one classification read in the runtime
// inspector); the benchmarked base runs with the no-op inspector
// and does not include it.
.saturating_add(T::DbWeight::get().reads(1))
.saturating_add(dispatch_info.call_weight),
dispatch_info.class,
)})]
pub fn as_recovered(
Expand Down
3 changes: 3 additions & 0 deletions pallets/recovery/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ construct_runtime!(
impl frame_system::Config for Test {
type Block = Block;
type AccountData = pallet_balances::AccountData<u128>;
// A non-zero database weight so tests can observe the db-op components of
// the weights the dispatchables charge (the prelude default is zero).
type DbWeight = frame::deps::frame_support::weights::constants::RocksDbWeight;
}

parameter_types! {
Expand Down
Loading
Loading