From 834f14cefa209f8a611bc06b58a748ebde2c4158 Mon Sep 17 00:00:00 2001
From: Pawel Polewicz
Date: Mon, 19 Jan 2026 22:17:12 +0000
Subject: [PATCH 1/9] Reapply "Voting Power EMA"
This reverts commit 8a1e018a87d9e296a57cbb10bff74b7166d7bfed.
---
contract-tests/src/contracts/votingPower.ts | 104 +++
.../test/votingPower.precompile.test.ts | 226 ++++++
pallets/admin-utils/src/lib.rs | 2 +
pallets/subtensor/src/epoch/run_epoch.rs | 6 +
pallets/subtensor/src/lib.rs | 47 +-
pallets/subtensor/src/macros/dispatches.rs | 91 +++
pallets/subtensor/src/macros/errors.rs | 4 +
pallets/subtensor/src/macros/events.rs | 29 +
pallets/subtensor/src/subnets/mechanism.rs | 7 +-
pallets/subtensor/src/swap/swap_hotkey.rs | 5 +
pallets/subtensor/src/tests/mod.rs | 1 +
pallets/subtensor/src/tests/voting_power.rs | 760 ++++++++++++++++++
pallets/subtensor/src/utils/mod.rs | 1 +
pallets/subtensor/src/utils/voting_power.rs | 280 +++++++
precompiles/src/lib.rs | 8 +-
precompiles/src/voting_power.rs | 131 +++
16 files changed, 1699 insertions(+), 3 deletions(-)
create mode 100644 contract-tests/src/contracts/votingPower.ts
create mode 100644 contract-tests/test/votingPower.precompile.test.ts
create mode 100644 pallets/subtensor/src/tests/voting_power.rs
create mode 100644 pallets/subtensor/src/utils/voting_power.rs
create mode 100644 precompiles/src/voting_power.rs
diff --git a/contract-tests/src/contracts/votingPower.ts b/contract-tests/src/contracts/votingPower.ts
new file mode 100644
index 0000000000..bbcc3ca6e6
--- /dev/null
+++ b/contract-tests/src/contracts/votingPower.ts
@@ -0,0 +1,104 @@
+export const IVOTING_POWER_ADDRESS = "0x000000000000000000000000000000000000080d";
+
+export const IVotingPowerABI = [
+ {
+ "inputs": [
+ {
+ "internalType": "uint16",
+ "name": "netuid",
+ "type": "uint16"
+ },
+ {
+ "internalType": "bytes32",
+ "name": "hotkey",
+ "type": "bytes32"
+ }
+ ],
+ "name": "getVotingPower",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint16",
+ "name": "netuid",
+ "type": "uint16"
+ }
+ ],
+ "name": "isVotingPowerTrackingEnabled",
+ "outputs": [
+ {
+ "internalType": "bool",
+ "name": "",
+ "type": "bool"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint16",
+ "name": "netuid",
+ "type": "uint16"
+ }
+ ],
+ "name": "getVotingPowerDisableAtBlock",
+ "outputs": [
+ {
+ "internalType": "uint64",
+ "name": "",
+ "type": "uint64"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint16",
+ "name": "netuid",
+ "type": "uint16"
+ }
+ ],
+ "name": "getVotingPowerEmaAlpha",
+ "outputs": [
+ {
+ "internalType": "uint64",
+ "name": "",
+ "type": "uint64"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ },
+ {
+ "inputs": [
+ {
+ "internalType": "uint16",
+ "name": "netuid",
+ "type": "uint16"
+ }
+ ],
+ "name": "getTotalVotingPower",
+ "outputs": [
+ {
+ "internalType": "uint256",
+ "name": "",
+ "type": "uint256"
+ }
+ ],
+ "stateMutability": "view",
+ "type": "function"
+ }
+]
diff --git a/contract-tests/test/votingPower.precompile.test.ts b/contract-tests/test/votingPower.precompile.test.ts
new file mode 100644
index 0000000000..26b29e257c
--- /dev/null
+++ b/contract-tests/test/votingPower.precompile.test.ts
@@ -0,0 +1,226 @@
+import * as assert from "assert";
+
+import { getDevnetApi, getRandomSubstrateKeypair, getAliceSigner, getSignerFromKeypair, waitForTransactionWithRetry } from "../src/substrate"
+import { getPublicClient } from "../src/utils";
+import { ETH_LOCAL_URL } from "../src/config";
+import { devnet } from "@polkadot-api/descriptors"
+import { PublicClient } from "viem";
+import { PolkadotSigner, TypedApi } from "polkadot-api";
+import { toViemAddress, convertPublicKeyToSs58 } from "../src/address-utils"
+import { IVotingPowerABI, IVOTING_POWER_ADDRESS } from "../src/contracts/votingPower"
+import { forceSetBalanceToSs58Address, addNewSubnetwork, startCall } from "../src/subtensor";
+
+describe("Test VotingPower Precompile", () => {
+ // init substrate part
+ const hotkey = getRandomSubstrateKeypair();
+ const coldkey = getRandomSubstrateKeypair();
+ let publicClient: PublicClient;
+
+ let api: TypedApi;
+
+ // sudo account alice as signer
+ let alice: PolkadotSigner;
+
+ // init other variable
+ let subnetId = 0;
+
+ before(async () => {
+ // init variables got from await and async
+ publicClient = await getPublicClient(ETH_LOCAL_URL)
+ api = await getDevnetApi()
+ alice = await getAliceSigner();
+
+ await forceSetBalanceToSs58Address(api, convertPublicKeyToSs58(hotkey.publicKey))
+ await forceSetBalanceToSs58Address(api, convertPublicKeyToSs58(coldkey.publicKey))
+
+ let netuid = await addNewSubnetwork(api, hotkey, coldkey)
+ await startCall(api, netuid, coldkey)
+ subnetId = netuid
+ })
+
+ describe("VotingPower Tracking Status Functions", () => {
+ it("isVotingPowerTrackingEnabled returns false by default", async () => {
+ const isEnabled = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "isVotingPowerTrackingEnabled",
+ args: [subnetId]
+ })
+
+ assert.ok(isEnabled !== undefined, "isVotingPowerTrackingEnabled should return a value");
+ assert.strictEqual(typeof isEnabled, 'boolean', "isVotingPowerTrackingEnabled should return a boolean");
+ // By default, voting power tracking is disabled
+ assert.strictEqual(isEnabled, false, "Voting power tracking should be disabled by default");
+ });
+
+ it("getVotingPowerDisableAtBlock returns 0 when not scheduled", async () => {
+ const disableAtBlock = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPowerDisableAtBlock",
+ args: [subnetId]
+ })
+
+ assert.ok(disableAtBlock !== undefined, "getVotingPowerDisableAtBlock should return a value");
+ assert.strictEqual(typeof disableAtBlock, 'bigint', "getVotingPowerDisableAtBlock should return a bigint");
+ assert.strictEqual(disableAtBlock, BigInt(0), "Disable at block should be 0 when not scheduled");
+ });
+
+ it("getVotingPowerEmaAlpha returns default alpha value", async () => {
+ const alpha = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPowerEmaAlpha",
+ args: [subnetId]
+ })
+
+ assert.ok(alpha !== undefined, "getVotingPowerEmaAlpha should return a value");
+ assert.strictEqual(typeof alpha, 'bigint', "getVotingPowerEmaAlpha should return a bigint");
+ // Default alpha is 0.1 * 10^18 = 100_000_000_000_000_000
+ assert.strictEqual(alpha, BigInt("100000000000000000"), "Default alpha should be 0.1 (100_000_000_000_000_000)");
+ });
+ });
+
+ describe("VotingPower Query Functions", () => {
+ it("getVotingPower returns 0 for hotkey without voting power", async () => {
+ // Convert hotkey public key to bytes32 format (0x prefixed hex string)
+ const hotkeyBytes32 = '0x' + Buffer.from(hotkey.publicKey).toString('hex');
+
+ const votingPower = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPower",
+ args: [subnetId, hotkeyBytes32 as `0x${string}`]
+ })
+
+ assert.ok(votingPower !== undefined, "getVotingPower should return a value");
+ assert.strictEqual(typeof votingPower, 'bigint', "getVotingPower should return a bigint");
+ // Without voting power tracking enabled, voting power should be 0
+ assert.strictEqual(votingPower, BigInt(0), "Voting power should be 0 when tracking is disabled");
+ });
+
+ it("getVotingPower returns 0 for unknown hotkey", async () => {
+ // Generate a random hotkey that doesn't exist
+ const randomHotkey = getRandomSubstrateKeypair();
+ const randomHotkeyBytes32 = '0x' + Buffer.from(randomHotkey.publicKey).toString('hex');
+
+ const votingPower = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPower",
+ args: [subnetId, randomHotkeyBytes32 as `0x${string}`]
+ })
+
+ assert.ok(votingPower !== undefined, "getVotingPower should return a value");
+ assert.strictEqual(votingPower, BigInt(0), "Voting power should be 0 for unknown hotkey");
+ });
+
+ it("getTotalVotingPower returns 0 when no voting power exists", async () => {
+ const totalVotingPower = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getTotalVotingPower",
+ args: [subnetId]
+ })
+
+ assert.ok(totalVotingPower !== undefined, "getTotalVotingPower should return a value");
+ assert.strictEqual(typeof totalVotingPower, 'bigint', "getTotalVotingPower should return a bigint");
+ assert.strictEqual(totalVotingPower, BigInt(0), "Total voting power should be 0 when tracking is disabled");
+ });
+ });
+
+ describe("VotingPower with Tracking Enabled", () => {
+ let enabledSubnetId: number;
+
+ before(async () => {
+ // Create a new subnet for this test
+ const hotkey2 = getRandomSubstrateKeypair();
+ const coldkey2 = getRandomSubstrateKeypair();
+
+ await forceSetBalanceToSs58Address(api, convertPublicKeyToSs58(hotkey2.publicKey))
+ await forceSetBalanceToSs58Address(api, convertPublicKeyToSs58(coldkey2.publicKey))
+
+ enabledSubnetId = await addNewSubnetwork(api, hotkey2, coldkey2)
+ await startCall(api, enabledSubnetId, coldkey2)
+
+ // Enable voting power tracking via sudo
+ const internalCall = api.tx.SubtensorModule.enable_voting_power_tracking({ netuid: enabledSubnetId })
+ const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall })
+ await waitForTransactionWithRetry(api, tx, alice)
+ });
+
+ it("isVotingPowerTrackingEnabled returns true after enabling", async () => {
+ const isEnabled = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "isVotingPowerTrackingEnabled",
+ args: [enabledSubnetId]
+ })
+
+ assert.strictEqual(isEnabled, true, "Voting power tracking should be enabled");
+ });
+
+ it("getVotingPowerDisableAtBlock still returns 0 when enabled but not scheduled for disable", async () => {
+ const disableAtBlock = await publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPowerDisableAtBlock",
+ args: [enabledSubnetId]
+ })
+
+ assert.strictEqual(disableAtBlock, BigInt(0), "Disable at block should still be 0");
+ });
+ });
+
+ describe("All precompile functions are accessible", () => {
+ it("All VotingPower precompile functions can be called", async () => {
+ const hotkeyBytes32 = '0x' + Buffer.from(hotkey.publicKey).toString('hex');
+
+ // Test all five functions
+ const results = await Promise.all([
+ publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPower",
+ args: [subnetId, hotkeyBytes32 as `0x${string}`]
+ }),
+ publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "isVotingPowerTrackingEnabled",
+ args: [subnetId]
+ }),
+ publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPowerDisableAtBlock",
+ args: [subnetId]
+ }),
+ publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getVotingPowerEmaAlpha",
+ args: [subnetId]
+ }),
+ publicClient.readContract({
+ abi: IVotingPowerABI,
+ address: toViemAddress(IVOTING_POWER_ADDRESS),
+ functionName: "getTotalVotingPower",
+ args: [subnetId]
+ })
+ ]);
+
+ // All functions should return defined values
+ results.forEach((result: unknown, index: number) => {
+ assert.ok(result !== undefined, `Function ${index} should return a value`);
+ });
+
+ // Verify types
+ assert.strictEqual(typeof results[0], 'bigint', "getVotingPower should return bigint");
+ assert.strictEqual(typeof results[1], 'boolean', "isVotingPowerTrackingEnabled should return boolean");
+ assert.strictEqual(typeof results[2], 'bigint', "getVotingPowerDisableAtBlock should return bigint");
+ assert.strictEqual(typeof results[3], 'bigint', "getVotingPowerEmaAlpha should return bigint");
+ assert.strictEqual(typeof results[4], 'bigint', "getTotalVotingPower should return bigint");
+ });
+ });
+});
diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs
index 2a33262073..5d7713df15 100644
--- a/pallets/admin-utils/src/lib.rs
+++ b/pallets/admin-utils/src/lib.rs
@@ -145,6 +145,8 @@ pub mod pallet {
Leasing,
/// Address mapping precompile
AddressMapping,
+ /// Voting power precompile
+ VotingPower,
}
#[pallet::type_value]
diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs
index f56b8a89a4..f90175ce0c 100644
--- a/pallets/subtensor/src/epoch/run_epoch.rs
+++ b/pallets/subtensor/src/epoch/run_epoch.rs
@@ -22,6 +22,7 @@ pub struct EpochTerms {
pub validator_trust: u16,
pub new_validator_permit: bool,
pub bond: Vec<(u16, u16)>,
+ pub stake: u64,
}
pub struct EpochOutput(pub BTreeMap);
@@ -988,6 +989,10 @@ impl Pallet {
.iter()
.map(|xi| fixed_proportion_to_u16(*xi))
.collect::>();
+ let raw_stake: Vec = total_stake
+ .iter()
+ .map(|s| s.saturating_to_num::())
+ .collect::>();
for (_hotkey, terms) in terms_map.iter_mut() {
terms.dividend = cloned_dividends.get(terms.uid).copied().unwrap_or_default();
@@ -1012,6 +1017,7 @@ impl Pallet {
.get(terms.uid)
.copied()
.unwrap_or_default();
+ terms.stake = raw_stake.get(terms.uid).copied().unwrap_or_default();
let old_validator_permit = validator_permits
.get(terms.uid)
.copied()
diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs
index 6ae43ac384..9db212330e 100644
--- a/pallets/subtensor/src/lib.rs
+++ b/pallets/subtensor/src/lib.rs
@@ -1,6 +1,7 @@
#![cfg_attr(not(feature = "std"), no_std)]
#![recursion_limit = "512"]
#![allow(clippy::too_many_arguments)]
+#![allow(clippy::zero_prefixed_literal)]
// Edit this file to define custom logic or remove it if it is not needed.
// Learn more about FRAME and the core library of Substrate FRAME pallets:
//
@@ -1894,8 +1895,52 @@ pub mod pallet {
pub type SubtokenEnabled =
StorageMap<_, Identity, NetUid, bool, ValueQuery, DefaultFalse>;
- /// Default value for burn keys limit
+ // =======================================
+ // ==== VotingPower Storage ====
+ // =======================================
+
#[pallet::type_value]
+ /// Default VotingPower EMA alpha value (0.1 represented as u64 with 18 decimals)
+ /// alpha = 0.1 means slow response, 10% weight to new values per epoch
+ pub fn DefaultVotingPowerEmaAlpha() -> u64 {
+ 0_003_570_000_000_000_000 // 0.00357 * 10^18 = 2 weeks e-folding (time-constant) @ 361
+ // blocks per tempo
+ // After 2 weeks -> EMA reaches 63.2% of a step change
+ // After ~4 weeks -> 86.5%
+ // After ~6 weeks -> 95%
+ }
+
+ #[pallet::storage]
+ /// --- DMAP ( netuid, hotkey ) --> voting_power | EMA of stake for voting
+ /// This tracks stake EMA updated every epoch when VotingPowerTrackingEnabled is true.
+ /// Used by smart contracts to determine validator voting power for subnet governance.
+ pub type VotingPower =
+ StorageDoubleMap<_, Identity, NetUid, Blake2_128Concat, T::AccountId, u64, ValueQuery>;
+
+ #[pallet::storage]
+ /// --- MAP ( netuid ) --> bool | Whether voting power tracking is enabled for this subnet.
+ /// When enabled, VotingPower EMA is updated every epoch. Default is false.
+ /// When disabled with disable_at_block set, tracking continues until that block.
+ pub type VotingPowerTrackingEnabled =
+ StorageMap<_, Identity, NetUid, bool, ValueQuery, DefaultFalse>;
+
+ #[pallet::storage]
+ /// --- MAP ( netuid ) --> block_number | Block at which voting power tracking will be disabled.
+ /// When set (non-zero), tracking continues until this block, then automatically disables
+ /// and clears VotingPower entries for the subnet. Provides a 14-day grace period.
+ pub type VotingPowerDisableAtBlock =
+ StorageMap<_, Identity, NetUid, u64, ValueQuery>;
+
+ #[pallet::storage]
+ /// --- MAP ( netuid ) --> u64 | EMA alpha value for voting power calculation.
+ /// Higher alpha = faster response to stake changes.
+ /// Stored as u64 with 18 decimal precision (1.0 = 10^18).
+ /// Only settable by sudo/root.
+ pub type VotingPowerEmaAlpha =
+ StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultVotingPowerEmaAlpha>;
+
+ #[pallet::type_value]
+ /// Default value for burn keys limit
pub fn DefaultImmuneOwnerUidsLimit() -> u16 {
1
}
diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs
index 2a362783ef..240864ae74 100644
--- a/pallets/subtensor/src/macros/dispatches.rs
+++ b/pallets/subtensor/src/macros/dispatches.rs
@@ -2432,5 +2432,96 @@ mod dispatches {
Ok(())
}
+
+ /// Enables voting power tracking for a subnet.
+ ///
+ /// This function can be called by the subnet owner or root.
+ /// When enabled, voting power EMA is updated every epoch for all validators.
+ /// Voting power starts at 0 and increases over epochs.
+ ///
+ /// # Arguments:
+ /// * `origin` - The origin of the call, must be subnet owner or root.
+ /// * `netuid` - The subnet to enable voting power tracking for.
+ ///
+ /// # Errors:
+ /// * `SubnetNotExist` - If the subnet does not exist.
+ /// * `NotSubnetOwner` - If the caller is not the subnet owner or root.
+ #[pallet::call_index(125)]
+ #[pallet::weight((
+ Weight::from_parts(10_000, 0)
+ .saturating_add(T::DbWeight::get().reads(2))
+ .saturating_add(T::DbWeight::get().writes(2)),
+ DispatchClass::Operational,
+ Pays::Yes
+ ))]
+ pub fn enable_voting_power_tracking(
+ origin: OriginFor,
+ netuid: NetUid,
+ ) -> DispatchResult {
+ Self::ensure_subnet_owner_or_root(origin, netuid)?;
+ Self::do_enable_voting_power_tracking(netuid)
+ }
+
+ /// Schedules disabling of voting power tracking for a subnet.
+ ///
+ /// This function can be called by the subnet owner or root.
+ /// Voting power tracking will continue for 14 days (grace period) after this call,
+ /// then automatically disable and clear all VotingPower entries for the subnet.
+ ///
+ /// # Arguments:
+ /// * `origin` - The origin of the call, must be subnet owner or root.
+ /// * `netuid` - The subnet to schedule disabling voting power tracking for.
+ ///
+ /// # Errors:
+ /// * `SubnetNotExist` - If the subnet does not exist.
+ /// * `NotSubnetOwner` - If the caller is not the subnet owner or root.
+ /// * `VotingPowerTrackingNotEnabled` - If voting power tracking is not enabled.
+ #[pallet::call_index(126)]
+ #[pallet::weight((
+ Weight::from_parts(10_000, 0)
+ .saturating_add(T::DbWeight::get().reads(2))
+ .saturating_add(T::DbWeight::get().writes(1)),
+ DispatchClass::Operational,
+ Pays::Yes
+ ))]
+ pub fn disable_voting_power_tracking(
+ origin: OriginFor,
+ netuid: NetUid,
+ ) -> DispatchResult {
+ Self::ensure_subnet_owner_or_root(origin, netuid)?;
+ Self::do_disable_voting_power_tracking(netuid)
+ }
+
+ /// Sets the EMA alpha value for voting power calculation on a subnet.
+ ///
+ /// This function can only be called by root (sudo).
+ /// Higher alpha = faster response to stake changes.
+ /// Alpha is stored as u64 with 18 decimal precision (1.0 = 10^18).
+ ///
+ /// # Arguments:
+ /// * `origin` - The origin of the call, must be root.
+ /// * `netuid` - The subnet to set the alpha for.
+ /// * `alpha` - The new alpha value (u64 with 18 decimal precision).
+ ///
+ /// # Errors:
+ /// * `BadOrigin` - If the origin is not root.
+ /// * `SubnetNotExist` - If the subnet does not exist.
+ /// * `InvalidVotingPowerEmaAlpha` - If alpha is greater than 10^18 (1.0).
+ #[pallet::call_index(127)]
+ #[pallet::weight((
+ Weight::from_parts(6_000, 0)
+ .saturating_add(T::DbWeight::get().reads(1))
+ .saturating_add(T::DbWeight::get().writes(1)),
+ DispatchClass::Operational,
+ Pays::Yes
+ ))]
+ pub fn sudo_set_voting_power_ema_alpha(
+ origin: OriginFor,
+ netuid: NetUid,
+ alpha: u64,
+ ) -> DispatchResult {
+ ensure_root(origin)?;
+ Self::do_set_voting_power_ema_alpha(netuid, alpha)
+ }
}
}
diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs
index 6c3d7a35df..995c9b5a31 100644
--- a/pallets/subtensor/src/macros/errors.rs
+++ b/pallets/subtensor/src/macros/errors.rs
@@ -266,6 +266,10 @@ mod errors {
InvalidRootClaimThreshold,
/// Exceeded subnet limit number or zero.
InvalidSubnetNumber,
+ /// Voting power tracking is not enabled for this subnet.
+ VotingPowerTrackingNotEnabled,
+ /// Invalid voting power EMA alpha value (must be <= 10^18).
+ InvalidVotingPowerEmaAlpha,
/// Unintended precision loss when unstaking alpha
PrecisionLoss,
}
diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs
index c86cc1a1e5..457eacd7b1 100644
--- a/pallets/subtensor/src/macros/events.rs
+++ b/pallets/subtensor/src/macros/events.rs
@@ -472,6 +472,35 @@ mod events {
root_claim_type: RootClaimTypeEnum,
},
+ /// Voting power tracking has been enabled for a subnet.
+ VotingPowerTrackingEnabled {
+ /// The subnet ID
+ netuid: NetUid,
+ },
+
+ /// Voting power tracking has been scheduled for disabling.
+ /// Tracking will continue until disable_at_block, then stop and clear entries.
+ VotingPowerTrackingDisableScheduled {
+ /// The subnet ID
+ netuid: NetUid,
+ /// Block at which tracking will be disabled
+ disable_at_block: u64,
+ },
+
+ /// Voting power tracking has been fully disabled and entries cleared.
+ VotingPowerTrackingDisabled {
+ /// The subnet ID
+ netuid: NetUid,
+ },
+
+ /// Voting power EMA alpha has been set for a subnet.
+ VotingPowerEmaAlphaSet {
+ /// The subnet ID
+ netuid: NetUid,
+ /// The new alpha value (u64 with 18 decimal precision)
+ alpha: u64,
+ },
+
/// Subnet lease dividends have been distributed.
SubnetLeaseDividendsDistributed {
/// The lease ID
diff --git a/pallets/subtensor/src/subnets/mechanism.rs b/pallets/subtensor/src/subnets/mechanism.rs
index 481974ef05..b5ed928930 100644
--- a/pallets/subtensor/src/subnets/mechanism.rs
+++ b/pallets/subtensor/src/subnets/mechanism.rs
@@ -322,6 +322,7 @@ impl Pallet {
sub_weight,
);
acc_terms.new_validator_permit |= terms.new_validator_permit;
+ acc_terms.stake = acc_terms.stake.saturating_add(terms.stake);
})
.or_insert_with(|| {
// weighted insert for the first sub-subnet seen for this hotkey
@@ -349,7 +350,8 @@ impl Pallet {
sub_weight,
),
new_validator_permit: terms.new_validator_permit,
- bond: Vec::new(), // aggregated map doesn’t use bonds; keep empty
+ bond: Vec::new(), // aggregated map doesn't use bonds; keep empty
+ stake: terms.stake,
}
});
acc
@@ -358,6 +360,9 @@ impl Pallet {
// State updates from epoch function
Self::persist_netuid_epoch_terms(netuid, &aggregated);
+ // Update voting power EMA for all validators on this subnet
+ Self::update_voting_power_for_subnet(netuid, &aggregated);
+
// Remap BTreeMap back to Vec<(T::AccountId, AlphaCurrency, AlphaCurrency)> format
// for processing emissions in run_coinbase
// Emission tuples ( hotkeys, server_emission, validator_emission )
diff --git a/pallets/subtensor/src/swap/swap_hotkey.rs b/pallets/subtensor/src/swap/swap_hotkey.rs
index 4fdf87fb7b..a54a02a750 100644
--- a/pallets/subtensor/src/swap/swap_hotkey.rs
+++ b/pallets/subtensor/src/swap/swap_hotkey.rs
@@ -493,6 +493,11 @@ impl Pallet {
// 8.3 Swap TaoDividendsPerSubnet
// Tao dividends were removed
+ // 8.4 Swap VotingPower
+ // VotingPower( netuid, hotkey ) --> u64 -- the voting power EMA for the hotkey.
+ Self::swap_voting_power_for_hotkey(old_hotkey, new_hotkey, netuid);
+ weight.saturating_accrue(T::DbWeight::get().reads_writes(2, 2));
+
// 9. Swap Alpha
// Alpha( hotkey, coldkey, netuid ) -> alpha
let old_alpha_values: Vec<((T::AccountId, NetUid), U64F64)> =
diff --git a/pallets/subtensor/src/tests/mod.rs b/pallets/subtensor/src/tests/mod.rs
index bbaf25af58..6ce0639516 100644
--- a/pallets/subtensor/src/tests/mod.rs
+++ b/pallets/subtensor/src/tests/mod.rs
@@ -30,4 +30,5 @@ mod swap_coldkey;
mod swap_hotkey;
mod swap_hotkey_with_subnet;
mod uids;
+mod voting_power;
mod weights;
diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs
new file mode 100644
index 0000000000..7edc06fbab
--- /dev/null
+++ b/pallets/subtensor/src/tests/voting_power.rs
@@ -0,0 +1,760 @@
+#![allow(unused, clippy::indexing_slicing, clippy::panic, clippy::unwrap_used)]
+
+use alloc::collections::BTreeMap;
+use frame_support::weights::Weight;
+use frame_support::{assert_err, assert_noop, assert_ok};
+use frame_system::RawOrigin;
+use sp_core::U256;
+use subtensor_runtime_common::NetUid;
+
+use super::mock;
+use super::mock::*;
+use crate::epoch::run_epoch::EpochTerms;
+use crate::utils::voting_power::{
+ MAX_VOTING_POWER_EMA_ALPHA, VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS,
+};
+use crate::*;
+
+// ============================================
+// === Test Helpers ===
+// ============================================
+
+const DEFAULT_STAKE_AMOUNT: u64 = 1_000_000_000_000; // 1 million RAO
+
+/// Build epoch output from current state for testing voting power updates.
+fn build_mock_epoch_output(netuid: NetUid) -> BTreeMap {
+ let n = SubtensorModule::get_subnetwork_n(netuid);
+ let validator_permits = ValidatorPermit::::get(netuid);
+
+ let mut output = BTreeMap::new();
+ for uid in 0..n {
+ if let Ok(hotkey) = SubtensorModule::get_hotkey_for_net_and_uid(netuid, uid) {
+ let has_permit = validator_permits
+ .get(uid as usize)
+ .copied()
+ .unwrap_or(false);
+ let stake = SubtensorModule::get_stake_for_hotkey_on_subnet(&hotkey, netuid).to_u64();
+ output.insert(
+ hotkey,
+ EpochTerms {
+ uid: uid as usize,
+ new_validator_permit: has_permit,
+ stake,
+ ..Default::default()
+ },
+ );
+ }
+ }
+ output
+}
+
+/// Test fixture containing common test setup data
+struct VotingPowerTestFixture {
+ hotkey: U256,
+ coldkey: U256,
+ netuid: NetUid,
+}
+
+impl VotingPowerTestFixture {
+ /// Create a basic fixture with a dynamic network
+ fn new() -> Self {
+ let hotkey = U256::from(1);
+ let coldkey = U256::from(2);
+ let netuid = add_dynamic_network(&hotkey, &coldkey);
+ Self {
+ hotkey,
+ coldkey,
+ netuid,
+ }
+ }
+
+ /// Setup reserves and add balance to coldkey for staking
+ fn setup_for_staking(&self) {
+ self.setup_for_staking_with_amount(DEFAULT_STAKE_AMOUNT);
+ }
+
+ /// Setup reserves and add balance with custom amount
+ #[allow(clippy::arithmetic_side_effects)]
+ fn setup_for_staking_with_amount(&self, amount: u64) {
+ mock::setup_reserves(self.netuid, (amount * 100).into(), (amount * 100).into());
+ SubtensorModule::add_balance_to_coldkey_account(&self.coldkey, amount * 10);
+ }
+
+ /// Enable voting power tracking for the subnet
+ fn enable_tracking(&self) {
+ assert_ok!(SubtensorModule::enable_voting_power_tracking(
+ RuntimeOrigin::signed(self.coldkey),
+ self.netuid
+ ));
+ }
+
+ /// Add stake from coldkey to hotkey
+ fn add_stake(&self, amount: u64) {
+ assert_ok!(SubtensorModule::add_stake(
+ RuntimeOrigin::signed(self.coldkey),
+ self.hotkey,
+ self.netuid,
+ amount.into()
+ ));
+ }
+
+ /// Set validator permit for the hotkey (uid 0)
+ fn set_validator_permit(&self, has_permit: bool) {
+ ValidatorPermit::::insert(self.netuid, vec![has_permit]);
+ }
+
+ /// Run voting power update for N epochs
+ fn run_epochs(&self, n: u32) {
+ for _ in 0..n {
+ let epoch_output = build_mock_epoch_output(self.netuid);
+ SubtensorModule::update_voting_power_for_subnet(self.netuid, &epoch_output);
+ }
+ }
+
+ /// Get current voting power for the hotkey
+ fn get_voting_power(&self) -> u64 {
+ SubtensorModule::get_voting_power(self.netuid, &self.hotkey)
+ }
+
+ /// Full setup: reserves, balance, tracking enabled, stake added, validator permit
+ fn setup_full(&self) {
+ self.setup_for_staking();
+ self.enable_tracking();
+ self.add_stake(DEFAULT_STAKE_AMOUNT);
+ self.set_validator_permit(true);
+ }
+}
+
+// ============================================
+// === Test Enable/Disable Voting Power ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_enable_voting_power_tracking --exact --nocapture
+#[test]
+fn test_enable_voting_power_tracking() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Initially disabled
+ assert!(!SubtensorModule::get_voting_power_tracking_enabled(
+ f.netuid
+ ));
+
+ // Enable tracking (subnet owner can do this)
+ f.enable_tracking();
+
+ // Now enabled
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ assert_eq!(
+ SubtensorModule::get_voting_power_disable_at_block(f.netuid),
+ 0
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_enable_voting_power_tracking_root_can_enable --exact --nocapture
+#[test]
+fn test_enable_voting_power_tracking_root_can_enable() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Root can enable
+ assert_ok!(SubtensorModule::enable_voting_power_tracking(
+ RuntimeOrigin::root(),
+ f.netuid
+ ));
+
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_disable_voting_power_tracking_schedules_disable --exact --nocapture
+#[test]
+fn test_disable_voting_power_tracking_schedules_disable() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.enable_tracking();
+
+ let current_block = SubtensorModule::get_current_block_as_u64();
+
+ // Schedule disable
+ assert_ok!(SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid
+ ));
+
+ // Still enabled, but scheduled for disable
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ let disable_at = SubtensorModule::get_voting_power_disable_at_block(f.netuid);
+ assert_eq!(
+ disable_at,
+ current_block + VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_disable_voting_power_tracking_fails_when_not_enabled --exact --nocapture
+#[test]
+fn test_disable_voting_power_tracking_fails_when_not_enabled() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Try to disable when not enabled
+ assert_noop!(
+ SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid
+ ),
+ Error::::VotingPowerTrackingNotEnabled
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_enable_voting_power_tracking_non_owner_fails --exact --nocapture
+#[test]
+fn test_enable_voting_power_tracking_non_owner_fails() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ let random_account = U256::from(999);
+
+ // Non-owner cannot enable (returns BadOrigin)
+ assert_noop!(
+ SubtensorModule::enable_voting_power_tracking(
+ RuntimeOrigin::signed(random_account),
+ f.netuid
+ ),
+ sp_runtime::DispatchError::BadOrigin
+ );
+
+ // Should still be disabled
+ assert!(!SubtensorModule::get_voting_power_tracking_enabled(
+ f.netuid
+ ));
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_disable_voting_power_tracking_non_owner_fails --exact --nocapture
+#[test]
+fn test_disable_voting_power_tracking_non_owner_fails() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ let random_account = U256::from(999);
+ f.enable_tracking();
+
+ // Non-owner cannot disable (returns BadOrigin)
+ assert_noop!(
+ SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(random_account),
+ f.netuid
+ ),
+ sp_runtime::DispatchError::BadOrigin
+ );
+
+ // Should still be enabled with no disable scheduled
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ assert_eq!(
+ SubtensorModule::get_voting_power_disable_at_block(f.netuid),
+ 0
+ );
+ });
+}
+
+// ============================================
+// === Test EMA Alpha ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_set_voting_power_ema_alpha --exact --nocapture
+#[test]
+fn test_set_voting_power_ema_alpha() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Get default alpha
+ let default_alpha = SubtensorModule::get_voting_power_ema_alpha(f.netuid);
+ assert_eq!(default_alpha, 3_570_000_000_000_000); // 0.00357 * 10^18 = 2 weeks e-folding
+
+ // Set new alpha (only root can do this)
+ let new_alpha: u64 = 500_000_000_000_000_000; // 0.5 * 10^18
+ assert_ok!(SubtensorModule::sudo_set_voting_power_ema_alpha(
+ RuntimeOrigin::root(),
+ f.netuid,
+ new_alpha
+ ));
+
+ assert_eq!(
+ SubtensorModule::get_voting_power_ema_alpha(f.netuid),
+ new_alpha
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_set_voting_power_ema_alpha_fails_above_one --exact --nocapture
+#[test]
+fn test_set_voting_power_ema_alpha_fails_above_one() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Try to set alpha > 1.0 (> 10^18)
+ let invalid_alpha: u64 = MAX_VOTING_POWER_EMA_ALPHA + 1;
+ assert_noop!(
+ SubtensorModule::sudo_set_voting_power_ema_alpha(
+ RuntimeOrigin::root(),
+ f.netuid,
+ invalid_alpha
+ ),
+ Error::::InvalidVotingPowerEmaAlpha
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_set_voting_power_ema_alpha_non_root_fails --exact --nocapture
+#[test]
+fn test_set_voting_power_ema_alpha_non_root_fails() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+
+ // Non-root cannot set alpha
+ assert_noop!(
+ SubtensorModule::sudo_set_voting_power_ema_alpha(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid,
+ 500_000_000_000_000_000
+ ),
+ sp_runtime::DispatchError::BadOrigin
+ );
+ });
+}
+
+// ============================================
+// === Test EMA Calculation ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_ema_calculation --exact --nocapture
+#[test]
+fn test_voting_power_ema_calculation() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Initially voting power is 0
+ assert_eq!(f.get_voting_power(), 0);
+
+ // Run epoch to update voting power
+ f.run_epochs(1);
+
+ // Voting power should now be > 0 (but less than full stake due to EMA starting from 0)
+ let voting_power_after_first_epoch = f.get_voting_power();
+ assert!(voting_power_after_first_epoch > 0);
+
+ // Run more epochs - voting power should increase towards stake
+ f.run_epochs(10);
+
+ let voting_power_after_many_epochs = f.get_voting_power();
+ assert!(voting_power_after_many_epochs > voting_power_after_first_epoch);
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_cleared_when_deregistered --exact --nocapture
+#[test]
+fn test_voting_power_cleared_when_deregistered() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Run epochs to build up voting power
+ f.run_epochs(10);
+
+ let voting_power_before = f.get_voting_power();
+ assert!(voting_power_before > 0, "Voting power should be built up");
+
+ // Deregister the hotkey (simulate by removing from IsNetworkMember)
+ IsNetworkMember::::remove(f.hotkey, f.netuid);
+
+ // Run epoch - voting power should be cleared for deregistered hotkey
+ f.run_epochs(1);
+
+ // Should be removed from storage immediately when deregistered
+ assert_eq!(f.get_voting_power(), 0);
+ assert!(
+ !VotingPower::::contains_key(f.netuid, f.hotkey),
+ "Entry should be removed when hotkey is deregistered"
+ );
+ });
+}
+
+// ============================================
+// === Test Validators Only ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_only_validators_get_voting_power --exact --nocapture
+#[test]
+fn test_only_validators_get_voting_power() {
+ new_test_ext(1).execute_with(|| {
+ let validator_hotkey = U256::from(1);
+ let miner_hotkey = U256::from(2);
+ let coldkey = U256::from(3);
+
+ let netuid = add_dynamic_network(&validator_hotkey, &coldkey);
+
+ mock::setup_reserves(
+ netuid,
+ (DEFAULT_STAKE_AMOUNT * 100).into(),
+ (DEFAULT_STAKE_AMOUNT * 100).into(),
+ );
+ SubtensorModule::add_balance_to_coldkey_account(&coldkey, DEFAULT_STAKE_AMOUNT * 20);
+
+ // Register miner
+ register_ok_neuron(netuid, miner_hotkey, coldkey, 0);
+
+ // Enable voting power tracking
+ assert_ok!(SubtensorModule::enable_voting_power_tracking(
+ RuntimeOrigin::signed(coldkey),
+ netuid
+ ));
+
+ // Add stake to both
+ assert_ok!(SubtensorModule::add_stake(
+ RuntimeOrigin::signed(coldkey),
+ validator_hotkey,
+ netuid,
+ DEFAULT_STAKE_AMOUNT.into()
+ ));
+ assert_ok!(SubtensorModule::add_stake(
+ RuntimeOrigin::signed(coldkey),
+ miner_hotkey,
+ netuid,
+ DEFAULT_STAKE_AMOUNT.into()
+ ));
+
+ // Set validator permit: uid 0 (validator) has permit, uid 1 (miner) does not
+ ValidatorPermit::::insert(netuid, vec![true, false]);
+
+ // Run epoch
+ let epoch_output = build_mock_epoch_output(netuid);
+ SubtensorModule::update_voting_power_for_subnet(netuid, &epoch_output);
+
+ // Only validator should have voting power
+ assert!(SubtensorModule::get_voting_power(netuid, &validator_hotkey) > 0);
+ assert_eq!(SubtensorModule::get_voting_power(netuid, &miner_hotkey), 0);
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_miner_voting_power_removed_when_loses_vpermit --exact --nocapture
+#[test]
+fn test_miner_voting_power_removed_when_loses_vpermit() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Run epochs to build voting power
+ f.run_epochs(10);
+
+ let voting_power_before = f.get_voting_power();
+ assert!(voting_power_before > 0);
+
+ // Remove validator permit (now they're a miner)
+ f.set_validator_permit(false);
+
+ // Run epoch - voting power should be removed
+ f.run_epochs(1);
+
+ assert_eq!(f.get_voting_power(), 0);
+ });
+}
+
+// ============================================
+// === Test Hotkey Swap ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_transfers_on_hotkey_swap --exact --nocapture
+#[test]
+fn test_voting_power_transfers_on_hotkey_swap() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ let new_hotkey = U256::from(99);
+ let voting_power_value = 5_000_000_000_000_u64;
+
+ // Set some voting power for the old hotkey
+ VotingPower::::insert(f.netuid, f.hotkey, voting_power_value);
+
+ // Verify old hotkey has voting power
+ assert_eq!(f.get_voting_power(), voting_power_value);
+ assert_eq!(SubtensorModule::get_voting_power(f.netuid, &new_hotkey), 0);
+
+ // Perform hotkey swap for this subnet
+ SubtensorModule::swap_voting_power_for_hotkey(&f.hotkey, &new_hotkey, f.netuid);
+
+ // Old hotkey should have 0, new hotkey should have the voting power
+ assert_eq!(f.get_voting_power(), 0);
+ assert_eq!(
+ SubtensorModule::get_voting_power(f.netuid, &new_hotkey),
+ voting_power_value
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_swap_adds_to_existing --exact --nocapture
+#[test]
+fn test_voting_power_swap_adds_to_existing() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ let new_hotkey = U256::from(99);
+ let old_voting_power = 5_000_000_000_000_u64;
+ let new_existing_voting_power = 2_000_000_000_000_u64;
+
+ // Set voting power for both hotkeys
+ VotingPower::::insert(f.netuid, f.hotkey, old_voting_power);
+ VotingPower::::insert(f.netuid, new_hotkey, new_existing_voting_power);
+
+ // Perform swap
+ SubtensorModule::swap_voting_power_for_hotkey(&f.hotkey, &new_hotkey, f.netuid);
+
+ // New hotkey should have combined voting power
+ assert_eq!(f.get_voting_power(), 0);
+ assert_eq!(
+ SubtensorModule::get_voting_power(f.netuid, &new_hotkey),
+ old_voting_power + new_existing_voting_power
+ );
+ });
+}
+
+// ============================================
+// === Test Threshold Logic ===
+// ============================================
+// Tests the rule: Only remove voting power entry if it decayed FROM above threshold TO below.
+// New validators building up from 0 should NOT be removed even if below threshold.
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_not_removed_if_never_above_threshold --exact --nocapture
+#[test]
+fn test_voting_power_not_removed_if_never_above_threshold() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Get the threshold
+ let min_stake = SubtensorModule::get_stake_threshold();
+
+ // Set voting power directly to a value below threshold (simulating building up)
+ // This is below threshold but was never above it
+ let below_threshold = min_stake.saturating_sub(1);
+ VotingPower::::insert(f.netuid, f.hotkey, below_threshold);
+
+ // Run epoch
+ f.run_epochs(1);
+
+ // Key assertion: Entry should NOT be removed because previous_ema was below threshold
+ // The removal rule only triggers when previous_ema >= threshold and new_ema < threshold
+ let voting_power = f.get_voting_power();
+ assert!(
+ voting_power > 0,
+ "Voting power should still exist - it was never above threshold"
+ );
+ assert!(
+ VotingPower::::contains_key(f.netuid, f.hotkey),
+ "Entry should exist - it was never above threshold so shouldn't be removed"
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_not_removed_with_small_dip_below_threshold --exact --nocapture
+#[test]
+fn test_voting_power_not_removed_with_small_dip_below_threshold() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_for_staking();
+ f.enable_tracking();
+ f.set_validator_permit(true);
+
+ let min_stake = SubtensorModule::get_stake_threshold();
+
+ // Set voting power above threshold (validator was established)
+ let above_threshold = min_stake + 100;
+ VotingPower::::insert(f.netuid, f.hotkey, above_threshold);
+
+ // Simulate a small dip: new EMA drops to 95% of threshold (within 10% buffer)
+ // This is above the removal threshold (90%) so should NOT be removed
+ let small_dip = min_stake * 95 / 100;
+ VotingPower::::insert(f.netuid, f.hotkey, small_dip);
+
+ // Manually trigger the removal check by setting previous to above threshold
+ // and running with stake that would produce EMA in the buffer zone
+ VotingPower::::insert(f.netuid, f.hotkey, above_threshold);
+
+ // Build epoch output with stake that will produce EMA around 95% of threshold
+ let mut epoch_output = build_mock_epoch_output(f.netuid);
+ if let Some(terms) = epoch_output.get_mut(&f.hotkey) {
+ terms.stake = small_dip; // Stake drops but stays in buffer zone
+ }
+
+ SubtensorModule::update_voting_power_for_subnet(f.netuid, &epoch_output);
+
+ // Should NOT be removed - dip is within hysteresis buffer
+ assert!(
+ VotingPower::::contains_key(f.netuid, f.hotkey),
+ "Entry should exist - small dip within 10% buffer should not trigger removal"
+ );
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_removed_with_significant_drop_below_threshold --exact --nocapture
+#[test]
+fn test_voting_power_removed_with_significant_drop_below_threshold() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.enable_tracking();
+
+ // Use explicit values since get_stake_threshold() may return 0 in tests
+ let min_stake: u64 = 1_000_000_000;
+ StakeThreshold::::put(min_stake);
+
+ // Set voting power above threshold (validator was established)
+ VotingPower::::insert(f.netuid, f.hotkey, min_stake);
+
+ // Set alpha to 100% so new_ema = current_stake directly (for testing removal)
+ VotingPowerEmaAlpha::::insert(f.netuid, MAX_VOTING_POWER_EMA_ALPHA);
+
+ // Build epoch output manually with stake = 0 and validator permit = true
+ let mut epoch_output = BTreeMap::new();
+ epoch_output.insert(
+ f.hotkey,
+ EpochTerms {
+ uid: 0,
+ new_validator_permit: true,
+ stake: 0, // Complete unstake
+ ..Default::default()
+ },
+ );
+
+ // With alpha = 1.0: new_ema = 1.0 * 0 + 0 * previous = 0
+ // 0 < removal_threshold (90% of min_stake = 900M) AND previous (1B) >= min_stake (1B)
+ // Should trigger removal
+ SubtensorModule::update_voting_power_for_subnet(f.netuid, &epoch_output);
+
+ assert!(
+ !VotingPower::::contains_key(f.netuid, f.hotkey),
+ "Entry should be removed - stake dropped to 0 with alpha=1.0"
+ );
+ });
+}
+
+// ============================================
+// === Test Tracking Not Active ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_not_updated_when_disabled --exact --nocapture
+#[test]
+fn test_voting_power_not_updated_when_disabled() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_for_staking();
+ // DON'T enable voting power tracking
+ f.add_stake(DEFAULT_STAKE_AMOUNT);
+ f.set_validator_permit(true);
+
+ // Run epoch
+ f.run_epochs(1);
+
+ // Voting power should still be 0 since tracking is disabled
+ assert_eq!(f.get_voting_power(), 0);
+ });
+}
+
+// ============================================
+// === Test Re-enable After Disable ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_reenable_voting_power_clears_disable_schedule --exact --nocapture
+#[test]
+fn test_reenable_voting_power_clears_disable_schedule() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.enable_tracking();
+
+ // Schedule disable
+ assert_ok!(SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid
+ ));
+
+ assert!(SubtensorModule::get_voting_power_disable_at_block(f.netuid) > 0);
+
+ // Re-enable should clear the disable schedule
+ f.enable_tracking();
+
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ assert_eq!(
+ SubtensorModule::get_voting_power_disable_at_block(f.netuid),
+ 0
+ );
+ });
+}
+
+// ============================================
+// === Test Grace Period Finalization ===
+// ============================================
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_finalized_after_grace_period --exact --nocapture
+#[test]
+fn test_voting_power_finalized_after_grace_period() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Build up voting power
+ f.run_epochs(10);
+
+ let voting_power_before = f.get_voting_power();
+ assert!(voting_power_before > 0);
+
+ // Schedule disable
+ assert_ok!(SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid
+ ));
+
+ let disable_at = SubtensorModule::get_voting_power_disable_at_block(f.netuid);
+
+ // Advance block past grace period (time travel!)
+ System::set_block_number(disable_at + 1);
+
+ // Run epoch - should finalize disable
+ f.run_epochs(1);
+
+ // Tracking should be disabled and all entries cleared
+ assert!(!SubtensorModule::get_voting_power_tracking_enabled(
+ f.netuid
+ ));
+ assert_eq!(
+ SubtensorModule::get_voting_power_disable_at_block(f.netuid),
+ 0
+ );
+ assert_eq!(f.get_voting_power(), 0);
+ });
+}
+
+// SKIP_WASM_BUILD=1 RUST_LOG=debug cargo test --package pallet-subtensor --lib -- tests::voting_power::test_voting_power_continues_during_grace_period --exact --nocapture
+#[test]
+fn test_voting_power_continues_during_grace_period() {
+ new_test_ext(1).execute_with(|| {
+ let f = VotingPowerTestFixture::new();
+ f.setup_full();
+
+ // Schedule disable
+ assert_ok!(SubtensorModule::disable_voting_power_tracking(
+ RuntimeOrigin::signed(f.coldkey),
+ f.netuid
+ ));
+
+ let disable_at = SubtensorModule::get_voting_power_disable_at_block(f.netuid);
+
+ // Set block to middle of grace period (time travel!)
+ System::set_block_number(disable_at - 1000);
+
+ // Run epoch - should still update voting power during grace period
+ f.run_epochs(1);
+
+ // Tracking should still be enabled and voting power should exist
+ assert!(SubtensorModule::get_voting_power_tracking_enabled(f.netuid));
+ assert!(f.get_voting_power() > 0);
+ });
+}
diff --git a/pallets/subtensor/src/utils/mod.rs b/pallets/subtensor/src/utils/mod.rs
index 3eb8439959..a91875da59 100644
--- a/pallets/subtensor/src/utils/mod.rs
+++ b/pallets/subtensor/src/utils/mod.rs
@@ -5,3 +5,4 @@ pub mod misc;
pub mod rate_limiting;
#[cfg(feature = "try-runtime")]
pub mod try_state;
+pub mod voting_power;
diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs
new file mode 100644
index 0000000000..380861bbf5
--- /dev/null
+++ b/pallets/subtensor/src/utils/voting_power.rs
@@ -0,0 +1,280 @@
+use super::*;
+use crate::epoch::run_epoch::EpochTerms;
+use alloc::collections::BTreeMap;
+use subtensor_runtime_common::NetUid;
+
+/// 14 days in blocks (assuming ~12 second blocks)
+/// 14 * 24 * 60 * 60 / 12 = 100800 blocks
+pub const VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS: u64 = 100800;
+
+/// Maximum alpha value (1.0 represented as u64 with 18 decimals)
+pub const MAX_VOTING_POWER_EMA_ALPHA: u64 = 1_000_000_000_000_000_000;
+
+impl Pallet {
+ // ========================
+ // === Getters ===
+ // ========================
+
+ /// Get voting power for a hotkey on a subnet.
+ /// Returns 0 if not found or tracking disabled.
+ pub fn get_voting_power(netuid: NetUid, hotkey: &T::AccountId) -> u64 {
+ VotingPower::::get(netuid, hotkey)
+ }
+
+ /// Check if voting power tracking is enabled for a subnet.
+ pub fn get_voting_power_tracking_enabled(netuid: NetUid) -> bool {
+ VotingPowerTrackingEnabled::::get(netuid)
+ }
+
+ /// Get the block at which voting power tracking will be disabled.
+ /// Returns 0 if not scheduled for disabling.
+ pub fn get_voting_power_disable_at_block(netuid: NetUid) -> u64 {
+ VotingPowerDisableAtBlock::::get(netuid)
+ }
+
+ /// Get the EMA alpha value for voting power calculation on a subnet.
+ pub fn get_voting_power_ema_alpha(netuid: NetUid) -> u64 {
+ VotingPowerEmaAlpha::::get(netuid)
+ }
+
+ // ========================
+ // === Extrinsic Handlers ===
+ // ========================
+
+ /// Enable voting power tracking for a subnet.
+ pub fn do_enable_voting_power_tracking(netuid: NetUid) -> DispatchResult {
+ // Enable tracking
+ VotingPowerTrackingEnabled::::insert(netuid, true);
+
+ // Clear any scheduled disable
+ VotingPowerDisableAtBlock::::remove(netuid);
+
+ // Emit event
+ Self::deposit_event(Event::VotingPowerTrackingEnabled { netuid });
+
+ log::info!("VotingPower tracking enabled for netuid {netuid:?}");
+
+ Ok(())
+ }
+
+ /// Schedule disabling of voting power tracking for a subnet.
+ /// Tracking will continue for 14 days, then automatically disable.
+ pub fn do_disable_voting_power_tracking(netuid: NetUid) -> DispatchResult {
+ // Check if tracking is enabled
+ ensure!(
+ Self::get_voting_power_tracking_enabled(netuid),
+ Error::::VotingPowerTrackingNotEnabled
+ );
+
+ // Calculate the block at which tracking will be disabled
+ let current_block = Self::get_current_block_as_u64();
+ let disable_at_block =
+ current_block.saturating_add(VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS);
+
+ // Schedule disable
+ VotingPowerDisableAtBlock::::insert(netuid, disable_at_block);
+
+ // Emit event
+ Self::deposit_event(Event::VotingPowerTrackingDisableScheduled {
+ netuid,
+ disable_at_block,
+ });
+
+ log::info!(
+ "VotingPower tracking scheduled to disable at block {disable_at_block:?} for netuid {netuid:?}"
+ );
+
+ Ok(())
+ }
+
+ /// Set the EMA alpha value for voting power calculation on a subnet.
+ pub fn do_set_voting_power_ema_alpha(netuid: NetUid, alpha: u64) -> DispatchResult {
+ // Validate alpha (must be <= 1.0, represented as 10^18)
+ ensure!(
+ alpha <= MAX_VOTING_POWER_EMA_ALPHA,
+ Error::::InvalidVotingPowerEmaAlpha
+ );
+
+ // Set the alpha
+ VotingPowerEmaAlpha::::insert(netuid, alpha);
+
+ // Emit event
+ Self::deposit_event(Event::VotingPowerEmaAlphaSet { netuid, alpha });
+
+ log::info!("VotingPower EMA alpha set to {alpha:?} for netuid {netuid:?}");
+
+ Ok(())
+ }
+
+ // ========================
+ // === Epoch Processing ===
+ // ========================
+
+ /// Update voting power for all validators on a subnet using pre-calculated epoch terms.
+ pub fn update_voting_power_for_subnet(
+ netuid: NetUid,
+ epoch_output: &BTreeMap,
+ ) {
+ // Early exit if tracking not enabled
+ if !Self::get_voting_power_tracking_enabled(netuid) {
+ return;
+ }
+
+ // Check if past grace period and should finalize disable
+ let disable_at = Self::get_voting_power_disable_at_block(netuid);
+ if disable_at > 0 {
+ let current_block = Self::get_current_block_as_u64();
+ if current_block >= disable_at {
+ Self::finalize_voting_power_disable(netuid);
+ return;
+ }
+ // Still in grace period - continue updating
+ }
+
+ // Get the EMA alpha value for this subnet
+ let alpha = Self::get_voting_power_ema_alpha(netuid);
+
+ // Get minimum stake threshold for validator permit
+ let min_stake = Self::get_stake_threshold();
+
+ // Iterate over epoch output using pre-calculated values
+ for (hotkey, terms) in epoch_output.iter() {
+ // Only validators (with vpermit) get voting power, not miners
+ if terms.new_validator_permit {
+ // Use the subnet-specific stake from epoch calculation
+ Self::update_voting_power_for_hotkey(netuid, hotkey, terms.stake, alpha, min_stake);
+ } else {
+ // Miner without vpermit - remove any existing voting power
+ VotingPower::::remove(netuid, hotkey);
+ }
+ }
+
+ // Remove voting power for any hotkeys that are no longer registered on this subnet
+ Self::clear_voting_power_for_deregistered_hotkeys(netuid);
+
+ log::trace!("VotingPower updated for validators on netuid {netuid:?}");
+ }
+
+ /// Clear voting power for hotkeys that are no longer registered on the subnet.
+ fn clear_voting_power_for_deregistered_hotkeys(netuid: NetUid) {
+ // Collect hotkeys to remove (can't mutate while iterating)
+ let hotkeys_to_remove: Vec = VotingPower::::iter_prefix(netuid)
+ .filter_map(|(hotkey, _)| {
+ // If the hotkey is not a network member, it's deregistered
+ if !IsNetworkMember::::get(&hotkey, netuid) {
+ Some(hotkey)
+ } else {
+ None
+ }
+ })
+ .collect();
+
+ // Remove voting power for deregistered hotkeys
+ for hotkey in hotkeys_to_remove {
+ VotingPower::::remove(netuid, &hotkey);
+ log::trace!(
+ "VotingPower removed for deregistered hotkey {hotkey:?} on netuid {netuid:?}"
+ );
+ }
+ }
+
+ /// Update voting power EMA for a single hotkey using subnet-specific stake.
+ fn update_voting_power_for_hotkey(
+ netuid: NetUid,
+ hotkey: &T::AccountId,
+ current_stake: u64,
+ alpha: u64,
+ min_stake: u64,
+ ) {
+ // Get previous EMA value
+ let previous_ema = VotingPower::::get(netuid, hotkey);
+
+ // Calculate new EMA value
+ // new_ema = alpha * current_stake + (1 - alpha) * previous_ema
+ // All values use 18 decimal precision for alpha (alpha is in range [0, 10^18])
+ let new_ema = Self::calculate_voting_power_ema(current_stake, previous_ema, alpha);
+
+ // Only remove if they previously had voting power ABOVE threshold and decayed below.
+ // This allows new validators to build up voting power from 0 without being removed.
+ if new_ema < min_stake && previous_ema >= min_stake {
+ // Was above threshold, now decayed below - remove
+ VotingPower::::remove(netuid, hotkey);
+ log::trace!(
+ "VotingPower removed for hotkey {hotkey:?} on netuid {netuid:?} (decayed below removal threshold: {new_ema:?} < {min_stake:?})"
+ );
+ } else if new_ema > 0 {
+ // Update voting power (building up or maintaining)
+ VotingPower::::insert(netuid, hotkey, new_ema);
+ log::trace!(
+ "VotingPower updated for hotkey {hotkey:?} on netuid {netuid:?}: {previous_ema:?} -> {new_ema:?}"
+ );
+ }
+ // If new_ema == 0 do nothing
+ }
+
+ /// Calculate EMA for voting power.
+ /// new_ema = alpha * current_stake + (1 - alpha) * previous_ema
+ /// Alpha is in 18 decimal precision (10^18 = 1.0)
+ fn calculate_voting_power_ema(current_stake: u64, previous_ema: u64, alpha: u64) -> u64 {
+ // Use u128 for intermediate calculations to avoid overflow
+ let alpha_128 = alpha as u128;
+ let one_minus_alpha = (MAX_VOTING_POWER_EMA_ALPHA as u128).saturating_sub(alpha_128);
+ let current_128 = current_stake as u128;
+ let previous_128 = previous_ema as u128;
+
+ // new_ema = (alpha * current_stake + (1 - alpha) * previous_ema) / 10^18
+ let numerator = alpha_128
+ .saturating_mul(current_128)
+ .saturating_add(one_minus_alpha.saturating_mul(previous_128));
+
+ let result = numerator
+ .checked_div(MAX_VOTING_POWER_EMA_ALPHA as u128)
+ .unwrap_or(0);
+
+ // Safely convert back to u64, saturating at u64::MAX
+ result.min(u64::MAX as u128) as u64
+ }
+
+ /// Finalize the disabling of voting power tracking.
+ /// Clears all VotingPower entries for the subnet.
+ fn finalize_voting_power_disable(netuid: NetUid) {
+ // Clear all VotingPower entries for this subnet
+ let _ = VotingPower::::clear_prefix(netuid, u32::MAX, None);
+
+ // Disable tracking
+ VotingPowerTrackingEnabled::::insert(netuid, false);
+
+ // Clear disable schedule
+ VotingPowerDisableAtBlock::::remove(netuid);
+
+ // Emit event
+ Self::deposit_event(Event::VotingPowerTrackingDisabled { netuid });
+
+ log::info!("VotingPower tracking disabled and entries cleared for netuid {netuid:?}");
+ }
+
+ // ========================
+ // === Hotkey Swap ===
+ // ========================
+
+ /// Transfer voting power from old hotkey to new hotkey during swap.
+ pub fn swap_voting_power_for_hotkey(
+ old_hotkey: &T::AccountId,
+ new_hotkey: &T::AccountId,
+ netuid: NetUid,
+ ) {
+ // Get voting power from old hotkey
+ let voting_power = VotingPower::::take(netuid, old_hotkey);
+
+ // Transfer to new hotkey if non-zero
+ if voting_power > 0 {
+ // Add to any existing voting power on new hotkey (in case new hotkey already has some)
+ let existing = VotingPower::::get(netuid, new_hotkey);
+ VotingPower::::insert(netuid, new_hotkey, voting_power.saturating_add(existing));
+
+ log::trace!(
+ "VotingPower transferred from {old_hotkey:?} to {new_hotkey:?} on netuid {netuid:?}: {voting_power:?}"
+ );
+ }
+ }
+}
diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs
index 864119d89f..abc5e744b2 100644
--- a/precompiles/src/lib.rs
+++ b/precompiles/src/lib.rs
@@ -41,6 +41,7 @@ use crate::staking::*;
use crate::storage_query::*;
use crate::subnet::*;
use crate::uid_lookup::*;
+use crate::voting_power::*;
mod address_mapping;
mod alpha;
@@ -57,6 +58,7 @@ mod staking;
mod storage_query;
mod subnet;
mod uid_lookup;
+mod voting_power;
pub struct Precompiles(PhantomData);
@@ -125,7 +127,7 @@ where
Self(Default::default())
}
- pub fn used_addresses() -> [H160; 26] {
+ pub fn used_addresses() -> [H160; 27] {
[
hash(1),
hash(2),
@@ -151,6 +153,7 @@ where
hash(AlphaPrecompile::::INDEX),
hash(CrowdloanPrecompile::::INDEX),
hash(LeasingPrecompile::::INDEX),
+ hash(VotingPowerPrecompile::::INDEX),
hash(ProxyPrecompile::::INDEX),
hash(AddressMappingPrecompile::::INDEX),
]
@@ -245,6 +248,9 @@ where
a if a == hash(LeasingPrecompile::::INDEX) => {
LeasingPrecompile::::try_execute::(handle, PrecompileEnum::Leasing)
}
+ a if a == hash(VotingPowerPrecompile::::INDEX) => {
+ VotingPowerPrecompile::::try_execute::(handle, PrecompileEnum::VotingPower)
+ }
a if a == hash(ProxyPrecompile::::INDEX) => {
ProxyPrecompile::::try_execute::(handle, PrecompileEnum::Proxy)
}
diff --git a/precompiles/src/voting_power.rs b/precompiles/src/voting_power.rs
new file mode 100644
index 0000000000..74e1731b6e
--- /dev/null
+++ b/precompiles/src/voting_power.rs
@@ -0,0 +1,131 @@
+use core::marker::PhantomData;
+
+use fp_evm::PrecompileHandle;
+use precompile_utils::EvmResult;
+use sp_core::{ByteArray, H256, U256};
+use subtensor_runtime_common::NetUid;
+
+use crate::PrecompileExt;
+
+/// VotingPower precompile for smart contract access to validator voting power.
+///
+/// This precompile allows smart contracts to query voting power for validators,
+/// enabling on-chain governance decisions like slashing and spending.
+pub struct VotingPowerPrecompile(PhantomData);
+
+impl PrecompileExt for VotingPowerPrecompile
+where
+ R: frame_system::Config + pallet_subtensor::Config,
+ R::AccountId: From<[u8; 32]> + ByteArray,
+{
+ const INDEX: u64 = 2061;
+}
+
+#[precompile_utils::precompile]
+impl VotingPowerPrecompile
+where
+ R: frame_system::Config + pallet_subtensor::Config,
+ R::AccountId: From<[u8; 32]>,
+{
+ /// Get voting power for a hotkey on a subnet.
+ ///
+ /// Returns the EMA of stake for the hotkey, which represents its voting power.
+ /// Returns 0 if:
+ /// - The hotkey has no voting power entry
+ /// - Voting power tracking is not enabled for the subnet
+ /// - The hotkey is not registered on the subnet
+ ///
+ /// # Arguments
+ /// * `netuid` - The subnet identifier (u16)
+ /// * `hotkey` - The hotkey account ID (bytes32)
+ ///
+ /// # Returns
+ /// * `u256` - The voting power value (in RAO, same precision as stake)
+ #[precompile::public("getVotingPower(uint16,bytes32)")]
+ #[precompile::view]
+ fn get_voting_power(
+ _: &mut impl PrecompileHandle,
+ netuid: u16,
+ hotkey: H256,
+ ) -> EvmResult {
+ let hotkey = R::AccountId::from(hotkey.0);
+ let voting_power = pallet_subtensor::VotingPower::::get(NetUid::from(netuid), &hotkey);
+ Ok(U256::from(voting_power))
+ }
+
+ /// Check if voting power tracking is enabled for a subnet.
+ ///
+ /// # Arguments
+ /// * `netuid` - The subnet identifier (u16)
+ ///
+ /// # Returns
+ /// * `bool` - True if voting power tracking is enabled
+ #[precompile::public("isVotingPowerTrackingEnabled(uint16)")]
+ #[precompile::view]
+ fn is_voting_power_tracking_enabled(
+ _: &mut impl PrecompileHandle,
+ netuid: u16,
+ ) -> EvmResult {
+ Ok(pallet_subtensor::VotingPowerTrackingEnabled::::get(
+ NetUid::from(netuid),
+ ))
+ }
+
+ /// Get the block at which voting power tracking will be disabled.
+ ///
+ /// Returns 0 if not scheduled for disabling.
+ /// When non-zero, tracking continues until this block, then stops.
+ ///
+ /// # Arguments
+ /// * `netuid` - The subnet identifier (u16)
+ ///
+ /// # Returns
+ /// * `u64` - The block number at which tracking will be disabled (0 if not scheduled)
+ #[precompile::public("getVotingPowerDisableAtBlock(uint16)")]
+ #[precompile::view]
+ fn get_voting_power_disable_at_block(
+ _: &mut impl PrecompileHandle,
+ netuid: u16,
+ ) -> EvmResult {
+ Ok(pallet_subtensor::VotingPowerDisableAtBlock::::get(
+ NetUid::from(netuid),
+ ))
+ }
+
+ /// Get the EMA alpha value for voting power calculation on a subnet.
+ ///
+ /// Alpha is stored with 18 decimal precision (1.0 = 10^18).
+ /// Higher alpha = faster response to stake changes.
+ ///
+ /// # Arguments
+ /// * `netuid` - The subnet identifier (u16)
+ ///
+ /// # Returns
+ /// * `u64` - The alpha value (with 18 decimal precision)
+ #[precompile::public("getVotingPowerEmaAlpha(uint16)")]
+ #[precompile::view]
+ fn get_voting_power_ema_alpha(_: &mut impl PrecompileHandle, netuid: u16) -> EvmResult {
+ Ok(pallet_subtensor::VotingPowerEmaAlpha::::get(
+ NetUid::from(netuid),
+ ))
+ }
+
+ /// Get total voting power for a subnet.
+ ///
+ /// Returns the sum of all voting power for all validators on the subnet.
+ /// Useful for calculating voting thresholds (e.g., 51% quorum).
+ ///
+ /// # Arguments
+ /// * `netuid` - The subnet identifier (u16)
+ ///
+ /// # Returns
+ /// * `u256` - The total voting power across all validators
+ #[precompile::public("getTotalVotingPower(uint16)")]
+ #[precompile::view]
+ fn get_total_voting_power(_: &mut impl PrecompileHandle, netuid: u16) -> EvmResult {
+ let total: u64 = pallet_subtensor::VotingPower::::iter_prefix(NetUid::from(netuid))
+ .map(|(_, voting_power)| voting_power)
+ .fold(0u64, |acc, vp| acc.saturating_add(vp));
+ Ok(U256::from(total))
+ }
+}
From d5d985f2d61745243585c31cb0294befe1892dd7 Mon Sep 17 00:00:00 2001
From: Pawel Polewicz
Date: Mon, 19 Jan 2026 22:04:02 +0000
Subject: [PATCH 2/9] Use AlphaCurrency type for stake in EpochTerms
Changed stake field from u64 to AlphaCurrency for type safety.
Updated all conversions in epoch processing and voting power
calculations. Fixed tests to use .into() for type conversions.
Addresses PR review comment about using type-safe currency types.
Co-Authored-By: Claude Sonnet 4.5
---
pallets/subtensor/src/epoch/run_epoch.rs | 8 ++++++--
pallets/subtensor/src/tests/voting_power.rs | 6 +++---
pallets/subtensor/src/utils/voting_power.rs | 6 +++---
3 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs
index f90175ce0c..d9eeafeb3b 100644
--- a/pallets/subtensor/src/epoch/run_epoch.rs
+++ b/pallets/subtensor/src/epoch/run_epoch.rs
@@ -22,7 +22,7 @@ pub struct EpochTerms {
pub validator_trust: u16,
pub new_validator_permit: bool,
pub bond: Vec<(u16, u16)>,
- pub stake: u64,
+ pub stake: AlphaCurrency,
}
pub struct EpochOutput(pub BTreeMap);
@@ -1017,7 +1017,11 @@ impl Pallet {
.get(terms.uid)
.copied()
.unwrap_or_default();
- terms.stake = raw_stake.get(terms.uid).copied().unwrap_or_default();
+ terms.stake = raw_stake
+ .get(terms.uid)
+ .copied()
+ .unwrap_or_default()
+ .into();
let old_validator_permit = validator_permits
.get(terms.uid)
.copied()
diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs
index 7edc06fbab..63418fb6a9 100644
--- a/pallets/subtensor/src/tests/voting_power.rs
+++ b/pallets/subtensor/src/tests/voting_power.rs
@@ -39,7 +39,7 @@ fn build_mock_epoch_output(netuid: NetUid) -> BTreeMap {
EpochTerms {
uid: uid as usize,
new_validator_permit: has_permit,
- stake,
+ stake: stake.into(),
..Default::default()
},
);
@@ -583,7 +583,7 @@ fn test_voting_power_not_removed_with_small_dip_below_threshold() {
// Build epoch output with stake that will produce EMA around 95% of threshold
let mut epoch_output = build_mock_epoch_output(f.netuid);
if let Some(terms) = epoch_output.get_mut(&f.hotkey) {
- terms.stake = small_dip; // Stake drops but stays in buffer zone
+ terms.stake = small_dip.into(); // Stake drops but stays in buffer zone
}
SubtensorModule::update_voting_power_for_subnet(f.netuid, &epoch_output);
@@ -620,7 +620,7 @@ fn test_voting_power_removed_with_significant_drop_below_threshold() {
EpochTerms {
uid: 0,
new_validator_permit: true,
- stake: 0, // Complete unstake
+ stake: 0.into(), // Complete unstake
..Default::default()
},
);
diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs
index 380861bbf5..55437f8885 100644
--- a/pallets/subtensor/src/utils/voting_power.rs
+++ b/pallets/subtensor/src/utils/voting_power.rs
@@ -1,7 +1,7 @@
use super::*;
use crate::epoch::run_epoch::EpochTerms;
use alloc::collections::BTreeMap;
-use subtensor_runtime_common::NetUid;
+use subtensor_runtime_common::{AlphaCurrency, NetUid};
/// 14 days in blocks (assuming ~12 second blocks)
/// 14 * 24 * 60 * 60 / 12 = 100800 blocks
@@ -182,7 +182,7 @@ impl Pallet {
fn update_voting_power_for_hotkey(
netuid: NetUid,
hotkey: &T::AccountId,
- current_stake: u64,
+ current_stake: AlphaCurrency,
alpha: u64,
min_stake: u64,
) {
@@ -192,7 +192,7 @@ impl Pallet {
// Calculate new EMA value
// new_ema = alpha * current_stake + (1 - alpha) * previous_ema
// All values use 18 decimal precision for alpha (alpha is in range [0, 10^18])
- let new_ema = Self::calculate_voting_power_ema(current_stake, previous_ema, alpha);
+ let new_ema = Self::calculate_voting_power_ema(current_stake.to_u64(), previous_ema, alpha);
// Only remove if they previously had voting power ABOVE threshold and decayed below.
// This allows new validators to build up voting power from 0 without being removed.
From cb74680af3be78b2b5e279245b0fe27481657269 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Tue, 20 Jan 2026 01:13:10 +0000
Subject: [PATCH 3/9] auto-update benchmark weights
---
pallets/subtensor/src/macros/dispatches.rs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs
index 240864ae74..7b2677d151 100644
--- a/pallets/subtensor/src/macros/dispatches.rs
+++ b/pallets/subtensor/src/macros/dispatches.rs
@@ -1055,7 +1055,7 @@ mod dispatches {
/// The extrinsic for user to change its hotkey in subnet or all subnets.
#[pallet::call_index(70)]
#[pallet::weight((Weight::from_parts(275_300_000, 0)
- .saturating_add(T::DbWeight::get().reads(50_u64))
+ .saturating_add(T::DbWeight::get().reads(52_u64))
.saturating_add(T::DbWeight::get().writes(35_u64)), DispatchClass::Normal, Pays::No))]
pub fn swap_hotkey(
origin: OriginFor,
@@ -1517,7 +1517,7 @@ mod dispatches {
/// User register a new subnetwork
#[pallet::call_index(79)]
- #[pallet::weight((Weight::from_parts(234_200_000, 0)
+ #[pallet::weight((Weight::from_parts(396_000_000, 0)
.saturating_add(T::DbWeight::get().reads(35_u64))
.saturating_add(T::DbWeight::get().writes(51_u64)), DispatchClass::Normal, Pays::Yes))]
pub fn register_network_with_identity(
From 550ce8d4c94c8e4ec900d83fa110b1dcc69c1f32 Mon Sep 17 00:00:00 2001
From: John Reed <87283488+JohnReedV@users.noreply.github.com>
Date: Mon, 19 Jan 2026 17:13:47 -0800
Subject: [PATCH 4/9] fmt
---
pallets/subtensor/src/epoch/run_epoch.rs | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs
index d9eeafeb3b..2290b49b8d 100644
--- a/pallets/subtensor/src/epoch/run_epoch.rs
+++ b/pallets/subtensor/src/epoch/run_epoch.rs
@@ -1017,11 +1017,7 @@ impl Pallet {
.get(terms.uid)
.copied()
.unwrap_or_default();
- terms.stake = raw_stake
- .get(terms.uid)
- .copied()
- .unwrap_or_default()
- .into();
+ terms.stake = raw_stake.get(terms.uid).copied().unwrap_or_default().into();
let old_validator_permit = validator_permits
.get(terms.uid)
.copied()
From 7f8fdd514c54f0ab17d2ba2f05872d2c84a4b313 Mon Sep 17 00:00:00 2001
From: open-junius
Date: Tue, 20 Jan 2026 14:09:32 +0800
Subject: [PATCH 5/9] fix voting power ema alpha check
---
contract-tests/test/votingPower.precompile.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/contract-tests/test/votingPower.precompile.test.ts b/contract-tests/test/votingPower.precompile.test.ts
index 26b29e257c..f98edd5fc1 100644
--- a/contract-tests/test/votingPower.precompile.test.ts
+++ b/contract-tests/test/votingPower.precompile.test.ts
@@ -76,8 +76,8 @@ describe("Test VotingPower Precompile", () => {
assert.ok(alpha !== undefined, "getVotingPowerEmaAlpha should return a value");
assert.strictEqual(typeof alpha, 'bigint', "getVotingPowerEmaAlpha should return a bigint");
- // Default alpha is 0.1 * 10^18 = 100_000_000_000_000_000
- assert.strictEqual(alpha, BigInt("100000000000000000"), "Default alpha should be 0.1 (100_000_000_000_000_000)");
+ // Default alpha is 0_003_570_000_000_000_000 // 0.00357 * 10^18 = 2 weeks e-folding (time-constant) @ 361
+ assert.strictEqual(alpha, BigInt("3570000000000000"), "Default alpha should be 0.00357 * 10^18 (3570000000000000)");
});
});
From 4864909bf392c21a6fd175446edfa87b93f26895 Mon Sep 17 00:00:00 2001
From: open-junius
Date: Tue, 20 Jan 2026 17:10:58 +0800
Subject: [PATCH 6/9] fix high network registration fee
---
contract-tests/src/subtensor.ts | 23 +++++++++++++++++++++--
1 file changed, 21 insertions(+), 2 deletions(-)
diff --git a/contract-tests/src/subtensor.ts b/contract-tests/src/subtensor.ts
index fab9e8cc10..21cab2e1e1 100644
--- a/contract-tests/src/subtensor.ts
+++ b/contract-tests/src/subtensor.ts
@@ -1,11 +1,12 @@
import * as assert from "assert";
import { devnet, MultiAddress } from '@polkadot-api/descriptors';
-import { TypedApi, TxCallData, Binary, Enum } from 'polkadot-api';
+import { TypedApi, TxCallData, Binary, Enum, getTypedCodecs } from 'polkadot-api';
import { KeyPair } from "@polkadot-labs/hdkd-helpers"
import { getAliceSigner, waitForTransactionCompletion, getSignerFromKeypair, waitForTransactionWithRetry } from './substrate'
import { convertH160ToSS58, convertPublicKeyToSs58, ethAddressToH160 } from './address-utils'
import { tao } from './balance-math'
import internal from "stream";
+import { createCodec } from "scale-ts";
// create a new subnet and return netuid
export async function addNewSubnetwork(api: TypedApi, hotkey: KeyPair, coldkey: KeyPair) {
@@ -26,6 +27,9 @@ export async function addNewSubnetwork(api: TypedApi, hotkey: Key
const newTotalNetworks = await api.query.SubtensorModule.TotalNetworks.getValue()
// could create multiple subnetworks during retry, just return the first created one
assert.ok(newTotalNetworks > totalNetworks)
+
+ // rewrite network last lock cost to 0, to avoid the lock cost calculation error
+ await setNetworkLastLockCost(api)
return totalNetworks
}
@@ -398,4 +402,19 @@ export async function sendWasmContractExtrinsic(api: TypedApi, co
storage_deposit_limit: BigInt(1000000000)
})
await waitForTransactionWithRetry(api, tx, signer)
-}
\ No newline at end of file
+}
+
+export async function setNetworkLastLockCost(api: TypedApi) {
+ const alice = getAliceSigner()
+ const key = await api.query.SubtensorModule.NetworkLastLockCost.getKey()
+ const codec = await getTypedCodecs(devnet);
+ const value = codec.query.SubtensorModule.NetworkLastLockCost.value.enc(BigInt(0))
+ const internalCall = api.tx.System.set_storage({
+ items: [[Binary.fromHex(key), Binary.fromBytes(value)]]
+ })
+ const tx = api.tx.Sudo.sudo({ call: internalCall.decodedCall })
+ await waitForTransactionWithRetry(api, tx, alice)
+
+ const valueOnChain = await api.query.SubtensorModule.NetworkLastLockCost.getValue()
+ assert.equal(BigInt(0), valueOnChain)
+}
\ No newline at end of file
From b2869b6bfb8b832b1b563c662f2710f205fe4cf2 Mon Sep 17 00:00:00 2001
From: open-junius
Date: Tue, 20 Jan 2026 19:40:49 +0800
Subject: [PATCH 7/9] reset as default value
---
contract-tests/src/subtensor.ts | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/contract-tests/src/subtensor.ts b/contract-tests/src/subtensor.ts
index 21cab2e1e1..1b05ad8d5a 100644
--- a/contract-tests/src/subtensor.ts
+++ b/contract-tests/src/subtensor.ts
@@ -13,6 +13,8 @@ export async function addNewSubnetwork(api: TypedApi, hotkey: Key
const alice = getAliceSigner()
const totalNetworks = await api.query.SubtensorModule.TotalNetworks.getValue()
+ const defaultNetworkLastLockCost = await api.query.SubtensorModule.NetworkLastLockCost.getValue()
+
const rateLimit = await api.query.SubtensorModule.NetworkRateLimit.getValue()
if (rateLimit !== BigInt(0)) {
const internalCall = api.tx.AdminUtils.sudo_set_network_rate_limit({ rate_limit: BigInt(0) })
@@ -28,8 +30,8 @@ export async function addNewSubnetwork(api: TypedApi, hotkey: Key
// could create multiple subnetworks during retry, just return the first created one
assert.ok(newTotalNetworks > totalNetworks)
- // rewrite network last lock cost to 0, to avoid the lock cost calculation error
- await setNetworkLastLockCost(api)
+ // reset network last lock cost to 0, to avoid the lock cost calculation error
+ await setNetworkLastLockCost(api, defaultNetworkLastLockCost)
return totalNetworks
}
@@ -404,11 +406,11 @@ export async function sendWasmContractExtrinsic(api: TypedApi, co
await waitForTransactionWithRetry(api, tx, signer)
}
-export async function setNetworkLastLockCost(api: TypedApi) {
+export async function setNetworkLastLockCost(api: TypedApi, defaultNetworkLastLockCost: bigint) {
const alice = getAliceSigner()
const key = await api.query.SubtensorModule.NetworkLastLockCost.getKey()
const codec = await getTypedCodecs(devnet);
- const value = codec.query.SubtensorModule.NetworkLastLockCost.value.enc(BigInt(0))
+ const value = codec.query.SubtensorModule.NetworkLastLockCost.value.enc(defaultNetworkLastLockCost)
const internalCall = api.tx.System.set_storage({
items: [[Binary.fromHex(key), Binary.fromBytes(value)]]
})
From 6cf5808ff7609f4963e4c2154da6bbac98c9bdf5 Mon Sep 17 00:00:00 2001
From: open-junius
Date: Tue, 20 Jan 2026 19:46:23 +0800
Subject: [PATCH 8/9] set as default value for last lock cost
---
contract-tests/src/subtensor.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/contract-tests/src/subtensor.ts b/contract-tests/src/subtensor.ts
index 1b05ad8d5a..f5829c76aa 100644
--- a/contract-tests/src/subtensor.ts
+++ b/contract-tests/src/subtensor.ts
@@ -418,5 +418,5 @@ export async function setNetworkLastLockCost(api: TypedApi, defau
await waitForTransactionWithRetry(api, tx, alice)
const valueOnChain = await api.query.SubtensorModule.NetworkLastLockCost.getValue()
- assert.equal(BigInt(0), valueOnChain)
+ assert.equal(defaultNetworkLastLockCost, valueOnChain)
}
\ No newline at end of file
From 3a3379f99e2be6f3166a03c2427142564b6eb08d Mon Sep 17 00:00:00 2001
From: John Reed <87283488+JohnReedV@users.noreply.github.com>
Date: Tue, 20 Jan 2026 10:38:34 -0800
Subject: [PATCH 9/9] bump spec
---
runtime/src/lib.rs | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs
index e5491e1eea..225c2e2706 100644
--- a/runtime/src/lib.rs
+++ b/runtime/src/lib.rs
@@ -241,7 +241,7 @@ pub const VERSION: RuntimeVersion = RuntimeVersion {
// `spec_version`, and `authoring_version` are the same between Wasm and native.
// This value is set to 100 to notify Polkadot-JS App (https://polkadot.js.org/apps) to use
// the compatible custom types.
- spec_version: 370,
+ spec_version: 371,
impl_version: 1,
apis: RUNTIME_API_VERSIONS,
transaction_version: 1,