Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/RUNTIME_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ the runtime, their dispatchable calls, the runtime APIs, transaction extensions,
genesis logic, and the workspace primitive crates pulled in.

- **Crate:** `quantus-runtime` (`runtime/`), version `0.7.1-q-day-2`
- **Spec:** `spec_name = quantus-runtime`, `spec_version = 142`, `transaction_version = 3`, `authoring_version = 1`
- **Spec:** `spec_name = quantus-runtime`, `spec_version = 143`, `transaction_version = 3`, `authoring_version = 1`
- **Build:** `no_std` WASM via `substrate-wasm-builder` (`runtime/build.rs`); native `std` build for the node/client
- **Block time target:** 12s (`TARGET_BLOCK_TIME_MS = 12_000`)
- **Consensus:** QPoW (quantum-resistant Proof of Work, Poseidon2-based)
Expand Down Expand Up @@ -223,7 +223,7 @@ Signed-extension pipeline applied to every extrinsic, in order:
8. `pallet_transaction_payment::ChargeTransactionPayment`
9. `frame_metadata_hash_extension::CheckMetadataHash`
10. `transaction_extensions::ReversibleTransactionExtension` — **custom**: blocks non-whitelisted calls from high-security accounts.
11. `transaction_extensions::WormholeProofRecorderExtension` — **custom**: in `post_dispatch`, scans emitted native `Balances::Transfer` / `Balances::Minted` events and records transfer proofs into the ZK tree (event-based, covers direct/batch/multisig/recovery native transfers). Statically pre-charged calls (`count_transfers`): `Balances` transfers and `Utility` wrappers; uncounted paths are reconciled via `register_extra_weight_unchecked`. Transfers touching the **vesting pot** are skipped: the vesting pallet records its own payouts (covering scheduler-enacted Root calls the extension never sees) and carries that cost in its benchmarked weights.
11. `transaction_extensions::WormholeProofRecorderExtension` — **custom**: in `post_dispatch`, scans emitted native `Balances::Transfer` / `Balances::Minted` events and records transfer proofs into the ZK tree (event-based, covers direct/batch/multisig/recovery native transfers). Statically pre-charged calls (`count_transfers`): `Balances` transfers and `Utility` wrappers; uncounted paths are reconciled via `register_extra_weight_unchecked`. Transfers touching the **vesting pot** are skipped: the vesting pallet records its own payouts (covering scheduler-enacted Root calls the extension never sees) and carries that cost in its benchmarked weights. A statically-counted transfer *into* the pot is charged for a leaf insert the scan then skips — an accepted overcharge on a rare bootstrap operation. Per-transfer recording weight comes from `pallet_zk_tree::insert_leaf_weight`, the one cost model shared by every leaf-inserting call site (depth-scaled DB ops, Poseidon path hashing, and per-key PoV).

The high-security whitelist (`HighSecurityConfig::is_whitelisted`, extension 10) admits `ReversibleTransfers::{schedule_transfer, cancel, recover_funds}` and `Vesting::claim` (safe: the payout target is fixed by storage, never the caller).

