From 3bc74fdeca4584f0bfc2c819a1e9a23f84ff23b6 Mon Sep 17 00:00:00 2001 From: Loris Moulin Date: Wed, 3 Jun 2026 16:46:09 -0300 Subject: [PATCH] Extract runtime wiring from governance umbrella PR --- .github/workflows/typescript-e2e.yml | 19 + Cargo.lock | 3 + Cargo.toml | 2 + chain-extensions/src/mock.rs | 3 + docs/governance/README.md | 203 +++++ eco-tests/src/mock.rs | 3 + pallets/admin-utils/src/tests/mock.rs | 4 +- pallets/subtensor/src/coinbase/root.rs | 15 +- pallets/subtensor/src/lib.rs | 32 + pallets/subtensor/src/macros/config.rs | 11 +- pallets/subtensor/src/macros/dispatches.rs | 20 +- pallets/subtensor/src/macros/hooks.rs | 39 +- ...grate_init_root_registered_hotkey_count.rs | 42 ++ pallets/subtensor/src/migrations/mod.rs | 1 + pallets/subtensor/src/root_registered/ema.rs | 135 ++++ pallets/subtensor/src/root_registered/mod.rs | 121 +++ .../src/root_registered/ref_count.rs | 31 + .../src/root_registered/try_state.rs | 66 ++ pallets/subtensor/src/subnets/uids.rs | 4 + pallets/subtensor/src/swap/swap_coldkey.rs | 4 + pallets/subtensor/src/tests/coinbase.rs | 300 ++++++++ pallets/subtensor/src/tests/migration.rs | 48 ++ pallets/subtensor/src/tests/mock.rs | 184 ++++- pallets/subtensor/src/tests/mock_high_ed.rs | 3 + pallets/subtensor/src/tests/mod.rs | 1 + .../subtensor/src/tests/root_registered.rs | 708 ++++++++++++++++++ pallets/subtensor/src/tests/swap_coldkey.rs | 78 ++ pallets/subtensor/src/tests/swap_hotkey.rs | 41 + pallets/subtensor/src/weights.rs | 24 +- pallets/transaction-fee/src/tests/mock.rs | 4 +- precompiles/src/mock.rs | 3 + runtime/Cargo.toml | 18 +- runtime/src/governance/README.md | 158 ++++ runtime/src/governance/benchmarking.rs | 207 +++++ runtime/src/governance/collectives.rs | 194 +++++ runtime/src/governance/ema_provider.rs | 415 ++++++++++ runtime/src/governance/member_set.rs | 147 ++++ runtime/src/governance/mod.rs | 301 ++++++++ runtime/src/governance/term_management.rs | 430 +++++++++++ runtime/src/governance/tracks.rs | 179 +++++ runtime/src/governance/weights.rs | 147 ++++ runtime/src/lib.rs | 17 +- scripts/benchmark_action.sh | 4 +- scripts/benchmark_all.sh | 20 +- scripts/discover_pallets.sh | 12 +- support/procedural-fork/Cargo.toml | 2 +- ts-tests/moonwall.config.json | 39 +- ts-tests/scripts/build-fast-runtime.sh | 52 ++ ts-tests/scripts/build-upgrade-runtime.sh | 60 ++ .../dev/subtensor/governance/test-capacity.ts | 143 ++++ .../subtensor/governance/test-full-flow.ts | 124 +++ .../governance/test-origin-guards.ts | 184 +++++ .../governance/test-runtime-config.ts | 217 ++++++ .../governance/test-runtime-upgrade.ts | 126 ++++ .../governance/test-track0-lifecycle.ts | 105 +++ .../governance/test-track1-lifecycle.ts | 211 ++++++ .../subtensor/governance/test-voter-sets.ts | 142 ++++ .../governance/test-track0-expired.ts | 108 +++ .../governance/test-track1-delay-curve.ts | 157 ++++ .../test-track1-natural-enactment.ts | 108 +++ ts-tests/utils/governance.ts | 305 ++++++++ 61 files changed, 6431 insertions(+), 53 deletions(-) create mode 100644 docs/governance/README.md create mode 100644 pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs create mode 100644 pallets/subtensor/src/root_registered/ema.rs create mode 100644 pallets/subtensor/src/root_registered/mod.rs create mode 100644 pallets/subtensor/src/root_registered/ref_count.rs create mode 100644 pallets/subtensor/src/root_registered/try_state.rs create mode 100644 pallets/subtensor/src/tests/root_registered.rs create mode 100644 runtime/src/governance/README.md create mode 100644 runtime/src/governance/benchmarking.rs create mode 100644 runtime/src/governance/collectives.rs create mode 100644 runtime/src/governance/ema_provider.rs create mode 100644 runtime/src/governance/member_set.rs create mode 100644 runtime/src/governance/mod.rs create mode 100644 runtime/src/governance/term_management.rs create mode 100644 runtime/src/governance/tracks.rs create mode 100644 runtime/src/governance/weights.rs create mode 100755 ts-tests/scripts/build-fast-runtime.sh create mode 100755 ts-tests/scripts/build-upgrade-runtime.sh create mode 100644 ts-tests/suites/dev/subtensor/governance/test-capacity.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-full-flow.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts create mode 100644 ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track0-expired.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts create mode 100644 ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts create mode 100644 ts-tests/utils/governance.ts diff --git a/.github/workflows/typescript-e2e.yml b/.github/workflows/typescript-e2e.yml index 82c63e1356..e0423c0a5a 100644 --- a/.github/workflows/typescript-e2e.yml +++ b/.github/workflows/typescript-e2e.yml @@ -137,6 +137,25 @@ jobs: working-directory: ts-tests run: pnpm install --frozen-lockfile + - name: Install system dependencies + run: | + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update + sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + build-essential clang curl git make libssl-dev llvm libudev-dev protobuf-compiler pkg-config + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + + - name: Utilize Shared Rust Cache + uses: Swatinem/rust-cache@v2 + with: + key: e2e-runtime-upgrade + cache-on-failure: true + workspaces: ". -> ts-tests/tmp/cargo-target" + - name: Run tests run: | cd ts-tests diff --git a/Cargo.lock b/Cargo.lock index 3f00ac1cb5..2b6bad0fb8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8416,16 +8416,19 @@ dependencies = [ "pallet-grandpa", "pallet-hotfix-sufficients", "pallet-insecure-randomness-collective-flip", + "pallet-multi-collective", "pallet-multisig", "pallet-nomination-pools", "pallet-nomination-pools-runtime-api", "pallet-offences", "pallet-preimage", + "pallet-referenda 1.0.0", "pallet-registry", "pallet-safe-mode", "pallet-scheduler", "pallet-session", "pallet-shield", + "pallet-signed-voting", "pallet-staking", "pallet-staking-reward-curve", "pallet-staking-reward-fn", diff --git a/Cargo.toml b/Cargo.toml index dba2dd32e0..b47bd1a6d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,8 @@ pallet-subtensor-swap = { path = "pallets/swap", default-features = false } pallet-subtensor-swap-runtime-api = { path = "pallets/swap/runtime-api", default-features = false } pallet-subtensor-swap-rpc = { path = "pallets/swap/rpc", default-features = false } pallet-multi-collective = { path = "pallets/multi-collective", default-features = false } +pallet-signed-voting = { path = "pallets/signed-voting", default-features = false } +pallet-referenda = { path = "pallets/referenda", default-features = false } procedural-fork = { path = "support/procedural-fork", default-features = false } safe-math = { path = "primitives/safe-math", default-features = false } share-pool = { path = "primitives/share-pool", default-features = false } diff --git a/chain-extensions/src/mock.rs b/chain-extensions/src/mock.rs index 37c6d4fb47..af8a585a58 100644 --- a/chain-extensions/src/mock.rs +++ b/chain-extensions/src/mock.rs @@ -431,6 +431,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); diff --git a/docs/governance/README.md b/docs/governance/README.md new file mode 100644 index 0000000000..8b4357ff30 --- /dev/null +++ b/docs/governance/README.md @@ -0,0 +1,203 @@ +# On-Chain Governance + +Subtensor governance is implemented as track-based referenda backed by +signed collective voting. The live runtime wiring is in +`runtime/src/governance`; the generic building blocks are +`pallets/referenda`, `pallets/signed-voting`, and +`pallets/multi-collective`. + +Governance has two stages: + +1. A proposal is submitted by an authorized proposer and decided by the + three-member Triumvirate. +2. If the Triumvirate approves it, the call is handed to a separate + collective review track where the Economic and Building collectives can + accelerate, delay, or cancel enactment. + +The governed call is dispatched as root only if it survives this flow. + +## Runtime Tracks + +| Track | Name | Submitters | Voters | Decision | +| ---- | ---- | ---- | ---- | ---- | +| `0` | `triumvirate` | `Proposers` collective | `Triumvirate` collective | `PassOrFail`: 7 day decision period, 2/3 approve, 2/3 reject. Approval delegates to track `1`. | +| `1` | `review` | None | Union of `Economic` and `Building` | `Adjustable`: scheduled at 24 hours by default, adjustable up to 2 days, 75% approval fast-tracks, 51% rejection cancels. | + +Track `1` is intentionally not directly submittable. Its only entry point +is the `ApprovalAction::Review` handoff from track `0`, so a proposer +cannot bypass Triumvirate approval and place a root call directly into the +review delay. + +Both tracks use `pallet-signed-voting`. When a referendum opens, the voting +backend snapshots the eligible voter set and uses that snapshot for the +entire poll. Members rotated out after the poll opens keep their vote on +that poll; members rotated in later cannot vote on old polls. For the review +track, the Economic and Building member lists are unioned and deduplicated, +so an account present in both collectives counts once. + +## Collectives + +| Collective | Size | Rotation | Purpose | +| ---- | ---- | ---- | ---- | +| `Proposers` | min `1`, max `20` | Manual | Accounts allowed to submit on the Triumvirate track. | +| `Triumvirate` | exactly `3` | Manual | Approval body for submitted proposals. | +| `Economic` | exactly `16` | Every 60 days | Top root-registered validator coldkeys by smoothed stake value. | +| `Building` | exactly `16` | Every 60 days | Top subnet-owner coldkeys by their best mature subnet price. | +| `EconomicEligible` | max `64` | Automatic sync, no voting role | Candidate pool for `Economic`; mirrors coldkeys with at least one root-registered hotkey. | + +Membership is stored by `pallet-multi-collective`. In the runtime all +membership mutation origins are root-gated, so changes to curated +collectives are expected to go through governance once sudo/root authority +is replaced by the governance flow. The rotating collectives can also be +force-rotated by root. + +The rotating collectives have `min_members == max_members == 16`. If a +rotation computes fewer than 16 eligible accounts, `set_members` fails the +minimum-member check and the previous membership remains in storage. The +failure is logged instead of partially rotating the set. + +## Economic Selection + +The Economic collective is selected from `EconomicEligible`, not directly +from every account on chain. + +`EconomicEligible` is synchronized from root registration: + +- When a coldkey's root-registered hotkey count moves from `0` to `1`, the + coldkey is added to `EconomicEligible` and its root-registered EMA is + initialized at zero. +- When the count moves from `1` to `0`, the coldkey is removed and its EMA + state is cleared. +- The cap is `64`, matching the root subnet UID limit. + +Each block, `pallet-subtensor` advances the root-registered EMA sampler. +The governance runtime provides the sample value through +`StakeValueProvider`: liquid TAO balance plus the TAO value of alpha held +by the coldkey's owned hotkeys across all subnets. The provider works in +chunks of 8 subnets and values at most 256 owned hotkeys per sample. + +The EMA uses alpha `0.02`. A coldkey must have at least `210` completed +samples before it can be selected for `Economic` membership. With the +current sampler cadence this is roughly a 30 day warmup. At rotation time, +the runtime ranks eligible coldkeys by descending EMA value and takes the +top 16. + +## Building Selection + +The Building collective represents subnet owners. + +At rotation time, the runtime iterates all subnets and ignores any subnet +younger than `MIN_SUBNET_AGE`, which is 180 days in production. For each +remaining subnet it reads: + +- `NetworkRegisteredAt` +- `SubnetMovingPrice` +- `SubnetOwner` + +An owner may control more than one mature subnet. The runtime keeps only +that owner's highest observed `SubnetMovingPrice`, then ranks owners by +that best price and takes the top 16. This means one coldkey can receive at +most one Building seat, even if it owns multiple high-priced subnets. + +## Referendum Lifecycle + +1. A member of `Proposers` calls `referenda.submit(0, call)`. +2. `pallet-referenda` checks the proposer set, global queue limit + (`MaxQueued = 20`), and per-proposer limit (`MaxActivePerProposer = 5`). +3. Triumvirate voters use `signed_voting.vote(index, approve)` or + `signed_voting.remove_vote(index)`. +4. If 2/3 of the Triumvirate snapshot votes approve before 7 days elapse, + the parent referendum becomes `Delegated` and a child review referendum + is created on track `1`. +5. If 2/3 reject, the referendum becomes `Rejected`. If neither threshold + is reached before the deadline, it becomes `Expired`. +6. The review child schedules the root call at `submitted + 24 hours`. + Economic and Building voters can approve, reject, change their vote, or + remove their vote while the review is ongoing. +7. If review approval reaches 75% of the snapshot, the call is rescheduled + for the next block and the referendum becomes `FastTracked`. +8. If review rejection reaches 51%, the scheduled call is cancelled and the + referendum becomes `Cancelled`. +9. Otherwise, net approval moves the scheduled block earlier and net + rejection moves it later, up to the 2 day maximum delay. +10. When the scheduler invokes `referenda.enact`, the inner call is + dispatched with root origin and the referendum becomes `Enacted`. The + event records whether the inner dispatch returned an error. + +There is no proposer-only withdraw or cancel extrinsic in the current +implementation. Privileged termination is `referenda.kill`, gated by root, +and can kill an ongoing, approved, or fast-tracked referendum before +dispatch. + +## Review Delay Formula + +Review uses the runtime's `EaseOutAdjustmentCurve`, so net vote progress is +shaped as `1 - (1 - p)^3`. Early net collective signal has a visible effect +on the dispatch delay, then the curve tapers off as the vote approaches the +hard fast-track or cancel threshold. + +If approval is greater than or equal to rejection: + +```text +net = approval - rejection +progress = net / fast_track_threshold +curved = 1 - (1 - progress)^3 +delay = initial_delay * (1 - curved) +``` + +If rejection is greater than approval: + +```text +net = rejection - approval +progress = net / cancel_threshold +curved = 1 - (1 - progress)^3 +delay = initial_delay + curved * (max_delay - initial_delay) +``` + +With production constants, `initial_delay = 24 hours`, +`max_delay = 2 days`, `fast_track_threshold = 75%`, and +`cancel_threshold = 51%`. If a recomputed target is already in the past, +the referendum is fast-tracked. + +## Storage and Audit Trail + +Referendum statuses remain queryable after conclusion. Votes are stored by +`pallet-signed-voting` while a poll is active, then cleaned lazily after the +poll completes. Per-voter records are no longer read after the tally is +removed, so lazy cleanup affects storage hygiene rather than governance +correctness. + +Relevant events: + +- `referenda.Submitted` +- `referenda.Delegated` +- `referenda.Rejected` +- `referenda.Expired` +- `referenda.FastTracked` +- `referenda.Cancelled` +- `referenda.Killed` +- `referenda.Enacted` +- `signed_voting.Voted` +- `signed_voting.VoteRemoved` +- `multi_collective.MemberAdded` +- `multi_collective.MemberRemoved` +- `multi_collective.MemberSwapped` +- `multi_collective.MembersSet` + +## Implementation Map + +- `runtime/src/governance/collectives.rs`: collective ids, sizes, term + duration, and root-registration sync for `EconomicEligible`. +- `runtime/src/governance/tracks.rs`: track ids, thresholds, delays, and + decision strategies. +- `runtime/src/governance/member_set.rs`: single and union collective voter + sets with deduplication. +- `runtime/src/governance/term_management.rs`: Economic and Building + rotation selection. +- `runtime/src/governance/ema_provider.rs`: Economic stake-value sample + provider. +- `pallets/referenda`: generic track state machine and scheduler wrapping. +- `pallets/signed-voting`: per-account aye/nay voting with frozen voter-set + snapshots. +- `pallets/multi-collective`: named collective membership and term + rotation hooks. diff --git a/eco-tests/src/mock.rs b/eco-tests/src/mock.rs index aba98da9b5..33497d0a7c 100644 --- a/eco-tests/src/mock.rs +++ b/eco-tests/src/mock.rs @@ -313,6 +313,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); diff --git a/pallets/admin-utils/src/tests/mock.rs b/pallets/admin-utils/src/tests/mock.rs index 37b4e06aa5..a70510f878 100644 --- a/pallets/admin-utils/src/tests/mock.rs +++ b/pallets/admin-utils/src/tests/mock.rs @@ -238,6 +238,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); @@ -400,7 +403,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index b64043a4f5..fca041ee62 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -121,14 +121,15 @@ impl Pallet { // --- 8. Check if the root net is below its allowed size. // max allowed is senate size. if current_num_root_validators < Self::get_max_root_validators() { - // --- 12.1.1 We can append to the subnetwork as it's not full. + // We can append to the subnetwork as it's not full. subnetwork_uid = current_num_root_validators; - // --- 12.1.2 Add the new account and make them a member of the Senate. + // Add the new account and make them a member of the Senate. Self::append_neuron(NetUid::ROOT, &hotkey, current_block_number); log::debug!("add new neuron: {hotkey:?} on uid {subnetwork_uid:?}"); + Self::increment_root_registered_hotkey_count(&coldkey); } else { - // --- 13.1.1 The network is full. Perform replacement. + // The network is full. Perform replacement. // Find the neuron with the lowest stake value to replace. let mut lowest_stake = AlphaBalance::MAX; let mut lowest_uid: u16 = 0; @@ -145,19 +146,23 @@ impl Pallet { let replaced_hotkey: T::AccountId = Self::get_hotkey_for_net_and_uid(NetUid::ROOT, subnetwork_uid)?; - // --- 13.1.2 The new account has a higher stake than the one being replaced. + // The new account has a higher stake than the one being replaced. ensure!( lowest_stake < Self::get_stake_for_hotkey_on_subnet(&hotkey, NetUid::ROOT), Error::::StakeTooLowForRoot ); - // --- 13.1.3 The new account has a higher stake than the one being replaced. + // The new account has a higher stake than the one being replaced. // Replace the neuron account with new information. Self::replace_neuron(NetUid::ROOT, lowest_uid, &hotkey, current_block_number); log::debug!( "replace neuron: {replaced_hotkey:?} with {hotkey:?} on uid {subnetwork_uid:?}" ); + + let replaced_owner = Owner::::get(&replaced_hotkey); + Self::decrement_root_registered_hotkey_count(&replaced_owner); + Self::increment_root_registered_hotkey_count(&coldkey); } // --- 13. Force all members on root to become a delegate. diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 97ba77a92a..80c8e34abb 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -39,6 +39,7 @@ pub mod extensions; pub mod guards; pub mod macros; pub mod migrations; +pub mod root_registered; pub mod rpc_info; pub mod staking; pub mod subnets; @@ -82,6 +83,7 @@ pub const MAX_ROOT_CLAIM_THRESHOLD: u64 = 10_000_000; pub mod pallet { use crate::RateLimitKey; use crate::migrations; + use crate::root_registered::{EmaState, EmaValueProvider, InFlightEmaSample}; use crate::staking::lock::LockState; use crate::subnets::leasing::{LeaseId, SubnetLeaseOf}; use frame_support::Twox64Concat; @@ -1406,6 +1408,36 @@ pub mod pallet { pub type OwnedHotkeys = StorageMap<_, Blake2_128Concat, T::AccountId, Vec, ValueQuery>; + /// Number of hotkeys controlled by this coldkey that are currently registered on the root subnet. + #[pallet::storage] + pub type RootRegisteredHotkeyCount = + StorageMap<_, Blake2_128Concat, T::AccountId, u32, ValueQuery>; + + /// EMA state for each root-registered coldkey. + #[pallet::storage] + pub type RootRegisteredEma = + StorageMap<_, Blake2_128Concat, T::AccountId, EmaState, ValueQuery>; + + /// Fixed coldkey snapshot used by the current EMA sampling cycle. + #[pallet::storage] + pub type CurrentCycleMembers = + StorageValue<_, BoundedVec>, ValueQuery>; + + /// Internal: the EMA value provider for the runtime. + pub type EmaProviderOf = ::EmaValueProvider; + + /// Internal: provider-owned progress for the coldkey currently being sampled. + pub type EmaProgressOf = as EmaValueProvider>>::Progress; + + /// Internal: in-flight sample for the current coldkey. Present only + /// while `T::EmaValueProvider` has returned `SampleStep::Continue`. + pub type InFlightEmaSampleOf = InFlightEmaSample, EmaProgressOf>; + + /// Cursor and in-flight provider progress for the EMA sampling cycle. + #[pallet::storage] + pub type EmaSamplerState = + StorageValue<_, (u32, Option>), ValueQuery>; + /// --- DMAP ( cold, netuid )--> hot | Returns the hotkey a coldkey will autostake to with mining rewards. #[pallet::storage] pub type AutoStakeDestination = StorageDoubleMap< diff --git a/pallets/subtensor/src/macros/config.rs b/pallets/subtensor/src/macros/config.rs index 7b94866b52..e83838095f 100644 --- a/pallets/subtensor/src/macros/config.rs +++ b/pallets/subtensor/src/macros/config.rs @@ -6,7 +6,7 @@ use frame_support::pallet_macros::pallet_section; #[pallet_section] mod config { - use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha}; + use crate::{CommitmentsInterface, GetAlphaForTao, GetTaoForAlpha, root_registered::*}; use frame_support::PalletId; use pallet_alpha_assets::AlphaAssetsInterface; use pallet_commitments::GetCommitments; @@ -71,6 +71,15 @@ mod config { /// Provider of current block author type AuthorshipProvider: AuthorshipInfo; + /// Handler for root-registration transitions. + type OnRootRegistrationChange: OnRootRegistrationChange; + + /// External snapshot of the root-registered coldkey set. + type RootRegisteredInspector: RootRegisteredInspector; + + /// Provider for the value sampled by root-registered EMAs. + type EmaValueProvider: EmaValueProvider; + /// Weight information for extrinsics in this pallet. type WeightInfo: crate::weights::WeightInfo; diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index b471328aec..7e112728e9 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -5,6 +5,7 @@ use frame_support::pallet_macros::pallet_section; /// This can later be imported into the pallet using [`import_section`]. #[pallet_section] mod dispatches { + use crate::root_registered::OnRootRegistrationChange; use crate::weights::WeightInfo; use frame_support::traits::schedule::v3::Anon as ScheduleAnon; use frame_system::pallet_prelude::BlockNumberFor; @@ -1003,7 +1004,12 @@ mod dispatches { /// Register the hotkey to root network #[pallet::call_index(62)] - #[pallet::weight(::WeightInfo::root_register())] + #[pallet::weight( + ::WeightInfo::root_register() + // Worst case: we kick someone off and we take their place. + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn root_register(origin: OriginFor, hotkey: T::AccountId) -> DispatchResult { Self::do_root_register(origin, hotkey) } @@ -1070,7 +1076,11 @@ mod dispatches { /// /// Only callable by root as it doesn't require an announcement and can be used to swap any coldkey. #[pallet::call_index(71)] - #[pallet::weight(::WeightInfo::swap_coldkey())] + #[pallet::weight( + ::WeightInfo::swap_coldkey() + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn swap_coldkey( origin: OriginFor, old_coldkey: T::AccountId, @@ -2297,7 +2307,11 @@ mod dispatches { /// /// The `ColdkeySwapped` event is emitted on successful swap. #[pallet::call_index(126)] - #[pallet::weight(::WeightInfo::swap_coldkey_announced())] + #[pallet::weight( + ::WeightInfo::swap_coldkey_announced() + .saturating_add(::OnRootRegistrationChange::on_added_weight()) + .saturating_add(::OnRootRegistrationChange::on_removed_weight()) + )] pub fn swap_coldkey_announced( origin: OriginFor, new_coldkey: T::AccountId, diff --git a/pallets/subtensor/src/macros/hooks.rs b/pallets/subtensor/src/macros/hooks.rs index 203a2d2828..ca39888163 100644 --- a/pallets/subtensor/src/macros/hooks.rs +++ b/pallets/subtensor/src/macros/hooks.rs @@ -15,27 +15,27 @@ mod hooks { // * 'n': (BlockNumberFor): // - The number of the block we are initializing. fn on_initialize(block_number: BlockNumberFor) -> Weight { - let hotkey_swap_clean_up_weight = Self::clean_up_hotkey_swap_records(block_number); + let mut weight = Weight::zero(); - let block_step_result = Self::block_step(); - match block_step_result { + weight.saturating_accrue(Self::clean_up_hotkey_swap_records(block_number)); + weight.saturating_accrue(Self::tick_root_registered_ema()); + + match Self::block_step() { Ok(_) => { - // --- If the block step was successful, return the weight. - log::debug!("Successfully ran block step."); - Weight::from_parts(110_634_229_000_u64, 0) - .saturating_add(T::DbWeight::get().reads(8304_u64)) - .saturating_add(T::DbWeight::get().writes(110_u64)) - .saturating_add(hotkey_swap_clean_up_weight) + log::debug!("Successfully ran block step.") } Err(e) => { - // --- If the block step was unsuccessful, return the weight anyway. - log::error!("Error while stepping block: {:?}", e); - Weight::from_parts(110_634_229_000_u64, 0) - .saturating_add(T::DbWeight::get().reads(8304_u64)) - .saturating_add(T::DbWeight::get().writes(110_u64)) - .saturating_add(hotkey_swap_clean_up_weight) + log::error!("Error while stepping block: {:?}", e) } - } + }; + // TODO: benchmark properly + weight.saturating_accrue( + Weight::from_parts(110_634_229_000_u64, 0) + .saturating_add(T::DbWeight::get().reads(8304_u64)) + .saturating_add(T::DbWeight::get().writes(110_u64)), + ); + + weight } // ---- Called on the finalization of this pallet. The code weight must be taken into account prior to the execution of this macro. @@ -182,7 +182,9 @@ mod hooks { // Reset testnet conviction lock storage before deploying the current design. .saturating_add(migrations::migrate_reset_tnet_conviction_locks::migrate_reset_tnet_conviction_locks::()) // Capture the runtime-upgrade block for TAO-in refund cutover. - .saturating_add(migrations::migrate_tao_in_refund_deployment_block::migrate_tao_in_refund_deployment_block::()); + .saturating_add(migrations::migrate_tao_in_refund_deployment_block::migrate_tao_in_refund_deployment_block::()) + // Backfill `RootRegisteredHotkeyCount` from the root-subnet `Keys` map + .saturating_add(migrations::migrate_init_root_registered_hotkey_count::migrate_init_root_registered_hotkey_count::()); weight } @@ -190,6 +192,9 @@ mod hooks { fn try_state(_n: BlockNumberFor) -> Result<(), sp_runtime::TryRuntimeError> { // Disabled: https://github.com/opentensor/subtensor/pull/1166 // Self::check_total_stake()?; + Self::check_root_registered_hotkey_count()?; + Self::check_root_registered_matches_inspector()?; + Self::check_root_registered_ema_matches_count()?; Ok(()) } } diff --git a/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs b/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs new file mode 100644 index 0000000000..1463e9cf70 --- /dev/null +++ b/pallets/subtensor/src/migrations/migrate_init_root_registered_hotkey_count.rs @@ -0,0 +1,42 @@ +use alloc::string::String; + +use frame_support::{traits::Get, weights::Weight}; +use subtensor_runtime_common::NetUid; + +use super::*; + +pub fn migrate_init_root_registered_hotkey_count() -> Weight { + let migration_name = b"migrate_init_root_registered_hotkey_count".to_vec(); + + let mut weight = T::DbWeight::get().reads(1); + + if HasMigrationRun::::get(&migration_name) { + log::info!( + "Migration '{:?}' has already run. Skipping.", + String::from_utf8_lossy(&migration_name) + ); + return weight; + } + log::info!( + "Running migration '{}'", + String::from_utf8_lossy(&migration_name) + ); + + let mut entries: u64 = 0; + for (_uid, hotkey) in Keys::::iter_prefix(NetUid::ROOT) { + let coldkey = Owner::::get(&hotkey); + Pallet::::increment_root_registered_hotkey_count(&coldkey); + weight.saturating_accrue(T::DbWeight::get().reads_writes(5, 2)); + entries = entries.saturating_add(1); + } + + HasMigrationRun::::insert(&migration_name, true); + weight = weight.saturating_add(T::DbWeight::get().writes(1)); + + log::info!( + "Migration '{:?}' completed. {entries} root hotkeys indexed.", + String::from_utf8_lossy(&migration_name) + ); + + weight +} diff --git a/pallets/subtensor/src/migrations/mod.rs b/pallets/subtensor/src/migrations/mod.rs index da3dcbb831..6805c864a7 100644 --- a/pallets/subtensor/src/migrations/mod.rs +++ b/pallets/subtensor/src/migrations/mod.rs @@ -24,6 +24,7 @@ pub mod migrate_fix_root_subnet_tao; pub mod migrate_fix_root_tao_and_alpha_in; pub mod migrate_fix_staking_hot_keys; pub mod migrate_fix_total_issuance_evm_fees; +pub mod migrate_init_root_registered_hotkey_count; pub mod migrate_init_tao_flow; pub mod migrate_init_total_issuance; pub mod migrate_kappa_map_to_default; diff --git a/pallets/subtensor/src/root_registered/ema.rs b/pallets/subtensor/src/root_registered/ema.rs new file mode 100644 index 0000000000..8342cfb4a4 --- /dev/null +++ b/pallets/subtensor/src/root_registered/ema.rs @@ -0,0 +1,135 @@ +use alloc::vec::Vec; +use frame_support::weights::Weight; +use substrate_fixed::types::U64F64; + +use super::*; +use crate::root_registered::{EmaState, EmaValueProvider, InFlightEmaSample, SampleStep}; + +/// EMA mixing constant numerator (alpha = 2/100 = 0.02). +const EMA_ALPHA_NUM: u64 = 2; +const EMA_ALPHA_DEN: u64 = 100; + +impl Pallet { + /// Advances the root-registered EMA sampler by one provider step. + pub fn tick_root_registered_ema() -> Weight { + let (sample, mut weight) = Self::load_current_sample(); + let Some((cursor, coldkey, in_flight)) = sample else { + return weight; + }; + + let has_ema = RootRegisteredEma::::contains_key(&coldkey); + weight.saturating_accrue(T::DbWeight::get().reads(1)); + + if !has_ema { + return weight.saturating_add(Self::skip_missing_sample(cursor)); + } + + let progress = Self::resume_progress(&coldkey, in_flight); + + let (step, step_weight) = T::EmaValueProvider::step(&coldkey, progress); + weight.saturating_accrue(step_weight); + + weight.saturating_add(match step { + SampleStep::Continue { progress } => Self::store_progress(cursor, coldkey, progress), + SampleStep::Complete { sample } => Self::complete_sample(cursor, coldkey, sample), + }) + } + + fn load_current_sample() -> ( + Option<(u32, T::AccountId, Option>)>, + Weight, + ) { + let db = T::DbWeight::get(); + let (mut cursor, mut in_flight) = EmaSamplerState::::get(); + let mut members = CurrentCycleMembers::::get(); + let mut weight = db.reads(2); + + // Cursor wrap starts a new fixed snapshot. Keeping the snapshot + // stable avoids mid-cycle joins reshuffling the round-robin order. + if (cursor as usize) >= members.len() { + let collected: Vec = + RootRegisteredEma::::iter().map(|(k, _)| k).collect(); + weight.saturating_accrue(db.reads(collected.len() as u64)); + + members = BoundedVec::try_from(collected).unwrap_or_default(); + cursor = 0; + in_flight = None; + + CurrentCycleMembers::::put(&members); + EmaSamplerState::::put((cursor, None::>)); + weight.saturating_accrue(db.writes(2)); + } + + let sample = members + .get(cursor as usize) + .map(|coldkey| (cursor, coldkey.clone(), in_flight)); + (sample, weight) + } + + fn resume_progress( + coldkey: &T::AccountId, + in_flight: Option>, + ) -> >::Progress { + // Progress is only reusable for the exact coldkey at the current + // cursor. Otherwise start a fresh provider sample. + match in_flight { + Some(p) if &p.coldkey == coldkey => p.progress, + _ => >::Progress::default(), + } + } + + fn skip_missing_sample(cursor: u32) -> Weight { + // A coldkey can disappear from storage while it is still present + // in the fixed cycle snapshot. Skip it and let the next cycle + // rebuild without it. + EmaSamplerState::::put((cursor.saturating_add(1), None::>)); + T::DbWeight::get().writes(1) + } + + fn store_progress( + cursor: u32, + coldkey: T::AccountId, + progress: >::Progress, + ) -> Weight { + EmaSamplerState::::put((cursor, Some(InFlightEmaSample { coldkey, progress }))); + T::DbWeight::get().writes(1) + } + + fn complete_sample(cursor: u32, coldkey: T::AccountId, sample: U64F64) -> Weight { + RootRegisteredEma::::mutate(&coldkey, |state| { + *state = EmaState { + ema: blend(sample, *state), + samples: state.samples.saturating_add(1), + }; + }); + EmaSamplerState::::put((cursor.saturating_add(1), None::>)); + T::DbWeight::get().reads_writes(1, 2) + } + + /// Seeds a fresh EMA slot at zero. The zero value enforces a + /// warmup window before the EMA carries meaningful weight. + pub(crate) fn init_root_registered_ema(coldkey: &T::AccountId) { + RootRegisteredEma::::insert(coldkey, EmaState::default()); + } + + pub(crate) fn clear_root_registered_ema(coldkey: &T::AccountId) { + RootRegisteredEma::::remove(coldkey); + EmaSamplerState::::mutate(|(_, progress)| { + if progress + .as_ref() + .is_some_and(|in_flight| &in_flight.coldkey == coldkey) + { + *progress = None; + } + }); + } +} + +fn blend(sample: U64F64, previous: EmaState) -> U64F64 { + let alpha = U64F64::saturating_from_num(EMA_ALPHA_NUM) + .saturating_div(U64F64::saturating_from_num(EMA_ALPHA_DEN)); + let one_minus_alpha = U64F64::saturating_from_num(1).saturating_sub(alpha); + alpha + .saturating_mul(sample) + .saturating_add(one_minus_alpha.saturating_mul(previous.ema)) +} diff --git a/pallets/subtensor/src/root_registered/mod.rs b/pallets/subtensor/src/root_registered/mod.rs new file mode 100644 index 0000000000..7a488d91dc --- /dev/null +++ b/pallets/subtensor/src/root_registered/mod.rs @@ -0,0 +1,121 @@ +use super::*; +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{pallet_prelude::Parameter, weights::Weight}; +use scale_info::TypeInfo; +use substrate_fixed::types::U64F64; + +pub mod ema; +pub mod ref_count; +#[cfg(any(feature = "try-runtime", test))] +pub mod try_state; + +/// Per-coldkey EMA state. +#[freeze_struct("f4bb10f7c2fb2cc1")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct EmaState { + /// Current EMA value. + pub ema: U64F64, + /// Samples folded in so far. + pub samples: u32, +} + +/// In-flight EMA sample for the coldkey at the current cursor. +/// The provider owns the inner progress shape; the root-registered EMA +/// engine only ties it to the coldkey being sampled. +#[freeze_struct("f9307bf115ed1bae")] +#[derive( + Clone, PartialEq, Eq, Debug, Encode, Decode, DecodeWithMemTracking, MaxEncodedLen, TypeInfo, +)] +pub struct InFlightEmaSample { + /// Coldkey whose sample is in progress. Used to discard stale + /// progress if the cursor moves or the account leaves mid-sample. + pub coldkey: AccountId, + /// Provider-owned progress for the current sample. + pub progress: Progress, +} + +/// Result of one provider sampling step. +pub enum SampleStep { + /// More work remains for this coldkey; persist `progress` and resume + /// on a later tick. + Continue { progress: Progress }, + /// The current sample is complete and ready to be folded into the EMA. + Complete { sample: U64F64 }, +} + +/// Provides the raw sample value over which the root-registered EMA is +/// computed. The EMA engine owns blending and sample counters; providers +/// only own how to incrementally measure one current value. +pub trait EmaValueProvider { + /// Opaque in-flight progress for a single sample. + type Progress: Parameter + MaxEncodedLen + Default; + + /// Process one chunk of work for `coldkey`. + fn step(coldkey: &AccountId, progress: Self::Progress) -> (SampleStep, Weight); + + /// Worst-case weight of `step`. + fn step_weight() -> Weight; +} + +/// Zero-valued provider for runtimes / test mocks that do not compute EMAs. +impl EmaValueProvider for () { + type Progress = (); + + fn step(_: &AccountId, _: Self::Progress) -> (SampleStep, Weight) { + let sample = U64F64::saturating_from_num(0u64); + (SampleStep::Complete { sample }, Weight::zero()) + } + + fn step_weight() -> Weight { + Weight::zero() + } +} + +/// Hook for coldkey root-registration transitions. Callers accrue +/// `on_added_weight` / `on_removed_weight` when a 0↔1 transition is +/// possible. +pub trait OnRootRegistrationChange { + /// Called when `coldkey` enters the root-registered set. + fn on_added(coldkey: &AccountId); + /// Called when `coldkey` leaves the root-registered set. + fn on_removed(coldkey: &AccountId); + /// Worst-case weight of `on_added`. + fn on_added_weight() -> Weight; + /// Worst-case weight of `on_removed`. + fn on_removed_weight() -> Weight; +} + +impl OnRootRegistrationChange for () { + fn on_added(_: &AccountId) {} + fn on_removed(_: &AccountId) {} + fn on_added_weight() -> Weight { + Weight::zero() + } + fn on_removed_weight() -> Weight { + Weight::zero() + } +} + +/// Snapshot of the root-registered coldkey set. +pub trait RootRegisteredInspector { + /// Returns the current snapshot, or `None` if unavailable. + fn members() -> Option>; +} + +impl RootRegisteredInspector for () { + fn members() -> Option> { + None + } +} diff --git a/pallets/subtensor/src/root_registered/ref_count.rs b/pallets/subtensor/src/root_registered/ref_count.rs new file mode 100644 index 0000000000..c5b85e0f90 --- /dev/null +++ b/pallets/subtensor/src/root_registered/ref_count.rs @@ -0,0 +1,31 @@ +use super::*; +use crate::root_registered::OnRootRegistrationChange; + +impl Pallet { + pub fn coldkey_has_root_hotkey(coldkey: &T::AccountId) -> bool { + RootRegisteredHotkeyCount::::get(coldkey) > 0 + } + + pub fn increment_root_registered_hotkey_count(coldkey: &T::AccountId) { + let was_zero = RootRegisteredHotkeyCount::::get(coldkey) == 0; + RootRegisteredHotkeyCount::::mutate(coldkey, |c| *c = c.saturating_add(1)); + if was_zero { + Self::init_root_registered_ema(coldkey); + T::OnRootRegistrationChange::on_added(coldkey); + } + } + + pub fn decrement_root_registered_hotkey_count(coldkey: &T::AccountId) { + let mut became_zero = false; + RootRegisteredHotkeyCount::::mutate_exists(coldkey, |c| { + let prev = c.unwrap_or(0); + let next = prev.saturating_sub(1); + became_zero = prev > 0 && next == 0; + *c = if next == 0 { None } else { Some(next) }; + }); + if became_zero { + Self::clear_root_registered_ema(coldkey); + T::OnRootRegistrationChange::on_removed(coldkey); + } + } +} diff --git a/pallets/subtensor/src/root_registered/try_state.rs b/pallets/subtensor/src/root_registered/try_state.rs new file mode 100644 index 0000000000..7555418059 --- /dev/null +++ b/pallets/subtensor/src/root_registered/try_state.rs @@ -0,0 +1,66 @@ +use alloc::collections::{BTreeMap, BTreeSet}; + +use super::*; +use subtensor_runtime_common::NetUid; + +impl Pallet { + /// Stored per-coldkey count equals the actual number of owned hotkeys registered on root. + pub(crate) fn check_root_registered_hotkey_count() -> Result<(), sp_runtime::TryRuntimeError> { + let mut expected: BTreeMap = BTreeMap::new(); + for (_uid, hotkey) in Keys::::iter_prefix(NetUid::ROOT) { + let owner = Owner::::get(&hotkey); + expected + .entry(owner) + .and_modify(|c| *c = c.saturating_add(1)) + .or_insert(1); + } + + for (coldkey, stored) in RootRegisteredHotkeyCount::::iter() { + let expected_count = expected.remove(&coldkey).unwrap_or(0); + ensure!( + stored == expected_count, + "RootRegisteredHotkeyCount mismatch for coldkey", + ); + } + + ensure!( + expected.is_empty(), + "RootRegisteredHotkeyCount missing entries for coldkeys with root hotkeys", + ); + + Ok(()) + } + + /// External inspector's coldkey set matches `RootRegisteredHotkeyCount`; skipped when unwired. + pub(crate) fn check_root_registered_matches_inspector() + -> Result<(), sp_runtime::TryRuntimeError> { + let Some(actual_members) = T::RootRegisteredInspector::members() else { + return Ok(()); + }; + let actual: BTreeSet = actual_members.into_iter().collect(); + let expected: BTreeSet = RootRegisteredHotkeyCount::::iter() + .map(|(coldkey, _)| coldkey) + .collect(); + ensure!( + actual == expected, + "RootRegisteredInspector members do not match root-registered coldkey set", + ); + Ok(()) + } + + /// `RootRegisteredEma` and `RootRegisteredHotkeyCount` always share the same key set. + #[cfg_attr(test, allow(dead_code))] + pub(crate) fn check_root_registered_ema_matches_count() + -> Result<(), sp_runtime::TryRuntimeError> { + let ema_keys: BTreeSet = + RootRegisteredEma::::iter().map(|(c, _)| c).collect(); + let count_keys: BTreeSet = RootRegisteredHotkeyCount::::iter() + .map(|(c, _)| c) + .collect(); + ensure!( + ema_keys == count_keys, + "RootRegisteredEma keys do not match RootRegisteredHotkeyCount keys", + ); + Ok(()) + } +} diff --git a/pallets/subtensor/src/subnets/uids.rs b/pallets/subtensor/src/subnets/uids.rs index 3665b139ff..768b5971c5 100644 --- a/pallets/subtensor/src/subnets/uids.rs +++ b/pallets/subtensor/src/subnets/uids.rs @@ -200,6 +200,10 @@ impl Pallet { // Remove hotkey related storage items if hotkey exists if let Ok(hotkey) = Keys::::try_get(netuid, neuron_uid) { + if netuid == NetUid::ROOT { + let owner = Owner::::get(&hotkey); + Self::decrement_root_registered_hotkey_count(&owner); + } Uids::::remove(netuid, &hotkey); IsNetworkMember::::remove(&hotkey, netuid); LastHotkeyEmissionOnNetuid::::remove(&hotkey, netuid); diff --git a/pallets/subtensor/src/swap/swap_coldkey.rs b/pallets/subtensor/src/swap/swap_coldkey.rs index 2358fcecf1..19f18045fb 100644 --- a/pallets/subtensor/src/swap/swap_coldkey.rs +++ b/pallets/subtensor/src/swap/swap_coldkey.rs @@ -147,6 +147,10 @@ impl Pallet { let old_owned_hotkeys: Vec = OwnedHotkeys::::get(old_coldkey); let mut new_owned_hotkeys: Vec = OwnedHotkeys::::get(new_coldkey); for owned_hotkey in old_owned_hotkeys.iter() { + if Uids::::contains_key(NetUid::ROOT, owned_hotkey) { + Self::decrement_root_registered_hotkey_count(old_coldkey); + Self::increment_root_registered_hotkey_count(new_coldkey); + } // Remove the hotkey from the old coldkey. Owner::::remove(owned_hotkey); // Add the hotkey to the new coldkey. diff --git a/pallets/subtensor/src/tests/coinbase.rs b/pallets/subtensor/src/tests/coinbase.rs index 45260ef8fc..9e8a18a9a6 100644 --- a/pallets/subtensor/src/tests/coinbase.rs +++ b/pallets/subtensor/src/tests/coinbase.rs @@ -4339,3 +4339,303 @@ fn test_get_subnet_terms_alpha_emissions_cap() { assert_eq!(alpha_in.get(&netuid).copied().unwrap(), tao_block_emission); }); } + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +fn root_register_with_stake(coldkey: &U256, hotkey: &U256, alpha_netuid: NetUid) { + register_ok_neuron(alpha_netuid, *hotkey, *coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(*coldkey), + *hotkey, + )); +} + +#[test] +fn root_register_increments_ref_count_for_new_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let hotkey = U256::from(11); + + assert_eq!(ref_count(&coldkey), 0); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + root_register_with_stake(&coldkey, &hotkey, alpha); + + assert_eq!(ref_count(&coldkey), 1); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} + +#[test] +fn root_register_accumulates_ref_count_for_same_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h3 = U256::from(13); + + root_register_with_stake(&coldkey, &h1, alpha); + root_register_with_stake(&coldkey, &h2, alpha); + root_register_with_stake(&coldkey, &h3, alpha); + + assert_eq!(ref_count(&coldkey), 3); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} + +#[test] +fn root_register_replace_path_shifts_ref_count_to_new_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Cap the root subnet at 1 so the second registration follows the + // replace path rather than the append path. + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let cold_old = U256::from(10); + let hot_old = U256::from(11); + register_ok_neuron(alpha, hot_old, cold_old, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot_old, + &cold_old, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold_old), + hot_old, + )); + assert_eq!(ref_count(&cold_old), 1); + + // Higher-stake new entrant displaces hot_old. + let cold_new = U256::from(20); + let hot_new = U256::from(21); + register_ok_neuron(alpha, hot_new, cold_new, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot_new, + &cold_new, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold_new), + hot_new, + )); + + assert_eq!(ref_count(&cold_old), 0); + assert_eq!(ref_count(&cold_new), 1); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&cold_old)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&cold_new)); + }); +} + +#[test] +fn root_register_replace_with_same_coldkey_keeps_ref_count_stable() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Same coldkey registers two hotkeys in a capacity-1 root subnet: + // the second registration goes through the replace path. The + // counter should land back at 1, not 0 or 2. + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let coldkey = U256::from(10); + let hot1 = U256::from(11); + let hot2 = U256::from(12); + + register_ok_neuron(alpha, hot1, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot1, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + hot1, + )); + + register_ok_neuron(alpha, hot2, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &hot2, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + hot2, + )); + + assert_eq!(ref_count(&coldkey), 1); + }); +} + +#[test] +fn trim_root_decrements_ref_count_for_evicted_hotkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // The trim must satisfy `max_n >= MinAllowedUids`. Setting the + // immunity period to zero stops the freshly-registered neurons + // from counting against the immune-percentage cap. + MinAllowedUids::::set(NetUid::ROOT, 1); + MaxAllowedUids::::set(NetUid::ROOT, 2); + ImmunityPeriod::::set(NetUid::ROOT, 0); + + // Two distinct coldkeys, each with one root-registered hotkey. The + // trim drops the lowest-emitter UID, which we force to be `hot_b` + // by giving `hot_a` the higher emission. + let cold_a = U256::from(10); + let hot_a = U256::from(11); + let cold_b = U256::from(20); + let hot_b = U256::from(21); + + root_register_with_stake(&cold_a, &hot_a, alpha); + root_register_with_stake(&cold_b, &hot_b, alpha); + assert_eq!(ref_count(&cold_a), 1); + assert_eq!(ref_count(&cold_b), 1); + + let uid_a = SubtensorModule::get_uid_for_net_and_hotkey(NetUid::ROOT, &hot_a) + .expect("hot_a registered"); + let uid_b = SubtensorModule::get_uid_for_net_and_hotkey(NetUid::ROOT, &hot_b) + .expect("hot_b registered"); + Emission::::mutate(NetUid::ROOT, |v| { + v[uid_a as usize] = AlphaBalance::from(100); + v[uid_b as usize] = AlphaBalance::from(1); + }); + + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + + assert!(!RootRegisteredHotkeyCount::::contains_key(cold_b)); + assert_eq!(ref_count(&cold_a), 1); + }); +} + +#[test] +fn root_register_fires_on_added_for_fresh_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let _ = take_root_registration_log(); + + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Added(coldkey)] + ); + + // Second root hotkey under the same coldkey: the ref count goes + // 1→2, no membership edge to report. + root_register_with_stake(&coldkey, &U256::from(12), alpha); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn root_register_replace_fires_removed_and_added_when_owners_differ() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + MaxAllowedUids::::set(NetUid::ROOT, 1); + + let outgoing = U256::from(10); + let incoming = U256::from(20); + root_register_with_stake(&outgoing, &U256::from(11), alpha); + let _ = take_root_registration_log(); + + // Replacement path: incoming coldkey displaces the outgoing one. + let h2 = U256::from(21); + register_ok_neuron(alpha, h2, incoming, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &h2, + &incoming, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(incoming), + h2, + )); + + assert_eq!( + take_root_registration_log(), + vec![ + RootRegistrationChange::Removed(outgoing), + RootRegistrationChange::Added(incoming), + ] + ); + }); +} + +#[test] +fn trim_to_max_allowed_uids_fires_removed_for_evicted_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + let _ = take_root_registration_log(); + + // Lifts the immunity guard so trim can pick a fresh UID; `MinAllowedUids` + // is dropped to 1 (the floor `trim_to_max_allowed_uids` honors) so the + // call doesn't bounce on the lower bound either. + ImmunityPeriod::::set(NetUid::ROOT, 0); + MinAllowedUids::::set(NetUid::ROOT, 1); + + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + + // Exactly one of the two coldkeys was evicted; the corresponding + // Removed must fire and no spurious events should appear. + let log = take_root_registration_log(); + let removed: Vec<_> = log + .iter() + .filter_map(|c| match c { + RootRegistrationChange::Removed(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!( + removed.len(), + 1, + "one Removed per evicted coldkey, got {log:?}" + ); + assert!(removed[0] == cold1 || removed[0] == cold2); + }); +} diff --git a/pallets/subtensor/src/tests/migration.rs b/pallets/subtensor/src/tests/migration.rs index 68ecd1e1fe..d748456068 100644 --- a/pallets/subtensor/src/tests/migration.rs +++ b/pallets/subtensor/src/tests/migration.rs @@ -4510,3 +4510,51 @@ fn test_migrate_reset_tnet_conviction_locks() { ); }); } + +#[test] +fn test_migrate_init_root_registered_hotkey_count_backfills_counts_and_fires_hooks() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + // Two hotkeys under cold1, one under cold2: the migration must + // reconstruct counts {cold1: 2, cold2: 1} and fire exactly one + // `on_added` per distinct coldkey. + let cold1 = U256::from(10); + let cold2 = U256::from(20); + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold1, &U256::from(12), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + + // Simulate pre-migration state: `Keys[ROOT]` populated, reverse + // index empty, and the hook log clean. + let _ = RootRegisteredHotkeyCount::::clear(u32::MAX, None); + let _ = take_root_registration_log(); + + crate::migrations::migrate_init_root_registered_hotkey_count::migrate_init_root_registered_hotkey_count::(); + + // Counts reconstructed. + assert_eq!(RootRegisteredHotkeyCount::::get(cold1), 2); + assert_eq!(RootRegisteredHotkeyCount::::get(cold2), 1); + assert!(HasMigrationRun::::get( + b"migrate_init_root_registered_hotkey_count".to_vec() + )); + + // One Added per distinct coldkey, regardless of hotkey count. + let log = take_root_registration_log(); + let added: Vec<_> = log + .iter() + .filter_map(|c| match c { + RootRegistrationChange::Added(c) => Some(*c), + _ => None, + }) + .collect(); + assert_eq!(added.len(), 2, "one Added per distinct coldkey, got {log:?}"); + assert!(added.contains(&cold1)); + assert!(added.contains(&cold2)); + }); +} diff --git a/pallets/subtensor/src/tests/mock.rs b/pallets/subtensor/src/tests/mock.rs index 277162dde4..275d91eb51 100644 --- a/pallets/subtensor/src/tests/mock.rs +++ b/pallets/subtensor/src/tests/mock.rs @@ -6,6 +6,9 @@ use core::num::NonZeroU64; +use crate::root_registered::{ + EmaValueProvider, OnRootRegistrationChange, RootRegisteredInspector, SampleStep, +}; use crate::utils::rate_limiting::TransactionType; use crate::*; pub use frame_support::traits::Imbalance; @@ -175,6 +178,169 @@ impl AuthorshipInfo for MockAuthorshipProvider { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RootRegistrationChange { + Added(U256), + Removed(U256), +} + +thread_local! { + static ROOT_REGISTRATION_LOG: core::cell::RefCell> = + const { core::cell::RefCell::new(Vec::new()) }; +} + +pub fn take_root_registration_log() -> Vec { + ROOT_REGISTRATION_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +pub struct MockOnRootRegistrationChange; + +impl OnRootRegistrationChange for MockOnRootRegistrationChange { + fn on_added(coldkey: &U256) { + ROOT_REGISTRATION_LOG.with(|log| { + log.borrow_mut() + .push(RootRegistrationChange::Added(*coldkey)) + }); + } + fn on_removed(coldkey: &U256) { + ROOT_REGISTRATION_LOG.with(|log| { + log.borrow_mut() + .push(RootRegistrationChange::Removed(*coldkey)) + }); + } + fn on_added_weight() -> Weight { + Weight::zero() + } + fn on_removed_weight() -> Weight { + Weight::zero() + } +} + +thread_local! { + static MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS: core::cell::RefCell>> = + const { core::cell::RefCell::new(None) }; +} + +/// Override the membership exposed by `MockRootRegisteredInspector` to +/// `pallet_subtensor`'s try_state check. `None` (the default) makes +/// the check a no-op; `Some(_)` opts the test in. +pub fn set_mock_root_registered_inspector_members(members: Option>) { + MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS.with(|m| *m.borrow_mut() = members); +} + +pub struct MockRootRegisteredInspector; + +impl RootRegisteredInspector for MockRootRegisteredInspector { + fn members() -> Option> { + MOCK_ROOT_REGISTERED_INSPECTOR_MEMBERS.with(|m| m.borrow().clone()) + } +} + +thread_local! { + static EMA_VALUE_PROVIDER_LOG: RefCell> = + const { RefCell::new(Vec::new()) }; +} + +pub fn take_ema_value_provider_log() -> Vec<(U256, U64F64)> { + EMA_VALUE_PROVIDER_LOG.with(|log| log.borrow_mut().drain(..).collect()) +} + +/// Define a thread-local whose value can be temporarily replaced via an +/// RAII guard. The previous value is restored when the guard drops, so +/// tests do not need to manually undo their setup (and inherit nothing +/// from a panicking neighbor). +macro_rules! define_scoped_state { + ($flag:ident, $guard:ident, $reader:ident, $ty:ty, $default:expr) => { + thread_local! { + static $flag: RefCell<$ty> = const { RefCell::new($default) }; + } + + #[must_use = "the guard restores the prior value on drop; bind it to a local"] + pub struct $guard { + previous: Option<$ty>, + } + + impl $guard { + pub fn new(value: $ty) -> Self { + let previous = + Some($flag.with(|r| core::mem::replace(&mut *r.borrow_mut(), value))); + Self { previous } + } + } + + impl Drop for $guard { + fn drop(&mut self) { + if let Some(prev) = self.previous.take() { + $flag.with(|r| *r.borrow_mut() = prev); + } + } + } + + fn $reader() -> $ty { + $flag.with(|r| r.borrow().clone()) + } + }; +} + +define_scoped_state!( + EMA_VALUE_PROVIDER_STEP, + EmaValueProviderStepGuard, + ema_value_provider_step, + Option (SampleStep, Weight)>, + None +); +define_scoped_state!( + EMA_VALUE_PROVIDER_STEP_WEIGHT, + EmaValueProviderStepWeightGuard, + ema_value_provider_step_weight, + Weight, + Weight::zero() +); + +#[freeze_struct("79e67cd33ad5c63b")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct MockEmaProgress { + pub offset: u32, + pub partial: u128, +} + +pub struct MockEmaValueProvider; + +impl EmaValueProvider for MockEmaValueProvider { + type Progress = MockEmaProgress; + + fn step(coldkey: &U256, progress: Self::Progress) -> (SampleStep, Weight) { + let (step, weight) = match ema_value_provider_step() { + Some(f) => f(*coldkey, progress), + None => ( + SampleStep::Complete { + sample: U64F64::from_num(0u64), + }, + ema_value_provider_step_weight(), + ), + }; + EMA_VALUE_PROVIDER_LOG + .with(|log| log.borrow_mut().push((*coldkey, U64F64::from_num(0u64)))); + (step, weight) + } + + fn step_weight() -> Weight { + ema_value_provider_step_weight() + } +} + parameter_types! { pub const InitialMinAllowedWeights: u16 = 0; pub const InitialEmissionValue: u16 = 0; @@ -330,6 +496,9 @@ impl crate::Config for Test { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = MockOnRootRegistrationChange; + type RootRegisteredInspector = MockRootRegisteredInspector; + type EmaValueProvider = MockEmaValueProvider; type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); @@ -376,7 +545,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { @@ -1195,3 +1363,17 @@ pub fn remove_owner_registration_stake(netuid: NetUid) { AlphaBalance::ZERO ); } + +pub fn root_register_with_stake(coldkey: &U256, hotkey: &U256, alpha_netuid: NetUid) { + register_ok_neuron(alpha_netuid, *hotkey, *coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + hotkey, + coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(*coldkey), + *hotkey, + )); +} diff --git a/pallets/subtensor/src/tests/mock_high_ed.rs b/pallets/subtensor/src/tests/mock_high_ed.rs index 5f19edf455..94e892dc74 100644 --- a/pallets/subtensor/src/tests/mock_high_ed.rs +++ b/pallets/subtensor/src/tests/mock_high_ed.rs @@ -290,6 +290,9 @@ impl crate::Config for Test { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); diff --git a/pallets/subtensor/src/tests/mod.rs b/pallets/subtensor/src/tests/mod.rs index be37a9227b..d18adab02a 100644 --- a/pallets/subtensor/src/tests/mod.rs +++ b/pallets/subtensor/src/tests/mod.rs @@ -22,6 +22,7 @@ mod networks; mod neuron_info; mod recycle_alpha; mod registration; +mod root_registered; mod serving; mod staking; mod staking2; diff --git a/pallets/subtensor/src/tests/root_registered.rs b/pallets/subtensor/src/tests/root_registered.rs new file mode 100644 index 0000000000..d3cc10a148 --- /dev/null +++ b/pallets/subtensor/src/tests/root_registered.rs @@ -0,0 +1,708 @@ +#![allow( + clippy::indexing_slicing, + clippy::unwrap_used, + clippy::expect_used, + clippy::arithmetic_side_effects +)] + +use super::mock::*; +use crate::root_registered::{EmaState, InFlightEmaSample, SampleStep}; +use crate::*; +use frame_support::assert_ok; +use frame_support::weights::Weight; +use sp_core::U256; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::{AlphaBalance, NetUid}; + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +#[test] +fn ref_count_helpers_basic_behavior() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(7); + + // Reader on an unset key. + assert_eq!(ref_count(&coldkey), 0); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + // Saturating decrement at zero must not underflow. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 0); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + + // Increment populates storage and flips the reader. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(RootRegisteredHotkeyCount::::contains_key(coldkey)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 2); + + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!(ref_count(&coldkey), 1); + + // Decrement to zero removes the storage entry. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + + // Saturating decrement on an absent key must not resurrect the entry. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredHotkeyCount::::contains_key(coldkey)); + }); +} + +#[test] +fn ref_count_increment_fires_added_hook_only_on_zero_to_one_transition() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(10); + let _ = take_root_registration_log(); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Added(coldkey)] + ); + + // Subsequent increments stay above zero and must not re-fire. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn ref_count_decrement_fires_removed_hook_only_on_one_to_zero_transition() { + new_test_ext(1).execute_with(|| { + let coldkey = U256::from(10); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + let _ = take_root_registration_log(); + + // Above-zero decrements are silent. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + + // The 1→0 edge fires once. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert_eq!( + take_root_registration_log(), + vec![RootRegistrationChange::Removed(coldkey)] + ); + + // Decrementing a zero count must not fire a spurious `Removed`. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(take_root_registration_log().is_empty()); + }); +} + +#[test] +fn ref_count_invariant_holds_across_mutations() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + // Lift the per-block / per-interval registration caps so the test + // can register five hotkeys without stepping blocks. + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + let cold3 = U256::from(30); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h3 = U256::from(21); + let h4 = U256::from(31); + + // Mix of registrations across multiple coldkeys. + root_register_with_stake(&cold1, &h1, alpha); + root_register_with_stake(&cold1, &h2, alpha); + root_register_with_stake(&cold2, &h3, alpha); + root_register_with_stake(&cold3, &h4, alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Replace path through `do_root_register` at the cap. + MaxAllowedUids::::set(NetUid::ROOT, 4); + let cold4 = U256::from(40); + let h5 = U256::from(41); + register_ok_neuron(alpha, h5, cold4, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &h5, + &cold4, + NetUid::ROOT, + AlphaBalance::from(10_000_000_000_u64), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(cold4), + h5, + )); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Coldkey swap moves a multi-hotkey holder's count to a fresh coldkey. + let cold1_new = U256::from(99); + assert_ok!(SubtensorModule::do_swap_coldkey(&cold1, &cold1_new)); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Trim drops the lowest emitter; tightens the invariant under + // bulk removal. + ImmunityPeriod::::set(NetUid::ROOT, 0); + MinAllowedUids::::set(NetUid::ROOT, 1); + assert_ok!(SubtensorModule::trim_to_max_allowed_uids(NetUid::ROOT, 1)); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + }); +} + +#[test] +fn ref_count_invariant_detects_stale_overcount() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Simulate a buggy code path that incremented the counter without a + // matching root registration. The invariant must surface the drift. + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + assert!(SubtensorModule::check_root_registered_hotkey_count().is_err()); + }); +} + +#[test] +fn ref_count_invariant_detects_missing_index_entry() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + assert_ok!(SubtensorModule::check_root_registered_hotkey_count()); + + // Simulate a buggy path that registered a root hotkey without + // updating the reverse index. The invariant must catch the + // coldkey that now has root hotkeys but no counter entry. + RootRegisteredHotkeyCount::::remove(coldkey); + assert!(SubtensorModule::check_root_registered_hotkey_count().is_err()); + }); +} + +#[test] +fn inspector_invariant_passes_when_members_match_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold1 = U256::from(10); + let cold2 = U256::from(20); + // Two hotkeys under cold1, one under cold2: the expected root-registered + // set is the two distinct coldkeys, not three. + root_register_with_stake(&cold1, &U256::from(11), alpha); + root_register_with_stake(&cold1, &U256::from(12), alpha); + root_register_with_stake(&cold2, &U256::from(21), alpha); + + set_mock_root_registered_inspector_members(Some(vec![cold1, cold2])); + assert_ok!(SubtensorModule::check_root_registered_matches_inspector()); + }); +} + +#[test] +fn inspector_invariant_skips_when_inspector_is_unset() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + // Inspector unset by default: the check must silently no-op even + // when the on-chain root set is non-empty. + set_mock_root_registered_inspector_members(None); + assert_ok!(SubtensorModule::check_root_registered_matches_inspector()); + }); +} + +#[test] +fn inspector_invariant_fails_when_members_differ_from_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let cold = U256::from(10); + root_register_with_stake(&cold, &U256::from(11), alpha); + + // Inspector forgot to include the root-registered coldkey. + set_mock_root_registered_inspector_members(Some(vec![])); + assert!(SubtensorModule::check_root_registered_matches_inspector().is_err()); + + // Inspector holds a coldkey that has no root hotkey. + set_mock_root_registered_inspector_members(Some(vec![cold, U256::from(999)])); + assert!(SubtensorModule::check_root_registered_matches_inspector().is_err()); + }); +} + +#[test] +fn ema_count_invariant_passes_when_ema_keys_match_root_registered_coldkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + assert_ok!(SubtensorModule::check_root_registered_ema_matches_count()); + }); +} + +#[test] +fn ema_count_invariant_detects_missing_ema_entry_for_registered_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + RootRegisteredEma::::remove(coldkey); + + assert!(SubtensorModule::check_root_registered_ema_matches_count().is_err()); + }); +} + +#[test] +fn ema_count_invariant_detects_stale_ema_entry_for_unregistered_coldkey() { + new_test_ext(1).execute_with(|| { + let stale = U256::from(99); + RootRegisteredEma::::insert(stale, EmaState::default()); + + assert!(SubtensorModule::check_root_registered_ema_matches_count().is_err()); + }); +} + +#[test] +fn ema_slot_is_initialized_cleared_and_reinitialized_on_reentry() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + assert!(!RootRegisteredEma::::contains_key(coldkey)); + + // First root registration seeds a zero-valued slot. + root_register_with_stake(&coldkey, &U256::from(11), alpha); + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 0); + + // The default mock provider completes a sample per tick, so two + // ticks land two samples on the only registered coldkey. + SubtensorModule::tick_root_registered_ema(); + SubtensorModule::tick_root_registered_ema(); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 2); + + // Drop to zero hotkeys: the EMA slot is cleared. + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!(!RootRegisteredEma::::contains_key(coldkey)); + + // Re-register: state starts fresh. + root_register_with_stake(&coldkey, &U256::from(12), alpha); + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 0); + }); +} + +#[test] +fn ema_tick_blends_completed_sample_with_fixed_alpha() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, _| { + ( + SampleStep::Complete { + sample: U64F64::from_num(100), + }, + Weight::zero(), + ) + })); + + SubtensorModule::tick_root_registered_ema(); + + let state = RootRegisteredEma::::get(coldkey); + let expected = U64F64::from_num(2) + .saturating_div(U64F64::from_num(100)) + .saturating_mul(U64F64::from_num(100)); + assert_eq!(state.ema, expected); + assert_eq!(state.samples, 1); + }); +} + +#[test] +fn ema_tick_finalizes_samples_and_advances_cursor() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + let _ = take_ema_value_provider_log(); + + // Default mock progress is single-shot; provider returns 42 as + // the raw sample and the pallet blends it into the EMA. + let _step = EmaValueProviderStepGuard::new(Some(|_, _| { + ( + SampleStep::Complete { + sample: U64F64::from_num(42), + }, + Weight::zero(), + ) + })); + + // Two consecutive ticks: each finalizes a distinct member and + // the cursor advances by one per finalize. + assert_eq!(EmaSamplerState::::get().0, 0); + SubtensorModule::tick_root_registered_ema(); + SubtensorModule::tick_root_registered_ema(); + + let log = take_ema_value_provider_log(); + let touched: Vec = log.iter().map(|(k, _)| *k).collect(); + assert_eq!(touched.len(), 2); + assert!(touched.contains(&cold_a) && touched.contains(&cold_b)); + + let state_a = RootRegisteredEma::::get(cold_a); + assert!(state_a.ema > U64F64::from_num(0)); + assert_eq!(state_a.samples, 1); + let state_b = RootRegisteredEma::::get(cold_b); + assert!(state_b.ema > U64F64::from_num(0)); + assert_eq!(state_b.samples, 1); + + // The cursor wraps and rebuilds the snapshot, so a third tick + // revisits one of the members and bumps its counter to 2. + SubtensorModule::tick_root_registered_ema(); + let revisited_samples = RootRegisteredEma::::get(cold_a).samples + + RootRegisteredEma::::get(cold_b).samples; + assert_eq!(revisited_samples, 3); + }); +} + +#[test] +fn ema_tick_is_no_op_when_no_members() { + new_test_ext(1).execute_with(|| { + // No registrations: the rebuild produces an empty snapshot and + // the tick must not touch the cursor or the provider log. + let _ = take_ema_value_provider_log(); + let cursor_before = EmaSamplerState::::get().0; + SubtensorModule::tick_root_registered_ema(); + assert_eq!(EmaSamplerState::::get().0, cursor_before); + assert!(take_ema_value_provider_log().is_empty()); + }); +} + +#[test] +fn ema_tick_returns_weight_including_provider_contribution() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + root_register_with_stake(&U256::from(10), &U256::from(11), alpha); + + // Provider reports a non-zero per-step weight; the tick must + // surface it through its return value so `on_initialize` can + // bill the actual cost. + let _step_weight = EmaValueProviderStepWeightGuard::new(Weight::from_parts(12_345, 0)); + let on_tick = SubtensorModule::tick_root_registered_ema(); + assert!( + on_tick.ref_time() >= 12_345, + "tick weight must include provider contribution, got {on_tick:?}" + ); + }); +} + +#[test] +fn ema_tick_default_provider_advances_sample_count_without_changing_zero_ema() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + // No guards: MockEmaValueProvider's default step is single-shot done + // with no contribution; finalize returns `previous.ema`. The EMA + // stays at the init value (0) but the sample counter advances. + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + + let state = RootRegisteredEma::::get(coldkey); + assert_eq!(state.ema, U64F64::from_num(0)); + assert_eq!(state.samples, 1); + }); +} + +#[test] +fn ema_tick_persists_provider_progress_until_sample_completes() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + // Step adds 100 per call and signals done only when offset + // reaches 3 (i.e. after three chunks). + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + if progress.offset >= 3 { + ( + SampleStep::Complete { + sample: U64F64::from_num(progress.partial as u64), + }, + Weight::zero(), + ) + } else { + (SampleStep::Continue { progress }, Weight::zero()) + } + })); + + // First two ticks accumulate partial state without finalizing. + SubtensorModule::tick_root_registered_ema(); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 0); + let in_flight = progress.expect("mid-sample progress must be Some"); + assert_eq!(in_flight.progress.offset, 1); + assert_eq!(in_flight.progress.partial, 100); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 0); + + SubtensorModule::tick_root_registered_ema(); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 0); + let in_flight = progress.expect("mid-sample progress must be Some"); + assert_eq!(in_flight.progress.offset, 2); + assert_eq!(in_flight.progress.partial, 200); + assert_eq!(RootRegisteredEma::::get(coldkey).samples, 0); + + // Third tick finalizes: the accumulated 300 sample is blended + // into the EMA, sample counter increments, progress resets, and + // cursor advances. + SubtensorModule::tick_root_registered_ema(); + let ema = RootRegisteredEma::::get(coldkey); + assert!(ema.ema > U64F64::from_num(0)); + assert!(ema.ema < U64F64::from_num(300u64)); + assert_eq!(ema.samples, 1); + let (cursor, progress) = EmaSamplerState::::get(); + assert_eq!(cursor, 1); + assert!(progress.is_none()); + }); +} + +#[test] +fn ema_in_flight_progress_is_cleared_when_sampled_coldkey_leaves() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + assert!(EmaSamplerState::::get().1.is_some()); + + SubtensorModule::decrement_root_registered_hotkey_count(&coldkey); + assert!( + EmaSamplerState::::get().1.is_none(), + "leaving the root-registered set must clear stale in-flight EMA progress" + ); + + SubtensorModule::increment_root_registered_hotkey_count(&coldkey); + SubtensorModule::tick_root_registered_ema(); + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("fresh re-entry starts a new in-flight sample"); + assert_eq!(progress.progress.offset, 1); + assert_eq!(progress.progress.partial, 100); + }); +} + +#[test] +fn ema_in_flight_progress_survives_when_different_coldkey_leaves() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + + let _step = EmaValueProviderStepGuard::new(Some(|_, mut progress| { + progress.offset = progress.offset.saturating_add(1); + progress.partial = progress.partial.saturating_add(100); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + let (_, progress) = EmaSamplerState::::get(); + let in_flight = progress.expect("first tick must start an in-flight sample"); + let sampled = in_flight.coldkey; + let other = if sampled == cold_a { cold_b } else { cold_a }; + + SubtensorModule::decrement_root_registered_hotkey_count(&other); + + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("unrelated coldkey removal must not clear progress"); + assert_eq!(progress.coldkey, sampled); + assert_eq!(progress.progress.offset, 1); + assert_eq!(progress.progress.partial, 100); + }); +} + +#[test] +fn ema_tick_discards_stale_in_flight_progress_for_wrong_coldkey() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let stale_coldkey = U256::from(20); + root_register_with_stake(&coldkey, &U256::from(11), alpha); + + CurrentCycleMembers::::put( + BoundedVec::try_from(vec![coldkey]).expect("one member fits snapshot bound"), + ); + EmaSamplerState::::put(( + 0, + Some(InFlightEmaSample { + coldkey: stale_coldkey, + progress: MockEmaProgress { + offset: 99, + partial: 999, + }, + }), + )); + + let _step = EmaValueProviderStepGuard::new(Some(|_, progress| { + assert_eq!(progress, MockEmaProgress::default()); + (SampleStep::Continue { progress }, Weight::zero()) + })); + + SubtensorModule::tick_root_registered_ema(); + + let (_, progress) = EmaSamplerState::::get(); + let progress = progress.expect("continued sample must store fresh progress"); + assert_eq!(progress.coldkey, coldkey); + assert_eq!(progress.progress, MockEmaProgress::default()); + }); +} + +#[test] +fn ema_tick_ignores_joined_coldkey_until_cycle_snapshot_rebuilds() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + let cold_c = U256::from(30); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + + SubtensorModule::tick_root_registered_ema(); + let first_snapshot = CurrentCycleMembers::::get(); + assert_eq!(first_snapshot.len(), 2); + + root_register_with_stake(&cold_c, &U256::from(31), alpha); + assert!(!first_snapshot.contains(&cold_c)); + assert!(!CurrentCycleMembers::::get().contains(&cold_c)); + + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + let touched: Vec = take_ema_value_provider_log() + .iter() + .map(|(coldkey, _)| *coldkey) + .collect(); + assert!(!touched.contains(&cold_c)); + + SubtensorModule::tick_root_registered_ema(); + assert!(CurrentCycleMembers::::get().contains(&cold_c)); + }); +} + +#[test] +fn ema_tick_skips_removed_coldkey_from_existing_cycle_snapshot() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + MaxRegistrationsPerBlock::::set(NetUid::ROOT, 64); + TargetRegistrationsPerInterval::::set(NetUid::ROOT, 64); + + let cold_a = U256::from(10); + let cold_b = U256::from(20); + root_register_with_stake(&cold_a, &U256::from(11), alpha); + root_register_with_stake(&cold_b, &U256::from(21), alpha); + let _ = take_ema_value_provider_log(); + + // Snapshot built on first tick; finalize bumps samples on + // whichever validator the cursor lands on. + SubtensorModule::tick_root_registered_ema(); + + // Identify the validator at the *next* cursor position and + // unregister it before the next tick reaches them. + let snapshot = CurrentCycleMembers::::get(); + let cursor = EmaSamplerState::::get().0; + let next = snapshot + .get(cursor as usize) + .copied() + .expect("cursor must point at a member after first tick"); + SubtensorModule::decrement_root_registered_hotkey_count(&next); + assert!(!RootRegisteredEma::::contains_key(next)); + + // The next tick lands on the unregistered coldkey, finds it + // missing from RootRegisteredEma, advances the cursor, and + // does not finalize. + let _ = take_ema_value_provider_log(); + SubtensorModule::tick_root_registered_ema(); + assert_eq!(EmaSamplerState::::get().0, cursor + 1); + assert!(take_ema_value_provider_log().is_empty()); + assert!(!RootRegisteredEma::::contains_key(next)); + }); +} diff --git a/pallets/subtensor/src/tests/swap_coldkey.rs b/pallets/subtensor/src/tests/swap_coldkey.rs index fd0281ad35..27491cebe3 100644 --- a/pallets/subtensor/src/tests/swap_coldkey.rs +++ b/pallets/subtensor/src/tests/swap_coldkey.rs @@ -1911,3 +1911,81 @@ fn dispute_coldkey_swap(who: U256) { RuntimeOrigin::signed(who), )); } + +fn ref_count(coldkey: &U256) -> u32 { + RootRegisteredHotkeyCount::::get(coldkey) +} + +#[test] +fn swap_coldkey_transfers_ref_count_for_root_registered_hotkeys() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let old_coldkey = U256::from(10); + let new_coldkey = U256::from(20); + let h1 = U256::from(11); + let h2 = U256::from(12); + let h_not_root = U256::from(13); + + // Two root-registered hotkeys plus one non-root-registered hotkey, + // all owned by old_coldkey. + root_register_with_stake(&old_coldkey, &h1, alpha); + root_register_with_stake(&old_coldkey, &h2, alpha); + register_ok_neuron(alpha, h_not_root, old_coldkey, 0); + + assert_eq!(ref_count(&old_coldkey), 2); + assert_eq!(ref_count(&new_coldkey), 0); + + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + + assert_eq!(ref_count(&old_coldkey), 0); + assert_eq!(ref_count(&new_coldkey), 2); + assert!(!SubtensorModule::coldkey_has_root_hotkey(&old_coldkey)); + assert!(SubtensorModule::coldkey_has_root_hotkey(&new_coldkey)); + }); +} + +#[test] +fn swap_coldkey_with_no_root_hotkeys_is_noop_for_ref_count() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let old_coldkey = U256::from(10); + let new_coldkey = U256::from(20); + let hot = U256::from(11); + + // Hotkey registered on alpha only, not on root. + register_ok_neuron(alpha, hot, old_coldkey, 0); + assert_eq!(ref_count(&old_coldkey), 0); + + assert_ok!(SubtensorModule::do_swap_coldkey(&old_coldkey, &new_coldkey)); + + assert_eq!(ref_count(&old_coldkey), 0); + assert_eq!(ref_count(&new_coldkey), 0); + }); +} + +#[test] +fn swap_coldkey_fires_removed_for_source_and_added_for_target() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let from = U256::from(10); + let to = U256::from(99); + root_register_with_stake(&from, &U256::from(11), alpha); + root_register_with_stake(&from, &U256::from(12), alpha); + let _ = take_root_registration_log(); + + assert_ok!(SubtensorModule::do_swap_coldkey(&from, &to)); + + let log = take_root_registration_log(); + assert!(log.contains(&RootRegistrationChange::Removed(from))); + assert!(log.contains(&RootRegistrationChange::Added(to))); + }); +} diff --git a/pallets/subtensor/src/tests/swap_hotkey.rs b/pallets/subtensor/src/tests/swap_hotkey.rs index 3fdacf23be..30e9fbdc3d 100644 --- a/pallets/subtensor/src/tests/swap_hotkey.rs +++ b/pallets/subtensor/src/tests/swap_hotkey.rs @@ -1686,3 +1686,44 @@ fn test_swap_auto_stake_destination_coldkeys() { ); }); } + +#[test] +fn test_swap_hotkey_preserves_root_registered_hotkey_count() { + new_test_ext(1).execute_with(|| { + let alpha = NetUid::from(1); + add_network(NetUid::ROOT, 1, 0); + add_network(alpha, 1, 0); + + let coldkey = U256::from(10); + let old_hotkey = U256::from(11); + let new_hotkey = U256::from(12); + + // Register `old_hotkey` on the root subnet under `coldkey`. + register_ok_neuron(alpha, old_hotkey, coldkey, 0); + SubtensorModule::increase_stake_for_hotkey_and_coldkey_on_subnet( + &old_hotkey, + &coldkey, + NetUid::ROOT, + AlphaBalance::from(1_000_000_000), + ); + assert_ok!(SubtensorModule::root_register( + RuntimeOrigin::signed(coldkey), + old_hotkey, + )); + assert_eq!(RootRegisteredHotkeyCount::::get(coldkey), 1); + + let mut weight = Weight::zero(); + assert_ok!(SubtensorModule::perform_hotkey_swap_on_all_subnets( + &old_hotkey, + &new_hotkey, + &coldkey, + &mut weight, + false, + )); + + // The coldkey still controls one root-registered hotkey; only the + // identity changed. + assert_eq!(RootRegisteredHotkeyCount::::get(coldkey), 1); + assert!(SubtensorModule::coldkey_has_root_hotkey(&coldkey)); + }); +} diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 6d536dadaa..21b74a7e67 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -193,7 +193,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) fn register() -> Weight { // Proof Size summary in bytes: - // Measured: `1716` + // Measured: `1753` // Estimated: `13600` // Minimum execution time: 374_002_000 picoseconds. Weight::from_parts(380_312_000, 13600) @@ -448,7 +448,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) fn burned_register() -> Weight { // Proof Size summary in bytes: - // Measured: `1649` + // Measured: `1686` // Estimated: `13600` // Minimum execution time: 362_295_000 picoseconds. Weight::from_parts(368_123_000, 13600) @@ -491,10 +491,16 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::ValidatorTrust` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ValidatorPermit` (r:1 w:1) /// Proof: `SubtensorModule::ValidatorPermit` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredHotkeyCount` (r:1 w:1) + /// Proof: `SubtensorModule::RootRegisteredHotkeyCount` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::Delegates` (r:1 w:1) /// Proof: `SubtensorModule::Delegates` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::BlockAtRegistration` (r:0 w:1) /// Proof: `SubtensorModule::BlockAtRegistration` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:0 w:1) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Keys` (r:0 w:1) /// Proof: `SubtensorModule::Keys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) @@ -1918,7 +1924,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) fn unstake_all_alpha() -> Weight { // Proof Size summary in bytes: - // Measured: `2642` + // Measured: `2679` // Estimated: `11306` // Minimum execution time: 583_803_000 picoseconds. Weight::from_parts(599_485_000, 11306) @@ -2573,7 +2579,7 @@ impl WeightInfo for () { /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) fn register() -> Weight { // Proof Size summary in bytes: - // Measured: `1716` + // Measured: `1753` // Estimated: `13600` // Minimum execution time: 374_002_000 picoseconds. Weight::from_parts(380_312_000, 13600) @@ -2828,7 +2834,7 @@ impl WeightInfo for () { /// Proof: `Swap::CurrentTick` (`max_values`: None, `max_size`: Some(14), added: 2489, mode: `MaxEncodedLen`) fn burned_register() -> Weight { // Proof Size summary in bytes: - // Measured: `1649` + // Measured: `1686` // Estimated: `13600` // Minimum execution time: 362_295_000 picoseconds. Weight::from_parts(368_123_000, 13600) @@ -2871,10 +2877,16 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::ValidatorTrust` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ValidatorPermit` (r:1 w:1) /// Proof: `SubtensorModule::ValidatorPermit` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredHotkeyCount` (r:1 w:1) + /// Proof: `SubtensorModule::RootRegisteredHotkeyCount` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::Delegates` (r:1 w:1) /// Proof: `SubtensorModule::Delegates` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::BlockAtRegistration` (r:0 w:1) /// Proof: `SubtensorModule::BlockAtRegistration` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:0 w:1) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Keys` (r:0 w:1) /// Proof: `SubtensorModule::Keys` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) @@ -4298,7 +4310,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::LastColdkeyHotkeyStakeBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) fn unstake_all_alpha() -> Weight { // Proof Size summary in bytes: - // Measured: `2642` + // Measured: `2679` // Estimated: `11306` // Minimum execution time: 583_803_000 picoseconds. Weight::from_parts(599_485_000, 11306) diff --git a/pallets/transaction-fee/src/tests/mock.rs b/pallets/transaction-fee/src/tests/mock.rs index 343decb8a8..ab7e67c0f0 100644 --- a/pallets/transaction-fee/src/tests/mock.rs +++ b/pallets/transaction-fee/src/tests/mock.rs @@ -310,6 +310,9 @@ impl pallet_subtensor::Config for Test { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); @@ -452,7 +455,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } impl pallet_scheduler::Config for Test { diff --git a/precompiles/src/mock.rs b/precompiles/src/mock.rs index 037e02d864..dcfc900f56 100644 --- a/precompiles/src/mock.rs +++ b/precompiles/src/mock.rs @@ -490,6 +490,9 @@ impl pallet_subtensor::Config for Runtime { type CommitmentsInterface = CommitmentsI; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = MockAuthorshipProvider; + type OnRootRegistrationChange = (); + type RootRegisteredInspector = (); + type EmaValueProvider = (); type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = (); diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 48269f5eb5..407633c0e1 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -157,6 +157,11 @@ stp-shield.workspace = true ethereum.workspace = true +# Governance +pallet-multi-collective.workspace = true +pallet-signed-voting.workspace = true +pallet-referenda.workspace = true + [dev-dependencies] frame-metadata.workspace = true sp-io.workspace = true @@ -202,6 +207,9 @@ std = [ "pallet-scheduler/std", "pallet-preimage/std", "pallet-commitments/std", + "pallet-multi-collective/std", + "pallet-signed-voting/std", + "pallet-referenda/std", "precompile-utils/std", "sp-api/std", "sp-block-builder/std", @@ -328,9 +336,12 @@ runtime-benchmarks = [ # Smart Tx fees pallet "subtensor-transaction-fee/runtime-benchmarks", "pallet-shield/runtime-benchmarks", - + "pallet-referenda/runtime-benchmarks", + "subtensor-runtime-common/runtime-benchmarks", - "subtensor-chain-extensions/runtime-benchmarks" + "subtensor-chain-extensions/runtime-benchmarks", + "pallet-multi-collective/runtime-benchmarks", + "pallet-signed-voting/runtime-benchmarks" ] try-runtime = [ "frame-try-runtime/try-runtime", @@ -368,6 +379,9 @@ try-runtime = [ "pallet-fast-unstake/try-runtime", "pallet-nomination-pools/try-runtime", "pallet-offences/try-runtime", + "pallet-multi-collective/try-runtime", + "pallet-signed-voting/try-runtime", + "pallet-referenda/try-runtime", # EVM + Frontier "fp-self-contained/try-runtime", diff --git a/runtime/src/governance/README.md b/runtime/src/governance/README.md new file mode 100644 index 0000000000..8aceae8ec9 --- /dev/null +++ b/runtime/src/governance/README.md @@ -0,0 +1,158 @@ +# Runtime Governance + +This directory wires Subtensor's concrete governance configuration into the +generic governance pallets. + +The runtime uses: + +- `pallet_multi_collective` for named membership sets. +- `pallet_referenda` for the track state machine. +- `pallet_signed_voting` for per-account aye/nay voting. +- `pallet_subtensor` root-registration and subnet state to select rotating + collective members. + +## Tracks + +`tracks.rs` defines two static tracks. + +| Id | Name | Proposer set | Voter set | Strategy | +| -- | ---- | ---- | ---- | ---- | +| `0` | `triumvirate` | `MemberSet::Single(Proposers)` | `MemberSet::Single(Triumvirate)` | `PassOrFail`: 7 day decision period, 2/3 approve, 2/3 reject, approval hands off to track `1`. | +| `1` | `review` | `None` | `MemberSet::Union(Economic, Building)` | `Adjustable`: 24 hour initial delay, 2 day max delay, 75% fast-track threshold, 51% cancel threshold. | + +Track `1` must stay non-submittable (`proposer_set: None`). It is reached +only through `ApprovalAction::Review` after track `0` approval. This is the +runtime invariant that prevents direct submission of a root call into the +review delay. + +`EaseOutAdjustmentCurve` shapes review delay changes as `1 - (1 - p)^3`. +Early net collective signal has a visible effect on the dispatch delay, and +then tapers off as the vote approaches the hard fast-track or cancel +threshold. Net approval pulls the scheduled call toward the submission +block; net rejection pushes it toward `max_delay`. + +## Collectives + +`collectives.rs` defines the consensus-facing `CollectiveId` values: + +| Id | Codec index | Members | Term | +| -- | -- | -- | -- | +| `Proposers` | `0` | min `1`, max `20` | none | +| `Triumvirate` | `1` | exactly `3` | none | +| `Economic` | `2` | exactly `16` | 60 days | +| `Building` | `3` | exactly `16` | 60 days | +| `EconomicEligible` | `4` | max `64` | none | + +Codec indices are consensus-facing. Do not reorder or renumber them. + +The pallet-level `MaxMembers` is `64` because it is the storage bound shared +by all collectives. The per-collective `max_members` values above are the +logical limits. + +## Voting Sets + +`member_set.rs` adapts collectives into the `SetLike` interface +used by referenda tracks. + +- `Single(id)` reads exactly one collective. +- `Union(ids)` concatenates members from several collectives, sorts them, + and deduplicates them. + +The review track uses `Union(Economic, Building)`, so an account that is in +both collectives is counted once in the signed-voting snapshot and in the +threshold denominator. + +## Economic Rotation + +`EconomicEligible` is a staging set for Economic selection. It is maintained +by `EconomicEligibleSync`, which implements `OnRootRegistrationChange` for +`pallet-subtensor`. + +- A coldkey is added when its root-registered hotkey count moves from `0` + to `1`. +- A coldkey is removed when its count moves from `1` to `0`. +- `EconomicEligibleInspector` lets Subtensor try-state verify that the + collective matches the root-registered coldkey set. + +`term_management.rs` rotates `Economic` by calling +`TermManagement::top_validators(16)`. + +Selection steps: + +1. Read all `EconomicEligible` coldkeys. +2. Read `RootRegisteredEma` for each coldkey. +3. Ignore candidates with fewer than `ECONOMIC_ELIGIBILITY_THRESHOLD` + samples (`210`, roughly 30 days with the current sampler cadence). +4. Sort remaining candidates by descending EMA value. +5. Set `Economic` to the top 16. + +The EMA sample value is provided by `ema_provider.rs`. A sample is: + +```text +liquid TAO balance ++ TAO value of alpha held by owned hotkeys across all subnets +``` + +Sampling is incremental: 8 subnets per provider step and at most 256 owned +hotkeys valued per sample. Subtensor calls `tick_root_registered_ema()` from +its `on_initialize` hook, so the sampler advances once per block. The EMA +blend alpha is `0.02` and new root-registered coldkeys start from zero. + +## Building Rotation + +`term_management.rs` rotates `Building` by calling +`TermManagement::top_subnet_owners(16, MIN_SUBNET_AGE)`. + +Selection steps: + +1. Iterate all subnet netuids. +2. Ignore subnets younger than `MIN_SUBNET_AGE` (`180` days in production). +3. For each mature subnet, read its owner and moving price. +4. Keep only each owner's highest moving price across all mature subnets. +5. Sort owners by descending best price. +6. Set `Building` to the top 16. + +This gives one seat per owner coldkey, based on that owner's strongest +mature subnet. + +## Rotation Behavior + +`pallet_multi_collective` runs term hooks from `on_initialize` whenever +`block_number % term_duration == 0`. For this runtime only `Economic` and +`Building` have a term duration, so only those collectives rotate +automatically. + +Both rotating collectives require exactly 16 members. If selection returns +fewer than 16 accounts, `do_set_members` fails with `TooFewMembers`; the +runtime logs the failure and leaves the previous member list unchanged. + +Root can call `force_rotate` for a rotating collective to run the same hook +outside the normal cadence. + +## Referenda Runtime Constants + +`mod.rs` wires these constants: + +| Constant | Value | Meaning | +| ---- | ---- | ---- | +| `MaxQueued` | `20` | Maximum active referenda. | +| `MaxActivePerProposer` | `5` | Maximum active referenda per proposer. | +| `MaxVoterSetSize` | `64` | Bound for signed-voting snapshots. | +| `MaxPendingCleanup` | `40` | Cleanup queue capacity for completed polls. | +| `CleanupChunkSize` | `16` | Per-idle-block vote-record cleanup chunk. | + +Compile-time assertions keep these constants aligned with the collective +sizes. The widest voter set is currently `Economic + Building` (`32` +before deduplication). + +## Operational Notes + +- `referenda.submit` is signed and only works on tracks with + `proposer_set: Some(_)`. In this runtime, that means only track `0`. +- There is no proposer-only cancel or withdraw call. Emergency termination + is `referenda.kill`, gated by root. +- Voting is snapshot-based. Active polls are not affected by later + collective rotations. +- Dispatch is wrapped through `referenda.enact(index, call)`, which marks + the referendum `Enacted` in the same root call that dispatches the inner + proposal. diff --git a/runtime/src/governance/benchmarking.rs b/runtime/src/governance/benchmarking.rs new file mode 100644 index 0000000000..2bdcf35cf4 --- /dev/null +++ b/runtime/src/governance/benchmarking.rs @@ -0,0 +1,207 @@ +#![allow(clippy::arithmetic_side_effects, clippy::unwrap_used)] + +use core::marker::PhantomData; +use frame_benchmarking::{BenchmarkError, account, v2::*}; +use pallet_multi_collective::Pallet as MultiCollective; +use pallet_subtensor::{ + Pallet as Subtensor, + root_registered::{EmaValueProvider, SampleStep}, + *, +}; +use sp_std::vec::Vec; +use substrate_fixed::types::{I96F32, U64F64}; +use subtensor_runtime_common::{AlphaBalance, NetUid, TaoBalance}; + +use super::{ + BUILDING_SIZE, CollectiveId, ECONOMIC_ELIGIBILITY_THRESHOLD, ECONOMIC_ELIGIBLE_SIZE, + ECONOMIC_SIZE, MIN_SUBNET_AGE, STAKE_CHUNK_SUBNETS, STAKE_VALUE_HOTKEYS, StakeValueProgress, + StakeValueProvider, TermManagement, +}; +use crate::{AccountId, Runtime}; + +pub trait Config: frame_system::Config {} + +pub struct Pallet(PhantomData); + +impl Config for Runtime {} + +const FIRST_BENCHMARK_NETUID: u16 = 1024; +const BUILDING_BENCHMARK_SUBNETS: u32 = 128; + +#[benchmarks] +mod benchmarks { + use super::*; + + #[benchmark] + fn stake_ema_provider_step() -> Result<(), BenchmarkError> { + let (coldkey, progress) = prepare_stake_value_state(); + let expected_offset = progress.subnet_offset.saturating_add(STAKE_CHUNK_SUBNETS); + let result; + + #[block] + { + result = StakeValueProvider::step(&coldkey, progress); + } + + assert!(matches!( + result.0, + SampleStep::Continue { progress } + if progress.subnet_offset == expected_offset && progress.accumulated_tao > 0 + )); + + Ok(()) + } + + #[benchmark] + fn rotate_economic() -> Result<(), BenchmarkError> { + let expected = expected_stored_members(prepare_economic_rotation_state()); + + #[block] + { + let _ = TermManagement::rotate_economic(); + } + + assert_eq!(members_of(CollectiveId::Economic), expected); + + Ok(()) + } + + #[benchmark] + fn rotate_building() -> Result<(), BenchmarkError> { + let expected = expected_stored_members(prepare_building_rotation_state()); + + #[block] + { + let _ = TermManagement::rotate_building(); + } + + assert_eq!(members_of(CollectiveId::Building), expected); + + Ok(()) + } + + fn seed_swap_reserves(netuid: NetUid) { + SubnetTAO::::insert(netuid, TaoBalance::from(150_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(100_000_000_000_u64)); + } + + fn add_balance_to_coldkey_account(coldkey: &AccountId, tao: TaoBalance) { + let credit = Subtensor::::mint_tao(tao); + let _ = Subtensor::::spend_tao(coldkey, credit, tao).unwrap(); + } + + fn prepare_stake_value_state() -> (AccountId, StakeValueProgress) { + let coldkey: AccountId = account("StakeValueColdkey", 0, 0); + add_balance_to_coldkey_account(&coldkey, TaoBalance::from(1_000_000_000_u64)); + + let mut hotkeys: Vec = Vec::with_capacity(STAKE_VALUE_HOTKEYS as usize); + for hotkey_index in 0..STAKE_VALUE_HOTKEYS { + hotkeys.push(account("StakeValueHotkey", hotkey_index, 0)); + } + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + let mut first_netuid = None; + for subnet_index in 0..STAKE_CHUNK_SUBNETS { + let netuid = NetUid::from(FIRST_BENCHMARK_NETUID.saturating_add(subnet_index as u16)); + if first_netuid.is_none() { + first_netuid = Some(netuid); + } + + Subtensor::::init_new_network(netuid, 1); + SubtokenEnabled::::insert(netuid, true); + seed_swap_reserves(netuid); + + for hotkey in &hotkeys { + TotalHotkeyAlpha::::insert( + hotkey.clone(), + netuid, + AlphaBalance::from(1_000_000_000_u64), + ); + } + } + + let netuids = Subtensor::::get_all_subnet_netuids(); + let subnet_offset = netuids + .iter() + .position(|netuid| Some(*netuid) == first_netuid) + .unwrap_or_default() as u32; + + ( + coldkey, + StakeValueProgress { + subnet_offset, + accumulated_tao: 0, + }, + ) + } + + fn set_members(collective_id: CollectiveId, members: Vec) { + MultiCollective::::set_members( + frame_system::RawOrigin::Root.into(), + collective_id, + members, + ) + .unwrap(); + } + + fn members_of(collective_id: CollectiveId) -> Vec { + as pallet_multi_collective::CollectiveInspect< + AccountId, + CollectiveId, + >>::members_of(collective_id) + } + + fn expected_stored_members(mut members: Vec) -> Vec { + members.sort(); + members + } + + fn prepare_economic_rotation_state() -> Vec { + let eligible = (0..ECONOMIC_ELIGIBLE_SIZE) + .map(|index| { + let coldkey = account("EconomicEligibleColdkey", index, 0); + RootRegisteredEma::::insert( + &coldkey, + pallet_subtensor::root_registered::EmaState { + ema: U64F64::from_num(ECONOMIC_ELIGIBLE_SIZE - index), + samples: ECONOMIC_ELIGIBILITY_THRESHOLD, + }, + ); + coldkey + }) + .collect::>(); + set_members(CollectiveId::EconomicEligible, eligible); + + let old_members = (0..ECONOMIC_SIZE) + .map(|index| account("OldEconomicMember", index, 0)) + .collect::>(); + set_members(CollectiveId::Economic, old_members); + + TermManagement::top_validators(ECONOMIC_SIZE).0 + } + + fn prepare_building_rotation_state() -> Vec { + frame_system::Pallet::::set_block_number(MIN_SUBNET_AGE.saturating_add(1)); + + let old_members = (0..BUILDING_SIZE) + .map(|index| account("OldBuildingMember", index, 0)) + .collect::>(); + set_members(CollectiveId::Building, old_members); + + for subnet_index in 0..BUILDING_BENCHMARK_SUBNETS { + let netuid = NetUid::from(4_096_u16.saturating_add(subnet_index as u16)); + let owner_index = subnet_index % BUILDING_SIZE; + let owner: AccountId = account("BuildingOwner", owner_index, 0); + + Subtensor::::init_new_network(netuid, 1); + NetworkRegisteredAt::::insert(netuid, 0); + SubnetOwner::::insert(netuid, owner); + SubnetMovingPrice::::insert( + netuid, + I96F32::from_num(BUILDING_BENCHMARK_SUBNETS - subnet_index), + ); + } + + TermManagement::top_subnet_owners(BUILDING_SIZE, MIN_SUBNET_AGE).0 + } +} diff --git a/runtime/src/governance/collectives.rs b/runtime/src/governance/collectives.rs new file mode 100644 index 0000000000..c24ecc6291 --- /dev/null +++ b/runtime/src/governance/collectives.rs @@ -0,0 +1,194 @@ +use alloc::vec::Vec; + +use frame_support::pallet_prelude::*; +use pallet_multi_collective::{ + Collective, CollectiveInfo, CollectiveInspect, CollectivesInfo, + weights::WeightInfo as MultiCollectiveWeightInfo, +}; +use pallet_subtensor::root_registered::{OnRootRegistrationChange, RootRegisteredInspector}; +use runtime_common::prod_or_fast; +use subtensor_runtime_common::{pad_name, time::DAYS}; + +use crate::{AccountId, BlockNumber, Runtime}; + +/// Keeps fresh subnet launches out of the Building rotation. +pub const MIN_SUBNET_AGE: BlockNumber = prod_or_fast!(180 * DAYS, 100); + +/// Voting seats rotated into the Economic collective. +pub const ECONOMIC_SIZE: u32 = 16; + +/// Voting seats rotated into the Building collective. +pub const BUILDING_SIZE: u32 = 16; + +/// Cap on the EconomicEligible collective. Equal to the root subnet's +/// maximum UID count: membership mirrors the set of coldkeys with at +/// least one root-registered hotkey, so the worst case is one distinct +/// coldkey per root UID. +pub const ECONOMIC_ELIGIBLE_SIZE: u32 = 64; + +/// Rotation cadence for ranked collectives. +const TERM_DURATION: BlockNumber = prod_or_fast!(60 * DAYS, 100); + +/// Stable collective ids. Codec indices are consensus-facing. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum CollectiveId { + /// Accounts authorized to submit proposals on the triumvirate track. + #[codec(index = 0)] + Proposers, + /// Three-member approval body for track 0. + #[codec(index = 1)] + Triumvirate, + /// Top validators: one half of the collective oversight voter set. + #[codec(index = 2)] + Economic, + /// Top subnet owners: one half of the collective oversight voter set. + #[codec(index = 3)] + Building, + /// Staging set for the Economic collective. Membership is driven by + /// `do_root_register` in `pallet-subtensor`; each rotation projects + /// the top-`ECONOMIC_SIZE` from here into `Economic`. + #[codec(index = 4)] + EconomicEligible, +} + +pub struct Collectives; +impl CollectivesInfo for Collectives { + type Id = CollectiveId; + + fn collectives() -> impl Iterator> { + [ + Collective { + id: CollectiveId::Proposers, + info: CollectiveInfo { + name: pad_name(b"proposers"), + min_members: 1, + max_members: Some(20), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Triumvirate, + info: CollectiveInfo { + name: pad_name(b"triumvirate"), + min_members: 3, + max_members: Some(3), + term_duration: None, + }, + }, + Collective { + id: CollectiveId::Economic, + info: CollectiveInfo { + name: pad_name(b"economic"), + min_members: ECONOMIC_SIZE, + max_members: Some(ECONOMIC_SIZE), + term_duration: Some(TERM_DURATION), + }, + }, + Collective { + id: CollectiveId::Building, + info: CollectiveInfo { + name: pad_name(b"building"), + min_members: BUILDING_SIZE, + max_members: Some(BUILDING_SIZE), + term_duration: Some(TERM_DURATION), + }, + }, + Collective { + id: CollectiveId::EconomicEligible, + info: CollectiveInfo { + name: pad_name(b"economic_eligible"), + min_members: 0, + max_members: Some(ECONOMIC_ELIGIBLE_SIZE), + term_duration: None, + }, + }, + ] + .into_iter() + } +} + +/// Keeps the Economic eligibility pool aligned with root registration. +/// +/// Failures are logged instead of blocking root-register or hotkey-swap +/// calls; `try_state` checks the invariant afterwards. +pub struct EconomicEligibleSync; + +impl OnRootRegistrationChange for EconomicEligibleSync { + fn on_added(coldkey: &AccountId) { + if let Err(err) = pallet_multi_collective::Pallet::::do_add_member( + CollectiveId::EconomicEligible, + coldkey.clone(), + ) { + log::error!( + target: "runtime::economic-eligible-sync", + "do_add_member failed for {:?}: {:?}", + coldkey, + err, + ); + } + } + + fn on_removed(coldkey: &AccountId) { + if let Err(err) = pallet_multi_collective::Pallet::::do_remove_member( + CollectiveId::EconomicEligible, + coldkey.clone(), + ) { + log::error!( + target: "runtime::economic-eligible-sync", + "do_remove_member failed for {:?}: {:?}", + coldkey, + err, + ); + } + } + + fn on_added_weight() -> Weight { + ::WeightInfo::do_add_member() + } + + fn on_removed_weight() -> Weight { + ::WeightInfo::do_remove_member() + } +} + +/// Lets `pallet-subtensor` verify its root-registration invariant. +pub struct EconomicEligibleInspector; + +impl RootRegisteredInspector for EconomicEligibleInspector { + fn members() -> Option> { + Some( + as CollectiveInspect< + AccountId, + CollectiveId, + >>::members_of(CollectiveId::EconomicEligible), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codec::Encode; + + #[test] + fn collective_id_codec_indices_are_pinned() { + assert_eq!(CollectiveId::Proposers.encode(), vec![0]); + assert_eq!(CollectiveId::Triumvirate.encode(), vec![1]); + assert_eq!(CollectiveId::Economic.encode(), vec![2]); + assert_eq!(CollectiveId::Building.encode(), vec![3]); + assert_eq!(CollectiveId::EconomicEligible.encode(), vec![4]); + } +} diff --git a/runtime/src/governance/ema_provider.rs b/runtime/src/governance/ema_provider.rs new file mode 100644 index 0000000000..8bc7ead29b --- /dev/null +++ b/runtime/src/governance/ema_provider.rs @@ -0,0 +1,415 @@ +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::{traits::fungible::Inspect, weights::Weight}; +use pallet_subtensor::{ + Pallet as Subtensor, + root_registered::{EmaValueProvider, SampleStep}, + *, +}; +use scale_info::TypeInfo; +use sp_runtime::traits::UniqueSaturatedInto; +use substrate_fixed::types::U64F64; +use subtensor_runtime_common::NetUid; +use subtensor_swap_interface::{Order, SwapHandler}; + +use super::weights::WeightInfo; +use crate::{AccountId, Runtime}; + +/// Number of subnets folded into the stake-value accumulator per tick. +pub(crate) const STAKE_CHUNK_SUBNETS: u32 = 8; + +/// Maximum owned hotkeys valued for one governance stake EMA sample. +pub(crate) const STAKE_VALUE_HOTKEYS: u32 = 256; + +/// Provider-owned progress for the governance stake-value EMA. +#[subtensor_macros::freeze_struct("1a8d9e6e7d73e9d3")] +#[derive( + Clone, + Copy, + Default, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub struct StakeValueProgress { + /// Subnet offset processed so far. + pub subnet_offset: u32, + /// Running TAO accumulator for processed subnet chunks. + pub accumulated_tao: u128, +} + +/// Governance stake-value provider: each root-registered coldkey's sample +/// is the TAO value of its liquid balance plus the alpha held across all +/// owned hotkeys on every subnet. +pub struct StakeValueProvider; + +impl StakeValueProvider { + fn subnet_chunk(netuids: &[NetUid], offset: u32) -> &[NetUid] { + let start: usize = offset.unique_saturated_into(); + let start = start.min(netuids.len()); + let netuids_len: u32 = netuids.len().unique_saturated_into(); + let end: usize = offset + .saturating_add(STAKE_CHUNK_SUBNETS) + .min(netuids_len) + .unique_saturated_into(); + netuids.get(start..end).unwrap_or_default() + } + + fn accumulate_subnet_values( + hotkeys: &[AccountId], + netuids: &[NetUid], + accumulated_tao: u128, + ) -> u128 { + netuids.iter().fold(accumulated_tao, |total, netuid| { + total.saturating_add(Self::tao_for_subnet_hotkeys(hotkeys, *netuid)) + }) + } + + fn tao_for_subnet_hotkeys(hotkeys: &[AccountId], netuid: NetUid) -> u128 { + let hotkey_limit: usize = STAKE_VALUE_HOTKEYS.unique_saturated_into(); + let total_alpha = hotkeys + .iter() + .take(hotkey_limit) + .fold(0_u128, |total, hotkey| { + let alpha = Subtensor::::get_stake_for_hotkey_on_subnet(hotkey, netuid); + total.saturating_add(u128::from(u64::from(alpha))) + }); + + if total_alpha == 0 { + return 0; + } + + let aggregated: u64 = total_alpha + .min(u128::from(u64::MAX)) + .unique_saturated_into(); + let order = GetTaoForAlpha::::with_amount(aggregated); + ::SwapInterface::sim_swap(netuid.into(), order) + .map(|r| u128::from(u64::from(r.amount_paid_out))) + .unwrap_or_default() + } +} + +impl EmaValueProvider for StakeValueProvider { + type Progress = StakeValueProgress; + + /// Advances one chunk of subnet valuation for `coldkey`, carrying the + /// accumulated TAO value in `Progress` until all subnets are sampled. + fn step(coldkey: &AccountId, progress: Self::Progress) -> (SampleStep, Weight) { + let netuids = Subtensor::::get_all_subnet_netuids(); + let total: u32 = netuids.len().unique_saturated_into(); + let hotkeys = OwnedHotkeys::::get(coldkey); + + let mut next = progress; + if next.subnet_offset < total { + let chunk = Self::subnet_chunk(&netuids, next.subnet_offset); + next.accumulated_tao = + Self::accumulate_subnet_values(&hotkeys, chunk, next.accumulated_tao); + let chunk_len: u32 = chunk.len().unique_saturated_into(); + next.subnet_offset = next.subnet_offset.saturating_add(chunk_len).min(total); + } + + let step = if next.subnet_offset >= total { + let liquid = u128::from(u64::from(::Currency::balance(coldkey))); + let sample = U64F64::saturating_from_num(next.accumulated_tao.saturating_add(liquid)); + SampleStep::Complete { sample } + } else { + SampleStep::Continue { progress: next } + }; + + (step, Self::step_weight()) + } + + fn step_weight() -> Weight { + super::weights::SubstrateWeight::::stake_ema_provider_step() + } +} + +#[cfg(test)] +#[allow(clippy::indexing_slicing)] +mod tests { + use super::*; + + use frame_support::traits::fungible::Mutate; + use sp_runtime::BuildStorage; + use subtensor_runtime_common::{AlphaBalance, TaoBalance}; + + fn new_test_ext() -> sp_io::TestExternalities { + let storage = match (crate::RuntimeGenesisConfig { + sudo: pallet_sudo::GenesisConfig { key: None }, + ..Default::default() + }) + .build_storage() + { + Ok(storage) => storage, + Err(err) => panic!("failed to build test storage: {err:?}"), + }; + let mut ext: sp_io::TestExternalities = storage.into(); + ext.execute_with(|| crate::System::set_block_number(1)); + ext + } + + fn account(seed: u8) -> AccountId { + AccountId::from([seed; 32]) + } + + fn indexed_account(index: u32) -> AccountId { + let mut bytes = [0; 32]; + bytes[..4].copy_from_slice(&index.to_le_bytes()); + AccountId::from(bytes) + } + + fn add_balance(coldkey: &AccountId, amount: u64) { + assert!( + ::Currency::mint_into(coldkey, TaoBalance::from(amount)).is_ok() + ); + } + + fn seed_subnet(netuid: NetUid) { + Subtensor::::init_new_network(netuid, 1); + SubtokenEnabled::::insert(netuid, true); + SubnetTAO::::insert(netuid, TaoBalance::from(1_000_000_000_u64)); + SubnetAlphaIn::::insert(netuid, AlphaBalance::from(1_000_000_000_u64)); + } + + fn progress_at(netuid: NetUid, accumulated_tao: u128) -> StakeValueProgress { + let netuids = Subtensor::::get_all_subnet_netuids(); + let Some(offset) = netuids.iter().position(|candidate| *candidate == netuid) else { + panic!("seeded subnet {netuid:?} is not in the subnet list"); + }; + StakeValueProgress { + subnet_offset: offset as u32, + accumulated_tao, + } + } + + fn complete_sample(step: SampleStep) -> U64F64 { + match step { + SampleStep::Complete { sample } => sample, + SampleStep::Continue { progress } => { + panic!("expected complete sample, got progress {progress:?}") + } + } + } + + fn continued_progress(step: SampleStep) -> StakeValueProgress { + match step { + SampleStep::Continue { progress } => progress, + SampleStep::Complete { sample } => { + panic!("expected continued sample, got complete sample {sample:?}") + } + } + } + + #[test] + fn step_completes_with_liquid_balance_when_there_are_no_subnets() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + add_balance(&coldkey, 1_000); + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + + assert_eq!(complete_sample(step), U64F64::from_num(1_000)); + assert_eq!(weight, StakeValueProvider::step_weight()); + }); + } + + #[test] + fn step_continues_after_one_subnet_chunk_when_more_subnets_remain() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + for index in 0..=STAKE_CHUNK_SUBNETS { + seed_subnet(NetUid::from(1_000_u16 + index as u16)); + } + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + let progress = continued_progress(step); + + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS); + assert_eq!(progress.accumulated_tao, 0); + assert_eq!(weight, StakeValueProvider::step_weight()); + }); + } + + #[test] + fn step_accumulates_multiple_chunks_with_many_hotkeys_until_complete() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let hotkeys = vec![account(2), account(3), account(4), account(5)]; + let unowned_hotkey = account(6); + let liquid = 1_000_u128; + add_balance(&coldkey, liquid as u64); + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + let subnet_count = STAKE_CHUNK_SUBNETS * 2 + 1; + for index in 0..subnet_count { + seed_subnet(NetUid::from(1_000_u16 + index as u16)); + } + + let netuids = Subtensor::::get_all_subnet_netuids(); + assert!(netuids.len() > (STAKE_CHUNK_SUBNETS * 2) as usize); + assert!(netuids.len() <= (STAKE_CHUNK_SUBNETS * 3) as usize); + + let expected_by_subnet = netuids + .iter() + .enumerate() + .map(|(subnet_index, netuid)| { + let total_owned_alpha = + hotkeys + .iter() + .enumerate() + .fold(0_u64, |total, (hotkey_index, hotkey)| { + let alpha = + ((subnet_index as u64) + 1) * ((hotkey_index as u64) + 1) * 10; + TotalHotkeyAlpha::::insert( + hotkey.clone(), + *netuid, + AlphaBalance::from(alpha), + ); + total + alpha + }); + TotalHotkeyAlpha::::insert( + unowned_hotkey.clone(), + *netuid, + AlphaBalance::from(1_000_000_u64), + ); + assert!(total_owned_alpha > 0); + StakeValueProvider::tao_for_subnet_hotkeys(&hotkeys, *netuid) + }) + .collect::>(); + + let first_chunk_end = STAKE_CHUNK_SUBNETS as usize; + let second_chunk_end = (STAKE_CHUNK_SUBNETS * 2) as usize; + let expected_first_chunk = expected_by_subnet[..first_chunk_end] + .iter() + .copied() + .sum::(); + let expected_second_chunk = expected_by_subnet[first_chunk_end..second_chunk_end] + .iter() + .copied() + .sum::(); + let expected_final_chunk = expected_by_subnet[second_chunk_end..] + .iter() + .copied() + .sum::(); + + let (step, weight) = StakeValueProvider::step(&coldkey, StakeValueProgress::default()); + let progress = continued_progress(step); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS); + assert_eq!(progress.accumulated_tao, expected_first_chunk); + + let (step, weight) = StakeValueProvider::step(&coldkey, progress); + let progress = continued_progress(step); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!(progress.subnet_offset, STAKE_CHUNK_SUBNETS * 2); + assert_eq!( + progress.accumulated_tao, + expected_first_chunk + expected_second_chunk + ); + + let (step, weight) = StakeValueProvider::step(&coldkey, progress); + assert_eq!(weight, StakeValueProvider::step_weight()); + assert_eq!( + complete_sample(step), + U64F64::from_num( + expected_first_chunk + expected_second_chunk + expected_final_chunk + liquid, + ) + ); + }); + } + + #[test] + fn step_completes_from_resumed_progress_and_adds_liquid_balance() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + add_balance(&coldkey, 1_000); + + let progress = StakeValueProgress { + subnet_offset: u32::MAX, + accumulated_tao: 12, + }; + let (step, _) = StakeValueProvider::step(&coldkey, progress); + + assert_eq!(complete_sample(step), U64F64::from_num(1_012)); + }); + } + + #[test] + fn step_aggregates_owned_hotkey_alpha_for_the_current_subnet() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let hotkey_a = account(2); + let hotkey_b = account(3); + let hotkeys = vec![hotkey_a.clone(), hotkey_b.clone()]; + let unowned_hotkey = account(4); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + TotalHotkeyAlpha::::insert(hotkey_a, netuid, AlphaBalance::from(100_u64)); + TotalHotkeyAlpha::::insert(hotkey_b, netuid, AlphaBalance::from(200_u64)); + TotalHotkeyAlpha::::insert( + unowned_hotkey, + netuid, + AlphaBalance::from(900_u64), + ); + + let expected = StakeValueProvider::tao_for_subnet_hotkeys(&hotkeys, netuid); + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 0)); + + assert_eq!(complete_sample(step), U64F64::from_num(expected)); + }); + } + + #[test] + fn step_values_only_the_governance_hotkey_limit() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + let hotkeys = (0..=STAKE_VALUE_HOTKEYS) + .map(|index| indexed_account(index + 10)) + .collect::>(); + OwnedHotkeys::::insert(&coldkey, hotkeys.clone()); + + for (index, hotkey) in hotkeys.iter().enumerate() { + let alpha = if index < STAKE_VALUE_HOTKEYS as usize { + 1_u64 + } else { + 1_000_000_000_u64 + }; + TotalHotkeyAlpha::::insert( + hotkey.clone(), + netuid, + AlphaBalance::from(alpha), + ); + } + + let expected = StakeValueProvider::tao_for_subnet_hotkeys( + &hotkeys[..STAKE_VALUE_HOTKEYS as usize], + netuid, + ); + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 0)); + + assert_eq!(complete_sample(step), U64F64::from_num(expected)); + }); + } + + #[test] + fn step_carries_existing_accumulator_through_zero_alpha_subnets() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + let netuid = NetUid::from(1_000); + seed_subnet(netuid); + + let (step, _) = StakeValueProvider::step(&coldkey, progress_at(netuid, 77)); + + assert_eq!(complete_sample(step), U64F64::from_num(77)); + }); + } +} diff --git a/runtime/src/governance/member_set.rs b/runtime/src/governance/member_set.rs new file mode 100644 index 0000000000..4e06f2dff0 --- /dev/null +++ b/runtime/src/governance/member_set.rs @@ -0,0 +1,147 @@ +use alloc::vec::Vec; + +use pallet_multi_collective::CollectiveInspect; +use sp_runtime::traits::UniqueSaturatedInto; +use subtensor_runtime_common::SetLike; + +use crate::{AccountId, MultiCollective}; + +use super::collectives::CollectiveId; + +/// A voter or proposer set composed of one or more collectives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum MemberSet { + Single(CollectiveId), + Union(Vec), +} + +impl MemberSet { + fn contains_with(&self, who: &A, lookup: F) -> bool + where + F: Fn(CollectiveId, &A) -> bool, + { + match self { + Self::Single(id) => lookup(*id, who), + Self::Union(ids) => ids.iter().any(|id| lookup(*id, who)), + } + } + + // Union members can overlap across collectives; dedup so the count + // signed-voting captures as `total` reflects true cardinality and + // does not bias thresholds upward. + fn to_vec_with(&self, lookup: F) -> Vec + where + A: Ord, + F: Fn(CollectiveId) -> Vec, + { + match self { + Self::Single(id) => lookup(*id), + Self::Union(ids) => { + let mut accounts: Vec = Vec::new(); + for id in ids { + accounts.extend(lookup(*id)); + } + accounts.sort(); + accounts.dedup(); + accounts + } + } + } + + fn is_initialized_with(&self, lookup: F) -> bool + where + F: Fn(CollectiveId) -> bool, + { + match self { + Self::Single(id) => lookup(*id), + Self::Union(ids) if ids.is_empty() => true, + Self::Union(ids) => ids.iter().any(|id| lookup(*id)), + } + } +} + +impl SetLike for MemberSet { + fn contains(&self, who: &AccountId) -> bool { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.contains_with(who, |id, who| { + >::is_member(id, who) + }) + } + + fn len(&self) -> u32 { + self.to_vec().len().unique_saturated_into() + } + + fn is_initialized(&self) -> bool { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.is_initialized_with(>::is_initialized) + } + + fn to_vec(&self) -> Vec { + use CollectiveInspect as CI; + use MultiCollective as MC; + + self.to_vec_with(>::members_of) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make(ids: &[u32]) -> Vec { + ids.to_vec() + } + + #[test] + fn single_delegates_to_lookup() { + let set = MemberSet::Single(CollectiveId::Triumvirate); + let out = set.to_vec_with::(|id| match id { + CollectiveId::Triumvirate => make(&[1, 2, 3]), + _ => make(&[]), + }); + assert_eq!(out, vec![1, 2, 3]); + } + + #[test] + fn union_concatenates_and_dedups() { + let set = MemberSet::Union(alloc::vec![CollectiveId::Economic, CollectiveId::Building,]); + let out = set.to_vec_with::(|id| match id { + CollectiveId::Economic => make(&[1, 2, 3]), + CollectiveId::Building => make(&[3, 4, 5]), + _ => make(&[]), + }); + assert_eq!(out, vec![1, 2, 3, 4, 5]); + } + + #[test] + fn union_with_no_ids_is_empty() { + let set = MemberSet::Union(alloc::vec![]); + let out = set.to_vec_with::(|_| make(&[1, 2])); + assert!(out.is_empty()); + } + + #[test] + fn single_contains_uses_only_named_collective() { + let set = MemberSet::Single(CollectiveId::Proposers); + let lookup = |id: CollectiveId, who: &u32| -> bool { + matches!(id, CollectiveId::Proposers) && *who == 7 + }; + assert!(set.contains_with(&7, lookup)); + assert!(!set.contains_with(&8, lookup)); + } + + #[test] + fn union_contains_short_circuits_on_first_match() { + let set = MemberSet::Union(alloc::vec![CollectiveId::Economic, CollectiveId::Building,]); + let lookup = |id: CollectiveId, who: &u32| -> bool { + matches!(id, CollectiveId::Building) && *who == 42 + }; + assert!(set.contains_with(&42, lookup)); + assert!(!set.contains_with(&1, lookup)); + } +} diff --git a/runtime/src/governance/mod.rs b/runtime/src/governance/mod.rs new file mode 100644 index 0000000000..99b02316d7 --- /dev/null +++ b/runtime/src/governance/mod.rs @@ -0,0 +1,301 @@ +//! Runtime governance wiring. +//! +//! This module connects Subtensor's concrete governance model to three +//! generic pallets: +//! +//! - `pallet_multi_collective`: stores named membership sets. +//! - `pallet_referenda`: owns proposal lifecycle, scheduling, and root dispatch. +//! - `pallet_signed_voting`: records per-account aye/nay votes over referendum +//! voter-set snapshots. +//! +//! The runtime governance path is intentionally two-stage: +//! +//! 1. Track 0 (`triumvirate`) is the only directly-submittable track. Members +//! of the `Proposers` collective may submit root calls, and the +//! `Triumvirate` collective decides by 2-of-3 signed vote. +//! 2. Approval on track 0 delegates the call to track 1 (`review`). Track 1 has +//! `proposer_set: None`, so it cannot be submitted to directly. Its voters +//! are the deduplicated union of the `Economic` and `Building` collectives. +//! +//! Collective selection is split by stakeholder role: +//! +//! - `Economic` rotates to the top root-registered coldkeys by governance +//! stake-value EMA. +//! - `Building` rotates to the top subnet-owner coldkeys by each owner's best +//! mature subnet moving price. +//! - `EconomicEligible` is a non-voting staging set synchronized from root +//! registration and used as the candidate pool for `Economic`. +//! +//! Keep the safety invariants close to the code: +//! +//! - `CollectiveId` codec indices are consensus-facing. +//! - Track 1 must remain non-submittable; otherwise proposers could bypass +//! Triumvirate approval and schedule root calls straight into review. +//! - Signed-voting snapshots voter sets at poll creation, so rotations do not +//! change eligibility for already-open referenda. +//! +//! See `runtime/src/governance/README.md` for the full operator-facing +//! explanation and selection details. + +mod collectives; +mod ema_provider; +mod member_set; +mod term_management; +mod tracks; +mod weights; + +#[cfg(feature = "runtime-benchmarks")] +pub mod benchmarking; + +pub use self::collectives::*; +pub use self::ema_provider::*; +pub use self::member_set::*; +pub use self::term_management::*; +pub use self::tracks::*; + +use codec::{Decode, DecodeWithMemTracking, Encode, MaxEncodedLen}; +use frame_support::parameter_types; +use frame_support::traits::AsEnsureOriginWithArg; +use frame_system::EnsureRoot; +use scale_info::TypeInfo; + +use crate::{ + AccountId, Preimage, Referenda, Runtime, RuntimeCall, Scheduler, SignedVoting, System, +}; + +parameter_types! { + /// Storage cap shared by all collectives; sized for the widest one + /// (`EconomicEligible`). Per-collective `info.max_members` are the + /// logical caps; this is just the `BoundedVec` capacity. + pub const MaxMembers: u32 = collectives::ECONOMIC_ELIGIBLE_SIZE; +} + +impl pallet_multi_collective::Config for Runtime { + type CollectiveId = CollectiveId; + type Collectives = Collectives; + type AddOrigin = AsEnsureOriginWithArg>; + type RemoveOrigin = AsEnsureOriginWithArg>; + type SwapOrigin = AsEnsureOriginWithArg>; + type SetOrigin = AsEnsureOriginWithArg>; + type RotateOrigin = AsEnsureOriginWithArg>; + type OnMembersChanged = (); + type OnNewTerm = TermManagement; + type MaxMembers = MaxMembers; + type WeightInfo = pallet_multi_collective::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = MultiCollectiveBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct MultiCollectiveBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_multi_collective::BenchmarkHelper for MultiCollectiveBenchmarkHelper { + fn collective() -> CollectiveId { + CollectiveId::EconomicEligible + } + + fn rotatable_collective() -> CollectiveId { + CollectiveId::Economic + } +} + +/// Voting scheme for each referenda track. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + Debug, + Encode, + Decode, + DecodeWithMemTracking, + MaxEncodedLen, + TypeInfo, +)] +pub enum VotingScheme { + Signed, +} + +parameter_types! { + pub const Scheme: VotingScheme = VotingScheme::Signed; + /// Headroom over the widest track's voter set (see guard below). + pub const MaxVoterSetSize: u32 = 64; + /// 2x `MaxQueued` for headroom; queue overflow leaks `VotingFor` storage. + pub const MaxPendingCleanup: u32 = 40; + /// `VotingFor` entries drained per `on_idle` step. A full poll drains + /// in `MaxVoterSetSize / CleanupChunkSize` idle blocks. + pub const CleanupChunkSize: u32 = 16; + /// Resume cursor for chunked cleanup; 128 bytes covers any FRAME + /// double-map partial trie key. + pub const CleanupCursorMaxLen: u32 = 128; +} + +impl pallet_signed_voting::Config for Runtime { + type Scheme = Scheme; + type Polls = Referenda; + type MaxVoterSetSize = MaxVoterSetSize; + type MaxPendingCleanup = MaxPendingCleanup; + type CleanupChunkSize = CleanupChunkSize; + type CleanupCursorMaxLen = CleanupCursorMaxLen; + type WeightInfo = pallet_signed_voting::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = SignedVotingBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct SignedVotingBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +impl pallet_signed_voting::benchmarking::BenchmarkHelper for SignedVotingBenchmarkHelper { + #[allow(clippy::expect_used)] + fn ongoing_poll() -> u32 { + use self::ReferendaBenchmarkHelper as RBH; + use pallet_referenda::{ + BenchmarkHelper as BH, ReferendumCount, ReferendumStatus, ReferendumStatusFor, + }; + use sp_runtime::Perbill; + use subtensor_runtime_common::VoteTally; + + let proposer = >::proposer(); + >::seed_collective_members(); + let track = >::track_passorfail(); + let call = >::call(); + let parent = ReferendumCount::::get(); + + Referenda::submit( + frame_system::RawOrigin::Signed(proposer).into(), + track, + sp_std::boxed::Box::new(call), + ) + .expect("submit must succeed in benchmark setup"); + + let child = ReferendumCount::::get(); + let mut info = match ReferendumStatusFor::::get(parent) { + Some(ReferendumStatus::Ongoing(info)) => info, + _ => panic!("expected ongoing referendum"), + }; + info.tally = VoteTally { + approval: Perbill::one(), + rejection: Perbill::zero(), + abstention: Perbill::zero(), + }; + ReferendumStatusFor::::insert(parent, ReferendumStatus::Ongoing(info)); + + Referenda::advance_referendum(frame_system::RawOrigin::Root.into(), parent) + .expect("advance must create review poll in benchmark setup"); + assert!(matches!( + ReferendumStatusFor::::get(child), + Some(ReferendumStatus::Ongoing(_)) + )); + child + } +} + +parameter_types! { + pub const MaxQueued: u32 = 20; + pub const MaxActivePerProposer: u32 = 5; +} + +impl pallet_referenda::Config for Runtime { + type RuntimeCall = RuntimeCall; + type Scheduler = Scheduler; + type Preimages = Preimage; + type MaxQueued = MaxQueued; + type MaxActivePerProposer = MaxActivePerProposer; + type KillOrigin = EnsureRoot; + type Tracks = tracks::Tracks; + type AdjustmentCurve = tracks::EaseOutAdjustmentCurve; + type BlockNumberProvider = System; + type OnPollCreated = SignedVoting; + type OnPollCompleted = SignedVoting; + type WeightInfo = pallet_referenda::weights::SubstrateWeight; + #[cfg(feature = "runtime-benchmarks")] + type BenchmarkHelper = ReferendaBenchmarkHelper; +} + +#[cfg(feature = "runtime-benchmarks")] +pub struct ReferendaBenchmarkHelper; + +#[cfg(feature = "runtime-benchmarks")] +#[allow(clippy::expect_used)] +impl pallet_referenda::BenchmarkHelper for ReferendaBenchmarkHelper { + fn track_passorfail() -> u8 { + 0 + } + + fn track_adjustable() -> u8 { + 1 + } + + fn proposer() -> AccountId { + use frame_system::RawOrigin; + use pallet_multi_collective::Pallet as MultiCollective; + use sp_core::crypto::AccountId32; + + let proposer: AccountId = AccountId32::new([1u8; 32]).into(); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Proposers, + proposer.clone(), + ) + .expect("add proposer must succeed in benchmark setup"); + + proposer + } + + fn seed_collective_members() { + use frame_system::RawOrigin; + use pallet_multi_collective::Pallet as MultiCollective; + use sp_core::crypto::AccountId32; + + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Triumvirate, + AccountId32::new([2u8; 32]).into(), + ) + .expect("add triumvirate member must succeed in benchmark setup"); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Economic, + AccountId32::new([3u8; 32]).into(), + ) + .expect("add economic member must succeed in benchmark setup"); + MultiCollective::::add_member( + RawOrigin::Root.into(), + CollectiveId::Building, + AccountId32::new([4u8; 32]).into(), + ) + .expect("add building member must succeed in benchmark setup"); + } + + fn call() -> RuntimeCall { + RuntimeCall::System(frame_system::Call::remark { + remark: alloc::vec![], + }) + } +} + +// Compile-time guards on the relationships between the constants above. +// A misconfiguration here would degrade signed-voting silently (oversized +// voter set collapses to an empty snapshot, queue overflow leaks state), +// so catch the obvious foot-guns at build time. +const _: () = { + // The widest track today is `Union(Economic, Building)`. Union members + // can overlap (a coldkey may sit in both), so this sum is an upper + // bound on the voter set's true cardinality before `MemberSet::Union`'s + // dedup runs. + let widest_union = (collectives::ECONOMIC_SIZE as u64) + (collectives::BUILDING_SIZE as u64); + assert!( + MaxVoterSetSize::get() as u64 >= widest_union, + "MaxVoterSetSize must fit the widest track's voter set", + ); + assert!( + MaxVoterSetSize::get() >= MaxMembers::get(), + "MaxVoterSetSize must fit any single-collective track", + ); + assert!( + MaxPendingCleanup::get() >= MaxQueued::get(), + "MaxPendingCleanup must absorb at least one full simultaneous-completion event from `pallet-referenda`", + ); +}; diff --git a/runtime/src/governance/term_management.rs b/runtime/src/governance/term_management.rs new file mode 100644 index 0000000000..fc1437c4b8 --- /dev/null +++ b/runtime/src/governance/term_management.rs @@ -0,0 +1,430 @@ +use alloc::vec::Vec; + +use frame_support::pallet_prelude::*; +use pallet_multi_collective::{ + CollectiveInspect, OnNewTerm, Pallet as MultiCollective, + weights::WeightInfo as MultiCollectiveWeightInfo, +}; +use pallet_subtensor::{Pallet as Subtensor, *}; +use sp_runtime::traits::UniqueSaturatedInto; +use substrate_fixed::types::{I96F32, U64F64}; + +use crate::{AccountId, BlockNumber, Runtime}; + +use super::collectives::{BUILDING_SIZE, CollectiveId, ECONOMIC_SIZE, MIN_SUBNET_AGE}; +use super::weights::{SubstrateWeight as GovernanceWeight, WeightInfo as GovernanceWeightInfo}; + +/// Minimum root-registered EMA samples before Economic eligibility. +/// With the current sampler cadence, 210 is roughly 30 days. +pub const ECONOMIC_ELIGIBILITY_THRESHOLD: u32 = 210; + +/// Runtime rotation policy for rotating collectives. +pub struct TermManagement; + +impl OnNewTerm for TermManagement { + fn weight() -> Weight { + [ + GovernanceWeight::::rotate_economic(), + GovernanceWeight::::rotate_building(), + ] + .into_iter() + .max_by_key(Weight::ref_time) + .unwrap_or_default() + } + + fn on_new_term(collective_id: CollectiveId) -> Weight { + // Curated collectives are managed outside this rotation policy. + match collective_id { + CollectiveId::Economic => Self::rotate_economic(), + CollectiveId::Building => Self::rotate_building(), + _ => Weight::zero(), + } + } +} + +impl TermManagement { + pub(crate) fn rotate_economic() -> Weight { + let (members, query_weight) = Self::top_validators(ECONOMIC_SIZE); + Self::apply_rotation(CollectiveId::Economic, members, query_weight) + } + + pub(crate) fn rotate_building() -> Weight { + let (members, query_weight) = Self::top_subnet_owners(BUILDING_SIZE, MIN_SUBNET_AGE); + Self::apply_rotation(CollectiveId::Building, members, query_weight) + } + + /// Top validator coldkeys by smoothed root-registered value. + pub fn top_validators(n: u32) -> (Vec, Weight) { + let db = ::DbWeight::get(); + let eligible = + as CollectiveInspect>::members_of( + CollectiveId::EconomicEligible, + ); + let mut weight = db.reads(1); + + let entries: Vec<(AccountId, U64F64)> = eligible + .into_iter() + .filter_map(|coldkey| { + weight.saturating_accrue(db.reads(1)); + let state = RootRegisteredEma::::get(&coldkey); + (state.samples >= ECONOMIC_ELIGIBILITY_THRESHOLD).then_some((coldkey, state.ema)) + }) + .collect(); + + (rank_top_n(entries, n), weight) + } + + /// Top subnet-owner coldkeys by their best mature subnet price. + pub fn top_subnet_owners(n: u32, min_age: BlockNumber) -> (Vec, Weight) { + let mut weight = Weight::zero(); + let now: u64 = >::block_number().into(); + let min_age_u64: u64 = min_age.into(); + + let mut entries: Vec<(AccountId, I96F32)> = Vec::new(); + for netuid in Subtensor::::get_all_subnet_netuids() { + weight.saturating_accrue(::DbWeight::get().reads(3)); + let registered_at: u64 = NetworkRegisteredAt::::get(netuid); + if now.saturating_sub(registered_at) < min_age_u64 { + continue; + } + let price = SubnetMovingPrice::::get(netuid); + let owner = SubnetOwner::::get(netuid); + merge_owner_by_highest_price(&mut entries, owner, price); + } + + (rank_top_n(entries, n), weight) + } + + /// Apply a rotated membership through the collective pallet. + fn apply_rotation( + collective_id: CollectiveId, + members: Vec, + query_weight: Weight, + ) -> Weight { + let result = MultiCollective::::do_set_members(collective_id, members); + + if let Err(err) = result { + log::error!( + target: "runtime::collective-management", + "rotation failed for {:?}: {:?}", + collective_id, + err, + ); + } + + query_weight + .saturating_add(::WeightInfo::set_members()) + } +} + +/// Sort by descending score and return the first `n` keys. +fn rank_top_n(mut entries: Vec<(K, S)>, n: u32) -> Vec { + entries.sort_by(|a, b| b.1.cmp(&a.1)); + entries.truncate(n.unique_saturated_into()); + entries.into_iter().map(|(k, _)| k).collect() +} + +/// Keep only an owner's highest observed subnet price. +fn merge_owner_by_highest_price( + entries: &mut Vec<(A, I96F32)>, + owner: A, + price: I96F32, +) { + if let Some(existing) = entries.iter_mut().find(|(o, _)| *o == owner) { + if price > existing.1 { + existing.1 = price; + } + } else { + entries.push((owner, price)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use pallet_subtensor::root_registered::EmaState; + use sp_runtime::BuildStorage; + use subtensor_runtime_common::NetUid; + + fn new_test_ext() -> sp_io::TestExternalities { + let storage = match (crate::RuntimeGenesisConfig { + sudo: pallet_sudo::GenesisConfig { key: None }, + ..Default::default() + }) + .build_storage() + { + Ok(storage) => storage, + Err(err) => panic!("failed to build test storage: {err:?}"), + }; + let mut ext: sp_io::TestExternalities = storage.into(); + ext.execute_with(|| crate::System::set_block_number(1)); + ext + } + + fn account(seed: u8) -> AccountId { + AccountId::from([seed; 32]) + } + + fn accounts(start: u8, count: u32) -> Vec { + (0..count) + .map(|offset| account(start + offset as u8)) + .collect() + } + + fn rank_entry(key: u32, score: u64) -> (u32, U64F64) { + (key, U64F64::from_num(score)) + } + + fn price(value: i64) -> I96F32 { + I96F32::from_num(value) + } + + fn set_members(collective_id: CollectiveId, members: Vec) { + assert!( + MultiCollective::::set_members( + frame_system::RawOrigin::Root.into(), + collective_id, + members, + ) + .is_ok() + ); + } + + fn members_of(collective_id: CollectiveId) -> Vec { + as CollectiveInspect>::members_of( + collective_id, + ) + } + + fn set_ema(coldkey: &AccountId, ema: u64, samples: u32) { + RootRegisteredEma::::insert( + coldkey, + EmaState { + ema: U64F64::from_num(ema), + samples, + }, + ); + } + + fn seed_subnet(netuid: NetUid, owner: AccountId, price: i64, registered_at: u64) { + Subtensor::::init_new_network(netuid, 1); + NetworkRegisteredAt::::insert(netuid, registered_at); + SubnetMovingPrice::::insert(netuid, I96F32::from_num(price)); + SubnetOwner::::insert(netuid, owner); + } + + #[test] + fn rank_top_n_truncates_to_n() { + let result = rank_top_n( + vec![ + rank_entry(1, 10), + rank_entry(2, 30), + rank_entry(3, 20), + rank_entry(4, 40), + ], + 2, + ); + assert_eq!(result, vec![4, 2]); + } + + #[test] + fn rank_top_n_zero_returns_empty() { + let result = rank_top_n(vec![rank_entry(1, 10), rank_entry(2, 30)], 0); + assert!(result.is_empty()); + } + + #[test] + fn rank_top_n_larger_than_input_returns_all_sorted() { + let result = rank_top_n(vec![rank_entry(1, 10), rank_entry(2, 30)], 100); + assert_eq!(result, vec![2, 1]); + } + + #[test] + fn rank_top_n_empty_input_returns_empty() { + let result = rank_top_n::(vec![], 5); + assert!(result.is_empty()); + } + + #[test] + fn rank_top_n_ties_preserve_insertion_order() { + let result = rank_top_n( + vec![rank_entry(1, 10), rank_entry(2, 10), rank_entry(3, 10)], + 2, + ); + assert_eq!(result, vec![1, 2]); + } + + #[test] + fn merge_inserts_first_observation() { + let mut entries: Vec<(u32, I96F32)> = Vec::new(); + merge_owner_by_highest_price(&mut entries, 7, price(100)); + assert_eq!(entries, vec![(7, price(100))]); + } + + #[test] + fn merge_upgrades_to_higher_price_for_same_owner() { + let mut entries = vec![(7, price(100))]; + merge_owner_by_highest_price(&mut entries, 7, price(250)); + assert_eq!(entries, vec![(7, price(250))]); + } + + #[test] + fn merge_keeps_existing_when_new_price_lower() { + let mut entries = vec![(7, price(250))]; + merge_owner_by_highest_price(&mut entries, 7, price(100)); + assert_eq!(entries, vec![(7, price(250))]); + } + + #[test] + fn merge_keeps_one_entry_with_highest_price_for_owner_with_multiple_subnets() { + let mut entries: Vec<(u32, I96F32)> = Vec::new(); + merge_owner_by_highest_price(&mut entries, 7, price(100)); + merge_owner_by_highest_price(&mut entries, 8, price(200)); + merge_owner_by_highest_price(&mut entries, 7, price(300)); + assert_eq!(entries, vec![(7, price(300)), (8, price(200))]); + } + + #[test] + fn top_validators_rank_by_ema_after_sample_threshold() { + new_test_ext().execute_with(|| { + let exact_threshold = account(1); + let above_threshold = account(2); + let below_threshold = account(3); + set_members( + CollectiveId::EconomicEligible, + vec![ + exact_threshold.clone(), + above_threshold.clone(), + below_threshold.clone(), + ], + ); + set_ema(&exact_threshold, 100, ECONOMIC_ELIGIBILITY_THRESHOLD); + set_ema( + &above_threshold, + 50, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_add(1), + ); + set_ema( + &below_threshold, + 1_000, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_sub(1), + ); + + let (members, weight) = TermManagement::top_validators(2); + + assert_eq!(members, vec![exact_threshold, above_threshold]); + assert!(weight.ref_time() > 0); + }); + } + + #[test] + fn top_validators_returns_empty_when_no_candidate_has_enough_samples() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + set_members(CollectiveId::EconomicEligible, vec![coldkey.clone()]); + set_ema( + &coldkey, + 1_000, + ECONOMIC_ELIGIBILITY_THRESHOLD.saturating_sub(1), + ); + + let (members, _) = TermManagement::top_validators(ECONOMIC_SIZE); + + assert!(members.is_empty()); + }); + } + + #[test] + fn top_validators_zero_limit_returns_empty() { + new_test_ext().execute_with(|| { + let coldkey = account(1); + set_members(CollectiveId::EconomicEligible, vec![coldkey.clone()]); + set_ema(&coldkey, 1_000, ECONOMIC_ELIGIBILITY_THRESHOLD); + + let (members, _) = TermManagement::top_validators(0); + + assert!(members.is_empty()); + }); + } + + #[test] + fn rotate_economic_keeps_old_members_when_validator_set_is_underfilled() { + new_test_ext().execute_with(|| { + let old_members = accounts(10, ECONOMIC_SIZE); + let candidate = account(1); + set_members(CollectiveId::Economic, old_members.clone()); + set_members(CollectiveId::EconomicEligible, vec![candidate.clone()]); + set_ema(&candidate, 1_000, ECONOMIC_ELIGIBILITY_THRESHOLD); + + let weight = TermManagement::rotate_economic(); + + assert!(weight.ref_time() > 0); + assert_eq!(members_of(CollectiveId::Economic), old_members); + }); + } + + #[test] + fn top_subnet_owners_ranks_best_mature_subnet_per_owner() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let owner_a = account(1); + let owner_b = account(2); + let immature_owner = account(3); + + seed_subnet(NetUid::from(1_000), owner_a.clone(), 10, 700); + seed_subnet(NetUid::from(1_001), owner_a.clone(), 30, 800); + seed_subnet(NetUid::from(1_002), owner_b.clone(), 20, 750); + seed_subnet(NetUid::from(1_003), immature_owner, 100, 950); + + let (members, weight) = TermManagement::top_subnet_owners(2, 100); + + assert_eq!(members, vec![owner_a, owner_b]); + assert!(weight.ref_time() > 0); + }); + } + + #[test] + fn rotate_building_keeps_old_members_when_owner_set_is_underfilled() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let old_members = accounts(20, BUILDING_SIZE); + let candidate = account(1); + set_members(CollectiveId::Building, old_members.clone()); + seed_subnet(NetUid::from(1_000), candidate, 10, 0); + + let weight = TermManagement::rotate_building(); + + assert!(weight.ref_time() > 0); + assert_eq!(members_of(CollectiveId::Building), old_members); + }); + } + + #[test] + fn top_subnet_owners_includes_exact_min_age_boundary() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + let exact_age_owner = account(1); + let too_young_owner = account(2); + + seed_subnet(NetUid::from(1_000), exact_age_owner.clone(), 10, 900); + seed_subnet(NetUid::from(1_001), too_young_owner, 100, 901); + + let (members, _) = TermManagement::top_subnet_owners(1, 100); + + assert_eq!(members, vec![exact_age_owner]); + }); + } + + #[test] + fn top_subnet_owners_zero_limit_returns_empty() { + new_test_ext().execute_with(|| { + crate::System::set_block_number(1_000); + seed_subnet(NetUid::from(1_000), account(1), 10, 0); + + let (members, _) = TermManagement::top_subnet_owners(0, 100); + + assert!(members.is_empty()); + }); + } +} diff --git a/runtime/src/governance/tracks.rs b/runtime/src/governance/tracks.rs new file mode 100644 index 0000000000..0556013f7e --- /dev/null +++ b/runtime/src/governance/tracks.rs @@ -0,0 +1,179 @@ +//! Static governance tracks: Triumvirate approval, then collective review. + +use pallet_referenda::{ + AdjustmentCurve, ApprovalAction, DecisionStrategy, MAX_TRACK_NAME_LEN, Track as RefTrack, + TrackInfo as RefTrackInfo, TracksInfo as RefTracksInfo, +}; +use runtime_common::prod_or_fast; +use safe_math::SafeDiv; +use sp_runtime::{Perbill, traits::UniqueSaturatedInto}; +use subtensor_runtime_common::{ + pad_name, + time::{DAYS, HOURS}, +}; + +use super::collectives::CollectiveId; +use super::{MemberSet, VotingScheme}; +use crate::{AccountId, BlockNumber, RuntimeCall}; + +const TRIUMVIRATE_DECISION_PERIOD: BlockNumber = prod_or_fast!(7 * DAYS, 50); + +const REVIEW_INITIAL_DELAY: BlockNumber = prod_or_fast!(24 * HOURS, 30); + +const TRIUMVIRATE_TRACK_ID: u8 = 0; +const REVIEW_TRACK_ID: u8 = 1; + +/// Upper bound on the Review dispatch delay, reached as net rejection +/// approaches `cancel_threshold`. +const REVIEW_MAX_DELAY: BlockNumber = prod_or_fast!(2 * DAYS, 60); + +/// Ease-out curve for review delay adjustment: `1 - (1 - p)^3`. +/// +/// Early collective signal has a visible effect on the dispatch time, while +/// additional votes near the threshold taper off before the hard fast-track +/// or cancel threshold concludes the referendum. +pub struct EaseOutAdjustmentCurve; +impl AdjustmentCurve for EaseOutAdjustmentCurve { + fn apply(progress: Perbill) -> Perbill { + let scale = u128::from(Perbill::from_percent(100).deconstruct()); + let remaining = scale.saturating_sub(u128::from(progress.deconstruct())); + let remaining_cubed = remaining + .saturating_mul(remaining) + .saturating_mul(remaining) + .safe_div(scale) + .safe_div(scale); + let curved = scale.saturating_sub(remaining_cubed); + + Perbill::from_parts(curved.min(scale).unique_saturated_into()) + } +} + +pub struct Tracks; +impl RefTracksInfo<[u8; MAX_TRACK_NAME_LEN], AccountId, RuntimeCall, BlockNumber> for Tracks { + type Id = u8; + type ProposerSet = MemberSet; + type VotingScheme = VotingScheme; + type VoterSet = MemberSet; + + fn tracks() -> impl Iterator< + Item = RefTrack< + Self::Id, + [u8; MAX_TRACK_NAME_LEN], + BlockNumber, + Self::ProposerSet, + Self::VoterSet, + Self::VotingScheme, + >, + > { + [ + RefTrack { + id: TRIUMVIRATE_TRACK_ID, + info: RefTrackInfo { + name: pad_name(b"triumvirate"), + proposer_set: Some(MemberSet::Single(CollectiveId::Proposers)), + voter_set: MemberSet::Single(CollectiveId::Triumvirate), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::PassOrFail { + decision_period: TRIUMVIRATE_DECISION_PERIOD, + approve_threshold: Perbill::from_rational(2u32, 3u32), + reject_threshold: Perbill::from_rational(2u32, 3u32), + // Triumvirate approval still gets a wider review + // window before enactment. + on_approval: ApprovalAction::Review { + track: REVIEW_TRACK_ID, + }, + }, + }, + }, + // `proposer_set: None` is load-bearing: it makes track 1 reachable + // only via Track 0's `ApprovalAction::Review` handoff. Setting it + // to `Some(_)` would let a proposer schedule a root call for + // auto-dispatch at `now + initial_delay`, bypassing Triumvirate + // approval. + RefTrack { + id: REVIEW_TRACK_ID, + info: RefTrackInfo { + name: pad_name(b"review"), + proposer_set: None, + voter_set: MemberSet::Union(alloc::vec![ + CollectiveId::Economic, + CollectiveId::Building, + ]), + voting_scheme: VotingScheme::Signed, + decision_strategy: DecisionStrategy::Adjustable { + initial_delay: REVIEW_INITIAL_DELAY, + max_delay: REVIEW_MAX_DELAY, + fast_track_threshold: Perbill::from_percent(75), + cancel_threshold: Perbill::from_percent(51), + }, + }, + }, + ] + .into_iter() + } +} + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use super::*; + use pallet_referenda::TracksInfo; + + fn track( + id: u8, + ) -> RefTrack + { + Tracks::tracks() + .find(|track| track.id == id) + .expect("track must exist") + } + + #[test] + fn track_0_triumvirate_is_directly_submittable() { + let track_0 = track(TRIUMVIRATE_TRACK_ID); + + assert!( + track_0.info.proposer_set.is_some(), + "track 0 must have a proposer_set; without it there is no \ + on-chain entry point into governance." + ); + + match track_0.info.decision_strategy { + DecisionStrategy::PassOrFail { + on_approval: ApprovalAction::Review { track }, + .. + } => assert_eq!( + track, REVIEW_TRACK_ID, + "track 0 approval must hand off to the review track" + ), + other => panic!("track 0 must stay PassOrFail with review handoff, got {other:?}"), + } + } + + #[test] + fn track_1_review_is_not_directly_submittable() { + let track_1 = track(REVIEW_TRACK_ID); + + assert!( + track_1.info.proposer_set.is_none(), + "track 1 must have proposer_set: None; Some(_) would let a \ + proposer schedule a root call without Triumvirate approval." + ); + } + + #[test] + fn ease_out_curve_uses_cubic_complement() { + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(0)), + Perbill::from_percent(0), + ); + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(50)), + Perbill::from_rational(7u32, 8u32), + ); + assert_eq!( + EaseOutAdjustmentCurve::apply(Perbill::from_percent(100)), + Perbill::from_percent(100), + ); + } +} diff --git a/runtime/src/governance/weights.rs b/runtime/src/governance/weights.rs new file mode 100644 index 0000000000..666bcc0dc7 --- /dev/null +++ b/runtime/src/governance/weights.rs @@ -0,0 +1,147 @@ + +//! Autogenerated weights for `governance` +//! +//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 49.1.0 +//! DATE: 2026-05-25, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]` +//! WORST CASE MAP SIZE: `1000000` +//! HOSTNAME: `runnervmg397c`, CPU: `AMD EPYC 7763 64-Core Processor` +//! WASM-EXECUTION: `Compiled`, CHAIN: `None`, DB CACHE: `1024` + +// Executed Command: +// /home/runner/work/subtensor/subtensor/target/production/node-subtensor +// benchmark +// pallet +// --runtime=/home/runner/work/subtensor/subtensor/target/production/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm +// --genesis-builder=runtime +// --genesis-builder-preset=benchmark +// --wasm-execution=compiled +// --pallet=governance +// --extrinsic=* +// --steps=50 +// --repeat=20 +// --no-storage-info +// --no-min-squares +// --no-median-slopes +// --output=/tmp/tmp.JF1LCW5Q9K +// --template=/home/runner/work/subtensor/subtensor/.maintain/frame-weight-template.hbs + +#![cfg_attr(rustfmt, rustfmt_skip)] +#![allow(unused_parens)] +#![allow(unused_imports)] +#![allow(missing_docs)] +#![allow(dead_code)] + +use frame_support::{traits::Get, weights::{Weight, constants::RocksDbWeight}}; +use core::marker::PhantomData; + +/// Weight functions needed for `governance`. +pub trait WeightInfo { + fn stake_ema_provider_step() -> Weight; + fn rotate_economic() -> Weight; + fn rotate_building() -> Weight; +} + +/// Weights for `governance` using the Substrate node and recommended hardware. +pub struct SubstrateWeight(PhantomData); +impl WeightInfo for SubstrateWeight { + /// Storage: `SubtensorModule::NetworksAdded` (r:11 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnedHotkeys` (r:1 w:0) + /// Proof: `SubtensorModule::OwnedHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:2048 w:0) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:7 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn stake_ema_provider_step() -> Weight { + // Proof Size summary in bytes: + // Measured: `48134` + // Estimated: `5117924` + // Minimum execution time: 6_809_228_000 picoseconds. + Weight::from_parts(6_912_642_000, 5117924) + .saturating_add(T::DbWeight::get().reads(2067_u64)) + } + /// Storage: `MultiCollective::Members` (r:2 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:64 w:0) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn rotate_economic() -> Weight { + // Proof Size summary in bytes: + // Measured: `7996` + // Estimated: `167386` + // Minimum execution time: 280_504_000 picoseconds. + Weight::from_parts(284_522_000, 167386) + .saturating_add(T::DbWeight::get().reads(66_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } + /// Storage: `SubtensorModule::NetworksAdded` (r:131 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:130 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn rotate_building() -> Weight { + // Proof Size summary in bytes: + // Measured: `11112` + // Estimated: `336327` + // Minimum execution time: 1_106_639_000 picoseconds. + Weight::from_parts(1_118_871_000, 336327) + .saturating_add(T::DbWeight::get().reads(522_u64)) + .saturating_add(T::DbWeight::get().writes(1_u64)) + } +} + +// For backwards compatibility and tests. +impl WeightInfo for () { + /// Storage: `SubtensorModule::NetworksAdded` (r:11 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::OwnedHotkeys` (r:1 w:0) + /// Proof: `SubtensorModule::OwnedHotkeys` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalHotkeyAlpha` (r:2048 w:0) + /// Proof: `SubtensorModule::TotalHotkeyAlpha` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:7 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn stake_ema_provider_step() -> Weight { + // Proof Size summary in bytes: + // Measured: `48134` + // Estimated: `5117924` + // Minimum execution time: 6_809_228_000 picoseconds. + Weight::from_parts(6_912_642_000, 5117924) + .saturating_add(RocksDbWeight::get().reads(2067_u64)) + } + /// Storage: `MultiCollective::Members` (r:2 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::RootRegisteredEma` (r:64 w:0) + /// Proof: `SubtensorModule::RootRegisteredEma` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn rotate_economic() -> Weight { + // Proof Size summary in bytes: + // Measured: `7996` + // Estimated: `167386` + // Minimum execution time: 280_504_000 picoseconds. + Weight::from_parts(284_522_000, 167386) + .saturating_add(RocksDbWeight::get().reads(66_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } + /// Storage: `SubtensorModule::NetworksAdded` (r:131 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:130 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:130 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `MultiCollective::Members` (r:1 w:1) + /// Proof: `MultiCollective::Members` (`max_values`: None, `max_size`: Some(2067), added: 4542, mode: `MaxEncodedLen`) + fn rotate_building() -> Weight { + // Proof Size summary in bytes: + // Measured: `11112` + // Estimated: `336327` + // Minimum execution time: 1_106_639_000 picoseconds. + Weight::from_parts(1_118_871_000, 336327) + .saturating_add(RocksDbWeight::get().reads(522_u64)) + .saturating_add(RocksDbWeight::get().writes(1_u64)) + } +} diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index e41653831c..ffebb8fc12 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -12,6 +12,7 @@ use core::num::NonZeroU64; pub mod check_mortality; pub mod check_nonce; +pub mod governance; mod migrations; pub mod sudo_wrapper; pub mod transaction_payment_wrapper; @@ -844,7 +845,6 @@ parameter_types! { pub MaximumSchedulerWeight: Weight = Perbill::from_percent(80) * BlockWeights::get().max_block; pub const MaxScheduledPerBlock: u32 = 50; - pub const NoPreimagePostponement: Option = Some(10); } /// Used the compare the privilege of an origin inside the scheduler. @@ -1082,7 +1082,6 @@ parameter_types! { pub const InitialDissolveNetworkScheduleDuration: BlockNumber = 5 * 24 * 60 * 60 / 12; // 5 days pub const SubtensorInitialTaoWeight: u64 = 971_718_665_099_567_868; // 0.05267697438728329% tao weight. pub const InitialEmaPriceHalvingPeriod: u64 = 201_600_u64; // 4 weeks - // 0 days pub const InitialStartCallDelay: u64 = 0; pub const SubtensorInitialKeySwapOnSubnetCost: TaoBalance = TaoBalance::new(1_000_000); // 0.001 TAO pub const HotkeySwapOnSubnetInterval : BlockNumber = prod_or_fast!(24 * 60 * 60 / 12, 1); // 1 day @@ -1168,6 +1167,9 @@ impl pallet_subtensor::Config for Runtime { type AlphaAssets = AlphaAssets; type EvmKeyAssociateRateLimit = EvmKeyAssociateRateLimit; type AuthorshipProvider = BlockAuthorFromAura; + type OnRootRegistrationChange = governance::EconomicEligibleSync; + type RootRegisteredInspector = governance::EconomicEligibleInspector; + type EmaValueProvider = governance::StakeValueProvider; type SubtensorPalletId = SubtensorPalletId; type BurnAccountId = BurnAccountId; type WeightInfo = pallet_subtensor::weights::SubstrateWeight; @@ -1649,6 +1651,11 @@ construct_runtime!( Contracts: pallet_contracts = 29, MevShield: pallet_shield = 30, AlphaAssets: pallet_alpha_assets = 31, + + // Governance + MultiCollective: pallet_multi_collective = 32, + SignedVoting: pallet_signed_voting = 33, + Referenda: pallet_referenda = 34, } ); @@ -1734,6 +1741,10 @@ mod benches { [pallet_shield, MevShield] [pallet_subtensor_proxy, Proxy] [pallet_subtensor_utility, Utility] + [pallet_referenda, Referenda] + [pallet_signed_voting, SignedVoting] + [pallet_multi_collective, MultiCollective] + [governance, GovernanceBench::] ); } @@ -2375,6 +2386,7 @@ impl_runtime_apis! { use frame_support::traits::StorageInfoTrait; use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use governance::benchmarking::Pallet as GovernanceBench; let mut list = Vec::::new(); list_benchmarks!(list, extra); @@ -2392,6 +2404,7 @@ impl_runtime_apis! { use frame_system_benchmarking::Pallet as SystemBench; use baseline::Pallet as BaselineBench; + use governance::benchmarking::Pallet as GovernanceBench; #[allow(non_local_definitions)] impl frame_system_benchmarking::Config for Runtime {} diff --git a/scripts/benchmark_action.sh b/scripts/benchmark_action.sh index 2497956a84..578672821d 100755 --- a/scripts/benchmark_action.sh +++ b/scripts/benchmark_action.sh @@ -19,13 +19,13 @@ REPEAT="${REPEAT:-20}" die() { echo "ERROR: $1" >&2; exit 1; } -# ── Auto-discover pallets ──────────────────────────────────────────────────── +# ── Auto-discover benchmark targets ────────────────────────────────────────── declare -A OUTPUTS while read -r name path; do OUTPUTS[$name]="$path" done < <("$SCRIPT_DIR/discover_pallets.sh") -(( ${#OUTPUTS[@]} > 0 )) || die "no benchmarked pallets found" +(( ${#OUTPUTS[@]} > 0 )) || die "no benchmark targets found" mkdir -p "$PATCH_DIR" diff --git a/scripts/benchmark_all.sh b/scripts/benchmark_all.sh index d73af7cdc4..a9cd81562f 100755 --- a/scripts/benchmark_all.sh +++ b/scripts/benchmark_all.sh @@ -1,21 +1,25 @@ #!/usr/bin/env zsh set -euo pipefail -# Generate weights.rs files for all (or a single) pallet using the standard +# Generate weights.rs files for all (or a single) benchmark target using the standard # frame-benchmarking-cli --output / --template approach. # -# Pallets are auto-discovered: any pallet with both benchmarking.rs and -# weights.rs is included. If a pallet is missing from define_benchmarks! +# Targets are auto-discovered: pallets with both benchmarking.rs and +# weights.rs are included, plus runtime-owned targets listed by +# scripts/discover_pallets.sh. If a target is missing from define_benchmarks! # in runtime/src/lib.rs, the benchmark CLI will error — no silent failures. # # Usage: # ./scripts/benchmark_all.sh # build + generate all -# ./scripts/benchmark_all.sh pallet_subtensor # build + generate one pallet +# ./scripts/benchmark_all.sh pallet_subtensor # build + generate one target +# ./scripts/benchmark_all.sh governance # build + generate governance weights # SKIP_BUILD=1 ./scripts/benchmark_all.sh # skip cargo build SCRIPT_DIR="$(cd "$(dirname "${0}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +export PATH="$HOME/.cargo/bin:$PATH" + RUNTIME_WASM="$ROOT_DIR/target/production/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm" NODE_BIN="$ROOT_DIR/target/production/node-subtensor" TEMPLATE="$ROOT_DIR/.maintain/frame-weight-template.hbs" @@ -25,13 +29,13 @@ REPEAT="${REPEAT:-20}" die() { echo "ERROR: $1" >&2; exit 1; } -# ── Auto-discover pallets ──────────────────────────────────────────────────── +# ── Auto-discover benchmark targets ────────────────────────────────────────── typeset -A PALLET_OUTPUTS while read -r name output_path; do PALLET_OUTPUTS[$name]="$output_path" done < <("$SCRIPT_DIR/discover_pallets.sh") -(( ${#PALLET_OUTPUTS} > 0 )) || die "no benchmarked pallets found" +(( ${#PALLET_OUTPUTS} > 0 )) || die "no benchmark targets found" # ── Build ──────────────────────────────────────────────────────────────────── if [[ "${SKIP_BUILD:-0}" != "1" ]]; then @@ -43,11 +47,11 @@ fi [[ -f "$RUNTIME_WASM" ]] || die "runtime WASM not found at $RUNTIME_WASM" [[ -f "$TEMPLATE" ]] || die "weight template not found at $TEMPLATE" -# ── Determine which pallets to benchmark ───────────────────────────────────── +# ── Determine which targets to benchmark ───────────────────────────────────── if [[ $# -gt 0 ]]; then PALLETS=("$@") for p in "${PALLETS[@]}"; do - [[ -n "${PALLET_OUTPUTS[$p]:-}" ]] || die "unknown pallet: $p (available: ${(k)PALLET_OUTPUTS})" + [[ -n "${PALLET_OUTPUTS[$p]:-}" ]] || die "unknown benchmark target: $p (available: ${(k)PALLET_OUTPUTS})" done else PALLETS=("${(k)PALLET_OUTPUTS[@]}") diff --git a/scripts/discover_pallets.sh b/scripts/discover_pallets.sh index 0b37239380..3e7e6edab0 100755 --- a/scripts/discover_pallets.sh +++ b/scripts/discover_pallets.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash -# Auto-discover benchmarked pallets. +# Auto-discover benchmarked runtime benchmark targets. # # Finds all pallets under pallets/ that have both: # - src/benchmarking.rs (or src/benchmarks.rs) # - src/weights.rs # -# Outputs one line per pallet: "pallet_name pallets//src/weights.rs" +# Also includes runtime-owned benchmark targets that are registered in +# runtime/src/lib.rs via define_benchmarks!. +# +# Outputs one line per target: "benchmark_name path/to/weights.rs" # The pallet name is derived from the Cargo.toml `name` field with dashes -> underscores. ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" @@ -18,3 +21,8 @@ for dir in "$ROOT_DIR"/pallets/*/; do relpath="pallets/$(basename "$dir")/src/weights.rs" echo "$name $relpath" done + +if [ -f "$ROOT_DIR/runtime/src/governance/benchmarking.rs" ] && \ + [ -f "$ROOT_DIR/runtime/src/governance/weights.rs" ]; then + echo "governance runtime/src/governance/weights.rs" +fi diff --git a/support/procedural-fork/Cargo.toml b/support/procedural-fork/Cargo.toml index fdc280ec14..cc03c78242 100644 --- a/support/procedural-fork/Cargo.toml +++ b/support/procedural-fork/Cargo.toml @@ -10,7 +10,7 @@ all = "allow" derive-syn-parse.workspace = true Inflector.workspace = true cfg-expr.workspace = true -itertools.workspace = true +itertools = { workspace = true, features = ["use_alloc"] } proc-macro2.workspace = true quote.workspace = true syn = { workspace = true, features = [ diff --git a/ts-tests/moonwall.config.json b/ts-tests/moonwall.config.json index cc5667af9f..6435eeab44 100644 --- a/ts-tests/moonwall.config.json +++ b/ts-tests/moonwall.config.json @@ -11,7 +11,9 @@ "testFileDir": [ "suites/dev" ], - "runScripts": [], + "runScripts": [ + "build-upgrade-runtime.sh" + ], "multiThreads": true, "reporters": ["basic"], "foundation": { @@ -37,6 +39,41 @@ ] } }, + { + "name": "dev_fast", + "timeout": 120000, + "envVars": ["DEBUG_COLORS=1"], + "testFileDir": [ + "suites/dev_fast" + ], + "runScripts": [ + "build-fast-runtime.sh" + ], + "multiThreads": true, + "reporters": ["basic"], + "foundation": { + "type": "dev", + "launchSpec": [ + { + "name": "subtensor", + "binPath": "../target/release-fast/node-subtensor", + "options": [ + "--one", + "--dev", + "--force-authoring", + "--rpc-cors=all", + "--no-prometheus", + "--no-telemetry", + "--reserved-only", + "--tmp", + "--sealing=manual" + ], + "disableDefaultEthProviders": true, + "newRpcBehaviour": true + } + ] + } + }, { "name": "zombienet_staking", "timeout": 600000, diff --git a/ts-tests/scripts/build-fast-runtime.sh b/ts-tests/scripts/build-fast-runtime.sh new file mode 100755 index 0000000000..fa5a2cc6e4 --- /dev/null +++ b/ts-tests/scripts/build-fast-runtime.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# +# Builds node-subtensor with --features fast-runtime, staging the binary at +# target/release-fast/node-subtensor so the prod build at target/release/ +# stays untouched (and the upgrade test keeps working against it). +# +# The fast-runtime build uses a dedicated CARGO_TARGET_DIR to avoid +# invalidating the prod build's incremental cache. +# +set -euo pipefail + +cd "$(dirname "$0")/.." +TS_TESTS_DIR="$(pwd)" +REPO_ROOT="$(cd .. && pwd)" + +OUTPUT_BIN="$REPO_ROOT/target/release-fast/node-subtensor" +FAST_TARGET_DIR="$TS_TESTS_DIR/tmp/cargo-target-fast" +BUILT_BIN="$FAST_TARGET_DIR/release/node-subtensor" + +# Skip if the staged binary is newer than every source file we care about. +# The set of paths mirrors what `cargo build -p node-subtensor` actually +# depends on; widen it if a future change moves source under a new prefix. +if [ -x "$OUTPUT_BIN" ]; then + newer=$(find \ + "$REPO_ROOT/runtime" \ + "$REPO_ROOT/common" \ + "$REPO_ROOT/pallets" \ + "$REPO_ROOT/node" \ + "$REPO_ROOT/primitives" \ + -name '*.rs' -newer "$OUTPUT_BIN" -print -quit 2>/dev/null || true) + if [ -z "$newer" ]; then + echo "==> $OUTPUT_BIN up-to-date, skipping fast-runtime build." + exit 0 + fi +fi + +echo "==> Building node-subtensor with --features fast-runtime" +echo " (CARGO_TARGET_DIR=$FAST_TARGET_DIR; first build is slow)" +( + cd "$REPO_ROOT" + CARGO_TARGET_DIR="$FAST_TARGET_DIR" \ + cargo build --release --features fast-runtime -p node-subtensor +) + +if [ ! -x "$BUILT_BIN" ]; then + echo "ERROR: expected binary not found at $BUILT_BIN" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUTPUT_BIN")" +cp "$BUILT_BIN" "$OUTPUT_BIN" +echo "==> Wrote $OUTPUT_BIN (fast-runtime)" diff --git a/ts-tests/scripts/build-upgrade-runtime.sh b/ts-tests/scripts/build-upgrade-runtime.sh new file mode 100755 index 0000000000..3dc576bc0e --- /dev/null +++ b/ts-tests/scripts/build-upgrade-runtime.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# +# Builds a runtime WASM with spec_version bumped by +1 +# +set -euo pipefail + +cd "$(dirname "$0")/.." +TS_TESTS_DIR="$(pwd)" +REPO_ROOT="$(cd .. && pwd)" + +LIB_RS="$REPO_ROOT/runtime/src/lib.rs" +RUNTIME_TOML="$REPO_ROOT/runtime/Cargo.toml" +OUTPUT_DIR="$TS_TESTS_DIR/tmp" +OUTPUT_WASM="$OUTPUT_DIR/upgraded-runtime.wasm" +UPGRADE_TARGET_DIR="$OUTPUT_DIR/cargo-target" +BUILT_WASM="$UPGRADE_TARGET_DIR/release/wbuild/node-subtensor-runtime/node_subtensor_runtime.compact.compressed.wasm" + +mkdir -p "$OUTPUT_DIR" + +# Skip if existing output is newer than every input source. +if [ -f "$OUTPUT_WASM" ] \ + && [ "$OUTPUT_WASM" -nt "$LIB_RS" ] \ + && [ "$OUTPUT_WASM" -nt "$RUNTIME_TOML" ]; then + echo "==> Upgraded runtime already up-to-date at $OUTPUT_WASM, skipping build." + exit 0 +fi + +# Read current spec_version from source. +CURRENT_VERSION=$(grep -E '^\s*spec_version:' "$LIB_RS" | head -1 | grep -oE '[0-9]+') +if [ -z "$CURRENT_VERSION" ]; then + echo "ERROR: failed to parse spec_version from $LIB_RS" >&2 + exit 1 +fi +NEW_VERSION=$((CURRENT_VERSION + 1)) +echo "==> Bumping spec_version: $CURRENT_VERSION -> $NEW_VERSION (transient, will be restored)" + +# Backup + always-restore guard. +BACKUP="$LIB_RS.upgrade-build-backup" +cp "$LIB_RS" "$BACKUP" +trap 'mv "$BACKUP" "$LIB_RS"' EXIT + +# In-place bump (BSD/macOS sed friendly: -i with empty suffix arg). +sed -i.tmp -E "s/^([[:space:]]*spec_version:[[:space:]]*)[0-9]+,/\1${NEW_VERSION},/" "$LIB_RS" +rm -f "$LIB_RS.tmp" + +echo "==> Building runtime crate (CARGO_TARGET_DIR=$UPGRADE_TARGET_DIR)" +echo " First build is slow (cold deps); subsequent runs are incremental." +( + cd "$REPO_ROOT" + CARGO_TARGET_DIR="$UPGRADE_TARGET_DIR" \ + cargo build --profile release -p node-subtensor-runtime +) + +if [ ! -f "$BUILT_WASM" ]; then + echo "ERROR: expected WASM not found at $BUILT_WASM" >&2 + exit 1 +fi + +cp "$BUILT_WASM" "$OUTPUT_WASM" +echo "==> Wrote $OUTPUT_WASM (spec_version=$NEW_VERSION)" diff --git a/ts-tests/suites/dev/subtensor/governance/test-capacity.ts b/ts-tests/suites/dev/subtensor/governance/test-capacity.ts new file mode 100644 index 0000000000..f617710c95 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-capacity.ts @@ -0,0 +1,143 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + getActiveCount, + getActivePerProposer, + getStatusKind, + inBlock, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_CAPACITY_01", + title: "Governance — runtime referendum capacity limits", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const idleProposer = generateKeyringPair("sr25519"); + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + const MAX_QUEUED = 20; + const MAX_ACTIVE_PER_PROPOSER = 5; + const PROPOSERS_NEEDED = MAX_QUEUED / MAX_ACTIVE_PER_PROPOSER; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: PROPOSERS_NEEDED, + triumvirate: 3, + economic: 1, + building: 1, + }); + + await fundAccounts(api, context, sudoer, [idleProposer.address]); + await inBlock( + context, + sudoer, + api.tx.sudo.sudo(api.tx.multiCollective.addMember("Proposers", idleProposer.address)) + ); + expect(await lastModuleError(api)).to.be.null; + }); + + it({ + id: "T01", + title: "runtime MaxActivePerProposer is enforced at five active referenda", + test: async () => { + const submitted: number[] = []; + for (let i = 0; i < MAX_ACTIVE_PER_PROPOSER; i++) { + submitted.push( + await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(BigInt(300 + i))) + ); + expect(await lastModuleError(api)).to.be.null; + } + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(MAX_ACTIVE_PER_PROPOSER); + + await inBlock(context, gov.proposer, api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, remark(399n))); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "ProposerQuotaExceeded", + }); + + for (const index of submitted) { + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(index)); + } + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(0); + }, + }); + + it({ + id: "T02", + title: "delegation is quota-neutral in the concrete two-track runtime", + test: async () => { + const fresh = gov.proposers[1]; + expect(await getActivePerProposer(api, fresh.address)).to.equal(0); + + const parent = await submitOnTrack(api, context, fresh, DEV_TRACK.TRIUMVIRATE, remark(600n)); + expect(await getActivePerProposer(api, fresh.address)).to.equal(1); + + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + expect(await getStatusKind(api, parent)).to.equal("delegated"); + expect(await getActivePerProposer(api, fresh.address)).to.equal(1); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(data.review ?? data[1])); + expect(await getActivePerProposer(api, fresh.address)).to.equal(0); + }, + }); + + it({ + id: "T03", + title: "with the queue at capacity, an idle proposer's submit fails with QueueFull", + test: async () => { + expect(await getActiveCount(api)).to.equal(0); + + for (let p = 0; p < PROPOSERS_NEEDED; p++) { + for (let i = 0; i < MAX_ACTIVE_PER_PROPOSER; i++) { + await submitOnTrack( + api, + context, + gov.proposers[p], + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark(`fill-${p}-${i}`) + ); + expect(await lastModuleError(api)).to.be.null; + } + } + expect(await getActiveCount(api)).to.equal(MAX_QUEUED); + + await inBlock( + context, + idleProposer, + api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, api.tx.system.remark("21st-attempt")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "QueueFull", + }); + expect(await getActiveCount(api)).to.equal(MAX_QUEUED); + expect((await api.query.referenda.activePerProposer(idleProposer.address)).toJSON()).to.equal(0); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts b/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts new file mode 100644 index 0000000000..d655ec9ed7 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-full-flow.ts @@ -0,0 +1,124 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { freeBalance, referendumCount, referendumStatusFor, systemEvents } from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_FULLFLOW_01", + title: "Governance — full two-phase flow (track 0 + track 1)", + foundationMethods: "dev", + testCases: ({ it, context, log }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposer = generateKeyringPair("sr25519"); + const triumvirate1 = generateKeyringPair("sr25519"); + const triumvirate2 = generateKeyringPair("sr25519"); + const triumvirate3 = generateKeyringPair("sr25519"); + const economic1 = generateKeyringPair("sr25519"); + const economic2 = generateKeyringPair("sr25519"); + const building1 = generateKeyringPair("sr25519"); + const building2 = generateKeyringPair("sr25519"); + const target = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + const fund = 1_000_000_000_000n; + for (const inner of [ + api.tx.balances.forceSetBalance(proposer.address, fund), + api.tx.balances.forceSetBalance(triumvirate1.address, fund), + api.tx.balances.forceSetBalance(triumvirate2.address, fund), + api.tx.balances.forceSetBalance(triumvirate3.address, fund), + api.tx.balances.forceSetBalance(economic1.address, fund), + api.tx.balances.forceSetBalance(economic2.address, fund), + api.tx.balances.forceSetBalance(building1.address, fund), + api.tx.balances.forceSetBalance(building2.address, fund), + api.tx.multiCollective.addMember("Proposers", proposer.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate1.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate2.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate3.address), + api.tx.multiCollective.addMember("Economic", economic1.address), + api.tx.multiCollective.addMember("Economic", economic2.address), + api.tx.multiCollective.addMember("Building", building1.address), + api.tx.multiCollective.addMember("Building", building2.address), + ]) { + await context.createBlock([await api.tx.sudo.sudo(inner).signAsync(sudoer)]); + } + const economic = await api.query.multiCollective.members("Economic"); + const building = await api.query.multiCollective.members("Building"); + log(`Economic: ${economic.toJSON()}`); + log(`Building: ${building.toJSON()}`); + expect(economic.toJSON()).to.have.length(2); + expect(building.toJSON()).to.have.length(2); + }); + + it({ + id: "T01", + title: "proposer submits; triumvirate delegates; collective fast-tracks; balance changes", + test: async () => { + const targetAmount = 2_000_000_000n; + const countBefore = await referendumCount(api); + + const payload = api.tx.balances.forceSetBalance(target.address, targetAmount); + + await context.createBlock([await api.tx.referenda.submit(0, payload).signAsync(proposer)]); + const outerPoll = countBefore; + + // Triumvirate reaches 2/3 aye. + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate1)]); + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate2)]); + + // The 2nd vote schedules a `nudge` for the next block, so need to create 1 block + await context.createBlock([]); + + const approveEvents = await systemEvents(api); + const delegated = approveEvents.find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated").to.exist; + + const delegatedData = delegated?.event.data as unknown as { + review: any; + track: any; + }; + expect(delegatedData.track.toString()).to.equal("1"); + + const innerPoll = outerPoll + 1; + expect(delegatedData.review.toString()).to.equal(innerPoll.toString()); + + const innerStatus = await referendumStatusFor(api, innerPoll); + expect(innerStatus.isSome, "inner poll stored").to.be.true; + expect(innerStatus.toJSON()).to.have.property("ongoing"); + + // Track 1 voter_set = Union(Economic, Building) → 4 voters total. + // 3 ayes (3/4 = 75% ≥ 67% fast_track threshold) is enough. + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic1)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic2)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(building1)]); + + // Same nudge pattern: 3rd vote schedules nudge → next block fast-tracks. + await context.createBlock([]); + + const fastTrackEvents = await systemEvents(api); + const fastTracked = fastTrackEvents.find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "inner FastTracked").to.exist; + + await context.createBlock([]); + + const finalEvents = await systemEvents(api); + const dispatched = finalEvents.find( + (e) => e.event.section === "scheduler" && e.event.method === "Dispatched" + ); + expect(dispatched, "scheduler.Dispatched").to.exist; + + const targetFinal = await freeBalance(api, target.address); + expect(targetFinal).to.equal(targetAmount); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts b/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts new file mode 100644 index 0000000000..b2d6fe419a --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-origin-guards.ts @@ -0,0 +1,184 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + inBlock, + lastModuleError, + submitOnTrack, +} from "../../../../utils/governance"; + +/** + * Comprehensive proof that every privileged extrinsic in the governance + * surface rejects non-Root callers with `BadOrigin`. Each test exercises a + * single extrinsic so a regression localizes immediately. This is the most + * security-critical file in the suite: governance is the only path to Root + * dispatch, and a leaky origin check would erase that guarantee. + */ +describeSuite({ + id: "DEV_SUB_GOV_ORIGIN_GUARDS_01", + title: "Governance — origin guards on privileged extrinsics", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const attacker = generateKeyringPair("sr25519"); + const victim = generateKeyringPair("sr25519"); + const accomplice = generateKeyringPair("sr25519"); + + const expectBadOrigin = async () => { + const err = await lastModuleError(api); + expect(err, "ExtrinsicFailed").to.exist; + expect((err as { kind: string }).kind).to.equal("BadOrigin"); + }; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + // Bootstrap a referendum so `kill`, `advance_referendum`, and + // `enact` have a real index to target. Seating Triumvirate also + // means `attacker` is a strict outsider. + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + await fundAccounts(api, context, sudoer, [attacker.address, victim.address, accomplice.address]); + }); + + it({ + id: "T01", + title: "multiCollective.add_member from a signed non-Root caller → BadOrigin", + test: async () => { + await inBlock(context, attacker, api.tx.multiCollective.addMember("Triumvirate", attacker.address)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T02", + title: "multiCollective.remove_member from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.removeMember("Triumvirate", gov.triumvirate[0].address) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T03", + title: "multiCollective.swap_member from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.swapMember("Triumvirate", gov.triumvirate[0].address, accomplice.address) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T04", + title: "multiCollective.set_members from non-Root → BadOrigin", + test: async () => { + await inBlock( + context, + attacker, + api.tx.multiCollective.setMembers("Triumvirate", [ + attacker.address, + accomplice.address, + victim.address, + ]) + ); + await expectBadOrigin(); + }, + }); + + it({ + id: "T05", + title: "multiCollective.force_rotate from non-Root → BadOrigin", + test: async () => { + await inBlock(context, attacker, api.tx.multiCollective.forceRotate("Economic")); + await expectBadOrigin(); + }, + }); + + it({ + id: "T06", + title: "referenda.kill from non-Root → BadOrigin", + test: async () => { + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark("victim-call") + ); + await inBlock(context, attacker, api.tx.referenda.kill(index)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T07", + title: "referenda.advance_referendum from non-Root → BadOrigin", + test: async () => { + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.system.remark("advance-target") + ); + await inBlock(context, attacker, api.tx.referenda.advanceReferendum(index)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T08", + title: "referenda.enact from non-Root → BadOrigin", + test: async () => { + const phantomCall = api.tx.system.remark("hijack-attempt"); + await inBlock(context, attacker, api.tx.referenda.enact(0, phantomCall)); + await expectBadOrigin(); + }, + }); + + it({ + id: "T09", + title: "sudo.sudo from a non-sudo caller is rejected before runtime (pool-level)", + test: async () => { + // Defense in depth: the sudo pallet pre-validates the caller + // via a signed extension, so a non-sudo signer never even + // reaches runtime dispatch. Any other behavior would let an + // attacker probe sudo'd calls cheaply. + let rejected = false; + try { + await context.createBlock([ + await api.tx.sudo + .sudo(api.tx.multiCollective.addMember("Triumvirate", attacker.address)) + .signAsync(attacker, { era: 0 }), + ]); + } catch (e) { + rejected = true; + expect(String(e)).to.match(/Invalid signing address|RequireSudo|BadOrigin/i); + } + expect(rejected, "transaction must be rejected").to.be.true; + + // The Triumvirate membership remains untouched. + const members = (await api.query.multiCollective.members("Triumvirate")).toJSON() as string[]; + expect(members).to.not.include(attacker.address); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts b/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts new file mode 100644 index 0000000000..6eb5fa5c6b --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-runtime-config.ts @@ -0,0 +1,217 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + addMembers, + castVote, + type Collective, + DEFAULT_FUND, + DEV_TRACK, + fundAccounts, + getMembers, + getStatusKind, + inBlock, + lastModuleError, + nudge, + referendumCount, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +const fresh = (n: number): KeyringPair[] => Array.from({ length: n }, () => generateKeyringPair("sr25519")); + +describeSuite({ + id: "DEV_SUB_GOV_RUNTIME_CONFIG_01", + title: "Governance — runtime configuration and submission guardrails", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposers = fresh(1); + const triumvirate = fresh(4); + const economicEligible = fresh(2); + const beneficiary = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + await fundAccounts( + api, + context, + sudoer, + [...proposers, ...triumvirate, ...economicEligible].map((kp) => kp.address), + DEFAULT_FUND + ); + await addMembers(api, context, sudoer, [{ collective: "Proposers", account: proposers[0] }]); + }); + + it({ + id: "T01", + title: "all runtime collective enum variants are addressable through metadata", + test: async () => { + const allCollectives: Collective[] = [ + "Proposers", + "Triumvirate", + "Economic", + "Building", + "EconomicEligible", + ]; + + for (const collective of allCollectives) { + const members = await api.query.multiCollective.members(collective); + expect(members.toJSON()).to.be.an("array"); + } + }, + }); + + it({ + id: "T02", + title: "Track 0 submission fails when the runtime Triumvirate voter set is empty", + test: async () => { + expect((await api.query.multiCollective.members("Triumvirate")).toJSON()).to.have.length(0); + + await inBlock( + context, + proposers[0], + api.tx.referenda.submit(DEV_TRACK.TRIUMVIRATE, api.tx.system.remark("attempted-with-no-voters")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "EmptyVoterSet", + }); + expect(await referendumCount(api)).to.equal(0); + }, + }); + + it({ + id: "T03", + title: "Track 1 is not directly submittable in the runtime", + test: async () => { + await inBlock( + context, + proposers[0], + api.tx.referenda.submit(DEV_TRACK.REVIEW, api.tx.system.remark("direct-track-1")) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "referenda", + name: "TrackNotSubmittable", + }); + }, + }); + + it({ + id: "T04", + title: "Triumvirate is runtime-configured as exactly three seats", + test: async () => { + await addMembers(api, context, sudoer, [ + { collective: "Triumvirate", account: triumvirate[0] }, + { collective: "Triumvirate", account: triumvirate[1] }, + { collective: "Triumvirate", account: triumvirate[2] }, + ]); + expect(await getMembers(api, "Triumvirate")).to.have.length(3); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.addMember("Triumvirate", triumvirate[3].address) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "TooManyMembers", + }); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.removeMember("Triumvirate", triumvirate[0].address) + ); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "TooFewMembers", + }); + }, + }); + + it({ + id: "T05", + title: "Proposers is not rotatable in the runtime", + test: async () => { + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.forceRotate("Proposers")); + expect(await lastModuleError(api)).to.deep.equal({ + section: "multiCollective", + name: "CollectiveDoesNotRotate", + }); + }, + }); + + it({ + id: "T06", + title: "EconomicEligible permits an empty runtime membership set", + test: async () => { + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.setMembers( + "EconomicEligible", + economicEligible.map((kp) => kp.address) + ) + ); + expect(await lastModuleError(api)).to.be.null; + expect(await getMembers(api, "EconomicEligible")).to.have.length(2); + + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.setMembers("EconomicEligible", [])); + expect(await lastModuleError(api)).to.be.null; + expect(await getMembers(api, "EconomicEligible")).to.have.length(0); + }, + }); + + it({ + id: "T07", + title: "approval with empty review voter set emits ReviewSchedulingFailed; parent stays Ongoing", + test: async () => { + expect((await api.query.multiCollective.members("Economic")).toJSON()).to.have.length(0); + expect((await api.query.multiCollective.members("Building")).toJSON()).to.have.length(0); + + const countBefore = await referendumCount(api); + const index = await submitOnTrack( + api, + context, + proposers[0], + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 7n) + ); + + await castVote(api, context, triumvirate[0], index, true); + await castVote(api, context, triumvirate[1], index, true); + await nudge(context); + + const events = await systemEvents(api); + const failed = events.find( + (e) => e.event.section === "referenda" && e.event.method === "ReviewSchedulingFailed" + ); + expect(failed, "ReviewSchedulingFailed event").to.exist; + const data = failed?.event.data.toJSON() as { index?: number; track?: number } | [number, number]; + if (Array.isArray(data)) { + expect(data[0]).to.equal(index); + expect(data[1]).to.equal(1); + } else { + expect(data.index).to.equal(index); + expect(data.track).to.equal(1); + } + + const delegated = events.find((e) => e.event.section === "referenda" && e.event.method === "Delegated"); + expect(delegated, "no Delegated when review scheduling fails").to.be.undefined; + + expect(await getStatusKind(api, index)).to.equal("ongoing"); + expect(await referendumCount(api)).to.equal(countBefore + 1); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts b/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts new file mode 100644 index 0000000000..4d61ee6ee8 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-runtime-upgrade.ts @@ -0,0 +1,126 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { referendumCount, systemEvents } from "../../../../utils/governance"; + +const UPGRADED_WASM_PATH = path.resolve(process.cwd(), "tmp/upgraded-runtime.wasm"); + +describeSuite({ + id: "DEV_SUB_GOV_UPGRADE_01", + title: "Governance — runtime upgrade via setCode", + foundationMethods: "dev", + testCases: ({ it, context, log }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + + const proposer = generateKeyringPair("sr25519"); + const triumvirate1 = generateKeyringPair("sr25519"); + const triumvirate2 = generateKeyringPair("sr25519"); + const triumvirate3 = generateKeyringPair("sr25519"); + const economic1 = generateKeyringPair("sr25519"); + const economic2 = generateKeyringPair("sr25519"); + const building1 = generateKeyringPair("sr25519"); + const building2 = generateKeyringPair("sr25519"); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + + if (!fs.existsSync(UPGRADED_WASM_PATH)) { + throw new Error( + `Upgraded runtime WASM not found at ${UPGRADED_WASM_PATH}. Run ts-tests/scripts/build-upgrade-runtime.sh first (moonwall should run it automatically via runScripts).` + ); + } + + const minimumPeriod = (api.consts.timestamp.minimumPeriod as unknown as { toNumber(): number }).toNumber(); + if (minimumPeriod !== 6000) { + throw new Error( + `node-subtensor binary appears to be built with --features fast-runtime (timestamp.minimumPeriod=${minimumPeriod}, expected 6000). The upgrade WASM is built without fast-runtime; mixing them bricks block production after setCode. Rebuild the node binary without --features fast-runtime: cargo build --release -p node-subtensor` + ); + } + + const fund = 1_000_000_000_000n; + for (const inner of [ + api.tx.balances.forceSetBalance(proposer.address, fund), + api.tx.balances.forceSetBalance(triumvirate1.address, fund), + api.tx.balances.forceSetBalance(triumvirate2.address, fund), + api.tx.balances.forceSetBalance(triumvirate3.address, fund), + api.tx.balances.forceSetBalance(economic1.address, fund), + api.tx.balances.forceSetBalance(economic2.address, fund), + api.tx.balances.forceSetBalance(building1.address, fund), + api.tx.balances.forceSetBalance(building2.address, fund), + api.tx.multiCollective.addMember("Proposers", proposer.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate1.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate2.address), + api.tx.multiCollective.addMember("Triumvirate", triumvirate3.address), + api.tx.multiCollective.addMember("Economic", economic1.address), + api.tx.multiCollective.addMember("Economic", economic2.address), + api.tx.multiCollective.addMember("Building", building1.address), + api.tx.multiCollective.addMember("Building", building2.address), + ]) { + await context.createBlock([await api.tx.sudo.sudo(inner).signAsync(sudoer)]); + } + }); + + it({ + id: "T01", + title: "setCode passes governance and bumps specVersion", + test: async () => { + const wasmBytes = fs.readFileSync(UPGRADED_WASM_PATH); + const wasmHex = `0x${wasmBytes.toString("hex")}`; + log(`upgraded runtime size: ${wasmBytes.length} bytes`); + + const versionBefore = await api.rpc.state.getRuntimeVersion(); + const specBefore = versionBefore.specVersion.toNumber(); + log(`specVersion before: ${specBefore}`); + + const setCodePayload = api.tx.system.setCode(wasmHex); + + const countBefore = await referendumCount(api); + + await context.createBlock([await api.tx.referenda.submit(0, setCodePayload).signAsync(proposer)]); + const outerPoll = countBefore; + + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate1)]); + await context.createBlock([await api.tx.signedVoting.vote(outerPoll, true).signAsync(triumvirate2)]); + + await context.createBlock([]); + + const delegatedEvent = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegatedEvent, "outer Delegated").to.exist; + const innerPoll = outerPoll + 1; + + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic1)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(economic2)]); + await context.createBlock([await api.tx.signedVoting.vote(innerPoll, true).signAsync(building1)]); + + await context.createBlock([]); + + const fastTracked = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "inner FastTracked").to.exist; + + await context.createBlock([]); + + const enactmentEvents = await systemEvents(api); + const codeUpdated = enactmentEvents.find( + (e) => e.event.section === "system" && e.event.method === "CodeUpdated" + ); + expect(codeUpdated, "system.CodeUpdated").to.exist; + + await context.createBlock([]); + + const versionAfter = await api.rpc.state.getRuntimeVersion(); + const specAfter = versionAfter.specVersion.toNumber(); + log(`specVersion after: ${specAfter}`); + expect(specAfter).to.equal(specBefore + 1); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts b/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts new file mode 100644 index 0000000000..7c494391c2 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-track0-lifecycle.ts @@ -0,0 +1,105 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getStatusKind, + getTally, + nudge, + referendumCount, + submitOnTrack, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_TRACK0_LIFECYCLE_01", + title: "Governance — Track 0 runtime thresholds", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + }); + + it({ + id: "T01", + title: "2-of-3 runtime Triumvirate ayes delegates to the review track", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(2n)); + + await castVote(api, context, gov.triumvirate[0], index, true); + await castVote(api, context, gov.triumvirate[1], index, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + + const data = delegated?.event.data.toJSON() as { + index?: number; + review?: number; + track?: number; + } & Array; + const childIndex = data.review ?? data[1]; + expect(data.index ?? data[0]).to.equal(index); + expect(data.track ?? data[2]).to.equal(DEV_TRACK.REVIEW); + expect(await getStatusKind(api, index)).to.equal("delegated"); + expect(await getStatusKind(api, childIndex)).to.equal("ongoing"); + }, + }); + + it({ + id: "T02", + title: "2-of-3 runtime Triumvirate nays reject without creating a review child", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(3n)); + const countBefore = await referendumCount(api); + + await castVote(api, context, gov.triumvirate[0], index, false); + await castVote(api, context, gov.triumvirate[1], index, false); + await nudge(context); + + const rejected = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Rejected" + ); + expect(rejected, "Rejected event").to.exist; + expect(await getStatusKind(api, index)).to.equal("rejected"); + expect(await referendumCount(api)).to.equal(countBefore); + }, + }); + + it({ + id: "T03", + title: "split Triumvirate votes stay below both runtime thresholds", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, remark(4n)); + await castVote(api, context, gov.triumvirate[0], index, true); + await castVote(api, context, gov.triumvirate[1], index, false); + await nudge(context, 2); + + expect(await getStatusKind(api, index)).to.equal("ongoing"); + expect(await getTally(api, index)).to.deep.equal({ + ayes: 1, + nays: 1, + total: 3, + }); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts b/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts new file mode 100644 index 0000000000..1542e6cfe6 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-track1-lifecycle.ts @@ -0,0 +1,211 @@ +import { beforeAll, type DevModeContext, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + freeBalance, + type GovernanceMembership, + getStatusKind, + getTally, + isEnactmentTaskNone, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +async function delegateToTrack1( + api: ApiPromise, + context: DevModeContext, + gov: GovernanceMembership, + payload: Parameters[4] +): Promise<{ outer: number; child: number }> { + const outer = await submitOnTrack(api, context, gov.proposer, DEV_TRACK.TRIUMVIRATE, payload); + await castVote(api, context, gov.triumvirate[0], outer, true); + await castVote(api, context, gov.triumvirate[1], outer, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + if (!delegated) { + throw new Error("Delegation never fired; the review voter set may be empty"); + } + const data = delegated.event.data.toJSON() as { review?: number } & Array; + return { outer, child: data.review ?? data[1] }; +} + +describeSuite({ + id: "DEV_SUB_GOV_TRACK1_LIFECYCLE_01", + title: "Governance — Track 1 runtime review path", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + it({ + id: "T01", + title: "delegation creates a Track 1 child with Economic ∪ Building as voters", + test: async () => { + const { child } = await delegateToTrack1(api, context, gov, remark(101n)); + expect(await getStatusKind(api, child)).to.equal("ongoing"); + expect(await getTally(api, child)).to.deep.equal({ + ayes: 0, + nays: 0, + total: 4, + }); + }, + }); + + it({ + id: "T02", + title: "3-of-4 runtime review ayes fast-track and dispatch as Root", + test: async () => { + const targetAmount = 7_777_777_000n; + const target = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.forceSetBalance(target.address, targetAmount) + ); + + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + await nudge(context); + + const fastTracked = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "FastTracked" + ); + expect(fastTracked, "FastTracked event").to.exist; + expect(await getStatusKind(api, child)).to.equal("fastTracked"); + + await nudge(context); + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + expect(await freeBalance(api, target.address)).to.equal(targetAmount); + }, + }); + + it({ + id: "T03", + title: "3-of-4 runtime review nays cancel and clear the enactment task", + test: async () => { + const { child } = await delegateToTrack1(api, context, gov, remark(103n)); + + await castVote(api, context, gov.economic[0], child, false); + await castVote(api, context, gov.economic[1], child, false); + await castVote(api, context, gov.building[0], child, false); + await nudge(context); + + const cancelled = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Cancelled" + ); + expect(cancelled, "Cancelled event").to.exist; + expect(await getStatusKind(api, child)).to.equal("cancelled"); + expect(await isEnactmentTaskNone(api, child), "enactment task cleared").to.be.true; + }, + }); + + it({ + id: "T04", + title: "Root kill in the fast-track block prevents scheduled dispatch", + test: async () => { + const target = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.forceSetBalance(target.address, 42n) + ); + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + + await context.createBlock([ + await api.tx.sudo.sudo(api.tx.referenda.kill(child)).signAsync(sudoer, { era: 0 }), + ]); + + const events = await systemEvents(api); + expect(events.find((e) => e.event.section === "referenda" && e.event.method === "FastTracked")).to + .exist; + expect(events.find((e) => e.event.section === "referenda" && e.event.method === "Killed")).to.exist; + expect(await lastModuleError(api)).to.be.null; + + await nudge(context, 3); + expect(await freeBalance(api, target.address)).to.equal(0n); + }, + }); + + it({ + id: "T05", + title: "runtime Root dispatch errors are recorded in the Enacted event", + test: async () => { + const recipient = generateKeyringPair("sr25519"); + const { child } = await delegateToTrack1( + api, + context, + gov, + api.tx.balances.transferKeepAlive(recipient.address, 100n) + ); + await castVote(api, context, gov.economic[0], child, true); + await castVote(api, context, gov.economic[1], child, true); + await castVote(api, context, gov.building[0], child, true); + await nudge(context); + await nudge(context); + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + const data = enacted?.event.data.toJSON() as { error?: unknown } | Array; + const errorField = Array.isArray(data) ? data[2] : data.error; + expect(errorField, "Enacted carries a non-null error").to.not.be.null; + expect(await freeBalance(api, recipient.address)).to.equal(0n); + }, + }); + + it({ + id: "T06", + title: "Root can directly enact an Ongoing runtime review referendum", + test: async () => { + const target = generateKeyringPair("sr25519"); + const amount = 12_345_000n; + const innerCall = api.tx.balances.forceSetBalance(target.address, amount); + + const { child } = await delegateToTrack1(api, context, gov, innerCall); + expect(await getStatusKind(api, child)).to.equal("ongoing"); + + await sudoInBlock(api, context, sudoer, api.tx.referenda.enact(child, innerCall)); + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + expect(enacted, "Enacted event").to.exist; + expect(await getStatusKind(api, child)).to.equal("enacted"); + expect(await freeBalance(api, target.address)).to.equal(amount); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts b/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts new file mode 100644 index 0000000000..eb82997011 --- /dev/null +++ b/ts-tests/suites/dev/subtensor/governance/test-voter-sets.ts @@ -0,0 +1,142 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../../utils/account"; +import { + addMembers, + bootstrapMembership, + castVote, + DEV_TRACK, + fundAccounts, + type GovernanceMembership, + getTally, + lastModuleError, + nudge, + submitOnTrack, + sudoInBlock, + systemEvents, +} from "../../../../utils/governance"; + +describeSuite({ + id: "DEV_SUB_GOV_VOTER_SETS_01", + title: "Governance — runtime voter-set wiring", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + + const latecomer = generateKeyringPair("sr25519"); + const overlap = generateKeyringPair("sr25519"); + const beneficiary = generateKeyringPair("sr25519"); + const remark = (amount: bigint) => api.tx.balances.forceSetBalance(beneficiary.address, amount); + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: 4, + triumvirate: 3, + economic: 1, + building: 1, + }); + await fundAccounts(api, context, sudoer, [latecomer.address, overlap.address]); + await addMembers(api, context, sudoer, [ + { collective: "Economic", account: overlap }, + { collective: "Building", account: overlap }, + ]); + }); + + it({ + id: "T01", + title: "runtime voter snapshots survive a Triumvirate membership swap", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposers[0], DEV_TRACK.TRIUMVIRATE, remark(208n)); + + const frozenSet = (await api.query.signedVoting.voterSetOf(index)).toJSON() as string[]; + expect(frozenSet).to.have.length(3); + expect(frozenSet).to.not.include(latecomer.address); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.swapMember("Triumvirate", gov.triumvirate[2].address, latecomer.address) + ); + expect(await lastModuleError(api)).to.be.null; + + await castVote(api, context, latecomer, index, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + + await sudoInBlock( + api, + context, + sudoer, + api.tx.multiCollective.swapMember("Triumvirate", latecomer.address, gov.triumvirate[2].address) + ); + }, + }); + + it({ + id: "T02", + title: "Triumvirate members cannot vote on the Track 1 review child", + test: async () => { + const parent = await submitOnTrack(api, context, gov.proposers[1], DEV_TRACK.TRIUMVIRATE, remark(214n)); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + const child = data.review ?? data[1]; + + await castVote(api, context, gov.triumvirate[0], child, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + }, + }); + + it({ + id: "T03", + title: "Economic/Building members cannot vote on the Track 0 parent", + test: async () => { + const index = await submitOnTrack(api, context, gov.proposers[2], DEV_TRACK.TRIUMVIRATE, remark(215n)); + await castVote(api, context, gov.economic[0], index, true); + expect(await lastModuleError(api)).to.deep.equal({ + section: "signedVoting", + name: "NotInVoterSet", + }); + }, + }); + + it({ + id: "T04", + title: "runtime Economic ∪ Building review voters dedupe overlapping accounts", + test: async () => { + const parent = await submitOnTrack(api, context, gov.proposers[3], DEV_TRACK.TRIUMVIRATE, remark(216n)); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + const data = delegated?.event.data.toJSON() as { review?: number } & Array; + const child = data.review ?? data[1]; + + const voterSet = (await api.query.signedVoting.voterSetOf(child)).toJSON() as string[]; + expect(voterSet).to.have.length(3); + expect(voterSet.filter((a) => a === overlap.address)).to.have.length(1); + expect((await getTally(api, child))?.total).to.equal(3); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track0-expired.ts b/ts-tests/suites/dev_fast/governance/test-track0-expired.ts new file mode 100644 index 0000000000..3f39393ec3 --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track0-expired.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getActivePerProposer, + getStatusKind, + nudge, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * TRIUMVIRATE_DECISION_PERIOD = prod_or_fast!(50_400, 50) + * + * A Track 0 referendum that never crosses `approve_threshold` (2/3) or + * `reject_threshold` (2/3) before the decision period elapses must time + * out as `Expired`. The deadline alarm is set on submission and re-armed + * on every `expire_or_rearm_deadline` call until it actually fires at + * `submitted + decision_period`. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK0_EXPIRED_01", + title: "Governance (fast-runtime) — Track 0 Expired", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + + // Mirrors `runtime/src/governance/tracks.rs` under fast-runtime. + const TRIUMVIRATE_DECISION_PERIOD = 50; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 1, + building: 1, + }); + + // Sanity: confirm we're running on a fast-runtime binary. The + // upgrade test uses the opposite check; mismatched binaries would + // silently make this test pass for the wrong reason. + const minimumPeriod = (api.consts.timestamp.minimumPeriod as unknown as { toNumber(): number }).toNumber(); + if (minimumPeriod === 6000) { + throw new Error( + `dev_fast suite requires a binary built with --features fast-runtime (got minimumPeriod=${minimumPeriod})` + ); + } + }); + + it({ + id: "T01", + title: "no threshold crossed before decision_period elapses → Expired", + test: async () => { + const beforeActive = await getActivePerProposer(api, gov.proposer.address); + const index = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 7n) + ); + + // 1 aye sits below the 2/3 approve_threshold (≈ 33% vs 66.6%) + // and rejection stays at 0, so neither threshold can ever + // fire. The only way out is the deadline. + await castVote(api, context, gov.triumvirate[0], index, true); + expect(await getStatusKind(api, index)).to.equal("ongoing"); + + // Drive blocks until the status flips to expired, capturing + // the per-block event log so the Expired event from the + // transitioning block isn't lost when the system events + // storage rolls over. + let expiredEvent: unknown = null; + for (let i = 0; i < TRIUMVIRATE_DECISION_PERIOD + 10; i++) { + const ev = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Expired" + ); + if (ev) { + expiredEvent = ev; + break; + } + if ((await getStatusKind(api, index)) === "expired") { + // Status flipped before we observed the event; still + // acceptable — status is the authoritative record. + break; + } + await nudge(context); + } + + expect(await getStatusKind(api, index)).to.equal("expired"); + expect(expiredEvent, "Expired event observed during polling").to.exist; + + // Expiration is terminal → proposer's slot is released. + expect(await getActivePerProposer(api, gov.proposer.address)).to.equal(beforeActive); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts b/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts new file mode 100644 index 0000000000..d7ba158ba9 --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track1-delay-curve.ts @@ -0,0 +1,157 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + type GovernanceMembership, + getStatusKind, + nudge, + referendumStatusFor, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * REVIEW_INITIAL_DELAY = prod_or_fast!(7_200, 30) + * REVIEW_MAX_DELAY = prod_or_fast!(14_400, 60) + * + * `do_adjust_delay` interpolates the enactment task's dispatch time + * between `submitted` (under full net approval) and `submitted + max_delay` + * (under full net rejection), shaped by the runtime's ease-out + * `AdjustmentCurve` (`1 - (1 - p)^3`). The exact mapping with a 4-voter set: + * + * - 0 votes → enacts at submitted + initial_delay (30) + * - 1 aye (1/4) → enacts at submitted + 8 + * progress = 25%/75% = 33%, curved = 1 - (2/3)^3, + * delay = floor(0.296 * 30) = 8 + * - 1 nay (1/4) → enacts at submitted + 56 + * progress = 25%/51% = 49%, curved ~= 86.7%, + * delay = 30 + floor(0.867 * 30) = 56 + * + * Three tests exercise the three regimes (net approval, net rejection, + * net zero from cancellation) by observing the actual block at which + * `Enacted` fires. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK1_DELAY_CURVE_01", + title: "Governance (fast-runtime) — Track 1 enactment delay adjustment curve", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const beneficiary = generateKeyringPair("sr25519"); + + const REVIEW_INITIAL_DELAY = 30; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + proposers: 3, + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + const delegateToChild = async ( + proposer: KeyringPair + ): Promise<{ + child: number; + childSubmitted: number; + }> => { + const parent = await submitOnTrack( + api, + context, + proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(beneficiary.address, 1n) + ); + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + const arr = (await systemEvents(api)) + .find((e) => e.event.section === "referenda" && e.event.method === "Delegated") + ?.event.data.toJSON() as Array; + const child = arr[1]; + const status = (await referendumStatusFor(api, child)).toJSON() as { + ongoing: { submitted: number }; + }; + return { child, childSubmitted: status.ongoing.submitted }; + }; + + /** Advance blocks until `index` reaches a terminal status; returns the block of transition. */ + const advanceUntilEnacted = async (index: number, maxBlocks: number): Promise => { + for (let i = 0; i < maxBlocks; i++) { + const kind = await getStatusKind(api, index); + if (kind === "enacted") { + return (await api.query.system.number()).toJSON() as number; + } + await nudge(context); + } + throw new Error(`referendum ${index} did not enact within ${maxBlocks} blocks`); + }; + + it({ + id: "T01", + title: "1 aye → enactment shifts earlier (submitted + 8 with ease-out curve)", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[0]); + await castVote(api, context, gov.economic[0], child, true); + // Let the alarm fire to apply the adjustment. + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, REVIEW_INITIAL_DELAY + 5); + const expected = childSubmitted + 8; + // Allow ±2 blocks of slack: the alarm fires one block after + // the vote, and the scheduler may include the task one block + // after its scheduled `when`. + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + expect(enactedAt, "earlier than initial_delay default").to.be.lessThan( + childSubmitted + REVIEW_INITIAL_DELAY + ); + }, + }); + + it({ + id: "T02", + title: "1 nay → enactment shifts later (submitted + 56 with ease-out curve)", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[1]); + await castVote(api, context, gov.economic[0], child, false); + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, 60); + const expected = childSubmitted + 56; + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + expect(enactedAt, "later than initial_delay default").to.be.greaterThan( + childSubmitted + REVIEW_INITIAL_DELAY + ); + }, + }); + + it({ + id: "T03", + title: "1 aye + 1 nay (net zero) returns the schedule to submitted + initial_delay", + test: async () => { + const { child, childSubmitted } = await delegateToChild(gov.proposers[2]); + await castVote(api, context, gov.economic[0], child, true); + await nudge(context); + await castVote(api, context, gov.economic[1], child, false); + await nudge(context); + + const enactedAt = await advanceUntilEnacted(child, 45); + const expected = childSubmitted + REVIEW_INITIAL_DELAY; + expect(enactedAt).to.be.at.least(expected); + expect(enactedAt).to.be.at.most(expected + 2); + }, + }); + }, +}); diff --git a/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts b/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts new file mode 100644 index 0000000000..962b69dada --- /dev/null +++ b/ts-tests/suites/dev_fast/governance/test-track1-natural-enactment.ts @@ -0,0 +1,108 @@ +import { beforeAll, describeSuite, expect } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import { generateKeyringPair } from "../../../utils/account"; +import { + bootstrapMembership, + castVote, + DEV_TRACK, + freeBalance, + type GovernanceMembership, + getStatusKind, + nudge, + referendumStatusFor, + submitOnTrack, + systemEvents, +} from "../../../utils/governance"; + +/** + * Reachable only with `--features fast-runtime`: + * REVIEW_INITIAL_DELAY = prod_or_fast!(7_200, 30) + * + * On delegation, a Track 1 child is born with its enactment task already + * scheduled at `submitted + initial_delay`. If voters do nothing (no + * fast-track and no cancel), the wrapper task fires naturally and runs the + * inner call. This locks in the "Adjustable defaults to executing" + * contract: an approved Triumvirate proposal will eventually dispatch even + * without any review activity. + */ +describeSuite({ + id: "DEV_FAST_GOV_TRACK1_NATURAL_01", + title: "Governance (fast-runtime) — Track 1 natural enactment at initial_delay", + foundationMethods: "dev", + testCases: ({ it, context }) => { + let api: ApiPromise; + let sudoer: KeyringPair; + let gov: GovernanceMembership; + const target = generateKeyringPair("sr25519"); + const targetAmount = 555_000_000n; + + // Mirrors `runtime/src/governance/tracks.rs` under fast-runtime. + const REVIEW_INITIAL_DELAY = 30; + + beforeAll(async () => { + api = context.polkadotJs(); + sudoer = context.keyring.alice; + gov = await bootstrapMembership(api, context, sudoer, { + triumvirate: 3, + economic: 2, + building: 2, + }); + }); + + it({ + id: "T01", + title: "delegated child enacts at submitted + initial_delay with no Track 1 votes", + test: async () => { + const parent = await submitOnTrack( + api, + context, + gov.proposer, + DEV_TRACK.TRIUMVIRATE, + api.tx.balances.forceSetBalance(target.address, targetAmount) + ); + + await castVote(api, context, gov.triumvirate[0], parent, true); + await castVote(api, context, gov.triumvirate[1], parent, true); + await nudge(context); + + const delegated = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Delegated" + ); + expect(delegated, "Delegated event").to.exist; + const arr = delegated?.event.data.toJSON() as Array; + const child = arr[1]; + expect(await getStatusKind(api, child)).to.equal("ongoing"); + + // Without any votes on the child, the scheduled enactment + // task fires at submitted + initial_delay. Use submitted from + // the child's status (set at delegation, not at parent + // submission). + const childStatus = (await referendumStatusFor(api, child)).toJSON() as { + ongoing: { submitted: number }; + } | null; + const childSubmitted = childStatus?.ongoing?.submitted; + expect(childSubmitted, "child submitted block").to.be.a("number"); + + const targetBlock = (childSubmitted as number) + REVIEW_INITIAL_DELAY + 2; + while (((await api.query.system.number()).toJSON() as number) < targetBlock) { + await nudge(context); + } + + const enacted = (await systemEvents(api)).find( + (e) => e.event.section === "referenda" && e.event.method === "Enacted" + ); + // The Enacted event may have fired in an earlier block within + // the polling loop; if so, also accept the terminal status. + expect(await getStatusKind(api, child)).to.equal("enacted"); + if (enacted) { + const data = enacted.event.data.toJSON() as { error?: unknown } | Array; + const errorField = Array.isArray(data) ? data[2] : data.error; + expect(errorField, "Enacted carries no error").to.be.null; + } + + expect(await freeBalance(api, target.address)).to.equal(targetAmount); + }, + }); + }, +}); diff --git a/ts-tests/utils/governance.ts b/ts-tests/utils/governance.ts new file mode 100644 index 0000000000..29d96c564f --- /dev/null +++ b/ts-tests/utils/governance.ts @@ -0,0 +1,305 @@ +import type { DevModeContext } from "@moonwall/cli"; +import type { KeyringPair } from "@moonwall/util"; +import type { ApiPromise } from "@polkadot/api"; +import type { SubmittableExtrinsic } from "@polkadot/api/types"; +import { generateKeyringPair } from "./account"; + +export type Collective = "Proposers" | "Triumvirate" | "Economic" | "Building" | "EconomicEligible"; + +export type ReferendumStatusKind = + | "ongoing" + | "approved" + | "delegated" + | "rejected" + | "cancelled" + | "expired" + | "fastTracked" + | "enacted" + | "killed"; + +export type DispatchModuleError = { section: string; name: string }; +export type DispatchFailure = DispatchModuleError | { kind: string; raw: string }; +export type EventRecordLike = { + event: { + section: string; + method: string; + data: { toJSON(): unknown } & ArrayLike; + }; +}; + +type NumberCodecLike = { toNumber(): number; toJSON(): unknown }; +type OptionCodecLike = { isNone: boolean; isSome: boolean; toJSON(): unknown }; +type AccountInfoLike = { data: { free: { toBigInt(): bigint } } }; + +export const DEV_TRACK = { TRIUMVIRATE: 0, REVIEW: 1 } as const; +export const DEFAULT_FUND = 1_000_000_000_000n; + +type SudoExtrinsic = SubmittableExtrinsic<"promise">; + +/** + * Sign an extrinsic with `signer` and seal it into a fresh block. + * + * Transactions are signed with `era: 0` (immortal). Mortal extrinsics check + * their birth block against `BlockHash`; under the parallel test runner, + * the in-process `ApiPromise` can briefly hold a stale "best block" while + * other forks' nodes drive their own chains forward, and a freshly signed + * mortal tx can be rejected as `AncientBirthBlock` before it reaches the + * pool. Immortal signing sidesteps that race without changing observable + * behavior on the chain under test. + */ +export async function inBlock(context: DevModeContext, signer: KeyringPair, tx: SudoExtrinsic): Promise { + await context.createBlock([await tx.signAsync(signer, { era: 0 })]); +} + +/** Wrap `inner` in `sudo.sudo` and execute it in its own block as `sudoer`. */ +export async function sudoInBlock( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + inner: SudoExtrinsic +): Promise { + await inBlock(context, sudoer, api.tx.sudo.sudo(inner)); +} + +/** Top up the free balance of each address. Idempotent on repeat addresses. */ +export async function fundAccounts( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + addresses: string[], + fund: bigint = DEFAULT_FUND +): Promise { + const seen = new Set(); + for (const address of addresses) { + if (seen.has(address)) continue; + seen.add(address); + await sudoInBlock(api, context, sudoer, api.tx.balances.forceSetBalance(address, fund)); + } +} + +/** Add each `{collective, account}` entry to its collective. */ +export async function addMembers( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + entries: Array<{ collective: Collective; account: KeyringPair | string }> +): Promise { + for (const { collective, account } of entries) { + const address = typeof account === "string" ? account : account.address; + await sudoInBlock(api, context, sudoer, api.tx.multiCollective.addMember(collective, address)); + } +} + +export type GovernanceMembership = { + /** First Proposer; convenient default for tests that only need one. */ + proposer: KeyringPair; + /** Full Proposers list, length matches `layout.proposers` (≥ 1). */ + proposers: KeyringPair[]; + triumvirate: KeyringPair[]; + economic: KeyringPair[]; + building: KeyringPair[]; +}; + +export type MembershipLayout = { + triumvirate: number; + economic: number; + building: number; + /** + * How many Proposers to seat. Distinct proposers are useful when a single + * suite needs to file more than `MaxActivePerProposer` (= 5) referenda + * without freeing slots first. Defaults to 1. + */ + proposers?: number; +}; + +/** + * Mint and seat a standard membership layout. Returns the generated keypairs + * so tests can keep using them. + * + * Triumvirate must equal 3 to satisfy `min_members` once seeded; the others + * accept any size up to the per-collective `max_members`. + */ +export async function bootstrapMembership( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + layout: MembershipLayout +): Promise { + const proposerCount = layout.proposers ?? 1; + const proposers = Array.from({ length: proposerCount }, () => generateKeyringPair("sr25519")); + const triumvirate = Array.from({ length: layout.triumvirate }, () => generateKeyringPair("sr25519")); + const economic = Array.from({ length: layout.economic }, () => generateKeyringPair("sr25519")); + const building = Array.from({ length: layout.building }, () => generateKeyringPair("sr25519")); + + await fundAccounts( + api, + context, + sudoer, + [...proposers, ...triumvirate, ...economic, ...building].map((kp) => kp.address) + ); + + const entries: Array<{ collective: Collective; account: KeyringPair }> = [ + ...proposers.map((account) => ({ collective: "Proposers" as Collective, account })), + ...triumvirate.map((account) => ({ collective: "Triumvirate" as Collective, account })), + ...economic.map((account) => ({ collective: "Economic" as Collective, account })), + ...building.map((account) => ({ collective: "Building" as Collective, account })), + ]; + + await addMembers(api, context, sudoer, entries); + + return { proposer: proposers[0], proposers, triumvirate, economic, building }; +} + +/** Submit `inner` on `track` as `proposer`. Returns the assigned index. */ +export async function submitOnTrack( + api: ApiPromise, + context: DevModeContext, + proposer: KeyringPair, + track: number, + inner: SudoExtrinsic +): Promise { + const index = await referendumCount(api); + await inBlock(context, proposer, api.tx.referenda.submit(track, inner)); + return index; +} + +export async function castVote( + api: ApiPromise, + context: DevModeContext, + voter: KeyringPair, + pollIndex: number, + approve: boolean +): Promise { + await inBlock(context, voter, api.tx.signedVoting.vote(pollIndex, approve)); +} + +export async function removeVote( + api: ApiPromise, + context: DevModeContext, + voter: KeyringPair, + pollIndex: number +): Promise { + await inBlock(context, voter, api.tx.signedVoting.removeVote(pollIndex)); +} + +export async function killReferendum( + api: ApiPromise, + context: DevModeContext, + sudoer: KeyringPair, + index: number +): Promise { + await sudoInBlock(api, context, sudoer, api.tx.referenda.kill(index)); +} + +/** Seal `count` empty blocks so the scheduler can fire pending alarms/tasks. */ +export async function nudge(context: DevModeContext, count = 1): Promise { + for (let i = 0; i < count; i++) { + await context.createBlock([]); + } +} + +type RawDispatchError = { + isModule: boolean; + asModule: Parameters[0]; + type?: string; + toString(): string; +}; + +function decodeDispatchError(api: ApiPromise, dispatchError: RawDispatchError): DispatchFailure { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + return { section: decoded.section, name: decoded.name }; + } + return { kind: dispatchError.type ?? "other", raw: dispatchError.toString() }; +} + +export async function systemEvents(api: ApiPromise): Promise { + return (await api.query.system.events()) as unknown as EventRecordLike[]; +} + +export async function referendumCount(api: ApiPromise): Promise { + return ((await api.query.referenda.referendumCount()) as unknown as NumberCodecLike).toNumber(); +} + +export async function referendumStatusFor(api: ApiPromise, index: number): Promise { + return (await api.query.referenda.referendumStatusFor(index)) as unknown as OptionCodecLike; +} + +export async function isReferendumStatusNone(api: ApiPromise, index: number): Promise { + return (await referendumStatusFor(api, index)).isNone; +} + +export async function isEnactmentTaskNone(api: ApiPromise, index: number): Promise { + return ((await api.query.referenda.enactmentTask(index)) as unknown as OptionCodecLike).isNone; +} + +export async function isVotingForNone(api: ApiPromise, index: number, address: string): Promise { + return ((await api.query.signedVoting.votingFor(index, address)) as unknown as OptionCodecLike).isNone; +} + +export async function freeBalance(api: ApiPromise, address: string): Promise { + return ((await api.query.system.account(address)) as unknown as AccountInfoLike).data.free.toBigInt(); +} + +/** + * Decoded summary of the most recent failure in the latest block. + * + * Captures both: + * - `system.ExtrinsicFailed` for direct signed calls, and + * - `sudo.Sudid { sudo_result: Err(...) }` for calls wrapped in `sudo.sudo`, + * where the outer extrinsic succeeds but the wrapped call returns `Err`. + * + * Returns `null` when the block contains neither. + */ +export async function lastModuleError(api: ApiPromise): Promise { + const events = await systemEvents(api); + + const failed = events.find((e) => e.event.section === "system" && e.event.method === "ExtrinsicFailed"); + if (failed) { + return decodeDispatchError(api, failed.event.data[0] as unknown as RawDispatchError); + } + + const sudid = events.find((e) => e.event.section === "sudo" && e.event.method === "Sudid"); + if (sudid) { + const result = sudid.event.data[0] as unknown as { + isErr: boolean; + asErr: RawDispatchError; + }; + if (result.isErr) { + return decodeDispatchError(api, result.asErr); + } + } + + return null; +} + +/** Reads the variant name of `referendumStatusFor(index)`. */ +export async function getStatusKind(api: ApiPromise, index: number): Promise { + const opt = await referendumStatusFor(api, index); + if (opt.isNone) return null; + const json = opt.toJSON() as Record | string | null; + if (!json || typeof json === "string") return null; + const keys = Object.keys(json); + if (keys.length === 0) return null; + return keys[0] as ReferendumStatusKind; +} + +export type Tally = { ayes: number; nays: number; total: number }; + +export async function getTally(api: ApiPromise, index: number): Promise { + const opt = (await api.query.signedVoting.tallyOf(index)) as unknown as OptionCodecLike; + return opt.isNone ? null : (opt.toJSON() as Tally); +} + +export async function getMembers(api: ApiPromise, collective: Collective): Promise { + const members = await api.query.multiCollective.members(collective); + return (members.toJSON() as string[]) ?? []; +} + +export async function getActiveCount(api: ApiPromise): Promise { + return (await api.query.referenda.activeCount()).toJSON() as number; +} + +export async function getActivePerProposer(api: ApiPromise, address: string): Promise { + return (await api.query.referenda.activePerProposer(address)).toJSON() as number; +}