From 7d800ba9a89009187c60ced64a2a6861da27af1d Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Thu, 30 Jul 2026 16:33:26 +0200 Subject: [PATCH 1/8] Benchmark register_network against a full subnet map register_network's benchmark registers into a chain with no subnets on it, so neither of the two costs that scale with subnet count lands in the measurement: the unconditional NetworksAdded::iter() count, and the get_network_to_prune scan that runs once the count reaches SubnetLimit. Fill the map before measuring, and add register_network_pruning for the branch that was never measured at all. The two outcomes are mutually exclusive and neither dominates: creation costs more ref_time, pruning more proof_size. Both dispatches charge the componentwise max. register_network measures 293 reads and 14.39 ms of charged weight against the 41 reads and 5.98 ms it currently declares. --- .../subtensor/src/benchmarks/benchmarks.rs | 35 ++++ pallets/subtensor/src/benchmarks/helpers.rs | 34 ++++ pallets/subtensor/src/macros/dispatches.rs | 11 +- pallets/subtensor/src/weights.rs | 191 +++++++++++++++--- 4 files changed, 237 insertions(+), 34 deletions(-) diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index 375bdb61ea..e869014ecb 100644 --- a/pallets/subtensor/src/benchmarks/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks/benchmarks.rs @@ -301,10 +301,42 @@ mod pallet_benchmarks { let amount: u64 = 100_000_000_000_000u64.saturating_mul(2); add_balance_to_coldkey_account::(&coldkey, amount.into()); + frame_system::Pallet::::set_block_number(1u32.into()); + fill_subnets_below_limit::(&account("FillOwner", 0, seed)); + #[extrinsic_call] _(RawOrigin::Signed(coldkey.clone()), hotkey.clone()); } + /// The other outcome of `do_register_network`: at `SubnetLimit` the call prunes a subnet and + /// queues rather than creating one. Not an extrinsic; it exists so the two dispatches that can + /// reach this branch can charge the worst of the two. + #[benchmark] + fn register_network_pruning() { + let seed: u32 = 1; + let coldkey: T::AccountId = account("Test", 0, seed); + let hotkey: T::AccountId = account("TestHotkey", 0, seed); + + Subtensor::::set_network_rate_limit(1); + let amount: u64 = 100_000_000_000_000u64.saturating_mul(2); + add_balance_to_coldkey_account::(&coldkey, amount.into()); + + frame_system::Pallet::::set_block_number(1u32.into()); + fill_subnets_to_limit::(&account("FillOwner", 0, seed)); + + #[block] + { + let _ = Subtensor::::do_register_network( + RawOrigin::Signed(coldkey.clone()).into(), + &hotkey, + 1, + None, + ); + } + + assert!(!NetworkRegistrationQueue::::get().is_empty()); + } + #[benchmark] fn commit_weights() { let tempo: u16 = 1; @@ -1456,6 +1488,9 @@ mod pallet_benchmarks { let amount: u64 = 9_999_999_999_999; add_balance_to_coldkey_account::(&coldkey, amount.into()); + frame_system::Pallet::::set_block_number(1u32.into()); + fill_subnets_below_limit::(&account("FillOwner", 0, 1)); + #[extrinsic_call] _( RawOrigin::Signed(coldkey.clone()), diff --git a/pallets/subtensor/src/benchmarks/helpers.rs b/pallets/subtensor/src/benchmarks/helpers.rs index 2f45e98e80..3abc6730dd 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -15,6 +15,40 @@ pub(super) fn set_reserves( SubnetAlphaIn::::insert(netuid, alpha_in); } +/// Fill the chain with prunable subnets so a registration benchmark runs against a realistic +/// `NetworksAdded` map rather than an empty one. +/// +/// `do_register_network` counts every entry in `NetworksAdded` on every call, and once that count +/// reaches `SubnetLimit` it also walks `get_network_to_prune`. Registering into an empty chain +/// measures neither. The two outcomes are mutually exclusive and neither dominates the other: +/// below the limit the call creates a subnet (many writes), at the limit it prunes and queues +/// instead (far more reads). Callers pick which one they are measuring via `subnets`. +/// +/// Immunity is set to one block and `NetworkRegisteredAt` to zero so no candidate is skipped. +/// Requires a current block above zero. +pub(super) fn fill_subnets(owner: &T::AccountId, subnets: u16) { + NetworkImmunityPeriod::::set(1); + + for netuid in 1..=subnets { + let netuid = NetUid::from(netuid); + Subtensor::::init_new_network(netuid, 1); + NetworkRegisteredAt::::insert(netuid, 0); + SubnetOwner::::insert(netuid, owner.clone()); + } +} + +/// One below `SubnetLimit`: the registration still creates a subnet, but pays the full count. +pub(super) fn fill_subnets_below_limit(owner: &T::AccountId) { + let limit = Subtensor::::get_max_subnets(); + fill_subnets::(owner, limit.saturating_sub(1)); +} + +/// At `SubnetLimit`: the registration prunes a subnet and queues instead of creating one. +pub(super) fn fill_subnets_to_limit(owner: &T::AccountId) { + let limit = Subtensor::::get_max_subnets(); + fill_subnets::(owner, limit); +} + pub(super) fn benchmark_registration_burn() -> TaoBalance { TaoBalance::from(1_000_000) } diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index 4c190a7497..a809553b7b 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1001,8 +1001,12 @@ mod dispatches { } /// User register a new subnetwork + /// + /// Below `SubnetLimit` this creates a subnet; at the limit it prunes one and queues + /// instead. The two are mutually exclusive and neither dominates, so charge the worse. #[pallet::call_index(59)] - #[pallet::weight(::WeightInfo::register_network())] + #[pallet::weight(::WeightInfo::register_network() + .max(::WeightInfo::register_network_pruning()))] pub fn register_network(origin: OriginFor, hotkey: T::AccountId) -> DispatchResult { Self::do_register_network(origin, &hotkey, 1, None) } @@ -1181,8 +1185,11 @@ mod dispatches { } /// User register a new subnetwork + /// + /// Same two outcomes as `register_network`, so the same worst case applies. #[pallet::call_index(79)] - #[pallet::weight(::WeightInfo::register_network_with_identity())] + #[pallet::weight(::WeightInfo::register_network_with_identity() + .max(::WeightInfo::register_network_pruning()))] pub fn register_network_with_identity( origin: OriginFor, hotkey: T::AccountId, diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 63bd54336a..5e74840483 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -44,6 +44,7 @@ pub trait WeightInfo { fn burned_register() -> Weight; fn root_register() -> Weight; fn register_network() -> Weight; + fn register_network_pruning() -> Weight; fn commit_weights() -> Weight; fn reveal_weights() -> Weight; fn sudo_set_tx_childkey_take_rate_limit() -> Weight; @@ -604,7 +605,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworksAdded` (r:3 w:1) + /// Storage: `SubtensorModule::NetworksAdded` (r:129 w:1) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:0) /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -620,7 +621,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -704,18 +705,79 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `1532` - // Estimated: `9947` - // Minimum execution time: 145_000_000 picoseconds. - Weight::from_parts(153_000_000, 9947) - .saturating_add(T::DbWeight::get().reads(41_u64)) - .saturating_add(T::DbWeight::get().writes(48_u64)) + // Measured: `3721` + // Estimated: `323986` + // Minimum execution time: 2_130_304_000 picoseconds. + Weight::from_parts(2_160_771_000, 323986) + .saturating_add(T::DbWeight::get().reads(293_u64)) + .saturating_add(T::DbWeight::get().writes(49_u64)) + } + /// Storage: `SubtensorModule::Owner` (r:1 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationStartBlock` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkRegistrationStartBlock` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:0) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworksAdded` (r:130 w:1) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:1) + /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkMinLockCost` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkMinLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLockReductionInterval` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) + /// Proof: `Swap::BalancerTaoReservoir` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::BalancerAlphaReservoir` (r:1 w:1) + /// Proof: `Swap::BalancerAlphaReservoir` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) + /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationLockId` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationLockId` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn register_network_pruning() -> Weight { + // Proof Size summary in bytes: + // Measured: `3943` + // Estimated: `326683` + // Minimum execution time: 2_210_744_000 picoseconds. + Weight::from_parts(2_283_058_000, 326683) + .saturating_add(T::DbWeight::get().reads(408_u64)) + .saturating_add(T::DbWeight::get().writes(10_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -2050,7 +2112,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworksAdded` (r:3 w:1) + /// Storage: `SubtensorModule::NetworksAdded` (r:129 w:1) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:0) /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -2064,7 +2126,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -2150,18 +2212,20 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `1468` - // Estimated: `9883` - // Minimum execution time: 143_000_000 picoseconds. - Weight::from_parts(148_000_000, 9883) - .saturating_add(T::DbWeight::get().reads(40_u64)) - .saturating_add(T::DbWeight::get().writes(47_u64)) + // Measured: `3657` + // Estimated: `323922` + // Minimum execution time: 2_160_801_000 picoseconds. + Weight::from_parts(2_248_876_000, 323922) + .saturating_add(T::DbWeight::get().reads(292_u64)) + .saturating_add(T::DbWeight::get().writes(48_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -4218,7 +4282,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworksAdded` (r:3 w:1) + /// Storage: `SubtensorModule::NetworksAdded` (r:129 w:1) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:0) /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -4234,7 +4298,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:2 w:2) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -4318,18 +4382,79 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `1532` - // Estimated: `9947` - // Minimum execution time: 145_000_000 picoseconds. - Weight::from_parts(153_000_000, 9947) - .saturating_add(RocksDbWeight::get().reads(41_u64)) - .saturating_add(RocksDbWeight::get().writes(48_u64)) + // Measured: `3721` + // Estimated: `323986` + // Minimum execution time: 2_130_304_000 picoseconds. + Weight::from_parts(2_160_771_000, 323986) + .saturating_add(RocksDbWeight::get().reads(293_u64)) + .saturating_add(RocksDbWeight::get().writes(49_u64)) + } + /// Storage: `SubtensorModule::Owner` (r:1 w:0) + /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationStartBlock` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkRegistrationStartBlock` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRateLimit` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkRateLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:1 w:0) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworksAdded` (r:130 w:1) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:1) + /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkMinLockCost` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkMinLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLockReductionInterval` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) + /// Proof: `Swap::BalancerTaoReservoir` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `Swap::BalancerAlphaReservoir` (r:1 w:1) + /// Proof: `Swap::BalancerAlphaReservoir` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) + /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) + /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationLockId` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationLockId` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn register_network_pruning() -> Weight { + // Proof Size summary in bytes: + // Measured: `3943` + // Estimated: `326683` + // Minimum execution time: 2_210_744_000 picoseconds. + Weight::from_parts(2_283_058_000, 326683) + .saturating_add(RocksDbWeight::get().reads(408_u64)) + .saturating_add(RocksDbWeight::get().writes(10_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -5664,7 +5789,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetLimit` (r:1 w:0) /// Proof: `SubtensorModule::SubnetLimit` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworksAdded` (r:3 w:1) + /// Storage: `SubtensorModule::NetworksAdded` (r:129 w:1) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::DissolveCleanupQueue` (r:1 w:0) /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -5678,7 +5803,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -5764,18 +5889,20 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `1468` - // Estimated: `9883` - // Minimum execution time: 143_000_000 picoseconds. - Weight::from_parts(148_000_000, 9883) - .saturating_add(RocksDbWeight::get().reads(40_u64)) - .saturating_add(RocksDbWeight::get().writes(47_u64)) + // Measured: `3657` + // Estimated: `323922` + // Minimum execution time: 2_160_801_000 picoseconds. + Weight::from_parts(2_248_876_000, 323922) + .saturating_add(RocksDbWeight::get().reads(292_u64)) + .saturating_add(RocksDbWeight::get().writes(48_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) From 601cd39801e941a90585f7c48b7b8164cf65a0d4 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Thu, 30 Jul 2026 18:45:15 +0200 Subject: [PATCH 2/8] benchmarks: price the subnets the registration fixture builds fill_subnets created stable subnets with empty pools, so both price scans over NetworksAdded exited early on every entry. get_moving_alpha_price returns on SubnetMechanism == 0 without reading SubnetMovingPrice, and swap current_price returns before reading the TAO reserve or the balancer unless the alpha reserve is nonzero. The map was walked while nothing on it was priced. Every synthetic subnet now gets a dynamic mechanism and a funded pool. Reserves and moving prices are staggered per subnet so the median's BTreeMap holds distinct keys instead of collapsing to a single entry. register_network_with_identity and register_network_pruning now carry a maximum 6,656 byte identity. Creation writes it to storage; queueing encodes it into the queue entry and again into the event. Passing None measured neither, which is why main charges register_network_with_identity less than register_network on every dimension despite it doing strictly more work. register_network 293 -> 671 reads, register_network_pruning 408 -> 916, register_network_with_identity 292 -> 670. do_register_network has three successful outcomes, not two. The third, wait_to_cleanup, reaches the same queueing block without running get_network_to_prune or do_dissolve_network, so at equal queue depth it is dominated by the pruning measurement. Comments corrected to say so. --- .../subtensor/src/benchmarks/benchmarks.rs | 20 ++-- pallets/subtensor/src/benchmarks/helpers.rs | 52 ++++++++- pallets/subtensor/src/macros/dispatches.rs | 7 +- pallets/subtensor/src/weights.rs | 108 ++++++++++-------- 4 files changed, 126 insertions(+), 61 deletions(-) diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index e869014ecb..e2c9bb7e72 100644 --- a/pallets/subtensor/src/benchmarks/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks/benchmarks.rs @@ -308,9 +308,15 @@ mod pallet_benchmarks { _(RawOrigin::Signed(coldkey.clone()), hotkey.clone()); } - /// The other outcome of `do_register_network`: at `SubnetLimit` the call prunes a subnet and - /// queues rather than creating one. Not an extrinsic; it exists so the two dispatches that can - /// reach this branch can charge the worst of the two. + /// The queueing outcome of `do_register_network`: at `SubnetLimit` the call prunes a subnet and + /// queues rather than creating one. Not an extrinsic; it exists so the dispatches that can reach + /// this branch have a worst case to charge. + /// + /// This also covers the third outcome, `wait_to_cleanup`. That path reaches the same queueing + /// block without running `get_network_to_prune` or `do_dissolve_network`, so at equal queue + /// depth it is strictly cheaper than this one. Queue depth itself is measured at neither: both + /// queues are decoded unconditionally on every call and neither is a `BoundedVec`, so there is + /// no maximum to benchmark against. #[benchmark] fn register_network_pruning() { let seed: u32 = 1; @@ -326,12 +332,12 @@ mod pallet_benchmarks { #[block] { - let _ = Subtensor::::do_register_network( + assert_ok!(Subtensor::::do_register_network( RawOrigin::Signed(coldkey.clone()).into(), &hotkey, 1, - None, - ); + Some(max_subnet_identity()), + )); } assert!(!NetworkRegistrationQueue::::get().is_empty()); @@ -1481,7 +1487,7 @@ mod pallet_benchmarks { fn register_network_with_identity() { let coldkey: T::AccountId = whitelisted_caller(); let hotkey: T::AccountId = account("Alice", 0, 1); - let identity: Option = None; + let identity: Option = Some(max_subnet_identity()); Subtensor::::set_network_registration_allowed(1.into(), true); Subtensor::::set_network_rate_limit(1); diff --git a/pallets/subtensor/src/benchmarks/helpers.rs b/pallets/subtensor/src/benchmarks/helpers.rs index 3abc6730dd..98f52fa43f 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -15,25 +15,71 @@ pub(super) fn set_reserves( SubnetAlphaIn::::insert(netuid, alpha_in); } +/// The largest identity `is_valid_subnet_identity` accepts: 6,656 bytes across the eight fields. +/// +/// Both registration outcomes carry the identity. Creation writes it to `SubnetIdentitiesV3`; +/// queueing encodes it into the `NetworkRegistrationQueue` entry and again into the event. `None` +/// measures neither. +pub(super) fn max_subnet_identity() -> SubnetIdentityOfV3 { + SubnetIdentityOfV3 { + subnet_name: vec![b'n'; 256], + github_repo: vec![b'g'; 1024], + subnet_contact: vec![b'c'; 1024], + subnet_url: vec![b'u'; 1024], + discord: vec![b'd'; 256], + description: vec![b'e'; 1024], + logo_url: vec![b'l'; 1024], + additional: vec![b'a'; 1024], + } +} + /// Fill the chain with prunable subnets so a registration benchmark runs against a realistic /// `NetworksAdded` map rather than an empty one. /// /// `do_register_network` counts every entry in `NetworksAdded` on every call, and once that count /// reaches `SubnetLimit` it also walks `get_network_to_prune`. Registering into an empty chain -/// measures neither. The two outcomes are mutually exclusive and neither dominates the other: -/// below the limit the call creates a subnet (many writes), at the limit it prunes and queues -/// instead (far more reads). Callers pick which one they are measuring via `subnets`. +/// measures neither. Callers pick which outcome they are measuring via `subnets`: below the limit +/// the call creates a subnet (many writes), at the limit it prunes and queues instead (far more +/// reads). Neither dominates the other. /// /// Immunity is set to one block and `NetworkRegisteredAt` to zero so no candidate is skipped. /// Requires a current block above zero. +/// +/// Every subnet is made dynamic with a funded pool, and both prices are staggered per subnet. +/// +/// Two independent scans walk `NetworksAdded`, and a fixture of stable, empty-pool subnets makes +/// both of them exit early on every entry: +/// +/// - `get_network_to_prune` calls `get_moving_alpha_price`, which returns on `SubnetMechanism == 0` +/// without reading `SubnetMovingPrice`. +/// - `get_median_subnet_alpha_price` calls swap `current_price`, which returns on a non-dynamic +/// mechanism, and on a dynamic one still returns before reading the TAO reserve or the balancer +/// unless the alpha reserve is nonzero. +/// +/// So the scans would walk 128 entries while pricing none, which is the same class of mistake as +/// benchmarking against an empty chain. Staggering matters as well as seeding: the median builds a +/// `BTreeMap` keyed on price, and identical reserves collapse it to a single entry. pub(super) fn fill_subnets(owner: &T::AccountId, subnets: u16) { NetworkImmunityPeriod::::set(1); for netuid in 1..=subnets { + let offset = u64::from(netuid); let netuid = NetUid::from(netuid); + Subtensor::::init_new_network(netuid, 1); NetworkRegisteredAt::::insert(netuid, 0); SubnetOwner::::insert(netuid, owner.clone()); + SubnetMechanism::::insert(netuid, 1); + set_reserves::( + netuid, + TaoBalance::from(150_000_000_000_u64.saturating_add(offset.saturating_mul(1_000_000))), + AlphaBalance::from(100_000_000_000_u64), + ); + SubnetMovingPrice::::insert( + netuid, + I96F32::saturating_from_num(1_000_u64.saturating_add(offset)) + .saturating_div(I96F32::saturating_from_num(1_000)), + ); } } diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index a809553b7b..da88a346b9 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1002,8 +1002,9 @@ mod dispatches { /// User register a new subnetwork /// - /// Below `SubnetLimit` this creates a subnet; at the limit it prunes one and queues - /// instead. The two are mutually exclusive and neither dominates, so charge the worse. + /// Below `SubnetLimit` this creates a subnet; at the limit it either prunes one and queues, + /// or queues to wait on a cleanup already in flight. The first two are mutually exclusive + /// and neither dominates, so charge the worse; the third is dominated by the second. #[pallet::call_index(59)] #[pallet::weight(::WeightInfo::register_network() .max(::WeightInfo::register_network_pruning()))] @@ -1186,7 +1187,7 @@ mod dispatches { /// User register a new subnetwork /// - /// Same two outcomes as `register_network`, so the same worst case applies. + /// Same outcomes as `register_network`, so the same worst case applies. #[pallet::call_index(79)] #[pallet::weight(::WeightInfo::register_network_with_identity() .max(::WeightInfo::register_network_pruning()))] diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 5e74840483..6f7658973a 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -623,11 +623,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetTAO` (r:127 w:1) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Swap::SwapBalancer` (r:1 w:0) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -713,11 +713,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `3721` - // Estimated: `323986` - // Minimum execution time: 2_130_304_000 picoseconds. - Weight::from_parts(2_160_771_000, 323986) - .saturating_add(T::DbWeight::get().reads(293_u64)) + // Measured: `7696` + // Estimated: `327961` + // Minimum execution time: 3_401_152_000 picoseconds. + Weight::from_parts(3_505_837_000, 327961) + .saturating_add(T::DbWeight::get().reads(671_u64)) .saturating_add(T::DbWeight::get().writes(49_u64)) } /// Storage: `SubtensorModule::Owner` (r:1 w:0) @@ -742,7 +742,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMovingPrice` (r:1 w:0) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -762,7 +762,7 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:0) + /// Storage: `SubtensorModule::SubnetTAO` (r:128 w:0) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationLockId` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationLockId` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -770,13 +770,17 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) /// Storage: `Balances::Freezes` (r:1 w:0) /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:0) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) + /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `3943` - // Estimated: `326683` - // Minimum execution time: 2_210_744_000 picoseconds. - Weight::from_parts(2_283_058_000, 326683) - .saturating_add(T::DbWeight::get().reads(408_u64)) + // Measured: `10544` + // Estimated: `333284` + // Minimum execution time: 4_004_051_000 picoseconds. + Weight::from_parts(4_090_472_000, 333284) + .saturating_add(T::DbWeight::get().reads(916_u64)) .saturating_add(T::DbWeight::get().writes(10_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) @@ -2128,11 +2132,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetTAO` (r:127 w:1) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Swap::SwapBalancer` (r:1 w:0) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -2206,6 +2210,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::Uids` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ImmunityPeriod` (r:0 w:1) /// Proof: `SubtensorModule::ImmunityPeriod` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetIdentitiesV3` (r:0 w:1) + /// Proof: `SubtensorModule::SubnetIdentitiesV3` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetEmissionEnabled` (r:0 w:1) /// Proof: `SubtensorModule::SubnetEmissionEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationAllowed` (r:0 w:1) @@ -2220,12 +2226,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `3657` - // Estimated: `323922` - // Minimum execution time: 2_160_801_000 picoseconds. - Weight::from_parts(2_248_876_000, 323922) - .saturating_add(T::DbWeight::get().reads(292_u64)) - .saturating_add(T::DbWeight::get().writes(48_u64)) + // Measured: `7632` + // Estimated: `327897` + // Minimum execution time: 3_420_479_000 picoseconds. + Weight::from_parts(3_487_302_000, 327897) + .saturating_add(T::DbWeight::get().reads(670_u64)) + .saturating_add(T::DbWeight::get().writes(49_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -4300,11 +4306,11 @@ impl WeightInfo for () { /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetTAO` (r:127 w:1) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Swap::SwapBalancer` (r:1 w:0) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -4390,11 +4396,11 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `3721` - // Estimated: `323986` - // Minimum execution time: 2_130_304_000 picoseconds. - Weight::from_parts(2_160_771_000, 323986) - .saturating_add(RocksDbWeight::get().reads(293_u64)) + // Measured: `7696` + // Estimated: `327961` + // Minimum execution time: 3_401_152_000 picoseconds. + Weight::from_parts(3_505_837_000, 327961) + .saturating_add(RocksDbWeight::get().reads(671_u64)) .saturating_add(RocksDbWeight::get().writes(49_u64)) } /// Storage: `SubtensorModule::Owner` (r:1 w:0) @@ -4419,7 +4425,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMovingPrice` (r:1 w:0) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -4439,7 +4445,7 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalStake` (r:1 w:1) /// Proof: `SubtensorModule::TotalStake` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:0) + /// Storage: `SubtensorModule::SubnetTAO` (r:128 w:0) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationLockId` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationLockId` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -4447,13 +4453,17 @@ impl WeightInfo for () { /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) /// Storage: `Balances::Freezes` (r:1 w:0) /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:0) + /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) + /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `3943` - // Estimated: `326683` - // Minimum execution time: 2_210_744_000 picoseconds. - Weight::from_parts(2_283_058_000, 326683) - .saturating_add(RocksDbWeight::get().reads(408_u64)) + // Measured: `10544` + // Estimated: `333284` + // Minimum execution time: 4_004_051_000 picoseconds. + Weight::from_parts(4_090_472_000, 333284) + .saturating_add(RocksDbWeight::get().reads(916_u64)) .saturating_add(RocksDbWeight::get().writes(10_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) @@ -5805,11 +5815,11 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:127 w:1) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetAlphaIn` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetAlphaIn` (r:127 w:1) /// Proof: `SubtensorModule::SubnetAlphaIn` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetTAO` (r:1 w:1) + /// Storage: `SubtensorModule::SubnetTAO` (r:127 w:1) /// Proof: `SubtensorModule::SubnetTAO` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `Swap::SwapBalancer` (r:1 w:0) + /// Storage: `Swap::SwapBalancer` (r:127 w:0) /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) /// Storage: `SubtensorModule::TotalNetworks` (r:1 w:1) /// Proof: `SubtensorModule::TotalNetworks` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -5883,6 +5893,8 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::Uids` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::ImmunityPeriod` (r:0 w:1) /// Proof: `SubtensorModule::ImmunityPeriod` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetIdentitiesV3` (r:0 w:1) + /// Proof: `SubtensorModule::SubnetIdentitiesV3` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetEmissionEnabled` (r:0 w:1) /// Proof: `SubtensorModule::SubnetEmissionEnabled` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationAllowed` (r:0 w:1) @@ -5897,12 +5909,12 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `3657` - // Estimated: `323922` - // Minimum execution time: 2_160_801_000 picoseconds. - Weight::from_parts(2_248_876_000, 323922) - .saturating_add(RocksDbWeight::get().reads(292_u64)) - .saturating_add(RocksDbWeight::get().writes(48_u64)) + // Measured: `7632` + // Estimated: `327897` + // Minimum execution time: 3_420_479_000 picoseconds. + Weight::from_parts(3_487_302_000, 327897) + .saturating_add(RocksDbWeight::get().reads(670_u64)) + .saturating_add(RocksDbWeight::get().writes(49_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) From 8e1649975d58012daa5318933d9bc31715a1db1c Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Fri, 31 Jul 2026 01:51:55 +0200 Subject: [PATCH 3/8] runtime: bump spec_version for the corrected register_network weights The weight constants in pallets/subtensor live in the runtime wasm, so the corrected register_network measurement only reaches a chain through a runtime release. Mainnet is on 440. --- runtime/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 95eaca16ee..521906bb58 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -235,7 +235,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: 440, + spec_version: 441, impl_version: 1, apis: RUNTIME_API_VERSIONS, transaction_version: 1, From c676ab4c0951c8c98012323b26f41465e407bb76 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Thu, 30 Jul 2026 18:58:12 +0200 Subject: [PATCH 4/8] Give a subnet owner first refusal on its own slot At the subnet limit a registration evicts the lowest-priced subnet outright, so an owner can lose a slot to a buyer they would happily have outbid. This gives that owner a window to keep it by matching what the challenger locked, and nothing else. The offer is recorded when the window opens, so answering can never cost more than a buyer just signed a transaction to pay, and the owner never names a figure of their own. Reaching the limit now queues the registration and opens a window on the eviction candidate rather than dissolving it. If the owner answers within FIRST_REFUSAL_WINDOW the payment is recycled and the subnet takes NetworkImmunityPeriod of immunity. If the window lapses, the next registration prunes the candidate exactly as before. The window carries its challenger's lock id, so it expires with the registration that created it: a window whose challenger has already been served from some other slot cannot be answered, and the offer cannot be burned for immunity nobody was contesting. It also snapshots the immunity period it was opened against, so lowering that parameter mid-window cannot turn a paid answer into nothing. A registration arriving against a subnet that already has an open window is refused rather than queued behind the first, which holds a subnet to a single queued challenger. The queue is bounded overall by the eviction pool: an answered challenge makes that subnet immune, a lapsed one prunes it and the replacement is immune by registration age, and once nothing is evictable registration returns SubnetLimitReached instead of queueing. Both paths that settle a stale figure clamp the price accumulator upward. A queued registration and an answered challenge each carry a price quoted long before they settle, and neither may drag the accumulator back down to it: a refusal exercised in the meantime raised the price deliberately. Answering clears the window and leaves the queue alone. The challenger's registration asked for a slot, not for that particular subnet, so matching the offer ends the challenge rather than the registration, and they keep their place in line at the price they locked. What the owner buys is their own subnet's safety, not the right to cancel a stranger's transaction. That leaves a queued registrant with no way out, which cost little before: NetworkRegistrationQueue has two writers and unlock_network_registration_cost has one caller, the serve path, and every push was paid for by a teardown already in flight. A challenge appends with nothing being torn down. cancel_network_registration lets whoever submitted a queued registration withdraw it and take the lock back, so the challenger chooses in both branches and registrants who never challenged anyone get the same way out. Withdrawing retracts the challenge without evicting the subnet it was aimed at: a window whose challenger has gone reads as unchallenged, not lapsed. Adds exercise_first_refusal and cancel_network_registration, the SubnetRefusalWindow and NetworkImmuneUntil storage maps, and benchmarks covering the challenge, the answer and the withdrawal. Regenerates the Python SDK's generated layer for the new calls, storage maps and errors. Regenerating sweeps in TooManyRootClaimHotkeys, which the runtime has declared since #2985 but the committed catalog never carried, so it is classified here to keep codegen.check --names green. --- .../subtensor/src/benchmarks/benchmarks.rs | 92 +++ pallets/subtensor/src/benchmarks/helpers.rs | 19 + pallets/subtensor/src/coinbase/root.rs | 10 +- pallets/subtensor/src/lib.rs | 27 +- pallets/subtensor/src/macros/dispatches.rs | 73 +- pallets/subtensor/src/macros/errors.rs | 10 + pallets/subtensor/src/macros/events.rs | 36 + pallets/subtensor/src/subnets/dissolution.rs | 9 +- pallets/subtensor/src/subnets/subnet.rs | 246 +++++- pallets/subtensor/src/tests/networks.rs | 722 +++++++++++++++++- pallets/subtensor/src/weights.rs | 162 +++- runtime/src/proxy_filters/call_groups.rs | 15 + runtime/src/proxy_filters/mod.rs | 28 +- sdk/python/bittensor/_generated/calls.py | 18 +- sdk/python/bittensor/_generated/constants.py | 2 +- sdk/python/bittensor/_generated/errors.py | 6 +- .../bittensor/_generated/runtime_apis.py | 2 +- sdk/python/bittensor/_generated/storage.py | 4 +- .../bittensor/error_descriptions/subtensor.py | 24 + sdk/python/bittensor/error_map.py | 7 + sdk/python/codegen/check.py | 7 + 21 files changed, 1458 insertions(+), 61 deletions(-) diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index e2c9bb7e72..c01fcbd0b4 100644 --- a/pallets/subtensor/src/benchmarks/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks/benchmarks.rs @@ -10,6 +10,7 @@ use crate::Pallet as Subtensor; use crate::staking::lock::LockState; use crate::subnets::mechanism::GLOBAL_MAX_SUBNET_COUNT; +use crate::subnets::subnet::{NetworkRegistrationInfo, RefusalWindow}; use crate::*; use codec::{Compact, Encode}; use frame_benchmarking::v2::*; @@ -1941,6 +1942,97 @@ mod pallet_benchmarks { assert_eq!(TokenSymbol::::get(netuid), new_symbol); } + /// Answering a challenge has to prove the challenger is still queued, so the queue is filled + /// to its bound and the matching entry put last, which is the worst case for that scan. The + /// bound is one queued challenge per subnet that can have a window open at once. + #[benchmark] + fn exercise_first_refusal() { + let coldkey: T::AccountId = whitelisted_caller(); + let netuid = NetUid::from(1); + Subtensor::::init_new_network(netuid, 1); + SubnetOwner::::insert(netuid, coldkey.clone()); + NetworkImmunityPeriod::::set(1); + + frame_system::Pallet::::set_block_number(1u32.into()); + let offer = TaoBalance::from(1_000_000_u64); + add_balance_to_coldkey_account::(&coldkey, offer.saturating_mul(2.into())); + TotalIssuance::::mutate(|total| *total = total.saturating_add(offer)); + + let challenger_lock_id = u32::from(Subtensor::::get_max_subnets()); + NetworkRegistrationQueue::::set( + (0..=challenger_lock_id) + .map(|lock_id| NetworkRegistrationInfo:: { + coldkey: account("Challenger", 0, lock_id), + hotkey: account("ChallengerHotkey", 0, lock_id), + mechid: 1, + identity: Some(max_subnet_identity()), + lock_amount: offer, + median_subnet_alpha_price: U64F64::saturating_from_num(1), + registration_block: 1, + lock_id, + }) + .collect::>(), + ); + + SubnetRefusalWindow::::insert( + netuid, + RefusalWindow { + offer, + expires_at: u64::MAX, + challenger_lock_id, + immunity_period: 1, + }, + ); + + #[extrinsic_call] + _(RawOrigin::Signed(coldkey), netuid); + + assert!(!SubnetRefusalWindow::::contains_key(netuid)); + assert!(NetworkImmuneUntil::::get(netuid) > 1); + } + + /// Cancelling scans the queue for the caller's entry and writes back what is left, so the queue + /// is filled to its bound and the caller's entry put last, the worst case for both. + #[benchmark] + fn cancel_network_registration() { + let coldkey: T::AccountId = whitelisted_caller(); + let lock_amount = TaoBalance::from(1_000_000_u64); + add_balance_to_coldkey_account::(&coldkey, lock_amount.saturating_mul(2.into())); + + let lock_id = u32::from(Subtensor::::get_max_subnets()); + NetworkRegistrationQueue::::set( + (0..=lock_id) + .map(|id| NetworkRegistrationInfo:: { + coldkey: if id == lock_id { + coldkey.clone() + } else { + account("Queued", 0, id) + }, + hotkey: account("QueuedHotkey", 0, id), + mechid: 1, + identity: Some(max_subnet_identity()), + lock_amount, + median_subnet_alpha_price: U64F64::saturating_from_num(1), + registration_block: 1, + lock_id: id, + }) + .collect::>(), + ); + assert_ok!(Subtensor::::lock_network_registration_cost( + &coldkey, + lock_amount.into(), + lock_id + )); + + #[extrinsic_call] + _(RawOrigin::Signed(coldkey), lock_id); + + assert_eq!( + NetworkRegistrationQueue::::get().len(), + usize::from(Subtensor::::get_max_subnets()) + ); + } + #[benchmark] fn commit_timelocked_weights() { let hotkey: T::AccountId = whitelisted_caller(); diff --git a/pallets/subtensor/src/benchmarks/helpers.rs b/pallets/subtensor/src/benchmarks/helpers.rs index 98f52fa43f..b95f776650 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -15,6 +15,20 @@ pub(super) fn set_reserves( SubnetAlphaIn::::insert(netuid, alpha_in); } +/// A refusal window that closed at block zero, so the candidate is evictable at any later block. +/// +/// The at-limit benchmark uses this because clearing a lapsed window and then evicting is the +/// heaviest way step 5 can go. Opening a fresh window instead does strictly less work, and being +/// refused for a live one does less still. +pub(super) fn lapsed_refusal_window() -> RefusalWindow { + RefusalWindow { + offer: TaoBalance::from(0u64), + expires_at: 0, + challenger_lock_id: 0, + immunity_period: 0, + } +} + /// The largest identity `is_valid_subnet_identity` accepts: 6,656 bytes across the eight fields. /// /// Both registration outcomes carry the identity. Creation writes it to `SubnetIdentitiesV3`; @@ -80,7 +94,12 @@ pub(super) fn fill_subnets(owner: &T::AccountId, subnets: u16) { I96F32::saturating_from_num(1_000_u64.saturating_add(offset)) .saturating_div(I96F32::saturating_from_num(1_000)), ); + SubnetRefusalWindow::::insert(netuid, lapsed_refusal_window()); } + + let price = Subtensor::::get_network_lock_cost(); + add_balance_to_coldkey_account::(owner, price.saturating_mul(2.into())); + TotalIssuance::::mutate(|total| *total = total.saturating_add(price)); } /// One below `SubnetLimit`: the registration still creates a subnet, but pays the full count. diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 7aeb0430b2..4e611f07d1 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -296,6 +296,14 @@ impl Pallet { LastRateLimitedBlock::::remove(rate_limit_key); } + /// Whether a subnet is currently shielded from pruning, either by the immunity its + /// registration block grants or by immunity its owner has paid for. `registered_at` is + /// taken as an argument because every caller already holds it. + pub fn is_subnet_immune(netuid: NetUid, registered_at: u64, current_block: u64) -> bool { + current_block < registered_at.saturating_add(Self::get_network_immunity_period()) + || current_block < NetworkImmuneUntil::::get(netuid) + } + pub fn get_network_to_prune() -> Option { let current_block: u64 = Self::get_current_block_as_u64(); @@ -311,7 +319,7 @@ impl Pallet { let registered_at = NetworkRegisteredAt::::get(netuid); // Skip immune networks. - if current_block < registered_at.saturating_add(Self::get_network_immunity_period()) { + if Self::is_subnet_immune(netuid, registered_at, current_block) { continue; } diff --git a/pallets/subtensor/src/lib.rs b/pallets/subtensor/src/lib.rs index 3cf4318e9d..a3bf4558da 100644 --- a/pallets/subtensor/src/lib.rs +++ b/pallets/subtensor/src/lib.rs @@ -125,7 +125,7 @@ pub mod pallet { use crate::staking::lock::LockState; use crate::subnets::dissolution::DissolveCleanupStatus; use crate::subnets::leasing::{LeaseId, SubnetLeaseOf}; - use crate::subnets::subnet::NetworkRegistrationInfo; + use crate::subnets::subnet::{NetworkRegistrationInfo, RefusalWindow}; use crate::weights::WeightInfo; use crate::{ MAX_ASSOCIATED_UIDS_PER_EVM_ADDRESS, MAX_COLDKEY_COLLATERAL_HOTKEYS, RateLimitKey, @@ -2079,6 +2079,31 @@ pub mod pallet { pub type NetworkRegisteredAt = StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultNetworkRegisteredAt>; + /// MAP ( netuid ) --> block before which the subnet cannot be pruned + /// + /// Written when an owner exercises first refusal by matching a challenger's price. + /// Deliberately separate from [`NetworkRegisteredAt`], which the `start_call` delay, the + /// legacy lock-refund flag and the conviction-based ownership handover all read; none of + /// those should move just because a challenge was answered. Zero means the subnet has only + /// the immunity its registration block grants it. + #[pallet::storage] + pub type NetworkImmuneUntil = + StorageMap<_, Identity, NetUid, u64, ValueQuery, DefaultZeroU64>; + + /// MAP ( netuid ) --> the open right of first refusal on that subnet + /// + /// Present only while a registration is waiting on this subnet's owner. A registration that + /// would have taken the slot opens the window and queues instead of evicting, and the owner + /// answers with [`Pallet::exercise_first_refusal`] or loses the slot when it lapses. + /// + /// The price is the challenger's own lock, recorded when the window opens. The owner + /// therefore names no figure in advance and nothing about their intent is on chain until + /// they act, which is the point: a number stored here ahead of time, or inferred from the + /// owner's balance, is a reservation price that a challenger can read and charge them. + #[pallet::storage] + pub type SubnetRefusalWindow = + StorageMap<_, Identity, NetUid, RefusalWindow, OptionQuery>; + /// MAP ( netuid ) --> registered_subnet_counter /// /// Monotonic counter incremented on every successful `do_register_network` diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index da88a346b9..a461cc96e3 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1679,7 +1679,16 @@ mod dispatches { /// /// * `end_block`: The block at which the lease will end. If not defined, the lease is perpetual. #[pallet::call_index(110)] - #[pallet::weight(::WeightInfo::register_leased_network(T::MaxContributors::get()))] + // `do_register_network` may walk every subnet looking for a pruning candidate, and that + // scan is charged whether the leased registration then succeeds or fails. The lease + // benchmark cannot reach it, because a registration that prunes or opens a refusal window + // is queued rather than completed and the benchmark asserts the subnet exists afterwards, + // so take the heaviest measured path instead of leaving the scan uncharged. + #[pallet::weight( + ::WeightInfo::register_leased_network(T::MaxContributors::get()) + .max(::WeightInfo::register_network()) + .max(::WeightInfo::register_network_pruning()) + )] pub fn register_leased_network( origin: OriginFor, emissions_share: Percent, @@ -2516,5 +2525,67 @@ mod dispatches { ) -> DispatchResult { Self::do_set_min_collateral(origin, netuid, hotkey, min_locked) } + + /// Keeps this subnet by matching the price a challenger offered for its slot. + /// + /// Reaching the subnet limit no longer evicts the lowest-priced pruning candidate + /// outright. It records the arriving registration's lock against that subnet as an offer, + /// gives the owner `FIRST_REFUSAL_WINDOW` blocks to answer, and queues the registration + /// meanwhile. This call is the answer: it charges the recorded offer and restores a full + /// `NetworkImmunityPeriod`. Let the window lapse and the next registration to reach the + /// limit takes the slot. + /// + /// The owner never names a price, only matches one, so first refusal cannot cost more than + /// a challenger just signed a transaction to pay for the same slot. Answering moves + /// `NetworkLastLockCost` exactly as a completed registration does. + /// + /// # Arguments + /// * `origin`: Signed by the subnet's owner coldkey. + /// * `netuid`: The subnet to keep. + /// + /// # Errors + /// * `SubnetNotExists`: The subnet does not exist. + /// * `BadOrigin`: The signer does not own the subnet. + /// * `NoChallengeToAnswer`: No registration is waiting on this subnet, the window has + /// already closed, or the challenger who opened it has since left the queue. + /// * `InsufficientTaoBalance`: The owner cannot match the offer without dropping below the + /// existential deposit. + /// + /// # Events + /// Emits `SubnetFirstRefusalExercised`. + #[pallet::call_index(146)] + #[pallet::weight(::WeightInfo::exercise_first_refusal())] + pub fn exercise_first_refusal(origin: OriginFor, netuid: NetUid) -> DispatchResult { + Self::do_exercise_first_refusal(origin, netuid) + } + + /// Withdraws a queued network registration and returns the TAO it locked. + /// + /// A registration that arrives with no slot free is parked in `NetworkRegistrationQueue` + /// with its lock cost locked rather than spent, and is served when a slot reaches it. This + /// call is the way back out. It removes the entry and releases the lock, so a registrant + /// who no longer wants to wait is not holding funds against a slot indefinitely. + /// + /// `lock_id` identifies which registration, since one coldkey can hold several. It is the + /// `lock_id` field of the entry in `NetworkRegistrationQueue`. + /// + /// Withdrawing retracts any right of first refusal the entry had opened: the owner it was + /// challenging has nothing left to match, and their subnet is not evicted for having + /// declined an offer that withdrew itself. + /// + /// # Arguments + /// * `origin`: Signed by the coldkey that submitted the queued registration. + /// * `lock_id`: The lock held by the registration to withdraw. + /// + /// # Errors + /// * `NoQueuedRegistration`: This coldkey holds no queued registration under that lock id. + /// + /// # Events + /// Emits `NetworkRegistrationCancelled`. + #[pallet::call_index(147)] + #[pallet::weight(::WeightInfo::cancel_network_registration())] + pub fn cancel_network_registration(origin: OriginFor, lock_id: u32) -> DispatchResult { + Self::do_cancel_network_registration(origin, lock_id) + } } } diff --git a/pallets/subtensor/src/macros/errors.rs b/pallets/subtensor/src/macros/errors.rs index 9c29f3cf21..0cb74bb787 100644 --- a/pallets/subtensor/src/macros/errors.rs +++ b/pallets/subtensor/src/macros/errors.rs @@ -348,5 +348,15 @@ mod errors { ColdkeyCollateralPositionsFull, /// The coldkey has too many staking hotkeys for a single manual root claim. TooManyRootClaimHotkeys, + /// No registration is currently waiting on this subnet's owner, or the window in which + /// the owner could have answered one has already closed. + NoChallengeToAnswer, + /// A registration is already waiting on this subnet's owner to decide. At most one + /// challenge stands against a given subnet, so this registration is refused rather than + /// queued behind it. Retry once the window closes or the owner answers. + SubnetChallengeInProgress, + /// This coldkey holds no queued network registration under the given lock id. Either it + /// was already served or cancelled, or the lock belongs to somebody else. + NoQueuedRegistration, } } diff --git a/pallets/subtensor/src/macros/events.rs b/pallets/subtensor/src/macros/events.rs index 5391c2a027..242ba7fb0d 100644 --- a/pallets/subtensor/src/macros/events.rs +++ b/pallets/subtensor/src/macros/events.rs @@ -745,5 +745,41 @@ mod events { /// The new floor; zero clears it. min_locked: AlphaBalance, }, + + /// A subnet owner matched a challenger's price and kept the subnet out of the pruning + /// candidate set. + SubnetFirstRefusalExercised { + /// Subnet identifier. + netuid: NetUid, + /// The owner coldkey that paid. + owner: T::AccountId, + /// TAO recycled to match the challenger's lock. + paid: TaoBalance, + /// Block before which the subnet cannot be pruned. + immune_until: u64, + }, + + /// A queued network registration was withdrawn and its lock returned. + NetworkRegistrationCancelled { + /// The coldkey that registered, and that the lock is returned to. + coldkey: T::AccountId, + /// The hotkey the withdrawn registration named. + hotkey: T::AccountId, + /// The lock that is released. + lock_amount: TaoBalance, + /// Which lock, since one coldkey can hold several. + lock_id: u32, + }, + + /// A registration came for this subnet's slot, opening the owner's right of first + /// refusal. The owner keeps the subnet by matching `offer` before `expires_at`. + SubnetChallenged { + /// Subnet identifier. + netuid: NetUid, + /// The price the challenger locked, and the figure the owner must match. + offer: TaoBalance, + /// Last block on which the owner can still answer. + expires_at: u64, + }, } } diff --git a/pallets/subtensor/src/subnets/dissolution.rs b/pallets/subtensor/src/subnets/dissolution.rs index 760d24aaa5..a996e3cab0 100644 --- a/pallets/subtensor/src/subnets/dissolution.rs +++ b/pallets/subtensor/src/subnets/dissolution.rs @@ -262,9 +262,10 @@ impl Pallet { } pub fn remove_network_parameters(netuid: NetUid, weight_meter: &mut WeightMeter) -> bool { - // Flat write charge for the `::remove(netuid)` list below. Bump this when + // Flat charge for the `::remove(netuid)` list below: 90 unconditional removals, plus + // the conditional `SubnetIdentitiesV3` read and removal at the end. Bump this when // adding or removing entries from that list so the weight stays in step. - let removal_weight = T::DbWeight::get().writes(82); + let removal_weight = T::DbWeight::get().reads_writes(1, 91); if !weight_meter.can_consume(removal_weight) { return false; } @@ -272,6 +273,10 @@ impl Pallet { SubnetOwner::::remove(netuid); SubnetworkN::::remove(netuid); NetworkRegisteredAt::::remove(netuid); + // Netuids are reused, so neither paid immunity nor an authorization to buy more may + // outlive the subnet they were given for. + NetworkImmuneUntil::::remove(netuid); + SubnetRefusalWindow::::remove(netuid); Active::::remove(netuid); Emission::::remove(netuid); Consensus::::remove(netuid); diff --git a/pallets/subtensor/src/subnets/subnet.rs b/pallets/subtensor/src/subnets/subnet.rs index 0d2e8ef52b..849be86060 100644 --- a/pallets/subtensor/src/subnets/subnet.rs +++ b/pallets/subtensor/src/subnets/subnet.rs @@ -6,6 +6,33 @@ use sp_runtime::{SaturatedConversion, traits::AccountIdConversion}; use substrate_fixed::types::U64F64; use subtensor_runtime_common::{NetUid, TaoBalance}; +/// An open right of first refusal on one subnet's slot. +#[crate::freeze_struct("a8515705edf1d14b")] +#[derive(Encode, Decode, Default, TypeInfo, Clone, PartialEq, Eq, Debug)] +pub struct RefusalWindow { + /// What the challenger locked, which is exactly what the owner matches. + pub offer: TaoBalance, + /// Last block on which the owner can answer. + pub expires_at: u64, + /// The challenger's registration lock. The window is live only while that entry is still in + /// `NetworkRegistrationQueue`, which ties the right to a real challenger. + pub challenger_lock_id: u32, + /// The immunity being bought, snapshotted so that governance lowering + /// `NetworkImmunityPeriod` mid-window cannot turn a paid answer into nothing. + pub immunity_period: u64, +} + +/// Where a subnet stands with respect to being evicted for a new registration. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum RefusalState { + /// Nobody is currently challenging this subnet. The next registration opens a window. + Unchallenged, + /// A challenger is queued and the owner's window is still open. The slot is spoken for. + Live, + /// The owner had their window and let it close. The slot can be taken. + Lapsed, +} + /// Data structure for a pending network registration in the execution queue. #[crate::freeze_struct("c47fe93995c89025")] #[derive(Encode, Decode, Default, TypeInfo, Clone, PartialEq, Eq, Debug)] @@ -191,10 +218,18 @@ impl Pallet { .count() as u16; let cleanup_queue_len = DissolveCleanupQueue::::get().len(); - let registration_queue_len = NetworkRegistrationQueue::::get().len(); + let registration_queue = NetworkRegistrationQueue::::get(); + let registration_queue_len = registration_queue.len(); + + // Quoted before step 5, so a first refusal exercised there moves the price for the next + // registration and not for this one. This registrant pays what they could see when they + // signed, and the owner answering them matches that same figure. + let lock_amount = Self::get_network_lock_cost(); + log::debug!("network lock_amount: {lock_amount:?}"); let mut prune_netuid: Option = None; let mut wait_to_cleanup = false; + let mut challenge_netuid: Option = None; if current_count.saturating_add(cleanup_queue_len.saturated_into::()) >= subnet_limit { // no netuid available now, but enough netuids in the cleanup queue @@ -202,15 +237,36 @@ impl Pallet { if cleanup_queue_len > registration_queue_len { wait_to_cleanup = true; } else if let Some(netuid) = Self::get_network_to_prune() { - prune_netuid = Some(netuid); + match Self::refusal_state(netuid, current_block, ®istration_queue) { + // The window closed unanswered, so the eviction it held off proceeds. Cleared + // here and not left to the teardown: `do_dissolve_network` only queues the + // subnet, and the entry survives until `remove_network_parameters` runs some + // blocks later, by which point the netuid may have been reissued. + RefusalState::Lapsed => { + SubnetRefusalWindow::::remove(netuid); + prune_netuid = Some(netuid); + } + // A second challenger is refused, not queued behind the first, because + // `NetworkRegistrationQueue` is unbounded, decoded by every registration, and + // has no teardown in flight to drain it while a window stands. One subnet + // therefore holds at most one challenger, and since answering and lapsing both + // take a subnet out of the eviction pool, the queue stays bounded overall. + RefusalState::Live => { + return Err(Error::::SubnetChallengeInProgress.into()); + } + // A zero `NetworkImmunityPeriod` switches immunity off protocol-wide, so there + // is nothing to buy and a window would only park the registration for a day. + RefusalState::Unchallenged if Self::get_network_immunity_period() == 0 => { + prune_netuid = Some(netuid); + } + RefusalState::Unchallenged => challenge_netuid = Some(netuid), + } } else { return Err(Error::::SubnetLimitReached.into()); } } - // --- 6. Calculate and lock the required tokens. - let lock_amount = Self::get_network_lock_cost(); - log::debug!("network lock_amount: {lock_amount:?}"); + // --- 6. Check the registrant can cover the quoted price. ensure!( Self::can_remove_balance_from_coldkey_account(&coldkey, lock_amount.into()), Error::::CannotAffordLockCost @@ -222,13 +278,33 @@ impl Pallet { } // can't get a netuid to register, so queue the registration - if wait_to_cleanup || prune_netuid.is_some() { + if wait_to_cleanup || prune_netuid.is_some() || challenge_netuid.is_some() { let lock_id = NetworkRegistrationLockId::::get(); ensure!(lock_id != u32::MAX, Error::::LockIdOverFlow); Self::lock_network_registration_cost(&coldkey, lock_amount.into(), lock_id)?; NetworkRegistrationLockId::::set(lock_id.saturating_add(1)); + // Opened here rather than during the scan so the window can name the lock it belongs + // to. That link is what lets the owner's right expire with its challenger. + if let Some(netuid) = challenge_netuid { + let expires_at = current_block.saturating_add(Self::FIRST_REFUSAL_WINDOW); + SubnetRefusalWindow::::insert( + netuid, + RefusalWindow { + offer: lock_amount, + expires_at, + challenger_lock_id: lock_id, + immunity_period: Self::get_network_immunity_period(), + }, + ); + Self::deposit_event(Event::SubnetChallenged { + netuid, + offer: lock_amount, + expires_at, + }); + } + let median_subnet_alpha_price = Self::get_median_subnet_alpha_price(); let info = NetworkRegistrationInfo:: { coldkey: coldkey.clone(), @@ -267,6 +343,152 @@ impl Pallet { .map_err(|e| e.error) } + /// How long an owner has to answer a challenge before the slot is taken. At 12 second blocks + /// this is roughly a day: long enough not to require watching every block, short enough that a + /// registration is not parked behind an owner who has walked away. + pub const FIRST_REFUSAL_WINDOW: u64 = 7_200; + + /// Matches the price a challenger offered for this subnet's slot and keeps the subnet. + /// See `exercise_first_refusal` for the full contract. + pub fn do_exercise_first_refusal(origin: OriginFor, netuid: NetUid) -> DispatchResult { + ensure!(Self::if_subnet_exist(netuid), Error::::SubnetNotExists); + let coldkey = Self::ensure_subnet_owner(origin, netuid)?; + + let window = + SubnetRefusalWindow::::get(netuid).ok_or(Error::::NoChallengeToAnswer)?; + let current_block = Self::get_current_block_as_u64(); + ensure!( + current_block <= window.expires_at, + Error::::NoChallengeToAnswer + ); + + // If the challenger has left the queue, whether served from some other slot that came free + // or withdrawn, nothing is threatening this subnet, so the owner is stopped from burning + // the offer for immunity nobody was contesting. + ensure!( + Self::challenger_is_queued(window.challenger_lock_id), + Error::::NoChallengeToAnswer + ); + + let offer = window.offer; + + // Recycled, not added to the subnet's own reserve. A registration lock is retained in + // `SubnetTAO` and paid back out to alpha holders pro rata on dissolution, so routing this + // payment the same way would return it to an owner who is usually the largest of those + // holders. `recycle_tao` is the one path with no route back to the payer, and it checks the + // balance against the existential deposit. + Self::recycle_tao(&coldkey, offer.into())?; + + // Only the window is cleared. The challenger's registration was for a slot, not for this + // subnet, so matching the offer ends the challenge and leaves them queued for the next slot + // to come free, at the price they locked. `cancel_network_registration` is theirs to call + // if they would rather have the lock back than the place in line. + SubnetRefusalWindow::::remove(netuid); + // The immunity promised when the window opened, not whatever the parameter says now. + NetworkImmuneUntil::::insert( + netuid, + current_block.saturating_add(window.immunity_period), + ); + + // Answering is the same demand for a scarce slot as a registration that lands, so it moves + // the price the same way. Otherwise an incumbent could hold a slot at `NetworkMinLockCost` + // forever and a newcomer willing to pay more could not make that cost anything. + // + // Clamped upward for the same reason the queued path is: `offer` can be a full + // `FIRST_REFUSAL_WINDOW` old, and registrations landing elsewhere meanwhile may have moved + // the price up. The owner still pays only what they were shown; what is refused is letting + // a stale figure drag the accumulator back down. + Self::set_network_last_lock(offer.max(Self::get_network_last_lock())); + Self::set_network_last_lock_block(current_block); + + Self::deposit_event(Event::SubnetFirstRefusalExercised { + netuid, + owner: coldkey, + paid: offer, + immune_until: NetworkImmuneUntil::::get(netuid), + }); + + Ok(()) + } + + /// Withdraws a queued registration and returns its lock. + /// See `cancel_network_registration` for the full contract. + pub fn do_cancel_network_registration(origin: OriginFor, lock_id: u32) -> DispatchResult { + let coldkey = ensure_signed(origin)?; + + // Matched on the lock rather than the coldkey: one coldkey can hold several queued + // registrations, and each has its own lock to return. + let info = NetworkRegistrationQueue::::try_mutate(|queue| { + let index = queue + .iter() + .position(|entry| entry.lock_id == lock_id && entry.coldkey == coldkey) + .ok_or(Error::::NoQueuedRegistration)?; + Ok::<_, Error>(queue.remove(index)) + })?; + + Self::unlock_network_registration_cost(&coldkey, lock_id)?; + + // Any refusal window this entry was challenging is left alone. `refusal_state` reads the + // queue, so a window whose challenger has gone reports as unchallenged and is cleared by + // the next registration to look at it. Withdrawing an offer retracts the threat behind it; + // it does not evict the subnet the offer was aimed at. + Self::deposit_event(Event::NetworkRegistrationCancelled { + coldkey, + hotkey: info.hotkey, + lock_amount: info.lock_amount, + lock_id, + }); + + Ok(()) + } + + /// Whether a queued registration is still holding a given lock. + /// + /// The queue is passed in by callers that have already read it so the read is not paid twice. + fn challenger_is_queued_in( + queue: &[NetworkRegistrationInfo], + lock_id: u32, + ) -> bool { + queue.iter().any(|entry| entry.lock_id == lock_id) + } + + fn challenger_is_queued(lock_id: u32) -> bool { + Self::challenger_is_queued_in(&NetworkRegistrationQueue::::get(), lock_id) + } + + /// Where the candidate stands: nobody challenging it, a live challenge the owner may answer, + /// or a window the owner has already let close. + /// + /// A window whose challenger has left the queue is cleared and reported as `Unchallenged`, not + /// as `Lapsed`: the owner never had a real challenge to answer, so the next registration opens + /// a fresh window at its own price instead of evicting on the strength of one that dissolved. + /// The clearing is not redundant with the teardown, which only queues the subnet and leaves its + /// storage in place until `remove_network_parameters` runs some blocks later. + fn refusal_state( + netuid: NetUid, + current_block: u64, + queue: &[NetworkRegistrationInfo], + ) -> RefusalState { + let Some(window) = SubnetRefusalWindow::::get(netuid) else { + return RefusalState::Unchallenged; + }; + + // Tested before expiry, and the order matters. A challenger who has left ends the challenge + // outright, so there is nothing remaining for the deadline to have lapsed. Checking expiry + // first would evict a subnet whose challenger was served elsewhere, for declining an offer + // that withdrew itself. + if !Self::challenger_is_queued_in(queue, window.challenger_lock_id) { + SubnetRefusalWindow::::remove(netuid); + return RefusalState::Unchallenged; + } + + if current_block > window.expires_at { + return RefusalState::Lapsed; + } + + RefusalState::Live + } + pub fn set_new_network_state( coldkey: &T::AccountId, hotkey: &T::AccountId, @@ -331,7 +553,17 @@ impl Pallet { log::debug!("actual_tao_lock_amount: {actual_tao_lock_amount:?}"); // --- 3. Set the lock amount for use to determine pricing. - Self::set_network_last_lock(actual_tao_lock_amount); + // + // A queued registration settles at the price it locked, which may be far behind the market + // by the time a slot reaches it, and must not drag the accumulator back down to that: a + // refusal exercised while it waited raised the price deliberately. Registrations that + // create a subnet directly carry no lock id and are always current. + let last_lock = if lock_id.is_some() { + actual_tao_lock_amount.max(Self::get_network_last_lock()) + } else { + actual_tao_lock_amount + }; + Self::set_network_last_lock(last_lock); Self::set_network_last_lock_block(current_block); weight.saturating_accrue(db_weight.reads(1)); weight.saturating_accrue(db_weight.writes(2)); diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index a1af973eb6..a983480603 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -4,7 +4,9 @@ use super::mock::*; use crate::migrations::migrate_network_immunity_period; use crate::staking::lock::LockState; use crate::*; -use frame_support::{assert_err, assert_ok, weights::Weight}; +use frame_support::{ + assert_err, assert_noop, assert_ok, traits::Currency, weights::Weight, weights::WeightMeter, +}; use frame_system::Config; use sp_core::U256; use sp_runtime::PerU16; @@ -1593,6 +1595,708 @@ fn prune_tie_on_price_earlier_registration_wins() { }); } +/// Two mature subnets whose prices make `low` the eviction candidate. `low`'s owner is funded +/// for one match; tests that care about an unfunded owner drain them. +fn refusal_setup(seed: u64) -> (NetUid, NetUid, U256, U256) { + SubnetLimit::::put(2u16); + + let low_cold = U256::from(seed); + let low = add_dynamic_network(&U256::from(seed.saturating_add(1)), &low_cold); + let high = add_dynamic_network( + &U256::from(seed.saturating_add(3)), + &U256::from(seed.saturating_add(2)), + ); + + let imm = SubtensorModule::get_network_immunity_period(); + System::set_block_number(imm.saturating_add(10)); + + SubnetMovingPrice::::insert(low, I96F32::from_num(1)); + SubnetMovingPrice::::insert(high, I96F32::from_num(10)); + assert_eq!(SubtensorModule::get_network_to_prune(), Some(low)); + + let price = SubtensorModule::get_network_lock_cost(); + add_balance_to_coldkey_account(&low_cold, price.into()); + TotalIssuance::::mutate(|total| *total = total.saturating_add(price)); + + let registrant = U256::from(seed.saturating_add(4)); + let funding = price.saturating_mul(10.into()); + add_balance_to_coldkey_account(®istrant, funding.into()); + TotalIssuance::::mutate(|total| *total = total.saturating_add(funding)); + + (low, high, low_cold, registrant) +} + +fn register_from(registrant: U256, hotkey: u64) -> DispatchResult { + frame_support::storage::with_storage_layer(|| { + SubtensorModule::do_register_network( + RuntimeOrigin::signed(registrant), + &U256::from(hotkey), + 1, + None, + ) + }) +} + +/// The core of the mechanism: reaching the cap no longer evicts anybody. It records what the +/// challenger locked and gives the owner a window to answer it. +#[test] +fn a_challenge_opens_a_window_instead_of_evicting() { + new_test_ext(0).execute_with(|| { + let (low, high, _low_cold, registrant) = refusal_setup(4000); + let price = SubtensorModule::get_network_lock_cost(); + + assert_ok!(register_from(registrant, 4005)); + + // Nobody lost a slot. + assert!(NetworksAdded::::get(low)); + assert!(NetworksAdded::::get(high)); + assert!(DissolveCleanupQueue::::get().is_empty()); + + let window = SubnetRefusalWindow::::get(low).unwrap(); + let (offer, expires_at) = (window.offer, window.expires_at); + assert_eq!(offer, price); + assert_eq!( + expires_at, + System::block_number().saturating_add(SubtensorModule::FIRST_REFUSAL_WINDOW) + ); + + // The challenger is in line rather than turned away, at the price they were quoted. + let queue = NetworkRegistrationQueue::::get(); + assert_eq!(queue.len(), 1); + assert_eq!(queue[0].coldkey, registrant); + assert_eq!(queue[0].lock_amount, price); + }); +} + +/// The right of first refusal itself: the owner is asked for the challenger's own figure, never +/// one of their own naming, so answering cannot cost more than a real buyer just committed. +#[test] +fn the_owner_pays_no_more_than_the_challenger_committed() { + new_test_ext(0).execute_with(|| { + let (low, high, low_cold, registrant) = refusal_setup(4010); + + assert_ok!(register_from(registrant, 4015)); + let balance_before = SubtensorModule::get_coldkey_balance(&low_cold); + let issuance_before = TotalIssuance::::get(); + + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + // The challenger keeps their place in line; only the challenge is over. + let queue = NetworkRegistrationQueue::::get(); + let paid = balance_before.saturating_sub(SubtensorModule::get_coldkey_balance(&low_cold)); + assert_eq!(paid, queue[0].lock_amount.into()); + assert_eq!( + issuance_before.saturating_sub(TotalIssuance::::get()), + queue[0].lock_amount + ); + + assert!(NetworksAdded::::get(low)); + assert!(NetworksAdded::::get(high)); + assert!(!SubnetRefusalWindow::::contains_key(low)); + assert!(NetworkImmuneUntil::::get(low) > System::block_number()); + }); +} + +/// Why the decision is a signed call in a window rather than a standing authorization. An owner +/// holding nothing when the challenge lands can still answer it, so there is no balance for a +/// challenger to read and no moment at which their intent is on chain. Without this, somebody +/// with no interest in a slot could wait for the decaying price to pass under a visible balance +/// and register purely to make the owner burn it. +#[test] +fn an_owner_who_was_broke_when_challenged_can_still_answer() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4020); + + // Nothing to see, and nothing to extract. + Balances::make_free_balance_be(&low_cold, TaoBalance::from(0u64)); + assert_eq!( + SubtensorModule::get_coldkey_balance(&low_cold), + TaoBalance::from(0u64) + ); + + assert_ok!(register_from(registrant, 4025)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; + + // Funded only after seeing a real offer. The extra existential deposit is there because + // no account may spend itself out of existence, not because the price is any higher. + let funded = offer.saturating_add(ExistentialDeposit::get()); + add_balance_to_coldkey_account(&low_cold, funded.into()); + TotalIssuance::::mutate(|total| *total = total.saturating_add(funded)); + + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + assert!(NetworksAdded::::get(low)); + assert!(NetworkImmuneUntil::::get(low) > System::block_number()); + }); +} + +/// Declining is allowed and costs the owner nothing. The next registration takes the slot. +#[test] +fn an_unanswered_window_lapses_and_the_slot_is_taken() { + new_test_ext(0).execute_with(|| { + let (low, high, low_cold, registrant) = refusal_setup(4030); + + assert_ok!(register_from(registrant, 4035)); + let expires_at = SubnetRefusalWindow::::get(low).unwrap().expires_at; + let balance_before = SubtensorModule::get_coldkey_balance(&low_cold); + + System::set_block_number(expires_at.saturating_add(1)); + assert_ok!(register_from(registrant, 4036)); + + assert!(!SubnetRefusalWindow::::contains_key(low)); + assert!(!DissolveCleanupQueue::::get().is_empty()); + assert!(NetworksAdded::::get(high)); + // Letting it lapse is free. + assert_eq!( + SubtensorModule::get_coldkey_balance(&low_cold), + balance_before + ); + }); +} + +/// One challenge stands against a given subnet at a time. +/// +/// A second registration arriving while an owner is deciding is refused outright rather than +/// queued behind the first. Queueing it would grow `NetworkRegistrationQueue` with no teardown +/// coming to drain it, and every registration on the chain decodes that queue. This is what holds +/// a subnet to a single queued challenger, so it is a chain-availability rule and not a nicety. +/// What bounds the queue overall is a separate argument, covered by +/// `challenges_cannot_grow_the_queue_past_the_evictable_pool`. +/// +/// The owner also still answers the one figure they were shown, since the recorded offer is left +/// alone. +#[test] +fn a_second_challenge_inside_the_window_is_refused_rather_than_queued() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4040); + + assert_ok!(register_from(registrant, 4045)); + let first_window = SubnetRefusalWindow::::get(low).unwrap(); + + System::set_block_number(System::block_number().saturating_add(10)); + assert_noop!( + register_from(registrant, 4046), + Error::::SubnetChallengeInProgress + ); + + assert_eq!(SubnetRefusalWindow::::get(low).unwrap(), first_window); + assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + }); +} + +/// What actually bounds the queue. +/// +/// A challenge is the one path that appends to `NetworkRegistrationQueue` without dissolving +/// anything, so the "every entry is paid for by a teardown" argument that bounds the unchallenged +/// path does not cover it. The bound instead comes from the eviction pool shrinking: answering a +/// challenge makes that subnet immune, so it stops being a candidate. Once nothing is evictable +/// `get_network_to_prune` yields `None` and registrations are refused outright, which is where the +/// queue stops rather than growing for a transaction fee. +#[test] +fn challenges_cannot_grow_the_queue_past_the_evictable_pool() { + new_test_ext(0).execute_with(|| { + let (low, high, low_cold, registrant) = refusal_setup(4090); + + // `refusal_setup` funds `low`'s owner for one match; the other owner needs the same. + let high_cold = U256::from(4092u64); + let funding = SubtensorModule::get_network_lock_cost().saturating_mul(8.into()); + add_balance_to_coldkey_account(&high_cold, funding.into()); + TotalIssuance::::mutate(|total| *total = total.saturating_add(funding)); + + // The candidate is challenged and its owner answers, so it leaves the eviction pool. + assert_ok!(register_from(registrant, 4095)); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + + // Only the other subnet is left to challenge, and its owner answers too. + assert_ok!(register_from(registrant, 4096)); + assert!(SubnetRefusalWindow::::contains_key(high)); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(high_cold), + high + )); + assert_eq!(NetworkRegistrationQueue::::get().len(), 2); + + // The pool is empty, so the next registration is turned away instead of queued. + assert_eq!(SubtensorModule::get_network_to_prune(), None); + assert_noop!( + register_from(registrant, 4097), + Error::::SubnetLimitReached + ); + assert_eq!(NetworkRegistrationQueue::::get().len(), 2); + }); +} + +/// The other place a stale figure is settled, and the same rule as the queued path. +/// +/// The offer is quoted when the window opens and can be a full `FIRST_REFUSAL_WINDOW` old by the +/// time it is answered. Registrations landing on other subnets in the meantime move the price up. +/// Letting the owner's stale figure reset the accumulator down to it would hand every incumbent a +/// way to cancel an escalation they took no part in, for the price they were quoted a day earlier. +#[test] +fn answering_an_old_challenge_does_not_lower_the_lock_cost() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4100); + + assert_ok!(register_from(registrant, 4105)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; + + // Demand elsewhere moves the price up while this owner is still deciding. + let raised = offer.saturating_mul(3.into()); + SubtensorModule::set_network_last_lock(raised); + + let balance_before = SubtensorModule::get_coldkey_balance(&low_cold); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + // The owner is still charged only the figure they were shown. + let paid = balance_before.saturating_sub(SubtensorModule::get_coldkey_balance(&low_cold)); + assert_eq!(paid, offer.into()); + + // The accumulator holds where the market left it. + assert_eq!(SubtensorModule::get_network_last_lock(), raised); + }); +} + +/// A window is only worth answering while the challenger who opened it is still waiting. +/// +/// If that registration is served from some other slot that came free, nothing is threatening this +/// subnet any more. The owner must not be able to burn the offer for immunity nobody was +/// contesting, and the next registration to arrive should open a fresh window at its own price +/// rather than evict on the strength of a challenge that has already dissolved. +#[test] +fn a_window_whose_challenger_left_the_queue_cannot_be_answered() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4040); + + assert_ok!(register_from(registrant, 4045)); + assert!(SubnetRefusalWindow::::contains_key(low)); + + // The challenger's registration is served from somewhere else. + NetworkRegistrationQueue::::set(vec![]); + + assert_noop!( + SubtensorModule::exercise_first_refusal(RuntimeOrigin::signed(low_cold), low), + Error::::NoChallengeToAnswer + ); + }); +} + +/// The same rule, on the eviction side and after the deadline has also passed. +/// +/// A challenger leaving the queue ends the challenge, and it ends it whether or not the window has +/// since expired. If expiry were checked first, a subnet could be evicted on the strength of a +/// challenge that had already dissolved: the owner would be pruned for declining an offer that +/// withdrew itself, and the registration actually taking the slot would never have put its own +/// price to them. +#[test] +fn a_lapsed_window_whose_challenger_left_does_not_evict() { + new_test_ext(0).execute_with(|| { + let (low, high, _low_cold, registrant) = refusal_setup(4050); + + assert_ok!(register_from(registrant, 4055)); + let expires_at = SubnetRefusalWindow::::get(low).unwrap().expires_at; + + // Served from a slot that came free elsewhere, so nothing contests `low` any more. Only + // then does the deadline pass. + NetworkRegistrationQueue::::set(vec![]); + System::set_block_number(expires_at.saturating_add(1)); + + assert_ok!(register_from(registrant, 4056)); + + assert!(NetworksAdded::::get(low)); + assert!(NetworksAdded::::get(high)); + assert!(DissolveCleanupQueue::::get().is_empty()); + + let fresh = SubnetRefusalWindow::::get(low).expect("a fresh window at its own price"); + assert!(fresh.expires_at > expires_at); + }); +} + +/// Serving the cheap head of the queue must not undo a refusal that raised the price. +/// +/// A queued registration settles at the price it locked, which can be far behind the market by the +/// time a slot reaches it. If that figure were written straight back into `NetworkLastLockCost`, +/// the escalation refusals are supposed to produce would collapse the moment the oldest entry is +/// served, which would make answering a challenge worth much less than it looks. +#[test] +fn serving_an_old_queued_registration_does_not_lower_the_lock_cost() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4040); + + assert_ok!(register_from(registrant, 4045)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; + + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + assert_eq!(SubtensorModule::get_network_last_lock(), offer); + + // The queued challenger is now served from a slot that came free. Raising the limit stands + // in for the dissolution that would have freed one; what is under test is the price the + // serving writes back, not the slot accounting that got it there. + SubnetLimit::::put(3u16); + + // The market moves further up while the entry waits, so the figure it locked is now stale. + let market = offer.saturating_mul(4.into()); + SubtensorModule::set_network_last_lock(market); + + let queued = NetworkRegistrationQueue::::get(); + let entry = queued.first().expect("challenger is still queued").clone(); + assert!(entry.lock_amount < market); + + assert_ok!(SubtensorModule::set_new_network_state( + &entry.coldkey, + &entry.hotkey, + entry.mechid, + entry.identity.clone(), + entry.lock_amount, + entry.median_subnet_alpha_price, + Some(entry.lock_id), + )); + + assert_eq!(SubtensorModule::get_network_last_lock(), market); + }); +} + +/// Answering is the same demand for a scarce slot as a registration that lands, so it moves the +/// price ladder the same way. Otherwise an incumbent holds a slot at the floor forever. +#[test] +fn answering_doubles_the_next_registration_price() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4050); + + assert_ok!(register_from(registrant, 4055)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; + + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + assert_eq!(SubtensorModule::get_network_last_lock(), offer); + assert_eq!( + SubtensorModule::get_network_last_lock_block(), + System::block_number() + ); + assert_eq!( + SubtensorModule::get_network_lock_cost(), + offer.saturating_mul(2.into()) + ); + }); +} + +/// Recycled, not pooled. `SubnetTAO` pays out pro rata on dissolution and the owner is usually +/// the largest alpha holder, so pooling would hand the payment back to the payer. +#[test] +fn answering_recycles_the_price_instead_of_pooling_it() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4060); + + assert_ok!(register_from(registrant, 4065)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; + let issuance_before = TotalIssuance::::get(); + let subnet_tao_before = SubnetTAO::::get(low); + let locked_before = SubnetLocked::::get(low); + + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + assert_eq!( + issuance_before.saturating_sub(TotalIssuance::::get()), + offer + ); + assert_eq!(SubnetTAO::::get(low), subnet_tao_before); + assert_eq!(SubnetLocked::::get(low), locked_before); + }); +} + +/// Inside the period it paid for, the subnet is not a candidate at all, so the next registration +/// asks the next subnet rather than the same owner again. +#[test] +fn an_owner_is_not_challenged_again_inside_the_immunity_period() { + new_test_ext(0).execute_with(|| { + let (low, high, low_cold, registrant) = refusal_setup(4070); + + assert_ok!(register_from(registrant, 4075)); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + assert_eq!(SubtensorModule::get_network_to_prune(), Some(high)); + assert_ok!(register_from(registrant, 4076)); + assert!(!SubnetRefusalWindow::::contains_key(low)); + assert!(SubnetRefusalWindow::::contains_key(high)); + + // Back in line once the cover lapses. + System::set_block_number(NetworkImmuneUntil::::get(low).saturating_add(1)); + assert_eq!(SubtensorModule::get_network_to_prune(), Some(low)); + }); +} + +/// A zero `NetworkImmunityPeriod` switches immunity off protocol-wide, so there is nothing to +/// buy and no point parking a registration behind a window that buys nothing. +#[test] +fn a_zero_immunity_period_prunes_without_opening_a_window() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4080); + NetworkImmunityPeriod::::put(0u64); + + assert_ok!(register_from(registrant, 4085)); + + assert!(!SubnetRefusalWindow::::contains_key(low)); + assert!(!DissolveCleanupQueue::::get().is_empty()); + }); +} + +/// The owner gets the immunity they were promised when the window opened, not whatever the +/// parameter happens to say when they pay. +/// +/// Reading `NetworkImmunityPeriod` at exercise time instead would let governance lowering it +/// mid-window turn a full-price answer into nothing: the payment is burned, `immune_until` is set +/// to the current block, and `current_block < immune_until` is false immediately, so the owner +/// buys zero protection and can be challenged again in the same block. +#[test] +fn shortening_the_immunity_period_mid_window_does_not_shrink_what_was_bought() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4100); + let promised = SubtensorModule::get_network_immunity_period(); + assert!(promised > 0); + + assert_ok!(register_from(registrant, 4105)); + assert!(SubnetRefusalWindow::::contains_key(low)); + + // Governance switches immunity off while the owner is deciding. + NetworkImmunityPeriod::::put(0u64); + + let at = System::block_number(); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + + assert_eq!( + NetworkImmuneUntil::::get(low), + at.saturating_add(promised) + ); + }); +} + +/// Answering after the window shuts is refused, so a lapsed right cannot be revived by an owner +/// who notices late and races the next registration. +#[test] +fn answering_after_the_window_closes_fails() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4090); + + assert_ok!(register_from(registrant, 4095)); + let expires_at = SubnetRefusalWindow::::get(low).unwrap().expires_at; + System::set_block_number(expires_at.saturating_add(1)); + + assert_err!( + SubtensorModule::exercise_first_refusal(RuntimeOrigin::signed(low_cold), low), + Error::::NoChallengeToAnswer + ); + }); +} + +/// Nothing to answer means nothing to pay. Without this an owner could buy immunity whenever they +/// liked and move everybody else's price with it. +#[test] +fn answering_without_a_challenge_fails() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, _registrant) = refusal_setup(4100); + + assert_err!( + SubtensorModule::exercise_first_refusal(RuntimeOrigin::signed(low_cold), low), + Error::::NoChallengeToAnswer + ); + assert_eq!(NetworkImmuneUntil::::get(low), 0); + }); +} + +/// Answering for someone else's subnet would be a way to burn their TAO. +#[test] +fn only_the_owner_can_answer_a_challenge() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4110); + + assert_ok!(register_from(registrant, 4115)); + + assert_err!( + SubtensorModule::exercise_first_refusal(RuntimeOrigin::signed(U256::from(4199)), low), + DispatchError::BadOrigin + ); + assert!(SubnetRefusalWindow::::contains_key(low)); + }); +} + +#[test] +fn answering_requires_an_existing_subnet() { + new_test_ext(0).execute_with(|| { + assert_err!( + SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(U256::from(4120)), + NetUid::from(999) + ), + Error::::SubnetNotExists + ); + }); +} + +/// A netuid handed to somebody else must not carry the previous owner's cover or their open +/// window. +#[test] +fn a_reused_netuid_inherits_neither_immunity_nor_a_window() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4130); + + assert_ok!(register_from(registrant, 4135)); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + assert!(NetworkImmuneUntil::::get(low) > 0); + + let mut meter = WeightMeter::new(); + SubtensorModule::remove_network_parameters(low, &mut meter); + + assert_eq!(NetworkImmuneUntil::::get(low), 0); + assert!(!SubnetRefusalWindow::::contains_key(low)); + }); +} + +/// A registration that fails for any other reason must not leave a window behind, since dispatch +/// unwinds the whole call and the challenge it recorded no longer exists. +#[test] +fn a_failed_registration_leaves_no_window() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4140); + + // Broke registrant: step 6's affordability check fails after step 5 opened the window. + Balances::make_free_balance_be(®istrant, TaoBalance::from(0u64)); + + assert_err!( + register_from(registrant, 4145), + Error::::CannotAffordLockCost + ); + assert!(!SubnetRefusalWindow::::contains_key(low)); + }); +} + +/// The way out of the queue. A registration that has not been served is the registrant's to +/// withdraw, and withdrawing has to return the lock, not merely drop the entry. +#[test] +fn a_queued_registrant_can_withdraw_and_get_their_lock_back() { + new_test_ext(0).execute_with(|| { + let (_low, _high, _low_cold, registrant) = refusal_setup(4150); + + let lock_id = NetworkRegistrationLockId::::get(); + let mut identifier = [0u8; 8]; + identifier[..4].copy_from_slice(b"rglk"); + identifier[4..8].copy_from_slice(&lock_id.to_le_bytes()); + + assert_ok!(register_from(registrant, 4155)); + let free_before = pallet_balances::Pallet::::free_balance(registrant); + assert!( + pallet_balances::Locks::::get(registrant) + .iter() + .any(|lock| lock.id == identifier) + ); + + assert_ok!(SubtensorModule::cancel_network_registration( + RuntimeOrigin::signed(registrant), + lock_id + )); + + assert!(NetworkRegistrationQueue::::get().is_empty()); + assert!( + pallet_balances::Locks::::get(registrant) + .iter() + .all(|lock| lock.id != identifier) + ); + assert_eq!( + pallet_balances::Pallet::::free_balance(registrant), + free_before + ); + + // The lock is gone, so there is nothing left to release a second time. + assert_noop!( + SubtensorModule::cancel_network_registration( + RuntimeOrigin::signed(registrant), + lock_id + ), + Error::::NoQueuedRegistration + ); + }); +} + +/// Withdrawing an offer retracts the ultimatum behind it. +/// +/// The subnet it was aimed at is not evicted for declining an offer that is no longer on the table. +/// A later registration has to open its own window at its own price, which is what stops a +/// withdrawn challenge from being worth as much to the attacker as a real one. +#[test] +fn withdrawing_a_challenge_leaves_the_subnet_standing() { + new_test_ext(0).execute_with(|| { + let (low, high, _low_cold, registrant) = refusal_setup(4160); + + let lock_id = NetworkRegistrationLockId::::get(); + assert_ok!(register_from(registrant, 4165)); + let retracted = SubnetRefusalWindow::::get(low) + .expect("the challenge opened a window") + .expires_at; + + assert_ok!(SubtensorModule::cancel_network_registration( + RuntimeOrigin::signed(registrant), + lock_id + )); + + // Past the deadline the retracted offer carried, so a window that survived its challenger + // would read as lapsed here and evict. + System::set_block_number(retracted.saturating_add(1)); + assert_ok!(register_from(registrant, 4166)); + + assert!(NetworksAdded::::get(low)); + assert!(NetworksAdded::::get(high)); + assert!(DissolveCleanupQueue::::get().is_empty()); + let fresh = SubnetRefusalWindow::::get(low).expect("a fresh window at its own price"); + assert!(fresh.expires_at > retracted); + }); +} + +/// A lock is only the account holder's to release, so the queue is matched on both the lock and the +/// coldkey that owns it. +#[test] +fn only_the_registrant_can_withdraw_their_registration() { + new_test_ext(0).execute_with(|| { + let (_low, _high, low_cold, registrant) = refusal_setup(4170); + + let lock_id = NetworkRegistrationLockId::::get(); + assert_ok!(register_from(registrant, 4175)); + + assert_noop!( + SubtensorModule::cancel_network_registration(RuntimeOrigin::signed(low_cold), lock_id), + Error::::NoQueuedRegistration + ); + assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + }); +} + #[test] fn prune_selection_complex_state_exhaustive() { new_test_ext(0).execute_with(|| { @@ -3508,6 +4212,22 @@ fn register_network_prune_registers_registration_queued() { )); assert!(NetworkRegistrationQueue::::get().len() == 1); + + // The first registration to reach the cap opens the owner's window rather than pruning, + // so n1 is still here and the challenger waits. + let expires_at = SubnetRefusalWindow::::get(n1) + .expect("window should open") + .expires_at; + assert!(!DissolveCleanupQueue::::get().contains(&n1)); + assert!(NetworksAdded::::get(n1)); + + // Unanswered, so the next registration takes the slot. + System::set_block_number(expires_at.saturating_add(1)); + let cold2 = U256::from(9903); + add_balance_to_coldkey_account(&cold2, lock_amount.saturating_mul(10.into()).into()); + TotalIssuance::::mutate(|total| *total = total.saturating_add(lock_amount)); + assert_ok!(register_from(cold2, 9904)); + assert!(DissolveCleanupQueue::::get().contains(&n1)); assert!(!NetworksAdded::::get(n1)); }); diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 6f7658973a..1d736b1c8a 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -69,6 +69,8 @@ pub trait WeightInfo { fn transfer_stake_and_hotkey() -> Weight; fn add_collateral() -> Weight; fn set_min_collateral() -> Weight; + fn exercise_first_refusal() -> Weight; + fn cancel_network_registration() -> Weight; fn swap_stake() -> Weight; fn batch_commit_weights() -> Weight; fn batch_set_weights() -> Weight; @@ -713,10 +715,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `7696` - // Estimated: `327961` - // Minimum execution time: 3_401_152_000 picoseconds. - Weight::from_parts(3_505_837_000, 327961) + // Measured: `8028` + // Estimated: `328293` + // Minimum execution time: 3_510_666_000 picoseconds. + Weight::from_parts(3_749_630_000, 328293) .saturating_add(T::DbWeight::get().reads(671_u64)) .saturating_add(T::DbWeight::get().writes(49_u64)) } @@ -776,12 +778,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `10544` - // Estimated: `333284` - // Minimum execution time: 4_004_051_000 picoseconds. - Weight::from_parts(4_090_472_000, 333284) - .saturating_add(T::DbWeight::get().reads(916_u64)) - .saturating_add(T::DbWeight::get().writes(10_u64)) + // Measured: `11314` + // Estimated: `334054` + // Minimum execution time: 4_355_915_000 picoseconds. + Weight::from_parts(4_733_135_000, 334054) + .saturating_add(T::DbWeight::get().reads(1045_u64)) + .saturating_add(T::DbWeight::get().writes(11_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -1913,6 +1915,46 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(4_u64)) .saturating_add(T::DbWeight::get().writes(1_u64)) } + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:0 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:0 w:1) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:0 w:1) + /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn exercise_first_refusal() -> Weight { + // Proof Size summary in bytes: + // Measured: `875042` + // Estimated: `878507` + // Minimum execution time: 791_340_000 picoseconds. + Weight::from_parts(845_149_000, 878507) + .saturating_add(T::DbWeight::get().reads(6_u64)) + .saturating_add(T::DbWeight::get().writes(5_u64)) + } + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn cancel_network_registration() -> Weight { + // Proof Size summary in bytes: + // Measured: `874625` + // Estimated: `876110` + // Minimum execution time: 1_321_376_000 picoseconds. + Weight::from_parts(1_383_951_000, 876110) + .saturating_add(T::DbWeight::get().reads(3_u64)) + .saturating_add(T::DbWeight::get().writes(2_u64)) + } /// Storage: `SubtensorModule::NetworksAdded` (r:2 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubtokenEnabled` (r:2 w:0) @@ -2226,10 +2268,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `7632` - // Estimated: `327897` - // Minimum execution time: 3_420_479_000 picoseconds. - Weight::from_parts(3_487_302_000, 327897) + // Measured: `7965` + // Estimated: `328230` + // Minimum execution time: 3_544_830_000 picoseconds. + Weight::from_parts(3_858_021_000, 328230) .saturating_add(T::DbWeight::get().reads(670_u64)) .saturating_add(T::DbWeight::get().writes(49_u64)) } @@ -2646,6 +2688,8 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) @@ -2655,13 +2699,13 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `1835 + k * (44 ±0)` // Estimated: `10256 + k * (2579 ±0)` - // Minimum execution time: 259_000_000 picoseconds. - Weight::from_parts(171_942_062, 10256) - // Standard Error: 74_270 - .saturating_add(Weight::from_parts(29_141_808, 0).saturating_mul(k.into())) + // Minimum execution time: 837_114_000 picoseconds. + Weight::from_parts(603_172_819, 10256) + // Standard Error: 129_800 + .saturating_add(Weight::from_parts(85_519_456, 0).saturating_mul(k.into())) .saturating_add(T::DbWeight::get().reads(50_u64)) .saturating_add(T::DbWeight::get().reads((2_u64).saturating_mul(k.into()))) - .saturating_add(T::DbWeight::get().writes(53_u64)) + .saturating_add(T::DbWeight::get().writes(54_u64)) .saturating_add(T::DbWeight::get().writes((2_u64).saturating_mul(k.into()))) .saturating_add(Weight::from_parts(0, 2579).saturating_mul(k.into())) } @@ -4396,10 +4440,10 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `7696` - // Estimated: `327961` - // Minimum execution time: 3_401_152_000 picoseconds. - Weight::from_parts(3_505_837_000, 327961) + // Measured: `8028` + // Estimated: `328293` + // Minimum execution time: 3_510_666_000 picoseconds. + Weight::from_parts(3_749_630_000, 328293) .saturating_add(RocksDbWeight::get().reads(671_u64)) .saturating_add(RocksDbWeight::get().writes(49_u64)) } @@ -4459,12 +4503,12 @@ impl WeightInfo for () { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `10544` - // Estimated: `333284` - // Minimum execution time: 4_004_051_000 picoseconds. - Weight::from_parts(4_090_472_000, 333284) - .saturating_add(RocksDbWeight::get().reads(916_u64)) - .saturating_add(RocksDbWeight::get().writes(10_u64)) + // Measured: `11314` + // Estimated: `334054` + // Minimum execution time: 4_355_915_000 picoseconds. + Weight::from_parts(4_733_135_000, 334054) + .saturating_add(RocksDbWeight::get().reads(1045_u64)) + .saturating_add(RocksDbWeight::get().writes(11_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -5596,6 +5640,46 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(4_u64)) .saturating_add(RocksDbWeight::get().writes(1_u64)) } + /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) + /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetOwner` (r:1 w:0) + /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) + /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:0 w:1) + /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:0 w:1) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:0 w:1) + /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + fn exercise_first_refusal() -> Weight { + // Proof Size summary in bytes: + // Measured: `875042` + // Estimated: `878507` + // Minimum execution time: 791_340_000 picoseconds. + Weight::from_parts(845_149_000, 878507) + .saturating_add(RocksDbWeight::get().reads(6_u64)) + .saturating_add(RocksDbWeight::get().writes(5_u64)) + } + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + fn cancel_network_registration() -> Weight { + // Proof Size summary in bytes: + // Measured: `874625` + // Estimated: `876110` + // Minimum execution time: 1_321_376_000 picoseconds. + Weight::from_parts(1_383_951_000, 876110) + .saturating_add(RocksDbWeight::get().reads(3_u64)) + .saturating_add(RocksDbWeight::get().writes(2_u64)) + } /// Storage: `SubtensorModule::NetworksAdded` (r:2 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubtokenEnabled` (r:2 w:0) @@ -5909,10 +5993,10 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network_with_identity() -> Weight { // Proof Size summary in bytes: - // Measured: `7632` - // Estimated: `327897` - // Minimum execution time: 3_420_479_000 picoseconds. - Weight::from_parts(3_487_302_000, 327897) + // Measured: `7965` + // Estimated: `328230` + // Minimum execution time: 3_544_830_000 picoseconds. + Weight::from_parts(3_858_021_000, 328230) .saturating_add(RocksDbWeight::get().reads(670_u64)) .saturating_add(RocksDbWeight::get().writes(49_u64)) } @@ -6329,6 +6413,8 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkRegistrationAllowed` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::Yuma3On` (r:0 w:1) /// Proof: `SubtensorModule::Yuma3On` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::HotkeySuccessor` (r:0 w:1) + /// Proof: `SubtensorModule::HotkeySuccessor` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::IsNetworkMember` (r:0 w:1) /// Proof: `SubtensorModule::IsNetworkMember` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::MaxAllowedUids` (r:0 w:1) @@ -6338,13 +6424,13 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `1835 + k * (44 ±0)` // Estimated: `10256 + k * (2579 ±0)` - // Minimum execution time: 259_000_000 picoseconds. - Weight::from_parts(171_942_062, 10256) - // Standard Error: 74_270 - .saturating_add(Weight::from_parts(29_141_808, 0).saturating_mul(k.into())) + // Minimum execution time: 837_114_000 picoseconds. + Weight::from_parts(603_172_819, 10256) + // Standard Error: 129_800 + .saturating_add(Weight::from_parts(85_519_456, 0).saturating_mul(k.into())) .saturating_add(RocksDbWeight::get().reads(50_u64)) .saturating_add(RocksDbWeight::get().reads((2_u64).saturating_mul(k.into()))) - .saturating_add(RocksDbWeight::get().writes(53_u64)) + .saturating_add(RocksDbWeight::get().writes(54_u64)) .saturating_add(RocksDbWeight::get().writes((2_u64).saturating_mul(k.into()))) .saturating_add(Weight::from_parts(0, 2579).saturating_mul(k.into())) } diff --git a/runtime/src/proxy_filters/call_groups.rs b/runtime/src/proxy_filters/call_groups.rs index 213b9be332..409942cf7f 100644 --- a/runtime/src/proxy_filters/call_groups.rs +++ b/runtime/src/proxy_filters/call_groups.rs @@ -435,6 +435,19 @@ call_filter_group!( [RuntimeCall::SubtensorModule(SubtensorCall::start_call),] ); +// Buying a subnet out of the pruning queue, and authorizing that purchase to happen +// automatically. Both spend the owner's TAO through `recycle_tao`, which destroys it, so they +// belong on the same side of the line as `burned_register` rather than with the rest of the +// subnet-owner calls: `NonFungible` must not reach them because it promises to move no value, +// and `NonCritical` must not because it cannot dissolve a subnet either, so it has no business +// spending to keep one alive. +call_filter_group!( + SubnetImmunityCalls, + [RuntimeCall::SubtensorModule( + SubtensorCall::exercise_first_refusal + ),] +); + // Residual pallet-subtensor calls that no proxy needs to grant on their own: // weights, serving, delegate-take, alpha lock/burn/preferences, network // registration, childkey admin, account association, tempo control, voting @@ -463,6 +476,7 @@ call_filter_group!( RuntimeCall::SubtensorModule(SubtensorCall::burn_alpha), RuntimeCall::SubtensorModule(SubtensorCall::register_network), RuntimeCall::SubtensorModule(SubtensorCall::register_network_with_identity), + RuntimeCall::SubtensorModule(SubtensorCall::cancel_network_registration), RuntimeCall::SubtensorModule(SubtensorCall::register_leased_network), RuntimeCall::SubtensorModule(SubtensorCall::decrease_take), RuntimeCall::SubtensorModule(SubtensorCall::increase_take), @@ -690,6 +704,7 @@ type SubtensorSplitCalls = ( RootClaimTypeCalls, SubnetIdentityCalls, SubnetActivationCalls, + SubnetImmunityCalls, SubtensorCommonCalls, ); diff --git a/runtime/src/proxy_filters/mod.rs b/runtime/src/proxy_filters/mod.rs index d0a356770d..e9026b7c69 100644 --- a/runtime/src/proxy_filters/mod.rs +++ b/runtime/src/proxy_filters/mod.rs @@ -66,6 +66,7 @@ type NonTransferAllowed = ( RootClaimTypeCalls, SubnetIdentityCalls, SubnetActivationCalls, + SubnetImmunityCalls, SubtensorCommonCalls, ); @@ -322,7 +323,8 @@ mod tests { | &(&group_calls::() | &group_calls::())) | &(&(&group_calls::() | &group_calls::()) - | &(&group_calls::() | &group_calls::())); + | &(&(&group_calls::() | &group_calls::()) + | &group_calls::())); assert_eq!( allowed_calls(ProxyType::NonFungible), &all_runtime_calls() - &denied @@ -333,7 +335,7 @@ mod tests { fn non_critical_is_everything_but_sudo_and_critical_ops() { let denied = &(&(&group_calls::() | &group_calls::()) | &(&group_calls::() | &group_calls::())) - | &group_calls::(); + | &(&group_calls::() | &group_calls::()); assert_eq!( allowed_calls(ProxyType::NonCritical), &all_runtime_calls() - &denied @@ -540,6 +542,28 @@ mod tests { ); } + // Answering a challenge spends the owner's TAO through `recycle_tao`, which destroys it. A + // proxy that promises not to move value must not reach it, and neither must one that cannot + // dissolve the subnet in the first place. Asserted directly rather than left to the three + // superset identities above, because those compare whole sets: a call landing in the wrong + // group there is indistinguishable from any other inventory gap. + #[test] + fn subnet_first_refusal_stays_out_of_non_spending_proxies() { + let call = "SubtensorModule::exercise_first_refusal"; + for proxy_type in [ + ProxyType::NonFungible, + ProxyType::NonCritical, + ProxyType::Owner, + ProxyType::SubnetLeaseBeneficiary, + ] { + assert!( + !allowed_calls(proxy_type).contains(call), + "{proxy_type:?} must not be able to spend the owner's TAO via {call}" + ); + } + assert!(allowed_calls(ProxyType::NonTransfer).contains(call)); + } + // The SmallTransfer / SudoUncheckedSetCode metadata must carry their // amount / nested-call constraints. #[test] diff --git a/sdk/python/bittensor/_generated/calls.py b/sdk/python/bittensor/_generated/calls.py index 7a70fa3db2..1dcd67d063 100644 --- a/sdk/python/bittensor/_generated/calls.py +++ b/sdk/python/bittensor/_generated/calls.py @@ -1,7 +1,7 @@ """Generated from runtime metadata by codegen. DO NOT EDIT BY HAND. Regenerate with: python -m codegen -Spec version: 440 +Spec version: 441 """ from typing import Any, NamedTuple @@ -255,9 +255,14 @@ def burned_register(netuid: 'NetUid', hotkey: 'AccountId32') -> Call: 'User register a new subnetwork via burning token' return Call('SubtensorModule', 'burned_register', {'netuid': netuid, 'hotkey': hotkey}) + @staticmethod + def cancel_network_registration(lock_id: 'u32') -> Call: + 'Withdraws a queued network registration and returns the TAO it locked. A registration that arrives with no slot free is parked in `NetworkRegistrationQueue` with its lock cost locked rather than spent, and is served when a slot reaches it. This call is the way back out. It removes the entry and releases the lock, so a registrant who no longer wants to wait is not holding funds against a slot indefinitely. `lock_id` identifies which registration, since one coldkey can hold several. It is the `lock_id` field of the entry in `NetworkRegistrationQueue`. Withdrawing retracts any right of first refusal the entry had opened: the owner it was challenging has nothing left to match, and their subnet is not evicted for having declined an offer that withdrew itself. # Arguments * `origin`: Signed by the coldkey that submitted the queued registration. * `lock_id`: The lock held by the registration to withdraw. # Errors * `NoQueuedRegistration`: This coldkey holds no queued registration under that lock id. # Events Emits `NetworkRegistrationCancelled`.' + return Call('SubtensorModule', 'cancel_network_registration', {'lock_id': lock_id}) + @staticmethod def claim_root(subnets: 'BTreeSet') -> Call: - "Claims the root emissions for a coldkey. # Arguments * `origin`: The signature of the caller's coldkey. # Events * `RootClaimed`: On the successfully claiming the root emissions for a coldkey. # Errors * `InvalidSubnetNumber`: The subnet set is empty or exceeds the maximum number of claims." + "Claims the root emissions for a coldkey. # Arguments * `origin`: The signature of the caller's coldkey. # Events * `RootClaimed`: On the successfully claiming the root emissions for a coldkey. # Errors * `InvalidSubnetNumber`: The subnet set is empty or exceeds the maximum number of claims. * `TooManyRootClaimHotkeys`: The coldkey's hotkey fanout exceeds one claim's bound." return Call('SubtensorModule', 'claim_root', {'subnets': subnets}) @staticmethod @@ -315,6 +320,11 @@ def enable_voting_power_tracking(netuid: 'NetUid') -> Call: '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.' return Call('SubtensorModule', 'enable_voting_power_tracking', {'netuid': netuid}) + @staticmethod + def exercise_first_refusal(netuid: 'NetUid') -> Call: + "Keeps this subnet by matching the price a challenger offered for its slot. Reaching the subnet limit no longer evicts the lowest-priced pruning candidate outright. It records the arriving registration's lock against that subnet as an offer, gives the owner `FIRST_REFUSAL_WINDOW` blocks to answer, and queues the registration meanwhile. This call is the answer: it charges the recorded offer and restores a full `NetworkImmunityPeriod`. Let the window lapse and the next registration to reach the limit takes the slot. The owner never names a price, only matches one, so first refusal cannot cost more than a challenger just signed a transaction to pay for the same slot. Answering moves `NetworkLastLockCost` exactly as a completed registration does. # Arguments * `origin`: Signed by the subnet's owner coldkey. * `netuid`: The subnet to keep. # Errors * `SubnetNotExists`: The subnet does not exist. * `BadOrigin`: The signer does not own the subnet. * `NoChallengeToAnswer`: No registration is waiting on this subnet, the window has already closed, or the challenger who opened it has since left the queue. * `InsufficientTaoBalance`: The owner cannot match the offer without dropping below the existential deposit. # Events Emits `SubnetFirstRefusalExercised`." + return Call('SubtensorModule', 'exercise_first_refusal', {'netuid': netuid}) + @staticmethod def increase_take(hotkey: 'AccountId32', take: 'PerU16') -> Call: "Allows delegates to increase its take value. This call is rate-limited. # Arguments * `origin`: The signature of the caller's coldkey. * `hotkey`: The hotkey we are delegating (must be owned by the coldkey). * `take`: The new stake proportion that this hotkey takes from delegations. The new value can be between 0 and 11_796 parts and should be strictly greater than the previous value. T is the new value (rational number), the the parameter is calculated as [65535 * T]. For example, 1% would be [0.01 * 65535] = [655.35] = 655 # Events * `TakeIncreased`: On successfully setting a increased take for this hotkey. # Errors * `NotRegistered`: The hotkey we are delegating is not registered on the network. * `NonAssociatedColdKey`: The hotkey we are delegating is not owned by the calling coldkey. * `DelegateTakeTooHigh`: The delegate is setting a take which is not greater than the previous." @@ -357,12 +367,12 @@ def register_limit(netuid: 'NetUid', hotkey: 'AccountId32', limit_price: 'u64') @staticmethod def register_network(hotkey: 'AccountId32') -> Call: - 'User register a new subnetwork' + 'User register a new subnetwork Below `SubnetLimit` this creates a subnet; at the limit it either prunes one and queues, or queues to wait on a cleanup already in flight. The first two are mutually exclusive and neither dominates, so charge the worse; the third is dominated by the second.' return Call('SubtensorModule', 'register_network', {'hotkey': hotkey}) @staticmethod def register_network_with_identity(hotkey: 'AccountId32', identity: 'Any') -> Call: - 'User register a new subnetwork' + 'User register a new subnetwork Same outcomes as `register_network`, so the same worst case applies.' return Call('SubtensorModule', 'register_network_with_identity', {'hotkey': hotkey, 'identity': identity}) @staticmethod diff --git a/sdk/python/bittensor/_generated/constants.py b/sdk/python/bittensor/_generated/constants.py index 6b90f809d8..2676148cfa 100644 --- a/sdk/python/bittensor/_generated/constants.py +++ b/sdk/python/bittensor/_generated/constants.py @@ -1,7 +1,7 @@ """Generated from runtime metadata by codegen. DO NOT EDIT BY HAND. Regenerate with: python -m codegen -Spec version: 440 +Spec version: 441 Pallet constant descriptors: unpack into substrate.constant. """ diff --git a/sdk/python/bittensor/_generated/errors.py b/sdk/python/bittensor/_generated/errors.py index fabd28868f..4f2eb7e3f5 100644 --- a/sdk/python/bittensor/_generated/errors.py +++ b/sdk/python/bittensor/_generated/errors.py @@ -1,7 +1,7 @@ """Generated from runtime metadata by codegen. DO NOT EDIT BY HAND. Regenerate with: python -m codegen -Spec version: 440 +Spec version: 441 """ from dataclasses import dataclass @@ -198,6 +198,10 @@ class ErrorInfo: (7, 154): ErrorInfo('SubtensorModule', 'InsufficientAlphaBalance', 'The caller does not have enough Alpha stake for the operation.'), (7, 155): ErrorInfo('SubtensorModule', 'ColdkeyCollateralIncomplete', "Coldkey swap could not fully migrate miner collateral: the old coldkey's [`ColdkeyMinerCollateral`] aggregate remained non-zero after migrating every indexed collateral hotkey. Failing closed avoids under-locking the destination unstake guard."), (7, 156): ErrorInfo('SubtensorModule', 'ColdkeyCollateralPositionsFull', 'This coldkey already has the maximum number of distinct hotkeys with miner collateral on the subnet ([`crate::MAX_COLDKEY_COLLATERAL_HOTKEYS`]).'), + (7, 157): ErrorInfo('SubtensorModule', 'TooManyRootClaimHotkeys', 'The coldkey has too many staking hotkeys for a single manual root claim.'), + (7, 158): ErrorInfo('SubtensorModule', 'NoChallengeToAnswer', "No registration is currently waiting on this subnet's owner, or the window in which the owner could have answered one has already closed."), + (7, 159): ErrorInfo('SubtensorModule', 'SubnetChallengeInProgress', "A registration is already waiting on this subnet's owner to decide. At most one challenge stands against a given subnet, so this registration is refused rather than queued behind it. Retry once the window closes or the owner answers."), + (7, 160): ErrorInfo('SubtensorModule', 'NoQueuedRegistration', 'This coldkey holds no queued network registration under the given lock id. Either it was already served or cancelled, or the lock belongs to somebody else.'), (11, 0): ErrorInfo('Utility', 'TooManyCalls', 'Too many calls batched.'), (11, 1): ErrorInfo('Utility', 'InvalidDerivedAccount', 'Bad input data for derived account ID'), (12, 0): ErrorInfo('Sudo', 'RequireSudo', 'Sender must be the Sudo account.'), diff --git a/sdk/python/bittensor/_generated/runtime_apis.py b/sdk/python/bittensor/_generated/runtime_apis.py index c132a56001..f3c1c5acbe 100644 --- a/sdk/python/bittensor/_generated/runtime_apis.py +++ b/sdk/python/bittensor/_generated/runtime_apis.py @@ -1,7 +1,7 @@ """Generated from runtime metadata by codegen. DO NOT EDIT BY HAND. Regenerate with: python -m codegen -Spec version: 440 +Spec version: 441 Runtime API method descriptors: unpack into substrate.runtime_call. """ diff --git a/sdk/python/bittensor/_generated/storage.py b/sdk/python/bittensor/_generated/storage.py index 5b7807db67..a0467f65ae 100644 --- a/sdk/python/bittensor/_generated/storage.py +++ b/sdk/python/bittensor/_generated/storage.py @@ -1,7 +1,7 @@ """Generated from runtime metadata by codegen. DO NOT EDIT BY HAND. Regenerate with: python -m codegen -Spec version: 440 +Spec version: 441 Storage item descriptors: unpack into substrate.query/query_map. Each carries its VALUE's type identity (value_type_ident) so normalization can key on the runtime's own type names without a node round-trip. """ @@ -189,6 +189,8 @@ class SubtensorModule: NetworkRegistrationAllowed = Item('SubtensorModule', 'NetworkRegistrationAllowed', 'bool') NetworkPowRegistrationAllowed = Item('SubtensorModule', 'NetworkPowRegistrationAllowed', 'bool') NetworkRegisteredAt = Item('SubtensorModule', 'NetworkRegisteredAt', 'u64') + NetworkImmuneUntil = Item('SubtensorModule', 'NetworkImmuneUntil', 'u64') + SubnetRefusalWindow = Item('SubtensorModule', 'SubnetRefusalWindow', 'RefusalWindow') RegisteredSubnetCounter = Item('SubtensorModule', 'RegisteredSubnetCounter', 'u64') PendingServerEmission = Item('SubtensorModule', 'PendingServerEmission', 'AlphaBalance') PendingValidatorEmission = Item('SubtensorModule', 'PendingValidatorEmission', 'AlphaBalance') diff --git a/sdk/python/bittensor/error_descriptions/subtensor.py b/sdk/python/bittensor/error_descriptions/subtensor.py index bf8dc5a342..79306fcfcb 100644 --- a/sdk/python/bittensor/error_descriptions/subtensor.py +++ b/sdk/python/bittensor/error_descriptions/subtensor.py @@ -516,6 +516,13 @@ "root-claimed history, so root accounting cannot merge safely. Check `RootClaimable` " "and root-subnet stake for the new hotkey; claim or clear them, or use a fresh hotkey." ), + "NoChallengeToAnswer": ( + "`exercise_first_refusal` was called but no registration is waiting on that subnet's " + "owner. Either no challenger has ever reached it, the window in which the owner could " + "have answered has already closed, or the challenger who opened the window has since " + "left the registration queue. Read `SubnetRefusalWindow` for the netuid to see whether " + "a window is open and when it expires." + ), "NoExistingLock": ( "move_lock was called but no conviction lock exists for the signing coldkey on that " "subnet. Check the lock storage for the coldkey and netuid, and create a lock before " @@ -526,6 +533,12 @@ "is full and every neuron is immune from pruning. Check `SubnetworkN` versus " "`MaxAllowedUids` for the netuid and retry after immunity periods expire." ), + "NoQueuedRegistration": ( + "`cancel_network_registration` was called with a lock id the signing coldkey does not " + "hold a queued registration under. Either the registration was already served or " + "cancelled, or the lock belongs to a different coldkey. Read `NetworkRegistrationQueue` " + "and cancel using the `lock_id` of an entry whose `coldkey` is the signer." + ), "NoWeightsCommitFound": ( "A weights reveal was submitted but no pending (non-expired) commit exists for the " "hotkey and netuid, possibly because it already expired. Query the `WeightCommits` " @@ -686,6 +699,12 @@ "e.g. via `add_stake_burn`) was repeated within its rate-limit window. Wait for the " "window to pass before retrying the buyback." ), + "SubnetChallengeInProgress": ( + "`register_network` reached a subnet that already has a registration waiting on its " + "owner. At most one challenge stands against a given subnet, so this registration is " + "refused rather than queued behind the one already there. Retry once the window in " + "`SubnetRefusalWindow` expires or the owner answers it." + ), "SubnetLimitReached": ( "`register_network` failed because the subnet count is at the network limit and no " "existing subnet is eligible to be pruned. Check the number of registered subnets " @@ -730,6 +749,11 @@ "`TargetRegistrationsPerInterval` for the subnet. Compare `RegistrationsThisInterval` " "against that hyperparameter and wait for the next interval to start." ), + "TooManyRootClaimHotkeys": ( + "`claim_root` was called by a coldkey staking to more hotkeys than one claim is allowed " + "to fan out over. Claim over a smaller set of subnets so fewer hotkeys are touched per " + "call, and repeat the call for the rest." + ), "TooManyUIDsPerMechanism": ( "Setting max UIDs or mechanism count would make max_uids times mechanism_count exceed " "the chain default of 256 UIDs per subnet. Check `MaxAllowedUids` and the subnet's " diff --git a/sdk/python/bittensor/error_map.py b/sdk/python/bittensor/error_map.py index 478959bfcf..4b0a4c867b 100644 --- a/sdk/python/bittensor/error_map.py +++ b/sdk/python/bittensor/error_map.py @@ -226,6 +226,13 @@ class ErrorCode(str, Enum): "AddStakeBurnRateLimitExceeded": _C.RATE_LIMITED, "ColdkeyCollateralIncomplete": _C.INTERNAL, "ColdkeyCollateralPositionsFull": _C.LIMIT_EXCEEDED, + # Declared on the chain but absent from the committed catalog before this + # branch regenerated it. Classified here so `codegen.check --names` stays + # green; unrelated to this PR otherwise. + "TooManyRootClaimHotkeys": _C.LIMIT_EXCEEDED, + "NoChallengeToAnswer": _C.NOT_FOUND, + "SubnetChallengeInProgress": _C.ALREADY_EXISTS, + "NoQueuedRegistration": _C.NOT_FOUND, # Pending announcement blocks nearly all signed calls until the swap is # executed or the announcement is cleared (guards/check_coldkey_swap.rs). # Bucketed as disabled (feature/path blocked), not already_exists. diff --git a/sdk/python/codegen/check.py b/sdk/python/codegen/check.py index cdf1de5e6b..ce610fb5fb 100644 --- a/sdk/python/codegen/check.py +++ b/sdk/python/codegen/check.py @@ -111,6 +111,13 @@ def check_drift(endpoint: str) -> int: "recycle_alpha", "remove_stake_full_limit", "register_limit", + # owner authorizes spending its own TAO to keep a subnet slot. Whether + # that spend should be agent-executable is a policy call, so raw-only + # until maintainers ask for an intent. + "exercise_first_refusal", + # withdraws a queued network registration; same class as the + # register_network variants below, which are raw-only too + "cancel_network_registration", # identity / metadata / misc (set_identity / set_subnet_identity / # update_symbol are wrapped) "register_network_with_identity", From 883abae67e74042d479e4a4dbe9675bbc08503d0 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Fri, 31 Jul 2026 03:08:49 +0200 Subject: [PATCH 5/8] Refund and dequeue the challenger when first refusal is exercised Matching an offer now removes the challenger's entry from NetworkRegistrationQueue and releases its lock, emitting NetworkRegistrationCancelled before SubnetFirstRefusalExercised. Leaving the entry queued made queue depth a function of elapsed time. The immunity an owner buys lapses, the subnet returns to the eviction pool and can be challenged again, and every earlier challenger stays on a list that each registration decodes. Fix the register_network_pruning fixture, which stamped a window whose challenger_lock_id was absent from an empty queue. refusal_state tests challenger presence before expiry, so the window resolved to Unchallenged and the benchmark measured the window branch instead of the prune branch it exists for. fill_subnets now queues the matching entry, and the benchmark asserts a started teardown rather than a non-empty queue, which held on either branch. Correct the bound stated in comments and tests: get_network_to_prune names one candidate and a live window there refuses rather than falling through, so at most one window is open chain-wide, not one per subnet. Re-measure register_network, register_network_pruning, exercise_first_refusal and cancel_network_registration. --- .../subtensor/src/benchmarks/benchmarks.rs | 25 ++- pallets/subtensor/src/benchmarks/helpers.rs | 43 +++- pallets/subtensor/src/macros/dispatches.rs | 12 +- pallets/subtensor/src/subnets/subnet.rs | 50 +++-- pallets/subtensor/src/tests/networks.rs | 202 ++++++++++++++++-- pallets/subtensor/src/weights.rs | 135 +++++++----- 6 files changed, 367 insertions(+), 100 deletions(-) diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index c01fcbd0b4..49a80fb1a0 100644 --- a/pallets/subtensor/src/benchmarks/benchmarks.rs +++ b/pallets/subtensor/src/benchmarks/benchmarks.rs @@ -341,7 +341,9 @@ mod pallet_benchmarks { )); } - assert!(!NetworkRegistrationQueue::::get().is_empty()); + // The prune branch specifically. `NetworkRegistrationQueue` grows on the window branch + // too, so its length alone cannot tell the two apart; a started teardown can. + assert!(!DissolveCleanupQueue::::get().is_empty()); } #[benchmark] @@ -1942,9 +1944,10 @@ mod pallet_benchmarks { assert_eq!(TokenSymbol::::get(netuid), new_symbol); } - /// Answering a challenge has to prove the challenger is still queued, so the queue is filled - /// to its bound and the matching entry put last, which is the worst case for that scan. The - /// bound is one queued challenge per subnet that can have a window open at once. + /// Answering a challenge scans the queue for the challenger, removes them and releases their + /// lock, so the queue is filled to its bound and the matching entry put last, which is the + /// worst case for both the scan and the write-back. The bound is one queued challenge per + /// subnet that can have a window open at once. #[benchmark] fn exercise_first_refusal() { let coldkey: T::AccountId = whitelisted_caller(); @@ -1984,11 +1987,25 @@ mod pallet_benchmarks { }, ); + // The lock the call releases has to exist, or the `Locks` write is not measured. + let challenger: T::AccountId = account("Challenger", 0, challenger_lock_id); + add_balance_to_coldkey_account::(&challenger, offer.saturating_mul(2.into())); + assert_ok!(Subtensor::::lock_network_registration_cost( + &challenger, + offer.into(), + challenger_lock_id + )); + #[extrinsic_call] _(RawOrigin::Signed(coldkey), netuid); assert!(!SubnetRefusalWindow::::contains_key(netuid)); assert!(NetworkImmuneUntil::::get(netuid) > 1); + assert!( + !NetworkRegistrationQueue::::get() + .iter() + .any(|entry| entry.lock_id == challenger_lock_id) + ); } /// Cancelling scans the queue for the caller's entry and writes back what is left, so the queue diff --git a/pallets/subtensor/src/benchmarks/helpers.rs b/pallets/subtensor/src/benchmarks/helpers.rs index b95f776650..8a2976ca0b 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -15,16 +15,27 @@ pub(super) fn set_reserves( SubnetAlphaIn::::insert(netuid, alpha_in); } +/// The lock held by the challenger behind every window `fill_subnets` stamps. +/// +/// Nonzero so it cannot collide with the id `NetworkRegistrationLockId` hands the registration +/// under measurement, which would put two entries in the queue under one lock. +pub(super) const LAPSED_CHALLENGER_LOCK_ID: u32 = 1; + /// A refusal window that closed at block zero, so the candidate is evictable at any later block. /// /// The at-limit benchmark uses this because clearing a lapsed window and then evicting is the /// heaviest way step 5 can go. Opening a fresh window instead does strictly less work, and being /// refused for a live one does less still. +/// +/// A window is only lapsed while its challenger is still queued: `refusal_state` tests challenger +/// presence first, so a window naming a lock id absent from `NetworkRegistrationQueue` reports as +/// `Unchallenged` and the registration opens a fresh window rather than pruning. `fill_subnets` +/// therefore queues the matching entry, and without it this fixture measures the wrong branch. pub(super) fn lapsed_refusal_window() -> RefusalWindow { RefusalWindow { offer: TaoBalance::from(0u64), expires_at: 0, - challenger_lock_id: 0, + challenger_lock_id: LAPSED_CHALLENGER_LOCK_ID, immunity_period: 0, } } @@ -97,6 +108,36 @@ pub(super) fn fill_subnets(owner: &T::AccountId, subnets: u16) { SubnetRefusalWindow::::insert(netuid, lapsed_refusal_window()); } + // Queue depth is measured at one entry per subnet slot. That is not the challenge path, which + // holds at most one entry at a time because only one window is ever open chain-wide; it is the + // `wait_to_cleanup` path, where registrations queue behind dissolutions already in flight and + // `DissolveCleanupQueue` has no bound of its own. Every entry carries a maximum identity + // because the whole queue is decoded and written back when the registration under measurement + // is pushed. `LAPSED_CHALLENGER_LOCK_ID` goes last, the worst case for the scan that resolves + // the window. + let mut queued: Vec> = (0..subnets) + .map(|index| NetworkRegistrationInfo:: { + coldkey: account("QueuedChallenger", 0, u32::from(index)), + hotkey: account("QueuedChallengerHotkey", 0, u32::from(index)), + mechid: 1, + identity: Some(max_subnet_identity()), + lock_amount: TaoBalance::from(0u64), + median_subnet_alpha_price: U64F64::saturating_from_num(1), + registration_block: 0, + lock_id: LAPSED_CHALLENGER_LOCK_ID + .saturating_add(u32::from(index)) + .saturating_add(1), + }) + .collect(); + if let Some(last) = queued.last_mut() { + last.lock_id = LAPSED_CHALLENGER_LOCK_ID; + } + let next_lock_id = LAPSED_CHALLENGER_LOCK_ID + .saturating_add(u32::from(subnets)) + .saturating_add(1); + NetworkRegistrationQueue::::set(queued); + NetworkRegistrationLockId::::set(next_lock_id); + let price = Subtensor::::get_network_lock_cost(); add_balance_to_coldkey_account::(owner, price.saturating_mul(2.into())); TotalIssuance::::mutate(|total| *total = total.saturating_add(price)); diff --git a/pallets/subtensor/src/macros/dispatches.rs b/pallets/subtensor/src/macros/dispatches.rs index a461cc96e3..f6ed26d558 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -2539,6 +2539,10 @@ mod dispatches { /// a challenger just signed a transaction to pay for the same slot. Answering moves /// `NetworkLastLockCost` exactly as a completed registration does. /// + /// The challenger is dequeued and their lock returned. Their offer was refused, which is + /// the whole of what this call decides, and it is what keeps `NetworkRegistrationQueue` + /// from carrying past challengers forward into the next immunity cycle. + /// /// # Arguments /// * `origin`: Signed by the subnet's owner coldkey. /// * `netuid`: The subnet to keep. @@ -2552,7 +2556,8 @@ mod dispatches { /// existential deposit. /// /// # Events - /// Emits `SubnetFirstRefusalExercised`. + /// Emits `NetworkRegistrationCancelled` for the refunded challenger, then + /// `SubnetFirstRefusalExercised`. #[pallet::call_index(146)] #[pallet::weight(::WeightInfo::exercise_first_refusal())] pub fn exercise_first_refusal(origin: OriginFor, netuid: NetUid) -> DispatchResult { @@ -2566,6 +2571,11 @@ mod dispatches { /// call is the way back out. It removes the entry and releases the lock, so a registrant /// who no longer wants to wait is not holding funds against a slot indefinitely. /// + /// Answering a challenge refunds that challenger without this call. The case this covers is + /// a window nobody resolves: expiry is lazy, so a lapsed window is acted on by the next + /// registration to reach the limit, and a queued entry is served only once a slot is free. + /// Absent further registrations, nothing block-driven releases the lock. + /// /// `lock_id` identifies which registration, since one coldkey can hold several. It is the /// `lock_id` field of the entry in `NetworkRegistrationQueue`. /// diff --git a/pallets/subtensor/src/subnets/subnet.rs b/pallets/subtensor/src/subnets/subnet.rs index 849be86060..ec0b722a74 100644 --- a/pallets/subtensor/src/subnets/subnet.rs +++ b/pallets/subtensor/src/subnets/subnet.rs @@ -248,9 +248,13 @@ impl Pallet { } // A second challenger is refused, not queued behind the first, because // `NetworkRegistrationQueue` is unbounded, decoded by every registration, and - // has no teardown in flight to drain it while a window stands. One subnet - // therefore holds at most one challenger, and since answering and lapsing both - // take a subnet out of the eviction pool, the queue stays bounded overall. + // has no teardown in flight to drain it while a window stands. + // + // `get_network_to_prune` names one candidate and this arm refuses rather than + // falling through to the next, so at most one window is open chain-wide and + // the challenge path contributes at most one queue entry at a time. Answering + // removes that entry and lapsing hands it the slot, so a challenge adds + // nothing that outlives the window it opened. RefusalState::Live => { return Err(Error::::SubnetChallengeInProgress.into()); } @@ -365,10 +369,10 @@ impl Pallet { // If the challenger has left the queue, whether served from some other slot that came free // or withdrawn, nothing is threatening this subnet, so the owner is stopped from burning // the offer for immunity nobody was contesting. - ensure!( - Self::challenger_is_queued(window.challenger_lock_id), - Error::::NoChallengeToAnswer - ); + let challenger = NetworkRegistrationQueue::::get() + .into_iter() + .find(|entry| entry.lock_id == window.challenger_lock_id) + .ok_or(Error::::NoChallengeToAnswer)?; let offer = window.offer; @@ -379,11 +383,19 @@ impl Pallet { // balance against the existential deposit. Self::recycle_tao(&coldkey, offer.into())?; - // Only the window is cleared. The challenger's registration was for a slot, not for this - // subnet, so matching the offer ends the challenge and leaves them queued for the next slot - // to come free, at the price they locked. `cancel_network_registration` is theirs to call - // if they would rather have the lock back than the place in line. + // The challenger is refunded and dequeued. Their offer was refused, which is the whole of + // what a right of first refusal decides, so they are not left holding a place in line at a + // price that was just beaten. + // + // This is also what bounds the queue. A challenge is the one path that appends without a + // teardown to drain it; leaving a matched challenger queued would let entries accumulate + // across immunity cycles, since the immunity the owner bought lapses and the same subnet + // returns to the eviction pool with every past challenger still on the list. SubnetRefusalWindow::::remove(netuid); + NetworkRegistrationQueue::::mutate(|queue| { + queue.retain(|entry| entry.lock_id != window.challenger_lock_id); + }); + Self::unlock_network_registration_cost(&challenger.coldkey, window.challenger_lock_id)?; // The immunity promised when the window opened, not whatever the parameter says now. NetworkImmuneUntil::::insert( netuid, @@ -401,6 +413,12 @@ impl Pallet { Self::set_network_last_lock(offer.max(Self::get_network_last_lock())); Self::set_network_last_lock_block(current_block); + Self::deposit_event(Event::NetworkRegistrationCancelled { + coldkey: challenger.coldkey, + hotkey: challenger.hotkey, + lock_amount: challenger.lock_amount, + lock_id: window.challenger_lock_id, + }); Self::deposit_event(Event::SubnetFirstRefusalExercised { netuid, owner: coldkey, @@ -432,6 +450,12 @@ impl Pallet { // queue, so a window whose challenger has gone reports as unchallenged and is cleared by // the next registration to look at it. Withdrawing an offer retracts the threat behind it; // it does not evict the subnet the offer was aimed at. + // + // An answered challenge refunds the challenger without this call. What it covers is the + // other end: a window that lapses is resolved by the next registration to reach the limit, + // and `process_network_registration_queue` can only serve an entry once a slot is actually + // free. If no further registration arrives, the entry waits with its TAO locked and no + // block-driven path releases it. Self::deposit_event(Event::NetworkRegistrationCancelled { coldkey, hotkey: info.hotkey, @@ -452,10 +476,6 @@ impl Pallet { queue.iter().any(|entry| entry.lock_id == lock_id) } - fn challenger_is_queued(lock_id: u32) -> bool { - Self::challenger_is_queued_in(&NetworkRegistrationQueue::::get(), lock_id) - } - /// Where the candidate stands: nobody challenging it, a live challenge the owner may answer, /// or a window the owner has already let close. /// diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index a983480603..b23f7e474e 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -3,6 +3,7 @@ use super::mock::*; use crate::migrations::migrate_network_immunity_period; use crate::staking::lock::LockState; +use crate::subnets::subnet::RefusalWindow; use crate::*; use frame_support::{ assert_err, assert_noop, assert_ok, traits::Currency, weights::Weight, weights::WeightMeter, @@ -1676,6 +1677,7 @@ fn the_owner_pays_no_more_than_the_challenger_committed() { let (low, high, low_cold, registrant) = refusal_setup(4010); assert_ok!(register_from(registrant, 4015)); + let offer = SubnetRefusalWindow::::get(low).unwrap().offer; let balance_before = SubtensorModule::get_coldkey_balance(&low_cold); let issuance_before = TotalIssuance::::get(); @@ -1684,13 +1686,19 @@ fn the_owner_pays_no_more_than_the_challenger_committed() { low )); - // The challenger keeps their place in line; only the challenge is over. - let queue = NetworkRegistrationQueue::::get(); + // The owner pays the challenger's own figure, and it is burned rather than banked. let paid = balance_before.saturating_sub(SubtensorModule::get_coldkey_balance(&low_cold)); - assert_eq!(paid, queue[0].lock_amount.into()); + assert_eq!(paid, offer.into()); assert_eq!( issuance_before.saturating_sub(TotalIssuance::::get()), - queue[0].lock_amount + offer + ); + + // The refused challenger is out of the queue with their lock released. + assert!(NetworkRegistrationQueue::::get().is_empty()); + assert_eq!( + Balances::usable_balance(registrant), + Balances::free_balance(registrant) ); assert!(NetworksAdded::::get(low)); @@ -1759,46 +1767,58 @@ fn an_unanswered_window_lapses_and_the_slot_is_taken() { }); } -/// One challenge stands against a given subnet at a time. +/// One challenge stands chain-wide at a time, not one per subnet. /// /// A second registration arriving while an owner is deciding is refused outright rather than /// queued behind the first. Queueing it would grow `NetworkRegistrationQueue` with no teardown -/// coming to drain it, and every registration on the chain decodes that queue. This is what holds -/// a subnet to a single queued challenger, so it is a chain-availability rule and not a nicety. -/// What bounds the queue overall is a separate argument, covered by -/// `challenges_cannot_grow_the_queue_past_the_evictable_pool`. +/// coming to drain it, and every registration on the chain decodes that queue, so this is a +/// chain-availability rule. +/// +/// The scope is the part worth pinning: `get_network_to_prune` names one candidate, and a live +/// window on that candidate refuses rather than falling through to the next-cheapest subnet. The +/// other subnet here is evictable and unchallenged, and the second registration is still turned +/// away, so the challenge path holds at most one queue entry at any moment. /// /// The owner also still answers the one figure they were shown, since the recorded offer is left /// alone. #[test] fn a_second_challenge_inside_the_window_is_refused_rather_than_queued() { new_test_ext(0).execute_with(|| { - let (low, _high, _low_cold, registrant) = refusal_setup(4040); + let (low, high, _low_cold, registrant) = refusal_setup(4040); assert_ok!(register_from(registrant, 4045)); let first_window = SubnetRefusalWindow::::get(low).unwrap(); + // The other subnet is a live eviction candidate with nothing standing on it. + assert!(NetworksAdded::::get(high)); + assert!(!SubnetRefusalWindow::::contains_key(high)); + System::set_block_number(System::block_number().saturating_add(10)); assert_noop!( register_from(registrant, 4046), Error::::SubnetChallengeInProgress ); + // Refused, not redirected: no window opened on the unchallenged subnet either. + assert!(!SubnetRefusalWindow::::contains_key(high)); assert_eq!(SubnetRefusalWindow::::get(low).unwrap(), first_window); assert_eq!(NetworkRegistrationQueue::::get().len(), 1); }); } -/// What actually bounds the queue. +/// What bounds the queue. /// /// A challenge is the one path that appends to `NetworkRegistrationQueue` without dissolving /// anything, so the "every entry is paid for by a teardown" argument that bounds the unchallenged -/// path does not cover it. The bound instead comes from the eviction pool shrinking: answering a -/// challenge makes that subnet immune, so it stops being a candidate. Once nothing is evictable -/// `get_network_to_prune` yields `None` and registrations are refused outright, which is where the -/// queue stops rather than growing for a transaction fee. +/// path does not cover it. Answering refunds and dequeues the challenger, so the append is undone +/// by the same call that ends the challenge and the queue is back where it started. Answering also +/// makes the subnet immune, so it leaves the eviction pool; once nothing is evictable +/// `get_network_to_prune` yields `None` and registrations are refused outright rather than queued. +/// +/// `matched_challengers_do_not_accumulate_across_immunity_cycles` covers the same property once +/// the bought immunity has lapsed and the subnet is back in the pool. #[test] -fn challenges_cannot_grow_the_queue_past_the_evictable_pool() { +fn challenges_cannot_grow_the_queue() { new_test_ext(0).execute_with(|| { let (low, high, low_cold, registrant) = refusal_setup(4090); @@ -1814,7 +1834,7 @@ fn challenges_cannot_grow_the_queue_past_the_evictable_pool() { RuntimeOrigin::signed(low_cold), low )); - assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + assert!(NetworkRegistrationQueue::::get().is_empty()); // Only the other subnet is left to challenge, and its owner answers too. assert_ok!(register_from(registrant, 4096)); @@ -1823,7 +1843,7 @@ fn challenges_cannot_grow_the_queue_past_the_evictable_pool() { RuntimeOrigin::signed(high_cold), high )); - assert_eq!(NetworkRegistrationQueue::::get().len(), 2); + assert!(NetworkRegistrationQueue::::get().is_empty()); // The pool is empty, so the next registration is turned away instead of queued. assert_eq!(SubtensorModule::get_network_to_prune(), None); @@ -1831,7 +1851,7 @@ fn challenges_cannot_grow_the_queue_past_the_evictable_pool() { register_from(registrant, 4097), Error::::SubnetLimitReached ); - assert_eq!(NetworkRegistrationQueue::::get().len(), 2); + assert!(NetworkRegistrationQueue::::get().is_empty()); }); } @@ -1943,9 +1963,13 @@ fn serving_an_old_queued_registration_does_not_lower_the_lock_cost() { )); assert_eq!(SubtensorModule::get_network_last_lock(), offer); - // The queued challenger is now served from a slot that came free. Raising the limit stands - // in for the dissolution that would have freed one; what is under test is the price the - // serving writes back, not the slot accounting that got it there. + // That challenger was refunded and dequeued, so the entry under test is the next one: a + // second registration, which lands on the other subnet now that `low` is immune. + assert_ok!(register_from(registrant, 4046)); + + // It is served from a slot that came free. Raising the limit stands in for the dissolution + // that would have freed one; what is under test is the price the serving writes back, not + // the slot accounting that got it there. SubnetLimit::::put(3u16); // The market moves further up while the entry waits, so the figure it locked is now stale. @@ -1953,7 +1977,10 @@ fn serving_an_old_queued_registration_does_not_lower_the_lock_cost() { SubtensorModule::set_network_last_lock(market); let queued = NetworkRegistrationQueue::::get(); - let entry = queued.first().expect("challenger is still queued").clone(); + let entry = queued + .first() + .expect("the second challenger is queued") + .clone(); assert!(entry.lock_amount < market); assert_ok!(SubtensorModule::set_new_network_state( @@ -4581,3 +4608,132 @@ fn process_network_registration_queue_unlocks_funds_and_charges_coldkey() { assert_eq!(SubnetLocked::::get(new_netuid), queued_lock); }); } + +/// Queue depth is not a function of elapsed time. +/// +/// Immunity bought by answering a challenge does lapse, and the subnet then returns to the +/// eviction pool and can be challenged again. Were a matched challenger left queued, one subnet +/// would add an entry every cycle forever, and nothing in the eviction pool would bound a list +/// that every registration on the chain decodes. Dequeueing on match is what stops that. +#[test] +fn matched_challengers_do_not_accumulate_across_immunity_cycles() { + new_test_ext(0).execute_with(|| { + let (low, _high, low_cold, registrant) = refusal_setup(4200); + + let big = SubtensorModule::get_network_lock_cost().saturating_mul(10_000.into()); + add_balance_to_coldkey_account(&low_cold, big.into()); + add_balance_to_coldkey_account(®istrant, big.into()); + TotalIssuance::::mutate(|t| *t = t.saturating_add(big.saturating_mul(2.into()))); + + // Two subnets exist, so the evictable pool is never larger than two. + for cycle in 0..4u64 { + assert_eq!(SubtensorModule::get_network_to_prune(), Some(low)); + assert_ok!(register_from(registrant, 4300u64.saturating_add(cycle))); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + assert!( + NetworkRegistrationQueue::::get().is_empty(), + "cycle {cycle}: the answered challenger was refunded and dequeued" + ); + System::set_block_number(NetworkImmuneUntil::::get(low).saturating_add(1)); + } + }); +} + +/// Guards the benchmark fixture: a window with no challenger behind it does not prune. +/// +/// `refusal_state` tests challenger presence before expiry, so a window whose `challenger_lock_id` +/// is absent from `NetworkRegistrationQueue` resolves to `Unchallenged` rather than `Lapsed`, and +/// the registration opens a fresh window instead of pruning. `register_network_pruning` used to +/// build exactly that state and so measured the wrong branch, and its old +/// `assert!(!queue.is_empty())` passed on both, which is why it went unnoticed. `fill_subnets` now +/// queues the matching entry; this pins the behavior that made the omission matter. +#[test] +fn a_window_with_no_queued_challenger_opens_afresh_instead_of_pruning() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4400); + + // exactly what benchmarks::helpers::lapsed_refusal_window() writes + SubnetRefusalWindow::::insert( + low, + RefusalWindow { + offer: TaoBalance::from(0u64), + expires_at: 0, + challenger_lock_id: 0, + immunity_period: 0, + }, + ); + assert!(NetworkRegistrationQueue::::get().is_empty()); + + assert_ok!(register_from(registrant, 4500)); + + // the benchmark's assertion -- passes either way + assert!(!NetworkRegistrationQueue::::get().is_empty()); + + // but nothing was pruned + assert!( + DissolveCleanupQueue::::get().is_empty(), + "no teardown was started, so the prune branch never ran" + ); + let fresh = SubnetRefusalWindow::::get(low).expect("a fresh window was opened"); + assert!( + fresh.expires_at > 0, + "the fixture's lapsed window (expires_at 0) was replaced by a live one, \ + so the registration opened a window rather than pruning" + ); + }); +} + +/// Documents pre-existing base behavior. This PR neither introduces it nor relies on it, and the +/// test is here because the collateral behind a queued registration is what an owner is deciding +/// against. Reported separately rather than patched here. +/// +/// Registration collateral uses `LockableCurrency::set_lock` under a per-lock-id identifier, and +/// Substrate locks overlap rather than stack. `lock_network_registration_cost` guards with +/// `can_remove_balance_from_coldkey_account`, which nets out already-frozen funds, so a coldkey +/// cannot open a second lock for its whole balance. It CAN open two locks of unequal size: the +/// pair freezes max(a, b) while promising a + b, leaving the difference spendable. +#[test] +fn unequal_registration_locks_freeze_max_not_sum() { + new_test_ext(0).execute_with(|| { + let coldkey = U256::from(9001u64); + let big: u64 = 100_000_000_000; + let small: u64 = 50_000_000_000; + add_balance_to_coldkey_account(&coldkey, big.saturating_add(small).into()); + + assert_ok!(SubtensorModule::lock_network_registration_cost( + &coldkey, + big.into(), + 0 + )); + // The guard rejects a second lock for the full balance -- overlap is not freely exploitable. + assert_err!( + SubtensorModule::lock_network_registration_cost(&coldkey, big.into(), 1), + Error::::InsufficientTaoBalance + ); + // But an unequal second lock fits inside what the first one left reducible. + assert_ok!(SubtensorModule::lock_network_registration_cost( + &coldkey, + small.into(), + 1 + )); + + let free: u64 = Balances::free_balance(coldkey).into(); + let usable: u64 = Balances::usable_balance(coldkey).into(); + let frozen = free.saturating_sub(usable); + + assert_eq!( + frozen, + big, + "two locks promising {} freeze only max(a, b) = {}", + big.saturating_add(small), + big + ); + assert_eq!( + usable, small, + "the {small} difference stays spendable while still promised to a queued offer" + ); + }); +} diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 1d736b1c8a..99f386be07 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -43,6 +43,7 @@ pub trait WeightInfo { fn serve_prometheus() -> Weight; fn burned_register() -> Weight; fn root_register() -> Weight; + fn register_network() -> Weight; fn register_network_pruning() -> Weight; fn commit_weights() -> Weight; @@ -597,6 +598,7 @@ impl WeightInfo for SubstrateWeight { .saturating_add(T::DbWeight::get().reads(219_u64)) .saturating_add(T::DbWeight::get().writes(88_u64)) } + /// Storage: `SubtensorModule::Owner` (r:1 w:1) /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationStartBlock` (r:1 w:0) @@ -715,10 +717,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `8028` - // Estimated: `328293` - // Minimum execution time: 3_510_666_000 picoseconds. - Weight::from_parts(3_749_630_000, 328293) + // Measured: `868501` + // Estimated: `1188766` + // Minimum execution time: 3_154_777_000 picoseconds. + Weight::from_parts(3_261_104_000, 1188766) .saturating_add(T::DbWeight::get().reads(671_u64)) .saturating_add(T::DbWeight::get().writes(49_u64)) } @@ -738,14 +740,6 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) - /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) - /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) - /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) - /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkMinLockCost` (r:1 w:0) @@ -754,6 +748,18 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) @@ -778,10 +784,10 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `11314` - // Estimated: `334054` - // Minimum execution time: 4_355_915_000 picoseconds. - Weight::from_parts(4_733_135_000, 334054) + // Measured: `878562` + // Estimated: `1201302` + // Minimum execution time: 5_461_229_000 picoseconds. + Weight::from_parts(5_821_408_000, 1201302) .saturating_add(T::DbWeight::get().reads(1045_u64)) .saturating_add(T::DbWeight::get().writes(11_u64)) } @@ -1921,24 +1927,30 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) - /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:0 w:1) /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkLastLockCost` (r:0 w:1) - /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:0 w:1) /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) fn exercise_first_refusal() -> Weight { // Proof Size summary in bytes: - // Measured: `875042` - // Estimated: `878507` - // Minimum execution time: 791_340_000 picoseconds. - Weight::from_parts(845_149_000, 878507) - .saturating_add(T::DbWeight::get().reads(6_u64)) - .saturating_add(T::DbWeight::get().writes(5_u64)) + // Measured: `875174` + // Estimated: `878639` + // Minimum execution time: 2_319_516_000 picoseconds. + Weight::from_parts(2_385_148_000, 878639) + .saturating_add(T::DbWeight::get().reads(9_u64)) + .saturating_add(T::DbWeight::get().writes(8_u64)) } /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -1950,8 +1962,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `874625` // Estimated: `876110` - // Minimum execution time: 1_321_376_000 picoseconds. - Weight::from_parts(1_383_951_000, 876110) + // Minimum execution time: 1_746_913_000 picoseconds. + Weight::from_parts(1_914_373_000, 876110) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -4322,6 +4334,7 @@ impl WeightInfo for () { .saturating_add(RocksDbWeight::get().reads(219_u64)) .saturating_add(RocksDbWeight::get().writes(88_u64)) } + /// Storage: `SubtensorModule::Owner` (r:1 w:1) /// Proof: `SubtensorModule::Owner` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationStartBlock` (r:1 w:0) @@ -4440,10 +4453,10 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::MaxAllowedUids` (`max_values`: None, `max_size`: None, mode: `Measured`) fn register_network() -> Weight { // Proof Size summary in bytes: - // Measured: `8028` - // Estimated: `328293` - // Minimum execution time: 3_510_666_000 picoseconds. - Weight::from_parts(3_749_630_000, 328293) + // Measured: `868501` + // Estimated: `1188766` + // Minimum execution time: 3_154_777_000 picoseconds. + Weight::from_parts(3_261_104_000, 1188766) .saturating_add(RocksDbWeight::get().reads(671_u64)) .saturating_add(RocksDbWeight::get().writes(49_u64)) } @@ -4463,14 +4476,6 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::DissolveCleanupQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) - /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) - /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) - /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) - /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:0) /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkMinLockCost` (r:1 w:0) @@ -4479,6 +4484,18 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkLockReductionInterval` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:0) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegisteredAt` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkRegisteredAt` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) + /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:128 w:0) + /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) + /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) @@ -4503,10 +4520,10 @@ impl WeightInfo for () { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `11314` - // Estimated: `334054` - // Minimum execution time: 4_355_915_000 picoseconds. - Weight::from_parts(4_733_135_000, 334054) + // Measured: `878562` + // Estimated: `1201302` + // Minimum execution time: 5_461_229_000 picoseconds. + Weight::from_parts(5_821_408_000, 1201302) .saturating_add(RocksDbWeight::get().reads(1045_u64)) .saturating_add(RocksDbWeight::get().writes(11_u64)) } @@ -5646,24 +5663,30 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::SubnetOwner` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::TotalIssuance` (r:1 w:1) /// Proof: `SubtensorModule::TotalIssuance` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkImmunityPeriod` (r:1 w:0) - /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) + /// Storage: `Balances::Locks` (r:1 w:1) + /// Proof: `Balances::Locks` (`max_values`: None, `max_size`: Some(899), added: 3374, mode: `MaxEncodedLen`) + /// Storage: `Balances::Freezes` (r:1 w:0) + /// Proof: `Balances::Freezes` (`max_values`: None, `max_size`: Some(499), added: 2974, mode: `MaxEncodedLen`) + /// Storage: `System::Account` (r:1 w:1) + /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) + /// Storage: `SubtensorModule::NetworkLastLockCost` (r:1 w:1) + /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::LastRateLimitedBlock` (r:0 w:1) /// Proof: `SubtensorModule::LastRateLimitedBlock` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::NetworkLastLockCost` (r:0 w:1) - /// Proof: `SubtensorModule::NetworkLastLockCost` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:0 w:1) /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) fn exercise_first_refusal() -> Weight { // Proof Size summary in bytes: - // Measured: `875042` - // Estimated: `878507` - // Minimum execution time: 791_340_000 picoseconds. - Weight::from_parts(845_149_000, 878507) - .saturating_add(RocksDbWeight::get().reads(6_u64)) - .saturating_add(RocksDbWeight::get().writes(5_u64)) + // Measured: `875174` + // Estimated: `878639` + // Minimum execution time: 2_319_516_000 picoseconds. + Weight::from_parts(2_385_148_000, 878639) + .saturating_add(RocksDbWeight::get().reads(9_u64)) + .saturating_add(RocksDbWeight::get().writes(8_u64)) } /// Storage: `SubtensorModule::NetworkRegistrationQueue` (r:1 w:1) /// Proof: `SubtensorModule::NetworkRegistrationQueue` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) @@ -5675,8 +5698,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `874625` // Estimated: `876110` - // Minimum execution time: 1_321_376_000 picoseconds. - Weight::from_parts(1_383_951_000, 876110) + // Minimum execution time: 1_746_913_000 picoseconds. + Weight::from_parts(1_914_373_000, 876110) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) } From 2624d375090168bf972b66a4a4209ec0dad362f2 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Fri, 31 Jul 2026 05:00:46 +0200 Subject: [PATCH 6/8] Skip subnets under a live refusal window instead of refusing the registration An open window used to make do_register_network return SubnetChallengeInProgress, which held up every registration on the chain until the window closed. Because do_cancel_network_registration refunds in full, one challenger could recycle a single lock to keep registrations frozen indefinitely at no protocol cost. get_network_to_prune now passes over a subnet whose window is still open with its challenger still waiting, and falls through to the next candidate. Freezing registrations therefore costs one full lock per evictable subnet held at once, each of which the targeted owner can end by answering. Adds refusal_window_is_live, a side-effect-free predicate, because the pruning scan and the read-only runtime API cannot write, unlike refusal_state which clears a window whose challenger has left. The RefusalState::Live arm is now unreachable by construction and kept as a guard. Tests: an_unanswered_cycle_queues_two_and_frees_one records the queue growth this trades for, and a_live_window_diverts_the_next_registration_instead_of_blocking_it covers the fall-through. a_second_challenge_inside_the_window_is_refused_rather_ than_queued pinned the removed behaviour and is replaced by a_challenge_elsewhere_does_not_reprice_an_open_window. --- pallets/subtensor/src/benchmarks/helpers.rs | 15 +-- pallets/subtensor/src/coinbase/root.rs | 10 ++ pallets/subtensor/src/subnets/subnet.rs | 32 +++-- pallets/subtensor/src/tests/networks.rs | 122 ++++++++++++++++---- 4 files changed, 143 insertions(+), 36 deletions(-) diff --git a/pallets/subtensor/src/benchmarks/helpers.rs b/pallets/subtensor/src/benchmarks/helpers.rs index 8a2976ca0b..31e0af7393 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -108,13 +108,14 @@ pub(super) fn fill_subnets(owner: &T::AccountId, subnets: u16) { SubnetRefusalWindow::::insert(netuid, lapsed_refusal_window()); } - // Queue depth is measured at one entry per subnet slot. That is not the challenge path, which - // holds at most one entry at a time because only one window is ever open chain-wide; it is the - // `wait_to_cleanup` path, where registrations queue behind dissolutions already in flight and - // `DissolveCleanupQueue` has no bound of its own. Every entry carries a maximum identity - // because the whole queue is decoded and written back when the registration under measurement - // is pushed. `LAPSED_CHALLENGER_LOCK_ID` goes last, the worst case for the scan that resolves - // the window. + // Queue depth is measured at one entry per subnet slot, and two paths reach it. The scan skips + // a subnet whose window is still live and names the next candidate, so every evictable subnet + // can carry its own window and its own queued challenger at the same time. The + // `wait_to_cleanup` path reaches the same depth from the other direction, with registrations + // queued behind dissolutions already in flight and `DissolveCleanupQueue` unbounded. Every + // entry carries a maximum identity because the whole queue is decoded and written back when + // the registration under measurement is pushed. `LAPSED_CHALLENGER_LOCK_ID` goes last, the + // worst case for the scan that resolves the window. let mut queued: Vec> = (0..subnets) .map(|index| NetworkRegistrationInfo:: { coldkey: account("QueuedChallenger", 0, u32::from(index)), diff --git a/pallets/subtensor/src/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 4e611f07d1..fb63b2255d 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -306,6 +306,7 @@ impl Pallet { pub fn get_network_to_prune() -> Option { let current_block: u64 = Self::get_current_block_as_u64(); + let queue = NetworkRegistrationQueue::::get(); let mut candidate_netuid: Option = None; let mut candidate_price: U64F64 = U64F64::saturating_from_num(u128::MAX); @@ -323,6 +324,15 @@ impl Pallet { continue; } + // Skip a subnet whose owner is still inside the window to answer. Passing over it + // rather than refusing the registration outright is what stops one open window from + // holding up every registration on the chain: the scan falls through to the next + // candidate, and freezing registrations now costs a challenger one full lock per + // evictable subnet held at once rather than one lock total. + if Self::refusal_window_is_live(netuid, current_block, &queue) { + continue; + } + let price: U64F64 = Self::get_moving_alpha_price(netuid); // If tie on price, earliest registration wins. diff --git a/pallets/subtensor/src/subnets/subnet.rs b/pallets/subtensor/src/subnets/subnet.rs index ec0b722a74..5a26fe86a0 100644 --- a/pallets/subtensor/src/subnets/subnet.rs +++ b/pallets/subtensor/src/subnets/subnet.rs @@ -246,15 +246,15 @@ impl Pallet { SubnetRefusalWindow::::remove(netuid); prune_netuid = Some(netuid); } - // A second challenger is refused, not queued behind the first, because - // `NetworkRegistrationQueue` is unbounded, decoded by every registration, and - // has no teardown in flight to drain it while a window stands. + // Unreachable by construction: `get_network_to_prune` skips a subnet whose + // window is still live, so the candidate it names is never one. Kept as a + // guard rather than an `unreachable!`, and it is what a registrant sees if + // the scan and this check ever disagree. // - // `get_network_to_prune` names one candidate and this arm refuses rather than - // falling through to the next, so at most one window is open chain-wide and - // the challenge path contributes at most one queue entry at a time. Answering - // removes that entry and lapsing hands it the slot, so a challenge adds - // nothing that outlives the window it opened. + // The scan skips rather than this arm refusing, which is the difference + // between one window blocking every at-cap registration and one window + // blocking only the subnet it names. Holding the chain now costs a challenger + // a full lock per evictable subnet, held simultaneously, instead of one lock. RefusalState::Live => { return Err(Error::::SubnetChallengeInProgress.into()); } @@ -476,6 +476,22 @@ impl Pallet { queue.iter().any(|entry| entry.lock_id == lock_id) } + /// Whether a window on `netuid` is still open with its challenger still waiting. + /// + /// Deliberately side-effect free, unlike `refusal_state`, which clears a window whose + /// challenger has left. This one runs inside the pruning scan and from a read-only runtime + /// API, neither of which may write. + pub(crate) fn refusal_window_is_live( + netuid: NetUid, + current_block: u64, + queue: &[NetworkRegistrationInfo], + ) -> bool { + SubnetRefusalWindow::::get(netuid).is_some_and(|window| { + current_block <= window.expires_at + && Self::challenger_is_queued_in(queue, window.challenger_lock_id) + }) + } + /// Where the candidate stands: nobody challenging it, a live challenge the owner may answer, /// or a window the owner has already let close. /// diff --git a/pallets/subtensor/src/tests/networks.rs b/pallets/subtensor/src/tests/networks.rs index b23f7e474e..9674ff432a 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -1767,24 +1767,25 @@ fn an_unanswered_window_lapses_and_the_slot_is_taken() { }); } -/// One challenge stands chain-wide at a time, not one per subnet. +/// A challenge on one subnet does not reprice a window already open on another. /// -/// A second registration arriving while an owner is deciding is refused outright rather than -/// queued behind the first. Queueing it would grow `NetworkRegistrationQueue` with no teardown -/// coming to drain it, and every registration on the chain decodes that queue, so this is a -/// chain-availability rule. +/// The scan passes over a subnet whose owner is still deciding, so two windows can stand at once +/// and the second registration moves the lock cost while the first owner is still inside their +/// deadline. The offer is recorded when the window opens, so that owner answers the figure they +/// were shown rather than a number somebody else's registration set afterwards. /// -/// The scope is the part worth pinning: `get_network_to_prune` names one candidate, and a live -/// window on that candidate refuses rather than falling through to the next-cheapest subnet. The -/// other subnet here is evictable and unchallenged, and the second registration is still turned -/// away, so the challenge path holds at most one queue entry at any moment. -/// -/// The owner also still answers the one figure they were shown, since the recorded offer is left -/// alone. +/// One queue entry per outstanding challenge is the honest bound here. It used to be one +/// chain-wide, enforced by refusing the second registration outright, and that turned a single +/// lock into a hold on every at-cap registration. #[test] -fn a_second_challenge_inside_the_window_is_refused_rather_than_queued() { +fn a_challenge_elsewhere_does_not_reprice_an_open_window() { new_test_ext(0).execute_with(|| { - let (low, high, _low_cold, registrant) = refusal_setup(4040); + let (low, high, low_cold, registrant) = refusal_setup(4040); + + let big = SubtensorModule::get_network_lock_cost().saturating_mul(10_000.into()); + add_balance_to_coldkey_account(®istrant, big.into()); + add_balance_to_coldkey_account(&low_cold, big.into()); + TotalIssuance::::mutate(|t| *t = t.saturating_add(big.saturating_mul(2.into()))); assert_ok!(register_from(registrant, 4045)); let first_window = SubnetRefusalWindow::::get(low).unwrap(); @@ -1794,15 +1795,21 @@ fn a_second_challenge_inside_the_window_is_refused_rather_than_queued() { assert!(!SubnetRefusalWindow::::contains_key(high)); System::set_block_number(System::block_number().saturating_add(10)); - assert_noop!( - register_from(registrant, 4046), - Error::::SubnetChallengeInProgress - ); - // Refused, not redirected: no window opened on the unchallenged subnet either. - assert!(!SubnetRefusalWindow::::contains_key(high)); + // Diverted to that candidate, not refused. + assert_ok!(register_from(registrant, 4046)); + assert!(SubnetRefusalWindow::::contains_key(high)); + assert_eq!(NetworkRegistrationQueue::::get().len(), 2); assert_eq!(SubnetRefusalWindow::::get(low).unwrap(), first_window); - assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + + // And the first owner is charged the figure their window recorded. + let balance_before = SubtensorModule::get_coldkey_balance(&low_cold); + assert_ok!(SubtensorModule::exercise_first_refusal( + RuntimeOrigin::signed(low_cold), + low + )); + let paid = balance_before.saturating_sub(SubtensorModule::get_coldkey_balance(&low_cold)); + assert_eq!(paid, first_window.offer.into()); }); } @@ -4642,6 +4649,79 @@ fn matched_challengers_do_not_accumulate_across_immunity_cycles() { }); } +/// The answered path is bounded. The unanswered path is not, and this pins the difference. +/// +/// A challenger opens a window and is queued, but nothing is pruned yet. Nobody answers. The next +/// registration to arrive clears the lapsed window, prunes, and joins the queue behind that +/// challenger. Two entries in, one slot out. Depth climbs by one per unanswered cycle, and every +/// registration decodes the queue, so this is a real cost rather than a cosmetic one. Stated in +/// the PR body rather than fixed: closing it means handing a lapsed window's challenger the slot +/// without waiting for a second registration to trigger the prune. +#[test] +fn an_unanswered_cycle_queues_two_and_frees_one() { + new_test_ext(0).execute_with(|| { + let (low, _high, _low_cold, registrant) = refusal_setup(4500); + + let big = SubtensorModule::get_network_lock_cost().saturating_mul(10_000.into()); + add_balance_to_coldkey_account(®istrant, big.into()); + TotalIssuance::::mutate(|t| *t = t.saturating_add(big)); + + assert_ok!(register_from(registrant, 4510)); + assert!(SubnetRefusalWindow::::contains_key(low)); + assert_eq!(NetworkRegistrationQueue::::get().len(), 1); + + let expires = SubnetRefusalWindow::::get(low).unwrap().expires_at; + System::set_block_number(expires.saturating_add(1)); + + assert_ok!(register_from(registrant, 4511)); + assert_eq!( + NetworkRegistrationQueue::::get().len(), + 2, + "the lapsed window's challenger is still queued when the pruner joins" + ); + + DissolveCleanupQueue::::kill(); + run_network_registration_queue(); + assert_eq!( + NetworkRegistrationQueue::::get().len(), + 1, + "one entry outlives the cycle that created it" + ); + }); +} + +/// One open window diverts the next registration rather than holding up the chain. +/// +/// `get_network_to_prune` passes over a subnet whose owner is still inside the deadline, so the +/// scan names the next candidate instead. Refusing here rather than skipping would have let a +/// single lock freeze every at-cap registration, and cancelling refunds in full, so the same lock +/// could be recycled indefinitely. Skipping prices the attack properly: freezing the chain now +/// takes a live window on every evictable subnet at once, each backed by its own full lock. +#[test] +fn a_live_window_diverts_the_next_registration_instead_of_blocking_it() { + new_test_ext(0).execute_with(|| { + let (low, high, _low_cold, registrant) = refusal_setup(4600); + + let big = SubtensorModule::get_network_lock_cost().saturating_mul(10_000.into()); + add_balance_to_coldkey_account(®istrant, big.into()); + TotalIssuance::::mutate(|t| *t = t.saturating_add(big)); + + assert_ok!(register_from(registrant, 4610)); + assert!(SubnetRefusalWindow::::contains_key(low)); + + // Not refused with `SubnetChallengeInProgress`: the scan skips `low` and names `high`. + assert_ok!(register_from(registrant, 4611)); + assert!( + SubnetRefusalWindow::::contains_key(high), + "the second challenge landed on the next candidate" + ); + assert!( + SubnetRefusalWindow::::contains_key(low), + "and the first window is untouched, so its owner keeps the full deadline" + ); + }); +} + /// Guards the benchmark fixture: a window with no challenger behind it does not prune. /// /// `refusal_state` tests challenger presence before expiry, so a window whose `challenger_lock_id` From bc89d631cb03bc2a00ef3e6163edb4de99caaef3 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Fri, 31 Jul 2026 05:12:42 +0200 Subject: [PATCH 7/8] Drop the stale auto-renewal mention from the SubnetImmunityCalls comment The group holds one call. The standing auto-renew it also described was dropped from the design. --- runtime/src/proxy_filters/call_groups.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/runtime/src/proxy_filters/call_groups.rs b/runtime/src/proxy_filters/call_groups.rs index 409942cf7f..d3f80769c9 100644 --- a/runtime/src/proxy_filters/call_groups.rs +++ b/runtime/src/proxy_filters/call_groups.rs @@ -435,12 +435,11 @@ call_filter_group!( [RuntimeCall::SubtensorModule(SubtensorCall::start_call),] ); -// Buying a subnet out of the pruning queue, and authorizing that purchase to happen -// automatically. Both spend the owner's TAO through `recycle_tao`, which destroys it, so they -// belong on the same side of the line as `burned_register` rather than with the rest of the -// subnet-owner calls: `NonFungible` must not reach them because it promises to move no value, -// and `NonCritical` must not because it cannot dissolve a subnet either, so it has no business -// spending to keep one alive. +// Buying a subnet out of the pruning queue. It spends the owner's TAO through `recycle_tao`, +// which destroys it, so it belongs on the same side of the line as `burned_register` rather than +// with the rest of the subnet-owner calls: `NonFungible` must not reach it because it promises to +// move no value, and `NonCritical` must not because it cannot dissolve a subnet either, so it has +// no business spending to keep one alive. call_filter_group!( SubnetImmunityCalls, [RuntimeCall::SubtensorModule( From 78ee0f4a725b76c8bf861b7589cc6920b6dacde4 Mon Sep 17 00:00:00 2001 From: Philip Maymin Date: Fri, 31 Jul 2026 06:05:53 +0200 Subject: [PATCH 8/8] Re-measure register_network weights after the refusal-window scan The pruning scan now reads NetworkImmuneUntil and SubnetRefusalWindow per candidate, so the at-limit path costs 1,172 reads rather than 1,045 and 7,044,918,000 ref_time rather than 5,821,408,000. The dispatches charge the componentwise max of the pair, which moves to 41.24 ms. --- pallets/subtensor/src/weights.rs | 52 ++++++++++++++++---------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/pallets/subtensor/src/weights.rs b/pallets/subtensor/src/weights.rs index 99f386be07..f6eb324368 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -719,8 +719,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `868501` // Estimated: `1188766` - // Minimum execution time: 3_154_777_000 picoseconds. - Weight::from_parts(3_261_104_000, 1188766) + // Minimum execution time: 3_244_383_000 picoseconds. + Weight::from_parts(3_520_204_000, 1188766) .saturating_add(T::DbWeight::get().reads(671_u64)) .saturating_add(T::DbWeight::get().writes(49_u64)) } @@ -754,12 +754,12 @@ impl WeightInfo for SubstrateWeight { /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:128 w:0) /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:128 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) - /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) @@ -784,11 +784,11 @@ impl WeightInfo for SubstrateWeight { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `878562` - // Estimated: `1201302` - // Minimum execution time: 5_461_229_000 picoseconds. - Weight::from_parts(5_821_408_000, 1201302) - .saturating_add(T::DbWeight::get().reads(1045_u64)) + // Measured: `881957` + // Estimated: `1204697` + // Minimum execution time: 6_642_992_000 picoseconds. + Weight::from_parts(7_044_918_000, 1204697) + .saturating_add(T::DbWeight::get().reads(1172_u64)) .saturating_add(T::DbWeight::get().writes(11_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) @@ -1947,8 +1947,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `875174` // Estimated: `878639` - // Minimum execution time: 2_319_516_000 picoseconds. - Weight::from_parts(2_385_148_000, 878639) + // Minimum execution time: 2_355_352_000 picoseconds. + Weight::from_parts(2_404_563_000, 878639) .saturating_add(T::DbWeight::get().reads(9_u64)) .saturating_add(T::DbWeight::get().writes(8_u64)) } @@ -1962,8 +1962,8 @@ impl WeightInfo for SubstrateWeight { // Proof Size summary in bytes: // Measured: `874625` // Estimated: `876110` - // Minimum execution time: 1_746_913_000 picoseconds. - Weight::from_parts(1_914_373_000, 876110) + // Minimum execution time: 1_874_529_000 picoseconds. + Weight::from_parts(1_987_090_000, 876110) .saturating_add(T::DbWeight::get().reads(3_u64)) .saturating_add(T::DbWeight::get().writes(2_u64)) } @@ -4455,8 +4455,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `868501` // Estimated: `1188766` - // Minimum execution time: 3_154_777_000 picoseconds. - Weight::from_parts(3_261_104_000, 1188766) + // Minimum execution time: 3_244_383_000 picoseconds. + Weight::from_parts(3_520_204_000, 1188766) .saturating_add(RocksDbWeight::get().reads(671_u64)) .saturating_add(RocksDbWeight::get().writes(49_u64)) } @@ -4490,12 +4490,12 @@ impl WeightInfo for () { /// Proof: `SubtensorModule::NetworkImmunityPeriod` (`max_values`: Some(1), `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::NetworkImmuneUntil` (r:128 w:0) /// Proof: `SubtensorModule::NetworkImmuneUntil` (`max_values`: None, `max_size`: None, mode: `Measured`) + /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:128 w:1) + /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMechanism` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMechanism` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `SubtensorModule::SubnetMovingPrice` (r:128 w:0) /// Proof: `SubtensorModule::SubnetMovingPrice` (`max_values`: None, `max_size`: None, mode: `Measured`) - /// Storage: `SubtensorModule::SubnetRefusalWindow` (r:1 w:1) - /// Proof: `SubtensorModule::SubnetRefusalWindow` (`max_values`: None, `max_size`: None, mode: `Measured`) /// Storage: `System::Account` (r:1 w:1) /// Proof: `System::Account` (`max_values`: None, `max_size`: Some(104), added: 2579, mode: `MaxEncodedLen`) /// Storage: `Swap::BalancerTaoReservoir` (r:1 w:1) @@ -4520,11 +4520,11 @@ impl WeightInfo for () { /// Proof: `Swap::SwapBalancer` (`max_values`: None, `max_size`: Some(18), added: 2493, mode: `MaxEncodedLen`) fn register_network_pruning() -> Weight { // Proof Size summary in bytes: - // Measured: `878562` - // Estimated: `1201302` - // Minimum execution time: 5_461_229_000 picoseconds. - Weight::from_parts(5_821_408_000, 1201302) - .saturating_add(RocksDbWeight::get().reads(1045_u64)) + // Measured: `881957` + // Estimated: `1204697` + // Minimum execution time: 6_642_992_000 picoseconds. + Weight::from_parts(7_044_918_000, 1204697) + .saturating_add(RocksDbWeight::get().reads(1172_u64)) .saturating_add(RocksDbWeight::get().writes(11_u64)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) @@ -5683,8 +5683,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `875174` // Estimated: `878639` - // Minimum execution time: 2_319_516_000 picoseconds. - Weight::from_parts(2_385_148_000, 878639) + // Minimum execution time: 2_355_352_000 picoseconds. + Weight::from_parts(2_404_563_000, 878639) .saturating_add(RocksDbWeight::get().reads(9_u64)) .saturating_add(RocksDbWeight::get().writes(8_u64)) } @@ -5698,8 +5698,8 @@ impl WeightInfo for () { // Proof Size summary in bytes: // Measured: `874625` // Estimated: `876110` - // Minimum execution time: 1_746_913_000 picoseconds. - Weight::from_parts(1_914_373_000, 876110) + // Minimum execution time: 1_874_529_000 picoseconds. + Weight::from_parts(1_987_090_000, 876110) .saturating_add(RocksDbWeight::get().reads(3_u64)) .saturating_add(RocksDbWeight::get().writes(2_u64)) }