Subnet owner right of first refusal over eviction - #3023
Open
philipmaymin wants to merge 8 commits into
Open
Conversation
register_network's benchmark registers into a chain with no subnets on it, so neither of the two costs that scale with subnet count lands in the measurement: the unconditional NetworksAdded::iter() count, and the get_network_to_prune scan that runs once the count reaches SubnetLimit. Fill the map before measuring, and add register_network_pruning for the branch that was never measured at all. The two outcomes are mutually exclusive and neither dominates: creation costs more ref_time, pruning more proof_size. Both dispatches charge the componentwise max. register_network measures 293 reads and 14.39 ms of charged weight against the 41 reads and 5.98 ms it currently declares.
fill_subnets created stable subnets with empty pools, so both price scans over NetworksAdded exited early on every entry. get_moving_alpha_price returns on SubnetMechanism == 0 without reading SubnetMovingPrice, and swap current_price returns before reading the TAO reserve or the balancer unless the alpha reserve is nonzero. The map was walked while nothing on it was priced. Every synthetic subnet now gets a dynamic mechanism and a funded pool. Reserves and moving prices are staggered per subnet so the median's BTreeMap holds distinct keys instead of collapsing to a single entry. register_network_with_identity and register_network_pruning now carry a maximum 6,656 byte identity. Creation writes it to storage; queueing encodes it into the queue entry and again into the event. Passing None measured neither, which is why main charges register_network_with_identity less than register_network on every dimension despite it doing strictly more work. register_network 293 -> 671 reads, register_network_pruning 408 -> 916, register_network_with_identity 292 -> 670. do_register_network has three successful outcomes, not two. The third, wait_to_cleanup, reaches the same queueing block without running get_network_to_prune or do_dissolve_network, so at equal queue depth it is dominated by the pruning measurement. Comments corrected to say so.
The weight constants in pallets/subtensor live in the runtime wasm, so the corrected register_network measurement only reaches a chain through a runtime release. Mainnet is on 440.
At the subnet limit a registration evicts the lowest-priced subnet outright, so an owner can lose a slot to a buyer they would happily have outbid. This gives that owner a window to keep it by matching what the challenger locked, and nothing else. The offer is recorded when the window opens, so answering can never cost more than a buyer just signed a transaction to pay, and the owner never names a figure of their own. Reaching the limit now queues the registration and opens a window on the eviction candidate rather than dissolving it. If the owner answers within FIRST_REFUSAL_WINDOW the payment is recycled and the subnet takes NetworkImmunityPeriod of immunity. If the window lapses, the next registration prunes the candidate exactly as before. The window carries its challenger's lock id, so it expires with the registration that created it: a window whose challenger has already been served from some other slot cannot be answered, and the offer cannot be burned for immunity nobody was contesting. It also snapshots the immunity period it was opened against, so lowering that parameter mid-window cannot turn a paid answer into nothing. A registration arriving against a subnet that already has an open window is refused rather than queued behind the first, which holds a subnet to a single queued challenger. The queue is bounded overall by the eviction pool: an answered challenge makes that subnet immune, a lapsed one prunes it and the replacement is immune by registration age, and once nothing is evictable registration returns SubnetLimitReached instead of queueing. Both paths that settle a stale figure clamp the price accumulator upward. A queued registration and an answered challenge each carry a price quoted long before they settle, and neither may drag the accumulator back down to it: a refusal exercised in the meantime raised the price deliberately. Answering clears the window and leaves the queue alone. The challenger's registration asked for a slot, not for that particular subnet, so matching the offer ends the challenge rather than the registration, and they keep their place in line at the price they locked. What the owner buys is their own subnet's safety, not the right to cancel a stranger's transaction. That leaves a queued registrant with no way out, which cost little before: NetworkRegistrationQueue has two writers and unlock_network_registration_cost has one caller, the serve path, and every push was paid for by a teardown already in flight. A challenge appends with nothing being torn down. cancel_network_registration lets whoever submitted a queued registration withdraw it and take the lock back, so the challenger chooses in both branches and registrants who never challenged anyone get the same way out. Withdrawing retracts the challenge without evicting the subnet it was aimed at: a window whose challenger has gone reads as unchallenged, not lapsed. Adds exercise_first_refusal and cancel_network_registration, the SubnetRefusalWindow and NetworkImmuneUntil storage maps, and benchmarks covering the challenge, the answer and the withdrawal. Regenerates the Python SDK's generated layer for the new calls, storage maps and errors. Regenerating sweeps in TooManyRootClaimHotkeys, which the runtime has declared since RaoFoundation#2985 but the committed catalog never carried, so it is classified here to keep codegen.check --names green.
Matching an offer now removes the challenger's entry from NetworkRegistrationQueue and releases its lock, emitting NetworkRegistrationCancelled before SubnetFirstRefusalExercised. Leaving the entry queued made queue depth a function of elapsed time. The immunity an owner buys lapses, the subnet returns to the eviction pool and can be challenged again, and every earlier challenger stays on a list that each registration decodes. Fix the register_network_pruning fixture, which stamped a window whose challenger_lock_id was absent from an empty queue. refusal_state tests challenger presence before expiry, so the window resolved to Unchallenged and the benchmark measured the window branch instead of the prune branch it exists for. fill_subnets now queues the matching entry, and the benchmark asserts a started teardown rather than a non-empty queue, which held on either branch. Correct the bound stated in comments and tests: get_network_to_prune names one candidate and a live window there refuses rather than falling through, so at most one window is open chain-wide, not one per subnet. Re-measure register_network, register_network_pruning, exercise_first_refusal and cancel_network_registration.
…stration An open window used to make do_register_network return SubnetChallengeInProgress, which held up every registration on the chain until the window closed. Because do_cancel_network_registration refunds in full, one challenger could recycle a single lock to keep registrations frozen indefinitely at no protocol cost. get_network_to_prune now passes over a subnet whose window is still open with its challenger still waiting, and falls through to the next candidate. Freezing registrations therefore costs one full lock per evictable subnet held at once, each of which the targeted owner can end by answering. Adds refusal_window_is_live, a side-effect-free predicate, because the pruning scan and the read-only runtime API cannot write, unlike refusal_state which clears a window whose challenger has left. The RefusalState::Live arm is now unreachable by construction and kept as a guard. Tests: an_unanswered_cycle_queues_two_and_frees_one records the queue growth this trades for, and a_live_window_diverts_the_next_registration_instead_of_blocking_it covers the fall-through. a_second_challenge_inside_the_window_is_refused_rather_ than_queued pinned the removed behaviour and is replaced by a_challenge_elsewhere_does_not_reprice_an_open_window.
The group holds one call. The standing auto-renew it also described was dropped from the design.
The pruning scan now reads NetworkImmuneUntil and SubnetRefusalWindow per candidate, so the at-limit path costs 1,172 reads rather than 1,045 and 7,044,918,000 ref_time rather than 5,821,408,000. The dispatches charge the componentwise max of the pair, which moves to 41.24 ms.
|
@philipmaymin is attempting to deploy a commit to the RaoFoundation Team on Vercel. A member of the Team first needs to authorize it. |
This was referenced Jul 31, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
At the subnet limit,
register_networkdissolves the cheapest subnet inside the caller's transaction. This puts a step in front of that: the registration is recorded as a priced offer and parked, and the owner gets about a day to match it and keep the slot. It adds two extrinsics,exercise_first_refusalandcancel_network_registration, with no new governance parameters, no bidding, and owner-only access.Stacked on #3022. The diff carries its commits, and the weights below are measured against it rather than main.
The problem
All 128 slots are full, and the v440 notes say registration cost should fall toward the transaction fee. Put those together and registering a subnet means taking one from somebody.
The owner finds out afterward. Every miner and validator on that subnet is deregistered,
destroy_alpha_in_out_stakescashes out every alpha holder near the worst price the subnet ever had (a low price is why it got picked), and the netuid goes, so every explorer listing, dashboard and validator config downstream migrates.An owner may value all that far above the current lock cost. It makes no difference, because there's no call they can make: they can't outbid the newcomer and they can't pay to stay.
Raising the cap only moves the date.
sudo_set_subnet_limitis root, so 128 could be 256 tomorrow, and the day 256 fills the owner is back here, because the eviction rule is what's broken.Issue #1651, which specified this deregistration design, lists "maintain root-only access to direct calls for now" among its goals. That "for now" reads like unfinished work rather than a settled decision, which is why I'm sending this.
What I changed
At the limit a registration no longer evicts anyone. It records what the registrant locked as an offer against the eviction candidate, parks the registration in
NetworkRegistrationQueue, and stops. The owner then has 7,200 blocks, about a day, to callexercise_first_refusal(netuid)and pay that offer.Pay, and the TAO is recycled and the subnet gets
NetworkImmunityPeriodof immunity, currently 864,000 blocks or 120 days.NetworkLastLockCostmoves up exactly as if the registration had gone through, so the next slot costs double.Don't, and nothing happens at the deadline. No timer fires. The window sits expired until the next registration hits the cap, notices, and prunes the subnet in its own transaction. Teardown runs over several blocks in
on_idle, thenprocess_network_registration_queuehands the slot to the head of the queue, which is the original challenger rather than whoever triggered the prune.The owner pays the recorded offer and never a number they pick themselves, so they can't overpay (somebody just signed a transaction at that price) and they can't lowball (paying moves the price ladder the same way a completed registration does, and fighting off repeated challengers gets expensive fast). A window is only live while its challenger is still queued, so an owner is never charged for immunity against a threat that already left.
Matching refunds the challenger and takes them out of the queue. Their offer was refused, which is all a right of first refusal decides, so they don't keep a place in line at a price that was just beaten. This is also what bounds the queue, and it took me two passes to get right. The tempting version leaves them queued, on the reasoning that their registration asked for a slot rather than for that particular subnet. The immunity the owner bought then lapses after 120 days, the subnet goes back in the eviction pool and can be challenged again, and every earlier challenger is still sitting on a list that each registration decodes. Queue depth ends up a function of elapsed time. Test:
matched_challengers_do_not_accumulate_across_immunity_cycles.cancel_network_registration(lock_id)is the second call. Nothing could leaveNetworkRegistrationQueuebefore: two writers, andunlock_network_registration_costhas a single caller on the serve path. That was fine when every queued entry had a teardown already running to drain it. A challenge doesn't. Expiry here is lazy, so a window that lapses is acted on by the next registration to reach the limit, andprocess_network_registration_queuecan only serve an entry once a slot is genuinely free. If no further registration ever arrives, a challenger waits with TAO locked and nothing block-driven releases it. That's the case this call covers. Withdrawing evicts nobody: a window whose challenger has left reads as unchallenged, and the next registration opens a fresh one at its own price.Why not a standing flag
The simpler version: the owner sets "charge me if anyone challenges me," and the challenger's transaction collects. No deadline, no second call, much less code. That's the version I wrote first, and it doesn't work.
Collecting inside the challenger's transaction means the chain has to know the charge will clear, that depends on the owner's balance, and balances are public. The flag becomes a published price for making a specific owner spend money. Watch a flagged owner's balance, wait for
get_network_lock_costto decay past it, then register. They pay, and you never wanted the slot at all. A window puts the decision after the offer instead, which is where a right of first refusal belongs: nothing about the owner sits on chain beforehand, so there's no number to walk down to, and they can fund during the window rather than holding TAO against a challenge that may never come.The cost is a day of delay on uncontested slot changes.
FIRST_REFUSAL_WINDOWmatchesSubtensorInitialNetworkRateLimitat 7,200 blocks, and finney hasNetworkRateLimitset to 14,400, so a chain at the cap is already taking at most one registration per two windows. The window is not the binding constraint on how fast slots can turn over. Expiry is lazy, so nothing needs scheduling.What I'd poke at if I were reviewing
Queue growth. A challenge appends to
NetworkRegistrationQueuewith no teardown paying for it, and that queue is unbounded and decoded by every registration on the chain, so this is the part I'd read hardest.get_network_to_prunepasses over a subnet whose window is still open with its challenger still waiting, so the scan names the next candidate instead. One challenge holds one queue entry, and the ceiling is one per evictable subnet rather than one chain-wide. Answering removes that entry and refunds it.Lapsing does not, and I had this wrong until I measured it. A lapsed window is only cleared by the next registration to arrive, and that registration is the one that prunes and then takes a place in the queue behind the challenger who opened the window. So an unanswered cycle puts two entries in and frees one slot: depth is 2 after the prune and 1 after the drain. Nobody is stranded, entries are served in order and
cancel_network_registrationstill works, but depth climbs by one per unanswered cycle, and every registration on the chain decodes that queue. The bound holds for the answered path only.Closing it means handing a lapsed window's challenger the slot it was queued for without waiting for a second registration to trigger the prune. That is more machinery than I think this change should carry, so I have stated the cost instead of hiding it. Tests:
challenges_cannot_grow_the_queue,matched_challengers_do_not_accumulate_across_immunity_cycles,an_unanswered_cycle_queues_two_and_frees_one,a_live_window_diverts_the_next_registration_instead_of_blocking_it.Worth naming separately: owners paying can now push the network into a no-slots-free state, where before only registration immunity could. Every answered challenge takes a subnet out of the eviction pool for 120 days, and once nothing is evictable, registration returns
SubnetLimitReached. It's expensive, you can't buy immunity on demand (only answer a challenge that arrived), and challenges only ever land on the single cheapest subnet.Stale prices dragging the market down. This one would wreck the argument for the whole PR.
get_network_lock_costdecays from twice the last lock, so a figure quoted an hour ago is usually below the current one. Both settlement paths clamp upward: you're charged what you were quoted, and your stale number can't pull the accumulator down with it. For answering a challenge that's the normal case, since a window runs a full 7,200 blocks. Tests:serving_an_old_queued_registration_does_not_lower_the_lock_cost,answering_an_old_challenge_does_not_lower_the_lock_cost.Paying for immunity against a challenger who already left. A window holding only
(offer, expires_at)outlives the registration behind it, so it storeschallenger_lock_idand exercise checks that lock is still queued. Check order matters: testing expiry first would evict a subnet for refusing an offer that had already been withdrawn. Tests:a_window_whose_challenger_left_the_queue_cannot_be_answered,a_lapsed_window_whose_challenger_left_does_not_evict,withdrawing_a_challenge_leaves_the_subnet_standing.Paying for immunity and getting none.
NetworkImmunityPeriodis governance-set and can move while a window is open. The window records the period it opened under, and exercise uses that. Test:shortening_the_immunity_period_mid_window_does_not_shrink_what_was_bought.How long an entry sits in the queue, which I make much longer.
process_network_registration_queueruns inon_idleand retries every queued entry each block. ItsErr(_)arm logs andcontinues with noweight.saturating_accrue, so a failed attempt is uncharged even though it ranset_new_network_statefar enough to walkNetworksAdded. That arm is on main and I haven't touched it; what I change is how often it's taken. A queued entry used to have a teardown already running behind it and drained within a few blocks. A challenge has no teardown, so an entry can sit for a fullFIRST_REFUSAL_WINDOWof 7,200 blocks, each one an uncharged full-map scan per queued entry plus alog::error!. Charging the failed attempt and breaking on a weight limit is a handful of lines, but it changes base behavior in a file this PR otherwise only reads. Separate PR or bundled here, your call.A registration freeze, and what it costs now. An earlier version of this refused the second registration with
SubnetChallengeInProgressinstead of moving on, which kept one window open chain-wide and made the queue easy to reason about. It also meant one window blocked every at-cap registration rather than just the subnet it named, and since cancelling refunds in full, one lock could be recycled through that position indefinitely. Measured on a test: three consecutive lockout rounds, cancelling before each expiry, cost the attacker nothing but transaction fees.The scan now skips a live window instead of refusing, which prices the attack properly. Freezing registrations takes a live window on every evictable subnet at once, each backed by its own full lock held simultaneously, and each one the targeted owner can end by answering. That trade costs a wider queue, bounded by the same evictable pool, and closes a chain-availability hole that one lock could hold open. The
SubnetChallengeInProgressarm is unreachable now and kept only as a guard; tell me if you want it and the error deleted.Weights
register_network's benchmark on main registers into an empty chain. It never hits the limit, so its weight misses both the unconditionalNetworksAdded::iter()and the prune scan behind it. That's a main bug with nothing to do with this work, and it's up as #3022. The numbers here are against #3022.Read counts move the way you'd expect: at the limit it's 256 more, one
NetworkImmuneUntiland oneSubnetRefusalWindowper subnet in the prune scan, both short-circuiting for anything still inside registration immunity. Under the cap the prune scan doesn't run, so ref_time lands within measurement noise of #3022. The two outcomes can't both happen and neither is strictly worse, so both dispatches charge the componentwise max: 7,044,918,000 ref_time, 1,204,697 proof, 1,172 reads, 49 writes, 41.24 ms.exercise_first_refusalis 2,404,563,000 ref_time, 878,639 proof, 9 reads, 8 writes, 3.43 ms.cancel_network_registrationis 1,987,090,000 ref_time, 876,110 proof, 3 reads, 2 writes, 2.26 ms. Both benchmarked with the queue at one entry per subnet slot and the target entry last.That queue depth is deliberately not the challenge path, which holds one entry per outstanding challenge and gains one per unanswered cycle. It's the
wait_to_cleanuppath: registrations queue behind dissolutions already in flight, andDissolveCleanupQueuehas no bound of its own, so one entry per slot is the closest thing to a defensible worst case available. If you'd rather these were measured against a depth the challenge path alone can reach, that's a one-line fixture change and the proof sizes collapse. I'd rather over-declare here, but it's your call which number goes in the runtime.The proof sizes are the honest part and I don't like them. Every one of these decodes
NetworkRegistrationQueue, and worst case every entry carries a 6,656-byte identity. That is whyregister_networkreads 3.6x the proof it does on #3022 while spending less ref_time: #3022's fixture leaves the queue empty and this one fills it. The difference is the finding, not a disagreement about fixtures. On main a queued entry always has a teardown in flight behind it, so depth is bounded by concurrent dissolutions; a challenge has no teardown, so deeper queues become reachable and the decode has to be measured against one.The fix is to put the challenger's target netuid in the queue entry and drop the scan, but
NetworkRegistrationInfois afreeze_structinside aVec, so changing it re-encodes entries already in storage. Without a migration they'd decode to nothing and queued registrants would lose their place with their TAO still locked. Happy to write that migration. These numbers are the case for it.register_leased_networkI couldn't benchmark: it assertsSubnetMechanism::contains_key(lease.netuid)after the call, and a registration that queues or opens a window never completes. Its dispatch takes the max over its own measurement,register_networkandregister_network_pruning, so the scan is paid for.I had left an open question here about what happens when a lease's registration gets queued. It is answered, so here it is: nothing needs to prevent it, because the lease cannot survive it.
do_register_leased_networkcallsfind_lease_netuidimmediately after registering, that scansSubnetOwnerfor the synthetic lease coldkey, and a queued registration has not written one, so the call returnsErr(LeaseNetuidNotFound)and the whole extrinsic reverts. That also means the post-dispatch refund at the end of the lease path is unreachable on any branch that ran the prune scan, and anErrcarries noactual_weight, so the full declared weight is charged there. The.max()is the right annotation but it is not what protects the scan; under the productionMaxContributorsof 500 it is inert, since the lease's own measurement already dominates both dimensions.Tests
1,361 pass and 0 fail, against 1,332 on the base. The 29 new ones are all in
src/tests/networks.rsand drive everything through real registrations instead of calling internals directly.register_network_prune_registers_registration_queuedchanges on purpose and now checks window, then lapse, then prune.Two are worth reading first if you only read two.
matched_challengers_do_not_accumulate_across_immunity_cyclesruns four full challenge-answer-lapse cycles on a two-subnet chain and asserts the queue is empty at each one, which is the property the refund exists to hold.an_unanswered_cycle_queues_two_and_frees_oneis the counterpart and the one I would read hardest: it measures the case the refund does not cover, and it is where the honest bound comes from.Things you'll notice in the diff
TooManyRootClaimHotkeys, declared in the runtime since Fix claim root weight accounting #2985 but never added to the committed catalog.--namespasses on the base only because both sides are missing it. I classified it here in five lines and will split it out if you'd rather.exercise_first_refusalemitsNetworkRegistrationCancelledfor the refunded challenger beforeSubnetFirstRefusalExercised. Reusing the existing event instead of adding one keeps anything watchingNetworkRegistrationQueuecorrect without a second thing to subscribe to, since the entry really does leave the queue with its lock released. Tell me if you want a distinct event.cancel_network_registrationsits inSubtensorCommonCallsnext toregister_network, since it moves no value out of the caller's account. Both new calls are raw-only in the SDK like the otherregister_networkvariants, and I'll wrap them as intents if you'd rather.remove_network_parameterscharged a flatwrites(82)and was already wrong: the list is 88 unconditional removals plus a conditionalSubnetIdentitiesV3read and removal. I added two entries, so the number had to move anyway, and set it toreads_writes(1, 91)instead of 84.SubnetOwneris the derived lease coldkey and nobody can sign for it. Putting the call inSubnetLeaseAllowedwould let a beneficiary burn crowdloan money, so that's your call. One consequence I want on the record because it is sharper than it first looks: ownership only leaves that synthetic coldkey throughdo_terminate_lease, which requiresend_block. A perpetual lease has none, so it can never terminate, which means a perpetually leased subnet can never answer a challenge and loses its slot on every lapse. That follows from the lease design rather than from this PR, but this PR is what makes it cost something.--features runtime-benchmarks. TheBenchmarkruntime API is feature-gated and a plain--releasenode drops it from_generated/runtime_apis.pywithout saying so.spec_version440 to 441, no migration. Both new maps default to pre-upgrade behavior and nothing moves index, sotransaction_versionstays 1. That 441 assumes this ships in the same release as Benchmarkregister_networkagainst a full subnet map #3022, which also declares 441. If Benchmarkregister_networkagainst a full subnet map #3022 goes out first on its own, this needs 442 and the pinnedSpec version:header in the fivesdk/python/bittensor/_generated/*.pyfiles moves with it. Tell me which release you want it in and I'll set the number to match.codegen.check --coverageand fourproxy_filterstests, all from the same two missing calls (AdminUtils::sudo_set_emission_bar_quantile,sudo_set_emission_gate_exponent). Mine are classified and covered.generate.py --checkis already 98 files stale on main and rerunning it is mostly line-number churn. I added three error names, so it's stale by three more. Tell me and I'll add just those.head.repo.fork == false, so none of it runs on a fork PR. Treat the numbers as mine until they run on yours.Related Issue(s)
register_networkagainst a full subnet map #3022, which is the branch this one is stacked onType of Change
Breaking Change
No storage migration and no call-index change, so
transaction_versionstays 1. What changes is behavior at the cap:register_networkno longer dissolves a subnet inside the caller's transaction. It opens a window, queues the registration, and returns. Anything downstream that reads a successful at-limit registration as "a subnet was just dissolved and mine was created" has to stop doing that. The registrant still gets the slot on the same terms, up toFIRST_REFUSAL_WINDOWlater, and gets it throughprocess_network_registration_queuerather than inline.Declared weight also rises, for the reasons in the Weights section. That part is #3022's change, not this one's, and this PR adds 256 reads on top of it.
Checklist
./scripts/fix_rust.shto ensure my code is formatted and linted correctlyDocumentation is unchecked on purpose:
generate.py --checkis 98 files stale onmain, so I did not regenerate it for the three error names I added. Dependent changes is unchecked because #3022 has not merged.Additional Notes
CI gates the benchmark runner on
head.repo.fork == false, so nothing here runs on a fork PR. Every number was measured locally with the flags fromscripts/benchmark_action.sh. Treat them as mine until they run on yours.