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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/typescript-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions chain-extensions/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ();
Expand Down
203 changes: 203 additions & 0 deletions docs/governance/README.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions eco-tests/src/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ();
Expand Down
4 changes: 3 additions & 1 deletion pallets/admin-utils/src/tests/mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ();
Expand Down Expand Up @@ -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<u32> = Some(10);
}

impl pallet_scheduler::Config for Test {
Expand Down
15 changes: 10 additions & 5 deletions pallets/subtensor/src/coinbase/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,14 +121,15 @@ impl<T: Config> Pallet<T> {
// --- 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;
Expand All @@ -145,19 +146,23 @@ impl<T: Config> Pallet<T> {
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::<T>::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::<T>::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.
Expand Down
32 changes: 32 additions & 0 deletions pallets/subtensor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1406,6 +1408,36 @@ pub mod pallet {
pub type OwnedHotkeys<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, Vec<T::AccountId>, ValueQuery>;

/// Number of hotkeys controlled by this coldkey that are currently registered on the root subnet.
#[pallet::storage]
pub type RootRegisteredHotkeyCount<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, u32, ValueQuery>;

/// EMA state for each root-registered coldkey.
#[pallet::storage]
pub type RootRegisteredEma<T: Config> =
StorageMap<_, Blake2_128Concat, T::AccountId, EmaState, ValueQuery>;

/// Fixed coldkey snapshot used by the current EMA sampling cycle.
#[pallet::storage]
pub type CurrentCycleMembers<T: Config> =
StorageValue<_, BoundedVec<T::AccountId, ConstU32<64>>, ValueQuery>;

/// Internal: the EMA value provider for the runtime.
pub type EmaProviderOf<T> = <T as Config>::EmaValueProvider;

/// Internal: provider-owned progress for the coldkey currently being sampled.
pub type EmaProgressOf<T> = <EmaProviderOf<T> as EmaValueProvider<AccountIdOf<T>>>::Progress;

/// Internal: in-flight sample for the current coldkey. Present only
/// while `T::EmaValueProvider` has returned `SampleStep::Continue`.
pub type InFlightEmaSampleOf<T> = InFlightEmaSample<AccountIdOf<T>, EmaProgressOf<T>>;

/// Cursor and in-flight provider progress for the EMA sampling cycle.
#[pallet::storage]
pub type EmaSamplerState<T: Config> =
StorageValue<_, (u32, Option<InFlightEmaSampleOf<T>>), ValueQuery>;

/// --- DMAP ( cold, netuid )--> hot | Returns the hotkey a coldkey will autostake to with mining rewards.
#[pallet::storage]
pub type AutoStakeDestination<T: Config> = StorageDoubleMap<
Expand Down
Loading
Loading