Expand Down
28 changes: 18 additions & 10 deletions pallets/vesting/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,10 +335,7 @@ pub mod pallet {
ClaimPlan::TooSoon => return Err(Error::<T>::ClaimTooSoon.into()),
ClaimPlan::WouldLeaveDust => return Err(Error::<T>::ClaimWouldLeaveDust.into()),
};
Self::pay_out(&Self::pot_account_id(), &schedule.beneficiary, payable)?;
schedule.claimed =
schedule.claimed.checked_add(&payable).ok_or(ArithmeticError::Overflow)?;
schedule.last_claim_at = Some(now);
Self::settle(schedule, payable, now)?;
Self::deposit_event(Event::Claimed {
schedule_id,
beneficiary: schedule.beneficiary.clone(),
Expand Down Expand Up @@ -457,12 +454,7 @@ pub mod pallet {
let now = T::TimeProvider::now();
let vested_paid = match Self::claim_plan(schedule, now)? {
ClaimPlan::Pay(amount) => {
Self::pay_out(&Self::pot_account_id(), &schedule.beneficiary, amount)?;
schedule.claimed = schedule
.claimed
.checked_add(&amount)
.ok_or(ArithmeticError::Overflow)?;
schedule.last_claim_at = Some(now);
Self::settle(schedule, amount, now)?;
amount
},
ClaimPlan::NothingToClaim | ClaimPlan::TooSoon | ClaimPlan::WouldLeaveDust =>
Expand Down Expand Up @@ -569,6 +561,22 @@ pub mod pallet {
amount.checked_sub(&remainder).expect("remainder never exceeds the dividend")
}

/// Pay `amount` to the schedule's beneficiary and advance the schedule to match:
/// the single place a claimable payout is settled, shared by `claim` and
/// `retarget_schedule` so the two can never drift on what a payout does to
/// `claimed` and `last_claim_at`.
fn settle(
schedule: &mut VestingScheduleOf<T>,
amount: BalanceOf<T>,
now: Moment,
) -> DispatchResult {
Self::pay_out(&Self::pot_account_id(), &schedule.beneficiary, amount)?;
schedule.claimed =
schedule.claimed.checked_add(&amount).ok_or(ArithmeticError::Overflow)?;
schedule.last_claim_at = Some(now);
Ok(())
}

/// Move a payout out of the pot AND record it as a wormhole transfer proof —
/// fused into one function so no payout path can move funds without creating
/// the ZK proof material a wormhole beneficiary needs to exit.
Expand Down
41 changes: 35 additions & 6 deletions pallets/vesting/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,25 @@ pub const PINGER: AccountId32 = AccountId32::new([8u8; 32]);
pub const TREASURY: AccountId32 = AccountId32::new([9u8; 32]);
pub const TREASURY_FUNDS: Balance = 1_000_000 * UNIT;

/// Defaults of the `static` config values below. Named so the per-test reset in
/// [`reset_thread_local_state`] restores exactly what a fresh thread would start with.
const DEFAULT_EXISTENTIAL_DEPOSIT: Balance = 1_000;
const DEFAULT_PAYOUT_QUANTUM: Balance = 1_000;
const DEFAULT_MINIMUM_PAYOUT: Balance = 10_000;
const DEFAULT_MIN_CLAIM_INTERVAL: u64 = 100_000;

parameter_types! {
pub const BlockHashCount: u64 = 250;
/// `static` so individual tests can vary it via `ExistentialDeposit::set`.
pub static ExistentialDeposit: Balance = 1_000;
pub static ExistentialDeposit: Balance = DEFAULT_EXISTENTIAL_DEPOSIT;
pub const VestingPalletId: PalletId = PalletId(*b"qvesting");
/// `static` so tests can unset it to exercise `TreasuryNotConfigured`.
pub static TreasuryAccount: Option<AccountId32> = Some(TREASURY);
/// `static` so tests can vary the wormhole leaf quantum (e.g. make it coarser than
/// the ED to exercise sub-quantum rounding, or finer to exercise below-ED payouts).
pub static PayoutQuantum: Balance = 1_000;
pub static MinimumPayout: Balance = 10_000;
pub static MinClaimInterval: u64 = 100_000;
pub static PayoutQuantum: Balance = DEFAULT_PAYOUT_QUANTUM;
pub static MinimumPayout: Balance = DEFAULT_MINIMUM_PAYOUT;
pub static MinClaimInterval: u64 = DEFAULT_MIN_CLAIM_INTERVAL;
}

impl frame_system::Config for Test {
Expand Down Expand Up @@ -137,6 +144,10 @@ impl MockProofRecorder {
pub fn recorded() -> Vec<RecordedProof> {
RECORDED_PROOFS.with(|proofs| proofs.borrow().clone())
}

fn clear() {
RECORDED_PROOFS.with(|proofs| proofs.borrow_mut().clear());
}
}

impl qp_wormhole::TransferProofRecorder<AccountId32, u32, Balance> for MockProofRecorder {
Expand Down Expand Up @@ -176,16 +187,34 @@ pub fn set_time(now_ms: u64) {

pub type ScheduleTuple = (AccountId32, u64, u64, u64, u128);

/// Pot endowed with exactly `sum(totals) + ED` — the valid genesis shape.
/// Pot endowed with exactly `sum(totals) + ED` — the valid genesis shape. The ED comes
/// from the const rather than the `static`: the builder resets the statics anyway, so
/// reading the live value here would only be a chance to read a leaked one.
pub fn new_test_ext(schedules: Vec<ScheduleTuple>) -> sp_io::TestExternalities {
let sum: u128 = schedules.iter().map(|(_, _, _, _, total)| total).sum();
new_test_ext_with_pot_balance(schedules, sum + ExistentialDeposit::get())
new_test_ext_with_pot_balance(schedules, sum + DEFAULT_EXISTENTIAL_DEPOSIT)
}

/// The `pub static` config values and `RECORDED_PROOFS` live in thread-local storage,
/// and the test harness reuses worker threads across tests: without this, a value a
/// test sets (`PayoutQuantum::set(3_000)`, `TreasuryAccount::set(None)`, …) leaks into
/// whichever test the same worker runs next, and recorded-proof assertions see earlier
/// tests' entries. Every test builds its externalities through here, so resetting at
/// build time makes each one start from the documented defaults.
fn reset_thread_local_state() {
ExistentialDeposit::set(DEFAULT_EXISTENTIAL_DEPOSIT);
TreasuryAccount::set(Some(TREASURY));
PayoutQuantum::set(DEFAULT_PAYOUT_QUANTUM);
MinimumPayout::set(DEFAULT_MINIMUM_PAYOUT);
MinClaimInterval::set(DEFAULT_MIN_CLAIM_INTERVAL);
MockProofRecorder::clear();
}

pub fn new_test_ext_with_pot_balance(
schedules: Vec<ScheduleTuple>,
pot_balance: Balance,
) -> sp_io::TestExternalities {
reset_thread_local_state();
let mut t = frame_system::GenesisConfig::<Test>::default().build_storage().unwrap();

let mut balances = vec![(TREASURY, TREASURY_FUNDS), (PINGER, UNIT)];
Expand Down
102 changes: 76 additions & 26 deletions pallets/vesting/src/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,28 @@ pub trait WeightInfo {
fn retarget_schedule() -> Weight;
}

/// ZK-tree storage ops the benchmark itself performed, per the storage tables in
/// [`crate::weights_generated`]: `LeafCount` + `Depth` + 3×`Leaves` reads, and
/// `LeafCount` + `Leaves` + `Root` writes (`Depth` too once the insert grows the tree,
/// which the shallow benchmark tree does for `end_schedule`/`retarget_schedule` but
/// not for `claim`). They are subtracted back out so the live-depth insert cost can
/// replace them; `payout_weight_covers_the_benchmarked_base` pins the result.
const BENCHMARK_TREE_READS: u64 = 5;
const BENCHMARK_TREE_WRITES: u64 = 4;
const CLAIM_BENCHMARK_TREE_WRITES: u64 = 3;

/// Benchmarked base with its benchmark-depth tree ops swapped for the live-depth
/// insert cost — DB ops, Poseidon path hashing and PoV all priced by
/// [`pallet_zk_tree::insert_leaf_weight_at_depth`].
fn payout_weight(
base: Weight,
db: RuntimeDbWeight,
benchmark_tree_writes: u64,
(tree_reads, tree_writes): (u64, u64),
tree_hash_time: u64,
insert_leaf: Weight,
) -> Weight {
base.saturating_sub(db.reads(BENCHMARK_TREE_READS))
.saturating_sub(db.writes(benchmark_tree_writes))
.saturating_add(Weight::from_parts(
tree_hash_time,
tree_reads
.saturating_add(tree_writes)
.saturating_mul(pallet_zk_tree::TREE_KEY_POV),
))
.saturating_add(db.reads(tree_reads))
.saturating_add(db.writes(tree_writes))
.saturating_add(insert_leaf)
}

pub struct SubstrateWeight<T>(PhantomData<T>);
Expand All @@ -45,8 +46,7 @@ impl<T: frame_system::Config + pallet_zk_tree::Config> WeightInfo for SubstrateW
<generated::SubstrateWeight<T> as generated::WeightInfo>::claim(),
T::DbWeight::get(),
CLAIM_BENCHMARK_TREE_WRITES,
pallet_zk_tree::Pallet::<T>::insert_leaf_db_ops(),
pallet_zk_tree::Pallet::<T>::insert_leaf_hash_ref_time(),
pallet_zk_tree::Pallet::<T>::insert_leaf_weight(T::DbWeight::get()),
)
}

Expand All @@ -59,8 +59,7 @@ impl<T: frame_system::Config + pallet_zk_tree::Config> WeightInfo for SubstrateW
<generated::SubstrateWeight<T> as generated::WeightInfo>::end_schedule(),
T::DbWeight::get(),
BENCHMARK_TREE_WRITES,
pallet_zk_tree::Pallet::<T>::insert_leaf_db_ops(),
pallet_zk_tree::Pallet::<T>::insert_leaf_hash_ref_time(),
pallet_zk_tree::Pallet::<T>::insert_leaf_weight(T::DbWeight::get()),
)
}

Expand All @@ -69,20 +68,26 @@ impl<T: frame_system::Config + pallet_zk_tree::Config> WeightInfo for SubstrateW
<generated::SubstrateWeight<T> as generated::WeightInfo>::retarget_schedule(),
T::DbWeight::get(),
BENCHMARK_TREE_WRITES,
pallet_zk_tree::Pallet::<T>::insert_leaf_db_ops(),
pallet_zk_tree::Pallet::<T>::insert_leaf_hash_ref_time(),
pallet_zk_tree::Pallet::<T>::insert_leaf_weight(T::DbWeight::get()),
)
}
}

/// Worst-case insert cost for the depth-blind `()` impl.
fn max_depth_insert_leaf() -> Weight {
pallet_zk_tree::insert_leaf_weight_at_depth(
RocksDbWeight::get(),
pallet_zk_tree::MAX_TREE_DEPTH,
)
}

impl WeightInfo for () {
fn claim() -> Weight {
payout_weight(
<() as generated::WeightInfo>::claim(),
RocksDbWeight::get(),
CLAIM_BENCHMARK_TREE_WRITES,
pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
max_depth_insert_leaf(),
)
}

Expand All @@ -95,8 +100,7 @@ impl WeightInfo for () {
<() as generated::WeightInfo>::end_schedule(),
RocksDbWeight::get(),
BENCHMARK_TREE_WRITES,
pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
max_depth_insert_leaf(),
)
}

Expand All @@ -105,8 +109,7 @@ impl WeightInfo for () {
<() as generated::WeightInfo>::retarget_schedule(),
RocksDbWeight::get(),
BENCHMARK_TREE_WRITES,
pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
max_depth_insert_leaf(),
)
}
}
Expand All @@ -115,6 +118,16 @@ impl WeightInfo for () {
mod tests {
use super::*;

/// Every payout call, paired with the benchmark tree writes `payout_weight`
/// subtracts back out for it.
fn payout_bases() -> [(Weight, u64); 3] {
[
(<() as generated::WeightInfo>::claim(), CLAIM_BENCHMARK_TREE_WRITES),
(<() as generated::WeightInfo>::end_schedule(), BENCHMARK_TREE_WRITES),
(<() as generated::WeightInfo>::retarget_schedule(), BENCHMARK_TREE_WRITES),
]
}

#[test]
fn payout_ref_time_grows_with_tree_depth() {
let db = RuntimeDbWeight { read: 0, write: 0 };
Expand All @@ -123,18 +136,55 @@ mod tests {
base,
db,
BENCHMARK_TREE_WRITES,
pallet_zk_tree::insert_leaf_db_ops_at_depth(1),
pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(1),
pallet_zk_tree::insert_leaf_weight_at_depth(db, 1),
);
let deep = payout_weight(
base,
db,
BENCHMARK_TREE_WRITES,
pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(pallet_zk_tree::MAX_TREE_DEPTH),
pallet_zk_tree::insert_leaf_weight_at_depth(db, pallet_zk_tree::MAX_TREE_DEPTH),
);

assert!(deep.ref_time() > shallow.ref_time());
assert!(deep.proof_size() > shallow.proof_size());
}

/// The augmentation subtracts hand-maintained `BENCHMARK_TREE_*` counts from the
/// generated base and adds the live-depth insert back. If a zk-tree cost-model
/// change ever made a live insert cheaper than the benchmark-time ops it replaces,
/// the subtraction would silently under-charge — no compile error, no failing
/// benchmark. Pin it: at every reachable depth the augmented weight must still
/// cover the measured base.
#[test]
fn payout_weight_never_undercharges_the_benchmarked_base() {
let db = RocksDbWeight::get();
for depth in 0..=pallet_zk_tree::MAX_TREE_DEPTH {
let insert_leaf = pallet_zk_tree::insert_leaf_weight_at_depth(db, depth);
for (base, benchmark_tree_writes) in payout_bases() {
let augmented = payout_weight(base, db, benchmark_tree_writes, insert_leaf);
assert!(
augmented.all_gte(base),
"depth {depth}: augmented {augmented:?} falls below benchmarked {base:?}"
);
}
}
}

/// The depth-blind `()` impl must stay a worst-case bound on the live-depth one.
#[test]
fn unit_impl_is_the_worst_case() {
let db = RocksDbWeight::get();
for (base, benchmark_tree_writes) in payout_bases() {
let at_max = payout_weight(base, db, benchmark_tree_writes, max_depth_insert_leaf());
for depth in 0..=pallet_zk_tree::MAX_TREE_DEPTH {
let live = payout_weight(
base,
db,
benchmark_tree_writes,
pallet_zk_tree::insert_leaf_weight_at_depth(db, depth),
);
assert!(at_max.all_gte(live), "depth {depth} exceeds the max-depth bound");
}
}
}
}
12 changes: 8 additions & 4 deletions pallets/wormhole/src/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,11 +166,13 @@ impl<T: frame_system::Config + pallet_zk_tree::Config> WeightInfo for SubstrateW
/// body), each charged in full (compute + DB + PoV), plus exit-processing storage
/// and the per-exit ZK-tree Poseidon hashing (one hash per tree level per insert).
fn verify_private_batch() -> Weight {
let tree_ops = pallet_zk_tree::Pallet::<T>::insert_leaf_db_ops();
// Read the live depth once: both tree terms below are derived from it.
let depth = pallet_zk_tree::Depth::<T>::get();
let tree_ops = pallet_zk_tree::insert_leaf_db_ops_at_depth(depth);
let (reads, writes, proof_size) =
storage_tail(private_batch_max_exits(), false, tree_ops);
let hash_time = private_batch_max_exits()
.saturating_mul(pallet_zk_tree::Pallet::<T>::insert_leaf_hash_ref_time());
.saturating_mul(pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(depth));
Weight::from_parts(
PRIVATE_BATCH_ZK_VERIFY_REF_TIME_PS.saturating_add(hash_time),
proof_size,
Expand All @@ -183,10 +185,12 @@ impl<T: frame_system::Config + pallet_zk_tree::Config> WeightInfo for SubstrateW
/// Same double-prevalidation shape as [`Self::verify_private_batch`], scaled
/// across all inner segments plus the aggregator rebate.
fn verify_public_batch() -> Weight {
let tree_ops = pallet_zk_tree::Pallet::<T>::insert_leaf_db_ops();
// Read the live depth once: both tree terms below are derived from it.
let depth = pallet_zk_tree::Depth::<T>::get();
let tree_ops = pallet_zk_tree::insert_leaf_db_ops_at_depth(depth);
let (reads, writes, proof_size) = storage_tail(public_batch_max_exits(), true, tree_ops);
let hash_time = public_batch_max_exits()
.saturating_mul(pallet_zk_tree::Pallet::<T>::insert_leaf_hash_ref_time());
.saturating_mul(pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(depth));
Weight::from_parts(
PUBLIC_BATCH_ZK_VERIFY_REF_TIME_PS.saturating_add(hash_time),
proof_size,
Expand Down
Loading
Loading