diff --git a/pallets/subtensor/src/benchmarks/benchmarks.rs b/pallets/subtensor/src/benchmarks/benchmarks.rs index 375bdb61ea..49a80fb1a0 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::*; @@ -301,10 +302,50 @@ 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 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; + 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] + { + assert_ok!(Subtensor::::do_register_network( + RawOrigin::Signed(coldkey.clone()).into(), + &hotkey, + 1, + Some(max_subnet_identity()), + )); + } + + // 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] fn commit_weights() { let tempo: u16 = 1; @@ -1449,13 +1490,16 @@ 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); 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()), @@ -1900,6 +1944,112 @@ mod pallet_benchmarks { assert_eq!(TokenSymbol::::get(netuid), new_symbol); } + /// 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(); + 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, + }, + ); + + // 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 + /// 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 2f45e98e80..31e0af7393 100644 --- a/pallets/subtensor/src/benchmarks/helpers.rs +++ b/pallets/subtensor/src/benchmarks/helpers.rs @@ -15,6 +15,147 @@ 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: LAPSED_CHALLENGER_LOCK_ID, + 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`; +/// 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. 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)), + ); + SubnetRefusalWindow::::insert(netuid, lapsed_refusal_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)), + 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)); +} + +/// 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/coinbase/root.rs b/pallets/subtensor/src/coinbase/root.rs index 7aeb0430b2..fb63b2255d 100644 --- a/pallets/subtensor/src/coinbase/root.rs +++ b/pallets/subtensor/src/coinbase/root.rs @@ -296,8 +296,17 @@ 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(); + let queue = NetworkRegistrationQueue::::get(); let mut candidate_netuid: Option = None; let mut candidate_price: U64F64 = U64F64::saturating_from_num(u128::MAX); @@ -311,7 +320,16 @@ 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; + } + + // 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; } 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 4c190a7497..f6ed26d558 100644 --- a/pallets/subtensor/src/macros/dispatches.rs +++ b/pallets/subtensor/src/macros/dispatches.rs @@ -1001,8 +1001,13 @@ mod dispatches { } /// 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. #[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 +1186,11 @@ mod dispatches { } /// User register a new subnetwork + /// + /// Same 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, @@ -1671,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, @@ -2508,5 +2525,77 @@ 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. + /// + /// 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. + /// + /// # 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 `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 { + 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. + /// + /// 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`. + /// + /// 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..5a26fe86a0 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,40 @@ 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); + } + // 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. + // + // 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()); + } + // 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 +282,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 +347,184 @@ 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. + let challenger = NetworkRegistrationQueue::::get() + .into_iter() + .find(|entry| entry.lock_id == window.challenger_lock_id) + .ok_or(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())?; + + // 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, + 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::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, + 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. + // + // 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, + 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) + } + + /// 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. + /// + /// 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 +589,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..9674ff432a 100644 --- a/pallets/subtensor/src/tests/networks.rs +++ b/pallets/subtensor/src/tests/networks.rs @@ -3,8 +3,11 @@ 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_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 +1596,741 @@ 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 offer = SubnetRefusalWindow::::get(low).unwrap().offer; + 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 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, offer.into()); + assert_eq!( + issuance_before.saturating_sub(TotalIssuance::::get()), + 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)); + 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 + ); + }); +} + +/// A challenge on one subnet does not reprice a window already open on another. +/// +/// 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. +/// +/// 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_challenge_elsewhere_does_not_reprice_an_open_window() { + new_test_ext(0).execute_with(|| { + 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(); + + // 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)); + + // 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); + + // 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()); + }); +} + +/// 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. 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() { + 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!(NetworkRegistrationQueue::::get().is_empty()); + + // 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!(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); + assert_noop!( + register_from(registrant, 4097), + Error::::SubnetLimitReached + ); + assert!(NetworkRegistrationQueue::::get().is_empty()); + }); +} + +/// 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); + + // 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. + let market = offer.saturating_mul(4.into()); + SubtensorModule::set_network_last_lock(market); + + let queued = NetworkRegistrationQueue::::get(); + let entry = queued + .first() + .expect("the second challenger is 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 +4246,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)); }); @@ -3861,3 +4615,205 @@ 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)); + } + }); +} + +/// 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` +/// 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 63bd54336a..f6eb324368 100644 --- a/pallets/subtensor/src/weights.rs +++ b/pallets/subtensor/src/weights.rs @@ -43,7 +43,9 @@ 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; fn reveal_weights() -> Weight; fn sudo_set_tx_childkey_take_rate_limit() -> Weight; @@ -68,6 +70,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; @@ -594,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) @@ -604,7 +609,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,13 +625,13 @@ 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) + /// 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`) @@ -704,18 +709,87 @@ 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: `868501` + // Estimated: `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)) + } + /// 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::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: `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::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: `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: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`) + /// 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: `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: `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) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -1847,6 +1921,52 @@ 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::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: `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::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: `875174` + // Estimated: `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)) + } + /// 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_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)) + } /// 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) @@ -2050,7 +2170,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,13 +2184,13 @@ 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) + /// 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`) @@ -2144,24 +2264,28 @@ 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) /// 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: `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)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -2576,6 +2700,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) @@ -2585,13 +2711,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())) } @@ -4208,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) @@ -4218,7 +4345,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,13 +4361,13 @@ 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) + /// 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`) @@ -4318,18 +4445,87 @@ 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: `868501` + // Estimated: `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)) + } + /// 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::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: `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::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: `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: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`) + /// 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: `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: `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) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -5461,6 +5657,52 @@ 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::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: `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::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: `875174` + // Estimated: `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)) + } + /// 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_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)) + } /// 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) @@ -5664,7 +5906,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,13 +5920,13 @@ 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) + /// 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`) @@ -5758,24 +6000,28 @@ 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) /// 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: `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)) } /// Storage: `SubtensorModule::NetworksAdded` (r:1 w:0) /// Proof: `SubtensorModule::NetworksAdded` (`max_values`: None, `max_size`: None, mode: `Measured`) @@ -6190,6 +6436,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) @@ -6199,13 +6447,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/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, diff --git a/runtime/src/proxy_filters/call_groups.rs b/runtime/src/proxy_filters/call_groups.rs index 213b9be332..d3f80769c9 100644 --- a/runtime/src/proxy_filters/call_groups.rs +++ b/runtime/src/proxy_filters/call_groups.rs @@ -435,6 +435,18 @@ call_filter_group!( [RuntimeCall::SubtensorModule(SubtensorCall::start_call),] ); +// 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( + 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 +475,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 +703,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",