diff --git a/Cargo.lock b/Cargo.lock index 42370a7f..eebb5625 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6234,6 +6234,25 @@ dependencies = [ "sp-runtime", ] +[[package]] +name = "pallet-vesting" +version = "0.1.0" +dependencies = [ + "frame-benchmarking", + "frame-support", + "frame-system", + "pallet-balances", + "pallet-timestamp", + "pallet-zk-tree", + "parity-scale-codec", + "qp-wormhole", + "scale-info", + "sp-arithmetic", + "sp-core", + "sp-io", + "sp-runtime", +] + [[package]] name = "pallet-wormhole" version = "0.1.0" @@ -7811,6 +7830,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 7deb2331..a3fb6d78 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 85500d46..21bf441c 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,16 @@ 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; 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 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 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. + --- ## 4. Runtime APIs (`apis.rs`, `impl_runtime_apis!`) @@ -212,7 +223,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 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). --- @@ -234,6 +247,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`). 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/Cargo.toml b/pallets/vesting/Cargo.toml new file mode 100644 index 00000000..d2e10aa5 --- /dev/null +++ b/pallets/vesting/Cargo.toml @@ -0,0 +1,64 @@ +[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 } +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 + +[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", + "pallet-zk-tree/std", + "qp-wormhole/std", + "scale-info/std", + "sp-arithmetic/std", + "sp-runtime/std", +] +try-runtime = [ + "frame-support/try-runtime", + "frame-system/try-runtime", + "pallet-zk-tree/try-runtime", +] diff --git a/pallets/vesting/src/benchmarking.rs b/pallets/vesting/src/benchmarking.rs new file mode 100644 index 00000000..96c5a713 --- /dev/null +++ b/pallets/vesting/src/benchmarking.rs @@ -0,0 +1,152 @@ +//! 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 benchmark_total() -> BalanceOf { + T::MinimumPayout::get().saturating_mul(1000u32.into()) +} + +fn admin_origin() -> Result { + 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 { + 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, end: u64) -> u64 { + let schedule_id = NextScheduleId::::get(); + NextScheduleId::::put(schedule_id + 1); + Schedules::::insert( + schedule_id, + VestingSchedule { + beneficiary, + start: START, + cliff: CLIFF, + end, + total, + claimed: Zero::zero(), + last_claim_at: None, + }, + ); + 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 = benchmark_total::(); + 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); + + 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(()) + } + + #[benchmark] + fn create_schedule() -> Result<(), BenchmarkError> { + let origin = admin_origin::()?; + let treasury = treasury::()?; + let ed = T::Currency::minimum_balance(); + 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); + + #[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 = benchmark_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); + + #[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 = benchmark_total::(); + let schedule_id = seed_schedule::(beneficiary, total, END); + set_time::(END / 2); + + #[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 00000000..13b28495 --- /dev/null +++ b/pallets/vesting/src/lib.rs @@ -0,0 +1,655 @@ +#![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 after settling any payout a permissionless claim could force (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; +mod weights_generated; +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 qp_wormhole::TransferProofRecorder; + use sp_arithmetic::{helpers_128bit::multiply_by_rational_with_rounding, Rounding}; + use sp_runtime::{ + traits::{AccountIdConversion, CheckedAdd, CheckedSub, 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, + /// Timestamp of the last successful beneficiary payout. + pub last_claim_at: Option, + } + + enum ClaimPlan { + Pay(Balance), + NothingToClaim, + TooSoon, + WouldLeaveDust, + } + + /// 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] + #[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>; + + /// 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>; + + /// 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; + } + + /// 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 after settling any currently claimable payout. + ScheduleRetargeted { + schedule_id: u64, + old_beneficiary: T::AccountId, + new_beneficiary: T::AccountId, + vested_paid: BalanceOf, + }, + } + + #[pallet::error] + pub enum Error { + /// No schedule exists under this id. + NoSchedule, + /// Schedule parameters violate `start <= cliff <= end`, `start < end`, + /// `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 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 or aliases the vesting pot. + 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(), + 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) == required, + "vesting genesis: pot balance must equal sum of schedule totals plus the \ + existential deposit" + ); + } + } + + #[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!( + 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")] + fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { + Self::do_try_state() + } + } + + #[pallet::call] + impl Pallet { + /// 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 + /// 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 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)?; + 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(), + amount: payable, + }); + 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, pot) = Self::treasury_and_pot()?; + ensure!(Self::schedule_is_valid(start, cliff, end, total), Error::::InvalidSchedule); + ensure!(beneficiary != pot, Error::::InvalidBeneficiary); + // 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(), + last_claim_at: None, + }, + ); + Self::deposit_event(Event::ScheduleCreated { + schedule_id, + beneficiary, + start, + cliff, + end, + total, + }); + Ok(()) + } + + /// 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. 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 { + T::AdminOrigin::ensure_origin(origin)?; + let (treasury, pot) = Self::treasury_and_pot()?; + let schedule = Schedules::::get(schedule_id).ok_or(Error::::NoSchedule)?; + let vested = Self::vested_amount(&schedule, T::TimeProvider::now()); + 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 = + remaining.checked_sub(&vested_paid).ok_or(ArithmeticError::Underflow)?; + if !vested_paid.is_zero() { + Self::pay_out(&pot, &schedule.beneficiary, vested_paid)?; + } + 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, + unvested_returned, + }); + Ok(()) + } + + /// 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( + 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 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(()) + }) + } + } + + 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() + } + + 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). + 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) + .expect("validated schedule duration is non-zero"); + vested.saturated_into() + } + + fn schedule_is_valid( + start: Moment, + cliff: Moment, + end: Moment, + total: BalanceOf, + ) -> bool { + start <= cliff && + cliff <= end && start < end && + 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 { + 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 — + /// 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: 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") + ); + 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.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.checked_add(&remaining).ok_or( + sp_runtime::TryRuntimeError::Other("outstanding obligations overflow"), + )?; + } + 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") + ); + Ok(()) + } + } +} diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs new file mode 100644 index 00000000..afc9f880 --- /dev/null +++ b/pallets/vesting/src/mock.rs @@ -0,0 +1,211 @@ +use crate as pallet_vesting; + +use core::cell::RefCell; +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); + /// `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 { + 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(()) + } +} + +/// 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 MinimumPayout = MinimumPayout; + type MinClaimInterval = MinClaimInterval; + 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); + // 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 new file mode 100644 index 00000000..f5ad312b --- /dev/null +++ b/pallets/vesting/src/tests.rs @@ -0,0 +1,940 @@ +use crate::{mock::*, Error, Event, NextScheduleId, Schedules, VestingSchedule}; +use frame_support::{assert_noop, assert_ok}; +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, + last_claim_at: None, + } + } + + #[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!(Vesting::vested_amount(&s, u64::MAX - 1) > s.total / 2); + }); + } +} + +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 minimum_payout_prevents_below_ed_transfers() { + new_test_ext(vec![(CHARLIE, START, START, END, TOTAL)]).execute_with(|| { + PayoutQuantum::set(100); + set_time(START + 50); + assert_noop!( + Vesting::claim(RuntimeOrigin::signed(PINGER), 0), + 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(END); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(stored(0).claimed, SMALL_TOTAL); + }); + } + + #[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 + (START, CLIFF, END, TOTAL + 500), // total not quantum-aligned + ]; + 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_000 + ), + 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 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(|| { + 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 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(|| { + 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, + vested_paid: 0, + } + .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 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(|| { + 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_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()); + }); + } + + #[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 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); + MinimumPayout::set(12_000); + set_time(START + 1_250); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(PINGER), 0)); + assert_eq!(free(&CHARLIE), 12_000); + assert_eq!(stored(0).claimed, 12_000); + 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); + MinimumPayout::set(12_000); + let treasury_before = free(&TREASURY); + set_time(START + 1_250); + assert_ok!(Vesting::end_schedule(RuntimeOrigin::signed(TREASURY), 0)); + 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: 12_000, + unvested_returned: TOTAL - 12_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_without_a_payout_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()); + }); + } + + #[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 { + 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::*; + + #[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 00000000..d20a6e4d --- /dev/null +++ b/pallets/vesting/src/weights.rs @@ -0,0 +1,140 @@ +//! 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}, +}; + +pub trait WeightInfo { + fn claim() -> Weight; + fn create_schedule() -> Weight; + fn end_schedule() -> Weight; + fn retarget_schedule() -> Weight; +} + +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_add(Weight::from_parts( + tree_hash_time, + tree_reads + .saturating_add(tree_writes) + .saturating_mul(pallet_zk_tree::TREE_KEY_POV), + )) + .saturating_add(db.reads(tree_reads)) + .saturating_add(db.writes(tree_writes)) +} + +pub struct SubstrateWeight(PhantomData); + +impl WeightInfo for SubstrateWeight { + fn claim() -> Weight { + 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(), + ) + } + + fn create_schedule() -> Weight { + as generated::WeightInfo>::create_schedule() + } + + fn end_schedule() -> Weight { + 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(), + ) + } + + fn retarget_schedule() -> Weight { + 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(), + ) + } +} + +impl WeightInfo for () { + fn claim() -> Weight { + payout_weight( + <() as generated::WeightInfo>::claim(), + RocksDbWeight::get(), + CLAIM_BENCHMARK_TREE_WRITES, + pallet_zk_tree::insert_leaf_db_ops_at_depth(pallet_zk_tree::MAX_TREE_DEPTH), + pallet_zk_tree::insert_leaf_hash_ref_time_at_depth(pallet_zk_tree::MAX_TREE_DEPTH), + ) + } + + fn create_schedule() -> Weight { + <() as generated::WeightInfo>::create_schedule() + } + + fn end_schedule() -> Weight { + 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), + ) + } + + fn retarget_schedule() -> Weight { + 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), + ) + } +} + +#[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, + 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), + ); + + 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 00000000..e22f4b2e --- /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=/Users/nikolaus/play/quantus-network/chain-pr646-fix/target/release/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: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`) + /// 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: `977` + // Estimated: `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(7_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: 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)) + } + /// 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: 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)) + } + /// 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: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`) + /// 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: `977` + // Estimated: `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(7_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: 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)) + } + /// 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: 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)) + } + /// 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/Cargo.toml b/runtime/Cargo.toml index 0ea2b4d3..70664ba2 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 d9892d3d..fee72410 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 07442bd7..712cb168 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,68 @@ impl pallet_treasury::Config for Runtime { type WeightInfo = pallet_treasury::weights::SubstrateWeight; } +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; + /// 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 +/// `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 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 MinimumPayout = VestingMinimumPayout; + type MinClaimInterval = VestingMinClaimInterval; + type WeightInfo = pallet_vesting::weights::SubstrateWeight; +} + // Multisig configuration parameter_types! { pub const MultisigPalletId: PalletId = PalletId(*b"py/mltsg"); @@ -550,10 +615,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 +637,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 5fbf7246..3df99991 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 @@ -158,6 +180,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 @@ -170,12 +193,32 @@ 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`. + // 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. Wormhole transfer proofs at block 1 derive from these + // balances (including the pot); the pot is keyless so its leaf is unspendable. + 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, dev_accounts: None }, treasury_pallet: pallet_treasury::GenesisConfig:: { treasury_account: Some(treasury.account), treasury_portion: Some(treasury.portion), }, + vesting: pallet_vesting::GenesisConfig:: { schedules: vesting_schedules }, ..Default::default() }; @@ -192,6 +235,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], @@ -227,6 +326,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")] { @@ -250,8 +351,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() @@ -267,7 +373,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) } } @@ -283,9 +389,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 { @@ -407,9 +515,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`. @@ -447,21 +558,59 @@ mod tests { use super::*; use sp_runtime::BuildStorage; - /// Every shipped preset must actually build genesis storage, i.e. pass every pallet's - /// genesis-build invariants. (Wormhole transfer proofs need no preset entry at all: - /// they are derived from these genesis balances at block 1, so they cannot disagree - /// with the value actually issued.) + /// 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); + } + + /// Every shipped preset must build genesis storage, including the vesting pallet's + /// pot-balance assertions. Wormhole transfer proofs need no preset entry: they are + /// derived from genesis balances at block 1. #[test] fn all_presets_build_genesis_storage() { for id in preset_names() { let bytes = get_preset(&id).expect("listed preset must resolve"); let (config_bytes, _members) = prepare_genesis_build_input(bytes) .unwrap_or_else(|e| panic!("preset {:?}: invalid genesis JSON: {e}", id)); - let config: crate::RuntimeGenesisConfig = serde_json::from_slice(&config_bytes) + let config: RuntimeGenesisConfig = serde_json::from_slice(&config_bytes) .unwrap_or_else(|e| panic!("preset {:?} must deserialize: {e}", id)); config .build_storage() .unwrap_or_else(|e| panic!("preset {:?} must build genesis storage: {e:?}", id)); } } + + /// 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); + } } diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index b286b31d..5efd77d8 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 c247f333..0b6135a3 100644 --- a/runtime/src/transaction_extensions.rs +++ b/runtime/src/transaction_extensions.rs @@ -204,6 +204,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, } } @@ -226,6 +231,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() @@ -237,7 +250,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(); @@ -852,6 +865,46 @@ mod tests { }); } + #[test] + fn wormhole_proof_recorder_ignores_vesting_calls_and_pot_events() { + new_test_ext().execute_with(|| { + // 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); + } + + // 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); + }); + } + #[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 4d1f688e..35b42ba3 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 00000000..aff2c74a --- /dev/null +++ b/runtime/tests/governance/vesting.rs @@ -0,0 +1,320 @@ +//! 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 quantus_runtime::{ + configs::{ + VestingMinClaimInterval, VestingMinimumPayout, VestingPayoutQuantum, VolumeFeeRateBps, + }, + 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}; + + 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 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(|| { + 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 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, + 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); + assert_ok!(Vesting::claim(RuntimeOrigin::signed(account(1)), 0)); + + // 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 { + 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, 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 + ); + }); + } + + #[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, + )); + + let count_before = Wormhole::transfer_count(&beneficiary); + 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); + }); + } +} diff --git a/scripts/regenerate_weights.sh b/scripts/regenerate_weights.sh index ed934329..866f6b75 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,6 +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_generated.rs:50:20" ) COMMON_ARGS=(