From d4c86073c9391cd5fa58e5151136e2b4d4eb121b Mon Sep 17 00:00:00 2001 From: Kordian Kowalski Date: Thu, 25 Jun 2026 16:44:22 +0000 Subject: [PATCH] fix: `localnet scaffold` failing end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Netuid detection** — read from the `NetworkAdded` event instead of a before/after subnet-set diff, whose "before" came from a disk cache that survives localnet restarts (stale set → empty diff → abort). - **Admin freeze window** — zero `AdminFreezeWindow` up front; otherwise the first hyperparameter call dies with `AdminActionProhibitedDuringWeightsWindow` (exit 13). - **Dead hyperparameter** — drop `max_weight_limit`; removed from the runtime entirely (subtensor#2141), so its set call was a no-op everywhere and unused. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/scaffold.toml | 1 - python/agcli-py/src/extrinsics.rs | 2 +- src/admin.rs | 19 ++++++++ src/chain/extrinsics.rs | 30 +++++++++++-- src/chain/mod.rs | 24 +++++++++-- src/cli/subnet_cmds.rs | 10 ++--- src/scaffold.rs | 72 +++++++------------------------ tests/e2e_modules/cases_part01.rs | 2 +- tests/e2e_modules/cases_part03.rs | 2 +- tests/e2e_modules/harness.rs | 13 +++--- tests/localnet_e2e_test.rs | 1 - tests/scaffold_config_test.rs | 2 - tests/user_flows_e2e.rs | 17 +++----- 13 files changed, 101 insertions(+), 94 deletions(-) diff --git a/examples/scaffold.toml b/examples/scaffold.toml index 4b7825f..08cd74b 100644 --- a/examples/scaffold.toml +++ b/examples/scaffold.toml @@ -25,7 +25,6 @@ weights_rate_limit = 0 # 0 = no rate limit commit_reveal = false # Disable commit-reveal for simpler testing # immunity_period = 100 # Uncomment to set immunity period # max_allowed_uids = 256 # Uncomment to set max UIDs -# max_weight_limit = 65535 # Uncomment to set max weight value # activity_cutoff = 5000 # Uncomment to set activity cutoff [[subnet.neuron]] diff --git a/python/agcli-py/src/extrinsics.rs b/python/agcli-py/src/extrinsics.rs index a3dec05..c2d38dc 100644 --- a/python/agcli-py/src/extrinsics.rs +++ b/python/agcli-py/src/extrinsics.rs @@ -941,7 +941,7 @@ impl PyClient { apply_call_overrides(&mut client, dry_run, finalization_timeout, mortality_blocks); let result = client.register_network(&pair, &hotkey_ss58).await; restore_call_overrides(&mut client, previous); - result.map_err(map_error) + result.map(|(hash, _netuid)| hash).map_err(map_error) }) } diff --git a/src/admin.rs b/src/admin.rs index 3b95fe0..330b0de 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -21,6 +21,25 @@ use anyhow::Result; use sp_core::sr25519; use subxt::dynamic::Value; +/// Set the chain-wide AdminFreezeWindow (in blocks) via sudo. Requires the root +/// key (Alice on localnet). Subtensor blocks subnet-owner admin extrinsics during +/// the last `window` blocks of each tempo; scaffold zeroes it so hyperparameter +/// calls aren't rejected with `AdminActionProhibitedDuringWeightsWindow`. +pub async fn set_admin_freeze_window( + client: &Client, + sudo_key: &sr25519::Pair, + window: u16, +) -> Result { + client + .submit_sudo_raw_call_checked( + sudo_key, + "AdminUtils", + "sudo_set_admin_freeze_window", + vec![Value::u128(window as u128)], + ) + .await +} + /// Set the tempo (blocks per epoch) for a subnet. pub async fn set_tempo( client: &Client, diff --git a/src/chain/extrinsics.rs b/src/chain/extrinsics.rs index 7f808e8..cc83f72 100644 --- a/src/chain/extrinsics.rs +++ b/src/chain/extrinsics.rs @@ -8,6 +8,7 @@ use crate::types::balance::{AlphaBalance, Balance, LimitPriceRao}; use crate::types::chain_data::*; use crate::types::network::NetUid; use crate::{api, AccountId}; +use subxt::ext::scale_value::{Composite, Primitive, ValueDef}; use super::Client; @@ -615,15 +616,36 @@ impl Client { // ──────── Registration ──────── - /// Register a new subnet. + /// Register a new subnet, returning `(tx_hash, netuid)`. + /// + /// The netuid is read straight from the `SubtensorModule::NetworkAdded` + /// event of the finalized extrinsic — the chain tells us which subnet we + /// got, so callers don't need to diff subnet sets (and race the query + /// cache). `netuid` is `None` only in dry-run mode (nothing was submitted). pub async fn register_network( &self, pair: &sr25519::Pair, hotkey_ss58: &str, - ) -> Result { + ) -> Result<(String, Option)> { let hk = Self::ss58_to_account_id(hotkey_ss58)?; - self.sign_submit(&api::tx().subtensor_module().register_network(hk), pair) - .await + let tx = api::tx().subtensor_module().register_network(hk); + let Some(events) = self.submit_finalized(&tx, pair).await? else { + return Ok(("dry-run".to_string(), None)); + }; + let hash = format!("{:?}", events.extrinsic_hash()); + let netuid = events.iter().flatten().find_map(|ev| { + if ev.pallet_name() == "SubtensorModule" && ev.variant_name() == "NetworkAdded" { + if let Ok(Composite::Unnamed(fields)) = ev.field_values() { + if let Some(ValueDef::Primitive(Primitive::U128(n))) = + fields.first().map(|v| &v.value) + { + return Some(*n as u16); + } + } + } + None + }); + Ok((hash, netuid)) } /// POW register on a subnet. diff --git a/src/chain/mod.rs b/src/chain/mod.rs index ac0970d..7e5a3fd 100644 --- a/src/chain/mod.rs +++ b/src/chain/mod.rs @@ -12,6 +12,7 @@ use sp_core::sr25519; use std::borrow::Cow; use subxt::backend::legacy::rpc_methods::LegacyRpcMethods; use subxt::backend::rpc::RpcClient; +use subxt::blocks::ExtrinsicEvents; use subxt::tx::PairSigner; use subxt::OnlineClient; @@ -496,6 +497,22 @@ impl Client { tx: &T, pair: &sr25519::Pair, ) -> Result { + Ok(match self.submit_finalized(tx, pair).await? { + Some(events) => format!("{:?}", events.extrinsic_hash()), + None => "dry-run".to_string(), + }) + } + + /// Sign, submit, and wait for finalized success, returning the finalized + /// events. Returns `None` in dry-run mode (nothing is submitted). Shared by + /// `sign_submit` (which only needs the tx hash) and callers that must read + /// emitted events — e.g. the assigned netuid from `register_network`'s + /// `NetworkAdded` event. + async fn submit_finalized( + &self, + tx: &T, + pair: &sr25519::Pair, + ) -> Result>> { // Dry-run: encode the call and show what would be submitted if self.dry_run { let call_data = self @@ -517,7 +534,7 @@ impl Client { call_data.len() ); print_dry_run_json(&info); - return Ok("dry-run".to_string()); + return Ok(None); } let signer = Self::signer(pair); @@ -582,10 +599,9 @@ impl Client { spinner.finish_and_clear(); format_dispatch_error(e) })?; - let hash = format!("{:?}", result.extrinsic_hash()); spinner.finish_and_clear(); - tracing::info!(tx_hash = %hash, elapsed_ms = start.elapsed().as_millis() as u64, "Extrinsic finalized"); - Ok(hash) + tracing::info!(tx_hash = %format!("{:?}", result.extrinsic_hash()), elapsed_ms = start.elapsed().as_millis() as u64, "Extrinsic finalized"); + Ok(Some(result)) } /// Sign and submit via MEV shield: SCALE-encode the call, encrypt with ML-KEM-768, diff --git a/src/cli/subnet_cmds.rs b/src/cli/subnet_cmds.rs index f82e1b2..6076d1a 100644 --- a/src/cli/subnet_cmds.rs +++ b/src/cli/subnet_cmds.rs @@ -511,11 +511,11 @@ pub(super) async fn handle_subnet( let (pair, hk) = unlock_and_resolve(wallet_dir, wallet_name, hotkey_name, None, password)?; println!("Registering new subnet..."); - let hash = client.register_network(&pair, &hk).await?; - println!( - "Subnet registered. Check `agcli subnet list` for your new subnet ID.\n Tx: {}", - hash - ); + let (hash, netuid) = client.register_network(&pair, &hk).await?; + match netuid { + Some(n) => println!("Subnet registered as netuid {}.\n Tx: {}", n, hash), + None => println!("Subnet registered (dry-run).\n Tx: {}", hash), + } Ok(()) } SubnetCommands::CreateCost => { diff --git a/src/scaffold.rs b/src/scaffold.rs index f7406ea..8fca98c 100644 --- a/src/scaffold.rs +++ b/src/scaffold.rs @@ -89,8 +89,6 @@ pub struct SubnetConfig { pub max_allowed_uids: Option, /// Minimum weights a validator must set. pub min_allowed_weights: Option, - /// Maximum weight value. - pub max_weight_limit: Option, /// Blocks of immunity after registration. pub immunity_period: Option, /// Blocks between weight submissions (0 = unlimited). @@ -110,7 +108,6 @@ impl Default for SubnetConfig { max_allowed_validators: Some(8), max_allowed_uids: None, min_allowed_weights: Some(1), - max_weight_limit: None, immunity_period: None, weights_rate_limit: Some(0), commit_reveal: Some(false), @@ -278,38 +275,24 @@ where bail!("No subnets defined in scaffold config"); } + // Disable the chain-wide admin freeze window (root/sudo). Subtensor rejects + // subnet-owner admin extrinsics during the last AdminFreezeWindow blocks of + // each tempo; on a localnet that intermittently kills scaffold's hyperparameter + // calls with AdminActionProhibitedDuringWeightsWindow. Zeroing it once up front + // makes the set_* calls below deterministic. + on_progress("Disabling admin freeze window..."); + admin::set_admin_freeze_window(&client, &alice, 0).await?; + let mut subnet_results = Vec::new(); for (i, subnet_cfg) in config.subnet.iter().enumerate() { on_progress(&format!("Creating subnet {}...", i + 1)); - // 4. Register subnet — collect netuids before and after to find the new one - // (avoids race condition: `total_networks - 1` assumes sequential assignment - // which breaks under concurrent registrations) - let netuids_before: std::collections::HashSet = client - .get_all_subnets() - .await? - .iter() - .map(|s| s.netuid.0) - .collect(); - retry_idempotent_extrinsic(|| client.register_network(&alice, &alice_ss58)).await?; - wait_blocks(&client, 2).await; - // Pin a fresh block to bypass query cache and see the newly created subnet - let pin_hash = client.pin_latest_block().await?; - let subnets_after = client.get_all_subnets_at_block(pin_hash).await?; - let netuids_after: std::collections::HashSet = - subnets_after.iter().map(|s| s.netuid.0).collect(); - let new_netuids: Vec = netuids_after.difference(&netuids_before).copied().collect(); - if new_netuids.is_empty() { - bail!( - "Subnet registration failed: no new netuid appeared (before: {:?}, after: {:?})", - netuids_before, - netuids_after - ); - } - // Use min() for determinism when multiple subnets appear concurrently - // (HashSet iteration order is non-deterministic; Substrate assigns netuids incrementally) - let netuid = *new_netuids.iter().min().unwrap(); + // 4. Register subnet — the assigned netuid comes straight from the + // NetworkAdded event of the finalized extrinsic (no subnet-set diffing). + let (_tx, netuid) = + retry_idempotent_extrinsic(|| client.register_network(&alice, &alice_ss58)).await?; + let netuid = netuid.context("register_network emitted no NetworkAdded event")?; on_progress(&format!("Subnet created: netuid {}", netuid)); // 5. Set hyperparameters via sudo @@ -382,16 +365,6 @@ where on_progress ); } - if let Some(max_wl) = subnet_cfg.max_weight_limit { - try_admin!( - "set_max_weight_limit", - admin::set_max_weight_limit(&client, &alice, netuid, max_wl), - "max_weight_limit", - max_wl, - hyperparams, - on_progress - ); - } if let Some(ip) = subnet_cfg.immunity_period { try_admin!( "set_immunity_period", @@ -518,14 +491,14 @@ where /// burned_register). Do NOT use for transfers or other operations where /// a retry could double-spend. For non-idempotent ops, call directly /// without retry. -async fn retry_idempotent_extrinsic(f: F) -> Result +async fn retry_idempotent_extrinsic(f: F) -> Result where F: Fn() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { for attempt in 1..=10 { match f().await { - Ok(hash) => return Ok(hash), + Ok(val) => return Ok(val), Err(e) => { let msg = format!("{}", e); let is_transient = msg.contains("outdated") @@ -815,19 +788,6 @@ mod tests { assert_eq!(result, short); } - // ──── Issue 114: deterministic netuid selection from HashSet ──── - - #[test] - fn min_netuid_is_deterministic() { - // Simulate the fix: when multiple netuids appear, min() gives deterministic result - use std::collections::HashSet; - let before: HashSet = [1, 2, 5, 10].into_iter().collect(); - let after: HashSet = [1, 2, 5, 7, 10, 12].into_iter().collect(); - let new_netuids: Vec = after.difference(&before).copied().collect(); - let netuid = *new_netuids.iter().min().unwrap(); - assert_eq!(netuid, 7, "Should deterministically pick lowest new netuid"); - } - // ──── TOML round-trip: deserialize → serialize → deserialize must be symmetric ──── #[test] diff --git a/tests/e2e_modules/cases_part01.rs b/tests/e2e_modules/cases_part01.rs index 5600533..c7028c9 100644 --- a/tests/e2e_modules/cases_part01.rs +++ b/tests/e2e_modules/cases_part01.rs @@ -115,7 +115,7 @@ pub async fn test_register_network(client: &mut Client) { let networks_before = client.get_total_networks().await.expect("networks before"); // Register a new subnet with Alice as owner, using Alice hotkey - let hash = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); + let (hash, _) = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); println!(" register_network tx: {hash}"); wait_blocks(client, 3).await; diff --git a/tests/e2e_modules/cases_part03.rs b/tests/e2e_modules/cases_part03.rs index 0d82a02..514a5f2 100644 --- a/tests/e2e_modules/cases_part03.rs +++ b/tests/e2e_modules/cases_part03.rs @@ -1044,7 +1044,7 @@ pub async fn test_dissolve_network(client: &mut Client) { .await .expect("networks before dissolve"); - let hash = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); + let (hash, _) = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); println!(" register_network for dissolve tx: {hash}"); wait_blocks(client, 3).await; diff --git a/tests/e2e_modules/harness.rs b/tests/e2e_modules/harness.rs index 1a63e5e..6499e89 100644 --- a/tests/e2e_modules/harness.rs +++ b/tests/e2e_modules/harness.rs @@ -363,13 +363,11 @@ pub async fn wait_blocks(client: &mut Client, n: u64) { /// Usage: retry_extrinsic!(client, client.transfer(...)) macro_rules! retry_extrinsic { ($client:expr, $call:expr) => {{ - let mut __re_result: String = String::new(); - let mut __re_done = false; + let mut __re_result = None; for __re_attempt in 1u32..=20 { match $call.await { - Ok(hash) => { - __re_result = hash; - __re_done = true; + Ok(val) => { + __re_result = Some(val); break; } Err(e) => { @@ -391,8 +389,7 @@ macro_rules! retry_extrinsic { } } } - assert!(__re_done, "retry_extrinsic: unreachable"); - __re_result + __re_result.expect("retry_extrinsic: unreachable") }}; } @@ -400,7 +397,7 @@ macro_rules! retry_extrinsic { /// Usage: try_extrinsic!(client, client.transfer(...)) macro_rules! try_extrinsic { ($client:expr, $call:expr) => {{ - let mut __te_result: Result = Err("max retries".to_string()); + let mut __te_result: Result<_, String> = Err("max retries".to_string()); for __te_attempt in 1u32..=20 { match $call.await { Ok(hash) => { diff --git a/tests/localnet_e2e_test.rs b/tests/localnet_e2e_test.rs index 87bb8e6..ec24cf1 100644 --- a/tests/localnet_e2e_test.rs +++ b/tests/localnet_e2e_test.rs @@ -525,7 +525,6 @@ async fn t30_scaffold_default_config() { commit_reveal: Some(false), // Leave the rest as None to minimize timing-sensitive calls max_allowed_uids: None, - max_weight_limit: None, immunity_period: None, activity_cutoff: None, neuron: vec![ diff --git a/tests/scaffold_config_test.rs b/tests/scaffold_config_test.rs index ddaf9bf..5a72747 100644 --- a/tests/scaffold_config_test.rs +++ b/tests/scaffold_config_test.rs @@ -75,7 +75,6 @@ fn scaffold_from_toml_all_hyperparams() { max_allowed_validators = 32 max_allowed_uids = 256 min_allowed_weights = 4 - max_weight_limit = 65535 immunity_period = 1000 weights_rate_limit = 10 commit_reveal = false @@ -90,7 +89,6 @@ fn scaffold_from_toml_all_hyperparams() { assert_eq!(s.max_allowed_validators, Some(32)); assert_eq!(s.max_allowed_uids, Some(256)); assert_eq!(s.min_allowed_weights, Some(4)); - assert_eq!(s.max_weight_limit, Some(65535)); assert_eq!(s.immunity_period, Some(1000)); assert_eq!(s.weights_rate_limit, Some(10)); assert_eq!(s.commit_reveal, Some(false)); diff --git a/tests/user_flows_e2e.rs b/tests/user_flows_e2e.rs index 97440e9..4780db1 100644 --- a/tests/user_flows_e2e.rs +++ b/tests/user_flows_e2e.rs @@ -213,13 +213,11 @@ async fn wait_blocks(client: &mut Client, n: u64) { macro_rules! retry_extrinsic { ($client:expr, $call:expr) => {{ - let mut __re_result: String = String::new(); - let mut __re_done = false; + let mut __re_result = None; for __re_attempt in 1u32..=20 { match $call.await { - Ok(hash) => { - __re_result = hash; - __re_done = true; + Ok(val) => { + __re_result = Some(val); break; } Err(e) => { @@ -240,14 +238,13 @@ macro_rules! retry_extrinsic { } } } - assert!(__re_done, "retry_extrinsic: unreachable"); - __re_result + __re_result.expect("retry_extrinsic: unreachable") }}; } macro_rules! try_extrinsic { ($client:expr, $call:expr) => {{ - let mut __te_result: Result = Err("max retries".to_string()); + let mut __te_result: Result<_, String> = Err("max retries".to_string()); for __te_attempt in 1u32..=20 { match $call.await { Ok(hash) => { @@ -1273,7 +1270,7 @@ async fn flow_subnet_owner_lifecycle(client: &mut Client) { // Step 1: Create a brand new subnet let networks_before = client.get_total_networks().await.unwrap_or(1); - let hash = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); + let (hash, _) = retry_extrinsic!(client, client.register_network(&alice, ALICE_SS58)); wait_blocks(client, 5).await; let networks_after = client.get_total_networks().await.unwrap_or(networks_before); let new_sn = NetUid(networks_after - 1); @@ -3819,7 +3816,7 @@ async fn flow_multi_subnet_empire(client: &mut Client) { let count_after = client.get_total_networks().await.unwrap_or(initial_count); let new_sn = NetUid(count_after as u16 - 1); match ®_result { - Ok(hash) => println!( + Ok((hash, _)) => println!( " 2. Created SN{} (total {} → {}): {}", new_sn.0, initial_count, count_after, hash ),