From fc6f3b277b5925a484801cf43968cde02194ba8a Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 12:44:31 +0800 Subject: [PATCH 1/4] Decouple staging treasury from genesis --- docs/CHAINSPEC_CREATION.md | 4 +- docs/RUNTIME_SURFACE.md | 4 +- docs/STAGING_MAINNET_LAUNCH.md | 64 +++--- docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md | 2 +- node/src/chain_spec.rs | 6 +- pallets/vesting/src/lib.rs | 17 +- pallets/vesting/src/mock.rs | 9 +- runtime/src/configs/mod.rs | 34 +-- runtime/src/genesis_config_presets.rs | 248 +++++++++++++--------- runtime/tests/governance/fast_upgrade.rs | 4 +- runtime/tests/governance/treasury.rs | 96 ++++++++- runtime/tests/governance/vesting.rs | 104 +++++---- 12 files changed, 350 insertions(+), 242 deletions(-) diff --git a/docs/CHAINSPEC_CREATION.md b/docs/CHAINSPEC_CREATION.md index 4db14039..9e4d2a6c 100644 --- a/docs/CHAINSPEC_CREATION.md +++ b/docs/CHAINSPEC_CREATION.md @@ -73,5 +73,5 @@ For a profile `` (e.g. `staging_mainnet`): 7. **Commit** the JSON plus the `load_spec` arm, and cut the node release that embeds them. -For the staging-mainnet launch sequence around this (treasury multisig, -first server, post-launch), see `docs/STAGING_MAINNET_LAUNCH.md`. +For the staging-mainnet launch sequence around this (post-launch treasury setup, +first server, vesting retargets), see `docs/STAGING_MAINNET_LAUNCH.md`. diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index a86a7149..0e9c3d35 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -178,7 +178,7 @@ All `Config` impls live in `runtime/src/configs/mod.rs` unless noted. ### Index 22 — `Vesting` (`pallet-vesting`, local) - Pull-based "vesting wallet": the pallet's sovereign pot (`PalletId(*b"qvesting")`, keyless) holds the entire unclaimed allocation; 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). Non-final claims are further aligned to `pallet_vesting::NON_FINAL_PAYOUT_QUANTA` (2,500) leaf quanta = 25 QUAN, the smallest 4 bps fee-exact multiple. +- Config: `Currency = Balances` (`fungible::{Inspect, Mutate}`), `TimeProvider = Timestamp` (ms since epoch), `AdminOrigin = EnsureRoot` (the configured treasury holds funds but has no unilateral schedule authority), `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). Non-final claims are further aligned to `pallet_vesting::NON_FINAL_PAYOUT_QUANTA` (2,500) leaf quanta = 25 QUAN, the smallest 4 bps fee-exact multiple. - **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 additionally round down to 25 QUAN (`NON_FINAL_PAYOUT_QUANTA` leaf quanta) so each intermediate leaf has an exact 4 bps Wormhole fee; leftover dust stays on the schedule. They also 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` pays the unpaid vested part rounded to the nearest `PayoutQuantum` to the beneficiary only when that amount meets `MinimumPayout`; otherwise the sliver is refunded with every leftover planck to the signature-controlled treasury. The refund is not quantized and never blocks ending. @@ -257,7 +257,7 @@ The high-security whitelist (`HighSecurityConfig::is_whitelisted`, extension 8) - `staging_mainnet` — mainnet dress rehearsal (see `docs/STAGING_MAINNET_LAUNCH.md`). - **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). `staging_mainnet` builds from `mainnet_config_genesis`, the exact function the eventual mainnet preset will use; its vesting table is a DUMMY scaffold gated by `MAINNET_VESTING_FINALIZED`, and until that flag is flipped with the real allocation table no mainnet preset is exposed (details in `docs/STAGING_MAINNET_LAUNCH.md`). - 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`, 6-of-10 of the mainnet signers for `staging_mainnet` (distinct nonce per preset); no dedicated treasury genesis balance (endowments are a separate list). +- Treasury = 2-of-3 multisig of the three signers for `dev`/`heisenberg`; `staging_mainnet` deliberately leaves it unconfigured for a post-launch Root referendum. No dedicated treasury genesis balance exists (endowments are a separate list). - Tech-collective seeded via the chain-spec-only `tech_collective_seed_members` JSON field (`prepare_genesis_build_input` + `seed_tech_collective`). - Endows all genesis balances with wormhole transfer proofs (ZK-spendable). `dev` also endows `TEST_WORMHOLE_SECRET`'s address. diff --git a/docs/STAGING_MAINNET_LAUNCH.md b/docs/STAGING_MAINNET_LAUNCH.md index e4d4f6f0..779fd413 100644 --- a/docs/STAGING_MAINNET_LAUNCH.md +++ b/docs/STAGING_MAINNET_LAUNCH.md @@ -1,37 +1,37 @@ # Staging-Mainnet Launch Mainnet dress rehearsal. Genesis comes from `mainnet_config_genesis` -(`runtime/src/genesis_config_presets.rs`) — identical to the eventual mainnet's -except the treasury multisig nonce (staging 1, mainnet 0), so the genesis hash -differs while everything else stays 1:1. +(`runtime/src/genesis_config_presets.rs`). It seeds the launch tech collective +but deliberately leaves the treasury unconfigured; the collective sets the +treasury account after launch through a Root referendum. -- Genesis hash (spec generated from `v0.11.0-rocket-fuel`, runtime `spec_version` 148): - `0xb0e90ed9f291a9194f79bf90dce39ad7e5ea88f1bf6a9db5f268f309d305e30d` - (state root `0x8f3f…fc50`). Any change to genesis data re-derives this — the - chain spec must then be regenerated from a new runtime-upgrade tag. -- Treasury: 6-of-10 multisig of `MAINNET_TREASURY_SIGNERS_SS58`, nonce 1 → - `qzpjP5r4NSeWDrbHvboYcychsmCCaJrbRixvghVjnaRzjRb5i` -- Tech collective: the same ten accounts (referenda curves are runtime constants) -- Balances: no fixed figure is written down. Each signer's liquid endowment is - computed at genesis by `governance_treasury_signer_seed()` from the live - runtime constants — treasury multisig bootstrap (multisig fee, proposal fee, - refundable proposal deposit, inclusion-fee prepay, ED), the referenda - submission and decision bonds, a maximum-size preimage deposit, and a small - scaled fee headroom. It is deliberately the minimum that lets any single - treasurer act from genesis: bootstrap the multisig and carry one referendum - end to end. Because it is derived, changing `FEE_SCALE` or any deposit - re-derives the endowment instead of stranding governance, and a genesis test - pins every signer balance to the formula. The 20 HD rehearsal accounts +- Genesis hash: regenerate and record it after this genesis change; the previous + staging hash is obsolete. +- Treasury: unconfigured at genesis. `TreasuryPallet::set_treasury_account` is + called by an approved Root referendum after the real treasury is ready. +- Tech collective: the ten accounts in + `MAINNET_TECH_COLLECTIVE_MEMBERS_SS58` (referenda curves are runtime constants). +- Balances: no fixed figure is written down. Each member's liquid endowment is + computed at genesis by `governance_member_seed()` from the live runtime + constants — ED, the referenda submission and decision bonds, a maximum-size + preimage deposit, and scaled fee headroom. It is deliberately the minimum + that lets any member carry one referendum end to end. Because it is derived, + changing `FEE_SCALE` or any deposit re-derives the endowment instead of + stranding governance, and a genesis test pins every member balance to the + formula. The 20 HD rehearsal accounts (`staging-0`..`staging-19`) each get `STAGING_REHEARSAL_LIQUID` so they can submit transactions. Everything else sits in the vesting pot. Read the - exact per-signer and total figures for a given build out of the generated + exact per-member and total figures for a given build out of the generated spec's `balances` section rather than restating them here. - Vesting: `mainnet_vesting_schedules` is a DUMMY scaffold (team / early-backer / - ecosystem entries with stand-in beneficiaries T1, T2, and the treasury, plus - 20 HD rehearsal accounts with distinct grants summing to 100_000 UNIT, - 5-minute cliff / 10-day vest from 2026-09-01 14:00 UTC). Replace it with the - real allocation table and flip `MAINNET_VESTING_FINALIZED` before the mainnet - preset is added — until then only staging-mainnet builds. + ecosystem entries with stand-in beneficiaries T1, T2, and T3, plus + 20 HD rehearsal accounts with distinct grants summing to 100_000 UNIT). Every + schedule starts on 2026-09-03 UTC and every cliff is at most 24 hours. Team and + ecosystem grants retain their 4×365-day duration, the early-backer grant retains + its 2×365-day duration, and rehearsal grants retain their 5-minute cliff / + 10-day duration from 14:00 UTC. Replace this with the real allocation table and + flip `MAINNET_VESTING_FINALIZED` before the mainnet preset is added — until then + only staging-mainnet builds. ## Generate the chain spec @@ -63,6 +63,12 @@ that field is outside genesis, so the hash is unchanged. ## Post-launch -One signer calls `multisig.createMultisig(signers, 6, 1)`; the derived address -must equal the genesis treasury account above. Treasury then operates via -`proposeTransaction` / `approveProposal` at 6-of-10. +Create the real treasury multisig with the chosen signers, threshold, and nonce. +Then submit and approve a normal Root referendum calling +`treasuryPallet.setTreasuryAccount(multisigAddress)`. Setting the account does +not move or create funds. + +Vesting administration is also Root-only. Replace each stand-in beneficiary +through `vesting.retargetSchedule`, using an atomic `utility.batchAll` where +appropriate. Complete each retarget before its cliff: `claim` is permissionless +and pays the beneficiary stored when the claim executes. diff --git a/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md b/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md index e3fa02c8..3f4768ee 100644 --- a/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md +++ b/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md @@ -55,7 +55,7 @@ fn approval(&self, _) -> Perbill { Perbill::from_rational(self.ayes, 1.max(self. Both tracks use flat curves, so the required numbers never decay over the decision period (`fast_track_curves_pin_eight_of_ten` in `runtime/tests/governance/fast_upgrade.rs` pins this for track 1): -| Track | `min_approval` | `min_support` | 10 members (staging-mainnet / mainnet: the treasury signers) | 5 members (`MIN_TECH_COLLECTIVE_MEMBERS`, dev/testnet presets) | +| Track | `min_approval` | `min_support` | 10 members (staging-mainnet / mainnet tech collective) | 5 members (`MIN_TECH_COLLECTIVE_MEMBERS`, dev/testnet presets) | |---|---|---|---|---| | 0 | 61% | 60% | 6 ayes; 4 nays block | 3 ayes; 2 nays block | | 1 `fast_upgrade` | 80% | 80% | 8 ayes; 3 nays block | 4 ayes; 2 nays block | diff --git a/node/src/chain_spec.rs b/node/src/chain_spec.rs index 98a3bae4..7b1fcb8f 100644 --- a/node/src/chain_spec.rs +++ b/node/src/chain_spec.rs @@ -77,9 +77,9 @@ pub fn heisenberg_chain_spec() -> Result { /// Staging-mainnet — dress rehearsal for the mainnet launch. /// -/// Genesis is 1:1 with the eventual mainnet's (same 6-of-10 treasury multisig -/// signers, same tech collective, same endowments); only the treasury multisig -/// nonce differs, giving staging its own treasury account and genesis hash. +/// Genesis seeds the launch tech collective but deliberately leaves the treasury +/// unconfigured. The collective sets the treasury account later through a Root +/// referendum, so no temporary treasury multisig is part of genesis. /// Bootnodes are added once the staging infrastructure exists (the `bootNodes` /// field lives outside genesis, so editing it does not change the hash). pub fn staging_mainnet_chain_spec() -> Result { diff --git a/pallets/vesting/src/lib.rs b/pallets/vesting/src/lib.rs index 0043e88b..6179f199 100644 --- a/pallets/vesting/src/lib.rs +++ b/pallets/vesting/src/lib.rs @@ -15,12 +15,11 @@ //! 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. A retarget replaces the wallet of the *same* grantee — the old address is -//! lost, stolen, or abandoned — so it pays the old address nothing; everything unclaimed -//! follows the schedule to the new wallet. +//! The configured admin origin 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. A retarget replaces the wallet of the +//! *same* grantee — the old address is lost, stolen, or abandoned — so it pays the old address +//! nothing; everything unclaimed follows the schedule to the new wallet. extern crate alloc; @@ -124,13 +123,13 @@ pub mod pallet { #[pallet::constant] type PalletId: Get; - /// Origin allowed to create, end, and retarget schedules - /// (Root or signed-by-treasury in the runtime). + /// Origin allowed to create, end, and retarget schedules. 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. + /// a treasury, in which case `create_schedule` and `end_schedule` fail loudly. + /// Retargeting does not read or move treasury funds. type TreasuryAccount: Get>; /// Asset id type forwarded to the proof recorder (payouts are always native: diff --git a/pallets/vesting/src/mock.rs b/pallets/vesting/src/mock.rs index b91a97f7..ccecb883 100644 --- a/pallets/vesting/src/mock.rs +++ b/pallets/vesting/src/mock.rs @@ -113,10 +113,9 @@ impl pallet_balances::Config for Test { 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 { +/// Test-only signed treasury origin used to exercise configurable admin-origin behavior. +pub struct EnsureTestTreasury; +impl EnsureOrigin for EnsureTestTreasury { type Success = AccountId32; fn try_origin(o: RuntimeOrigin) -> Result { match (o.clone().into(), TreasuryAccount::get()) { @@ -179,7 +178,7 @@ impl pallet_vesting::Config for Test { type Currency = Balances; type TimeProvider = Timestamp; type PalletId = VestingPalletId; - type AdminOrigin = EitherOfDiverse, EnsureTreasury>; + type AdminOrigin = EitherOfDiverse, EnsureTestTreasury>; type TreasuryAccount = TreasuryAccount; type AssetId = u32; type ProofRecorder = MockProofRecorder; diff --git a/runtime/src/configs/mod.rs b/runtime/src/configs/mod.rs index 9306d9cd..769918ef 100644 --- a/runtime/src/configs/mod.rs +++ b/runtime/src/configs/mod.rs @@ -37,8 +37,8 @@ use crate::{ use frame_support::{ derive_impl, parameter_types, traits::{ - ConstU128, ConstU16, ConstU32, ConstU8, EitherOfDiverse, EnsureOrigin, Get, - NeverEnsureOrigin, VariantCountOf, + ConstU128, ConstU16, ConstU32, ConstU8, EitherOfDiverse, Get, NeverEnsureOrigin, + VariantCountOf, }, weights::{ constants::{RocksDbWeight, WEIGHT_REF_TIME_PER_SECOND}, @@ -635,7 +635,8 @@ const _: () = assert!( /// 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. +/// genesis omitted the treasury; vesting's fund-moving admin calls fail with an +/// explicit error while beneficiary retargeting remains available. pub struct TreasuryAccountOption; impl Get> for TreasuryAccountOption { fn get() -> Option { @@ -643,34 +644,13 @@ impl Get> for TreasuryAccountOption { } } -/// `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>; + // Vesting administration is collective governance only. The configured + // treasury account holds funds but has no unilateral schedule authority. + type AdminOrigin = EnsureRoot; type TreasuryAccount = TreasuryAccountOption; type AssetId = AssetId; // The pallet records its payouts itself so Root calls enacted by the scheduler diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 9eec3208..1b062954 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -117,9 +117,8 @@ pub const PLANCK_RUNTIME_PRESET: &str = "planck"; /// /// Staging-mainnet is the mainnet dress rehearsal: its genesis is produced by /// `mainnet_config_genesis`, the same function the eventual mainnet preset -/// will use, differing only in the treasury multisig nonce -/// (`STAGING_MAINNET_TREASURY_MULTISIG_NONCE`) so the two chains get -/// distinct treasury accounts and therefore distinct genesis hashes. +/// will use. The treasury is deliberately left unconfigured at genesis and is +/// set later by a Root referendum of the tech collective. pub const STAGING_MAINNET_RUNTIME_PRESET: &str = "staging_mainnet"; /// SS58 address format used by all Quantus chains. @@ -180,7 +179,7 @@ fn heisenberg_treasury_account() -> AccountId { /// balance comes from the ordinary genesis endowment list, not from mining. #[derive(Clone)] struct TreasuryGenesis { - account: AccountId, + account: Option, } /// Two extra well-known Dilithium accounts (public seeds `[3u8; 32]` / `[4u8; 32]`) that pad the @@ -269,7 +268,7 @@ fn genesis_template( let config = RuntimeGenesisConfig { balances: BalancesConfig { balances, dev_accounts: None }, treasury_pallet: pallet_treasury::GenesisConfig:: { - treasury_account: Some(treasury.account), + treasury_account: treasury.account, }, vesting: pallet_vesting::GenesisConfig:: { schedules: vesting_schedules }, ..Default::default() @@ -347,7 +346,7 @@ fn log_vesting_schedules(preset: &str, schedules: &[VestingScheduleTuple]) { fn log_genesis_accounts( preset: &str, endowed: &[AccountId], - treasury_account: &AccountId, + treasury_account: Option<&AccountId>, treasury_signers: &[AccountId], tech_collective: &[AccountId], ) { @@ -355,7 +354,14 @@ fn log_genesis_accounts( for account in endowed { log::info!("[{preset}] 💰 Endowed: {:?}", account.to_ss58check_with_version(ss58)); } - log::info!("[{preset}] 🏦 Treasury: {:?}", treasury_account.to_ss58check_with_version(ss58)); + if let Some(treasury_account) = treasury_account { + log::info!( + "[{preset}] 🏦 Treasury: {:?}", + treasury_account.to_ss58check_with_version(ss58) + ); + } else { + log::info!("[{preset}] 🏦 Treasury: unconfigured (set by Root referendum)"); + } for signer in treasury_signers { log::info!("[{preset}] 🔑 Treasury signer: {:?}", signer.to_ss58check_with_version(ss58)); } @@ -374,7 +380,7 @@ pub fn development_config_genesis() -> Value { log_genesis_accounts( "dev", &endowed_accounts, - &treasury_account, + Some(&treasury_account), &dilithium_default_accounts(), &tech_collective, ); @@ -402,7 +408,7 @@ pub fn development_config_genesis() -> Value { initial_high_security_accounts: vec![(multisig_address, guardian, delay)], }; - let treasury = TreasuryGenesis { account: treasury_account }; + let treasury = TreasuryGenesis { account: Some(treasury_account) }; let mut template_value = genesis_template( endowed_accounts, treasury, @@ -432,7 +438,7 @@ pub fn development_config_genesis() -> Value { #[cfg(not(feature = "runtime-benchmarks"))] { - let treasury = TreasuryGenesis { account: treasury_account }; + let treasury = TreasuryGenesis { account: Some(treasury_account) }; genesis_template(endowed_accounts, treasury, tech_collective, vec![], vesting_schedules) } } @@ -445,13 +451,13 @@ pub fn heisenberg_config_genesis() -> Value { log_genesis_accounts( "heisenberg", &endowed_accounts, - &treasury_account, + Some(&treasury_account), &treasury_signers, &tech_collective, ); let vesting_schedules = testnet_vesting_schedules(); log_vesting_schedules("heisenberg", &vesting_schedules); - let treasury = TreasuryGenesis { account: treasury_account }; + let treasury = TreasuryGenesis { account: Some(treasury_account) }; genesis_template(endowed_accounts, treasury, tech_collective, vec![], vesting_schedules) } @@ -586,7 +592,7 @@ pub fn treasury_signer_seed(signers_count: u32) -> crate::Balance { crate::scale_fee(100 * crate::MILLI_UNIT) } -pub fn governance_treasury_signer_seed(signers_count: u32) -> crate::Balance { +pub fn governance_member_seed() -> crate::Balance { use crate::{ configs::{MaxReferendaProposalSize, ReferendumSubmissionDeposit}, governance::definitions::{preimage_amount, TECH_COLLECTIVE_DECISION_DEPOSIT}, @@ -594,7 +600,7 @@ pub fn governance_treasury_signer_seed(signers_count: u32) -> crate::Balance { use frame_support::traits::Footprint; let max_preimage_deposit = preimage_amount(Footprint { count: 1, size: u64::from(MaxReferendaProposalSize::get()) }); - treasury_signer_seed(signers_count) + EXISTENTIAL_DEPOSIT .saturating_add(ReferendumSubmissionDeposit::get()) .saturating_add(TECH_COLLECTIVE_DECISION_DEPOSIT) .saturating_add(max_preimage_deposit) @@ -611,24 +617,23 @@ pub fn planck_config_genesis() -> Value { log_genesis_accounts( "planck", &endowed_accounts, - &treasury_account, + Some(&treasury_account), &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 }; + let treasury = TreasuryGenesis { account: Some(treasury_account) }; genesis_template(endowed_accounts, treasury, tech_collective, signer_fee_seed, vec![]) } -/// The ten mainnet treasury multisig signers, also seeded as the mainnet tech -/// collective. Staging-mainnet uses the exact same accounts. +/// The ten mainnet tech-collective members. Staging-mainnet uses the exact same +/// accounts. No treasury account or signer set is configured at genesis. /// /// Spec building panics while any placeholder remains, and the preset is -/// skipped in tests until then. Signer order does not matter: multisig address -/// derivation sorts internally. -const MAINNET_TREASURY_SIGNERS_SS58: [&str; 10] = [ +/// skipped in tests until then. +const MAINNET_TECH_COLLECTIVE_MEMBERS_SS58: [&str; 10] = [ "qznoXy8q1BgD4jka7wGHMgPtEw63Ut8gvLv9b6x1GvavmCgi1", "qzq8U3GsQraM2pofq9zHbrBvByczzCHrYXphPwdPf2cq9Q55r", "qzkCCKgT3r4PekyzFAP6MB9mmmCtPZj1tT56CZiECNq38L24z", @@ -641,18 +646,9 @@ const MAINNET_TREASURY_SIGNERS_SS58: [&str; 10] = [ "qzob8fLUV7xUE8EjXpGsy3JHtxYdCUXhgYh9GhPCTpys1LttF", ]; -/// Approvals required on the mainnet (and staging-mainnet) treasury multisig. -const MAINNET_TREASURY_MULTISIG_THRESHOLD: u32 = 6; - -/// Multisig nonce for the staging-mainnet treasury. The eventual mainnet -/// treasury uses the same signers and threshold with nonce 0; the different -/// nonce yields a different derived treasury account and therefore a different -/// genesis hash — the only intended state difference between the two chains. -const STAGING_MAINNET_TREASURY_MULTISIG_NONCE: u64 = 1; - -/// Placeholder TGE timestamp for the mainnet vesting table: **2026-12-01 00:00:00 UTC**. +/// Placeholder TGE timestamp for the mainnet vesting table: **2026-09-03 00:00:00 UTC**. /// DUMMY — re-derive as midnight UTC of the real launch date. -const MAINNET_VESTING_START_MS: VestingMoment = utc_midnight_ms(2026, 12, 1); +const MAINNET_VESTING_START_MS: VestingMoment = utc_midnight_ms(2026, 9, 3); /// Flip to `true` only when [`mainnet_vesting_schedules`] holds the final mainnet /// allocation table. While `false`, `mainnet_config_genesis` refuses to build @@ -686,12 +682,12 @@ const STAGING_REHEARSAL_VESTING: [(&str, u128); 20] = [ ("qznhDdGC8aRrHLHBGt99eHaLkGYwnRZxqNJ8yi4VTPqz9wfDo", 4_000 * UNIT), // staging-19 ]; -/// Staging rehearsal vest clock: **2026-09-01 14:00:00 UTC**, 5-minute cliff, +/// Staging rehearsal vest clock: **2026-09-03 14:00:00 UTC**, 5-minute cliff, /// fully vested after 10 days. Independent of the dummy mainnet TGE so these /// keys can be exercised on the dress-rehearsal chain. Not midnight UTC — the /// rehearsal clock is a wall-clock offset from launch day, not a TGE epoch. const STAGING_REHEARSAL_VESTING_START_MS: VestingMoment = - utc_midnight_ms(2026, 9, 1) + minutes_ms(14 * 60); + MAINNET_VESTING_START_MS + minutes_ms(14 * 60); const STAGING_REHEARSAL_VESTING_CLIFF_MS: VestingMoment = STAGING_REHEARSAL_VESTING_START_MS + minutes_ms(5); const STAGING_REHEARSAL_VESTING_END_MS: VestingMoment = @@ -739,98 +735,91 @@ fn staging_rehearsal_vesting_schedules() -> Vec { /// Mainnet vesting allocation table, shared with staging-mainnet. /// /// DUMMY scaffold: the category shape (cliff / duration) approximates the launch -/// plan, but the beneficiaries are stand-ins (treasury signers T1/T2 and the -/// treasury itself) plus the staging rehearsal HD accounts, and the amounts are +/// plan, but the beneficiaries are stand-ins (tech-collective members T1/T2/T3) +/// plus the staging rehearsal HD accounts, and the amounts are /// placeholders. Replace every entry with the real allocation and flip /// [`MAINNET_VESTING_FINALIZED`] before adding the mainnet preset. -fn mainnet_vesting_schedules(treasury_account: &AccountId) -> Vec { - let signers = mainnet_treasury_signers(); +fn mainnet_vesting_schedules(tech_collective: &[AccountId]) -> Vec { + assert!( + tech_collective.len() >= 3, + "mainnet vesting placeholders require at least three tech-collective members" + ); let start = MAINNET_VESTING_START_MS; let mut schedules = vec![ - // DUMMY team allocation — 12-month cliff, 4-year linear vest. + // DUMMY team allocation — 24-hour test cliff, 4-year linear vest. ( - signers[0].clone(), + tech_collective[0].clone(), start, - start + days_ms(365), + start + days_ms(1), start + days_ms(4 * 365), 1_500_000 * UNIT, ), - // DUMMY early-backer allocation — 6-month cliff, 2-year linear vest. + // DUMMY early-backer allocation — 24-hour test cliff, 2-year linear vest. ( - signers[1].clone(), + tech_collective[1].clone(), start, - start + days_ms(180), + start + days_ms(1), start + days_ms(2 * 365), 1_000_000 * UNIT, ), - // DUMMY ecosystem reserve — no cliff, 4-year linear vest into the treasury. - (treasury_account.clone(), start, start, start + days_ms(4 * 365), 500_000 * UNIT), + // DUMMY ecosystem reserve — no cliff, 4-year linear vest to a placeholder. + (tech_collective[2].clone(), start, start, start + days_ms(4 * 365), 500_000 * UNIT), ]; schedules.extend(staging_rehearsal_vesting_schedules()); schedules } -/// True once every entry of [`MAINNET_TREASURY_SIGNERS_SS58`] has been +/// True once every entry of [`MAINNET_TECH_COLLECTIVE_MEMBERS_SS58`] has been /// replaced with a real address. -fn mainnet_signers_finalized() -> bool { - MAINNET_TREASURY_SIGNERS_SS58 +fn mainnet_tech_collective_finalized() -> bool { + MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 .iter() - .all(|s| !s.starts_with("REPLACE_WITH_SIGNER")) + .all(|s| !s.starts_with("REPLACE_WITH_MEMBER")) } -fn mainnet_treasury_signers() -> Vec { +fn mainnet_tech_collective_members() -> Vec { assert!( - mainnet_signers_finalized(), - "mainnet treasury signers are placeholders — fill MAINNET_TREASURY_SIGNERS_SS58 \ + mainnet_tech_collective_finalized(), + "mainnet tech-collective members are placeholders — fill \ + MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 \ with the real launch addresses before building this chain spec" ); - let signers: Vec = - MAINNET_TREASURY_SIGNERS_SS58.iter().map(|s| account_from_ss58(s)).collect(); - let mut deduped = signers.clone(); + let members: Vec = MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 + .iter() + .map(|s| account_from_ss58(s)) + .collect(); + let mut deduped = members.clone(); deduped.sort(); deduped.dedup(); - assert_eq!(deduped.len(), signers.len(), "mainnet treasury signers must be distinct"); - signers + assert_eq!(deduped.len(), members.len(), "mainnet tech-collective members must be distinct"); + members } -/// Genesis shared 1:1 between mainnet and staging-mainnet: the 6-of-10 -/// treasury multisig, a tech collective of the same ten accounts, and the -/// [`mainnet_vesting_schedules`] allocation table. Final mainnet allocations -/// belong in here so staging inherits them unchanged; only `treasury_nonce` -/// may differ between the two presets. -fn mainnet_config_genesis(preset: &str, treasury_nonce: u64) -> Value { +/// Genesis shared between mainnet and staging-mainnet: an unconfigured treasury, +/// the ten-member tech collective, and the [`mainnet_vesting_schedules`] +/// allocation table. Final mainnet allocations belong here so staging inherits +/// them unchanged. +fn mainnet_config_genesis(preset: &str) -> Value { assert!( MAINNET_VESTING_FINALIZED || preset == STAGING_MAINNET_RUNTIME_PRESET, "mainnet vesting table still holds dummy entries — finalize mainnet_vesting_schedules() \ and flip MAINNET_VESTING_FINALIZED before building this chain spec" ); - let treasury_signers = mainnet_treasury_signers(); - let tech_collective = treasury_signers.clone(); - let treasury_account = Multisig::::derive_multisig_address( - &treasury_signers, - MAINNET_TREASURY_MULTISIG_THRESHOLD, - treasury_nonce, - ); - let signer_seed = governance_treasury_signer_seed(treasury_signers.len() as u32); + let tech_collective = mainnet_tech_collective_members(); + let member_seed = governance_member_seed(); let mut extra_balances: Vec<_> = - treasury_signers.iter().cloned().map(|a| (a, signer_seed)).collect(); + tech_collective.iter().cloned().map(|a| (a, member_seed)).collect(); extra_balances.extend(staging_rehearsal_liquid_seed()); let rehearsal_accounts = staging_rehearsal_accounts(); - log_genesis_accounts( - preset, - &rehearsal_accounts, - &treasury_account, - &treasury_signers, - &tech_collective, - ); - let vesting_schedules = mainnet_vesting_schedules(&treasury_account); + log_genesis_accounts(preset, &rehearsal_accounts, None, &[], &tech_collective); + let vesting_schedules = mainnet_vesting_schedules(&tech_collective); log_vesting_schedules(preset, &vesting_schedules); - let treasury = TreasuryGenesis { account: treasury_account }; + let treasury = TreasuryGenesis { account: None }; genesis_template(vec![], treasury, tech_collective, extra_balances, vesting_schedules) } pub fn staging_mainnet_config_genesis() -> Value { - mainnet_config_genesis(STAGING_MAINNET_RUNTIME_PRESET, STAGING_MAINNET_TREASURY_MULTISIG_NONCE) + mainnet_config_genesis(STAGING_MAINNET_RUNTIME_PRESET) } /// Provides the JSON representation of predefined genesis config for given `id`. @@ -889,25 +878,24 @@ mod tests { ] { assert!(seed.len() >= MIN_TECH_COLLECTIVE_MEMBERS); } - // Staging-mainnet's collective is the signer table itself. - assert!(MAINNET_TREASURY_SIGNERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS); + assert!(MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS); } - /// While the signer table still holds placeholders, staging-mainnet spec building + /// While the member table still holds placeholders, staging-mainnet spec building /// must fail loudly; once the real launch addresses land, the preset must build and - /// seed all ten signers as the tech collective. + /// seed all ten tech-collective members. #[test] - fn staging_mainnet_gates_on_real_signers() { - if mainnet_signers_finalized() { + fn staging_mainnet_gates_on_real_tech_collective_members() { + if mainnet_tech_collective_finalized() { let raw = get_preset(&PresetId::from(STAGING_MAINNET_RUNTIME_PRESET)).expect("preset exists"); let (_, members) = prepare_genesis_build_input(raw).expect("well-formed"); assert_eq!( members.expect("tech collective must be seeded").len(), - MAINNET_TREASURY_SIGNERS_SS58.len() + MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() ); } else { - assert!(std::panic::catch_unwind(mainnet_treasury_signers).is_err()); + assert!(std::panic::catch_unwind(mainnet_tech_collective_members).is_err()); } } @@ -933,8 +921,8 @@ mod tests { assert_eq!(utc_midnight_ms(2024, 2, 29), 1_709_164_800_000); // Each documented vesting epoch, pinned to its independently derived value. assert_eq!(GENESIS_VESTING_START_MS, 1_785_888_000_000); // 2026-08-05 UTC - assert_eq!(STAGING_REHEARSAL_VESTING_START_MS, 1_788_271_200_000); // 2026-09-01 14:00 UTC - assert_eq!(MAINNET_VESTING_START_MS, 1_796_083_200_000); // 2026-12-01 UTC + assert_eq!(MAINNET_VESTING_START_MS, 1_788_393_600_000); // 2026-09-03 UTC + assert_eq!(STAGING_REHEARSAL_VESTING_START_MS, 1_788_444_000_000); // 2026-09-03 14:00 UTC } #[test] @@ -969,10 +957,10 @@ mod tests { #[test] fn all_presets_build_genesis_storage() { for id in preset_names() { - // Staging-mainnet intentionally panics on its placeholder signer table + // Staging-mainnet intentionally panics on its placeholder member table // until the real launch addresses are filled in. if AsRef::::as_ref(&id) == STAGING_MAINNET_RUNTIME_PRESET && - !mainnet_signers_finalized() + !mainnet_tech_collective_finalized() { continue; } @@ -1023,7 +1011,7 @@ mod tests { (HEISENBERG_RUNTIME_PRESET, 3), (PLANCK_RUNTIME_PRESET, 0), ]; - if mainnet_signers_finalized() { + if mainnet_tech_collective_finalized() { expected.push((STAGING_MAINNET_RUNTIME_PRESET, 3 + STAGING_REHEARSAL_VESTING.len())); } let mut total = 0usize; @@ -1063,13 +1051,25 @@ mod tests { #[test] fn staging_mainnet_includes_rehearsal_vesting_accounts() { assert!( - mainnet_signers_finalized(), - "staging-mainnet preset is gated on real treasury signers" + mainnet_tech_collective_finalized(), + "staging-mainnet preset is gated on real tech-collective members" ); let raw = get_preset(&PresetId::from(STAGING_MAINNET_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"); + assert!(config.vesting.schedules.iter().all(|(_, start, cliff, end, _)| { + *start >= MAINNET_VESTING_START_MS && + *start < MAINNET_VESTING_START_MS + days_ms(1) && + *cliff >= *start && + *cliff <= *start + days_ms(1) && + *end > *start + })); + let mut end_times: Vec<_> = + config.vesting.schedules.iter().map(|(_, _, _, end, _)| *end).collect(); + end_times.sort(); + end_times.dedup(); + assert_eq!(end_times.len(), 3, "staging schedules must retain varied durations"); let mut totals = Vec::with_capacity(STAGING_REHEARSAL_VESTING.len()); for (ss58, total) in STAGING_REHEARSAL_VESTING { let who = account_from_ss58(ss58); @@ -1098,21 +1098,61 @@ mod tests { } #[test] - fn staging_mainnet_funds_each_signer_for_governance() { + fn staging_mainnet_leaves_treasury_unconfigured() { + let raw = + get_preset(&PresetId::from(STAGING_MAINNET_RUNTIME_PRESET)).expect("preset exists"); + let (json, members) = prepare_genesis_build_input(raw).expect("well-formed"); + let config: RuntimeGenesisConfig = serde_json::from_slice(&json).expect("deserializes"); + assert!(config.treasury_pallet.treasury_account.is_none()); + let members = members.expect("tech collective must be seeded"); + for (schedule, member) in config.vesting.schedules.iter().take(3).zip(members.iter()) { + assert_eq!(&schedule.0, member, "dummy grant must use a collective placeholder"); + } + } + + #[test] + fn staging_mainnet_root_retargets_vesting_before_treasury_setup() { + let raw = + get_preset(&PresetId::from(STAGING_MAINNET_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 storage = config.build_storage().expect("staging genesis must build"); + let mut ext = sp_io::TestExternalities::new(storage); + ext.execute_with(|| { + crate::System::set_block_number(1); + assert!(crate::TreasuryPallet::treasury_account().is_none()); + let new_beneficiary = AccountId::new([99u8; 32]); + crate::Vesting::retarget_schedule( + crate::RuntimeOrigin::root(), + 0, + new_beneficiary.clone(), + ) + .expect("Root must retarget without a configured treasury"); + assert_eq!( + pallet_vesting::Schedules::::get(0) + .expect("schedule must remain") + .beneficiary, + new_beneficiary + ); + }); + } + + #[test] + fn staging_mainnet_funds_each_tech_collective_member_for_governance() { let raw = get_preset(&PresetId::from(STAGING_MAINNET_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 signers = mainnet_treasury_signers(); - let expected = governance_treasury_signer_seed(signers.len() as u32); - for signer in signers { + let members = mainnet_tech_collective_members(); + let expected = governance_member_seed(); + for member in members { let balance = config .balances .balances .iter() - .find(|(account, _)| account == &signer) + .find(|(account, _)| account == &member) .map(|(_, amount)| *amount) - .expect("mainnet signer must have a genesis balance"); + .expect("mainnet tech-collective member must have a genesis balance"); assert_eq!(balance, expected); } } diff --git a/runtime/tests/governance/fast_upgrade.rs b/runtime/tests/governance/fast_upgrade.rs index f454988e..2fc595dc 100644 --- a/runtime/tests/governance/fast_upgrade.rs +++ b/runtime/tests/governance/fast_upgrade.rs @@ -11,7 +11,7 @@ mod tests { use pallet_referenda::TracksInfo; use quantus_runtime::{ configs::TechReferendaInstance, - genesis_config_presets::governance_treasury_signer_seed, + genesis_config_presets::governance_member_seed, governance::definitions::{TechCollectiveTracksInfo, FAST_UPGRADE_TRACK_ID}, pallet_custom_origins, Balances, OriginCaller, Preimage, Runtime, RuntimeCall, RuntimeOrigin, System, TechCollective, TechReferenda, @@ -37,7 +37,7 @@ mod tests { )); } let proposer = TestCommons::account_id(1); - Balances::make_free_balance_be(&proposer, governance_treasury_signer_seed(10)); + Balances::make_free_balance_be(&proposer, governance_member_seed()); proposer } diff --git a/runtime/tests/governance/treasury.rs b/runtime/tests/governance/treasury.rs index ec88ee49..7dfac281 100644 --- a/runtime/tests/governance/treasury.rs +++ b/runtime/tests/governance/treasury.rs @@ -1,13 +1,20 @@ -//! Tests for the treasury config pallet (account for mining-reward fallbacks). +//! Tests for the governance-controlled treasury account configuration. #[cfg(test)] mod tests { - use frame_support::{assert_err, assert_ok}; + use crate::common::TestCommons; + use codec::Encode; + use frame_support::{assert_err, assert_ok, traits::Currency}; use frame_system::RawOrigin; + use pallet_referenda::TracksInfo; use quantus_runtime::{ - configs::TreasuryPalletId, AccountId, Runtime, System, TreasuryPallet, UNIT, + configs::{TechReferendaInstance, TreasuryPalletId}, + genesis_config_presets::governance_member_seed, + governance::definitions::TechCollectiveTracksInfo, AccountId, Balances, OriginCaller, + Preimage, Runtime, RuntimeCall, RuntimeOrigin, System, TechCollective, TechReferenda, + TreasuryPallet, UNIT, }; - use sp_runtime::{traits::AccountIdConversion, BuildStorage}; + use sp_runtime::{traits::AccountIdConversion, traits::Hash, BuildStorage, MultiAddress}; fn treasury_account_id() -> AccountId { TreasuryPalletId::get().into_account_truncating() @@ -32,6 +39,13 @@ mod tests { ext } + fn new_unconfigured_test_ext() -> sp_io::TestExternalities { + let t = frame_system::GenesisConfig::::default().build_storage().unwrap(); + let mut ext = sp_io::TestExternalities::new(t); + ext.execute_with(|| System::set_block_number(1)); + ext + } + #[test] fn genesis_sets_treasury_config() { new_test_ext().execute_with(|| { @@ -51,6 +65,80 @@ mod tests { }); } + #[test] + fn root_sets_treasury_after_unconfigured_genesis() { + new_unconfigured_test_ext().execute_with(|| { + assert!(TreasuryPallet::treasury_account().is_none()); + let treasury = AccountId::new([99u8; 32]); + assert_ok!(TreasuryPallet::set_treasury_account( + RawOrigin::Root.into(), + treasury.clone(), + )); + assert_eq!(TreasuryPallet::treasury_account(), Some(treasury)); + }); + } + + #[test] + fn tech_collective_sets_treasury_after_unconfigured_genesis() { + new_unconfigured_test_ext().execute_with(|| { + for member in 1..=10u8 { + assert_ok!(TechCollective::add_member( + RuntimeOrigin::root(), + MultiAddress::from(TestCommons::account_id(member)), + )); + } + let proposer = TestCommons::account_id(1); + Balances::make_free_balance_be(&proposer, governance_member_seed()); + let treasury = AccountId::new([99u8; 32]); + let call = RuntimeCall::TreasuryPallet(pallet_treasury::Call::set_treasury_account { + account: treasury.clone(), + }); + let encoded = call.encode(); + let hash = ::Hashing::hash(&encoded); + assert_ok!(Preimage::note_preimage( + RuntimeOrigin::signed(proposer.clone()), + encoded.clone(), + )); + assert_ok!(TechReferenda::submit( + RuntimeOrigin::signed(proposer.clone()), + Box::new(OriginCaller::system(RawOrigin::Root)), + frame_support::traits::Bounded::Lookup { + hash, + len: encoded.len() as u32, + }, + frame_support::traits::schedule::DispatchTime::After(0), + )); + let index = + pallet_referenda::ReferendumCount::::get() - 1; + assert_ok!(TechReferenda::place_decision_deposit( + RuntimeOrigin::signed(proposer), + index, + )); + for member in 1..=10u8 { + assert_ok!(TechCollective::vote( + RuntimeOrigin::signed(TestCommons::account_id(member)), + index, + true, + )); + } + let track = >::info(0) + .expect("Root track must exist"); + let target = System::block_number() + + TestCommons::calculate_governance_blocks( + track.prepare_period, + track.decision_period, + track.confirm_period, + track.min_enactment_period, + ); + TestCommons::run_to_block(target); + assert!(matches!( + pallet_referenda::ReferendumInfoFor::::get(index), + Some(pallet_referenda::ReferendumInfo::Approved(..)) + )); + assert_eq!(TreasuryPallet::treasury_account(), Some(treasury)); + }); + } + #[test] fn set_treasury_account_requires_root() { new_test_ext().execute_with(|| { diff --git a/runtime/tests/governance/vesting.rs b/runtime/tests/governance/vesting.rs index 6ec41c04..8dcc8105 100644 --- a/runtime/tests/governance/vesting.rs +++ b/runtime/tests/governance/vesting.rs @@ -1,19 +1,17 @@ //! 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. +//! cannot see: Root-only schedule administration and the wormhole proof recorder consuming +//! claim payout events. #[cfg(test)] mod tests { use crate::common::TestCommons; - 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, + pallet_custom_origins, AccountId, Balance, Balances, OriginCaller, Runtime, RuntimeCall, + RuntimeEvent, RuntimeOrigin, System, Vesting, Wormhole, EXISTENTIAL_DEPOSIT, UNIT, }; use sp_core::crypto::AccountId32; use sp_runtime::{BuildStorage, DispatchError}; @@ -69,30 +67,6 @@ mod tests { (leaves.len(), max_exit) } - 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, - Box::new(call), - )); - } - #[test] fn payout_policy_covers_account_and_wormhole_minimums() { let quantum = VestingPayoutQuantum::get(); @@ -142,29 +116,21 @@ mod tests { } #[test] - fn treasury_multisig_creates_and_ends_schedules() { + fn root_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, - }), + assert_ok!(Vesting::create_schedule( + RuntimeOrigin::root(), + beneficiary.clone(), 0, - ); + 0, + END_MS, + GRANT, + )); let schedule = pallet_vesting::Schedules::::get(0).expect("schedule must be created"); assert_eq!(schedule.beneficiary, beneficiary); @@ -173,10 +139,7 @@ mod tests { // 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_ok!(Vesting::end_schedule(RuntimeOrigin::root(), 0)); assert!(pallet_vesting::Schedules::::get(0).is_none()); assert_eq!(Balances::total_balance(&beneficiary), GRANT / 2); assert_eq!(Balances::total_balance(&treasury), 950 * UNIT); @@ -185,8 +148,30 @@ mod tests { } #[test] - fn non_treasury_origins_are_rejected() { + fn all_signed_origins_including_treasury_are_rejected() { new_test_ext(Some(treasury_multisig())).execute_with(|| { + Balances::make_free_balance_be(&treasury_multisig(), 1000 * UNIT); + assert_noop!( + Vesting::retarget_schedule( + RuntimeOrigin::from(OriginCaller::Origins( + pallet_custom_origins::Origin::FastUpgrade, + )), + 0, + account(8), + ), + DispatchError::BadOrigin + ); + assert_noop!( + Vesting::create_schedule( + RuntimeOrigin::signed(treasury_multisig()), + account(7), + 0, + 0, + END_MS, + GRANT, + ), + DispatchError::BadOrigin + ); assert_noop!( Vesting::create_schedule( RuntimeOrigin::signed(account(1)), @@ -207,8 +192,7 @@ mod tests { DispatchError::BadOrigin ); - // Root is the break-glass admin and works without the multisig. - Balances::make_free_balance_be(&treasury_multisig(), 1000 * UNIT); + // Root is the only schedule administrator. assert_ok!(Vesting::create_schedule( RuntimeOrigin::root(), account(7), @@ -217,6 +201,18 @@ mod tests { END_MS, GRANT, )); + assert_noop!( + Vesting::end_schedule(RuntimeOrigin::signed(treasury_multisig()), 0), + DispatchError::BadOrigin + ); + assert_noop!( + Vesting::retarget_schedule( + RuntimeOrigin::signed(treasury_multisig()), + 0, + account(8), + ), + DispatchError::BadOrigin + ); }); } @@ -229,7 +225,7 @@ mod tests { 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. + // Signed origins never administer schedules. assert_noop!( Vesting::create_schedule( RuntimeOrigin::signed(account(1)), From a688acea0d6a1756613792c6d4ee4e050dd518ae Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 12:55:32 +0800 Subject: [PATCH 2/4] Separate staging genesis naming from mainnet --- docs/RUNTIME_SURFACE.md | 2 +- docs/STAGING_MAINNET_LAUNCH.md | 15 +-- docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md | 2 +- node/src/chain_spec.rs | 3 +- runtime/src/genesis_config_presets.rs | 134 ++++++++++------------ 5 files changed, 74 insertions(+), 82 deletions(-) diff --git a/docs/RUNTIME_SURFACE.md b/docs/RUNTIME_SURFACE.md index 0e9c3d35..31c72c93 100644 --- a/docs/RUNTIME_SURFACE.md +++ b/docs/RUNTIME_SURFACE.md @@ -255,7 +255,7 @@ The high-security whitelist (`HighSecurityConfig::is_whitelisted`, extension 8) - `heisenberg` — **internal integration testnet**, not mainnet. Tokens have no monetary value; the network may be reset. - `planck` — public testnet (live treasury signers + faucet). - `staging_mainnet` — mainnet dress rehearsal (see `docs/STAGING_MAINNET_LAUNCH.md`). -- **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). `staging_mainnet` builds from `mainnet_config_genesis`, the exact function the eventual mainnet preset will use; its vesting table is a DUMMY scaffold gated by `MAINNET_VESTING_FINALIZED`, and until that flag is flipped with the real allocation table no mainnet preset is exposed (details in `docs/STAGING_MAINNET_LAUNCH.md`). +- **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). `staging_mainnet` has its own staging-only genesis function and vesting table; mainnet will use a separate preset and chain spec (details in `docs/STAGING_MAINNET_LAUNCH.md`). - 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`; `staging_mainnet` deliberately leaves it unconfigured for a post-launch Root referendum. No dedicated treasury genesis balance exists (endowments are a separate list). - Tech-collective seeded via the chain-spec-only `tech_collective_seed_members` JSON field (`prepare_genesis_build_input` + `seed_tech_collective`). diff --git a/docs/STAGING_MAINNET_LAUNCH.md b/docs/STAGING_MAINNET_LAUNCH.md index 779fd413..93e4e6a8 100644 --- a/docs/STAGING_MAINNET_LAUNCH.md +++ b/docs/STAGING_MAINNET_LAUNCH.md @@ -1,7 +1,8 @@ # Staging-Mainnet Launch -Mainnet dress rehearsal. Genesis comes from `mainnet_config_genesis` -(`runtime/src/genesis_config_presets.rs`). It seeds the launch tech collective +Mainnet dress rehearsal. Genesis comes from `staging_mainnet_config_genesis` +(`runtime/src/genesis_config_presets.rs`). It is staging-only; mainnet will use +a separate genesis preset and chain spec. It seeds the launch tech collective but deliberately leaves the treasury unconfigured; the collective sets the treasury account after launch through a Root referendum. @@ -10,7 +11,8 @@ treasury account after launch through a Root referendum. - Treasury: unconfigured at genesis. `TreasuryPallet::set_treasury_account` is called by an approved Root referendum after the real treasury is ready. - Tech collective: the ten accounts in - `MAINNET_TECH_COLLECTIVE_MEMBERS_SS58` (referenda curves are runtime constants). + `STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58` (referenda curves are runtime + constants). - Balances: no fixed figure is written down. Each member's liquid endowment is computed at genesis by `governance_member_seed()` from the live runtime constants — ED, the referenda submission and decision bonds, a maximum-size @@ -23,15 +25,14 @@ treasury account after launch through a Root referendum. submit transactions. Everything else sits in the vesting pot. Read the exact per-member and total figures for a given build out of the generated spec's `balances` section rather than restating them here. -- Vesting: `mainnet_vesting_schedules` is a DUMMY scaffold (team / early-backer / +- Vesting: `staging_mainnet_vesting_schedules` contains team / early-backer / ecosystem entries with stand-in beneficiaries T1, T2, and T3, plus 20 HD rehearsal accounts with distinct grants summing to 100_000 UNIT). Every schedule starts on 2026-09-03 UTC and every cliff is at most 24 hours. Team and ecosystem grants retain their 4×365-day duration, the early-backer grant retains its 2×365-day duration, and rehearsal grants retain their 5-minute cliff / - 10-day duration from 14:00 UTC. Replace this with the real allocation table and - flip `MAINNET_VESTING_FINALIZED` before the mainnet preset is added — until then - only staging-mainnet builds. + 10-day duration from 14:00 UTC. These schedules belong only to staging-mainnet + and are not the basis of the future mainnet allocation table. ## Generate the chain spec diff --git a/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md b/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md index 3f4768ee..45dc996d 100644 --- a/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md +++ b/docs/TECH_COLLECTIVE_GOVERNANCE_TUNING.md @@ -55,7 +55,7 @@ fn approval(&self, _) -> Perbill { Perbill::from_rational(self.ayes, 1.max(self. Both tracks use flat curves, so the required numbers never decay over the decision period (`fast_track_curves_pin_eight_of_ten` in `runtime/tests/governance/fast_upgrade.rs` pins this for track 1): -| Track | `min_approval` | `min_support` | 10 members (staging-mainnet / mainnet tech collective) | 5 members (`MIN_TECH_COLLECTIVE_MEMBERS`, dev/testnet presets) | +| Track | `min_approval` | `min_support` | 10 members (staging-mainnet tech collective) | 5 members (`MIN_TECH_COLLECTIVE_MEMBERS`, dev/testnet presets) | |---|---|---|---|---| | 0 | 61% | 60% | 6 ayes; 4 nays block | 3 ayes; 2 nays block | | 1 `fast_upgrade` | 80% | 80% | 8 ayes; 3 nays block | 4 ayes; 2 nays block | diff --git a/node/src/chain_spec.rs b/node/src/chain_spec.rs index 7b1fcb8f..c8a620b9 100644 --- a/node/src/chain_spec.rs +++ b/node/src/chain_spec.rs @@ -79,7 +79,8 @@ pub fn heisenberg_chain_spec() -> Result { /// /// Genesis seeds the launch tech collective but deliberately leaves the treasury /// unconfigured. The collective sets the treasury account later through a Root -/// referendum, so no temporary treasury multisig is part of genesis. +/// referendum, so no temporary treasury multisig is part of genesis. Mainnet +/// will use a separate genesis preset and chain spec. /// Bootnodes are added once the staging infrastructure exists (the `bootNodes` /// field lives outside genesis, so editing it does not change the hash). pub fn staging_mainnet_chain_spec() -> Result { diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 1b062954..0240a0e9 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -115,10 +115,10 @@ pub const PLANCK_RUNTIME_PRESET: &str = "planck"; /// Identifier for the staging-mainnet runtime preset. /// -/// Staging-mainnet is the mainnet dress rehearsal: its genesis is produced by -/// `mainnet_config_genesis`, the same function the eventual mainnet preset -/// will use. The treasury is deliberately left unconfigured at genesis and is -/// set later by a Root referendum of the tech collective. +/// Staging-mainnet is the mainnet dress rehearsal. It has its own genesis +/// preset; mainnet will use a separate preset and chain spec. The treasury is +/// deliberately left unconfigured at genesis and is set later by a Root +/// referendum of the tech collective. pub const STAGING_MAINNET_RUNTIME_PRESET: &str = "staging_mainnet"; /// SS58 address format used by all Quantus chains. @@ -628,12 +628,12 @@ pub fn planck_config_genesis() -> Value { genesis_template(endowed_accounts, treasury, tech_collective, signer_fee_seed, vec![]) } -/// The ten mainnet tech-collective members. Staging-mainnet uses the exact same -/// accounts. No treasury account or signer set is configured at genesis. +/// The ten staging-mainnet tech-collective members. No treasury account or +/// signer set is configured at genesis. /// /// Spec building panics while any placeholder remains, and the preset is /// skipped in tests until then. -const MAINNET_TECH_COLLECTIVE_MEMBERS_SS58: [&str; 10] = [ +const STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58: [&str; 10] = [ "qznoXy8q1BgD4jka7wGHMgPtEw63Ut8gvLv9b6x1GvavmCgi1", "qzq8U3GsQraM2pofq9zHbrBvByczzCHrYXphPwdPf2cq9Q55r", "qzkCCKgT3r4PekyzFAP6MB9mmmCtPZj1tT56CZiECNq38L24z", @@ -646,19 +646,12 @@ const MAINNET_TECH_COLLECTIVE_MEMBERS_SS58: [&str; 10] = [ "qzob8fLUV7xUE8EjXpGsy3JHtxYdCUXhgYh9GhPCTpys1LttF", ]; -/// Placeholder TGE timestamp for the mainnet vesting table: **2026-09-03 00:00:00 UTC**. -/// DUMMY — re-derive as midnight UTC of the real launch date. -const MAINNET_VESTING_START_MS: VestingMoment = utc_midnight_ms(2026, 9, 3); - -/// Flip to `true` only when [`mainnet_vesting_schedules`] holds the final mainnet -/// allocation table. While `false`, `mainnet_config_genesis` refuses to build -/// any preset except staging-mainnet (the dress rehearsal may ship dummies). -const MAINNET_VESTING_FINALIZED: bool = false; +/// Staging-mainnet vesting start: **2026-09-03 00:00:00 UTC**. +const STAGING_MAINNET_START: VestingMoment = utc_midnight_ms(2026, 9, 3); /// HD indexes 0..=19 of the staging rehearsal wallet /// (`m/44'/189189'/{i}'/0'/0'`, ML-DSA-87). Dummy grants so those keys can -/// exercise claim on staging-mainnet; replace each amount with the real -/// allocation before flipping [`MAINNET_VESTING_FINALIZED`]. +/// exercise claim on staging-mainnet. const STAGING_REHEARSAL_VESTING: [(&str, u128); 20] = [ ("qznAAg1Mxu9cmFyxdqi9yTFHm8ycaHfgWbL71z3QHraFAqJCP", 400 * UNIT), // staging-0 ("qznwThCk3UNZNQNGtz93D3KQi9aQRfnZbsA7AmbhzLTP1cUWK", 1_000 * UNIT), // staging-1 @@ -683,11 +676,10 @@ const STAGING_REHEARSAL_VESTING: [(&str, u128); 20] = [ ]; /// Staging rehearsal vest clock: **2026-09-03 14:00:00 UTC**, 5-minute cliff, -/// fully vested after 10 days. Independent of the dummy mainnet TGE so these -/// keys can be exercised on the dress-rehearsal chain. Not midnight UTC — the -/// rehearsal clock is a wall-clock offset from launch day, not a TGE epoch. +/// fully vested after 10 days. Not midnight UTC — the rehearsal clock is a +/// wall-clock offset on the staging-mainnet launch day. const STAGING_REHEARSAL_VESTING_START_MS: VestingMoment = - MAINNET_VESTING_START_MS + minutes_ms(14 * 60); + STAGING_MAINNET_START + minutes_ms(14 * 60); const STAGING_REHEARSAL_VESTING_CLIFF_MS: VestingMoment = STAGING_REHEARSAL_VESTING_START_MS + minutes_ms(5); const STAGING_REHEARSAL_VESTING_END_MS: VestingMoment = @@ -732,19 +724,16 @@ fn staging_rehearsal_vesting_schedules() -> Vec { .collect() } -/// Mainnet vesting allocation table, shared with staging-mainnet. -/// -/// DUMMY scaffold: the category shape (cliff / duration) approximates the launch -/// plan, but the beneficiaries are stand-ins (tech-collective members T1/T2/T3) -/// plus the staging rehearsal HD accounts, and the amounts are -/// placeholders. Replace every entry with the real allocation and flip -/// [`MAINNET_VESTING_FINALIZED`] before adding the mainnet preset. -fn mainnet_vesting_schedules(tech_collective: &[AccountId]) -> Vec { +/// Staging-mainnet vesting schedules. The first three beneficiaries are +/// tech-collective stand-ins; the remainder are staging rehearsal accounts. +fn staging_mainnet_vesting_schedules( + tech_collective: &[AccountId], +) -> Vec { assert!( tech_collective.len() >= 3, - "mainnet vesting placeholders require at least three tech-collective members" + "staging-mainnet vesting placeholders require at least three tech-collective members" ); - let start = MAINNET_VESTING_START_MS; + let start = STAGING_MAINNET_START; let mut schedules = vec![ // DUMMY team allocation — 24-hour test cliff, 4-year linear vest. ( @@ -769,59 +758,58 @@ fn mainnet_vesting_schedules(tech_collective: &[AccountId]) -> Vec bool { - MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 +fn staging_mainnet_tech_collective_configured() -> bool { + STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 .iter() .all(|s| !s.starts_with("REPLACE_WITH_MEMBER")) } -fn mainnet_tech_collective_members() -> Vec { +fn staging_mainnet_tech_collective_members() -> Vec { assert!( - mainnet_tech_collective_finalized(), - "mainnet tech-collective members are placeholders — fill \ - MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 \ + staging_mainnet_tech_collective_configured(), + "staging-mainnet tech-collective members are placeholders — fill \ + STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 \ with the real launch addresses before building this chain spec" ); - let members: Vec = MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 + let members: Vec = STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58 .iter() .map(|s| account_from_ss58(s)) .collect(); let mut deduped = members.clone(); deduped.sort(); deduped.dedup(); - assert_eq!(deduped.len(), members.len(), "mainnet tech-collective members must be distinct"); + assert_eq!( + deduped.len(), + members.len(), + "staging-mainnet tech-collective members must be distinct" + ); members } -/// Genesis shared between mainnet and staging-mainnet: an unconfigured treasury, -/// the ten-member tech collective, and the [`mainnet_vesting_schedules`] -/// allocation table. Final mainnet allocations belong here so staging inherits -/// them unchanged. -fn mainnet_config_genesis(preset: &str) -> Value { - assert!( - MAINNET_VESTING_FINALIZED || preset == STAGING_MAINNET_RUNTIME_PRESET, - "mainnet vesting table still holds dummy entries — finalize mainnet_vesting_schedules() \ - and flip MAINNET_VESTING_FINALIZED before building this chain spec" - ); - let tech_collective = mainnet_tech_collective_members(); +/// Staging-mainnet genesis: an unconfigured treasury, the staging tech +/// collective, rehearsal balances, and staging-only vesting schedules. +pub fn staging_mainnet_config_genesis() -> Value { + let tech_collective = staging_mainnet_tech_collective_members(); let member_seed = governance_member_seed(); let mut extra_balances: Vec<_> = tech_collective.iter().cloned().map(|a| (a, member_seed)).collect(); extra_balances.extend(staging_rehearsal_liquid_seed()); let rehearsal_accounts = staging_rehearsal_accounts(); - log_genesis_accounts(preset, &rehearsal_accounts, None, &[], &tech_collective); - let vesting_schedules = mainnet_vesting_schedules(&tech_collective); - log_vesting_schedules(preset, &vesting_schedules); + log_genesis_accounts( + STAGING_MAINNET_RUNTIME_PRESET, + &rehearsal_accounts, + None, + &[], + &tech_collective, + ); + let vesting_schedules = staging_mainnet_vesting_schedules(&tech_collective); + log_vesting_schedules(STAGING_MAINNET_RUNTIME_PRESET, &vesting_schedules); let treasury = TreasuryGenesis { account: None }; genesis_template(vec![], treasury, tech_collective, extra_balances, vesting_schedules) } -pub fn staging_mainnet_config_genesis() -> Value { - mainnet_config_genesis(STAGING_MAINNET_RUNTIME_PRESET) -} - /// Provides the JSON representation of predefined genesis config for given `id`. pub fn get_preset(id: &PresetId) -> Option> { let patch = match id.as_ref() { @@ -878,24 +866,26 @@ mod tests { ] { assert!(seed.len() >= MIN_TECH_COLLECTIVE_MEMBERS); } - assert!(MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS); + assert!( + STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS + ); } /// While the member table still holds placeholders, staging-mainnet spec building /// must fail loudly; once the real launch addresses land, the preset must build and /// seed all ten tech-collective members. #[test] - fn staging_mainnet_gates_on_real_tech_collective_members() { - if mainnet_tech_collective_finalized() { + fn staging_mainnet_gates_on_configured_tech_collective_members() { + if staging_mainnet_tech_collective_configured() { let raw = get_preset(&PresetId::from(STAGING_MAINNET_RUNTIME_PRESET)).expect("preset exists"); let (_, members) = prepare_genesis_build_input(raw).expect("well-formed"); assert_eq!( members.expect("tech collective must be seeded").len(), - MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() + STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() ); } else { - assert!(std::panic::catch_unwind(mainnet_tech_collective_members).is_err()); + assert!(std::panic::catch_unwind(staging_mainnet_tech_collective_members).is_err()); } } @@ -921,7 +911,7 @@ mod tests { assert_eq!(utc_midnight_ms(2024, 2, 29), 1_709_164_800_000); // Each documented vesting epoch, pinned to its independently derived value. assert_eq!(GENESIS_VESTING_START_MS, 1_785_888_000_000); // 2026-08-05 UTC - assert_eq!(MAINNET_VESTING_START_MS, 1_788_393_600_000); // 2026-09-03 UTC + assert_eq!(STAGING_MAINNET_START, 1_788_393_600_000); // 2026-09-03 UTC assert_eq!(STAGING_REHEARSAL_VESTING_START_MS, 1_788_444_000_000); // 2026-09-03 14:00 UTC } @@ -929,7 +919,7 @@ mod tests { fn genesis_vesting_times_are_sane() { // TGE-style epochs are midnight UTC. The rehearsal clock is a wall-clock // offset from launch day and is not required to land on midnight. - for start in [GENESIS_VESTING_START_MS, MAINNET_VESTING_START_MS] { + for start in [GENESIS_VESTING_START_MS, STAGING_MAINNET_START] { assert_eq!(start % MILLIS_PER_DAY, 0, "start must be midnight UTC"); assert!(start > YEAR_2020_MS); assert!(start < YEAR_2100_MS); @@ -960,7 +950,7 @@ mod tests { // Staging-mainnet intentionally panics on its placeholder member table // until the real launch addresses are filled in. if AsRef::::as_ref(&id) == STAGING_MAINNET_RUNTIME_PRESET && - !mainnet_tech_collective_finalized() + !staging_mainnet_tech_collective_configured() { continue; } @@ -1011,7 +1001,7 @@ mod tests { (HEISENBERG_RUNTIME_PRESET, 3), (PLANCK_RUNTIME_PRESET, 0), ]; - if mainnet_tech_collective_finalized() { + if staging_mainnet_tech_collective_configured() { expected.push((STAGING_MAINNET_RUNTIME_PRESET, 3 + STAGING_REHEARSAL_VESTING.len())); } let mut total = 0usize; @@ -1051,7 +1041,7 @@ mod tests { #[test] fn staging_mainnet_includes_rehearsal_vesting_accounts() { assert!( - mainnet_tech_collective_finalized(), + staging_mainnet_tech_collective_configured(), "staging-mainnet preset is gated on real tech-collective members" ); let raw = @@ -1059,8 +1049,8 @@ mod tests { let (json, _) = prepare_genesis_build_input(raw).expect("well-formed"); let config: RuntimeGenesisConfig = serde_json::from_slice(&json).expect("deserializes"); assert!(config.vesting.schedules.iter().all(|(_, start, cliff, end, _)| { - *start >= MAINNET_VESTING_START_MS && - *start < MAINNET_VESTING_START_MS + days_ms(1) && + *start >= STAGING_MAINNET_START && + *start < STAGING_MAINNET_START + days_ms(1) && *cliff >= *start && *cliff <= *start + days_ms(1) && *end > *start @@ -1143,7 +1133,7 @@ mod tests { get_preset(&PresetId::from(STAGING_MAINNET_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 members = mainnet_tech_collective_members(); + let members = staging_mainnet_tech_collective_members(); let expected = governance_member_seed(); for member in members { let balance = config @@ -1152,7 +1142,7 @@ mod tests { .iter() .find(|(account, _)| account == &member) .map(|(_, amount)| *amount) - .expect("mainnet tech-collective member must have a genesis balance"); + .expect("staging-mainnet tech-collective member must have a genesis balance"); assert_eq!(balance, expected); } } From 6f409f1a454e3b133f7dc4ff47c97c24ac3082d5 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 13:19:03 +0800 Subject: [PATCH 3/4] Apply pinned Rust formatting --- runtime/src/genesis_config_presets.rs | 8 ++------ runtime/tests/governance/treasury.rs | 16 ++++++++-------- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 0240a0e9..9be2ba41 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -726,9 +726,7 @@ fn staging_rehearsal_vesting_schedules() -> Vec { /// Staging-mainnet vesting schedules. The first three beneficiaries are /// tech-collective stand-ins; the remainder are staging rehearsal accounts. -fn staging_mainnet_vesting_schedules( - tech_collective: &[AccountId], -) -> Vec { +fn staging_mainnet_vesting_schedules(tech_collective: &[AccountId]) -> Vec { assert!( tech_collective.len() >= 3, "staging-mainnet vesting placeholders require at least three tech-collective members" @@ -866,9 +864,7 @@ mod tests { ] { assert!(seed.len() >= MIN_TECH_COLLECTIVE_MEMBERS); } - assert!( - STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS - ); + assert!(STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58.len() >= MIN_TECH_COLLECTIVE_MEMBERS); } /// While the member table still holds placeholders, staging-mainnet spec building diff --git a/runtime/tests/governance/treasury.rs b/runtime/tests/governance/treasury.rs index 7dfac281..4a301eb4 100644 --- a/runtime/tests/governance/treasury.rs +++ b/runtime/tests/governance/treasury.rs @@ -10,11 +10,14 @@ mod tests { use quantus_runtime::{ configs::{TechReferendaInstance, TreasuryPalletId}, genesis_config_presets::governance_member_seed, - governance::definitions::TechCollectiveTracksInfo, AccountId, Balances, OriginCaller, - Preimage, Runtime, RuntimeCall, RuntimeOrigin, System, TechCollective, TechReferenda, - TreasuryPallet, UNIT, + governance::definitions::TechCollectiveTracksInfo, + AccountId, Balances, OriginCaller, Preimage, Runtime, RuntimeCall, RuntimeOrigin, System, + TechCollective, TechReferenda, TreasuryPallet, UNIT, + }; + use sp_runtime::{ + traits::{AccountIdConversion, Hash}, + BuildStorage, MultiAddress, }; - use sp_runtime::{traits::AccountIdConversion, traits::Hash, BuildStorage, MultiAddress}; fn treasury_account_id() -> AccountId { TreasuryPalletId::get().into_account_truncating() @@ -102,10 +105,7 @@ mod tests { assert_ok!(TechReferenda::submit( RuntimeOrigin::signed(proposer.clone()), Box::new(OriginCaller::system(RawOrigin::Root)), - frame_support::traits::Bounded::Lookup { - hash, - len: encoded.len() as u32, - }, + frame_support::traits::Bounded::Lookup { hash, len: encoded.len() as u32 }, frame_support::traits::schedule::DispatchTime::After(0), )); let index = From b24667e32bf3b0e00256f90d0280f3bb40a50508 Mon Sep 17 00:00:00 2001 From: Nikolaus Heger Date: Wed, 2 Sep 2026 13:40:45 +0800 Subject: [PATCH 4/4] Delay staging placeholder vesting past the Root governance latency The three stand-in vesting beneficiaries started at launch with cliffs of at most 24 hours. Retargeting them is Root-only and the Root track needs 2h prepare + 24h confirm + 24h enactment, so the placeholders became claimable about a day before governance could replace them. claim is permissionless and pays whoever the schedule names at execution time. Placeholder schedules now start 2026-09-17 UTC, two weeks after the chain. Rehearsal accounts are unchanged on the launch-day clock. Track periods are named constants (ROOT_TRACK_*) so the latency can be read without the fast-governance override. A const assertion and a new preset test tie the earliest placeholder cliff to that latency. --- docs/STAGING_MAINNET_LAUNCH.md | 28 ++++++++--- runtime/src/genesis_config_presets.rs | 70 ++++++++++++++++++++++++--- runtime/src/governance/definitions.rs | 31 ++++++++++-- 3 files changed, 111 insertions(+), 18 deletions(-) diff --git a/docs/STAGING_MAINNET_LAUNCH.md b/docs/STAGING_MAINNET_LAUNCH.md index 93e4e6a8..b1d6ba8d 100644 --- a/docs/STAGING_MAINNET_LAUNCH.md +++ b/docs/STAGING_MAINNET_LAUNCH.md @@ -27,11 +27,23 @@ treasury account after launch through a Root referendum. spec's `balances` section rather than restating them here. - Vesting: `staging_mainnet_vesting_schedules` contains team / early-backer / ecosystem entries with stand-in beneficiaries T1, T2, and T3, plus - 20 HD rehearsal accounts with distinct grants summing to 100_000 UNIT). Every - schedule starts on 2026-09-03 UTC and every cliff is at most 24 hours. Team and - ecosystem grants retain their 4×365-day duration, the early-backer grant retains - its 2×365-day duration, and rehearsal grants retain their 5-minute cliff / - 10-day duration from 14:00 UTC. These schedules belong only to staging-mainnet + 20 HD rehearsal accounts with distinct grants summing to 100_000 UNIT). Two + clocks: + - Rehearsal accounts start on 2026-09-03 at 14:00 UTC and keep their 5-minute + cliff / 10-day duration. Those keys are ours, so they vest on launch day. + - The three stand-in schedules start on 2026-09-17 UTC, two weeks after the + chain, with cliffs at most 24 hours after that start. `claim` is + permissionless and pays whoever the schedule names when it runs, and + retargeting is Root-only, so a placeholder must not become claimable before + the collective can replace it. The Root track needs at least + `ROOT_TRACK_MIN_ENACTMENT_DELAY` (2h prepare + 24h confirm + 24h enactment) + even on an immediate unanimous vote; the rest is operational margin. A + compile-time assertion and + `staging_placeholder_cliffs_clear_the_root_governance_latency` hold the two + apart, so moving either date re-checks the invariant. + + Team and ecosystem grants retain their 4×365-day duration and the early-backer + grant its 2×365-day duration. These schedules belong only to staging-mainnet and are not the basis of the future mainnet allocation table. ## Generate the chain spec @@ -71,5 +83,7 @@ not move or create funds. Vesting administration is also Root-only. Replace each stand-in beneficiary through `vesting.retargetSchedule`, using an atomic `utility.batchAll` where -appropriate. Complete each retarget before its cliff: `claim` is permissionless -and pays the beneficiary stored when the claim executes. +appropriate. Complete each retarget before 2026-09-17 UTC, the earliest +placeholder cliff: `claim` is permissionless and pays the beneficiary stored when +the claim executes. Start the referendum well inside that window — the Root track +cannot enact anything in under about 50 hours. diff --git a/runtime/src/genesis_config_presets.rs b/runtime/src/genesis_config_presets.rs index 9be2ba41..ab10a7b3 100644 --- a/runtime/src/genesis_config_presets.rs +++ b/runtime/src/genesis_config_presets.rs @@ -19,7 +19,8 @@ #![allow(clippy::expect_used)] use crate::{ - AccountId, BalancesConfig, RuntimeGenesisConfig, EXISTENTIAL_DEPOSIT, MILLIS_PER_DAY, UNIT, + governance::definitions::ROOT_TRACK_MIN_ENACTMENT_DELAY_MS, AccountId, BalancesConfig, + RuntimeGenesisConfig, EXISTENTIAL_DEPOSIT, MILLIS_PER_DAY, UNIT, }; use alloc::{ string::{String, ToString}, @@ -649,6 +650,24 @@ const STAGING_MAINNET_TECH_COLLECTIVE_MEMBERS_SS58: [&str; 10] = [ /// Staging-mainnet vesting start: **2026-09-03 00:00:00 UTC**. const STAGING_MAINNET_START: VestingMoment = utc_midnight_ms(2026, 9, 3); +/// Start of the three stand-in schedules: **2026-09-17 00:00:00 UTC**, two weeks +/// after the chain's own epoch. +/// +/// `claim` is permissionless and pays whichever beneficiary the schedule holds +/// when it executes, while `retarget_schedule` is Root-only. A placeholder that +/// starts vesting at launch is therefore claimable long before the collective can +/// replace it: the Root track needs [`ROOT_TRACK_MIN_ENACTMENT_DELAY_MS`] even +/// with an immediate unanimous vote, and the treasury multisig, preimages, and +/// ten signatures have to be arranged on top of that. Two weeks covers the +/// governance latency with operational margin and keeps the epoch on midnight UTC. +const STAGING_PLACEHOLDER_VESTING_START_MS: VestingMoment = utc_midnight_ms(2026, 9, 17); + +const _: () = assert!( + STAGING_PLACEHOLDER_VESTING_START_MS >= + STAGING_MAINNET_START + ROOT_TRACK_MIN_ENACTMENT_DELAY_MS, + "staging placeholder vesting starts before a Root retarget referendum could be enacted" +); + /// HD indexes 0..=19 of the staging rehearsal wallet /// (`m/44'/189189'/{i}'/0'/0'`, ML-DSA-87). Dummy grants so those keys can /// exercise claim on staging-mainnet. @@ -725,13 +744,14 @@ fn staging_rehearsal_vesting_schedules() -> Vec { } /// Staging-mainnet vesting schedules. The first three beneficiaries are -/// tech-collective stand-ins; the remainder are staging rehearsal accounts. +/// tech-collective stand-ins vesting from [`STAGING_PLACEHOLDER_VESTING_START_MS`]; +/// the remainder are staging rehearsal accounts on the launch-day clock. fn staging_mainnet_vesting_schedules(tech_collective: &[AccountId]) -> Vec { assert!( tech_collective.len() >= 3, "staging-mainnet vesting placeholders require at least three tech-collective members" ); - let start = STAGING_MAINNET_START; + let start = STAGING_PLACEHOLDER_VESTING_START_MS; let mut schedules = vec![ // DUMMY team allocation — 24-hour test cliff, 4-year linear vest. ( @@ -909,13 +929,16 @@ mod tests { assert_eq!(GENESIS_VESTING_START_MS, 1_785_888_000_000); // 2026-08-05 UTC assert_eq!(STAGING_MAINNET_START, 1_788_393_600_000); // 2026-09-03 UTC assert_eq!(STAGING_REHEARSAL_VESTING_START_MS, 1_788_444_000_000); // 2026-09-03 14:00 UTC + assert_eq!(STAGING_PLACEHOLDER_VESTING_START_MS, 1_789_603_200_000); // 2026-09-17 UTC } #[test] fn genesis_vesting_times_are_sane() { // TGE-style epochs are midnight UTC. The rehearsal clock is a wall-clock // offset from launch day and is not required to land on midnight. - for start in [GENESIS_VESTING_START_MS, STAGING_MAINNET_START] { + for start in + [GENESIS_VESTING_START_MS, STAGING_MAINNET_START, STAGING_PLACEHOLDER_VESTING_START_MS] + { assert_eq!(start % MILLIS_PER_DAY, 0, "start must be midnight UTC"); assert!(start > YEAR_2020_MS); assert!(start < YEAR_2100_MS); @@ -1045,8 +1068,9 @@ mod tests { let (json, _) = prepare_genesis_build_input(raw).expect("well-formed"); let config: RuntimeGenesisConfig = serde_json::from_slice(&json).expect("deserializes"); assert!(config.vesting.schedules.iter().all(|(_, start, cliff, end, _)| { - *start >= STAGING_MAINNET_START && - *start < STAGING_MAINNET_START + days_ms(1) && + let launch_day = + *start >= STAGING_MAINNET_START && *start < STAGING_MAINNET_START + days_ms(1); + (launch_day || *start == STAGING_PLACEHOLDER_VESTING_START_MS) && *cliff >= *start && *cliff <= *start + days_ms(1) && *end > *start @@ -1083,6 +1107,40 @@ mod tests { assert_eq!(totals.iter().sum::(), 100_000 * UNIT); } + /// The three stand-in beneficiaries are replaced by a Root referendum after + /// launch, and `claim` is permissionless, so the earliest placeholder cliff must + /// sit beyond the fastest possible Root enactment measured from the chain's own + /// vesting epoch. Regression: the table first shipped with the placeholders + /// starting at launch and cliffing at most 24 hours later, roughly a day before + /// any retarget could have been enacted. + #[test] + fn staging_placeholder_cliffs_clear_the_root_governance_latency() { + assert!( + staging_mainnet_tech_collective_configured(), + "staging-mainnet preset is gated on real tech-collective members" + ); + let placeholders = &staging_mainnet_tech_collective_members()[..3]; + let raw = + get_preset(&PresetId::from(STAGING_MAINNET_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 cliffs: Vec = config + .vesting + .schedules + .iter() + .filter(|(who, _, _, _, _)| placeholders.contains(who)) + .map(|(_, _, cliff, _, _)| *cliff) + .collect(); + assert_eq!(cliffs.len(), 3, "every placeholder must hold exactly one schedule"); + let earliest = *cliffs.iter().min().expect("placeholder schedules exist"); + assert_eq!(earliest, STAGING_PLACEHOLDER_VESTING_START_MS); + assert!( + earliest - STAGING_MAINNET_START >= ROOT_TRACK_MIN_ENACTMENT_DELAY_MS, + "placeholder cliff at {earliest} is claimable before a Root retarget can be \ + enacted ({ROOT_TRACK_MIN_ENACTMENT_DELAY_MS} ms after the staging epoch)" + ); + } + #[test] fn staging_mainnet_leaves_treasury_unconfigured() { let raw = diff --git a/runtime/src/governance/definitions.rs b/runtime/src/governance/definitions.rs index 6d425757..ae8be5a5 100644 --- a/runtime/src/governance/definitions.rs +++ b/runtime/src/governance/definitions.rs @@ -1,6 +1,7 @@ use crate::{ governance::origins::Origin as CustomOrigin, scale_fee, AccountId, Balance, Balances, - BlockNumber, OriginCaller, Runtime, RuntimeOrigin, DAYS, HOURS, MINUTES, UNIT, + BlockNumber, OriginCaller, Runtime, RuntimeOrigin, DAYS, HOURS, MINUTES, TARGET_BLOCK_TIME_MS, + UNIT, }; use alloc::borrow::Cow; use codec::{Decode, Encode, MaxEncodedLen}; @@ -97,6 +98,26 @@ pub struct TechCollectiveTracksInfo; pub const FAST_UPGRADE_TRACK_ID: u16 = 1; pub const TECH_COLLECTIVE_DECISION_DEPOSIT: Balance = scale_fee(10 * UNIT); +/// Production windows of the Root track (track 0). `fast-governance` collapses the +/// live track to 2-block windows in test builds, so anything that has to reason +/// about real launch timing reads these constants instead of the track itself. +pub const ROOT_TRACK_PREPARE_PERIOD: BlockNumber = 2 * HOURS; +pub const ROOT_TRACK_DECISION_PERIOD: BlockNumber = DAYS; +pub const ROOT_TRACK_CONFIRM_PERIOD: BlockNumber = DAYS; +pub const ROOT_TRACK_MIN_ENACTMENT_PERIOD: BlockNumber = DAYS; + +/// Fastest a Root referendum can dispatch its call: the prepare window, then a +/// full confirmation window (confirmation runs inside the decision period), then +/// enactment. Nothing gated on Root governance can be assumed to happen sooner, +/// even with an immediate unanimous vote. +pub const ROOT_TRACK_MIN_ENACTMENT_DELAY: BlockNumber = + ROOT_TRACK_PREPARE_PERIOD + ROOT_TRACK_CONFIRM_PERIOD + ROOT_TRACK_MIN_ENACTMENT_PERIOD; + +/// [`ROOT_TRACK_MIN_ENACTMENT_DELAY`] in milliseconds, for comparing against +/// wall-clock deadlines such as vesting cliffs. +pub const ROOT_TRACK_MIN_ENACTMENT_DELAY_MS: u64 = + ROOT_TRACK_MIN_ENACTMENT_DELAY as u64 * TARGET_BLOCK_TIME_MS; + impl TechCollectiveTracksInfo { fn create_tech_collective_tracks() -> [pallet_referenda::Track; 2] { // With 5 members: >=3 ayes required (support 60%), and 2 nays always block @@ -116,10 +137,10 @@ impl TechCollectiveTracksInfo { // Advance-notice window before deciding starts. Raised from 4 min to give the // collective (and observers) visibility of a pending Root proposal before voting can // conclude. - prepare_period: 2 * HOURS, - decision_period: DAYS, - confirm_period: DAYS, - min_enactment_period: DAYS, + prepare_period: ROOT_TRACK_PREPARE_PERIOD, + decision_period: ROOT_TRACK_DECISION_PERIOD, + confirm_period: ROOT_TRACK_CONFIRM_PERIOD, + min_enactment_period: ROOT_TRACK_MIN_ENACTMENT_PERIOD, min_approval: pallet_referenda::Curve::LinearDecreasing { length: Perbill::from_percent(100), floor: Perbill::from_percent(61),