Skip to content

Subnet owner right of first refusal over eviction - #3023

Open
philipmaymin wants to merge 8 commits into
RaoFoundation:mainfrom
philipmaymin:feat/subnet-owner-right-of-first-refusal
Open

Subnet owner right of first refusal over eviction#3023
philipmaymin wants to merge 8 commits into
RaoFoundation:mainfrom
philipmaymin:feat/subnet-owner-right-of-first-refusal

Conversation

@philipmaymin

@philipmaymin philipmaymin commented Jul 31, 2026

Copy link
Copy Markdown

At the subnet limit, register_network dissolves 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_refusal and cancel_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_stakes cashes 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_limit is 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 call exercise_first_refusal(netuid) and pay that offer.

Pay, and the TAO is recycled and the subnet gets NetworkImmunityPeriod of immunity, currently 864,000 blocks or 120 days. NetworkLastLockCost moves 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, then process_network_registration_queue hands 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 leave NetworkRegistrationQueue before: two writers, and unlock_network_registration_cost has 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, and process_network_registration_queue can 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_cost to 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_WINDOW matches SubtensorInitialNetworkRateLimit at 7,200 blocks, and finney has NetworkRateLimit set 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

  1. Queue growth. A challenge appends to NetworkRegistrationQueue with 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_prune passes 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_registration still 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.

  2. Stale prices dragging the market down. This one would wreck the argument for the whole PR. get_network_lock_cost decays 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.

  3. Paying for immunity against a challenger who already left. A window holding only (offer, expires_at) outlives the registration behind it, so it stores challenger_lock_id and 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.

  4. Paying for immunity and getting none. NetworkImmunityPeriod is 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.

  5. How long an entry sits in the queue, which I make much longer. process_network_registration_queue runs in on_idle and retries every queued entry each block. Its Err(_) arm logs and continues with no weight.saturating_accrue, so a failed attempt is uncharged even though it ran set_new_network_state far enough to walk NetworksAdded. 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 full FIRST_REFUSAL_WINDOW of 7,200 blocks, each one an uncharged full-map scan per queued entry plus a log::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.

  6. A registration freeze, and what it costs now. An earlier version of this refused the second registration with SubnetChallengeInProgress instead 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 SubnetChallengeInProgress arm 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 unconditional NetworksAdded::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.

ref_time proof_size reads writes charged
below the cap, #3022 3,505,837,000 327,961 671 49 25.18 ms
below the cap, this PR 3,520,204,000 1,188,766 671 49 25.20 ms
at the limit, #3022 4,090,472,000 333,284 916 10 27.99 ms
at the limit, this PR 7,044,918,000 1,204,697 1,172 11 37.44 ms

Read counts move the way you'd expect: at the limit it's 256 more, one NetworkImmuneUntil and one SubnetRefusalWindow per 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_refusal is 2,404,563,000 ref_time, 878,639 proof, 9 reads, 8 writes, 3.43 ms. cancel_network_registration is 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_cleanup path: registrations queue behind dissolutions already in flight, and DissolveCleanupQueue has 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 why register_network reads 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 NetworkRegistrationInfo is a freeze_struct inside a Vec, 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_network I couldn't benchmark: it asserts SubnetMechanism::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_network and register_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_network calls find_lease_netuid immediately after registering, that scans SubnetOwner for the synthetic lease coldkey, and a queued registration has not written one, so the call returns Err(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 an Err carries no actual_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 production MaxContributors of 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.rs and drive everything through real registrations instead of calling internals directly. register_network_prune_registers_registration_queued changes 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_cycles runs 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_one is 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

  • The generated SDK layer is regenerated for the new calls, maps and errors. That drags in TooManyRootClaimHotkeys, declared in the runtime since Fix claim root weight accounting #2985 but never added to the committed catalog. --names passes 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_refusal emits NetworkRegistrationCancelled for the refunded challenger before SubnetFirstRefusalExercised. Reusing the existing event instead of adding one keeps anything watching NetworkRegistrationQueue correct 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.
  • New errors go at the end of the enum instead of beside related ones. FRAME encodes by position, so inserting in the middle renumbers everything after. Same reasoning for events.
  • cancel_network_registration sits in SubtensorCommonCalls next to register_network, since it moves no value out of the caller's account. Both new calls are raw-only in the SDK like the other register_network variants, and I'll wrap them as intents if you'd rather.
  • remove_network_parameters charged a flat writes(82) and was already wrong: the list is 88 unconditional removals plus a conditional SubnetIdentitiesV3 read and removal. I added two entries, so the number had to move anyway, and set it to reads_writes(1, 91) instead of 84.
  • Leased subnets can't use this. SubnetOwner is the derived lease coldkey and nobody can sign for it. Putting the call in SubnetLeaseAllowed would 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 through do_terminate_lease, which requires end_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.
  • Regenerate the SDK against a node built with --features runtime-benchmarks. The Benchmark runtime API is feature-gated and a plain --release node drops it from _generated/runtime_apis.py without saying so.
  • spec_version 440 to 441, no migration. Both new maps default to pre-upgrade behavior and nothing moves index, so transaction_version stays 1. That 441 assumes this ships in the same release as Benchmark register_network against a full subnet map #3022, which also declares 441. If Benchmark register_network against a full subnet map #3022 goes out first on its own, this needs 442 and the pinned Spec version: header in the five sdk/python/bittensor/_generated/*.py files moves with it. Tell me which release you want it in and I'll set the number to match.
  • Two gates are red on the base as well, and I checked rather than assumed: codegen.check --coverage and four proxy_filters tests, all from the same two missing calls (AdminUtils::sudo_set_emission_bar_quantile, sudo_set_emission_gate_exponent). Mine are classified and covered.
  • Website docs aren't regenerated. generate.py --check is 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.
  • All of this was benchmarked and tested locally. CI gates on 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)

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

Breaking Change

No storage migration and no call-index change, so transaction_version stays 1. What changes is behavior at the cap: register_network no 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 to FIRST_REFUSAL_WINDOW later, and gets it through process_network_registration_queue rather 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

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Documentation is unchecked on purpose: generate.py --check is 98 files stale on main, 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 from scripts/benchmark_action.sh. Treat them as mine until they run on yours.

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.
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown

@philipmaymin is attempting to deploy a commit to the RaoFoundation Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Give a subnet owner the right to match the registration that would evict it

1 participant