From 3f985eb62678ea80a33fdb0296722670f8619dd0 Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Mon, 1 Dec 2025 23:03:16 +0100 Subject: [PATCH 01/16] commit Cargo.lock --- contract-tests/src/contracts/votingPower.ts | 85 +++ .../test/votingPower.precompile.test.ts | 206 ++++++ pallets/admin-utils/src/lib.rs | 2 + pallets/subtensor/src/epoch/run_epoch.rs | 3 + pallets/subtensor/src/lib.rs | 49 +- pallets/subtensor/src/macros/dispatches.rs | 91 +++ pallets/subtensor/src/macros/errors.rs | 4 + pallets/subtensor/src/macros/events.rs | 29 + pallets/subtensor/src/swap/swap_hotkey.rs | 5 + pallets/subtensor/src/tests/mod.rs | 1 + pallets/subtensor/src/tests/voting_power.rs | 603 ++++++++++++++++++ pallets/subtensor/src/utils/mod.rs | 1 + pallets/subtensor/src/utils/voting_power.rs | 314 +++++++++ precompiles/src/lib.rs | 8 +- precompiles/src/voting_power.rs | 112 ++++ 15 files changed, 1511 insertions(+), 2 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..7cbc5b30d0 --- /dev/null +++ b/contract-tests/src/contracts/votingPower.ts @@ -0,0 +1,85 @@ +export const IVOTING_POWER_ADDRESS = "0x0000000000000000000000000000000000000806"; + +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" + } +] diff --git a/contract-tests/test/votingPower.precompile.test.ts b/contract-tests/test/votingPower.precompile.test.ts new file mode 100644 index 0000000000..8337437c93 --- /dev/null +++ b/contract-tests/test/votingPower.precompile.test.ts @@ -0,0 +1,206 @@ +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"); + }); + }); + + 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 four 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] + }) + ]); + + // All functions should return defined values + results.forEach((result, index) => { + 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"); + }); + }); +}); diff --git a/pallets/admin-utils/src/lib.rs b/pallets/admin-utils/src/lib.rs index c3999312b3..4ee9d4ef8a 100644 --- a/pallets/admin-utils/src/lib.rs +++ b/pallets/admin-utils/src/lib.rs @@ -143,6 +143,8 @@ pub mod pallet { Proxy, /// Leasing precompile Leasing, + /// 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..ce010eaa96 100644 --- a/pallets/subtensor/src/epoch/run_epoch.rs +++ b/pallets/subtensor/src/epoch/run_epoch.rs @@ -138,6 +138,9 @@ impl Pallet { ValidatorTrust::::insert(netuid, validator_trust); ValidatorPermit::::insert(netuid, new_validator_permit); StakeWeight::::insert(netuid, stake_weight); + + // Update voting power EMA for all validators on this subnet + Self::update_voting_power_for_subnet(netuid); } /// Calculates reward consensus and returns the emissions for uids/hotkeys in a given `netuid`. diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index ef2d44e68b..537dcabb78 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1890,8 +1890,55 @@ 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 { + 100_000_000_000_000_000 // 0.1 * 10^18 + } + + #[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 8c0b2210ec..47998f0983 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 a06e035d86..aca8bf09aa 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -470,6 +470,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/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..11b67064c0 --- /dev/null +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -0,0 +1,603 @@ +#![allow(unused, clippy::indexing_slicing, clippy::panic, clippy::unwrap_used)] + +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::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 + +/// 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 + 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 { + SubtensorModule::update_voting_power_for_subnet(self.netuid); + } + } + + /// 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, 100_000_000_000_000_000); // 0.1 * 10^18 + + // 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 + SubtensorModule::update_voting_power_for_subnet(netuid); + + // 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"); + }); +} + +// ============================================ +// === 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..0c11d52668 100644 --- a/pallets/subtensor/src/utils/mod.rs +++ b/pallets/subtensor/src/utils/mod.rs @@ -3,5 +3,6 @@ pub mod evm; pub mod identity; pub mod misc; pub mod rate_limiting; +pub mod voting_power; #[cfg(feature = "try-runtime")] pub mod try_state; diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs new file mode 100644 index 0000000000..ebc5cd431e --- /dev/null +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -0,0 +1,314 @@ +use super::*; +use subtensor_runtime_common::{Currency, 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 {:?} for netuid {:?}", + disable_at_block, + 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 {:?} for netuid {:?}", + alpha, + netuid + ); + + Ok(()) + } + + // ======================== + // === Epoch Processing === + // ======================== + + /// Update voting power for all validators on a subnet during epoch. + /// Called from persist_netuid_epoch_terms or similar epoch processing function. + pub fn update_voting_power_for_subnet(netuid: NetUid) { + // 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(); + + // Get all hotkeys registered on this subnet + let n = Self::get_subnetwork_n(netuid); + + for uid in 0..n { + if let Ok(hotkey) = Self::get_hotkey_for_net_and_uid(netuid, uid) { + // Only validators (with vpermit) get voting power, not miners + if Self::get_validator_permit_for_uid(netuid, uid) { + Self::update_voting_power_for_hotkey(netuid, &hotkey, 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 {:?} on netuid {:?}", + hotkey, + netuid + ); + } + } + + /// Update voting power for a single hotkey. + fn update_voting_power_for_hotkey( + netuid: NetUid, + hotkey: &T::AccountId, + alpha: u64, + min_stake: u64, + ) { + // Get current stake for the hotkey on this subnet + // If deregistered (not in IsNetworkMember), stake is treated as 0 + let current_stake = if IsNetworkMember::::get(hotkey, netuid) { + Self::get_total_stake_for_hotkey(hotkey).to_u64() + } else { + 0 + }; + + // 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 it 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 {:?} on netuid {:?} (decayed below threshold: {:?} < {:?})", + hotkey, + netuid, + 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 {:?} on netuid {:?}: {:?} -> {:?}", + hotkey, + 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 - 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 {:?} to {:?} on netuid {:?}: {:?}", + old_hotkey, + new_hotkey, + netuid, + voting_power + ); + } + } +} diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index dedeebe249..d9f8222e23 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -40,6 +40,7 @@ use crate::staking::*; use crate::storage_query::*; use crate::subnet::*; use crate::uid_lookup::*; +use crate::voting_power::*; mod alpha; mod balance_transfer; @@ -55,6 +56,7 @@ mod staking; mod storage_query; mod subnet; mod uid_lookup; +mod voting_power; pub struct Precompiles(PhantomData); @@ -123,7 +125,7 @@ where Self(Default::default()) } - pub fn used_addresses() -> [H160; 25] { + pub fn used_addresses() -> [H160; 26] { [ hash(1), hash(2), @@ -149,6 +151,7 @@ where hash(AlphaPrecompile::::INDEX), hash(CrowdloanPrecompile::::INDEX), hash(LeasingPrecompile::::INDEX), + hash(VotingPowerPrecompile::::INDEX), hash(ProxyPrecompile::::INDEX), ] } @@ -242,6 +245,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..9f59c6d886 --- /dev/null +++ b/precompiles/src/voting_power.rs @@ -0,0 +1,112 @@ +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 = 2054; +} + +#[precompile_utils::precompile] +impl VotingPowerPrecompile +where + R: frame_system::Config + pallet_subtensor::Config, + R::AccountId: From<[u8; 32]> + ByteArray, +{ + /// 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), + )) + } +} From 7c56e26b3022b95e0ddfb59810379032a1c2b570 Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Tue, 2 Dec 2025 23:56:11 +0100 Subject: [PATCH 02/16] CI fixes --- pallets/subtensor/src/lib.rs | 11 +-- pallets/subtensor/src/tests/voting_power.rs | 80 ++++++++++++++++----- pallets/subtensor/src/utils/mod.rs | 2 +- pallets/subtensor/src/utils/voting_power.rs | 52 ++++---------- precompiles/src/voting_power.rs | 2 +- runtime/src/lib.rs | 2 +- 6 files changed, 79 insertions(+), 70 deletions(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 537dcabb78..599ec6342b 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1905,15 +1905,8 @@ pub mod pallet { /// --- 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, - >; + 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. diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index 11b67064c0..fa78e0b7a3 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -8,7 +8,9 @@ use subtensor_runtime_common::NetUid; use super::mock; use super::mock::*; -use crate::utils::voting_power::{MAX_VOTING_POWER_EMA_ALPHA, VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS}; +use crate::utils::voting_power::{ + MAX_VOTING_POWER_EMA_ALPHA, VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS, +}; use crate::*; // ============================================ @@ -30,7 +32,11 @@ impl VotingPowerTestFixture { let hotkey = U256::from(1); let coldkey = U256::from(2); let netuid = add_dynamic_network(&hotkey, &coldkey); - Self { hotkey, coldkey, netuid } + Self { + hotkey, + coldkey, + netuid, + } } /// Setup reserves and add balance to coldkey for staking @@ -99,14 +105,19 @@ fn test_enable_voting_power_tracking() { let f = VotingPowerTestFixture::new(); // Initially disabled - assert!(!SubtensorModule::get_voting_power_tracking_enabled(f.netuid)); + 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); + assert_eq!( + SubtensorModule::get_voting_power_disable_at_block(f.netuid), + 0 + ); }); } @@ -144,7 +155,10 @@ fn test_disable_voting_power_tracking_schedules_disable() { // 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); + assert_eq!( + disable_at, + current_block + VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS + ); }); } @@ -182,7 +196,9 @@ fn test_enable_voting_power_tracking_non_owner_fails() { ); // Should still be disabled - assert!(!SubtensorModule::get_voting_power_tracking_enabled(f.netuid)); + assert!(!SubtensorModule::get_voting_power_tracking_enabled( + f.netuid + )); }); } @@ -205,7 +221,10 @@ fn test_disable_voting_power_tracking_non_owner_fails() { // 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); + assert_eq!( + SubtensorModule::get_voting_power_disable_at_block(f.netuid), + 0 + ); }); } @@ -231,7 +250,10 @@ fn test_set_voting_power_ema_alpha() { new_alpha )); - assert_eq!(SubtensorModule::get_voting_power_ema_alpha(f.netuid), new_alpha); + assert_eq!( + SubtensorModule::get_voting_power_ema_alpha(f.netuid), + new_alpha + ); }); } @@ -322,8 +344,10 @@ fn test_voting_power_cleared_when_deregistered() { // 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"); + assert!( + !VotingPower::::contains_key(f.netuid, &f.hotkey), + "Entry should be removed when hotkey is deregistered" + ); }); } @@ -341,7 +365,11 @@ fn test_only_validators_get_voting_power() { let netuid = add_dynamic_network(&validator_hotkey, &coldkey); - mock::setup_reserves(netuid, (DEFAULT_STAKE_AMOUNT * 100).into(), (DEFAULT_STAKE_AMOUNT * 100).into()); + 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 @@ -426,7 +454,10 @@ fn test_voting_power_transfers_on_hotkey_swap() { // 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); + assert_eq!( + SubtensorModule::get_voting_power(f.netuid, &new_hotkey), + voting_power_value + ); }); } @@ -482,9 +513,14 @@ fn test_voting_power_not_removed_if_never_above_threshold() { // 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"); + 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" + ); }); } @@ -533,7 +569,10 @@ fn test_reenable_voting_power_clears_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); + assert_eq!( + SubtensorModule::get_voting_power_disable_at_block(f.netuid), + 0 + ); }); } @@ -569,8 +608,13 @@ fn test_voting_power_finalized_after_grace_period() { 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!(!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); }); } diff --git a/pallets/subtensor/src/utils/mod.rs b/pallets/subtensor/src/utils/mod.rs index 0c11d52668..a91875da59 100644 --- a/pallets/subtensor/src/utils/mod.rs +++ b/pallets/subtensor/src/utils/mod.rs @@ -3,6 +3,6 @@ pub mod evm; pub mod identity; pub mod misc; pub mod rate_limiting; -pub mod voting_power; #[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 index ebc5cd431e..61a944bf8a 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -50,10 +50,7 @@ impl Pallet { // Emit event Self::deposit_event(Event::VotingPowerTrackingEnabled { netuid }); - log::info!( - "VotingPower tracking enabled for netuid {:?}", - netuid - ); + log::info!("VotingPower tracking enabled for netuid {netuid:?}"); Ok(()) } @@ -69,7 +66,8 @@ impl Pallet { // 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); + let disable_at_block = + current_block.saturating_add(VOTING_POWER_DISABLE_GRACE_PERIOD_BLOCKS); // Schedule disable VotingPowerDisableAtBlock::::insert(netuid, disable_at_block); @@ -81,9 +79,7 @@ impl Pallet { }); log::info!( - "VotingPower tracking scheduled to disable at block {:?} for netuid {:?}", - disable_at_block, - netuid + "VotingPower tracking scheduled to disable at block {disable_at_block:?} for netuid {netuid:?}" ); Ok(()) @@ -103,11 +99,7 @@ impl Pallet { // Emit event Self::deposit_event(Event::VotingPowerEmaAlphaSet { netuid, alpha }); - log::info!( - "VotingPower EMA alpha set to {:?} for netuid {:?}", - alpha, - netuid - ); + log::info!("VotingPower EMA alpha set to {alpha:?} for netuid {netuid:?}"); Ok(()) } @@ -159,10 +151,7 @@ impl Pallet { // 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 - ); + log::trace!("VotingPower updated for validators on netuid {netuid:?}"); } /// Clear voting power for hotkeys that are no longer registered on the subnet. @@ -183,9 +172,7 @@ impl Pallet { for hotkey in hotkeys_to_remove { VotingPower::::remove(netuid, &hotkey); log::trace!( - "VotingPower removed for deregistered hotkey {:?} on netuid {:?}", - hotkey, - netuid + "VotingPower removed for deregistered hotkey {hotkey:?} on netuid {netuid:?}" ); } } @@ -219,21 +206,13 @@ impl Pallet { // Was above threshold, now decayed below - remove VotingPower::::remove(netuid, hotkey); log::trace!( - "VotingPower removed for hotkey {:?} on netuid {:?} (decayed below threshold: {:?} < {:?})", - hotkey, - netuid, - new_ema, - min_stake + "VotingPower removed for hotkey {hotkey:?} on netuid {netuid:?} (decayed below 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 {:?} on netuid {:?}: {:?} -> {:?}", - hotkey, - netuid, - previous_ema, - new_ema + "VotingPower updated for hotkey {hotkey:?} on netuid {netuid:?}: {previous_ema:?} -> {new_ema:?}" ); } // If new_ema == 0 do nothing @@ -245,7 +224,7 @@ impl Pallet { 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 - alpha_128; + 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; @@ -277,10 +256,7 @@ impl Pallet { // Emit event Self::deposit_event(Event::VotingPowerTrackingDisabled { netuid }); - log::info!( - "VotingPower tracking disabled and entries cleared for netuid {:?}", - netuid - ); + log::info!("VotingPower tracking disabled and entries cleared for netuid {netuid:?}"); } // ======================== @@ -303,11 +279,7 @@ impl Pallet { VotingPower::::insert(netuid, new_hotkey, voting_power.saturating_add(existing)); log::trace!( - "VotingPower transferred from {:?} to {:?} on netuid {:?}: {:?}", - old_hotkey, - new_hotkey, - netuid, - voting_power + "VotingPower transferred from {old_hotkey:?} to {new_hotkey:?} on netuid {netuid:?}: {voting_power:?}" ); } } diff --git a/precompiles/src/voting_power.rs b/precompiles/src/voting_power.rs index 9f59c6d886..23cdfbe69d 100644 --- a/precompiles/src/voting_power.rs +++ b/precompiles/src/voting_power.rs @@ -25,7 +25,7 @@ where impl VotingPowerPrecompile where R: frame_system::Config + pallet_subtensor::Config, - R::AccountId: From<[u8; 32]> + ByteArray, + R::AccountId: From<[u8; 32]>, { /// Get voting power for a hotkey on a subnet. /// diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 48e49b76c9..1af76d502b 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: 366, + spec_version: 367, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, From aa08aa68e9a84bb26e5ecd231a974065348c741f Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Thu, 8 Jan 2026 22:23:55 +0100 Subject: [PATCH 03/16] refactor: pass epoch terms to voting power instead of re-reading state --- pallets/subtensor/src/epoch/run_epoch.rs | 8 +++- pallets/subtensor/src/subnets/mechanism.rs | 4 +- pallets/subtensor/src/tests/voting_power.rs | 32 ++++++++++++- pallets/subtensor/src/utils/voting_power.rs | 50 ++++++++++----------- 4 files changed, 65 insertions(+), 29 deletions(-) diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs index ce010eaa96..33b774415d 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); @@ -140,7 +141,7 @@ impl Pallet { StakeWeight::::insert(netuid, stake_weight); // Update voting power EMA for all validators on this subnet - Self::update_voting_power_for_subnet(netuid); + Self::update_voting_power_for_subnet(netuid, output); } /// Calculates reward consensus and returns the emissions for uids/hotkeys in a given `netuid`. @@ -991,6 +992,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(); @@ -1015,6 +1020,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/subnets/mechanism.rs b/pallets/subtensor/src/subnets/mechanism.rs index 481974ef05..2ef68ae4be 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 diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index fa78e0b7a3..87a64bbcc1 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -1,5 +1,6 @@ #![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; @@ -8,6 +9,7 @@ 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, }; @@ -19,6 +21,30 @@ use crate::*; 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, @@ -76,7 +102,8 @@ impl VotingPowerTestFixture { /// Run voting power update for N epochs fn run_epochs(&self, n: u32) { for _ in 0..n { - SubtensorModule::update_voting_power_for_subnet(self.netuid); + let epoch_output = build_mock_epoch_output(self.netuid); + SubtensorModule::update_voting_power_for_subnet(self.netuid, &epoch_output); } } @@ -399,7 +426,8 @@ fn test_only_validators_get_voting_power() { ValidatorPermit::::insert(netuid, vec![true, false]); // Run epoch - SubtensorModule::update_voting_power_for_subnet(netuid); + 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); diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs index 61a944bf8a..e58f383818 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -1,5 +1,7 @@ use super::*; -use subtensor_runtime_common::{Currency, NetUid}; +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 @@ -108,9 +110,11 @@ impl Pallet { // === Epoch Processing === // ======================== - /// Update voting power for all validators on a subnet during epoch. - /// Called from persist_netuid_epoch_terms or similar epoch processing function. - pub fn update_voting_power_for_subnet(netuid: NetUid) { + /// 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; @@ -133,18 +137,21 @@ impl Pallet { // Get minimum stake threshold for validator permit let min_stake = Self::get_stake_threshold(); - // Get all hotkeys registered on this subnet - let n = Self::get_subnetwork_n(netuid); - - for uid in 0..n { - if let Ok(hotkey) = Self::get_hotkey_for_net_and_uid(netuid, uid) { - // Only validators (with vpermit) get voting power, not miners - if Self::get_validator_permit_for_uid(netuid, uid) { - Self::update_voting_power_for_hotkey(netuid, &hotkey, alpha, min_stake); - } else { - // Miner without vpermit - remove any existing voting power - VotingPower::::remove(netuid, &hotkey); - } + // 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); } } @@ -177,21 +184,14 @@ impl Pallet { } } - /// Update voting power for a single hotkey. + /// 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 current stake for the hotkey on this subnet - // If deregistered (not in IsNetworkMember), stake is treated as 0 - let current_stake = if IsNetworkMember::::get(hotkey, netuid) { - Self::get_total_stake_for_hotkey(hotkey).to_u64() - } else { - 0 - }; - // Get previous EMA value let previous_ema = VotingPower::::get(netuid, hotkey); From e541d534dd668abbe3b88b9c638c875fead1bd99 Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Thu, 8 Jan 2026 23:16:28 +0100 Subject: [PATCH 04/16] add removal hysteresis --- pallets/subtensor/src/tests/voting_power.rs | 81 +++++++++++++++++++++ pallets/subtensor/src/utils/voting_power.rs | 11 ++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index 87a64bbcc1..df51ecc49b 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -552,6 +552,87 @@ fn test_voting_power_not_removed_if_never_above_threshold() { }); } +// 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 === // ============================================ diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs index e58f383818..7c37616c34 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -200,13 +200,16 @@ impl Pallet { // 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 it decayed below. + // Use 90% of min_stake as removal threshold (hysteresis to prevent noise-triggered removal) + let removal_threshold = min_stake.saturating_mul(9) / 10; + + // Only remove if they previously had voting power ABOVE threshold and decayed significantly 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 + if new_ema < removal_threshold && previous_ema >= min_stake { + // Was above threshold, now decayed significantly below - remove VotingPower::::remove(netuid, hotkey); log::trace!( - "VotingPower removed for hotkey {hotkey:?} on netuid {netuid:?} (decayed below threshold: {new_ema:?} < {min_stake:?})" + "VotingPower removed for hotkey {hotkey:?} on netuid {netuid:?} (decayed below removal threshold: {new_ema:?} < {removal_threshold:?})" ); } else if new_ema > 0 { // Update voting power (building up or maintaining) From 4af751d672b89e8d33454c1b84f0d9902e49d342 Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Fri, 9 Jan 2026 22:19:50 +0100 Subject: [PATCH 05/16] move update_voting_power_for_subnet and fix formatting --- pallets/subtensor/src/epoch/run_epoch.rs | 3 --- pallets/subtensor/src/subnets/mechanism.rs | 3 +++ pallets/subtensor/src/tests/voting_power.rs | 5 ++++- pallets/subtensor/src/utils/voting_power.rs | 11 +++-------- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/pallets/subtensor/src/epoch/run_epoch.rs b/pallets/subtensor/src/epoch/run_epoch.rs index 33b774415d..f90175ce0c 100644 --- a/pallets/subtensor/src/epoch/run_epoch.rs +++ b/pallets/subtensor/src/epoch/run_epoch.rs @@ -139,9 +139,6 @@ impl Pallet { ValidatorTrust::::insert(netuid, validator_trust); ValidatorPermit::::insert(netuid, new_validator_permit); StakeWeight::::insert(netuid, stake_weight); - - // Update voting power EMA for all validators on this subnet - Self::update_voting_power_for_subnet(netuid, output); } /// Calculates reward consensus and returns the emissions for uids/hotkeys in a given `netuid`. diff --git a/pallets/subtensor/src/subnets/mechanism.rs b/pallets/subtensor/src/subnets/mechanism.rs index 2ef68ae4be..b5ed928930 100644 --- a/pallets/subtensor/src/subnets/mechanism.rs +++ b/pallets/subtensor/src/subnets/mechanism.rs @@ -360,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/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index df51ecc49b..51edcfd351 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -29,7 +29,10 @@ fn build_mock_epoch_output(netuid: NetUid) -> BTreeMap { 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 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, diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs index 7c37616c34..5b66cfebb4 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -1,6 +1,7 @@ use super::*; use crate::epoch::run_epoch::EpochTerms; use alloc::collections::BTreeMap; +use safe_math::*; use subtensor_runtime_common::NetUid; /// 14 days in blocks (assuming ~12 second blocks) @@ -142,13 +143,7 @@ impl Pallet { // 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, - ); + 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); @@ -201,7 +196,7 @@ impl Pallet { let new_ema = Self::calculate_voting_power_ema(current_stake, previous_ema, alpha); // Use 90% of min_stake as removal threshold (hysteresis to prevent noise-triggered removal) - let removal_threshold = min_stake.saturating_mul(9) / 10; + let removal_threshold = min_stake.saturating_mul(9).safe_div(10); // Only remove if they previously had voting power ABOVE threshold and decayed significantly below. // This allows new validators to build up voting power from 0 without being removed. From 6144ef6c01400dfb2ba5d7a81aea645abc38b2bb Mon Sep 17 00:00:00 2001 From: konrad0960 Date: Fri, 9 Jan 2026 22:41:12 +0100 Subject: [PATCH 06/16] get total voting power precompile --- contract-tests/src/contracts/votingPower.ts | 19 +++++++++++++++ .../test/votingPower.precompile.test.ts | 24 +++++++++++++++++-- precompiles/src/voting_power.rs | 19 +++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/contract-tests/src/contracts/votingPower.ts b/contract-tests/src/contracts/votingPower.ts index 7cbc5b30d0..743b4ed1a6 100644 --- a/contract-tests/src/contracts/votingPower.ts +++ b/contract-tests/src/contracts/votingPower.ts @@ -81,5 +81,24 @@ export const IVotingPowerABI = [ ], "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 index 8337437c93..26b29e257c 100644 --- a/contract-tests/test/votingPower.precompile.test.ts +++ b/contract-tests/test/votingPower.precompile.test.ts @@ -114,6 +114,19 @@ describe("Test VotingPower Precompile", () => { 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", () => { @@ -163,7 +176,7 @@ describe("Test VotingPower Precompile", () => { it("All VotingPower precompile functions can be called", async () => { const hotkeyBytes32 = '0x' + Buffer.from(hotkey.publicKey).toString('hex'); - // Test all four functions + // Test all five functions const results = await Promise.all([ publicClient.readContract({ abi: IVotingPowerABI, @@ -188,11 +201,17 @@ describe("Test VotingPower Precompile", () => { 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, index) => { + results.forEach((result: unknown, index: number) => { assert.ok(result !== undefined, `Function ${index} should return a value`); }); @@ -201,6 +220,7 @@ describe("Test VotingPower Precompile", () => { 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/precompiles/src/voting_power.rs b/precompiles/src/voting_power.rs index 23cdfbe69d..c558ce03b0 100644 --- a/precompiles/src/voting_power.rs +++ b/precompiles/src/voting_power.rs @@ -109,4 +109,23 @@ where 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 9b43431ba7eae29afdfcfdbbf1e2f98ceecb706d Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Tue, 13 Jan 2026 22:15:32 +0000 Subject: [PATCH 07/16] Simplify the formula for removal of VotingPower entry --- pallets/subtensor/src/utils/voting_power.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs index 5b66cfebb4..8950910074 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -195,16 +195,13 @@ impl Pallet { // 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); - // Use 90% of min_stake as removal threshold (hysteresis to prevent noise-triggered removal) - let removal_threshold = min_stake.saturating_mul(9).safe_div(10); - - // Only remove if they previously had voting power ABOVE threshold and decayed significantly below. + // 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 < removal_threshold && previous_ema >= min_stake { - // Was above threshold, now decayed significantly below - remove + 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:?} < {removal_threshold:?})" + "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) From 61968495f4dd9569bf2080eba7a0061658f8b5b9 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Wed, 14 Jan 2026 17:39:15 +0000 Subject: [PATCH 08/16] Adjust DefaultVotingPowerEmaAlpha to 2 weeks --- pallets/subtensor/src/lib.rs | 6 +++++- pallets/subtensor/src/utils/voting_power.rs | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 5d7359d74b..a97c5ae521 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1902,7 +1902,11 @@ pub mod pallet { /// 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 { - 100_000_000_000_000_000 // 0.1 * 10^18 + 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] diff --git a/pallets/subtensor/src/utils/voting_power.rs b/pallets/subtensor/src/utils/voting_power.rs index 8950910074..380861bbf5 100644 --- a/pallets/subtensor/src/utils/voting_power.rs +++ b/pallets/subtensor/src/utils/voting_power.rs @@ -1,7 +1,6 @@ use super::*; use crate::epoch::run_epoch::EpochTerms; use alloc::collections::BTreeMap; -use safe_math::*; use subtensor_runtime_common::NetUid; /// 14 days in blocks (assuming ~12 second blocks) From bd3969aca1ee1c5f5670f24f592187ba48c59393 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Wed, 14 Jan 2026 19:00:31 +0000 Subject: [PATCH 09/16] cargo fmt --- pallets/subtensor/src/lib.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index a97c5ae521..ee3e8823cb 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1903,10 +1903,10 @@ pub mod pallet { /// 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% + // 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] From c4b8ff98202429105f203d03965bcd29b0389875 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Wed, 14 Jan 2026 22:20:41 +0000 Subject: [PATCH 10/16] cargo clippy --- pallets/subtensor/src/tests/voting_power.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index 51edcfd351..4c9428c6b8 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -367,7 +367,7 @@ fn test_voting_power_cleared_when_deregistered() { 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); + IsNetworkMember::::remove(f.hotkey, f.netuid); // Run epoch - voting power should be cleared for deregistered hotkey f.run_epochs(1); @@ -375,7 +375,7 @@ fn test_voting_power_cleared_when_deregistered() { // Should be removed from storage immediately when deregistered assert_eq!(f.get_voting_power(), 0); assert!( - !VotingPower::::contains_key(f.netuid, &f.hotkey), + !VotingPower::::contains_key(f.netuid, f.hotkey), "Entry should be removed when hotkey is deregistered" ); }); @@ -549,7 +549,7 @@ fn test_voting_power_not_removed_if_never_above_threshold() { "Voting power should still exist - it was never above threshold" ); assert!( - VotingPower::::contains_key(f.netuid, &f.hotkey), + VotingPower::::contains_key(f.netuid, f.hotkey), "Entry should exist - it was never above threshold so shouldn't be removed" ); }); @@ -589,7 +589,7 @@ fn test_voting_power_not_removed_with_small_dip_below_threshold() { // Should NOT be removed - dip is within hysteresis buffer assert!( - VotingPower::::contains_key(f.netuid, &f.hotkey), + VotingPower::::contains_key(f.netuid, f.hotkey), "Entry should exist - small dip within 10% buffer should not trigger removal" ); }); @@ -630,7 +630,7 @@ fn test_voting_power_removed_with_significant_drop_below_threshold() { SubtensorModule::update_voting_power_for_subnet(f.netuid, &epoch_output); assert!( - !VotingPower::::contains_key(f.netuid, &f.hotkey), + !VotingPower::::contains_key(f.netuid, f.hotkey), "Entry should be removed - stake dropped to 0 with alpha=1.0" ); }); From 3d65741b778c4b6e293329df6728bd9955ac2417 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Wed, 14 Jan 2026 22:54:34 +0000 Subject: [PATCH 11/16] Thank you clippy but I know what I'm doing --- pallets/subtensor/src/lib.rs | 1 + pallets/subtensor/src/tests/voting_power.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index ee3e8823cb..ac175b62ad 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1901,6 +1901,7 @@ pub mod pallet { #[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 + #[allow(clippy::zero_prefixed_literal)] 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 diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index 4c9428c6b8..5306769770 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -74,6 +74,7 @@ impl VotingPowerTestFixture { } /// 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); From 4ce1f5d6da9abee7eb0de57e879104e5f1868053 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Wed, 14 Jan 2026 23:59:17 +0000 Subject: [PATCH 12/16] Fix macro conflict --- pallets/subtensor/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index ac175b62ad..58e1c6de4b 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -1901,8 +1901,8 @@ pub mod pallet { #[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 - #[allow(clippy::zero_prefixed_literal)] pub fn DefaultVotingPowerEmaAlpha() -> u64 { + #![allow(clippy::zero_prefixed_literal)] 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 From eeb01f1c226e44ada1c753f140e6aac1a7362b44 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 15 Jan 2026 16:16:12 +0000 Subject: [PATCH 13/16] fix macro usage --- pallets/subtensor/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 58e1c6de4b..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: // @@ -1902,7 +1903,6 @@ pub mod pallet { /// 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 { - #![allow(clippy::zero_prefixed_literal)] 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 From 0e1bd21ea9b82319a75d87d2c0943a818dce2dcf Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Thu, 15 Jan 2026 22:14:17 +0000 Subject: [PATCH 14/16] Fix precompile to have unique id --- contract-tests/src/contracts/votingPower.ts | 2 +- precompiles/src/voting_power.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contract-tests/src/contracts/votingPower.ts b/contract-tests/src/contracts/votingPower.ts index 743b4ed1a6..af1ff4b55f 100644 --- a/contract-tests/src/contracts/votingPower.ts +++ b/contract-tests/src/contracts/votingPower.ts @@ -1,4 +1,4 @@ -export const IVOTING_POWER_ADDRESS = "0x0000000000000000000000000000000000000806"; +export const IVOTING_POWER_ADDRESS = "0x000000000000000000000000000000000000080c"; export const IVotingPowerABI = [ { diff --git a/precompiles/src/voting_power.rs b/precompiles/src/voting_power.rs index c558ce03b0..e9a572863b 100644 --- a/precompiles/src/voting_power.rs +++ b/precompiles/src/voting_power.rs @@ -18,7 +18,7 @@ where R: frame_system::Config + pallet_subtensor::Config, R::AccountId: From<[u8; 32]> + ByteArray, { - const INDEX: u64 = 2054; + const INDEX: u64 = 2060; } #[precompile_utils::precompile] From 1526f896e872eb6b6b8483669df084c003146816 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Fri, 16 Jan 2026 19:44:59 +0000 Subject: [PATCH 15/16] Change the precompile address again as after something else merged this one is no longer unique --- contract-tests/src/contracts/votingPower.ts | 2 +- precompiles/src/lib.rs | 2 +- precompiles/src/voting_power.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contract-tests/src/contracts/votingPower.ts b/contract-tests/src/contracts/votingPower.ts index af1ff4b55f..bbcc3ca6e6 100644 --- a/contract-tests/src/contracts/votingPower.ts +++ b/contract-tests/src/contracts/votingPower.ts @@ -1,4 +1,4 @@ -export const IVOTING_POWER_ADDRESS = "0x000000000000000000000000000000000000080c"; +export const IVOTING_POWER_ADDRESS = "0x000000000000000000000000000000000000080d"; export const IVotingPowerABI = [ { diff --git a/precompiles/src/lib.rs b/precompiles/src/lib.rs index 42de81d56e..abc5e744b2 100644 --- a/precompiles/src/lib.rs +++ b/precompiles/src/lib.rs @@ -127,7 +127,7 @@ where Self(Default::default()) } - pub fn used_addresses() -> [H160; 26] { + pub fn used_addresses() -> [H160; 27] { [ hash(1), hash(2), diff --git a/precompiles/src/voting_power.rs b/precompiles/src/voting_power.rs index e9a572863b..74e1731b6e 100644 --- a/precompiles/src/voting_power.rs +++ b/precompiles/src/voting_power.rs @@ -18,7 +18,7 @@ where R: frame_system::Config + pallet_subtensor::Config, R::AccountId: From<[u8; 32]> + ByteArray, { - const INDEX: u64 = 2060; + const INDEX: u64 = 2061; } #[precompile_utils::precompile] From c66f7823d91e456798bc249fe59244141e423982 Mon Sep 17 00:00:00 2001 From: Pawel Polewicz Date: Fri, 16 Jan 2026 20:15:53 +0000 Subject: [PATCH 16/16] update test --- pallets/subtensor/src/tests/voting_power.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pallets/subtensor/src/tests/voting_power.rs b/pallets/subtensor/src/tests/voting_power.rs index 5306769770..7edc06fbab 100644 --- a/pallets/subtensor/src/tests/voting_power.rs +++ b/pallets/subtensor/src/tests/voting_power.rs @@ -271,7 +271,7 @@ fn test_set_voting_power_ema_alpha() { // Get default alpha let default_alpha = SubtensorModule::get_voting_power_ema_alpha(f.netuid); - assert_eq!(default_alpha, 100_000_000_000_000_000); // 0.1 * 10^18 + 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