Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion examples/scaffold.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
2 changes: 1 addition & 1 deletion python/agcli-py/src/extrinsics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}

Expand Down
19 changes: 19 additions & 0 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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,
Expand Down
30 changes: 26 additions & 4 deletions src/chain/extrinsics.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<String> {
) -> Result<(String, Option<u16>)> {
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.
Expand Down
24 changes: 20 additions & 4 deletions src/chain/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -496,6 +497,22 @@ impl Client {
tx: &T,
pair: &sr25519::Pair,
) -> Result<String> {
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<T: subxt::tx::Payload>(
&self,
tx: &T,
pair: &sr25519::Pair,
) -> Result<Option<ExtrinsicEvents<SubtensorConfig>>> {
// Dry-run: encode the call and show what would be submitted
if self.dry_run {
let call_data = self
Expand All @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions src/cli/subnet_cmds.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
72 changes: 16 additions & 56 deletions src/scaffold.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,6 @@ pub struct SubnetConfig {
pub max_allowed_uids: Option<u16>,
/// Minimum weights a validator must set.
pub min_allowed_weights: Option<u16>,
/// Maximum weight value.
pub max_weight_limit: Option<u16>,
/// Blocks of immunity after registration.
pub immunity_period: Option<u16>,
/// Blocks between weight submissions (0 = unlimited).
Expand All @@ -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),
Expand Down Expand Up @@ -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<u16> = 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<u16> =
subnets_after.iter().map(|s| s.netuid.0).collect();
let new_netuids: Vec<u16> = 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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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, Fut>(f: F) -> Result<String>
async fn retry_idempotent_extrinsic<F, Fut, T>(f: F) -> Result<T>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = Result<String>>,
Fut: std::future::Future<Output = Result<T>>,
{
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")
Expand Down Expand Up @@ -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<u16> = [1, 2, 5, 10].into_iter().collect();
let after: HashSet<u16> = [1, 2, 5, 7, 10, 12].into_iter().collect();
let new_netuids: Vec<u16> = 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]
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e_modules/cases_part01.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e_modules/cases_part03.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
13 changes: 5 additions & 8 deletions tests/e2e_modules/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -391,16 +389,15 @@ macro_rules! retry_extrinsic {
}
}
}
assert!(__re_done, "retry_extrinsic: unreachable");
__re_result
__re_result.expect("retry_extrinsic: unreachable")
}};
}

/// Retry an extrinsic that might fail, returning Ok(hash) or Err(msg).
/// Usage: try_extrinsic!(client, client.transfer(...))
macro_rules! try_extrinsic {
($client:expr, $call:expr) => {{
let mut __te_result: Result<String, String> = 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) => {
Expand Down
1 change: 0 additions & 1 deletion tests/localnet_e2e_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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![
Expand Down
2 changes: 0 additions & 2 deletions tests/scaffold_config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
Expand Down
Loading