From a86b796afd8710a742780d5a720b714207a66c58 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 7 Aug 2026 15:52:28 +0800 Subject: [PATCH 1/6] feat: add pull-based vesting pallet Add pallet-vesting (runtime index 22): a pallet-owned pot endowed at genesis holds the entire vesting allocation and pays beneficiaries by plain keep-alive transfers at claim time. No locks, freezes, or holds ever touch a beneficiary account, so wormhole addresses can be beneficiaries and the cancel-and-repurchase double-spend of the earlier lock-based draft is impossible by construction. - Schedules keyed by sequential u64 ids (any number per account): {beneficiary, start, cliff, end, total, claimed} in wall-clock ms; linear vesting with cliff, 256-bit exact math, floor rounding with exactness at end. - claim(schedule_id) is permissionless; the payout always goes to the stored beneficiary. This is the only claim path for keyless wormhole addresses and high-security accounts (claim is HS-whitelisted). - Admin (treasury account via EnsureTreasury, Root as break-glass): create_schedule funds the pot from the treasury atomically, end_schedule pays unpaid vested to the beneficiary and returns the unvested remainder to the treasury, retarget_schedule recovers lost keys. - Genesis presets endow the pot with sum(totals) + ED (ED buffer even with an empty table, as on planck) and keep the keyless pot out of the wormhole endowment list; genesis build panics on any mismatch. - Wormhole proof-recorder extension statically pre-charges vesting payout transfers; claim payouts are recorded into the ZK tree like any other transfer. - Benchmarked weights, 44 pallet tests, runtime preset build tests, and integration tests covering the real treasury-multisig admin flow. spec_version 141 -> 142. Note: the genesis pre-mine raises total issuance, reducing every future block reward by sum(totals) / EmissionDivisor. The mainnet genesis preset (4-of-6 treasury multisig) ships in a separate PR. --- Cargo.lock | 18 + Cargo.toml | 2 + docs/RUNTIME_SURFACE.md | 22 +- pallets/vesting/Cargo.toml | 59 +++ pallets/vesting/src/benchmarking.rs | 129 ++++++ pallets/vesting/src/lib.rs | 450 +++++++++++++++++++ pallets/vesting/src/mock.rs | 166 +++++++ pallets/vesting/src/tests.rs | 620 ++++++++++++++++++++++++++ pallets/vesting/src/weights.rs | 186 ++++++++ runtime/Cargo.toml | 4 + runtime/src/benchmarks.rs | 1 + runtime/src/configs/mod.rs | 57 ++- runtime/src/genesis_config_presets.rs | 200 ++++++++- runtime/src/lib.rs | 5 +- runtime/src/transaction_extensions.rs | 37 ++ runtime/tests/governance/mod.rs | 1 + runtime/tests/governance/vesting.rs | 223 +++++++++ scripts/regenerate_weights.sh | 1 + 18 files changed, 2163 insertions(+), 18 deletions(-) create mode 100644 pallets/vesting/Cargo.toml create mode 100644 pallets/vesting/src/benchmarking.rs create mode 100644 pallets/vesting/src/lib.rs create mode 100644 pallets/vesting/src/mock.rs create mode 100644 pallets/vesting/src/tests.rs create mode 100644 pallets/vesting/src/weights.rs create mode 100644 runtime/tests/governance/vesting.rs diff --git a/Cargo.lock b/Cargo.lock index 42370a7f3..294475e2b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6234,6 +6234,23 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-vesting" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-timestamp", + "parity-scale-codec", + "scale-info", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-wormhole" version = "0.1.0" @@ -7811,6 +7828,7 @@ dependencies = [ "pallet-transaction-payment-rpc-runtime-api", "pallet-treasury", "pallet-utility", + "pallet-vesting", "pallet-wormhole", "pallet-zk-tree", "parity-scale-codec", diff --git a/Cargo.toml b/Cargo.toml index 7deb23311..a3fb6d782 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ members = [ "pallets/transaction-payment-rpc-runtime-api", "pallets/treasury", "pallets/utility", + "pallets/vesting", "pallets/wormhole", "pallets/zk-tree", "primitives/consensus/qpow", @@ -273,6 +274,7 @@ pallet-transaction-payment-rpc = { path = "./pallets/transaction-payment-rpc", d pallet-transaction-payment-rpc-runtime-api = { path = "./pallets/transaction-payment-rpc-runtime-api", default-features = false } pallet-treasury = { path = "pallets/treasury", default-features = false } pallet-utility = { path = "./pallets/utility", default-features = false } +pallet-vesting = { path = "./pallets/vesting", default-features = false } prometheus-endpoint = { version = "0.17.7", default-features = false, package = "substrate-prometheus-endpoint" } sc-basic-authorship = { version = "0.53.0", default-features = false } sc-block-builder = { version = "0.48.0", default-features = true } diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index 85500d46d..e6dff13f6 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 = 139`, `transaction_version = 3`, `authoring_version = 1` +- **Spec:** `spec_name = quantus-runtime`, `spec_version = 142`, `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) @@ -70,9 +70,9 @@ The runtime derives `RuntimeCall`, `RuntimeEvent`, `RuntimeError`, `RuntimeOrigi | 7 | `Preimage` | `pallet-preimage` `45.0.0` | **Inlined** (`pallets/preimage`) | yes | | 8 | `Scheduler` | `pallet-scheduler` | **Local fork** (`pallets/scheduler`) | **calls disabled** (`#[runtime::disable_call]`) | | 9 | `Utility` | `pallet-utility` `45.0.0` | **Inlined** (`pallets/utility`) | yes | -| 10 | `Referenda` | `pallet-referenda` `45.0.0` | **Inlined** (`pallets/referenda`) | yes | +| 10 | — | *(vacant; was community `Referenda`)* | — | — | | 11 | `ReversibleTransfers` | `pallet-reversible-transfers` | **Local** (`pallets/reversible-transfers`) | yes | -| 12 | `ConvictionVoting` | `pallet-conviction-voting` `45.0.0` | **Inlined** (`pallets/conviction-voting`) | yes | +| 12 | — | *(vacant; was `ConvictionVoting`)* | — | — | | 13 | `TechCollective` | `pallet-ranked-collective` `45.0.0` | **Inlined** (`pallets/ranked-collective`) | yes | | 14 | `TechReferenda` | `pallet-referenda::Pallet` `45.0.0` | **Inlined** (2nd instance) | yes | | 15 | `TreasuryPallet` | `pallet-treasury` | **Local** (`pallets/treasury`) | yes | @@ -82,8 +82,9 @@ The runtime derives `RuntimeCall`, `RuntimeEvent`, `RuntimeError`, `RuntimeOrigi | 19 | `Multisig` | `pallet-multisig` | **Local** (`pallets/multisig`) | yes | | 20 | `Wormhole` | `pallet-wormhole` | **Local** (`pallets/wormhole`) | yes | | 21 | `ZkTree` | `pallet-zk-tree` | **Local** (`pallets/zk-tree`) | no | +| 22 | `Vesting` | `pallet-vesting` | **Local** (`pallets/vesting`) | yes | -> Indices 4, 17, and 18 are intentionally left vacant after pallet removals so downstream indices stay stable. +> Indices 4, 10, 12, 17, and 18 are intentionally left vacant after pallet removals so downstream indices stay stable. --- @@ -175,6 +176,14 @@ All `Config` impls live in `runtime/src/configs/mod.rs` unless noted. - **Storage:** `Leaves`, `Nodes`, `LeafCount`, `Depth`, `Root`. Types `ZkLeaf`, `ZkMerkleProof`, `ZkMerkleProofRpc`, `Hash256`. - `on_finalize` commits the merkle root. Backs the `ZkTreeApi` runtime API. +### Index 22 — `Vesting` (`pallet-vesting`, local) +- Pull-based "vesting wallet": the pallet's sovereign pot (`PalletId(*b"qvesting")`, keyless) holds the entire unclaimed allocation, endowed at genesis with `Σ schedule totals + ED`; beneficiaries are paid by plain keep-alive transfers only at claim time. **No locks, freezes, or holds ever touch a beneficiary account**, so wormhole addresses can be beneficiaries. +- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EitherOfDiverse` (`EnsureTreasury` = signed by the configured treasury account; the treasury multisig executes proposals as a plain signed origin), `TreasuryAccount = TreasuryAccountOption` (Option-returning storage read, never panics). +- **Storage:** `Schedules: schedule_id (u64) → { beneficiary, start, cliff, end, total, claimed }` (ids sequential, never reused; a beneficiary may hold any number of schedules), `NextScheduleId`. +- Vesting math: `vested(t) = 0` before `cliff`, `total` from `end`, else `⌊total·(t−start)/(end−start)⌋` (256-bit rational, floor; the `end` branch guarantees exactness). +- **Calls:** `claim`(0) — **permissionless**; pays `vested − claimed` from the pot to the schedule's stored beneficiary (never the caller); the only claim path for keyless/high-security beneficiaries. `create_schedule`(1) — admin; funds the pot from the treasury in the same call. `end_schedule`(2) — admin; unpaid vested part → beneficiary, unvested remainder → treasury, schedule removed. `retarget_schedule`(3) — admin; changes the beneficiary key only (lost-key remedy). +- Genesis build validates every schedule (`start ≤ cliff ≤ end`, `start < end`, `total ≥ ED`, beneficiary ≠ pot) and asserts the pot's endowment exactly; a misconfigured chain refuses to start. `try_state` checks `pot balance ≥ Σ(total − claimed) + ED`. + --- ## 4. Runtime APIs (`apis.rs`, `impl_runtime_apis!`) @@ -212,7 +221,9 @@ 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/scheduled native transfers). +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/scheduled native transfers). Statically pre-charged calls (`count_transfers`): `Balances` transfers, `Utility` wrappers, and `Vesting::{claim, create_schedule}` (1 transfer) / `Vesting::end_schedule` (2, worst case); uncounted paths are reconciled via `register_extra_weight_unchecked`. + +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). --- @@ -234,6 +245,7 @@ Signed-extension pipeline applied to every extrinsic, in order: - `dev` — local development. - `heisenberg` — **internal integration testnet**, not mainnet. Tokens have no monetary value; the network may be reset. - `planck` — public testnet (live treasury signers + faucet). +- **Vesting genesis:** every preset endows the vesting pot with `Σ schedule totals + ED` (ED alone when the table is empty, as on `planck`) and keeps the keyless pot **out** of the wormhole endowment list. `dev`/`heisenberg` seed example schedules (one account with two schedules; `dev` also vests the keyless test wormhole address, claimable only via third-party ping). A mainnet preset (4-of-6 treasury multisig, launch-gated allocation table) is planned as a separate PR. - Dilithium well-known accounts: `crystal_alice`, `dilithium_bob`, `crystal_charlie` (public seeds `[0]` / `[1]` / `[2]`). Used by `dev` and **intentionally also by `heisenberg`** so integrators and CI can exercise governance, treasury, and transfer flows without distributing secrets. Those private keys are public by design; do **not** reuse this pattern on a mainnet or any value-bearing chain (Planck already uses distinct live treasury signers). - Treasury = 2-of-3 multisig of the three signers for `dev`/`heisenberg` (distinct nonce per preset); no genesis endowment (funded from mining-reward share only). - Tech-collective seeded via the chain-spec-only `tech_collective_seed_members` JSON field (`prepare_genesis_build_input` + `seed_tech_collective`). diff --git a/pallets/vesting/Cargo.toml b/pallets/vesting/Cargo.toml new file mode 100644 index 000000000..172d3bceb --- /dev/null +++ b/pallets/vesting/Cargo.toml @@ -0,0 +1,59 @@ +[package] +authors.workspace = true +description = "Pull-based vesting: a pallet-owned pot funded at genesis pays out vested amounts on claim" +edition.workspace = true +homepage.workspace = true +license = "Apache-2.0" +name = "pallet-vesting" +publish = false +repository.workspace = true +version = "0.1.0" + +[package.metadata.docs.rs] +targets = [ + "aarch64-apple-darwin", + "wasm32-unknown-unknown", + "x86_64-unknown-linux-gnu", +] + +[dependencies] +codec = { workspace = true, default-features = false, features = ["derive"] } +frame-benchmarking = { optional = true, workspace = true, default-features = false } +frame-support.workspace = true +frame-system.workspace = true +pallet-timestamp = { optional = true, workspace = true } +scale-info = { workspace = true, default-features = false, features = ["derive"] } +sp-arithmetic.workspace = true +sp-runtime.workspace = true + +[dev-dependencies] +pallet-balances.features = ["std"] +pallet-balances.workspace = true +pallet-timestamp.features = ["std"] +pallet-timestamp.workspace = true +sp-core.workspace = true +sp-io.workspace = true + +[features] +default = ["std"] +runtime-benchmarks = [ + "dep:pallet-timestamp", + "frame-benchmarking", + "frame-support/runtime-benchmarks", + "frame-system/runtime-benchmarks", + "pallet-timestamp?/runtime-benchmarks", +] +std = [ + "codec/std", + "frame-benchmarking?/std", + "frame-support/std", + "frame-system/std", + "pallet-timestamp?/std", + "scale-info/std", + "sp-arithmetic/std", + "sp-runtime/std", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", +] diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs new file mode 100644 index 000000000..d0a19d501 --- /dev/null +++ b/pallets/vesting/src/benchmarking.rs @@ -0,0 +1,129 @@ +//! Benchmarking setup for pallet-vesting. + +use super::*; +use crate::pallet::{BalanceOf, NextScheduleId, Pallet as Vesting, Schedules, VestingSchedule}; +use frame_benchmarking::v2::*; +use frame_support::traits::{ + fungible::{Inspect, Mutate}, + EnsureOrigin, Get, +}; +use frame_system::RawOrigin; +use sp_runtime::traits::{Saturating, Zero}; + +fn set_time>(now_ms: u64) { + pallet_timestamp::Now::::put(now_ms); +} + +const START: u64 = 0; +const CLIFF: u64 = 0; +const END: u64 = 1_000_000; + +fn fund(who: &T::AccountId, amount: BalanceOf) { + T::Currency::mint_into(who, amount).expect("minting benchmark funds must succeed"); +} + +fn admin_origin() -> Result { + T::AdminOrigin::try_successful_origin().map_err(|_| BenchmarkError::Stop("no admin origin")) +} + +fn treasury() -> Result { + T::TreasuryAccount::get().ok_or(BenchmarkError::Stop("treasury not configured")) +} + +/// Insert a schedule directly, with the pot funded to cover it plus its ED buffer. +fn seed_schedule(beneficiary: T::AccountId, total: BalanceOf) -> u64 { + let schedule_id = NextScheduleId::::get(); + NextScheduleId::::put(schedule_id + 1); + Schedules::::insert( + schedule_id, + VestingSchedule { + beneficiary, + start: START, + cliff: CLIFF, + end: END, + total, + claimed: Zero::zero(), + }, + ); + fund::( + &Vesting::::pot_account_id(), + total.saturating_add(T::Currency::minimum_balance()), + ); + schedule_id +} + +#[benchmarks(where T: pallet_timestamp::Config)] +mod benchmarks { + use super::*; + + #[benchmark] + fn claim() -> Result<(), BenchmarkError> { + let beneficiary: T::AccountId = account("beneficiary", 0, 0); + let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let schedule_id = seed_schedule::(beneficiary.clone(), total); + set_time::(END); + let caller: T::AccountId = whitelisted_caller(); + + #[extrinsic_call] + _(RawOrigin::Signed(caller), schedule_id); + + assert_eq!(T::Currency::balance(&beneficiary), total); + Ok(()) + } + + #[benchmark] + fn create_schedule() -> Result<(), BenchmarkError> { + let origin = admin_origin::()?; + let treasury = treasury::()?; + let ed = T::Currency::minimum_balance(); + let total = ed.saturating_mul(1000u32.into()); + fund::(&treasury, total.saturating_mul(2u32.into())); + fund::(&Vesting::::pot_account_id(), ed); + let beneficiary: T::AccountId = account("beneficiary", 0, 0); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, beneficiary.clone(), START, CLIFF, END, total); + + assert!(Schedules::::iter().any(|(_, s)| s.beneficiary == beneficiary)); + Ok(()) + } + + #[benchmark] + fn end_schedule() -> Result<(), BenchmarkError> { + let origin = admin_origin::()?; + let treasury = treasury::()?; + fund::(&treasury, T::Currency::minimum_balance()); + let beneficiary: T::AccountId = account("beneficiary", 0, 0); + let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let schedule_id = seed_schedule::(beneficiary.clone(), total); + // Mid-vesting: both the beneficiary payout and the treasury refund execute. + set_time::(END / 2); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, schedule_id); + + assert!(Schedules::::get(schedule_id).is_none()); + assert!(!T::Currency::balance(&beneficiary).is_zero()); + Ok(()) + } + + #[benchmark] + fn retarget_schedule() -> Result<(), BenchmarkError> { + let origin = admin_origin::()?; + let beneficiary: T::AccountId = account("beneficiary", 0, 0); + let new_beneficiary: T::AccountId = account("new-beneficiary", 0, 0); + let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let schedule_id = seed_schedule::(beneficiary, total); + + #[extrinsic_call] + _(origin as T::RuntimeOrigin, schedule_id, new_beneficiary.clone()); + + assert_eq!( + Schedules::::get(schedule_id).expect("schedule persists").beneficiary, + new_beneficiary + ); + Ok(()) + } + + impl_benchmark_test_suite!(Vesting, crate::mock::new_test_ext(Vec::new()), crate::mock::Test); +} diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs new file mode 100644 index 000000000..e33b91c22 --- /dev/null +++ b/pallets/vesting/src/lib.rs @@ -0,0 +1,450 @@ +#![cfg_attr(not(feature = "std"), no_std)] + +//! # Vesting Pallet (pull-based) +//! +//! A minimal "vesting wallet": the pallet's sovereign account (the **pot**) holds the entire +//! unclaimed vesting allocation, endowed at genesis, and beneficiaries are paid by plain +//! transfers only at claim time. No locks, freezes, or holds ever touch a beneficiary account, +//! so any address — including a keyless wormhole address — can be a beneficiary. +//! +//! Each schedule has a globally unique `u64` id; an account may hold any number of schedules. +//! Vesting is linear between `start` and `end` with nothing claimable before `cliff` +//! (all timestamps are milliseconds since the unix epoch, read from `pallet_timestamp`). +//! +//! `claim` is deliberately permissionless: wormhole addresses can never sign and +//! high-security accounts are call-whitelisted, so for both a third-party "ping" is the +//! only claim path. The payout always goes to the stored beneficiary, never the caller. +//! +//! The admin origin (the treasury account, with Root as break-glass) can create schedules +//! (funded from the treasury in the same call), end them early (vested part to the +//! beneficiary, unvested remainder back to the treasury), and retarget a schedule's +//! beneficiary (lost-key remedy). + +extern crate alloc; + +pub use pallet::*; + +#[cfg(test)] +mod mock; + +#[cfg(test)] +mod tests; + +#[cfg(feature = "runtime-benchmarks")] +mod benchmarking; +pub mod weights; +pub use weights::*; + +#[frame_support::pallet] +pub mod pallet { + use super::*; + use alloc::vec::Vec; + use frame_support::{ + pallet_prelude::*, + traits::{ + fungible::{Inspect, Mutate}, + tokens::Preservation, + Time, + }, + PalletId, + }; + use frame_system::pallet_prelude::*; + use sp_arithmetic::{helpers_128bit::multiply_by_rational_with_rounding, Rounding}; + use sp_runtime::{ + traits::{AccountIdConversion, CheckedAdd, Saturating, Zero}, + ArithmeticError, SaturatedConversion, + }; + + pub(crate) type BalanceOf = + <::Currency as Inspect<::AccountId>>::Balance; + pub type VestingScheduleOf = + VestingSchedule<::AccountId, BalanceOf>; + + /// Milliseconds since the unix epoch, as reported by `pallet_timestamp`. + pub type Moment = u64; + + /// A single vesting grant. `claimed` only ever grows and never exceeds `total`. + #[derive(Encode, Decode, MaxEncodedLen, Clone, TypeInfo, Debug, PartialEq, Eq)] + pub struct VestingSchedule { + /// Account the pot pays out to. Admin-retargetable (lost-key remedy). + pub beneficiary: AccountId, + /// When linear accrual starts (ms since unix epoch). + pub start: Moment, + /// Before this moment nothing is claimable; at it, the amount accrued since + /// `start` unlocks at once. `start <= cliff <= end`. + pub cliff: Moment, + /// When the full `total` is vested. `start < end`. + pub end: Moment, + /// Total grant size. + pub total: Balance, + /// Already paid out. + pub claimed: Balance, + } + + /// The in-code storage version. + const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); + + #[pallet::pallet] + #[pallet::storage_version(STORAGE_VERSION)] + pub struct Pallet(_); + + #[pallet::config] + pub trait Config: frame_system::Config { + /// The native currency. Payouts are plain transfers — never locks/holds/freezes. + type Currency: Inspect + Mutate; + + /// Wall-clock source (`pallet_timestamp`), milliseconds since the unix epoch. + type TimeProvider: Time; + + /// Derives the pot's sovereign account. + #[pallet::constant] + type PalletId: Get; + + /// Origin allowed to create, end, and retarget schedules + /// (Root or signed-by-treasury in the runtime). + type AdminOrigin: EnsureOrigin; + + /// The configured treasury account: funding source for `create_schedule` and + /// destination for unvested remainders. `None` if the chain was started without + /// a treasury, in which case admin calls fail loudly. + type TreasuryAccount: Get>; + + /// Weight information for extrinsics in this pallet. + type WeightInfo: WeightInfo; + } + + /// Next schedule id to assign. Ids are sequential and never reused. + #[pallet::storage] + pub type NextScheduleId = StorageValue<_, u64, ValueQuery>; + + /// All vesting schedules by id. A beneficiary may appear in any number of entries. + #[pallet::storage] + pub type Schedules = StorageMap<_, Twox64Concat, u64, VestingScheduleOf>; + + #[pallet::event] + #[pallet::generate_deposit(pub(super) fn deposit_event)] + pub enum Event { + /// A new schedule was created and the pot funded from the treasury. + ScheduleCreated { + schedule_id: u64, + beneficiary: T::AccountId, + start: Moment, + cliff: Moment, + end: Moment, + total: BalanceOf, + }, + /// Vested funds were paid out to the beneficiary. + Claimed { schedule_id: u64, beneficiary: T::AccountId, amount: BalanceOf }, + /// A schedule was ended early: unpaid vested part to the beneficiary, + /// unvested remainder back to the treasury. + ScheduleEnded { + schedule_id: u64, + beneficiary: T::AccountId, + vested_paid: BalanceOf, + unvested_returned: BalanceOf, + }, + /// A schedule's beneficiary was changed. + ScheduleRetargeted { + schedule_id: u64, + old_beneficiary: T::AccountId, + new_beneficiary: T::AccountId, + }, + } + + #[pallet::error] + pub enum Error { + /// No schedule exists under this id. + NoSchedule, + /// Schedule parameters violate `start <= cliff <= end`, `start < end`, or + /// `total >= existential deposit`. + InvalidSchedule, + /// Nothing is claimable right now (before cliff, or already fully claimed). + NothingToClaim, + /// The treasury account is not configured on this chain. + TreasuryNotConfigured, + /// The pot does not hold its existential-deposit buffer; endow it first. + PotUnderfunded, + /// The beneficiary must not be the pot, and retargeting must change the account. + InvalidBeneficiary, + } + + #[pallet::genesis_config] + #[derive(frame_support::DefaultNoBound)] + pub struct GenesisConfig { + /// `(beneficiary, start_ms, cliff_ms, end_ms, total)`; ids are assigned + /// sequentially from 0 in list order. The pot must be endowed (via the balances + /// genesis) with exactly the sum of totals plus the existential deposit. + pub schedules: Vec<(T::AccountId, Moment, Moment, Moment, u128)>, + } + + #[pallet::genesis_build] + impl BuildGenesisConfig for GenesisConfig { + fn build(&self) { + if self.schedules.is_empty() { + return; + } + let pot = Pallet::::pot_account_id(); + let ed = T::Currency::minimum_balance(); + let mut sum: BalanceOf = Zero::zero(); + for (i, (beneficiary, start, cliff, end, total)) in self.schedules.iter().enumerate() { + let total: BalanceOf = (*total) + .try_into() + .ok() + .expect("vesting genesis: total does not fit the Balance type"); + assert!( + Pallet::::schedule_is_valid(*start, *cliff, *end, total), + "vesting genesis: invalid schedule at index {i}" + ); + assert!( + beneficiary != &pot, + "vesting genesis: the pot cannot be a beneficiary (index {i})" + ); + sum = sum + .checked_add(&total) + .expect("vesting genesis: sum of totals overflows Balance"); + Schedules::::insert( + i as u64, + VestingSchedule { + beneficiary: beneficiary.clone(), + start: *start, + cliff: *cliff, + end: *end, + total, + claimed: Zero::zero(), + }, + ); + } + NextScheduleId::::put(self.schedules.len() as u64); + assert!( + T::Currency::total_balance(&pot) == sum.saturating_add(ed), + "vesting genesis: pot balance must equal sum of schedule totals plus the \ + existential deposit" + ); + } + } + + #[pallet::hooks] + impl Hooks> for Pallet { + #[cfg(feature = "try-runtime")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + Self::do_try_state() + } + } + + #[pallet::call] + impl Pallet { + /// Pay out everything currently claimable on `schedule_id` to its beneficiary. + /// + /// Permissionless: any signed account may call this for any schedule; the payout + /// always goes to the stored beneficiary. This is the only claim path for + /// beneficiaries that cannot sign (wormhole addresses, high-security accounts). + #[pallet::call_index(0)] + #[pallet::weight(T::WeightInfo::claim())] + pub fn claim(origin: OriginFor, schedule_id: u64) -> DispatchResult { + ensure_signed(origin)?; + Schedules::::try_mutate(schedule_id, |maybe_schedule| { + let schedule = maybe_schedule.as_mut().ok_or(Error::::NoSchedule)?; + let vested = Self::vested_amount(schedule, T::TimeProvider::now()); + let owed = vested.saturating_sub(schedule.claimed); + ensure!(!owed.is_zero(), Error::::NothingToClaim); + T::Currency::transfer( + &Self::pot_account_id(), + &schedule.beneficiary, + owed, + Preservation::Preserve, + )?; + schedule.claimed = schedule.claimed.saturating_add(owed); + Self::deposit_event(Event::Claimed { + schedule_id, + beneficiary: schedule.beneficiary.clone(), + amount: owed, + }); + Ok(()) + }) + } + + /// Create a new schedule under the next free id, moving `total` from the + /// treasury account into the pot in the same call. + #[pallet::call_index(1)] + #[pallet::weight(T::WeightInfo::create_schedule())] + pub fn create_schedule( + origin: OriginFor, + beneficiary: T::AccountId, + start: Moment, + cliff: Moment, + end: Moment, + total: BalanceOf, + ) -> DispatchResult { + T::AdminOrigin::ensure_origin(origin)?; + let treasury = T::TreasuryAccount::get().ok_or(Error::::TreasuryNotConfigured)?; + let pot = Self::pot_account_id(); + ensure!(Self::schedule_is_valid(start, cliff, end, total), Error::::InvalidSchedule); + ensure!(beneficiary != pot, Error::::InvalidBeneficiary); + // A treasury misconfigured to be the pot itself would record an obligation + // without funding it, silently corrupting the pot's accounting invariant. + ensure!(treasury != pot, Error::::TreasuryNotConfigured); + // The pot's ED buffer is what lets keep-alive payouts always clear; a chain + // launched without genesis schedules must endow the pot before creating any. + ensure!( + T::Currency::total_balance(&pot) >= T::Currency::minimum_balance(), + Error::::PotUnderfunded + ); + let schedule_id = NextScheduleId::::get(); + let next_id = schedule_id.checked_add(1).ok_or(ArithmeticError::Overflow)?; + T::Currency::transfer(&treasury, &pot, total, Preservation::Preserve)?; + NextScheduleId::::put(next_id); + Schedules::::insert( + schedule_id, + VestingSchedule { + beneficiary: beneficiary.clone(), + start, + cliff, + end, + total, + claimed: Zero::zero(), + }, + ); + Self::deposit_event(Event::ScheduleCreated { + schedule_id, + beneficiary, + start, + cliff, + end, + total, + }); + Ok(()) + } + + /// End a schedule early: the still-unpaid vested part goes to the beneficiary, + /// the unvested remainder returns to the treasury, and the schedule is removed. + #[pallet::call_index(2)] + #[pallet::weight(T::WeightInfo::end_schedule())] + pub fn end_schedule(origin: OriginFor, schedule_id: u64) -> DispatchResult { + T::AdminOrigin::ensure_origin(origin)?; + let treasury = T::TreasuryAccount::get().ok_or(Error::::TreasuryNotConfigured)?; + let schedule = Schedules::::get(schedule_id).ok_or(Error::::NoSchedule)?; + let pot = Self::pot_account_id(); + let vested = Self::vested_amount(&schedule, T::TimeProvider::now()); + let owed = vested.saturating_sub(schedule.claimed); + let remainder = schedule.total.saturating_sub(vested); + if !owed.is_zero() { + T::Currency::transfer(&pot, &schedule.beneficiary, owed, Preservation::Preserve)?; + } + if !remainder.is_zero() { + T::Currency::transfer(&pot, &treasury, remainder, Preservation::Preserve)?; + } + Schedules::::remove(schedule_id); + Self::deposit_event(Event::ScheduleEnded { + schedule_id, + beneficiary: schedule.beneficiary, + vested_paid: owed, + unvested_returned: remainder, + }); + Ok(()) + } + + /// Change a schedule's beneficiary; everything else, including `claimed`, is + /// untouched. Remedy for a lost key or migration to a multisig. + #[pallet::call_index(3)] + #[pallet::weight(T::WeightInfo::retarget_schedule())] + pub fn retarget_schedule( + origin: OriginFor, + schedule_id: u64, + new_beneficiary: T::AccountId, + ) -> DispatchResult { + T::AdminOrigin::ensure_origin(origin)?; + ensure!(new_beneficiary != Self::pot_account_id(), Error::::InvalidBeneficiary); + Schedules::::try_mutate(schedule_id, |maybe_schedule| { + let schedule = maybe_schedule.as_mut().ok_or(Error::::NoSchedule)?; + ensure!(new_beneficiary != schedule.beneficiary, Error::::InvalidBeneficiary); + let old_beneficiary = + core::mem::replace(&mut schedule.beneficiary, new_beneficiary.clone()); + Self::deposit_event(Event::ScheduleRetargeted { + schedule_id, + old_beneficiary, + new_beneficiary, + }); + Ok(()) + }) + } + } + + impl Pallet { + /// The pot: the pallet's sovereign account holding all unclaimed vesting funds. + pub fn pot_account_id() -> T::AccountId { + T::PalletId::get().into_account_truncating() + } + + /// Amount vested at `now`: 0 before the cliff, `total` from `end`, linear in + /// between (floor rounding; the `end` branch guarantees exactness, the final + /// claim absorbs rounding dust). + pub fn vested_amount(schedule: &VestingScheduleOf, now: Moment) -> BalanceOf { + if now < schedule.cliff { + return Zero::zero(); + } + if now >= schedule.end { + return schedule.total; + } + // Here `cliff <= now < end`, and `start <= cliff`, so both differences are + // in range and `duration > 0`. + let elapsed = u128::from(now.saturating_sub(schedule.start)); + let duration = u128::from(schedule.end.saturating_sub(schedule.start)); + let total: u128 = schedule.total.saturated_into(); + // 256-bit internally: exact for the whole input domain. `None` only on a + // zero divisor, which the branches above rule out. + let vested = + multiply_by_rational_with_rounding(total, elapsed, duration, Rounding::Down) + .unwrap_or(total); + vested.saturated_into() + } + + fn schedule_is_valid( + start: Moment, + cliff: Moment, + end: Moment, + total: BalanceOf, + ) -> bool { + start <= cliff && cliff <= end && start < end && total >= T::Currency::minimum_balance() + } + + /// Invariant: the pot covers all outstanding obligations plus its ED buffer, and + /// every stored schedule is internally consistent. + #[cfg(any(feature = "try-runtime", test))] + pub fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { + let pot = Self::pot_account_id(); + let next_id = NextScheduleId::::get(); + let mut outstanding: BalanceOf = Zero::zero(); + for (id, schedule) in Schedules::::iter() { + frame_support::ensure!( + id < next_id, + sp_runtime::TryRuntimeError::Other("schedule id >= NextScheduleId") + ); + frame_support::ensure!( + Self::schedule_is_valid( + schedule.start, + schedule.cliff, + schedule.end, + schedule.total + ), + sp_runtime::TryRuntimeError::Other("invalid stored schedule") + ); + frame_support::ensure!( + schedule.claimed <= schedule.total, + sp_runtime::TryRuntimeError::Other("claimed exceeds total") + ); + frame_support::ensure!( + schedule.beneficiary != pot, + sp_runtime::TryRuntimeError::Other("pot is a beneficiary") + ); + outstanding = + outstanding.saturating_add(schedule.total.saturating_sub(schedule.claimed)); + } + frame_support::ensure!( + T::Currency::total_balance(&pot) >= + outstanding.saturating_add(T::Currency::minimum_balance()), + sp_runtime::TryRuntimeError::Other("pot does not cover outstanding obligations") + ); + Ok(()) + } + } +} diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs new file mode 100644 index 000000000..99acf18e4 --- /dev/null +++ b/pallets/vesting/src/mock.rs @@ -0,0 +1,166 @@ +use crate as pallet_vesting; + +use frame_support::{ + parameter_types, + traits::{ConstU32, ConstU64, EitherOfDiverse, EnsureOrigin, Everything}, + PalletId, +}; +use frame_system::EnsureRoot; +use sp_core::crypto::AccountId32; +use sp_runtime::{ + testing::H256, + traits::{BlakeTwo256, IdentityLookup}, + BuildStorage, +}; + +frame_support::construct_runtime!( + pub enum Test { + System: frame_system, + Timestamp: pallet_timestamp, + Balances: pallet_balances, + Vesting: pallet_vesting, + } +); + +pub type Balance = u128; +pub type Block = frame_system::mocking::MockBlock; +pub const UNIT: Balance = 1_000_000_000_000; + +pub const ALICE: AccountId32 = AccountId32::new([1u8; 32]); +pub const BOB: AccountId32 = AccountId32::new([2u8; 32]); +pub const CHARLIE: AccountId32 = AccountId32::new([3u8; 32]); +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; + +parameter_types! { + pub const BlockHashCount: u64 = 250; + /// `static` so individual tests can vary it via `ExistentialDeposit::set`. + pub static ExistentialDeposit: Balance = 1_000; + pub const VestingPalletId: PalletId = PalletId(*b"qvesting"); + /// `static` so tests can unset it to exercise `TreasuryNotConfigured`. + pub static TreasuryAccount: Option = Some(TREASURY); +} + +impl frame_system::Config for Test { + type BaseCallFilter = Everything; + type BlockWeights = (); + type BlockLength = (); + type RuntimeOrigin = RuntimeOrigin; + type RuntimeCall = RuntimeCall; + type RuntimeTask = (); + type Nonce = u64; + type Hash = H256; + type Hashing = BlakeTwo256; + type AccountId = AccountId32; + type Lookup = IdentityLookup; + type Block = Block; + type BlockHashCount = BlockHashCount; + type DbWeight = (); + type Version = (); + type PalletInfo = PalletInfo; + type AccountData = pallet_balances::AccountData; + type OnNewAccount = (); + type OnKilledAccount = (); + type SystemWeightInfo = (); + type ExtensionsWeightInfo = (); + type SS58Prefix = (); + type OnSetCode = (); + type MaxConsumers = ConstU32<16>; + type SingleBlockMigrations = (); + type MultiBlockMigrator = (); + type PreInherents = (); + type PostInherents = (); + type PostTransactions = (); + type RuntimeEvent = RuntimeEvent; +} + +impl pallet_timestamp::Config for Test { + type Moment = u64; + type OnTimestampSet = (); + type MinimumPeriod = ConstU64<1>; + type WeightInfo = (); +} + +impl pallet_balances::Config for Test { + type RuntimeEvent = RuntimeEvent; + type RuntimeHoldReason = (); + type RuntimeFreezeReason = (); + type WeightInfo = (); + type Balance = Balance; + type DustRemoval = (); + type ExistentialDeposit = ExistentialDeposit; + type AccountStore = System; + type ReserveIdentifier = [u8; 8]; + type FreezeIdentifier = (); + type MaxLocks = ConstU32<50>; + type MaxReserves = (); + type MaxFreezes = ConstU32<0>; + type DoneSlashHandler = (); +} + +/// Same shape as the runtime's `EnsureTreasury`: `Signed(who)` where `who` is the +/// configured treasury account. +pub struct EnsureTreasury; +impl EnsureOrigin for EnsureTreasury { + type Success = AccountId32; + fn try_origin(o: RuntimeOrigin) -> Result { + match (o.clone().into(), TreasuryAccount::get()) { + (Ok(frame_system::RawOrigin::Signed(who)), Some(treasury)) if who == treasury => + Ok(who), + _ => Err(o), + } + } + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result { + TreasuryAccount::get().map(RuntimeOrigin::signed).ok_or(()) + } +} + +impl pallet_vesting::Config for Test { + type Currency = Balances; + type TimeProvider = Timestamp; + type PalletId = VestingPalletId; + type AdminOrigin = EitherOfDiverse, EnsureTreasury>; + type TreasuryAccount = TreasuryAccount; + type WeightInfo = (); +} + +pub fn pot() -> AccountId32 { + Vesting::pot_account_id() +} + +pub fn set_time(now_ms: u64) { + pallet_timestamp::Now::::put(now_ms); +} + +pub type ScheduleTuple = (AccountId32, u64, u64, u64, u128); + +/// Pot endowed with exactly `sum(totals) + ED` — the valid genesis shape. +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()) +} + +pub fn new_test_ext_with_pot_balance( + schedules: Vec, + pot_balance: Balance, +) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + + let mut balances = vec![(TREASURY, TREASURY_FUNDS), (PINGER, UNIT)]; + if pot_balance > 0 { + balances.push((pot(), pot_balance)); + } + pallet_balances::GenesisConfig:: { balances, dev_accounts: None } + .assimilate_storage(&mut t) + .unwrap(); + + pallet_vesting::GenesisConfig:: { schedules } + .assimilate_storage(&mut t) + .unwrap(); + + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext +} diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs new file mode 100644 index 000000000..ea7350643 --- /dev/null +++ b/pallets/vesting/src/tests.rs @@ -0,0 +1,620 @@ +use crate::{mock::*, Error, Event, NextScheduleId, Schedules, VestingSchedule}; +use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect}; +use sp_runtime::{DispatchError, TokenError}; + +const START: u64 = 100_000; +const CLIFF: u64 = 200_000; +const END: u64 = 500_000; +const TOTAL: u128 = 4_000_000; + +fn default_schedule(beneficiary: sp_core::crypto::AccountId32) -> ScheduleTuple { + (beneficiary, START, CLIFF, END, TOTAL) +} + +fn free(who: &sp_core::crypto::AccountId32) -> u128 { + Balances::free_balance(who) +} + +fn stored(id: u64) -> VestingSchedule { + Schedules::::get(id).expect("schedule must exist") +} + +mod vested_amount { + use super::*; + + fn schedule( + start: u64, + cliff: u64, + end: u64, + total: u128, + ) -> VestingSchedule { + VestingSchedule { beneficiary: BOB, start, cliff, end, total, claimed: 0 } + } + + #[test] + fn zero_before_cliff_even_after_start() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, CLIFF, END, TOTAL); + assert_eq!(Vesting::vested_amount(&s, 0), 0); + assert_eq!(Vesting::vested_amount(&s, START), 0); + assert_eq!(Vesting::vested_amount(&s, CLIFF - 1), 0); + }); + } + + #[test] + fn jumps_to_accrued_amount_at_cliff() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, CLIFF, END, TOTAL); + // Accrual runs from `start`, so the cliff unlocks 100_000ms worth at once. + assert_eq!(Vesting::vested_amount(&s, CLIFF), TOTAL / 4); + }); + } + + #[test] + fn linear_between_cliff_and_end() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, CLIFF, END, TOTAL); + assert_eq!(Vesting::vested_amount(&s, 300_000), TOTAL / 2); + assert_eq!(Vesting::vested_amount(&s, 400_000), TOTAL * 3 / 4); + assert_eq!(Vesting::vested_amount(&s, END - 1), 3_999_990); + }); + } + + #[test] + fn exact_total_at_and_after_end() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, CLIFF, END, TOTAL); + assert_eq!(Vesting::vested_amount(&s, END), TOTAL); + assert_eq!(Vesting::vested_amount(&s, u64::MAX), TOTAL); + }); + } + + #[test] + fn floor_rounding() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(0, 0, 3, 100); + assert_eq!(Vesting::vested_amount(&s, 1), 33); + assert_eq!(Vesting::vested_amount(&s, 2), 66); + assert_eq!(Vesting::vested_amount(&s, 3), 100); + }); + } + + #[test] + fn start_equals_cliff_is_pure_linear() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, START, END, TOTAL); + assert_eq!(Vesting::vested_amount(&s, START), 0); + assert_eq!(Vesting::vested_amount(&s, START + 1), 10); + }); + } + + #[test] + fn cliff_equals_end_is_all_at_once() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(START, END, END, TOTAL); + assert_eq!(Vesting::vested_amount(&s, END - 1), 0); + assert_eq!(Vesting::vested_amount(&s, END), TOTAL); + }); + } + + #[test] + fn no_overflow_at_extreme_values() { + new_test_ext(vec![]).execute_with(|| { + let s = schedule(0, 0, u64::MAX, u128::from(u64::MAX) * 1_000_000_000_000); + assert_eq!(Vesting::vested_amount(&s, u64::MAX), s.total); + assert_eq!(Vesting::vested_amount(&s, u64::MAX - 1) > s.total / 2, true); + }); + } +} + +mod claim { + use super::*; + + #[test] + fn pays_out_and_updates_claimed() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(stored(0).claimed, TOTAL / 2); + assert_eq!(free(&pot()), TOTAL / 2 + ExistentialDeposit::get()); + System::assert_last_event( + Event::Claimed { schedule_id: 0, beneficiary: BOB, amount: TOTAL / 2 }.into(), + ); + }); + } + + #[test] + fn is_permissionless_and_pays_only_the_beneficiary() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + let pinger_before = free(&PINGER); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(free(&PINGER), pinger_before); + }); + } + + #[test] + fn nothing_before_cliff() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(CLIFF - 1); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(BOB), 0), + Error::::NothingToClaim + ); + }); + } + + #[test] + fn repeat_claim_at_same_time_errors() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(BOB), 0), + Error::::NothingToClaim + ); + }); + } + + #[test] + fn unknown_id_errors() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_noop!(Vesting::claim(RuntimeOrigin::signed(BOB), 1), Error::::NoSchedule); + }); + } + + #[test] + fn after_end_pays_remainder_exactly_and_schedule_stays() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + set_time(END + 1); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_eq!(free(&BOB), TOTAL); + assert_eq!(stored(0).claimed, TOTAL); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(BOB), 0), + Error::::NothingToClaim + ); + }); + } + + #[test] + fn below_ed_payout_to_nonexistent_account_fails_cleanly_then_succeeds_later() { + new_test_ext(vec![(CHARLIE, START, START, END, TOTAL)]).execute_with(|| { + // 10 per ms; ED is 1_000, so 50ms in only 500 is owed. + set_time(START + 50); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + TokenError::BelowMinimum + ); + assert_eq!(stored(0).claimed, 0); + set_time(START + 200); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), 2_000); + assert_eq!(stored(0).claimed, 2_000); + }); + } + + #[test] + fn full_drain_leaves_pot_with_exactly_the_ed_buffer() { + new_test_ext(vec![default_schedule(BOB), (CHARLIE, START, START, END, TOTAL)]) + .execute_with(|| { + set_time(250_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 1)); + set_time(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 1)); + assert_eq!(free(&BOB), TOTAL); + assert_eq!(free(&CHARLIE), TOTAL); + assert_eq!(free(&pot()), ExistentialDeposit::get()); + assert_ok!(Vesting::do_try_state()); + }); + } + + #[test] + fn multiple_schedules_for_same_beneficiary_claim_independently() { + new_test_ext(vec![default_schedule(BOB), (BOB, START, START, 900_000, 8_000_000)]) + .execute_with(|| { + set_time(500_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_eq!(free(&BOB), TOTAL); + assert_eq!(stored(1).claimed, 0); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 1)); + assert_eq!(free(&BOB), TOTAL + 4_000_000); + assert_eq!(stored(1).claimed, 4_000_000); + }); + } +} + +mod create_schedule { + use super::*; + + #[test] + fn treasury_signed_and_root_can_create_others_cannot() { + new_test_ext(vec![]).execute_with(|| { + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + START, + CLIFF, + END, + TOTAL + )); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::root(), + CHARLIE, + START, + CLIFF, + END, + TOTAL + )); + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(ALICE), + BOB, + START, + CLIFF, + END, + TOTAL + ), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn assigns_sequential_ids_and_moves_funds() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + let treasury_before = free(&TREASURY); + let pot_before = free(&pot()); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + CHARLIE, + START, + CLIFF, + END, + TOTAL + )); + assert_eq!(NextScheduleId::::get(), 2); + assert_eq!(stored(1).beneficiary, CHARLIE); + assert_eq!(free(&TREASURY), treasury_before - TOTAL); + assert_eq!(free(&pot()), pot_before + TOTAL); + System::assert_last_event( + Event::ScheduleCreated { + schedule_id: 1, + beneficiary: CHARLIE, + start: START, + cliff: CLIFF, + end: END, + total: TOTAL, + } + .into(), + ); + }); + } + + #[test] + fn rejects_invalid_parameters() { + new_test_ext(vec![]).execute_with(|| { + let cases = [ + (CLIFF, START, END, TOTAL), // start > cliff + (START, END + 1, END, TOTAL), // cliff > end + (START, START, START, TOTAL), // start == end + (START, CLIFF, END, 999), // total < ED + (START, CLIFF, END, 0), // total == 0 + ]; + for (start, cliff, end, total) in cases { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + start, + cliff, + end, + total + ), + Error::::InvalidSchedule + ); + } + }); + } + + #[test] + fn rejects_pot_as_beneficiary() { + new_test_ext(vec![]).execute_with(|| { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + pot(), + START, + CLIFF, + END, + TOTAL + ), + Error::::InvalidBeneficiary + ); + }); + } + + #[test] + fn fails_when_treasury_not_configured() { + new_test_ext(vec![]).execute_with(|| { + TreasuryAccount::set(None); + assert_noop!( + Vesting::create_schedule(RuntimeOrigin::root(), BOB, START, CLIFF, END, TOTAL), + Error::::TreasuryNotConfigured + ); + }); + } + + #[test] + fn fails_when_treasury_underfunded() { + new_test_ext(vec![]).execute_with(|| { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + START, + CLIFF, + END, + TREASURY_FUNDS + 1 + ), + TokenError::FundsUnavailable + ); + assert_eq!(NextScheduleId::::get(), 0); + assert!(Schedules::::get(0).is_none()); + }); + } + + #[test] + fn fails_when_pot_has_no_ed_buffer() { + new_test_ext_with_pot_balance(vec![], 0).execute_with(|| { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + START, + CLIFF, + END, + TOTAL + ), + Error::::PotUnderfunded + ); + }); + } +} + +mod end_schedule { + use super::*; + + #[test] + fn splits_mid_vesting_exactly() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + let treasury_before = free(&TREASURY); + set_time(300_000); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(free(&TREASURY), treasury_before + TOTAL / 2); + assert_eq!(free(&pot()), ExistentialDeposit::get()); + assert!(Schedules::::get(0).is_none()); + System::assert_last_event( + Event::ScheduleEnded { + schedule_id: 0, + beneficiary: BOB, + vested_paid: TOTAL / 2, + unvested_returned: TOTAL / 2, + } + .into(), + ); + }); + } + + #[test] + fn before_cliff_returns_everything_to_treasury() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + let treasury_before = free(&TREASURY); + set_time(CLIFF - 1); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::root(), 0)); + assert_eq!(free(&BOB), 0); + assert_eq!(free(&TREASURY), treasury_before + TOTAL); + }); + } + + #[test] + fn fully_vested_pays_beneficiary_everything() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + let treasury_before = free(&TREASURY); + set_time(END); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_eq!(free(&BOB), TOTAL); + assert_eq!(free(&TREASURY), treasury_before); + }); + } + + #[test] + fn after_partial_claims_pays_only_the_unpaid_vested_part() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(CLIFF); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_eq!(free(&BOB), TOTAL / 4); + let treasury_before = free(&TREASURY); + set_time(300_000); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(free(&TREASURY), treasury_before + TOTAL / 2); + assert_eq!(free(&pot()), ExistentialDeposit::get()); + }); + } + + #[test] + fn origin_and_error_paths() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::signed(ALICE), 0), + DispatchError::BadOrigin + ); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 7), + Error::::NoSchedule + ); + TreasuryAccount::set(None); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::root(), 0), + Error::::TreasuryNotConfigured + ); + }); + } + + #[test] + fn freed_ids_are_never_reused() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + CHARLIE, + START, + CLIFF, + END, + TOTAL + )); + assert!(Schedules::::get(0).is_none()); + assert_eq!(stored(1).beneficiary, CHARLIE); + assert_eq!(NextScheduleId::::get(), 2); + }); + } +} + +mod retarget_schedule { + use super::*; + + #[test] + fn updates_beneficiary_and_preserves_everything_else() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(CLIFF); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_ok!(Vesting::retarget_schedule(RuntimeOrigin::signed(TREASURY), 0, CHARLIE)); + let s = stored(0); + assert_eq!(s.beneficiary, CHARLIE); + assert_eq!(s.claimed, TOTAL / 4); + System::assert_last_event( + Event::ScheduleRetargeted { + schedule_id: 0, + old_beneficiary: BOB, + new_beneficiary: CHARLIE, + } + .into(), + ); + set_time(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), TOTAL * 3 / 4); + assert_eq!(free(&BOB), TOTAL / 4); + }); + } + + #[test] + fn rejects_pot_same_target_unknown_id_and_bad_origin() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + assert_noop!( + Vesting::retarget_schedule(RuntimeOrigin::signed(TREASURY), 0, pot()), + Error::::InvalidBeneficiary + ); + assert_noop!( + Vesting::retarget_schedule(RuntimeOrigin::root(), 0, BOB), + Error::::InvalidBeneficiary + ); + assert_noop!( + Vesting::retarget_schedule(RuntimeOrigin::root(), 5, CHARLIE), + Error::::NoSchedule + ); + assert_noop!( + Vesting::retarget_schedule(RuntimeOrigin::signed(ALICE), 0, CHARLIE), + DispatchError::BadOrigin + ); + }); + } +} + +mod genesis { + use super::*; + + #[test] + fn builds_schedules_including_repeated_beneficiary() { + new_test_ext(vec![ + default_schedule(BOB), + default_schedule(BOB), + (CHARLIE, START, START, END, TOTAL), + ]) + .execute_with(|| { + assert_eq!(NextScheduleId::::get(), 3); + assert_eq!(stored(0).beneficiary, BOB); + assert_eq!(stored(1).beneficiary, BOB); + assert_eq!(stored(2).beneficiary, CHARLIE); + assert_eq!(free(&pot()), 3 * TOTAL + ExistentialDeposit::get()); + assert_ok!(Vesting::do_try_state()); + }); + } + + #[test] + fn empty_is_a_noop() { + new_test_ext(vec![]).execute_with(|| { + assert_eq!(NextScheduleId::::get(), 0); + assert_eq!(Schedules::::iter().count(), 0); + assert_ok!(Vesting::do_try_state()); + }); + } + + #[test] + #[should_panic(expected = "pot balance must equal sum of schedule totals")] + fn pot_balance_mismatch_panics() { + new_test_ext_with_pot_balance(vec![default_schedule(BOB)], TOTAL); + } + + #[test] + #[should_panic(expected = "invalid schedule at index 0")] + fn invalid_schedule_panics() { + new_test_ext(vec![(BOB, CLIFF, START, END, TOTAL)]); + } + + #[test] + #[should_panic(expected = "the pot cannot be a beneficiary")] + fn pot_as_beneficiary_panics() { + let pot = crate::Pallet::::pot_account_id(); + new_test_ext(vec![(pot, START, CLIFF, END, TOTAL)]); + } +} + +mod try_state { + use super::*; + + #[test] + fn holds_through_a_full_lifecycle() { + new_test_ext(vec![default_schedule(BOB), (BOB, START, START, 900_000, 8_000_000)]) + .execute_with(|| { + assert_ok!(Vesting::do_try_state()); + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(BOB), 0)); + assert_ok!(Vesting::do_try_state()); + assert_ok!(Vesting::retarget_schedule(RuntimeOrigin::root(), 1, CHARLIE)); + assert_ok!(Vesting::do_try_state()); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_ok!(Vesting::do_try_state()); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + ALICE, + START, + CLIFF, + END, + TOTAL + )); + assert_ok!(Vesting::do_try_state()); + set_time(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 2)); + assert_ok!(Vesting::do_try_state()); + }); + } +} diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs new file mode 100644 index 000000000..9dfffcacc --- /dev/null +++ b/pallets/vesting/src/weights.rs @@ -0,0 +1,186 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Autogenerated weights for `pallet_vesting` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 +//! DATE: 2026-08-07, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `Arunachala.local`, CPU: `` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// ./target/release/quantus-node +// benchmark +// pallet +// --pallet=pallet_vesting +// --steps=50 +// --repeat=20 +// --runtime=./target/release/wbuild/quantus-runtime/quantus_runtime.wasm +// --genesis-builder=runtime +// --extrinsic=* +// --wasm-execution=compiled +// --heap-pages=4096 +// --template=./.maintain/frame-weight-template.hbs +// --output=./pallets/vesting/src/weights.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_vesting`. +pub trait WeightInfo { + fn claim() -> Weight; + fn create_schedule() -> Weight; + fn end_schedule() -> Weight; + fn retarget_schedule() -> Weight; +} + +/// Weights for `pallet_vesting` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `574` + // Estimated: `6196` + // Minimum execution time: 53_000_000 picoseconds. + Weight::from_parts(56_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(3_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::NextScheduleId` (r:1 w:1) + /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:0 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn create_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `474` + // Estimated: `6196` + // Minimum execution time: 51_000_000 picoseconds. + Weight::from_parts(52_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn end_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `798` + // Estimated: `8799` + // Minimum execution time: 92_000_000 picoseconds. + Weight::from_parts(95_000_000, 8799) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn retarget_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `308` + // Estimated: `3569` + // Minimum execution time: 10_000_000 picoseconds. + Weight::from_parts(11_000_000, 3569) + .saturating_add(T::DbWeight::get().reads(1_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `574` + // Estimated: `6196` + // Minimum execution time: 53_000_000 picoseconds. + Weight::from_parts(56_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(3_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::NextScheduleId` (r:1 w:1) + /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:0 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn create_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `474` + // Estimated: `6196` + // Minimum execution time: 51_000_000 picoseconds. + Weight::from_parts(52_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + fn end_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `798` + // Estimated: `8799` + // Minimum execution time: 92_000_000 picoseconds. + Weight::from_parts(95_000_000, 8799) + .saturating_add(RocksDbWeight::get().reads(6_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn retarget_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `308` + // Estimated: `3569` + // Minimum execution time: 10_000_000 picoseconds. + Weight::from_parts(11_000_000, 3569) + .saturating_add(RocksDbWeight::get().reads(1_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } +} diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 0ea2b4d31..70664ba2c 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -42,6 +42,7 @@ pallet-transaction-payment.workspace = true pallet-transaction-payment-rpc-runtime-api.workspace = true pallet-treasury.workspace = true pallet-utility.workspace = true +pallet-vesting.workspace = true pallet-wormhole.workspace = true pallet-zk-tree.workspace = true primitive-types.workspace = true @@ -105,6 +106,7 @@ std = [ "pallet-transaction-payment/std", "pallet-treasury/std", "pallet-utility/std", + "pallet-vesting/std", "pallet-wormhole/std", "pallet-zk-tree/std", "primitive-types/std", @@ -149,6 +151,7 @@ runtime-benchmarks = [ "pallet-timestamp/runtime-benchmarks", "pallet-transaction-payment/runtime-benchmarks", "pallet-treasury/runtime-benchmarks", + "pallet-vesting/runtime-benchmarks", "pallet-wormhole/runtime-benchmarks", "sp-runtime/runtime-benchmarks", ] @@ -166,6 +169,7 @@ try-runtime = [ "pallet-timestamp/try-runtime", "pallet-transaction-payment/try-runtime", "pallet-treasury/try-runtime", + "pallet-vesting/try-runtime", "pallet-wormhole/try-runtime", "sp-runtime/try-runtime", ] diff --git a/runtime/src/benchmarks.rs b/runtime/src/benchmarks.rs index d9892d3dc..fee72410a 100644 --- a/runtime/src/benchmarks.rs +++ b/runtime/src/benchmarks.rs @@ -35,4 +35,5 @@ frame_benchmarking::define_benchmarks!( [pallet_scheduler, Scheduler] [pallet_qpow, QPoW] [pallet_wormhole, Wormhole] + [pallet_vesting, Vesting] ); diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 07442bd7e..c73af069e 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -33,7 +33,10 @@ use crate::{ }; use frame_support::{ derive_impl, parameter_types, - traits::{ConstU128, ConstU16, ConstU32, ConstU8, NeverEnsureOrigin, VariantCountOf}, + traits::{ + ConstU128, ConstU16, ConstU32, ConstU8, EitherOfDiverse, EnsureOrigin, Get, + NeverEnsureOrigin, VariantCountOf, + }, weights::{ constants::{RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, IdentityFee, Weight, WeightToFeeCoefficient, WeightToFeeCoefficients, @@ -524,6 +527,52 @@ impl pallet_treasury::Config for Runtime { type WeightInfo = pallet_treasury::weights::SubstrateWeight; } +parameter_types! { + pub const VestingPalletId: PalletId = PalletId(*b"qvesting"); +} + +/// The configured treasury account as an `Option` — unlike +/// `pallet_treasury::Pallet::account_id()`, this never panics on a chain whose +/// genesis omitted the treasury; vesting admin calls fail with an explicit error instead. +pub struct TreasuryAccountOption; +impl Get> for TreasuryAccountOption { + fn get() -> Option { + pallet_treasury::Pallet::::treasury_account() + } +} + +/// `Signed(who)` where `who` is the configured treasury account. +/// +/// The treasury is a multisig in real deployments; the multisig pallet dispatches +/// approved proposals as `RawOrigin::Signed(multisig_address)`, so a plain +/// signed-origin check covers it. +pub struct EnsureTreasury; +impl EnsureOrigin for EnsureTreasury { + type Success = AccountId; + fn try_origin(o: RuntimeOrigin) -> Result { + match (o.clone().into(), pallet_treasury::Pallet::::treasury_account()) { + (Ok(frame_system::RawOrigin::Signed(who)), Some(treasury)) if who == treasury => + Ok(who), + _ => Err(o), + } + } + #[cfg(feature = "runtime-benchmarks")] + fn try_successful_origin() -> Result { + pallet_treasury::Pallet::::treasury_account() + .map(RuntimeOrigin::signed) + .ok_or(()) + } +} + +impl pallet_vesting::Config for Runtime { + type Currency = Balances; + type TimeProvider = Timestamp; + type PalletId = VestingPalletId; + type AdminOrigin = EitherOfDiverse, EnsureTreasury>; + type TreasuryAccount = TreasuryAccountOption; + type WeightInfo = pallet_vesting::weights::SubstrateWeight; +} + // Multisig configuration parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); @@ -550,10 +599,12 @@ parameter_types! { /// - Multisig pallet: validates calls in `propose()` extrinsic /// - Transaction extensions: validates calls for high-security EOAs /// -/// Whitelist includes only delayed, reversible operations: +/// Whitelist includes only delayed, reversible operations plus vesting claims: /// - `schedule_transfer`: Schedule delayed native token transfer /// - `cancel`: Cancel pending delayed transfer /// - `recover_funds`: Guardian-initiated recovery +/// - `Vesting::claim`: safe because the payout goes to the schedule's stored beneficiary, never to +/// the caller pub struct HighSecurityConfig; impl qp_high_security::HighSecurityInspector for HighSecurityConfig { @@ -570,7 +621,7 @@ impl qp_high_security::HighSecurityInspector for HighSec ) | RuntimeCall::ReversibleTransfers(pallet_reversible_transfers::Call::cancel { .. }) | RuntimeCall::ReversibleTransfers( pallet_reversible_transfers::Call::recover_funds { .. } - ) + ) | RuntimeCall::Vesting(pallet_vesting::Call::claim { .. }) ) } diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 2bc943761..2a8846c07 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -18,7 +18,7 @@ // this module is used by the client, so it's ok to panic/unwrap here #![allow(clippy::expect_used)] -use crate::{AccountId, BalancesConfig, RuntimeGenesisConfig, UNIT}; +use crate::{AccountId, BalancesConfig, RuntimeGenesisConfig, EXISTENTIAL_DEPOSIT, UNIT}; use alloc::{ string::{String, ToString}, vec, @@ -55,6 +55,28 @@ fn test_wormhole_account() -> AccountId { AccountId::new(TEST_WORMHOLE_ADDRESS) } +/// Milliseconds since the unix epoch, the time basis of vesting schedules. +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 +} + +/// Genesis vesting starts at this wall-clock time: **2026-08-05 00:00:00 UTC**. +/// Re-derive this (midnight UTC of the intended start date) before any real launch. +const GENESIS_VESTING_START_MS: VestingMoment = 1_785_888_000_000; +/// Testnet example cliff: 90 days after start. +const GENESIS_VESTING_CLIFF_MS: VestingMoment = GENESIS_VESTING_START_MS + days_ms(90); +/// Testnet example vesting end: 1 year after start. +const GENESIS_VESTING_END_MS: VestingMoment = GENESIS_VESTING_START_MS + days_ms(365); +/// Testnet example grant size. +const GENESIS_VESTING_TOTAL: u128 = 10_000 * UNIT; + /// Identifier for the heisenberg runtime preset. /// /// Heisenberg is the internal integration testnet. Its genesis deliberately @@ -157,6 +179,7 @@ fn genesis_template( treasury: TreasuryGenesis, tech_collective_members: Vec, extra_balances: Vec<(AccountId, u128)>, + vesting_schedules: Vec, ) -> Value { const ENDOWED_BALANCE_UNITS: u128 = 100_000; let mut balances = endowed_accounts @@ -169,17 +192,42 @@ fn genesis_template( // No pre-mine: the treasury starts at zero balance and is funded only by its share of // mining rewards. It is intentionally NOT added to `balances`. + // Record transfer proofs for the user-visible endowments only. The vesting pot is + // appended to `balances` below but deliberately kept OUT of the wormhole endowment + // list: it is a keyless pallet account nobody can ZK-spend from, and a block-1 tree + // leaf crediting it would be a misleading artifact. + let endowed_addresses = balances.clone(); + + // The pot must hold exactly the sum of all schedule totals plus its existential- + // deposit buffer (asserted by the vesting pallet's genesis build). It is endowed + // with at least the ED even when no schedules exist, so `create_schedule` works on + // every chain from day one. + let mut vesting_total: u128 = 0; + for (_, _, _, _, total) in &vesting_schedules { + vesting_total = vesting_total + .checked_add(*total) + .expect("vesting genesis allocation overflows u128"); + } + let pot_account = pallet_vesting::Pallet::::pot_account_id(); + balances.push(( + pot_account, + vesting_total + .checked_add(EXISTENTIAL_DEPOSIT) + .expect("vesting pot endowment overflows u128"), + )); + let config = RuntimeGenesisConfig { - balances: BalancesConfig { balances: balances.clone(), dev_accounts: None }, + balances: BalancesConfig { balances, dev_accounts: None }, treasury_pallet: pallet_treasury::GenesisConfig:: { treasury_account: Some(treasury.account), treasury_portion: Some(treasury.portion), }, wormhole: pallet_wormhole::GenesisConfig:: { - // Record transfer proofs for ALL endowed addresses, enabling ZK spending. + // Record transfer proofs for endowed addresses, enabling ZK spending. // Events are emitted in on_initialize at block 1 for indexer compatibility. - endowed_addresses: balances, + endowed_addresses, }, + vesting: pallet_vesting::GenesisConfig:: { schedules: vesting_schedules }, ..Default::default() }; @@ -196,6 +244,62 @@ fn genesis_template( v } +/// Testnet vesting table for `dev` and `heisenberg`: Bob holds two schedules +/// (exercises multi-schedule-per-account), Charlie one. +fn testnet_vesting_schedules() -> Vec { + let accounts = dilithium_default_accounts(); + vec![ + ( + accounts[1].clone(), + GENESIS_VESTING_START_MS, + GENESIS_VESTING_CLIFF_MS, + GENESIS_VESTING_END_MS, + GENESIS_VESTING_TOTAL, + ), + ( + accounts[1].clone(), + GENESIS_VESTING_START_MS, + GENESIS_VESTING_START_MS, + GENESIS_VESTING_END_MS, + GENESIS_VESTING_TOTAL, + ), + ( + accounts[2].clone(), + GENESIS_VESTING_START_MS, + GENESIS_VESTING_CLIFF_MS, + GENESIS_VESTING_END_MS, + GENESIS_VESTING_TOTAL, + ), + ] +} + +/// Dev additionally vests the keyless test wormhole address: it can never sign, so its +/// grants are claimable only via the permissionless third-party `claim` — exercising the +/// exact path wormhole beneficiaries rely on. +fn development_vesting_schedules() -> Vec { + let mut schedules = testnet_vesting_schedules(); + schedules.push(( + test_wormhole_account(), + GENESIS_VESTING_START_MS, + GENESIS_VESTING_CLIFF_MS, + GENESIS_VESTING_END_MS, + GENESIS_VESTING_TOTAL, + )); + schedules +} + +fn log_vesting_schedules(preset: &str, schedules: &[VestingScheduleTuple]) { + let ss58 = ss58_version(); + let pot = pallet_vesting::Pallet::::pot_account_id(); + log::info!("[{preset}] 🪙 Vesting pot: {:?}", pot.to_ss58check_with_version(ss58)); + for (beneficiary, start, cliff, end, total) in schedules { + log::info!( + "[{preset}] 🪙 Vesting: {:?} total={total} start={start} cliff={cliff} end={end}", + beneficiary.to_ss58check_with_version(ss58), + ); + } +} + fn log_genesis_accounts( preset: &str, endowed: &[AccountId], @@ -231,6 +335,8 @@ pub fn development_config_genesis() -> Value { &tech_collective, ); log::info!("[dev] 🕳️ Test ZK: {:?}", test_account.to_ss58check_with_version(ss58_version())); + let vesting_schedules = development_vesting_schedules(); + log_vesting_schedules("dev", &vesting_schedules); #[cfg(feature = "runtime-benchmarks")] { @@ -254,8 +360,13 @@ pub fn development_config_genesis() -> Value { let treasury = TreasuryGenesis { account: treasury_account, portion: Permill::from_percent(50) }; - let mut template_value = - genesis_template(endowed_accounts, treasury, tech_collective, vec![]); + let mut template_value = genesis_template( + endowed_accounts, + treasury, + tech_collective, + vec![], + vesting_schedules, + ); // `genesis_template` adds a chain-spec-only field; strip before deserializing. template_value .as_object_mut() @@ -271,7 +382,7 @@ pub fn development_config_genesis() -> Value { { let treasury = TreasuryGenesis { account: treasury_account, portion: Permill::from_percent(50) }; - genesis_template(endowed_accounts, treasury, tech_collective, vec![]) + genesis_template(endowed_accounts, treasury, tech_collective, vec![], vesting_schedules) } } @@ -287,9 +398,11 @@ pub fn heisenberg_config_genesis() -> Value { &treasury_signers, &tech_collective, ); + let vesting_schedules = testnet_vesting_schedules(); + log_vesting_schedules("heisenberg", &vesting_schedules); let treasury = TreasuryGenesis { account: treasury_account, portion: Permill::from_percent(50) }; - genesis_template(endowed_accounts, treasury, tech_collective, vec![]) + genesis_template(endowed_accounts, treasury, tech_collective, vec![], vesting_schedules) } fn planck_faucet_account() -> AccountId { @@ -391,9 +504,12 @@ pub fn planck_config_genesis() -> Value { &treasury_signers, &tech_collective, ); + // No vesting allocations on Planck; the pot still receives its ED buffer so + // `create_schedule` works post-genesis. + log_vesting_schedules("planck", &[]); let treasury = TreasuryGenesis { account: treasury_account, portion: Permill::from_percent(50) }; - genesis_template(endowed_accounts, treasury, tech_collective, signer_fee_seed) + genesis_template(endowed_accounts, treasury, tech_collective, signer_fee_seed, vec![]) } /// Provides the JSON representation of predefined genesis config for given `id`. @@ -425,3 +541,69 @@ pub fn preset_names() -> Vec { PresetId::from(PLANCK_RUNTIME_PRESET), ] } + +#[cfg(test)] +mod tests { + use super::*; + use sp_runtime::BuildStorage; + + /// 2020-01-01 and 2100-01-01 UTC, sanity bounds for genesis vesting dates. + const YEAR_2020_MS: u64 = 1_577_836_800_000; + const YEAR_2100_MS: u64 = 4_102_444_800_000; + + #[test] + fn days_ms_is_exact() { + assert_eq!(days_ms(1), 86_400_000); + assert_eq!(days_ms(365), 31_536_000_000); + } + + #[test] + fn genesis_vesting_times_are_sane() { + assert_eq!(GENESIS_VESTING_START_MS % MILLIS_PER_DAY, 0, "start must be midnight UTC"); + assert!(GENESIS_VESTING_START_MS > YEAR_2020_MS); + assert!(GENESIS_VESTING_START_MS < YEAR_2100_MS); + assert!(GENESIS_VESTING_START_MS <= GENESIS_VESTING_CLIFF_MS); + assert!(GENESIS_VESTING_CLIFF_MS <= GENESIS_VESTING_END_MS); + assert!(GENESIS_VESTING_START_MS < GENESIS_VESTING_END_MS); + } + + /// Deserializes every preset through the real genesis-build input path and executes + /// the full genesis build — including the vesting pallet's pot-balance assertions — + /// so a misconfigured preset fails in CI, not at chain launch. + #[test] + fn all_presets_deserialize_and_build() { + for preset in preset_names() { + let raw = get_preset(&preset).expect("preset must exist"); + let (json, _members) = + prepare_genesis_build_input(raw).expect("preset JSON must be well-formed"); + let config: RuntimeGenesisConfig = + serde_json::from_slice(&json).expect("preset must deserialize"); + config + .build_storage() + .unwrap_or_else(|e| panic!("genesis build failed for {preset:?}: {e:?}")); + } + } + + /// The vesting pot's genesis endowment must exactly cover the schedule table. + #[test] + fn preset_pot_endowment_matches_schedules() { + let raw = get_preset(&PresetId::from(HEISENBERG_RUNTIME_PRESET)).expect("preset exists"); + let (json, _) = prepare_genesis_build_input(raw).expect("well-formed"); + let config: RuntimeGenesisConfig = serde_json::from_slice(&json).expect("deserializes"); + let pot = pallet_vesting::Pallet::::pot_account_id(); + let pot_balance = config + .balances + .balances + .iter() + .find(|(who, _)| *who == pot) + .map(|(_, amount)| *amount) + .expect("pot must be endowed"); + let schedule_sum: u128 = + config.vesting.schedules.iter().map(|(_, _, _, _, total)| *total).sum(); + assert_eq!(pot_balance, schedule_sum + EXISTENTIAL_DEPOSIT); + assert!( + !config.wormhole.endowed_addresses.iter().any(|(who, _)| *who == pot), + "the keyless pot must not be in the wormhole endowment list" + ); + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b286b31d1..5efd77d84 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: 141, + spec_version: 142, impl_version: 1, apis: apis::RUNTIME_API_VERSIONS, transaction_version: 3, @@ -265,4 +265,7 @@ mod runtime { #[runtime::pallet_index(21)] pub type ZkTree = pallet_zk_tree; + + #[runtime::pallet_index(22)] + pub type Vesting = pallet_vesting; } diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index 3bdec54a1..c4555b8ad 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -135,6 +135,13 @@ impl WormholeProofRecorderExtension RuntimeCall::Balances(pallet_balances::Call::transfer_all { .. }) | RuntimeCall::Balances(pallet_balances::Call::force_transfer { .. }) => 1, + // Vesting payouts are plain pot transfers recorded like any other. `end_schedule` + // makes up to two (beneficiary payout + treasury refund); charge the worst case, + // consistent with `if_else` below. `retarget_schedule` moves no funds. + RuntimeCall::Vesting(pallet_vesting::Call::claim { .. }) | + RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { .. }) => 1, + RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { .. }) => 2, + RuntimeCall::Utility(pallet_utility::Call::batch { calls }) | RuntimeCall::Utility(pallet_utility::Call::batch_all { calls }) | RuntimeCall::Utility(pallet_utility::Call::force_batch { calls }) => @@ -765,6 +772,36 @@ mod tests { }); } + #[test] + fn wormhole_proof_recorder_counts_vesting_calls() { + new_test_ext().execute_with(|| { + // `claim` and `create_schedule` each move funds once; `end_schedule` up to twice + // (beneficiary payout + treasury refund, worst case); `retarget_schedule` never. + // Admin calls executed *through* `Multisig::execute` are statically opaque and + // rely on the post_dispatch shortfall reconciliation instead. + let claim = RuntimeCall::Vesting(pallet_vesting::Call::claim { schedule_id: 0 }); + assert_eq!(WormholeProofRecorderExtension::::count_transfers(&claim), 1); + + let create = RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { + beneficiary: alice(), + start: 0, + cliff: 0, + end: 1, + total: 1, + }); + assert_eq!(WormholeProofRecorderExtension::::count_transfers(&create), 1); + + let end = RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { schedule_id: 0 }); + assert_eq!(WormholeProofRecorderExtension::::count_transfers(&end), 2); + + let retarget = RuntimeCall::Vesting(pallet_vesting::Call::retarget_schedule { + schedule_id: 0, + new_beneficiary: alice(), + }); + assert_eq!(WormholeProofRecorderExtension::::count_transfers(&retarget), 0); + }); + } + #[test] fn wormhole_proof_recorder_counts_dispatch_as_fallible_wrapped_transfers() { new_test_ext().execute_with(|| { diff --git a/runtime/tests/governance/mod.rs b/runtime/tests/governance/mod.rs index 4d1f688ed..35b42ba3d 100644 --- a/runtime/tests/governance/mod.rs +++ b/runtime/tests/governance/mod.rs @@ -1,2 +1,3 @@ pub mod tech_collective; pub mod treasury; +pub mod vesting; diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs new file mode 100644 index 000000000..f157d5f87 --- /dev/null +++ b/runtime/tests/governance/vesting.rs @@ -0,0 +1,223 @@ +//! Integration tests for the vesting pallet's runtime couplings that pallet unit tests +//! cannot see: the `EnsureTreasury` admin origin exercised through the *real* treasury +//! multisig, and the wormhole proof recorder consuming claim payout events. + +#[cfg(test)] +mod tests { + use codec::Encode; + use frame_support::{assert_noop, assert_ok, traits::Currency}; + use pallet_multisig::BoundedCallOf; + use qp_wormhole::TransferProofRecorder; + use quantus_runtime::{ + AccountId, AssetId, Balance, Balances, Multisig, Runtime, RuntimeCall, RuntimeEvent, + RuntimeOrigin, System, Vesting, Wormhole, EXISTENTIAL_DEPOSIT, UNIT, + }; + use sp_core::crypto::AccountId32; + use sp_runtime::{BuildStorage, DispatchError, Permill}; + + const END_MS: u64 = 1_000_000; + const GRANT: Balance = 100 * UNIT; + + fn account(id: u8) -> AccountId32 { + let mut bytes = [0u8; 32]; + bytes[0] = id; + AccountId32::new(bytes) + } + + fn signers() -> Vec { + vec![account(1), account(2), account(3)] + } + + fn treasury_multisig() -> AccountId { + pallet_multisig::Pallet::::derive_multisig_address(&signers(), 2, 0) + } + + fn new_test_ext(treasury: Option) -> sp_io::TestExternalities { + let mut t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + pallet_treasury::GenesisConfig:: { + treasury_account: treasury.clone(), + treasury_portion: treasury.map(|_| Permill::from_percent(50)), + } + .assimilate_storage(&mut t) + .unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| { + System::set_block_number(1); + for signer in signers() { + Balances::make_free_balance_be(&signer, 1000 * UNIT); + } + Balances::make_free_balance_be(&Vesting::pot_account_id(), EXISTENTIAL_DEPOSIT); + }); + ext + } + + fn set_time(now_ms: u64) { + pallet_timestamp::Now::::put(now_ms); + } + + fn propose_approve_execute(call: RuntimeCall, proposal_id: u32) { + let treasury = treasury_multisig(); + let encoded: BoundedCallOf = call.encode().try_into().unwrap(); + let expiry = System::block_number() + 100; + assert_ok!(Multisig::propose( + RuntimeOrigin::signed(account(1)), + treasury.clone(), + encoded.clone(), + expiry, + )); + assert_ok!(Multisig::approve( + RuntimeOrigin::signed(account(2)), + treasury.clone(), + proposal_id, + encoded, + )); + assert_ok!(Multisig::execute(RuntimeOrigin::signed(account(3)), treasury, proposal_id)); + } + + #[test] + fn treasury_multisig_creates_and_ends_schedules() { + new_test_ext(Some(treasury_multisig())).execute_with(|| { + let treasury = treasury_multisig(); + let beneficiary = account(7); + let pot = Vesting::pot_account_id(); + assert_ok!(Multisig::create_multisig( + RuntimeOrigin::signed(account(1)), + signers(), + 2, + 0, + )); + Balances::make_free_balance_be(&treasury, 1000 * UNIT); + + propose_approve_execute( + RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { + beneficiary: beneficiary.clone(), + start: 0, + cliff: 0, + end: END_MS, + total: GRANT, + }), + 0, + ); + let schedule = + pallet_vesting::Schedules::::get(0).expect("schedule must be created"); + assert_eq!(schedule.beneficiary, beneficiary); + assert_eq!(Balances::total_balance(&pot), GRANT + EXISTENTIAL_DEPOSIT); + assert_eq!(Balances::total_balance(&treasury), 900 * UNIT); + + // Halfway through the schedule: ending it splits the grant exactly. + set_time(END_MS / 2); + propose_approve_execute( + RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { schedule_id: 0 }), + 1, + ); + assert!(pallet_vesting::Schedules::::get(0).is_none()); + assert_eq!(Balances::total_balance(&beneficiary), GRANT / 2); + assert_eq!(Balances::total_balance(&treasury), 950 * UNIT); + assert_eq!(Balances::total_balance(&pot), EXISTENTIAL_DEPOSIT); + }); + } + + #[test] + fn non_treasury_origins_are_rejected() { + new_test_ext(Some(treasury_multisig())).execute_with(|| { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(account(1)), + account(7), + 0, + 0, + END_MS, + GRANT, + ), + DispatchError::BadOrigin + ); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::signed(account(1)), 0), + DispatchError::BadOrigin + ); + assert_noop!( + Vesting::retarget_schedule(RuntimeOrigin::signed(account(1)), 0, account(8)), + DispatchError::BadOrigin + ); + + // Root is the break-glass admin and works without the multisig. + Balances::make_free_balance_be(&treasury_multisig(), 1000 * UNIT); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::root(), + account(7), + 0, + 0, + END_MS, + GRANT, + )); + }); + } + + #[test] + fn unconfigured_treasury_fails_loudly() { + new_test_ext(None).execute_with(|| { + // Root passes the origin check but the pallet still refuses: there is no + // treasury to fund from or refund to. + assert_noop!( + Vesting::create_schedule(RuntimeOrigin::root(), account(7), 0, 0, END_MS, GRANT), + pallet_vesting::Error::::TreasuryNotConfigured + ); + // A signed origin cannot match an unconfigured treasury either. + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(account(1)), + account(7), + 0, + 0, + END_MS, + GRANT, + ), + DispatchError::BadOrigin + ); + }); + } + + #[test] + fn claim_payout_event_feeds_the_wormhole_proof_recorder() { + new_test_ext(Some(account(4))).execute_with(|| { + Balances::make_free_balance_be(&account(4), 1000 * UNIT); + // The beneficiary never signs anything — exactly like a wormhole address. + let beneficiary = account(9); + let pot = Vesting::pot_account_id(); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::root(), + beneficiary.clone(), + 0, + 0, + END_MS, + GRANT, + )); + set_time(END_MS); + System::reset_events(); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(account(1)), 0)); + + // The payout is an ordinary `Balances::Transfer` — the exact event shape the + // `WormholeProofRecorderExtension` scans in `post_dispatch`. + let payout = System::events() + .into_iter() + .find_map(|record| match record.event { + RuntimeEvent::Balances(pallet_balances::Event::Transfer { + from, + to, + amount, + }) if from == pot && to == beneficiary => Some(amount), + _ => None, + }) + .expect("claim must emit a plain Transfer event from the pot"); + assert_eq!(payout, GRANT); + + // Feeding that event into the recorder (as post_dispatch does) records a + // ZK-tree leaf for the beneficiary — this is what lets a wormhole owner + // later exit the funds via ZK proof. + let count_before = Wormhole::transfer_count(&beneficiary); + assert!(>:: + record_transfer_proof(None, pot, beneficiary.clone(), payout)); + assert_eq!(Wormhole::transfer_count(&beneficiary), count_before + 1); + }); + } +} diff --git a/scripts/regenerate_weights.sh b/scripts/regenerate_weights.sh index ed9343297..ffe6e950b 100755 --- a/scripts/regenerate_weights.sh +++ b/scripts/regenerate_weights.sh @@ -16,6 +16,7 @@ PALLETS=( "pallet_scheduler:pallets/scheduler/src/weights.rs:50:20" "pallet_mining_rewards:pallets/mining-rewards/src/weights.rs:50:20" "pallet_treasury:pallets/treasury/src/weights.rs:50:20" + "pallet_vesting:pallets/vesting/src/weights.rs:50:20" ) COMMON_ARGS=( From 5c628a7d957b1765b4f964d51b35859d56f6c8ab Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Fri, 7 Aug 2026 17:08:46 +0800 Subject: [PATCH 2/6] fix: quantize vesting payouts and record proofs on every dispatch origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review findings on the vesting pallet: - Quantize payouts to the wormhole leaf quantum (SCALE_DOWN_FACTOR, 10^10 planck): leaves commit amount/quantum, so a sub-quantum payout would be committed as a zero-value leaf and strand funds on a keyless beneficiary. Schedule totals must now be quantum-aligned, every payout is rounded down to a quantum multiple, and claimed advances only by the paid amount (stays aligned; the final claim at end remains exact). end_schedule sends sub-quantum vested dust to the treasury, which is signature-controlled and needs no leaf. Includes a regression test for the reviewed griefing scenario (per-block accrual above the ED but below one quantum). - Record payouts through the canonical TransferProofRecorder inside the pallet: transfer and proof recording are fused into a single pay_out helper, so payouts create ZK-tree leaves on every dispatch origin — including Root calls enacted by the scheduler, which run outside the signed-extrinsic lifecycle and are invisible to the event-scanning extension. The extension now skips pot-touching transfer events (no double-recording on signed paths) and no longer statically counts vesting calls; the recording cost lives in the pallet's re-benchmarked weights, with the depth-dependent ZK-tree augmentation following the reversible-transfers pattern. An integration test drives end_schedule through the real scheduler as Root and asserts the payout leaf. - No upgrade migration, by decision: this pallet ships on fresh chains whose genesis endows the pot. If it ever landed on a live chain in place, create_schedule fails loudly with PotUnderfunded until the treasury sends the pot its ED buffer — documented and covered by a bootstrap test. --- Cargo.lock | 2 + docs/RUNTIME_SURFACE.md | 12 +- pallets/vesting/Cargo.toml | 5 + pallets/vesting/src/lib.rs | 127 +++++++++++++---- pallets/vesting/src/mock.rs | 43 +++++- pallets/vesting/src/tests.rs | 190 ++++++++++++++++++++++++- pallets/vesting/src/weights.rs | 195 +++++++++----------------- runtime/src/configs/mod.rs | 10 ++ runtime/src/transaction_extensions.rs | 80 ++++++----- runtime/tests/governance/vesting.rs | 73 ++++++++-- 10 files changed, 530 insertions(+), 207 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 294475e2b..eebb5625c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6243,7 +6243,9 @@ dependencies = [ "frame-system", "pallet-balances", "pallet-timestamp", + "pallet-zk-tree", "parity-scale-codec", + "qp-wormhole", "scale-info", "sp-arithmetic", "sp-core", diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index e6dff13f6..bb62a0858 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -178,11 +178,13 @@ All `Config` impls live in `runtime/src/configs/mod.rs` unless noted. ### Index 22 — `Vesting` (`pallet-vesting`, local) - Pull-based "vesting wallet": the pallet's sovereign pot (`PalletId(*b"qvesting")`, keyless) holds the entire unclaimed allocation, endowed at genesis with `Σ schedule totals + ED`; beneficiaries are paid by plain keep-alive transfers only at claim time. **No locks, freezes, or holds ever touch a beneficiary account**, so wormhole addresses can be beneficiaries. -- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EitherOfDiverse` (`EnsureTreasury` = signed by the configured treasury account; the treasury multisig executes proposals as a plain signed origin), `TreasuryAccount = TreasuryAccountOption` (Option-returning storage read, never panics). -- **Storage:** `Schedules: schedule_id (u64) → { beneficiary, start, cliff, end, total, claimed }` (ids sequential, never reused; a beneficiary may hold any number of schedules), `NextScheduleId`. +- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EitherOfDiverse` (`EnsureTreasury` = signed by the configured treasury account; the treasury multisig executes proposals as a plain signed origin), `TreasuryAccount = TreasuryAccountOption` (Option-returning storage read, never panics), `ProofRecorder = Wormhole`, `PayoutQuantum = SCALE_DOWN_FACTOR` (10^10). +- **Storage:** `Schedules: schedule_id (u64) → { beneficiary, start, cliff, end, total, claimed }` (ids sequential, never reused; a beneficiary may hold any number of schedules), `NextScheduleId`. Deployed on fresh chains only (genesis endows the pot); deliberately no upgrade migration — an unfunded pot blocks `create_schedule` loudly with `PotUnderfunded` until the treasury sends it one ED. - Vesting math: `vested(t) = 0` before `cliff`, `total` from `end`, else `⌊total·(t−start)/(end−start)⌋` (256-bit rational, floor; the `end` branch guarantees exactness). -- **Calls:** `claim`(0) — **permissionless**; pays `vested − claimed` from the pot to the schedule's stored beneficiary (never the caller); the only claim path for keyless/high-security beneficiaries. `create_schedule`(1) — admin; funds the pot from the treasury in the same call. `end_schedule`(2) — admin; unpaid vested part → beneficiary, unvested remainder → treasury, schedule removed. `retarget_schedule`(3) — admin; changes the beneficiary key only (lost-key remedy). -- Genesis build validates every schedule (`start ≤ cliff ≤ end`, `start < end`, `total ≥ ED`, beneficiary ≠ pot) and asserts the pot's endowment exactly; a misconfigured chain refuses to start. `try_state` checks `pot balance ≥ Σ(total − claimed) + ED`. +- **Payout quantization:** wormhole leaves commit `amount / 10^10`, so a sub-quantum payout would create a zero-value leaf and strand funds on a keyless beneficiary. Totals must be multiples of `PayoutQuantum`; every payout is rounded down to a multiple and `claimed` advances only by the paid amount (stays aligned, final claim at `end` is exact). `end_schedule` sends sub-quantum vested dust to the treasury (signature-controlled, needs no leaf). +- **Proof recording:** the pallet records each pot → beneficiary payout via `TransferProofRecorder` itself (`pay_out` fuses transfer + record), so scheduler-enacted Root calls — invisible to the event-scanning extension — still create leaves; the extension skips pot-touching transfer events and charges no static weight for vesting calls. +- **Calls:** `claim`(0) — **permissionless**; pays the quantized `vested − claimed` from the pot to the schedule's stored beneficiary (never the caller); the only claim path for keyless/high-security beneficiaries. `create_schedule`(1) — admin; funds the pot from the treasury in the same call. `end_schedule`(2) — admin; quantized unpaid vested part → beneficiary, everything else → treasury, schedule removed. `retarget_schedule`(3) — admin; changes the beneficiary key only (lost-key remedy). +- Genesis build validates every schedule (`start ≤ cliff ≤ end`, `start < end`, `total ≥ ED`, `total % quantum = 0`, beneficiary ≠ pot) and asserts the pot's endowment exactly; a misconfigured chain refuses to start. `try_state` checks `pot balance ≥ Σ(total − claimed) + ED` and quantum alignment of `claimed`. --- @@ -221,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/scheduled native transfers). Statically pre-charged calls (`count_transfers`): `Balances` transfers, `Utility` wrappers, and `Vesting::{claim, create_schedule}` (1 transfer) / `Vesting::end_schedule` (2, worst case); uncounted paths are reconciled via `register_extra_weight_unchecked`. +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. 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/vesting/Cargo.toml b/pallets/vesting/Cargo.toml index 172d3bceb..d2e10aa57 100644 --- a/pallets/vesting/Cargo.toml +++ b/pallets/vesting/Cargo.toml @@ -22,6 +22,8 @@ frame-benchmarking = { optional = true, workspace = true, default-features = fal frame-support.workspace = true frame-system.workspace = true pallet-timestamp = { optional = true, workspace = true } +pallet-zk-tree.workspace = true +qp-wormhole.workspace = true scale-info = { workspace = true, default-features = false, features = ["derive"] } sp-arithmetic.workspace = true sp-runtime.workspace = true @@ -49,6 +51,8 @@ std = [ "frame-support/std", "frame-system/std", "pallet-timestamp?/std", + "pallet-zk-tree/std", + "qp-wormhole/std", "scale-info/std", "sp-arithmetic/std", "sp-runtime/std", @@ -56,4 +60,5 @@ std = [ try-runtime = [ "frame-support/try-runtime", "frame-system/try-runtime", + "pallet-zk-tree/try-runtime", ] diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index e33b91c22..5ef7666f3 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -49,6 +49,7 @@ pub mod pallet { PalletId, }; use frame_system::pallet_prelude::*; + use qp_wormhole::TransferProofRecorder; use sp_arithmetic::{helpers_128bit::multiply_by_rational_with_rounding, Rounding}; use sp_runtime::{ traits::{AccountIdConversion, CheckedAdd, Saturating, Zero}, @@ -82,6 +83,12 @@ pub mod pallet { } /// The in-code storage version. + /// + /// This pallet is deployed on fresh chains only: genesis endows the pot and seeds + /// the schedule table. There is deliberately no upgrade migration — if the pallet + /// ever were added to a live chain in place, the pot would simply start unfunded + /// and `create_schedule` fails loudly with [`Error::PotUnderfunded`] until the + /// treasury sends the pot its existential-deposit buffer. const STORAGE_VERSION: StorageVersion = StorageVersion::new(0); #[pallet::pallet] @@ -109,6 +116,30 @@ pub mod pallet { /// a treasury, in which case admin calls fail loudly. type TreasuryAccount: Get>; + /// Asset id type forwarded to the proof recorder (payouts are always native: + /// `None`). + type AssetId; + + /// Records beneficiary payouts as wormhole transfer proofs (ZK-tree leaves). + /// + /// The pallet records its payouts itself so that they are captured on **every** + /// dispatch origin — including Root calls enacted by the scheduler, which run + /// outside the signed-extrinsic lifecycle and are invisible to the event-scanning + /// `WormholeProofRecorderExtension`. The extension in turn skips pot-sourced + /// transfer events, so signed paths are not double-recorded. + type ProofRecorder: qp_wormhole::TransferProofRecorder< + Self::AccountId, + Self::AssetId, + BalanceOf, + >; + + /// Wormhole leaf amount quantum. ZK-tree leaves commit `amount / quantum`, so a + /// payout below one quantum would create a zero-value leaf: funds moved to a + /// keyless beneficiary would be irrecoverable. Every schedule total must be a + /// multiple of this, and every payout is rounded down to a multiple. + #[pallet::constant] + type PayoutQuantum: Get>; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -155,10 +186,12 @@ pub mod pallet { pub enum Error { /// No schedule exists under this id. NoSchedule, - /// Schedule parameters violate `start <= cliff <= end`, `start < end`, or - /// `total >= existential deposit`. + /// Schedule parameters violate `start <= cliff <= end`, `start < end`, + /// `total >= existential deposit`, or `total` is not a multiple of the payout + /// quantum. InvalidSchedule, - /// Nothing is claimable right now (before cliff, or already fully claimed). + /// Nothing is claimable right now (before the cliff, already fully claimed, or + /// less than one payout quantum accrued). NothingToClaim, /// The treasury account is not configured on this chain. TreasuryNotConfigured, @@ -225,6 +258,13 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { + fn integrity_test() { + assert!( + !T::PayoutQuantum::get().is_zero(), + "PayoutQuantum must be non-zero (it is a divisor)" + ); + } + #[cfg(feature = "try-runtime")] fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { Self::do_try_state() @@ -233,7 +273,10 @@ pub mod pallet { #[pallet::call] impl Pallet { - /// Pay out everything currently claimable on `schedule_id` to its beneficiary. + /// Pay out everything currently claimable on `schedule_id` to its beneficiary, + /// rounded down to a multiple of [`Config::PayoutQuantum`] (sub-quantum payouts + /// would create zero-value wormhole leaves and strand funds on keyless + /// beneficiaries). /// /// Permissionless: any signed account may call this for any schedule; the payout /// always goes to the stored beneficiary. This is the only claim path for @@ -246,18 +289,17 @@ pub mod pallet { let schedule = maybe_schedule.as_mut().ok_or(Error::::NoSchedule)?; let vested = Self::vested_amount(schedule, T::TimeProvider::now()); let owed = vested.saturating_sub(schedule.claimed); - ensure!(!owed.is_zero(), Error::::NothingToClaim); - T::Currency::transfer( - &Self::pot_account_id(), - &schedule.beneficiary, - owed, - Preservation::Preserve, - )?; - schedule.claimed = schedule.claimed.saturating_add(owed); + let payable = Self::quantize_down(owed); + ensure!(!payable.is_zero(), Error::::NothingToClaim); + Self::pay_out(&Self::pot_account_id(), &schedule.beneficiary, payable)?; + // `claimed` advances only by the transferred amount, so it stays + // quantum-aligned; totals are quantum-aligned too, hence the final claim + // at `end` pays out exactly and no dust is ever left behind. + schedule.claimed = schedule.claimed.saturating_add(payable); Self::deposit_event(Event::Claimed { schedule_id, beneficiary: schedule.beneficiary.clone(), - amount: owed, + amount: payable, }); Ok(()) }) @@ -315,8 +357,12 @@ pub mod pallet { Ok(()) } - /// End a schedule early: the still-unpaid vested part goes to the beneficiary, - /// the unvested remainder returns to the treasury, and the schedule is removed. + /// End a schedule early: the still-unpaid vested part (rounded down to a + /// [`Config::PayoutQuantum`] multiple) goes to the beneficiary, everything else + /// this schedule still holds — the unvested remainder plus any sub-quantum + /// vested dust — returns to the treasury, and the schedule is removed. The + /// treasury is signature-controlled and needs no wormhole leaf, so dust is safe + /// there but would be stranded on a keyless beneficiary. #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::end_schedule())] pub fn end_schedule(origin: OriginFor, schedule_id: u64) -> DispatchResult { @@ -325,20 +371,21 @@ pub mod pallet { let schedule = Schedules::::get(schedule_id).ok_or(Error::::NoSchedule)?; let pot = Self::pot_account_id(); let vested = Self::vested_amount(&schedule, T::TimeProvider::now()); - let owed = vested.saturating_sub(schedule.claimed); - let remainder = schedule.total.saturating_sub(vested); - if !owed.is_zero() { - T::Currency::transfer(&pot, &schedule.beneficiary, owed, Preservation::Preserve)?; + let vested_paid = Self::quantize_down(vested.saturating_sub(schedule.claimed)); + let unvested_returned = + schedule.total.saturating_sub(schedule.claimed).saturating_sub(vested_paid); + if !vested_paid.is_zero() { + Self::pay_out(&pot, &schedule.beneficiary, vested_paid)?; } - if !remainder.is_zero() { - T::Currency::transfer(&pot, &treasury, remainder, Preservation::Preserve)?; + if !unvested_returned.is_zero() { + T::Currency::transfer(&pot, &treasury, unvested_returned, Preservation::Preserve)?; } Schedules::::remove(schedule_id); Self::deposit_event(Event::ScheduleEnded { schedule_id, beneficiary: schedule.beneficiary, - vested_paid: owed, - unvested_returned: remainder, + vested_paid, + unvested_returned, }); Ok(()) } @@ -404,7 +451,35 @@ pub mod pallet { end: Moment, total: BalanceOf, ) -> bool { - start <= cliff && cliff <= end && start < end && total >= T::Currency::minimum_balance() + start <= cliff && + cliff <= end && start < end && + total >= T::Currency::minimum_balance() && + (total % T::PayoutQuantum::get()).is_zero() + } + + /// Round down to a multiple of the payout quantum. + fn quantize_down(amount: BalanceOf) -> BalanceOf { + amount.saturating_sub(amount % T::PayoutQuantum::get()) + } + + /// 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. + /// + /// The recorder is the same canonical entry point every recorded transfer on + /// this chain funnels through. It must be invoked here, with the payout, rather + /// than left to the event-scanning transaction extension: Root calls enacted by + /// the scheduler run outside the signed-extrinsic lifecycle and the extension + /// never sees them. The extension in turn skips pot-sourced transfer events, so + /// signed paths are not double-recorded. + fn pay_out( + pot: &T::AccountId, + beneficiary: &T::AccountId, + amount: BalanceOf, + ) -> DispatchResult { + T::Currency::transfer(pot, beneficiary, amount, Preservation::Preserve)?; + T::ProofRecorder::record_transfer_proof(None, pot.clone(), beneficiary.clone(), amount); + Ok(()) } /// Invariant: the pot covers all outstanding obligations plus its ED buffer, and @@ -432,6 +507,10 @@ pub mod pallet { schedule.claimed <= schedule.total, sp_runtime::TryRuntimeError::Other("claimed exceeds total") ); + frame_support::ensure!( + (schedule.claimed % T::PayoutQuantum::get()).is_zero(), + sp_runtime::TryRuntimeError::Other("claimed is not quantum-aligned") + ); frame_support::ensure!( schedule.beneficiary != pot, sp_runtime::TryRuntimeError::Other("pot is a beneficiary") diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs index 99acf18e4..fae0271d4 100644 --- a/pallets/vesting/src/mock.rs +++ b/pallets/vesting/src/mock.rs @@ -1,5 +1,6 @@ use crate as pallet_vesting; +use core::cell::RefCell; use frame_support::{ parameter_types, traits::{ConstU32, ConstU64, EitherOfDiverse, EnsureOrigin, Everything}, @@ -40,6 +41,9 @@ parameter_types! { 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; } impl frame_system::Config for Test { @@ -117,12 +121,44 @@ impl EnsureOrigin for EnsureTreasury { } } +/// One recorded payout proof: `(from, to, amount)`. +pub type RecordedProof = (AccountId32, AccountId32, Balance); + +thread_local! { + static RECORDED_PROOFS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Captures every payout the pallet records, for test assertions. +pub struct MockProofRecorder; + +impl MockProofRecorder { + pub fn recorded() -> Vec { + RECORDED_PROOFS.with(|proofs| proofs.borrow().clone()) + } +} + +impl qp_wormhole::TransferProofRecorder for MockProofRecorder { + fn record_transfer_proof( + asset_id: Option, + from: AccountId32, + to: AccountId32, + amount: Balance, + ) -> bool { + assert!(asset_id.is_none(), "vesting payouts are always native"); + RECORDED_PROOFS.with(|proofs| proofs.borrow_mut().push((from, to, amount))); + true + } +} + impl pallet_vesting::Config for Test { type Currency = Balances; type TimeProvider = Timestamp; type PalletId = VestingPalletId; type AdminOrigin = EitherOfDiverse, EnsureTreasury>; type TreasuryAccount = TreasuryAccount; + type AssetId = u32; + type ProofRecorder = MockProofRecorder; + type PayoutQuantum = PayoutQuantum; type WeightInfo = (); } @@ -161,6 +197,11 @@ pub fn new_test_ext_with_pot_balance( .unwrap(); let mut ext = sp_io::TestExternalities::new(t); - ext.execute_with(|| System::set_block_number(1)); + ext.execute_with(|| { + System::set_block_number(1); + // Stamp in-code storage versions like the real runtime genesis build does + // (this mock assembles storage from individual pallet configs, which skips it). + ::on_genesis(); + }); ext } diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index ea7350643..1a4735409 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -185,7 +185,9 @@ mod claim { #[test] fn below_ed_payout_to_nonexistent_account_fails_cleanly_then_succeeds_later() { new_test_ext(vec![(CHARLIE, START, START, END, TOTAL)]).execute_with(|| { - // 10 per ms; ED is 1_000, so 50ms in only 500 is owed. + // A quantum finer than the ED so the transfer itself is what fails: + // 10 per ms; at 50ms the quantized 500 is payable but below the 1_000 ED. + PayoutQuantum::set(100); set_time(START + 50); assert_noop!( Vesting::claim(RuntimeOrigin::signed(PINGER), 0), @@ -302,11 +304,12 @@ mod create_schedule { fn rejects_invalid_parameters() { new_test_ext(vec![]).execute_with(|| { let cases = [ - (CLIFF, START, END, TOTAL), // start > cliff - (START, END + 1, END, TOTAL), // cliff > end - (START, START, START, TOTAL), // start == end - (START, CLIFF, END, 999), // total < ED - (START, CLIFF, END, 0), // total == 0 + (CLIFF, START, END, TOTAL), // start > cliff + (START, END + 1, END, TOTAL), // cliff > end + (START, START, START, TOTAL), // start == end + (START, CLIFF, END, 999), // total < ED + (START, CLIFF, END, 0), // total == 0 + (START, CLIFF, END, TOTAL + 500), // total not quantum-aligned ]; for (start, cliff, end, total) in cases { assert_noop!( @@ -362,7 +365,7 @@ mod create_schedule { START, CLIFF, END, - TREASURY_FUNDS + 1 + TREASURY_FUNDS + 1_000 ), TokenError::FundsUnavailable ); @@ -588,6 +591,179 @@ mod genesis { } } +mod quantization { + use super::*; + + #[test] + fn sub_quantum_claims_are_rejected_not_paid() { + // The griefing scenario from review: per-block accrual above the ED but below + // one wormhole quantum. A third party claiming every block must get an error — + // never a sub-quantum payout that would land as a zero-value leaf. + new_test_ext(vec![(CHARLIE, START, START, END, TOTAL)]).execute_with(|| { + PayoutQuantum::set(3_000); + // 10 per ms: 250ms in, 2_500 accrued — above the 1_000 ED, below one quantum. + set_time(START + 250); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + Error::::NothingToClaim + ); + assert_eq!(stored(0).claimed, 0); + assert_eq!(free(&CHARLIE), 0); + assert!(MockProofRecorder::recorded().is_empty()); + }); + } + + #[test] + fn payouts_are_always_quantum_multiples_and_claimed_stays_aligned() { + // A total aligned to the coarser 3_000 quantum this test switches to. + const ALIGNED_TOTAL: u128 = 3_000_000; + const ALIGNED_END: u64 = START + 300_000; + new_test_ext(vec![(CHARLIE, START, START, ALIGNED_END, ALIGNED_TOTAL)]).execute_with( + || { + PayoutQuantum::set(3_000); + // 350ms in: 3_500 accrued -> pays exactly one quantum, 500 stays owed. + set_time(START + 350); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), 3_000); + assert_eq!(stored(0).claimed, 3_000); + // Immediately claiming again: only the 500 remainder accrued — rejected. + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + Error::::NothingToClaim + ); + // Repeated eager third-party claims can never strand value: at `end` + // the aligned total drains exactly. + set_time(ALIGNED_END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), ALIGNED_TOTAL); + assert_eq!(stored(0).claimed, ALIGNED_TOTAL); + assert_eq!(free(&pot()), ExistentialDeposit::get()); + for (_, _, amount) in MockProofRecorder::recorded() { + assert_eq!(amount % 3_000, 0, "every payout must be quantum-aligned"); + } + }, + ); + } + + #[test] + fn end_schedule_sends_sub_quantum_dust_to_treasury_not_the_beneficiary() { + new_test_ext(vec![(BOB, START, START, END, TOTAL)]).execute_with(|| { + PayoutQuantum::set(3_000); + let treasury_before = free(&TREASURY); + // 350ms in: 3_500 vested. Beneficiary gets the aligned 3_000; the 500 dust + // plus the 3_996_500 unvested remainder return to the treasury. + set_time(START + 350); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_eq!(free(&BOB), 3_000); + assert_eq!(free(&TREASURY), treasury_before + TOTAL - 3_000); + assert_eq!(free(&pot()), ExistentialDeposit::get()); + System::assert_last_event( + Event::ScheduleEnded { + schedule_id: 0, + beneficiary: BOB, + vested_paid: 3_000, + unvested_returned: TOTAL - 3_000, + } + .into(), + ); + }); + } +} + +mod proof_recording { + use super::*; + + #[test] + fn claim_records_the_payout() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(MockProofRecorder::recorded(), vec![(pot(), BOB, TOTAL / 2)]); + }); + } + + #[test] + fn end_schedule_records_only_the_beneficiary_payout() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + // Root origin — the scheduler-enacted governance path the event-scanning + // extension never sees; the pallet must record the payout itself. The + // treasury refund is signature-controlled and gets no leaf. + set_time(300_000); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::root(), 0)); + assert_eq!(MockProofRecorder::recorded(), vec![(pot(), BOB, TOTAL / 2)]); + }); + } + + #[test] + fn failed_and_zero_payouts_record_nothing() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(CLIFF - 1); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(BOB), 0), + Error::::NothingToClaim + ); + // Ending before the cliff pays the beneficiary nothing: no payout, no leaf. + assert_ok!(Vesting::end_schedule(RuntimeOrigin::root(), 0)); + assert!(MockProofRecorder::recorded().is_empty()); + }); + } + + #[test] + fn create_and_retarget_record_nothing() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + CHARLIE, + START, + CLIFF, + END, + TOTAL + )); + assert_ok!(Vesting::retarget_schedule(RuntimeOrigin::root(), 0, ALICE)); + assert!(MockProofRecorder::recorded().is_empty()); + }); + } +} + +mod unfunded_pot_bootstrap { + use super::*; + + #[test] + fn create_is_blocked_loudly_until_the_pot_gets_its_ed_buffer() { + // The state of any chain where genesis did not endow the pot (this pallet is + // deployed on fresh chains, so normally genesis does): schedule creation fails + // with an explicit error until the treasury sends the pot one ED — the + // documented manual bootstrap, no migration involved. + new_test_ext_with_pot_balance(vec![], 0).execute_with(|| { + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + START, + CLIFF, + END, + TOTAL + ), + Error::::PotUnderfunded + ); + assert_ok!(Balances::transfer_keep_alive( + RuntimeOrigin::signed(TREASURY), + pot(), + ExistentialDeposit::get() + )); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::signed(TREASURY), + BOB, + START, + CLIFF, + END, + TOTAL + )); + assert_ok!(Vesting::do_try_state()); + }); + } +} + mod try_state { use super::*; diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index 9dfffcacc..844229760 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -1,51 +1,17 @@ -// This file is part of Substrate. - -// Copyright (C) Parity Technologies (UK) Ltd. -// SPDX-License-Identifier: Apache-2.0 - -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - - -//! Autogenerated weights for `pallet_vesting` +//! Weights for `pallet_vesting`. //! -//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 -//! DATE: 2026-08-07, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` -//! WORST CASE MAP SIZE: `1000000` -//! HOSTNAME: `Arunachala.local`, CPU: `` -//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` - -// Executed Command: -// ./target/release/quantus-node -// benchmark -// pallet -// --pallet=pallet_vesting -// --steps=50 -// --repeat=20 -// --runtime=./target/release/wbuild/quantus-runtime/quantus_runtime.wasm -// --genesis-builder=runtime -// --extrinsic=* -// --wasm-execution=compiled -// --heap-pages=4096 -// --template=./.maintain/frame-weight-template.hbs -// --output=./pallets/vesting/src/weights.rs +//! Base numbers benchmarked with the Substrate benchmark CLI (STEPS 50, REPEAT 20, +//! WASM compiled) via `scripts/regenerate_weights.sh`. `claim` and `end_schedule` +//! record a wormhole transfer proof, whose ZK-tree leaf insert walks the tree +//! leaf-to-root — their weight therefore adds depth-dependent tree ops on top of the +//! benchmarked base. **Keep this augmentation when regenerating from benchmarks** +//! (benchmarks measure the compute base only, at benchmark-time tree depth). #![cfg_attr(rustfmt, rustfmt_skip)] #![allow(unused_parens)] -#![allow(unused_imports)] #![allow(missing_docs)] -#![allow(dead_code)] -use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use frame_support::{traits::Get, weights::{Weight, RuntimeDbWeight, constants::RocksDbWeight}}; use core::marker::PhantomData; /// Weight functions needed for `pallet_vesting`. @@ -56,23 +22,54 @@ pub trait WeightInfo { fn retarget_schedule() -> Weight; } +/// Non-tree storage ops of `claim`: `Vesting::Schedules` (r:1 w:1), `Timestamp::Now` +/// (r:1 w:0), `System::Account` (r:2 w:2), `Wormhole::TransferCount` (r:1 w:1). +const CLAIM_BASE_READS: u64 = 5; +const CLAIM_BASE_WRITES: u64 = 4; + +/// Non-tree storage ops of `end_schedule`: `TreasuryPallet::TreasuryAccount` (r:1 w:0), +/// `Vesting::Schedules` (r:1 w:1), `Timestamp::Now` (r:1 w:0), `System::Account` +/// (r:3 w:3), `Wormhole::TransferCount` (r:1 w:1). +const END_SCHEDULE_BASE_READS: u64 = 7; +const END_SCHEDULE_BASE_WRITES: u64 = 5; + +/// 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`]. +fn claim_weight(db: RuntimeDbWeight, (tree_reads, tree_writes): (u64, u64)) -> Weight { + // Minimum execution time: 111_000_000 picoseconds. + Weight::from_parts(114_000_000, 8619) + .saturating_add(Weight::from_parts( + 0, + tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), + )) + .saturating_add(db.reads(CLAIM_BASE_READS.saturating_add(tree_reads))) + .saturating_add(db.writes(CLAIM_BASE_WRITES.saturating_add(tree_writes))) +} + +/// See [`claim_weight`]. +fn end_schedule_weight(db: RuntimeDbWeight, (tree_reads, tree_writes): (u64, u64)) -> Weight { + // Minimum execution time: 136_000_000 picoseconds. + Weight::from_parts(138_000_000, 8799) + .saturating_add(Weight::from_parts( + 0, + tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), + )) + .saturating_add(db.reads(END_SCHEDULE_BASE_READS.saturating_add(tree_reads))) + .saturating_add(db.writes(END_SCHEDULE_BASE_WRITES.saturating_add(tree_writes))) +} + /// Weights for `pallet_vesting` using the Substrate node and recommended hardware. +/// +/// Bounded on `pallet_zk_tree::Config` because the payout calls' weight reads the +/// current tree depth: paying out records a wormhole proof, which inserts a ZK-tree +/// leaf whose storage cost grows with depth. pub struct SubstrateWeight(PhantomData); -impl WeightInfo for SubstrateWeight { - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `Timestamp::Now` (r:1 w:0) - /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) +impl WeightInfo for SubstrateWeight { + /// [`claim_weight`] — benchmarked base plus live-depth tree ops. fn claim() -> Weight { - // Proof Size summary in bytes: - // Measured: `574` - // Estimated: `6196` - // Minimum execution time: 53_000_000 picoseconds. - Weight::from_parts(56_000_000, 6196) - .saturating_add(T::DbWeight::get().reads(4_u64)) - .saturating_add(T::DbWeight::get().writes(3_u64)) + claim_weight(T::DbWeight::get(), pallet_zk_tree::Pallet::::insert_leaf_db_ops()) } /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) @@ -83,37 +80,18 @@ impl WeightInfo for SubstrateWeight { /// Storage: `Vesting::Schedules` (r:0 w:1) /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) fn create_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `474` - // Estimated: `6196` - // Minimum execution time: 51_000_000 picoseconds. - Weight::from_parts(52_000_000, 6196) + // Minimum execution time: 58_000_000 picoseconds. + Weight::from_parts(63_000_000, 6196) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } - /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) - /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `Timestamp::Now` (r:1 w:0) - /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:3 w:3) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// [`end_schedule_weight`] — benchmarked base plus live-depth tree ops. fn end_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `798` - // Estimated: `8799` - // Minimum execution time: 92_000_000 picoseconds. - Weight::from_parts(95_000_000, 8799) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) + end_schedule_weight(T::DbWeight::get(), pallet_zk_tree::Pallet::::insert_leaf_db_ops()) } /// Storage: `Vesting::Schedules` (r:1 w:1) /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) fn retarget_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `308` - // Estimated: `3569` // Minimum execution time: 10_000_000 picoseconds. Weight::from_parts(11_000_000, 3569) .saturating_add(T::DbWeight::get().reads(1_u64)) @@ -121,64 +99,27 @@ impl WeightInfo for SubstrateWeight { } } -// For backwards compatibility and tests. +// For backwards compatibility and tests: charges the worst-case (max-depth) tree walk +// since it cannot read the live depth without a `pallet_zk_tree::Config` bound. impl WeightInfo for () { - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `Timestamp::Now` (r:1 w:0) - /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn claim() -> Weight { - // Proof Size summary in bytes: - // Measured: `574` - // Estimated: `6196` - // Minimum execution time: 53_000_000 picoseconds. - Weight::from_parts(56_000_000, 6196) - .saturating_add(RocksDbWeight::get().reads(4_u64)) - .saturating_add(RocksDbWeight::get().writes(3_u64)) + claim_weight( + RocksDbWeight::get(), + pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH), + ) } - /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) - /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Vesting::NextScheduleId` (r:1 w:1) - /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Schedules` (r:0 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) fn create_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `474` - // Estimated: `6196` - // Minimum execution time: 51_000_000 picoseconds. - Weight::from_parts(52_000_000, 6196) + Weight::from_parts(63_000_000, 6196) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } - /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) - /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `Timestamp::Now` (r:1 w:0) - /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:3 w:3) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) fn end_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `798` - // Estimated: `8799` - // Minimum execution time: 92_000_000 picoseconds. - Weight::from_parts(95_000_000, 8799) - .saturating_add(RocksDbWeight::get().reads(6_u64)) - .saturating_add(RocksDbWeight::get().writes(4_u64)) + end_schedule_weight( + RocksDbWeight::get(), + pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH), + ) } - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) fn retarget_schedule() -> Weight { - // Proof Size summary in bytes: - // Measured: `308` - // Estimated: `3569` - // Minimum execution time: 10_000_000 picoseconds. Weight::from_parts(11_000_000, 3569) .saturating_add(RocksDbWeight::get().reads(1_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index c73af069e..e0cd301d0 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -529,6 +529,10 @@ impl pallet_treasury::Config for Runtime { parameter_types! { pub const VestingPalletId: PalletId = PalletId(*b"qvesting"); + /// Vesting payouts are rounded down to multiples of the wormhole leaf quantum + /// (`SCALE_DOWN_FACTOR`): a sub-quantum transfer would be committed as a + /// zero-value leaf, stranding funds paid to keyless beneficiaries. + pub const VestingPayoutQuantum: Balance = pallet_wormhole::SCALE_DOWN_FACTOR; } /// The configured treasury account as an `Option` — unlike @@ -570,6 +574,12 @@ impl pallet_vesting::Config for Runtime { type PalletId = VestingPalletId; type AdminOrigin = EitherOfDiverse, EnsureTreasury>; type TreasuryAccount = TreasuryAccountOption; + type AssetId = AssetId; + // The pallet records its payouts itself so Root calls enacted by the scheduler + // (invisible to the event-scanning extension) still create ZK-tree leaves; the + // extension skips pot-sourced events to avoid double-recording signed paths. + type ProofRecorder = Wormhole; + type PayoutQuantum = VestingPayoutQuantum; type WeightInfo = pallet_vesting::weights::SubstrateWeight; } diff --git a/runtime/src/transaction_extensions.rs b/runtime/src/transaction_extensions.rs index c4555b8ad..6fab51abf 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -135,13 +135,6 @@ impl WormholeProofRecorderExtension RuntimeCall::Balances(pallet_balances::Call::transfer_all { .. }) | RuntimeCall::Balances(pallet_balances::Call::force_transfer { .. }) => 1, - // Vesting payouts are plain pot transfers recorded like any other. `end_schedule` - // makes up to two (beneficiary payout + treasury refund); charge the worst case, - // consistent with `if_else` below. `retarget_schedule` moves no funds. - RuntimeCall::Vesting(pallet_vesting::Call::claim { .. }) | - RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { .. }) => 1, - RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { .. }) => 2, - RuntimeCall::Utility(pallet_utility::Call::batch { calls }) | RuntimeCall::Utility(pallet_utility::Call::batch_all { calls }) | RuntimeCall::Utility(pallet_utility::Call::force_batch { calls }) => @@ -161,6 +154,11 @@ impl WormholeProofRecorderExtension RuntimeCall::Utility(pallet_utility::Call::if_else { main, fallback }) => Self::count_transfers(main).max(Self::count_transfers(fallback)), + // Vesting calls fall through to 0 deliberately: the pallet records its + // payouts itself (so Root calls enacted by the scheduler are captured too) + // 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. _ => 0, } } @@ -183,6 +181,14 @@ impl WormholeProofRecorderExtension // If we modify Events storage during iteration (by depositing new events), // the cached data becomes stale and decoding fails. + // The vesting pot's flows are excluded: the vesting pallet records its own + // pot -> beneficiary payouts (also for scheduler-enacted Root calls this + // extension never sees), so scanning them here would double-record; and pot + // 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(); + // Collect transfers to record - (asset_id, from, to, amount) let transfers_to_record: alloc::vec::Vec<(Option, AccountId, AccountId, Balance)> = frame_system::Pallet::::read_events_no_consensus() @@ -194,7 +200,7 @@ impl WormholeProofRecorderExtension from, to, amount, - }) => Some((None, from, to, amount)), + }) if from != vesting_pot && to != vesting_pot => Some((None, from, to, amount)), // Native balance mints RuntimeEvent::Balances(pallet_balances::Event::Minted { who, amount }) => { let minting_account = crate::configs::MintingAccount::get(); @@ -773,32 +779,42 @@ mod tests { } #[test] - fn wormhole_proof_recorder_counts_vesting_calls() { + fn wormhole_proof_recorder_ignores_vesting_calls_and_pot_events() { new_test_ext().execute_with(|| { - // `claim` and `create_schedule` each move funds once; `end_schedule` up to twice - // (beneficiary payout + treasury refund, worst case); `retarget_schedule` never. - // Admin calls executed *through* `Multisig::execute` are statically opaque and - // rely on the post_dispatch shortfall reconciliation instead. - let claim = RuntimeCall::Vesting(pallet_vesting::Call::claim { schedule_id: 0 }); - assert_eq!(WormholeProofRecorderExtension::::count_transfers(&claim), 1); - - let create = RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { - beneficiary: alice(), - start: 0, - cliff: 0, - end: 1, - total: 1, - }); - assert_eq!(WormholeProofRecorderExtension::::count_transfers(&create), 1); - - let end = RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { schedule_id: 0 }); - assert_eq!(WormholeProofRecorderExtension::::count_transfers(&end), 2); + // Vesting calls charge no extension weight: the pallet records its own + // payouts (covering scheduler-enacted Root calls too) and its benchmarked + // weights carry that cost, while the event scan skips pot-touching + // transfers. + for call in [ + RuntimeCall::Vesting(pallet_vesting::Call::claim { schedule_id: 0 }), + RuntimeCall::Vesting(pallet_vesting::Call::create_schedule { + beneficiary: alice(), + start: 0, + cliff: 0, + end: 1, + total: 1, + }), + RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { schedule_id: 0 }), + RuntimeCall::Vesting(pallet_vesting::Call::retarget_schedule { + schedule_id: 0, + new_beneficiary: alice(), + }), + ] { + assert_eq!(WormholeProofRecorderExtension::::count_transfers(&call), 0); + } - let retarget = RuntimeCall::Vesting(pallet_vesting::Call::retarget_schedule { - schedule_id: 0, - new_beneficiary: alice(), - }); - assert_eq!(WormholeProofRecorderExtension::::count_transfers(&retarget), 0); + // And the event scan must not record pot-sourced payouts (the pallet + // already did): a pot -> alice transfer event yields no new proof. + System::set_block_number(1); + let pot = pallet_vesting::Pallet::::pot_account_id(); + let count_before = Wormhole::transfer_count(&alice()); + System::deposit_event(RuntimeEvent::Balances(pallet_balances::Event::Transfer { + from: pot, + to: alice(), + amount: EXISTENTIAL_DEPOSIT * 100, + })); + WormholeProofRecorderExtension::::record_proofs_from_events_since(0); + assert_eq!(Wormhole::transfer_count(&alice()), count_before); }); } diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index f157d5f87..0de810ca6 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -7,10 +7,9 @@ mod tests { use codec::Encode; use frame_support::{assert_noop, assert_ok, traits::Currency}; use pallet_multisig::BoundedCallOf; - use qp_wormhole::TransferProofRecorder; use quantus_runtime::{ - AccountId, AssetId, Balance, Balances, Multisig, Runtime, RuntimeCall, RuntimeEvent, - RuntimeOrigin, System, Vesting, Wormhole, EXISTENTIAL_DEPOSIT, UNIT, + AccountId, Balance, Balances, Multisig, Runtime, RuntimeCall, RuntimeEvent, RuntimeOrigin, + System, Vesting, Wormhole, EXISTENTIAL_DEPOSIT, UNIT, }; use sp_core::crypto::AccountId32; use sp_runtime::{BuildStorage, DispatchError, Permill}; @@ -178,7 +177,7 @@ mod tests { } #[test] - fn claim_payout_event_feeds_the_wormhole_proof_recorder() { + fn claim_records_exactly_one_wormhole_leaf_for_the_beneficiary() { new_test_ext(Some(account(4))).execute_with(|| { Balances::make_free_balance_be(&account(4), 1000 * UNIT); // The beneficiary never signs anything — exactly like a wormhole address. @@ -194,10 +193,17 @@ mod tests { )); set_time(END_MS); System::reset_events(); + let count_before = Wormhole::transfer_count(&beneficiary); assert_ok!(Vesting::claim(RuntimeOrigin::signed(account(1)), 0)); - // The payout is an ordinary `Balances::Transfer` — the exact event shape the - // `WormholeProofRecorderExtension` scans in `post_dispatch`. + // The pallet records the payout itself, exactly once — this ZK-tree leaf is + // what lets a wormhole owner later exit the funds via ZK proof. (The + // event-scanning extension skips pot-sourced transfers, so signed + // submissions don't add a second leaf — covered by the extension's own + // unit test.) + assert_eq!(Wormhole::transfer_count(&beneficiary), count_before + 1); + + // The payout itself is an ordinary keep-alive `Transfer` from the pot. let payout = System::events() .into_iter() .find_map(|record| match record.event { @@ -210,13 +216,58 @@ mod tests { }) .expect("claim must emit a plain Transfer event from the pot"); assert_eq!(payout, GRANT); + }); + } + + #[test] + fn scheduler_enacted_root_end_schedule_records_the_payout_leaf() { + use frame_support::traits::{ + schedule::{v3::Anon, DispatchTime}, + Hooks, StorePreimage, + }; + use quantus_runtime::{OriginCaller, Scheduler}; + + new_test_ext(Some(account(4))).execute_with(|| { + Balances::make_free_balance_be(&account(4), 1000 * UNIT); + let beneficiary = account(9); + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::root(), + beneficiary.clone(), + 0, + 0, + END_MS, + GRANT, + )); + // Halfway vested at enactment time. + set_time(END_MS / 2); + + // Schedule `end_schedule` as Root — the exact shape of a governance + // enactment. The scheduler dispatches it from `on_initialize`, entirely + // outside the signed-extrinsic pipeline: no transaction extension runs. + let call = RuntimeCall::Vesting(pallet_vesting::Call::end_schedule { schedule_id: 0 }); + let bounded = ::Preimages::bound(call) + .expect("small call bounds inline"); + assert_ok!(>::schedule( + DispatchTime::At(3), + None, + 0, + OriginCaller::system(frame_system::RawOrigin::Root), + bounded, + )); - // Feeding that event into the recorder (as post_dispatch does) records a - // ZK-tree leaf for the beneficiary — this is what lets a wormhole owner - // later exit the funds via ZK proof. let count_before = Wormhole::transfer_count(&beneficiary); - assert!(>:: - record_transfer_proof(None, pot, beneficiary.clone(), payout)); + while System::block_number() < 3 { + let block = System::block_number(); + Scheduler::on_finalize(block); + System::set_block_number(block + 1); + Scheduler::on_initialize(block + 1); + } + + // The schedule was ended by the hook-dispatched Root call: the beneficiary + // got the vested half, the treasury the rest — and the payout leaf exists + // even though no extension ever saw this dispatch. + assert!(pallet_vesting::Schedules::::get(0).is_none()); + assert_eq!(Balances::total_balance(&beneficiary), GRANT / 2); assert_eq!(Wormhole::transfer_count(&beneficiary), count_before + 1); }); } From df8a0cc044df7a432f8621a37d81dc8d6bb276d6 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 8 Aug 2026 11:13:44 +0800 Subject: [PATCH 3/6] fix: harden vesting payouts --- pallets/vesting/src/benchmarking.rs | 19 +- pallets/vesting/src/lib.rs | 182 +++++++++++++--- pallets/vesting/src/mock.rs | 4 + pallets/vesting/src/tests.rs | 170 +++++++++++++-- pallets/vesting/src/weights.rs | 173 +++++++-------- pallets/vesting/src/weights_generated.rs | 258 +++++++++++++++++++++++ runtime/src/configs/mod.rs | 6 + runtime/tests/governance/vesting.rs | 19 ++ scripts/regenerate_weights.sh | 7 +- 9 files changed, 691 insertions(+), 147 deletions(-) create mode 100644 pallets/vesting/src/weights_generated.rs diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs index d0a19d501..7d537bdc4 100644 --- a/pallets/vesting/src/benchmarking.rs +++ b/pallets/vesting/src/benchmarking.rs @@ -22,8 +22,15 @@ fn fund(who: &T::AccountId, amount: BalanceOf) { T::Currency::mint_into(who, amount).expect("minting benchmark funds must succeed"); } +fn benchmark_total() -> BalanceOf { + T::MinimumPayout::get().saturating_mul(1000u32.into()) +} + fn admin_origin() -> Result { - T::AdminOrigin::try_successful_origin().map_err(|_| BenchmarkError::Stop("no admin origin")) + let origin: T::RuntimeOrigin = RawOrigin::Signed(treasury::()?).into(); + T::AdminOrigin::try_origin(origin.clone()) + .map_err(|_| BenchmarkError::Stop("signed treasury is not an admin origin"))?; + Ok(origin) } fn treasury() -> Result { @@ -43,6 +50,7 @@ fn seed_schedule(beneficiary: T::AccountId, total: BalanceOf) -> u end: END, total, claimed: Zero::zero(), + last_claim_at: None, }, ); fund::( @@ -59,7 +67,7 @@ mod benchmarks { #[benchmark] fn claim() -> Result<(), BenchmarkError> { let beneficiary: T::AccountId = account("beneficiary", 0, 0); - let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let total = benchmark_total::(); let schedule_id = seed_schedule::(beneficiary.clone(), total); set_time::(END); let caller: T::AccountId = whitelisted_caller(); @@ -76,7 +84,7 @@ mod benchmarks { let origin = admin_origin::()?; let treasury = treasury::()?; let ed = T::Currency::minimum_balance(); - let total = ed.saturating_mul(1000u32.into()); + let total = benchmark_total::(); fund::(&treasury, total.saturating_mul(2u32.into())); fund::(&Vesting::::pot_account_id(), ed); let beneficiary: T::AccountId = account("beneficiary", 0, 0); @@ -94,7 +102,7 @@ mod benchmarks { let treasury = treasury::()?; fund::(&treasury, T::Currency::minimum_balance()); let beneficiary: T::AccountId = account("beneficiary", 0, 0); - let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let total = benchmark_total::(); let schedule_id = seed_schedule::(beneficiary.clone(), total); // Mid-vesting: both the beneficiary payout and the treasury refund execute. set_time::(END / 2); @@ -112,8 +120,9 @@ mod benchmarks { let origin = admin_origin::()?; let beneficiary: T::AccountId = account("beneficiary", 0, 0); let new_beneficiary: T::AccountId = account("new-beneficiary", 0, 0); - let total = T::Currency::minimum_balance().saturating_mul(1000u32.into()); + let total = benchmark_total::(); let schedule_id = seed_schedule::(beneficiary, total); + set_time::(END / 2); #[extrinsic_call] _(origin as T::RuntimeOrigin, schedule_id, new_beneficiary.clone()); diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index 5ef7666f3..dd7f7d952 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -18,7 +18,8 @@ //! The admin origin (the treasury account, with Root as break-glass) can create schedules //! (funded from the treasury in the same call), end them early (vested part to the //! beneficiary, unvested remainder back to the treasury), and retarget a schedule's -//! beneficiary (lost-key remedy). +//! beneficiary after settling any payout a permissionless claim could force (lost-key +//! remedy). extern crate alloc; @@ -33,6 +34,7 @@ mod tests; #[cfg(feature = "runtime-benchmarks")] mod benchmarking; pub mod weights; +mod weights_generated; pub use weights::*; #[frame_support::pallet] @@ -52,7 +54,7 @@ pub mod pallet { use qp_wormhole::TransferProofRecorder; use sp_arithmetic::{helpers_128bit::multiply_by_rational_with_rounding, Rounding}; use sp_runtime::{ - traits::{AccountIdConversion, CheckedAdd, Saturating, Zero}, + traits::{AccountIdConversion, CheckedAdd, CheckedSub, Zero}, ArithmeticError, SaturatedConversion, }; @@ -80,6 +82,15 @@ pub mod pallet { pub total: Balance, /// Already paid out. pub claimed: Balance, + /// Timestamp of the last successful beneficiary payout. + pub last_claim_at: Option, + } + + enum ClaimPlan { + Pay(Balance), + NothingToClaim, + TooSoon, + WouldLeaveDust, } /// The in-code storage version. @@ -140,6 +151,15 @@ pub mod pallet { #[pallet::constant] type PayoutQuantum: Get>; + /// Smallest beneficiary payout. Must be quantum-aligned, at least two quanta, + /// and larger than the existential deposit. + #[pallet::constant] + type MinimumPayout: Get>; + + /// Minimum elapsed milliseconds between successful claims on one schedule. + #[pallet::constant] + type MinClaimInterval: Get; + /// Weight information for extrinsics in this pallet. type WeightInfo: WeightInfo; } @@ -174,11 +194,12 @@ pub mod pallet { vested_paid: BalanceOf, unvested_returned: BalanceOf, }, - /// A schedule's beneficiary was changed. + /// A schedule's beneficiary was changed after settling any currently claimable payout. ScheduleRetargeted { schedule_id: u64, old_beneficiary: T::AccountId, new_beneficiary: T::AccountId, + vested_paid: BalanceOf, }, } @@ -187,12 +208,19 @@ pub mod pallet { /// No schedule exists under this id. NoSchedule, /// Schedule parameters violate `start <= cliff <= end`, `start < end`, - /// `total >= existential deposit`, or `total` is not a multiple of the payout + /// `total >= MinimumPayout`, or `total` is not a multiple of the payout /// quantum. InvalidSchedule, /// Nothing is claimable right now (before the cliff, already fully claimed, or - /// less than one payout quantum accrued). + /// less than the minimum payout accrued). NothingToClaim, + /// This schedule has already paid out within the minimum claim interval. + ClaimTooSoon, + /// Paying now would leave a remainder below the minimum payout; wait until the + /// entire remainder has vested. + ClaimWouldLeaveDust, + /// Ending now would emit a non-zero beneficiary payout below the minimum. + PayoutBelowMinimum, /// The treasury account is not configured on this chain. TreasuryNotConfigured, /// The pot does not hold its existential-deposit buffer; endow it first. @@ -244,12 +272,16 @@ pub mod pallet { end: *end, total, claimed: Zero::zero(), + last_claim_at: None, }, ); } NextScheduleId::::put(self.schedules.len() as u64); + let required = sum + .checked_add(&ed) + .expect("vesting genesis: obligations plus existential deposit overflow Balance"); assert!( - T::Currency::total_balance(&pot) == sum.saturating_add(ed), + T::Currency::total_balance(&pot) == required, "vesting genesis: pot balance must equal sum of schedule totals plus the \ existential deposit" ); @@ -259,10 +291,19 @@ pub mod pallet { #[pallet::hooks] impl Hooks> for Pallet { fn integrity_test() { + let quantum = T::PayoutQuantum::get(); + let minimum = T::MinimumPayout::get(); + assert!(!quantum.is_zero(), "PayoutQuantum must be non-zero (it is a divisor)"); assert!( - !T::PayoutQuantum::get().is_zero(), - "PayoutQuantum must be non-zero (it is a divisor)" + minimum > T::Currency::minimum_balance(), + "MinimumPayout must exceed the existential deposit" ); + assert!((minimum % quantum).is_zero(), "MinimumPayout must be quantum-aligned"); + let two_quanta = quantum + .checked_add(&quantum) + .expect("two payout quanta must fit the balance type"); + assert!(minimum >= two_quanta, "MinimumPayout must contain at least two quanta"); + assert!(!T::MinClaimInterval::get().is_zero(), "MinClaimInterval must be non-zero"); } #[cfg(feature = "try-runtime")] @@ -273,10 +314,10 @@ pub mod pallet { #[pallet::call] impl Pallet { - /// Pay out everything currently claimable on `schedule_id` to its beneficiary, - /// rounded down to a multiple of [`Config::PayoutQuantum`] (sub-quantum payouts - /// would create zero-value wormhole leaves and strand funds on keyless - /// beneficiaries). + /// Pay the largest valid claim on `schedule_id` to its beneficiary. Payouts are + /// rounded down to [`Config::PayoutQuantum`], must meet [`Config::MinimumPayout`], + /// and reserve at least one minimum-sized final claim unless the schedule is fully + /// vested. /// /// Permissionless: any signed account may call this for any schedule; the payout /// always goes to the stored beneficiary. This is the only claim path for @@ -287,15 +328,17 @@ pub mod pallet { ensure_signed(origin)?; Schedules::::try_mutate(schedule_id, |maybe_schedule| { let schedule = maybe_schedule.as_mut().ok_or(Error::::NoSchedule)?; - let vested = Self::vested_amount(schedule, T::TimeProvider::now()); - let owed = vested.saturating_sub(schedule.claimed); - let payable = Self::quantize_down(owed); - ensure!(!payable.is_zero(), Error::::NothingToClaim); + let now = T::TimeProvider::now(); + let payable = match Self::claim_plan(schedule, now)? { + ClaimPlan::Pay(amount) => amount, + ClaimPlan::NothingToClaim => return Err(Error::::NothingToClaim.into()), + ClaimPlan::TooSoon => return Err(Error::::ClaimTooSoon.into()), + ClaimPlan::WouldLeaveDust => return Err(Error::::ClaimWouldLeaveDust.into()), + }; Self::pay_out(&Self::pot_account_id(), &schedule.beneficiary, payable)?; - // `claimed` advances only by the transferred amount, so it stays - // quantum-aligned; totals are quantum-aligned too, hence the final claim - // at `end` pays out exactly and no dust is ever left behind. - schedule.claimed = schedule.claimed.saturating_add(payable); + schedule.claimed = + schedule.claimed.checked_add(&payable).ok_or(ArithmeticError::Overflow)?; + schedule.last_claim_at = Some(now); Self::deposit_event(Event::Claimed { schedule_id, beneficiary: schedule.beneficiary.clone(), @@ -344,6 +387,7 @@ pub mod pallet { end, total, claimed: Zero::zero(), + last_claim_at: None, }, ); Self::deposit_event(Event::ScheduleCreated { @@ -362,7 +406,8 @@ pub mod pallet { /// this schedule still holds — the unvested remainder plus any sub-quantum /// vested dust — returns to the treasury, and the schedule is removed. The /// treasury is signature-controlled and needs no wormhole leaf, so dust is safe - /// there but would be stranded on a keyless beneficiary. + /// there but would be stranded on a keyless beneficiary. A non-zero beneficiary + /// payout below [`Config::MinimumPayout`] is rejected without ending the schedule. #[pallet::call_index(2)] #[pallet::weight(T::WeightInfo::end_schedule())] pub fn end_schedule(origin: OriginFor, schedule_id: u64) -> DispatchResult { @@ -371,9 +416,19 @@ pub mod pallet { let schedule = Schedules::::get(schedule_id).ok_or(Error::::NoSchedule)?; let pot = Self::pot_account_id(); let vested = Self::vested_amount(&schedule, T::TimeProvider::now()); - let vested_paid = Self::quantize_down(vested.saturating_sub(schedule.claimed)); + let unpaid_vested = + vested.checked_sub(&schedule.claimed).ok_or(ArithmeticError::Underflow)?; + let vested_paid = Self::quantize_down(unpaid_vested); + ensure!( + vested_paid.is_zero() || vested_paid >= T::MinimumPayout::get(), + Error::::PayoutBelowMinimum + ); + let remaining = schedule + .total + .checked_sub(&schedule.claimed) + .ok_or(ArithmeticError::Underflow)?; let unvested_returned = - schedule.total.saturating_sub(schedule.claimed).saturating_sub(vested_paid); + remaining.checked_sub(&vested_paid).ok_or(ArithmeticError::Underflow)?; if !vested_paid.is_zero() { Self::pay_out(&pot, &schedule.beneficiary, vested_paid)?; } @@ -390,8 +445,8 @@ pub mod pallet { Ok(()) } - /// Change a schedule's beneficiary; everything else, including `claimed`, is - /// untouched. Remedy for a lost key or migration to a multisig. + /// Settle any payout a permissionless claim could currently force, then change the + /// beneficiary. This makes retargeting independent of claim transaction ordering. #[pallet::call_index(3)] #[pallet::weight(T::WeightInfo::retarget_schedule())] pub fn retarget_schedule( @@ -404,12 +459,27 @@ pub mod pallet { Schedules::::try_mutate(schedule_id, |maybe_schedule| { let schedule = maybe_schedule.as_mut().ok_or(Error::::NoSchedule)?; ensure!(new_beneficiary != schedule.beneficiary, Error::::InvalidBeneficiary); + 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); + amount + }, + ClaimPlan::NothingToClaim | ClaimPlan::TooSoon | ClaimPlan::WouldLeaveDust => + Zero::zero(), + }; let old_beneficiary = core::mem::replace(&mut schedule.beneficiary, new_beneficiary.clone()); Self::deposit_event(Event::ScheduleRetargeted { schedule_id, old_beneficiary, new_beneficiary, + vested_paid, }); Ok(()) }) @@ -441,7 +511,7 @@ pub mod pallet { // zero divisor, which the branches above rule out. let vested = multiply_by_rational_with_rounding(total, elapsed, duration, Rounding::Down) - .unwrap_or(total); + .expect("validated schedule duration is non-zero"); vested.saturated_into() } @@ -453,13 +523,48 @@ pub mod pallet { ) -> bool { start <= cliff && cliff <= end && start < end && - total >= T::Currency::minimum_balance() && + total >= T::MinimumPayout::get() && (total % T::PayoutQuantum::get()).is_zero() } + fn claim_plan( + schedule: &VestingScheduleOf, + now: Moment, + ) -> Result>, ArithmeticError> { + let remaining = schedule + .total + .checked_sub(&schedule.claimed) + .ok_or(ArithmeticError::Underflow)?; + let vested = Self::vested_amount(schedule, now); + let owed = vested.checked_sub(&schedule.claimed).ok_or(ArithmeticError::Underflow)?; + let candidate = Self::quantize_down(owed); + let minimum = T::MinimumPayout::get(); + if candidate < minimum { + return Ok(ClaimPlan::NothingToClaim); + } + let payable = if candidate == remaining { + candidate + } else { + let max_non_final = + remaining.checked_sub(&minimum).ok_or(ArithmeticError::Underflow)?; + if max_non_final < minimum { + return Ok(ClaimPlan::WouldLeaveDust); + } + candidate.min(max_non_final) + }; + if let Some(last) = schedule.last_claim_at { + let elapsed = now.checked_sub(last).ok_or(ArithmeticError::Underflow)?; + if elapsed < T::MinClaimInterval::get() { + return Ok(ClaimPlan::TooSoon); + } + } + Ok(ClaimPlan::Pay(payable)) + } + /// Round down to a multiple of the payout quantum. fn quantize_down(amount: BalanceOf) -> BalanceOf { - amount.saturating_sub(amount % T::PayoutQuantum::get()) + let remainder = amount % T::PayoutQuantum::get(); + amount.checked_sub(&remainder).expect("remainder never exceeds the dividend") } /// Move a payout out of the pot AND record it as a wormhole transfer proof — @@ -511,16 +616,29 @@ pub mod pallet { (schedule.claimed % T::PayoutQuantum::get()).is_zero(), sp_runtime::TryRuntimeError::Other("claimed is not quantum-aligned") ); + let remaining = schedule + .total + .checked_sub(&schedule.claimed) + .ok_or(sp_runtime::TryRuntimeError::Other("claimed exceeds total"))?; + frame_support::ensure!( + remaining.is_zero() || remaining >= T::MinimumPayout::get(), + sp_runtime::TryRuntimeError::Other( + "remaining obligation is below MinimumPayout" + ) + ); frame_support::ensure!( schedule.beneficiary != pot, sp_runtime::TryRuntimeError::Other("pot is a beneficiary") ); - outstanding = - outstanding.saturating_add(schedule.total.saturating_sub(schedule.claimed)); + outstanding = outstanding.checked_add(&remaining).ok_or( + sp_runtime::TryRuntimeError::Other("outstanding obligations overflow"), + )?; } + let required = outstanding + .checked_add(&T::Currency::minimum_balance()) + .ok_or(sp_runtime::TryRuntimeError::Other("required pot balance overflows"))?; frame_support::ensure!( - T::Currency::total_balance(&pot) >= - outstanding.saturating_add(T::Currency::minimum_balance()), + T::Currency::total_balance(&pot) >= required, sp_runtime::TryRuntimeError::Other("pot does not cover outstanding obligations") ); Ok(()) diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs index fae0271d4..afc9f8801 100644 --- a/pallets/vesting/src/mock.rs +++ b/pallets/vesting/src/mock.rs @@ -44,6 +44,8 @@ parameter_types! { /// `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; } impl frame_system::Config for Test { @@ -159,6 +161,8 @@ impl pallet_vesting::Config for Test { type AssetId = u32; type ProofRecorder = MockProofRecorder; type PayoutQuantum = PayoutQuantum; + type MinimumPayout = MinimumPayout; + type MinClaimInterval = MinClaimInterval; type WeightInfo = (); } diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index 1a4735409..547801430 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -1,5 +1,5 @@ use crate::{mock::*, Error, Event, NextScheduleId, Schedules, VestingSchedule}; -use frame_support::{assert_noop, assert_ok, traits::fungible::Inspect}; +use frame_support::{assert_noop, assert_ok}; use sp_runtime::{DispatchError, TokenError}; const START: u64 = 100_000; @@ -28,7 +28,15 @@ mod vested_amount { end: u64, total: u128, ) -> VestingSchedule { - VestingSchedule { beneficiary: BOB, start, cliff, end, total, claimed: 0 } + VestingSchedule { + beneficiary: BOB, + start, + cliff, + end, + total, + claimed: 0, + last_claim_at: None, + } } #[test] @@ -183,21 +191,91 @@ mod claim { } #[test] - fn below_ed_payout_to_nonexistent_account_fails_cleanly_then_succeeds_later() { + fn minimum_payout_prevents_below_ed_transfers() { new_test_ext(vec![(CHARLIE, START, START, END, TOTAL)]).execute_with(|| { - // A quantum finer than the ED so the transfer itself is what fails: - // 10 per ms; at 50ms the quantized 500 is payable but below the 1_000 ED. PayoutQuantum::set(100); set_time(START + 50); assert_noop!( Vesting::claim(RuntimeOrigin::signed(PINGER), 0), - TokenError::BelowMinimum + Error::::NothingToClaim + ); + assert_eq!(stored(0).claimed, 0); + set_time(START + 1_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), MinimumPayout::get()); + assert_eq!(stored(0).claimed, MinimumPayout::get()); + }); + } + + #[test] + fn claims_are_rate_limited_per_schedule() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(stored(0).last_claim_at, Some(300_000)); + set_time(350_000); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + Error::::ClaimTooSoon + ); + set_time(400_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(stored(0).last_claim_at, Some(400_000)); + }); + } + + #[test] + fn daily_cadence_bounds_a_year_schedule_to_366_payouts() { + const DAY: u64 = 24 * 60 * 60 * 1000; + const YEAR: u64 = 365 * DAY; + const YEAR_TOTAL: u128 = YEAR as u128 * 10_000; + new_test_ext(vec![(BOB, 0, 0, YEAR, YEAR_TOTAL)]).execute_with(|| { + MinClaimInterval::set(DAY); + for day in 0..365 { + set_time(1 + day * DAY); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + } + set_time(YEAR); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + Error::::ClaimTooSoon + ); + set_time(YEAR + 1); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(MockProofRecorder::recorded().len(), 366); + assert_eq!(stored(0).claimed, YEAR_TOTAL); + }); + } + + #[test] + fn reserves_a_minimum_sized_final_payout() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + MinimumPayout::set(1_000_000); + MinClaimInterval::set(40_000); + set_time(450_000); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(stored(0).claimed, 3_000_000); + assert_eq!(TOTAL - stored(0).claimed, MinimumPayout::get()); + set_time(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(stored(0).claimed, TOTAL); + }); + } + + #[test] + fn waits_for_full_vesting_when_no_valid_non_final_payout_exists() { + const SMALL_TOTAL: u128 = 1_500_000; + new_test_ext(vec![(BOB, START, START, END, SMALL_TOTAL)]).execute_with(|| { + MinimumPayout::set(1_000_000); + set_time(START + 266_667); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + Error::::ClaimWouldLeaveDust ); assert_eq!(stored(0).claimed, 0); - set_time(START + 200); + set_time(END); assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); - assert_eq!(free(&CHARLIE), 2_000); - assert_eq!(stored(0).claimed, 2_000); + assert_eq!(stored(0).claimed, SMALL_TOTAL); }); } @@ -439,6 +517,21 @@ mod end_schedule { }); } + #[test] + fn rejects_a_non_zero_payout_below_the_minimum() { + new_test_ext(vec![(BOB, START, START, END, TOTAL)]).execute_with(|| { + set_time(START + 500); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0), + Error::::PayoutBelowMinimum + ); + assert_eq!(stored(0).claimed, 0); + set_time(START + 1_000); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + assert_eq!(free(&BOB), MinimumPayout::get()); + }); + } + #[test] fn after_partial_claims_pays_only_the_unpaid_vested_part() { new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { @@ -509,6 +602,7 @@ mod retarget_schedule { schedule_id: 0, old_beneficiary: BOB, new_beneficiary: CHARLIE, + vested_paid: 0, } .into(), ); @@ -519,6 +613,31 @@ mod retarget_schedule { }); } + #[test] + fn settles_a_front_runnable_claim_before_retargeting() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::retarget_schedule(RuntimeOrigin::signed(TREASURY), 0, CHARLIE)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(stored(0).beneficiary, CHARLIE); + assert_eq!(stored(0).claimed, TOTAL / 2); + assert_eq!(stored(0).last_claim_at, Some(300_000)); + System::assert_last_event( + Event::ScheduleRetargeted { + schedule_id: 0, + old_beneficiary: BOB, + new_beneficiary: CHARLIE, + vested_paid: TOTAL / 2, + } + .into(), + ); + set_time(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&BOB), TOTAL / 2); + assert_eq!(free(&CHARLIE), TOTAL / 2); + }); + } + #[test] fn rejects_pot_same_target_unknown_id_and_bad_origin() { new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { @@ -621,12 +740,11 @@ mod quantization { new_test_ext(vec![(CHARLIE, START, START, ALIGNED_END, ALIGNED_TOTAL)]).execute_with( || { PayoutQuantum::set(3_000); - // 350ms in: 3_500 accrued -> pays exactly one quantum, 500 stays owed. - set_time(START + 350); + MinimumPayout::set(12_000); + set_time(START + 1_250); assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); - assert_eq!(free(&CHARLIE), 3_000); - assert_eq!(stored(0).claimed, 3_000); - // Immediately claiming again: only the 500 remainder accrued — rejected. + assert_eq!(free(&CHARLIE), 12_000); + assert_eq!(stored(0).claimed, 12_000); assert_noop!( Vesting::claim(RuntimeOrigin::signed(PINGER), 0), Error::::NothingToClaim @@ -649,20 +767,19 @@ mod quantization { fn end_schedule_sends_sub_quantum_dust_to_treasury_not_the_beneficiary() { new_test_ext(vec![(BOB, START, START, END, TOTAL)]).execute_with(|| { PayoutQuantum::set(3_000); + MinimumPayout::set(12_000); let treasury_before = free(&TREASURY); - // 350ms in: 3_500 vested. Beneficiary gets the aligned 3_000; the 500 dust - // plus the 3_996_500 unvested remainder return to the treasury. - set_time(START + 350); + set_time(START + 1_250); assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); - assert_eq!(free(&BOB), 3_000); - assert_eq!(free(&TREASURY), treasury_before + TOTAL - 3_000); + assert_eq!(free(&BOB), 12_000); + assert_eq!(free(&TREASURY), treasury_before + TOTAL - 12_000); assert_eq!(free(&pot()), ExistentialDeposit::get()); System::assert_last_event( Event::ScheduleEnded { schedule_id: 0, beneficiary: BOB, - vested_paid: 3_000, - unvested_returned: TOTAL - 3_000, + vested_paid: 12_000, + unvested_returned: TOTAL - 12_000, } .into(), ); @@ -709,7 +826,7 @@ mod proof_recording { } #[test] - fn create_and_retarget_record_nothing() { + fn create_and_retarget_without_a_payout_record_nothing() { new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { assert_ok!(Vesting::create_schedule( RuntimeOrigin::signed(TREASURY), @@ -723,6 +840,15 @@ mod proof_recording { assert!(MockProofRecorder::recorded().is_empty()); }); } + + #[test] + fn retarget_with_a_settlement_records_the_old_beneficiary_payout() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + set_time(300_000); + assert_ok!(Vesting::retarget_schedule(RuntimeOrigin::root(), 0, CHARLIE)); + assert_eq!(MockProofRecorder::recorded(), vec![(pot(), BOB, TOTAL / 2)]); + }); + } } mod unfunded_pot_bootstrap { diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index 844229760..316f06aaf 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -1,20 +1,12 @@ -//! Weights for `pallet_vesting`. -//! -//! Base numbers benchmarked with the Substrate benchmark CLI (STEPS 50, REPEAT 20, -//! WASM compiled) via `scripts/regenerate_weights.sh`. `claim` and `end_schedule` -//! record a wormhole transfer proof, whose ZK-tree leaf insert walks the tree -//! leaf-to-root — their weight therefore adds depth-dependent tree ops on top of the -//! benchmarked base. **Keep this augmentation when regenerating from benchmarks** -//! (benchmarks measure the compute base only, at benchmark-time tree depth). - -#![cfg_attr(rustfmt, rustfmt_skip)] -#![allow(unused_parens)] -#![allow(missing_docs)] - -use frame_support::{traits::Get, weights::{Weight, RuntimeDbWeight, constants::RocksDbWeight}}; +//! Depth-aware weights for `pallet_vesting`. + +use crate::weights_generated as generated; use core::marker::PhantomData; +use frame_support::{ + traits::Get, + weights::{constants::RocksDbWeight, RuntimeDbWeight, Weight}, +}; -/// Weight functions needed for `pallet_vesting`. pub trait WeightInfo { fn claim() -> Weight; fn create_schedule() -> Weight; @@ -22,106 +14,117 @@ pub trait WeightInfo { fn retarget_schedule() -> Weight; } -/// Non-tree storage ops of `claim`: `Vesting::Schedules` (r:1 w:1), `Timestamp::Now` -/// (r:1 w:0), `System::Account` (r:2 w:2), `Wormhole::TransferCount` (r:1 w:1). -const CLAIM_BASE_READS: u64 = 5; -const CLAIM_BASE_WRITES: u64 = 4; - -/// Non-tree storage ops of `end_schedule`: `TreasuryPallet::TreasuryAccount` (r:1 w:0), -/// `Vesting::Schedules` (r:1 w:1), `Timestamp::Now` (r:1 w:0), `System::Account` -/// (r:3 w:3), `Wormhole::TransferCount` (r:1 w:1). -const END_SCHEDULE_BASE_READS: u64 = 7; -const END_SCHEDULE_BASE_WRITES: u64 = 5; - -/// 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`]. -fn claim_weight(db: RuntimeDbWeight, (tree_reads, tree_writes): (u64, u64)) -> Weight { - // Minimum execution time: 111_000_000 picoseconds. - Weight::from_parts(114_000_000, 8619) - .saturating_add(Weight::from_parts( - 0, - tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), - )) - .saturating_add(db.reads(CLAIM_BASE_READS.saturating_add(tree_reads))) - .saturating_add(db.writes(CLAIM_BASE_WRITES.saturating_add(tree_writes))) -} +const BENCHMARK_TREE_READS: u64 = 5; +const BENCHMARK_TREE_WRITES: u64 = 4; -/// See [`claim_weight`]. -fn end_schedule_weight(db: RuntimeDbWeight, (tree_reads, tree_writes): (u64, u64)) -> Weight { - // Minimum execution time: 136_000_000 picoseconds. - Weight::from_parts(138_000_000, 8799) +fn payout_weight( + base: Weight, + db: RuntimeDbWeight, + (tree_reads, tree_writes): (u64, u64), + tree_hash_time: u64, +) -> Weight { + base.saturating_sub(db.reads(BENCHMARK_TREE_READS)) + .saturating_sub(db.writes(BENCHMARK_TREE_WRITES)) .saturating_add(Weight::from_parts( - 0, - tree_reads.saturating_mul(pallet_zk_tree::TREE_KEY_POV), + tree_hash_time, + tree_reads + .saturating_add(tree_writes) + .saturating_mul(pallet_zk_tree::TREE_KEY_POV), )) - .saturating_add(db.reads(END_SCHEDULE_BASE_READS.saturating_add(tree_reads))) - .saturating_add(db.writes(END_SCHEDULE_BASE_WRITES.saturating_add(tree_writes))) + .saturating_add(db.reads(tree_reads)) + .saturating_add(db.writes(tree_writes)) } -/// Weights for `pallet_vesting` using the Substrate node and recommended hardware. -/// -/// Bounded on `pallet_zk_tree::Config` because the payout calls' weight reads the -/// current tree depth: paying out records a wormhole proof, which inserts a ZK-tree -/// leaf whose storage cost grows with depth. pub struct SubstrateWeight(PhantomData); + impl WeightInfo for SubstrateWeight { - /// [`claim_weight`] — benchmarked base plus live-depth tree ops. fn claim() -> Weight { - claim_weight(T::DbWeight::get(), pallet_zk_tree::Pallet::::insert_leaf_db_ops()) + payout_weight( + as generated::WeightInfo>::claim(), + T::DbWeight::get(), + pallet_zk_tree::Pallet::::insert_leaf_db_ops(), + pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + ) } - /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) - /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) - /// Storage: `System::Account` (r:2 w:2) - /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) - /// Storage: `Vesting::NextScheduleId` (r:1 w:1) - /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `Vesting::Schedules` (r:0 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn create_schedule() -> Weight { - // Minimum execution time: 58_000_000 picoseconds. - Weight::from_parts(63_000_000, 6196) - .saturating_add(T::DbWeight::get().reads(4_u64)) - .saturating_add(T::DbWeight::get().writes(4_u64)) + as generated::WeightInfo>::create_schedule() } - /// [`end_schedule_weight`] — benchmarked base plus live-depth tree ops. + fn end_schedule() -> Weight { - end_schedule_weight(T::DbWeight::get(), pallet_zk_tree::Pallet::::insert_leaf_db_ops()) + payout_weight( + as generated::WeightInfo>::end_schedule(), + T::DbWeight::get(), + pallet_zk_tree::Pallet::::insert_leaf_db_ops(), + pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + ) } - /// Storage: `Vesting::Schedules` (r:1 w:1) - /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + fn retarget_schedule() -> Weight { - // Minimum execution time: 10_000_000 picoseconds. - Weight::from_parts(11_000_000, 3569) - .saturating_add(T::DbWeight::get().reads(1_u64)) - .saturating_add(T::DbWeight::get().writes(1_u64)) + payout_weight( + as generated::WeightInfo>::retarget_schedule(), + T::DbWeight::get(), + pallet_zk_tree::Pallet::::insert_leaf_db_ops(), + pallet_zk_tree::Pallet::::insert_leaf_hash_ref_time(), + ) } } -// For backwards compatibility and tests: charges the worst-case (max-depth) tree walk -// since it cannot read the live depth without a `pallet_zk_tree::Config` bound. impl WeightInfo for () { fn claim() -> Weight { - claim_weight( + payout_weight( + <() as generated::WeightInfo>::claim(), 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), ) } + fn create_schedule() -> Weight { - Weight::from_parts(63_000_000, 6196) - .saturating_add(RocksDbWeight::get().reads(4_u64)) - .saturating_add(RocksDbWeight::get().writes(4_u64)) + <() as generated::WeightInfo>::create_schedule() } + fn end_schedule() -> Weight { - end_schedule_weight( + payout_weight( + <() as generated::WeightInfo>::end_schedule(), 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), ) } + fn retarget_schedule() -> Weight { - Weight::from_parts(11_000_000, 3569) - .saturating_add(RocksDbWeight::get().reads(1_u64)) - .saturating_add(RocksDbWeight::get().writes(1_u64)) + payout_weight( + <() as generated::WeightInfo>::retarget_schedule(), + 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), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payout_ref_time_grows_with_tree_depth() { + let db = RuntimeDbWeight { read: 0, write: 0 }; + let base = Weight::zero(); + let shallow = payout_weight( + base, + db, + pallet_zk_tree::insert_leaf_db_ops_at_depth(1), + pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(1), + ); + let deep = payout_weight( + base, + db, + 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), + ); + + assert!(deep.ref_time() > shallow.ref_time()); + assert!(deep.proof_size() > shallow.proof_size()); } } diff --git a/pallets/vesting/src/weights_generated.rs b/pallets/vesting/src/weights_generated.rs new file mode 100644 index 000000000..822b4c317 --- /dev/null +++ b/pallets/vesting/src/weights_generated.rs @@ -0,0 +1,258 @@ +// This file is part of Substrate. + +// Copyright (C) Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: Apache-2.0 + +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + + +//! Autogenerated weights for `pallet_vesting` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 53.0.0 +//! DATE: 2026-08-08, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `minisqulenik.local`, CPU: `` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// /Users/nikolaus/play/quantus-network/chain/target/release/quantus-node +// benchmark +// pallet +// --runtime=target/debug/wbuild/quantus-runtime/quantus_runtime.wasm +// --genesis-builder=runtime +// --pallet=pallet_vesting +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --wasm-execution=compiled +// --heap-pages=4096 +// --template=.maintain/frame-weight-template.hbs +// --output=./pallets/vesting/src/weights_generated.rs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `pallet_vesting`. +pub trait WeightInfo { + fn claim() -> Weight; + fn create_schedule() -> Weight; + fn end_schedule() -> Weight; + fn retarget_schedule() -> Weight; +} + +/// Weights for `pallet_vesting` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `621` + // Estimated: `8619` + // Minimum execution time: 74_000_000 picoseconds. + Weight::from_parts(74_000_000, 8619) + .saturating_add(T::DbWeight::get().reads(10_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::NextScheduleId` (r:1 w:1) + /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:0 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn create_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `474` + // Estimated: `6196` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(39_000_000, 6196) + .saturating_add(T::DbWeight::get().reads(4_u64)) + .saturating_add(T::DbWeight::get().writes(4_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn end_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `845` + // Estimated: `8799` + // Minimum execution time: 104_000_000 picoseconds. + Weight::from_parts(105_000_000, 8799) + .saturating_add(T::DbWeight::get().reads(12_u64)) + .saturating_add(T::DbWeight::get().writes(9_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn retarget_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `742` + // Estimated: `8619` + // Minimum execution time: 76_000_000 picoseconds. + Weight::from_parts(77_000_000, 8619) + .saturating_add(T::DbWeight::get().reads(11_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn claim() -> Weight { + // Proof Size summary in bytes: + // Measured: `621` + // Estimated: `8619` + // Minimum execution time: 74_000_000 picoseconds. + Weight::from_parts(74_000_000, 8619) + .saturating_add(RocksDbWeight::get().reads(10_u64)) + .saturating_add(RocksDbWeight::get().writes(8_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Vesting::NextScheduleId` (r:1 w:1) + /// Proof: `Vesting::NextScheduleId` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:0 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + fn create_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `474` + // Estimated: `6196` + // Minimum execution time: 38_000_000 picoseconds. + Weight::from_parts(39_000_000, 6196) + .saturating_add(RocksDbWeight::get().reads(4_u64)) + .saturating_add(RocksDbWeight::get().writes(4_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:3 w:3) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn end_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `845` + // Estimated: `8799` + // Minimum execution time: 104_000_000 picoseconds. + Weight::from_parts(105_000_000, 8799) + .saturating_add(RocksDbWeight::get().reads(12_u64)) + .saturating_add(RocksDbWeight::get().writes(9_u64)) + } + /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) + /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + /// Storage: `Vesting::Schedules` (r:1 w:1) + /// Proof: `Vesting::Schedules` (`max_values`: None, `max_size`: Some(113), added: 2588, mode: `MaxEncodedLen`) + /// Storage: `Timestamp::Now` (r:1 w:0) + /// Proof: `Timestamp::Now` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:2 w:2) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(128), added: 2603, mode: `MaxEncodedLen`) + /// Storage: `Wormhole::TransferCount` (r:1 w:1) + /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::LeafCount` (r:1 w:1) + /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Leaves` (r:3 w:1) + /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) + /// Storage: `ZkTree::Root` (r:0 w:1) + /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) + fn retarget_schedule() -> Weight { + // Proof Size summary in bytes: + // Measured: `742` + // Estimated: `8619` + // Minimum execution time: 76_000_000 picoseconds. + Weight::from_parts(77_000_000, 8619) + .saturating_add(RocksDbWeight::get().reads(11_u64)) + .saturating_add(RocksDbWeight::get().writes(8_u64)) + } +} diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index e0cd301d0..712cb168d 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -533,6 +533,10 @@ parameter_types! { /// (`SCALE_DOWN_FACTOR`): a sub-quantum transfer would be committed as a /// zero-value leaf, stranding funds paid to keyless beneficiaries. pub const VestingPayoutQuantum: Balance = pallet_wormhole::SCALE_DOWN_FACTOR; + /// 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; } /// The configured treasury account as an `Option` — unlike @@ -580,6 +584,8 @@ impl pallet_vesting::Config for Runtime { // extension skips pot-sourced events to avoid double-recording signed paths. type ProofRecorder = Wormhole; type PayoutQuantum = VestingPayoutQuantum; + type MinimumPayout = VestingMinimumPayout; + type MinClaimInterval = VestingMinClaimInterval; type WeightInfo = pallet_vesting::weights::SubstrateWeight; } diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index 0de810ca6..f97f2a7d2 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -8,6 +8,9 @@ mod tests { use frame_support::{assert_noop, assert_ok, traits::Currency}; use pallet_multisig::BoundedCallOf; use quantus_runtime::{ + configs::{ + VestingMinClaimInterval, VestingMinimumPayout, VestingPayoutQuantum, VolumeFeeRateBps, + }, AccountId, Balance, Balances, Multisig, Runtime, RuntimeCall, RuntimeEvent, RuntimeOrigin, System, Vesting, Wormhole, EXISTENTIAL_DEPOSIT, UNIT, }; @@ -73,6 +76,22 @@ mod tests { assert_ok!(Multisig::execute(RuntimeOrigin::signed(account(3)), treasury, proposal_id)); } + #[test] + fn payout_policy_covers_account_and_wormhole_minimums() { + let quantum = VestingPayoutQuantum::get(); + let minimum = VestingMinimumPayout::get(); + let quantized_minimum = minimum / quantum; + let max_quantized_output = + quantized_minimum * (10_000 - VolumeFeeRateBps::get() as u128) / 10_000; + + assert!(minimum > EXISTENTIAL_DEPOSIT); + assert!(minimum >= 2 * quantum); + assert_eq!(minimum % quantum, 0); + assert!(max_quantized_output > 0); + assert!(max_quantized_output < quantized_minimum); + assert_eq!(VestingMinClaimInterval::get(), 24 * 60 * 60 * 1000); + } + #[test] fn treasury_multisig_creates_and_ends_schedules() { new_test_ext(Some(treasury_multisig())).execute_with(|| { diff --git a/scripts/regenerate_weights.sh b/scripts/regenerate_weights.sh index ffe6e950b..866f6b758 100755 --- a/scripts/regenerate_weights.sh +++ b/scripts/regenerate_weights.sh @@ -4,8 +4,9 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -NODE="./target/release/quantus-node" -RUNTIME="./target/release/wbuild/quantus-runtime/quantus_runtime.wasm" +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +NODE="$TARGET_DIR/release/quantus-node" +RUNTIME="$TARGET_DIR/release/wbuild/quantus-runtime/quantus_runtime.wasm" TEMPLATE="./.maintain/frame-weight-template.hbs" # pallet_name:output_path:steps:repeat @@ -16,7 +17,7 @@ PALLETS=( "pallet_scheduler:pallets/scheduler/src/weights.rs:50:20" "pallet_mining_rewards:pallets/mining-rewards/src/weights.rs:50:20" "pallet_treasury:pallets/treasury/src/weights.rs:50:20" - "pallet_vesting:pallets/vesting/src/weights.rs:50:20" + "pallet_vesting:pallets/vesting/src/weights_generated.rs:50:20" ) COMMON_ARGS=( From e142ed23e3234f57e407e303d1c9ba748b4d1658 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 8 Aug 2026 11:20:19 +0800 Subject: [PATCH 4/6] test: cover one QUAN vesting boundary --- runtime/tests/governance/vesting.rs | 33 ++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index f97f2a7d2..aff2c74a8 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -196,20 +196,38 @@ mod tests { } #[test] - fn claim_records_exactly_one_wormhole_leaf_for_the_beneficiary() { + fn one_quan_schedule_claims_exactly_once_and_records_one_wormhole_leaf() { new_test_ext(Some(account(4))).execute_with(|| { Balances::make_free_balance_be(&account(4), 1000 * UNIT); + let minimum = VestingMinimumPayout::get(); + assert_eq!(minimum, UNIT); // The beneficiary never signs anything — exactly like a wormhole address. let beneficiary = account(9); let pot = Vesting::pot_account_id(); + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::root(), + beneficiary.clone(), + 0, + 0, + END_MS, + minimum - VestingPayoutQuantum::get(), + ), + pallet_vesting::Error::::InvalidSchedule + ); assert_ok!(Vesting::create_schedule( RuntimeOrigin::root(), beneficiary.clone(), 0, 0, END_MS, - GRANT, + minimum, )); + set_time(END_MS - 1); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(account(1)), 0), + pallet_vesting::Error::::NothingToClaim + ); set_time(END_MS); System::reset_events(); let count_before = Wormhole::transfer_count(&beneficiary); @@ -234,7 +252,16 @@ mod tests { _ => None, }) .expect("claim must emit a plain Transfer event from the pot"); - assert_eq!(payout, GRANT); + assert_eq!(payout, UNIT); + assert_eq!(Balances::total_balance(&beneficiary), UNIT); + assert_eq!(Balances::total_balance(&pot), EXISTENTIAL_DEPOSIT); + let schedule = pallet_vesting::Schedules::::get(0).unwrap(); + assert_eq!(schedule.total, UNIT); + assert_eq!(schedule.claimed, UNIT); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(account(1)), 0), + pallet_vesting::Error::::NothingToClaim + ); }); } From c1c1b9fea965b3220fe7c84bfbe424dda5b48df7 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 8 Aug 2026 14:20:29 +0800 Subject: [PATCH 5/6] fix: address vesting review blockers --- pallets/vesting/src/benchmarking.rs | 28 ++++++++++++----- pallets/vesting/src/lib.rs | 34 ++++++++++++-------- pallets/vesting/src/tests.rs | 20 +++++++++++- pallets/vesting/src/weights.rs | 12 ++++++- pallets/vesting/src/weights_generated.rs | 40 ++++++++++++------------ 5 files changed, 92 insertions(+), 42 deletions(-) diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs index 7d537bdc4..96c5a7133 100644 --- a/pallets/vesting/src/benchmarking.rs +++ b/pallets/vesting/src/benchmarking.rs @@ -38,7 +38,7 @@ fn treasury() -> Result { } /// Insert a schedule directly, with the pot funded to cover it plus its ED buffer. -fn seed_schedule(beneficiary: T::AccountId, total: BalanceOf) -> u64 { +fn seed_schedule(beneficiary: T::AccountId, total: BalanceOf, end: u64) -> u64 { let schedule_id = NextScheduleId::::get(); NextScheduleId::::put(schedule_id + 1); Schedules::::insert( @@ -47,7 +47,7 @@ fn seed_schedule(beneficiary: T::AccountId, total: BalanceOf) -> u beneficiary, start: START, cliff: CLIFF, - end: END, + end, total, claimed: Zero::zero(), last_claim_at: None, @@ -68,14 +68,28 @@ mod benchmarks { fn claim() -> Result<(), BenchmarkError> { let beneficiary: T::AccountId = account("beneficiary", 0, 0); let total = benchmark_total::(); - let schedule_id = seed_schedule::(beneficiary.clone(), total); - set_time::(END); let caller: T::AccountId = whitelisted_caller(); + let interval = T::MinClaimInterval::get(); + let now = interval + .checked_mul(2) + .ok_or(BenchmarkError::Stop("claim benchmark time overflow"))?; + let end = interval + .checked_mul(4) + .ok_or(BenchmarkError::Stop("claim benchmark end overflow"))?; + let schedule_id = seed_schedule::(beneficiary.clone(), total, end); + set_time::(interval); + Vesting::::claim(RawOrigin::Signed(caller.clone()).into(), schedule_id) + .map_err(|_| BenchmarkError::Stop("claim benchmark setup failed"))?; + let claimed_before = Schedules::::get(schedule_id).expect("schedule exists").claimed; + set_time::(now); #[extrinsic_call] _(RawOrigin::Signed(caller), schedule_id); - assert_eq!(T::Currency::balance(&beneficiary), total); + let schedule = Schedules::::get(schedule_id).expect("schedule persists"); + assert!(schedule.claimed > claimed_before); + assert!(schedule.claimed < total); + assert_eq!(schedule.last_claim_at, Some(now)); Ok(()) } @@ -103,7 +117,7 @@ mod benchmarks { fund::(&treasury, T::Currency::minimum_balance()); let beneficiary: T::AccountId = account("beneficiary", 0, 0); let total = benchmark_total::(); - let schedule_id = seed_schedule::(beneficiary.clone(), total); + let schedule_id = seed_schedule::(beneficiary.clone(), total, END); // Mid-vesting: both the beneficiary payout and the treasury refund execute. set_time::(END / 2); @@ -121,7 +135,7 @@ mod benchmarks { let beneficiary: T::AccountId = account("beneficiary", 0, 0); let new_beneficiary: T::AccountId = account("new-beneficiary", 0, 0); let total = benchmark_total::(); - let schedule_id = seed_schedule::(beneficiary, total); + let schedule_id = seed_schedule::(beneficiary, total, END); set_time::(END / 2); #[extrinsic_call] diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index dd7f7d952..13b284953 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -221,7 +221,7 @@ pub mod pallet { ClaimWouldLeaveDust, /// Ending now would emit a non-zero beneficiary payout below the minimum. PayoutBelowMinimum, - /// The treasury account is not configured on this chain. + /// The treasury account is not configured or aliases the vesting pot. TreasuryNotConfigured, /// The pot does not hold its existential-deposit buffer; endow it first. PotUnderfunded, @@ -361,13 +361,9 @@ pub mod pallet { total: BalanceOf, ) -> DispatchResult { T::AdminOrigin::ensure_origin(origin)?; - let treasury = T::TreasuryAccount::get().ok_or(Error::::TreasuryNotConfigured)?; - let pot = Self::pot_account_id(); + let (treasury, pot) = Self::treasury_and_pot()?; ensure!(Self::schedule_is_valid(start, cliff, end, total), Error::::InvalidSchedule); ensure!(beneficiary != pot, Error::::InvalidBeneficiary); - // A treasury misconfigured to be the pot itself would record an obligation - // without funding it, silently corrupting the pot's accounting invariant. - ensure!(treasury != pot, Error::::TreasuryNotConfigured); // The pot's ED buffer is what lets keep-alive payouts always clear; a chain // launched without genesis schedules must endow the pot before creating any. ensure!( @@ -412,9 +408,8 @@ pub mod pallet { #[pallet::weight(T::WeightInfo::end_schedule())] pub fn end_schedule(origin: OriginFor, schedule_id: u64) -> DispatchResult { T::AdminOrigin::ensure_origin(origin)?; - let treasury = T::TreasuryAccount::get().ok_or(Error::::TreasuryNotConfigured)?; + let (treasury, pot) = Self::treasury_and_pot()?; let schedule = Schedules::::get(schedule_id).ok_or(Error::::NoSchedule)?; - let pot = Self::pot_account_id(); let vested = Self::vested_amount(&schedule, T::TimeProvider::now()); let unpaid_vested = vested.checked_sub(&schedule.claimed).ok_or(ArithmeticError::Underflow)?; @@ -492,6 +487,13 @@ pub mod pallet { T::PalletId::get().into_account_truncating() } + fn treasury_and_pot() -> Result<(T::AccountId, T::AccountId), Error> { + let treasury = T::TreasuryAccount::get().ok_or(Error::::TreasuryNotConfigured)?; + let pot = Self::pot_account_id(); + ensure!(treasury != pot, Error::::TreasuryNotConfigured); + Ok((treasury, pot)) + } + /// Amount vested at `now`: 0 before the cliff, `total` from `end`, linear in /// between (floor rounding; the `end` branch guarantees exactness, the final /// claim absorbs rounding dust). @@ -587,14 +589,16 @@ pub mod pallet { Ok(()) } - /// Invariant: the pot covers all outstanding obligations plus its ED buffer, and - /// every stored schedule is internally consistent. + /// Invariant: when schedules exist, the pot covers all outstanding obligations + /// plus its ED buffer, and every stored schedule is internally consistent. #[cfg(any(feature = "try-runtime", test))] pub fn do_try_state() -> Result<(), sp_runtime::TryRuntimeError> { let pot = Self::pot_account_id(); let next_id = NextScheduleId::::get(); let mut outstanding: BalanceOf = Zero::zero(); + let mut has_schedules = false; for (id, schedule) in Schedules::::iter() { + has_schedules = true; frame_support::ensure!( id < next_id, sp_runtime::TryRuntimeError::Other("schedule id >= NextScheduleId") @@ -634,9 +638,13 @@ pub mod pallet { sp_runtime::TryRuntimeError::Other("outstanding obligations overflow"), )?; } - let required = outstanding - .checked_add(&T::Currency::minimum_balance()) - .ok_or(sp_runtime::TryRuntimeError::Other("required pot balance overflows"))?; + let required = if has_schedules { + outstanding + .checked_add(&T::Currency::minimum_balance()) + .ok_or(sp_runtime::TryRuntimeError::Other("required pot balance overflows"))? + } else { + Zero::zero() + }; frame_support::ensure!( T::Currency::total_balance(&pot) >= required, sp_runtime::TryRuntimeError::Other("pot does not cover outstanding obligations") diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index 547801430..d731e0ccd 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -566,6 +566,23 @@ mod end_schedule { }); } + #[test] + fn rejects_the_pot_as_treasury_without_removing_the_schedule() { + new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { + let pot_before = free(&pot()); + TreasuryAccount::set(Some(pot())); + set_time(300_000); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::root(), 0), + Error::::TreasuryNotConfigured + ); + assert!(Schedules::::contains_key(0)); + assert_eq!(free(&BOB), 0); + assert_eq!(free(&pot()), pot_before); + TreasuryAccount::set(Some(TREASURY)); + }); + } + #[test] fn freed_ids_are_never_reused() { new_test_ext(vec![default_schedule(BOB)]).execute_with(|| { @@ -683,9 +700,10 @@ mod genesis { #[test] fn empty_is_a_noop() { - new_test_ext(vec![]).execute_with(|| { + new_test_ext_with_pot_balance(vec![], 0).execute_with(|| { assert_eq!(NextScheduleId::::get(), 0); assert_eq!(Schedules::::iter().count(), 0); + assert_eq!(free(&pot()), 0); assert_ok!(Vesting::do_try_state()); }); } diff --git a/pallets/vesting/src/weights.rs b/pallets/vesting/src/weights.rs index 316f06aaf..d20a6e4d8 100644 --- a/pallets/vesting/src/weights.rs +++ b/pallets/vesting/src/weights.rs @@ -16,15 +16,17 @@ pub trait WeightInfo { const BENCHMARK_TREE_READS: u64 = 5; const BENCHMARK_TREE_WRITES: u64 = 4; +const CLAIM_BENCHMARK_TREE_WRITES: u64 = 3; fn payout_weight( base: Weight, db: RuntimeDbWeight, + benchmark_tree_writes: u64, (tree_reads, tree_writes): (u64, u64), tree_hash_time: u64, ) -> Weight { base.saturating_sub(db.reads(BENCHMARK_TREE_READS)) - .saturating_sub(db.writes(BENCHMARK_TREE_WRITES)) + .saturating_sub(db.writes(benchmark_tree_writes)) .saturating_add(Weight::from_parts( tree_hash_time, tree_reads @@ -42,6 +44,7 @@ impl WeightInfo for SubstrateW payout_weight( 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(), ) @@ -55,6 +58,7 @@ impl WeightInfo for SubstrateW payout_weight( 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(), ) @@ -64,6 +68,7 @@ impl WeightInfo for SubstrateW payout_weight( 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(), ) @@ -75,6 +80,7 @@ impl WeightInfo for () { 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), ) @@ -88,6 +94,7 @@ impl WeightInfo for () { payout_weight( <() 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), ) @@ -97,6 +104,7 @@ impl WeightInfo for () { payout_weight( <() 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), ) @@ -114,12 +122,14 @@ mod tests { let shallow = payout_weight( 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), ); 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), ); diff --git a/pallets/vesting/src/weights_generated.rs b/pallets/vesting/src/weights_generated.rs index 822b4c317..e22f4b2ea 100644 --- a/pallets/vesting/src/weights_generated.rs +++ b/pallets/vesting/src/weights_generated.rs @@ -28,7 +28,7 @@ // /Users/nikolaus/play/quantus-network/chain/target/release/quantus-node // benchmark // pallet -// --runtime=target/debug/wbuild/quantus-runtime/quantus_runtime.wasm +// --runtime=/Users/nikolaus/play/quantus-network/chain-pr646-fix/target/release/wbuild/quantus-runtime/quantus_runtime.wasm // --genesis-builder=runtime // --pallet=pallet_vesting // --extrinsic=* @@ -36,7 +36,7 @@ // --repeat=20 // --wasm-execution=compiled // --heap-pages=4096 -// --template=.maintain/frame-weight-template.hbs +// --template=./.maintain/frame-weight-template.hbs // --output=./pallets/vesting/src/weights_generated.rs #![cfg_attr(rustfmt, rustfmt_skip)] @@ -69,7 +69,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) /// Storage: `ZkTree::LeafCount` (r:1 w:1) /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Storage: `ZkTree::Depth` (r:1 w:0) /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) /// Storage: `ZkTree::Leaves` (r:3 w:1) /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) @@ -77,12 +77,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) fn claim() -> Weight { // Proof Size summary in bytes: - // Measured: `621` + // Measured: `977` // Estimated: `8619` - // Minimum execution time: 74_000_000 picoseconds. - Weight::from_parts(74_000_000, 8619) + // Minimum execution time: 81_000_000 picoseconds. + Weight::from_parts(83_000_000, 8619) .saturating_add(T::DbWeight::get().reads(10_u64)) - .saturating_add(T::DbWeight::get().writes(8_u64)) + .saturating_add(T::DbWeight::get().writes(7_u64)) } /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) @@ -96,8 +96,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `474` // Estimated: `6196` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(39_000_000, 6196) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(40_000_000, 6196) .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(4_u64)) } @@ -123,8 +123,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `845` // Estimated: `8799` - // Minimum execution time: 104_000_000 picoseconds. - Weight::from_parts(105_000_000, 8799) + // Minimum execution time: 103_000_000 picoseconds. + Weight::from_parts(104_000_000, 8799) .saturating_add(T::DbWeight::get().reads(12_u64)) .saturating_add(T::DbWeight::get().writes(9_u64)) } @@ -169,7 +169,7 @@ impl WeightInfo for () { /// Proof: `Wormhole::TransferCount` (`max_values`: None, `max_size`: Some(56), added: 2531, mode: `MaxEncodedLen`) /// Storage: `ZkTree::LeafCount` (r:1 w:1) /// Proof: `ZkTree::LeafCount` (`max_values`: Some(1), `max_size`: Some(8), added: 503, mode: `MaxEncodedLen`) - /// Storage: `ZkTree::Depth` (r:1 w:1) + /// Storage: `ZkTree::Depth` (r:1 w:0) /// Proof: `ZkTree::Depth` (`max_values`: Some(1), `max_size`: Some(1), added: 496, mode: `MaxEncodedLen`) /// Storage: `ZkTree::Leaves` (r:3 w:1) /// Proof: `ZkTree::Leaves` (`max_values`: None, `max_size`: Some(68), added: 2543, mode: `MaxEncodedLen`) @@ -177,12 +177,12 @@ impl WeightInfo for () { /// Proof: `ZkTree::Root` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) fn claim() -> Weight { // Proof Size summary in bytes: - // Measured: `621` + // Measured: `977` // Estimated: `8619` - // Minimum execution time: 74_000_000 picoseconds. - Weight::from_parts(74_000_000, 8619) + // Minimum execution time: 81_000_000 picoseconds. + Weight::from_parts(83_000_000, 8619) .saturating_add(RocksDbWeight::get().reads(10_u64)) - .saturating_add(RocksDbWeight::get().writes(8_u64)) + .saturating_add(RocksDbWeight::get().writes(7_u64)) } /// Storage: `TreasuryPallet::TreasuryAccount` (r:1 w:0) /// Proof: `TreasuryPallet::TreasuryAccount` (`max_values`: Some(1), `max_size`: Some(32), added: 527, mode: `MaxEncodedLen`) @@ -196,8 +196,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `474` // Estimated: `6196` - // Minimum execution time: 38_000_000 picoseconds. - Weight::from_parts(39_000_000, 6196) + // Minimum execution time: 39_000_000 picoseconds. + Weight::from_parts(40_000_000, 6196) .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(4_u64)) } @@ -223,8 +223,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `845` // Estimated: `8799` - // Minimum execution time: 104_000_000 picoseconds. - Weight::from_parts(105_000_000, 8799) + // Minimum execution time: 103_000_000 picoseconds. + Weight::from_parts(104_000_000, 8799) .saturating_add(RocksDbWeight::get().reads(12_u64)) .saturating_add(RocksDbWeight::get().writes(9_u64)) } From 4f584733de97f7eb6204430105bec16854e75656 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Sat, 8 Aug 2026 14:36:36 +0800 Subject: [PATCH 6/6] chore: sync vesting docs and lint --- docs/RUNTIME_SURFACE.md | 14 +++++++------- pallets/vesting/src/tests.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index bb62a0858..21bf441c8 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -177,14 +177,14 @@ All `Config` impls live in `runtime/src/configs/mod.rs` unless noted. - `on_finalize` commits the merkle root. Backs the `ZkTreeApi` runtime API. ### Index 22 — `Vesting` (`pallet-vesting`, local) -- Pull-based "vesting wallet": the pallet's sovereign pot (`PalletId(*b"qvesting")`, keyless) holds the entire unclaimed allocation, endowed at genesis with `Σ schedule totals + ED`; beneficiaries are paid by plain keep-alive transfers only at claim time. **No locks, freezes, or holds ever touch a beneficiary account**, so wormhole addresses can be beneficiaries. -- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EitherOfDiverse` (`EnsureTreasury` = signed by the configured treasury account; the treasury multisig executes proposals as a plain signed origin), `TreasuryAccount = TreasuryAccountOption` (Option-returning storage read, never panics), `ProofRecorder = Wormhole`, `PayoutQuantum = SCALE_DOWN_FACTOR` (10^10). -- **Storage:** `Schedules: schedule_id (u64) → { beneficiary, start, cliff, end, total, claimed }` (ids sequential, never reused; a beneficiary may hold any number of schedules), `NextScheduleId`. Deployed on fresh chains only (genesis endows the pot); deliberately no upgrade migration — an unfunded pot blocks `create_schedule` loudly with `PotUnderfunded` until the treasury sends it one ED. +- Pull-based "vesting wallet": the pallet's sovereign pot (`PalletId(*b"qvesting")`, keyless) holds the entire unclaimed allocation; beneficiaries are paid by plain keep-alive transfers only when a payout is due. **No locks, freezes, or holds ever touch a beneficiary account**, so wormhole addresses can be beneficiaries. +- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EitherOfDiverse` (`EnsureTreasury` = signed by the configured treasury account; the treasury multisig executes proposals as a plain signed origin), `TreasuryAccount = TreasuryAccountOption` (Option-returning storage read, never panics), `ProofRecorder = Wormhole`, `PayoutQuantum = SCALE_DOWN_FACTOR` (10^10), `MinimumPayout = UNIT` (1 QUAN), `MinClaimInterval = 86,400,000 ms` (24 hours). +- **Storage:** `Schedules: schedule_id (u64) → { beneficiary, start, cliff, end, total, claimed, last_claim_at }` (ids sequential, never reused; a beneficiary may hold any number of schedules), `NextScheduleId`. Storage version 0 has no migration: an in-place upgrade with no schedules may leave the pot unfunded, and `create_schedule` then fails with `PotUnderfunded` until the treasury sends it one ED. - Vesting math: `vested(t) = 0` before `cliff`, `total` from `end`, else `⌊total·(t−start)/(end−start)⌋` (256-bit rational, floor; the `end` branch guarantees exactness). -- **Payout quantization:** wormhole leaves commit `amount / 10^10`, so a sub-quantum payout would create a zero-value leaf and strand funds on a keyless beneficiary. Totals must be multiples of `PayoutQuantum`; every payout is rounded down to a multiple and `claimed` advances only by the paid amount (stays aligned, final claim at `end` is exact). `end_schedule` sends sub-quantum vested dust to the treasury (signature-controlled, needs no leaf). +- **Payout policy:** wormhole leaves commit `amount / 10^10`, so a sub-quantum payout would create a zero-value leaf and strand funds on a keyless beneficiary. Schedule totals must be at least `MinimumPayout` and multiples of `PayoutQuantum`; payouts are quantized and `claimed` stays aligned. A successful claim must pay at least 1 QUAN and be at least 24 hours after that schedule's previous payout. Non-final claims reserve a complete minimum-sized final payout; a claim that cannot avoid a sub-minimum remainder fails with `ClaimWouldLeaveDust` until the full remainder vests. The final claim pays the exact remainder. `end_schedule` returns sub-quantum vested dust to the signature-controlled treasury and rejects a non-zero beneficiary payout below `MinimumPayout` without removing the schedule. - **Proof recording:** the pallet records each pot → beneficiary payout via `TransferProofRecorder` itself (`pay_out` fuses transfer + record), so scheduler-enacted Root calls — invisible to the event-scanning extension — still create leaves; the extension skips pot-touching transfer events and charges no static weight for vesting calls. -- **Calls:** `claim`(0) — **permissionless**; pays the quantized `vested − claimed` from the pot to the schedule's stored beneficiary (never the caller); the only claim path for keyless/high-security beneficiaries. `create_schedule`(1) — admin; funds the pot from the treasury in the same call. `end_schedule`(2) — admin; quantized unpaid vested part → beneficiary, everything else → treasury, schedule removed. `retarget_schedule`(3) — admin; changes the beneficiary key only (lost-key remedy). -- Genesis build validates every schedule (`start ≤ cliff ≤ end`, `start < end`, `total ≥ ED`, `total % quantum = 0`, beneficiary ≠ pot) and asserts the pot's endowment exactly; a misconfigured chain refuses to start. `try_state` checks `pot balance ≥ Σ(total − claimed) + ED` and quantum alignment of `claimed`. +- **Calls:** `claim`(0) — **permissionless**; pays the largest valid claim from the pot to the schedule's stored beneficiary (never the caller); the only claim path for keyless/high-security beneficiaries. `create_schedule`(1) — admin; validates the schedule and funds the pot from the treasury in the same call. `end_schedule`(2) — admin; quantized unpaid vested part → beneficiary, everything else → treasury, schedule removed. `retarget_schedule`(3) — admin; first settles exactly the payout a permissionless claim could currently force to the old beneficiary, then changes the beneficiary (lost-key remedy independent of claim/retarget ordering). +- Genesis build validates every schedule (`start ≤ cliff ≤ end`, `start < end`, `total ≥ MinimumPayout`, `total % PayoutQuantum = 0`, beneficiary ≠ pot) and, for a non-empty table, asserts the pot holds exactly `Σ schedule totals + ED`; a misconfigured chain refuses to start. `try_state` validates stored schedules, aligned claims, dust-safe remaining obligations, and—when any schedule exists—`pot balance ≥ Σ(total − claimed) + ED`; an empty schedule table is valid with an unfunded pot. --- @@ -247,7 +247,7 @@ The high-security whitelist (`HighSecurityConfig::is_whitelisted`, extension 10) - `dev` — local development. - `heisenberg` — **internal integration testnet**, not mainnet. Tokens have no monetary value; the network may be reset. - `planck` — public testnet (live treasury signers + faucet). -- **Vesting genesis:** every preset endows the vesting pot with `Σ schedule totals + ED` (ED alone when the table is empty, as on `planck`) and keeps the keyless pot **out** of the wormhole endowment list. `dev`/`heisenberg` seed example schedules (one account with two schedules; `dev` also vests the keyless test wormhole address, claimable only via third-party ping). A mainnet preset (4-of-6 treasury multisig, launch-gated allocation table) is planned as a separate PR. +- **Vesting genesis:** every preset endows the vesting pot with `Σ schedule totals + ED` (ED alone when the table is empty, as on `planck`). Because the pot is part of the balances genesis endowment, standard genesis proof generation creates a block-1 Wormhole leaf for it; that leaf is unspendable because the pot is keyless. `dev`/`heisenberg` seed example schedules (one account with two schedules; `dev` also vests the keyless test wormhole address, claimable only via third-party ping). A mainnet preset (4-of-6 treasury multisig, launch-gated allocation table) is planned as a separate PR. - Dilithium well-known accounts: `crystal_alice`, `dilithium_bob`, `crystal_charlie` (public seeds `[0]` / `[1]` / `[2]`). Used by `dev` and **intentionally also by `heisenberg`** so integrators and CI can exercise governance, treasury, and transfer flows without distributing secrets. Those private keys are public by design; do **not** reuse this pattern on a mainnet or any value-bearing chain (Planck already uses distinct live treasury signers). - Treasury = 2-of-3 multisig of the three signers for `dev`/`heisenberg` (distinct nonce per preset); no genesis endowment (funded from mining-reward share only). - Tech-collective seeded via the chain-spec-only `tech_collective_seed_members` JSON field (`prepare_genesis_build_input` + `seed_tech_collective`). diff --git a/pallets/vesting/src/tests.rs b/pallets/vesting/src/tests.rs index d731e0ccd..f5ad312b9 100644 --- a/pallets/vesting/src/tests.rs +++ b/pallets/vesting/src/tests.rs @@ -110,7 +110,7 @@ mod vested_amount { new_test_ext(vec![]).execute_with(|| { let s = schedule(0, 0, u64::MAX, u128::from(u64::MAX) * 1_000_000_000_000); assert_eq!(Vesting::vested_amount(&s, u64::MAX), s.total); - assert_eq!(Vesting::vested_amount(&s, u64::MAX - 1) > s.total / 2, true); + assert!(Vesting::vested_amount(&s, u64::MAX - 1) > s.total / 2); }); } }