From 296bcfc592712e56304e33b845fb4cb1c402aef4 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 8 Aug 2026 15:19:09 +0800 Subject: [PATCH] fix: address non-blocking vesting review findings Follow-up to #646, covering the polish items left open by the review. - One shared cost model for ZK-tree leaf inserts. `insert_leaf_weight[_at_depth]` in pallet-zk-tree composes the depth-scaled DB ops, the Poseidon path hashing and the per-key PoV, from a single `Depth` read. Vesting, reversible-transfers and the proof-recorder extension now price inserts through it instead of re-composing the parts (each of which read `Depth` again). This also gives the extension the tree PoV it was omitting. - Pin the vesting weights augmentation: it subtracts hand-maintained benchmark tree-op counts from the generated base, and a zk-tree cost-model change could silently make that an under-charge. Tests assert the augmented weight covers the benchmarked base at every depth, and that the `()` impl bounds all of them. - Reset the vesting mock's `static` config and recorded proofs per test. The harness reuses worker threads, so a `PayoutQuantum::set` leaked into whichever test ran next on the same worker. - Extract `settle`: `claim` and `retarget_schedule` duplicated the payout, `claimed` and `last_claim_at` updates. - Derive the vesting pot lazily in the event scan (it costs a Blake2b hash on every extrinsic, and most emit no `Transfer` at all). - Const-assert the two properties that keep payouts exitable: the vesting quantum equals the tree's leaf amount scale factor, and max supply stays below the leaf's u32 amount ceiling. - Deduplicate `MAX_SUPPLY`, `MILLIS_PER_DAY` and the integration tests' account helper; document the accepted inbound-pot weight overcharge. spec_version 142 -> 143. --- docs/RUNTIME_SURFACE.md | 4 +- pallets/reversible-transfers/src/weights.rs | 30 +++--- pallets/vesting/src/lib.rs | 28 ++++-- pallets/vesting/src/mock.rs | 41 ++++++-- pallets/vesting/src/weights.rs | 102 +++++++++++++++----- pallets/wormhole/src/weights.rs | 12 ++- pallets/zk-tree/src/lib.rs | 26 +++++ runtime/src/configs/mod.rs | 28 +++++- runtime/src/genesis_config_presets.rs | 6 +- runtime/src/lib.rs | 9 +- runtime/src/transaction_extensions.rs | 44 ++++++--- runtime/tests/governance/vesting.rs | 5 +- 12 files changed, 246 insertions(+), 89 deletions(-) diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index 21bf441c..ccf831b7 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -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) @@ -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). diff --git a/pallets/reversible-transfers/src/weights.rs b/pallets/reversible-transfers/src/weights.rs index a0992be4..631568a1 100644 --- a/pallets/reversible-transfers/src/weights.rs +++ b/pallets/reversible-transfers/src/weights.rs @@ -65,22 +65,13 @@ const EXECUTE_TRANSFER_BASE_WRITES: u64 = 5; /// `execute_transfer`'s weight: the benchmarked base (compute + non-tree storage) /// plus the depth-dependent ZK-tree leaf insert performed by the wormhole proof -/// recorder. `insert_leaf` walks the tree leaf-to-root, so DB ops and PoV scale -/// with `tree_ops` via [`pallet_zk_tree::TREE_KEY_POV`], and the path update also -/// computes one Poseidon hash per level (`tree_hash_time`). -fn execute_transfer_weight( - db: RuntimeDbWeight, - (tree_reads, tree_writes): (u64, u64), - tree_hash_time: u64, -) -> Weight { +/// recorder, priced by [`pallet_zk_tree::insert_leaf_weight_at_depth`]. +fn execute_transfer_weight(db: RuntimeDbWeight, insert_leaf: Weight) -> Weight { // Minimum execution time: 105_000_000 picoseconds. - Weight::from_parts(110_000_000_u64.saturating_add(tree_hash_time), 8619) - .saturating_add(Weight::from_parts( - 0, - tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), - )) - .saturating_add(db.reads(EXECUTE_TRANSFER_BASE_READS.saturating_add(tree_reads))) - .saturating_add(db.writes(EXECUTE_TRANSFER_BASE_WRITES.saturating_add(tree_writes))) + Weight::from_parts(110_000_000, 8619) + .saturating_add(db.reads(EXECUTE_TRANSFER_BASE_READS)) + .saturating_add(db.writes(EXECUTE_TRANSFER_BASE_WRITES)) + .saturating_add(insert_leaf) } /// Weights for `pallet_reversible_transfers` using the Substrate node and recommended hardware. @@ -182,8 +173,7 @@ impl WeightInfo for SubstrateW // Estimated: `8619` + tree execute_transfer_weight( T::DbWeight::get(), - pallet_zk_tree::Pallet::::insert_leaf_db_ops(), - pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + pallet_zk_tree::Pallet::::insert_leaf_weight(T::DbWeight::get()), ) } /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) @@ -316,8 +306,10 @@ impl WeightInfo for () { // Estimated: `8619` + tree execute_transfer_weight( RocksDbWeight::get(), - 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( + RocksDbWeight::get(), + pallet_zk_tree::MAX_TREE_DEPTH, + ), ) } /// Storage: `ReversibleTransfers::HighSecurityAccounts` (r:1 w:0) diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index 13b28495..1154cc60 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -335,10 +335,7 @@ pub mod pallet { ClaimPlan::TooSoon => return Err(Error::::ClaimTooSoon.into()), ClaimPlan::WouldLeaveDust => return Err(Error::::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(), @@ -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 => @@ -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, + amount: BalanceOf, + 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. diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs index afc9f880..0bb5aa89 100644 --- a/pallets/vesting/src/mock.rs +++ b/pallets/vesting/src/mock.rs @@ -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 = 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 { @@ -137,6 +144,10 @@ impl MockProofRecorder { pub fn recorded() -> Vec { RECORDED_PROOFS.with(|proofs| proofs.borrow().clone()) } + + fn clear() { + RECORDED_PROOFS.with(|proofs| proofs.borrow_mut().clear()); + } } impl qp_wormhole::TransferProofRecorder for MockProofRecorder { @@ -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) -> 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, pot_balance: Balance, ) -> sp_io::TestExternalities { + reset_thread_local_state(); let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); let mut balances = vec![(TREASURY, TREASURY_FUNDS), (PINGER, UNIT)]; diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index d20a6e4d..c2d5dca9 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -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(PhantomData); @@ -45,8 +46,7 @@ impl WeightInfo for SubstrateW as generated::WeightInfo>::claim(), T::DbWeight::get(), CLAIM_BENCHMARK_TREE_WRITES, - pallet_zk_tree::Pallet::::insert_leaf_db_ops(), - pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + pallet_zk_tree::Pallet::::insert_leaf_weight(T::DbWeight::get()), ) } @@ -59,8 +59,7 @@ impl WeightInfo for SubstrateW as generated::WeightInfo>::end_schedule(), T::DbWeight::get(), BENCHMARK_TREE_WRITES, - pallet_zk_tree::Pallet::::insert_leaf_db_ops(), - pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + pallet_zk_tree::Pallet::::insert_leaf_weight(T::DbWeight::get()), ) } @@ -69,20 +68,26 @@ impl WeightInfo for SubstrateW as generated::WeightInfo>::retarget_schedule(), T::DbWeight::get(), BENCHMARK_TREE_WRITES, - pallet_zk_tree::Pallet::::insert_leaf_db_ops(), - pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + pallet_zk_tree::Pallet::::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(), ) } @@ -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(), ) } @@ -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(), ) } } @@ -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 }; @@ -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"); + } + } + } } diff --git a/pallets/wormhole/src/weights.rs b/pallets/wormhole/src/weights.rs index 7f815b66..92398417 100644 --- a/pallets/wormhole/src/weights.rs +++ b/pallets/wormhole/src/weights.rs @@ -166,11 +166,13 @@ impl 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::::insert_leaf_db_ops(); + // Read the live depth once: both tree terms below are derived from it. + let depth = pallet_zk_tree::Depth::::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::::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, @@ -183,10 +185,12 @@ impl 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::::insert_leaf_db_ops(); + // Read the live depth once: both tree terms below are derived from it. + let depth = pallet_zk_tree::Depth::::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::::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, diff --git a/pallets/zk-tree/src/lib.rs b/pallets/zk-tree/src/lib.rs index efcee98f..4330cb94 100644 --- a/pallets/zk-tree/src/lib.rs +++ b/pallets/zk-tree/src/lib.rs @@ -29,6 +29,7 @@ extern crate alloc; use alloc::vec::Vec; +use frame_support::weights::{RuntimeDbWeight, Weight}; pub use pallet::*; @@ -106,6 +107,23 @@ pub fn insert_leaf_hash_ref_time_at_depth(depth: u8) -> u64 { /// use this for the proof-size term so all pallets share one assumption. pub const TREE_KEY_POV: u64 = 2600; +/// Complete worst-case weight of one [`Pallet::insert_leaf`] at `depth`: the storage +/// I/O ([`insert_leaf_db_ops_at_depth`]), the per-level Poseidon path hashing +/// ([`insert_leaf_hash_ref_time_at_depth`]) and the per-key PoV ([`TREE_KEY_POV`]). +/// +/// Everything that prices a leaf insert must compose it through here so one change to +/// the cost model reaches every caller — pricing an insert by hand risks charging the +/// DB ops while silently dropping the hashing or the proof size. +pub fn insert_leaf_weight_at_depth(db: RuntimeDbWeight, depth: u8) -> Weight { + let (reads, writes) = insert_leaf_db_ops_at_depth(depth); + Weight::from_parts( + insert_leaf_hash_ref_time_at_depth(depth), + reads.saturating_mul(TREE_KEY_POV), + ) + .saturating_add(db.reads(reads)) + .saturating_add(db.writes(writes)) +} + /// Branching factor of the tree. pub const ARITY: usize = 4; @@ -253,6 +271,14 @@ pub mod pallet { pub fn insert_leaf_hash_ref_time() -> u64 { crate::insert_leaf_hash_ref_time_at_depth(Depth::::get()) } + + /// [`insert_leaf_weight_at_depth`] at the tree's *current* depth, reading + /// `Depth` once. Prefer this over composing the parts by hand: each part + /// reads `Depth` again, and weight functions run on every dispatch-info + /// evaluation. + pub fn insert_leaf_weight(db: crate::RuntimeDbWeight) -> crate::Weight { + crate::insert_leaf_weight_at_depth(db, Depth::::get()) + } } impl Pallet diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 712cb168..99d135ea 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -64,7 +64,8 @@ use super::{ AccountId, AssetId, Balance, Balances, Block, BlockNumber, Hash, Nonce, OriginCaller, PalletInfo, Preimage, Runtime, RuntimeCall, RuntimeEvent, RuntimeFreezeReason, RuntimeHoldReason, RuntimeOrigin, RuntimeTask, Scheduler, System, Timestamp, Wormhole, ZkTree, - DAYS, EXISTENTIAL_DEPOSIT, MICRO_UNIT, TARGET_BLOCK_TIME_MS, UNIT, VERSION, + DAYS, EXISTENTIAL_DEPOSIT, MAX_SUPPLY, MICRO_UNIT, MILLIS_PER_DAY, TARGET_BLOCK_TIME_MS, UNIT, + VERSION, }; use sp_core::U512; @@ -139,7 +140,7 @@ impl pallet_mining_rewards::Config for Runtime { type AssetId = AssetId; type ProofRecorder = Wormhole; type WeightInfo = pallet_mining_rewards::weights::SubstrateWeight; - type MaxSupply = ConstU128<{ 21_000_000 * UNIT }>; // 21 million tokens + type MaxSupply = ConstU128<{ MAX_SUPPLY }>; type EmissionDivisor = ConstU128<15_163_560>; // Divide remaining supply by this amount type Treasury = pallet_treasury::Pallet; type MintingAccount = MintingAccount; @@ -536,8 +537,27 @@ parameter_types! { /// One QUAN keeps every payout above the existential deposit and the Wormhole /// circuit's fee-consuming minimum. pub const VestingMinimumPayout: Balance = UNIT; - pub const VestingMinClaimInterval: u64 = 24 * 60 * 60 * 1000; -} + pub const VestingMinClaimInterval: u64 = MILLIS_PER_DAY; +} + +/// The quantum above is anchored to the wormhole pallet's constant, but the value that +/// actually decides whether a leaf is non-zero is the ZK tree's. They are the same +/// number today; if they ever diverge, sub-quantum payouts would round to zero-value +/// leaves and strand funds on keyless beneficiaries. +const _: () = assert!( + pallet_wormhole::SCALE_DOWN_FACTOR == pallet_zk_tree::tree::AMOUNT_SCALE_DOWN_FACTOR, + "vesting payout quantum must match the ZK tree's leaf amount scale factor" +); + +/// A ZK leaf commits `amount / AMOUNT_SCALE_DOWN_FACTOR` as a `u32`, saturating at +/// `u32::MAX`. A payout past that ceiling would move real funds while committing a +/// clamped leaf, leaving the excess unexitable for a keyless beneficiary. Nothing in +/// the runtime bounds a single vesting payout below the ceiling — total issuance does: +/// no payout can exceed the maximum supply. +const _: () = assert!( + MAX_SUPPLY < (u32::MAX as Balance) * pallet_zk_tree::tree::AMOUNT_SCALE_DOWN_FACTOR, + "a single payout could exceed the ZK leaf's u32 amount ceiling" +); /// The configured treasury account as an `Option` — unlike /// `pallet_treasury::Pallet::account_id()`, this never panics on a chain whose diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 3df99991..7e4f6ce1 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -18,7 +18,9 @@ // this module is used by the client, so it's ok to panic/unwrap here #![allow(clippy::expect_used)] -use crate::{AccountId, BalancesConfig, RuntimeGenesisConfig, EXISTENTIAL_DEPOSIT, UNIT}; +use crate::{ + AccountId, BalancesConfig, RuntimeGenesisConfig, EXISTENTIAL_DEPOSIT, MILLIS_PER_DAY, UNIT, +}; use alloc::{ string::{String, ToString}, vec, @@ -61,8 +63,6 @@ type VestingMoment = u64; /// One vesting genesis entry: `(beneficiary, start_ms, cliff_ms, end_ms, total)`. type VestingScheduleTuple = (AccountId, VestingMoment, VestingMoment, VestingMoment, u128); -const MILLIS_PER_DAY: VestingMoment = 24 * 60 * 60 * 1000; - const fn days_ms(days: u64) -> VestingMoment { days * MILLIS_PER_DAY } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 5efd77d8..2a92701e 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -76,7 +76,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion { // `spec_version`, and `authoring_version` are the same between Wasm and native. // This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use // the compatible custom types. - spec_version: 142, + spec_version: 143, impl_version: 1, apis: apis::RUNTIME_API_VERSIONS, transaction_version: 3, @@ -101,6 +101,13 @@ pub const MICRO_UNIT: Balance = 1_000_000; /// Existential deposit. pub const EXISTENTIAL_DEPOSIT: Balance = MILLI_UNIT; +/// Hard cap on total issuance; mining emissions stop here. +pub const MAX_SUPPLY: Balance = 21_000_000 * UNIT; + +/// Wall-clock day in milliseconds — the unit vesting schedules and claim cadence are +/// expressed in (`pallet_timestamp` moments, not block numbers). +pub const MILLIS_PER_DAY: u64 = 24 * 60 * 60 * 1000; + /// Alias to 512-bit hash when used in the context of a transaction signature on the chain. // pub type Signature = MultiSignature; pub type Signature = DilithiumSignatureScheme; diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index 0b6135a3..c7f609b2 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -133,16 +133,15 @@ impl WormholeProofRecorderExtension /// Weight charged per recorded transfer proof. /// /// Per recorded transfer, `record_transfer` touches one `TransferCount` read and one - /// write, plus the ZK-tree leaf insert, whose path update walks the tree leaf-to-root - /// and therefore costs reads/writes *and* one Poseidon hash per level, both - /// proportional to the *current* tree depth (read from storage here, so the charge - /// tracks the tree as it deepens over the chain's life). + /// write, plus the ZK-tree leaf insert. The insert is priced by + /// `pallet_zk_tree::insert_leaf_weight`, the single cost model every leaf-inserting + /// call site shares: it charges the depth-proportional DB ops, the per-level + /// Poseidon hashing and the per-key PoV from one `Depth` read, so the charge tracks + /// the tree as it deepens over the chain's life. fn per_transfer_weight() -> Weight { - let (tree_reads, tree_writes) = pallet_zk_tree::Pallet::::insert_leaf_db_ops(); - let hash_time = pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(); - T::DbWeight::get() - .reads_writes(1u64.saturating_add(tree_reads), 1u64.saturating_add(tree_writes)) - .saturating_add(Weight::from_parts(hash_time, 0)) + T::DbWeight::get().reads_writes(1, 1).saturating_add( + pallet_zk_tree::Pallet::::insert_leaf_weight(T::DbWeight::get()), + ) } /// Worst-case `ref_time` (picoseconds) to stream-decode one `EventRecord` in @@ -209,6 +208,13 @@ impl WormholeProofRecorderExtension // and carries the recording cost in its own benchmarked weights, while the // event scan below skips every pot-touching transfer. Counting them here // would charge twice for work this extension never performs. + // + // The converse — a plain `Balances` transfer whose *destination* is the + // vesting pot (endowing it with its existential-deposit buffer) — is + // charged for a leaf insert the scan then skips. That overcharge is + // accepted: resolving the destination here would mean a `Lookup` on the + // hottest call in the runtime to spare a handful of one-off bootstrap + // transfers, and the direction is conservative. _ => 0, } } @@ -237,7 +243,10 @@ impl WormholeProofRecorderExtension // inbound/refund legs (treasury <-> pot) need no leaves — the pot is a keyless // pallet account and the treasury spends by signature, so neither can ever // exit through the wormhole. - let vesting_pot = pallet_vesting::Pallet::::pot_account_id(); + // + // Derived lazily: it costs a Blake2b hash and the overwhelming majority of + // extrinsics emit no `Transfer` event at all. + let mut vesting_pot: Option = None; // Collect transfers to record - (asset_id, from, to, amount) let transfers_to_record: alloc::vec::Vec<(Option, AccountId, AccountId, Balance)> = @@ -250,7 +259,12 @@ impl WormholeProofRecorderExtension from, to, amount, - }) if from != vesting_pot && to != vesting_pot => Some((None, from, to, amount)), + }) => { + let pot = vesting_pot.get_or_insert_with( + pallet_vesting::Pallet::::pot_account_id, + ); + (&from != pot && &to != pot).then_some((None, from, to, amount)) + }, // Native balance mints RuntimeEvent::Balances(pallet_balances::Event::Minted { who, amount }) => { let minting_account = crate::configs::MintingAccount::get(); @@ -942,6 +956,14 @@ mod tests { "per-transfer weight must charge the leaf insert's Poseidon hashing \ on top of its DB ops" ); + // The leaf insert's path update also puts every tree key it reads into the + // PoV; a recorded transfer that declares no proof size lets deep-tree + // blocks exceed the PoV budget validators re-execute against. + assert_eq!( + weight.proof_size(), + tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), + "per-transfer weight must charge PoV for the tree keys the insert reads" + ); }); } diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index aff2c74a..4da000fb 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -4,6 +4,7 @@ #[cfg(test)] mod tests { + use crate::common::TestCommons; use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::Currency}; use pallet_multisig::BoundedCallOf; @@ -21,9 +22,7 @@ mod tests { const GRANT: Balance = 100 * UNIT; fn account(id: u8) -> AccountId32 { - let mut bytes = [0u8; 32]; - bytes[0] = id; - AccountId32::new(bytes) + TestCommons::account_id(id) } fn signers() -> Vec {