Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 }
Expand Down
24 changes: 19 additions & 5 deletions docs/RUNTIME_SURFACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ the runtime, their dispatchable calls, the runtime APIs, transaction extensions,
genesis logic, and the workspace primitive crates pulled in.

- **Crate:** `quantus-runtime` (`runtime/`), version `0.7.1-q-day-2`
- **Spec:** `spec_name = quantus-runtime`, `spec_version = 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)
Expand Down Expand Up @@ -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<Runtime, Instance1>` `45.0.0` | **Inlined** (2nd instance) | yes |
| 15 | `TreasuryPallet` | `pallet-treasury` | **Local** (`pallets/treasury`) | yes |
Expand All @@ -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.

---

Expand Down Expand Up @@ -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<EnsureRoot, EnsureTreasury>` (`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!`)
Expand Down Expand Up @@ -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).

---

Expand All @@ -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`).
Expand Down
64 changes: 64 additions & 0 deletions pallets/vesting/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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",
]
152 changes: 152 additions & 0 deletions pallets/vesting/src/benchmarking.rs
Original file line number Diff line number Diff line change
@@ -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<T: pallet_timestamp::Config<Moment = u64>>(now_ms: u64) {
pallet_timestamp::Now::<T>::put(now_ms);
}

const START: u64 = 0;
const CLIFF: u64 = 0;
const END: u64 = 1_000_000;

fn fund<T: Config>(who: &T::AccountId, amount: BalanceOf<T>) {
T::Currency::mint_into(who, amount).expect("minting benchmark funds must succeed");
}

fn benchmark_total<T: Config>() -> BalanceOf<T> {
T::MinimumPayout::get().saturating_mul(1000u32.into())
}

fn admin_origin<T: Config>() -> Result<T::RuntimeOrigin, BenchmarkError> {
let origin: T::RuntimeOrigin = RawOrigin::Signed(treasury::<T>()?).into();
T::AdminOrigin::try_origin(origin.clone())
.map_err(|_| BenchmarkError::Stop("signed treasury is not an admin origin"))?;
Ok(origin)
}

fn treasury<T: Config>() -> Result<T::AccountId, BenchmarkError> {
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<T: Config>(beneficiary: T::AccountId, total: BalanceOf<T>, end: u64) -> u64 {
let schedule_id = NextScheduleId::<T>::get();
NextScheduleId::<T>::put(schedule_id + 1);
Schedules::<T>::insert(
schedule_id,
VestingSchedule {
beneficiary,
start: START,
cliff: CLIFF,
end,
total,
claimed: Zero::zero(),
last_claim_at: None,
},
);
fund::<T>(
&Vesting::<T>::pot_account_id(),
total.saturating_add(T::Currency::minimum_balance()),
);
schedule_id
}

#[benchmarks(where T: pallet_timestamp::Config<Moment = u64>)]
mod benchmarks {
use super::*;

#[benchmark]
fn claim() -> Result<(), BenchmarkError> {
let beneficiary: T::AccountId = account("beneficiary", 0, 0);
let total = benchmark_total::<T>();
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::<T>(beneficiary.clone(), total, end);
set_time::<T>(interval);
Vesting::<T>::claim(RawOrigin::Signed(caller.clone()).into(), schedule_id)
.map_err(|_| BenchmarkError::Stop("claim benchmark setup failed"))?;
let claimed_before = Schedules::<T>::get(schedule_id).expect("schedule exists").claimed;
set_time::<T>(now);

#[extrinsic_call]
_(RawOrigin::Signed(caller), schedule_id);

let schedule = Schedules::<T>::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::<T>()?;
let treasury = treasury::<T>()?;
let ed = T::Currency::minimum_balance();
let total = benchmark_total::<T>();
fund::<T>(&treasury, total.saturating_mul(2u32.into()));
fund::<T>(&Vesting::<T>::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::<T>::iter().any(|(_, s)| s.beneficiary == beneficiary));
Ok(())
}

#[benchmark]
fn end_schedule() -> Result<(), BenchmarkError> {
let origin = admin_origin::<T>()?;
let treasury = treasury::<T>()?;
fund::<T>(&treasury, T::Currency::minimum_balance());
let beneficiary: T::AccountId = account("beneficiary", 0, 0);
let total = benchmark_total::<T>();
let schedule_id = seed_schedule::<T>(beneficiary.clone(), total, END);
// Mid-vesting: both the beneficiary payout and the treasury refund execute.
set_time::<T>(END / 2);

#[extrinsic_call]
_(origin as T::RuntimeOrigin, schedule_id);

assert!(Schedules::<T>::get(schedule_id).is_none());
assert!(!T::Currency::balance(&beneficiary).is_zero());
Ok(())
}

#[benchmark]
fn retarget_schedule() -> Result<(), BenchmarkError> {
let origin = admin_origin::<T>()?;
let beneficiary: T::AccountId = account("beneficiary", 0, 0);
let new_beneficiary: T::AccountId = account("new-beneficiary", 0, 0);
let total = benchmark_total::<T>();
let schedule_id = seed_schedule::<T>(beneficiary, total, END);
set_time::<T>(END / 2);

#[extrinsic_call]
_(origin as T::RuntimeOrigin, schedule_id, new_beneficiary.clone());

assert_eq!(
Schedules::<T>::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);
}
Loading
Loading