diff --git a/README.md b/README.md index 93176f85..f1b45007 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,54 @@ flowchart LR --- +## WASM Route Handling + +```mermaid +sequenceDiagram + participant Client + participant RPC as RPC Server + participant WE as WASM Executor + participant WM as WASM Module + + Client->>RPC: challenge_call(id, method, path) + RPC->>WE: execute_handle_route(request) + WE->>WM: handle_route(serialized_request) + WM-->>WE: serialized_response + WE-->>RPC: WasmRouteResponse + RPC-->>Client: JSON-RPC result +``` + +--- + +## Review Assignment Flow + +```mermaid +flowchart LR + Submit[Submission] --> Select[Validator Selection] + Select --> LLM[3 LLM Reviewers] + Select --> AST[3 AST Reviewers] + LLM --> |Review Results| Aggregate[Result Aggregation] + AST --> |Review Results| Aggregate + Aggregate --> Score[Final Score] + LLM -.-> |Timeout| Replace1[Replacement Validator] + AST -.-> |Timeout| Replace2[Replacement Validator] +``` + +--- + +## Subnet Owner Resolution + +```mermaid +flowchart TB + Sync[Metagraph Sync] --> Parse[Parse Neurons] + Parse --> UID0{UID 0 Found?} + UID0 -->|Yes| Update[Update ChainState.sudo_key] + UID0 -->|No| Keep[Keep Existing] + Update --> Owner[Subnet Owner = UID 0 Hotkey] +``` + +--- + ## Quick Start (Validator) ```bash diff --git a/bins/validator-node/src/wasm_executor.rs b/bins/validator-node/src/wasm_executor.rs index 6f488472..41d40e3e 100644 --- a/bins/validator-node/src/wasm_executor.rs +++ b/bins/validator-node/src/wasm_executor.rs @@ -14,6 +14,8 @@ use wasm_runtime_interface::{ }; const MAX_EVALUATION_OUTPUT_SIZE: u64 = 64 * 1024 * 1024; +const MAX_ROUTE_OUTPUT_SIZE: u64 = 16 * 1024 * 1024; +const MAX_TASK_OUTPUT_SIZE: u64 = 16 * 1024 * 1024; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct EvaluationInput { @@ -470,6 +472,14 @@ impl WasmChallengeExecutor { let out_len = (result >> 32) as i32; let out_ptr = (result & 0xFFFF_FFFF) as i32; + if out_len > 0 && out_len as u64 > MAX_TASK_OUTPUT_SIZE { + return Err(anyhow::anyhow!( + "WASM get_tasks output size {} exceeds maximum allowed {}", + out_len, + MAX_TASK_OUTPUT_SIZE + )); + } + let result_data = if out_ptr > 0 && out_len > 0 { instance .read_memory(out_ptr as usize, out_len as usize) @@ -580,6 +590,209 @@ impl WasmChallengeExecutor { Ok((result, metrics)) } + #[allow(dead_code)] + pub fn execute_get_routes( + &self, + module_path: &str, + network_policy: &NetworkPolicy, + sandbox_policy: &SandboxPolicy, + ) -> Result<(Vec, ExecutionMetrics)> { + let start = Instant::now(); + + let module = self + .load_module(module_path) + .context("Failed to load WASM module")?; + + let network_host_fns = Arc::new(NetworkHostFunctions::all()); + + let instance_config = InstanceConfig { + network_policy: network_policy.clone(), + sandbox_policy: sandbox_policy.clone(), + exec_policy: ExecPolicy::default(), + time_policy: TimePolicy::default(), + audit_logger: None, + memory_export: "memory".to_string(), + challenge_id: module_path.to_string(), + validator_id: "validator".to_string(), + restart_id: String::new(), + config_version: 0, + storage_host_config: StorageHostConfig::default(), + storage_backend: Arc::new(InMemoryStorageBackend::new()), + fixed_timestamp_ms: None, + consensus_policy: ConsensusPolicy::default(), + terminal_policy: TerminalPolicy::default(), + llm_policy: match &self.config.chutes_api_key { + Some(key) => LlmPolicy::with_api_key(key.clone()), + None => LlmPolicy::default(), + }, + ..Default::default() + }; + + let mut instance = self + .runtime + .instantiate(&module, instance_config, Some(network_host_fns)) + .map_err(|e| anyhow::anyhow!("WASM instantiation failed: {}", e))?; + + let initial_fuel = instance.fuel_remaining(); + + let result = instance + .call_return_i64("get_routes") + .map_err(|e| anyhow::anyhow!("WASM get_routes call failed: {}", e))?; + + let out_len = (result >> 32) as i32; + let out_ptr = (result & 0xFFFF_FFFF) as i32; + + if out_len > 0 && out_len as u64 > MAX_ROUTE_OUTPUT_SIZE { + return Err(anyhow::anyhow!( + "WASM get_routes output size {} exceeds maximum allowed {}", + out_len, + MAX_ROUTE_OUTPUT_SIZE + )); + } + + let result_data = if out_ptr > 0 && out_len > 0 { + instance + .read_memory(out_ptr as usize, out_len as usize) + .map_err(|e| { + anyhow::anyhow!("failed to read WASM memory for get_routes output: {}", e) + })? + } else { + Vec::new() + }; + + let fuel_consumed = match (initial_fuel, instance.fuel_remaining()) { + (Some(initial), Some(remaining)) => Some(initial.saturating_sub(remaining)), + _ => None, + }; + + let metrics = ExecutionMetrics { + execution_time_ms: start.elapsed().as_millis(), + memory_used_bytes: instance.memory().data_size(instance.store()) as u64, + network_requests_made: instance.network_requests_made(), + fuel_consumed, + }; + + info!( + module = module_path, + result_bytes = result_data.len(), + execution_time_ms = metrics.execution_time_ms, + "WASM get_routes completed" + ); + + Ok((result_data, metrics)) + } + + #[allow(dead_code)] + pub fn execute_handle_route( + &self, + module_path: &str, + network_policy: &NetworkPolicy, + sandbox_policy: &SandboxPolicy, + request_data: &[u8], + ) -> Result<(Vec, ExecutionMetrics)> { + let start = Instant::now(); + + let module = self + .load_module(module_path) + .context("Failed to load WASM module")?; + + let network_host_fns = Arc::new(NetworkHostFunctions::all()); + + let instance_config = InstanceConfig { + network_policy: network_policy.clone(), + sandbox_policy: sandbox_policy.clone(), + exec_policy: ExecPolicy::default(), + time_policy: TimePolicy::default(), + audit_logger: None, + memory_export: "memory".to_string(), + challenge_id: module_path.to_string(), + validator_id: "validator".to_string(), + restart_id: String::new(), + config_version: 0, + storage_host_config: StorageHostConfig { + allow_direct_writes: true, + require_consensus: false, + ..self.config.storage_host_config.clone() + }, + storage_backend: Arc::clone(&self.config.storage_backend), + fixed_timestamp_ms: None, + consensus_policy: ConsensusPolicy::default(), + terminal_policy: TerminalPolicy::default(), + llm_policy: match &self.config.chutes_api_key { + Some(key) => LlmPolicy::with_api_key(key.clone()), + None => LlmPolicy::default(), + }, + ..Default::default() + }; + + let mut instance = self + .runtime + .instantiate(&module, instance_config, Some(network_host_fns)) + .map_err(|e| anyhow::anyhow!("WASM instantiation failed: {}", e))?; + + let initial_fuel = instance.fuel_remaining(); + + let ptr = self.allocate_input(&mut instance, request_data)?; + + instance + .write_memory(ptr as usize, request_data) + .map_err(|e| anyhow::anyhow!("Failed to write request data to WASM memory: {}", e))?; + + let result = instance + .call_i32_i32_return_i64("handle_route", ptr, request_data.len() as i32) + .map_err(|e| match &e { + WasmRuntimeError::FuelExhausted => { + anyhow::anyhow!("WASM execution exceeded fuel limit") + } + WasmRuntimeError::Execution(msg) if msg.contains("timeout") => { + anyhow::anyhow!("WASM execution timed out") + } + _ => anyhow::anyhow!("WASM handle_route call failed: {}", e), + })?; + + let out_len = (result >> 32) as i32; + let out_ptr = (result & 0xFFFF_FFFF) as i32; + + if out_len > 0 && out_len as u64 > MAX_ROUTE_OUTPUT_SIZE { + return Err(anyhow::anyhow!( + "WASM handle_route output size {} exceeds maximum allowed {}", + out_len, + MAX_ROUTE_OUTPUT_SIZE + )); + } + + let result_data = if out_ptr > 0 && out_len > 0 { + instance + .read_memory(out_ptr as usize, out_len as usize) + .map_err(|e| { + anyhow::anyhow!("failed to read WASM memory for handle_route output: {}", e) + })? + } else { + Vec::new() + }; + + let fuel_consumed = match (initial_fuel, instance.fuel_remaining()) { + (Some(initial), Some(remaining)) => Some(initial.saturating_sub(remaining)), + _ => None, + }; + + let metrics = ExecutionMetrics { + execution_time_ms: start.elapsed().as_millis(), + memory_used_bytes: instance.memory().data_size(instance.store()) as u64, + network_requests_made: instance.network_requests_made(), + fuel_consumed, + }; + + info!( + module = module_path, + result_bytes = result_data.len(), + execution_time_ms = metrics.execution_time_ms, + "WASM handle_route completed" + ); + + Ok((result_data, metrics)) + } + fn load_module(&self, module_path: &str) -> Result> { { let cache = self.module_cache.read(); diff --git a/crates/bittensor-integration/src/validator_sync.rs b/crates/bittensor-integration/src/validator_sync.rs index 39763ec4..42a75e3e 100644 --- a/crates/bittensor-integration/src/validator_sync.rs +++ b/crates/bittensor-integration/src/validator_sync.rs @@ -13,6 +13,9 @@ use std::sync::Arc; use tokio::sync::Mutex as TokioMutex; use tracing::{debug, info}; +/// UID of the subnet owner (always the first registered neuron). +const SUBNET_OWNER_UID: u16 = 0; + /// Validator info from Bittensor metagraph #[derive(Clone, Debug)] pub struct MetagraphValidator { @@ -90,6 +93,16 @@ impl ValidatorSync { // Parse validators and all hotkeys from metagraph let (bt_validators, all_hotkeys) = self.parse_metagraph(metagraph)?; + + // Extract subnet owner hotkey before dropping client borrow + let uid0_hotkey = metagraph + .neurons + .get(&(SUBNET_OWNER_UID as u64)) + .map(|neuron| { + let hotkey_bytes: &[u8; 32] = neuron.hotkey.as_ref(); + Hotkey(*hotkey_bytes) + }); + drop(client); // Release lock // Update registered hotkeys in state (all miners + validators) @@ -101,6 +114,13 @@ impl ValidatorSync { // Update state with validators let result = self.update_state(state, bt_validators, banned_validators); + // Resolve subnet owner from UID 0 + if let Some(hotkey) = uid0_hotkey { + let mut state_guard = state.write(); + state_guard.sudo_key = hotkey; + debug!("Subnet owner set to UID 0 hotkey: {}", state_guard.sudo_key); + } + // Update last sync block self.last_sync_block = state.read().block_height; diff --git a/crates/challenge-sdk-wasm/src/lib.rs b/crates/challenge-sdk-wasm/src/lib.rs index 820c7d4f..ac25f73c 100644 --- a/crates/challenge-sdk-wasm/src/lib.rs +++ b/crates/challenge-sdk-wasm/src/lib.rs @@ -13,6 +13,7 @@ pub use types::{ }; pub use types::{ContainerRunRequest, ContainerRunResponse}; pub use types::{EvaluationInput, EvaluationOutput}; +pub use types::{WasmRouteDefinition, WasmRouteRequest, WasmRouteResponse}; pub trait Challenge { fn name(&self) -> &'static str; @@ -33,6 +34,21 @@ pub trait Challenge { } fn configure(&self, _config: &[u8]) {} + + /// Return serialized [`WasmRouteDefinition`]s describing the HTTP routes + /// this challenge exposes. The default implementation returns an empty + /// vector (no custom routes). + fn routes(&self) -> alloc::vec::Vec { + alloc::vec::Vec::new() + } + + /// Handle an incoming route request and return a serialized + /// [`WasmRouteResponse`]. The `request` parameter is a bincode-encoded + /// [`WasmRouteRequest`]. The default implementation returns an empty + /// vector. + fn handle_route(&self, _request: &[u8]) -> alloc::vec::Vec { + alloc::vec::Vec::new() + } } /// Pack a pointer and length into a single i64 value. @@ -46,7 +62,8 @@ pub fn pack_ptr_len(ptr: i32, len: i32) -> i64 { /// Register a [`Challenge`] implementation and export the required WASM ABI /// functions (`evaluate`, `validate`, `get_name`, `get_version`, -/// `generate_task`, `setup_environment`, `get_tasks`, `configure`, and `alloc`). +/// `generate_task`, `setup_environment`, `get_tasks`, `configure`, +/// `get_routes`, `handle_route`, and `alloc`). /// /// The type must provide a `const fn new() -> Self` constructor so that the /// challenge instance can be placed in a `static`. @@ -213,5 +230,39 @@ macro_rules! register_challenge { <$ty as $crate::Challenge>::configure(&_CHALLENGE, slice); 1 } + + #[no_mangle] + pub extern "C" fn get_routes() -> i64 { + let output = <$ty as $crate::Challenge>::routes(&_CHALLENGE); + if output.is_empty() { + return $crate::pack_ptr_len(0, 0); + } + let ptr = $crate::alloc_impl::sdk_alloc(output.len()); + if ptr.is_null() { + return $crate::pack_ptr_len(0, 0); + } + unsafe { + core::ptr::copy_nonoverlapping(output.as_ptr(), ptr, output.len()); + } + $crate::pack_ptr_len(ptr as i32, output.len() as i32) + } + + #[no_mangle] + pub extern "C" fn handle_route(req_ptr: i32, req_len: i32) -> i64 { + let slice = + unsafe { core::slice::from_raw_parts(req_ptr as *const u8, req_len as usize) }; + let output = <$ty as $crate::Challenge>::handle_route(&_CHALLENGE, slice); + if output.is_empty() { + return $crate::pack_ptr_len(0, 0); + } + let ptr = $crate::alloc_impl::sdk_alloc(output.len()); + if ptr.is_null() { + return $crate::pack_ptr_len(0, 0); + } + unsafe { + core::ptr::copy_nonoverlapping(output.as_ptr(), ptr, output.len()); + } + $crate::pack_ptr_len(ptr as i32, output.len() as i32) + } }; } diff --git a/crates/challenge-sdk-wasm/src/types.rs b/crates/challenge-sdk-wasm/src/types.rs index 3c3763fb..f06544a0 100644 --- a/crates/challenge-sdk-wasm/src/types.rs +++ b/crates/challenge-sdk-wasm/src/types.rs @@ -141,3 +141,51 @@ pub struct ContainerRunResponse { pub stderr: Vec, pub duration_ms: u64, } + +/// Definition of a route exposed by a WASM challenge module. +/// +/// Challenge implementations return a serialized list of these definitions from +/// [`Challenge::routes`] so the validator can register HTTP endpoints. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WasmRouteDefinition { + /// HTTP method (e.g. `"GET"`, `"POST"`). + pub method: String, + /// URL path pattern (e.g. `"/status"`, `"/submit"`). + pub path: String, + /// Human-readable description of the route. + pub description: String, + /// Whether the route requires hotkey authentication. + pub requires_auth: bool, +} + +/// Incoming request forwarded to a WASM challenge route handler. +/// +/// The validator serializes this struct and passes it to +/// [`Challenge::handle_route`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WasmRouteRequest { + /// HTTP method of the incoming request. + pub method: String, + /// Matched URL path. + pub path: String, + /// Path parameters extracted from the URL pattern. + pub params: Vec<(String, String)>, + /// Query-string key/value pairs. + pub query: Vec<(String, String)>, + /// Raw request body bytes. + pub body: Vec, + /// Authenticated caller hotkey, if present. + pub auth_hotkey: Option, +} + +/// Response returned by a WASM challenge route handler. +/// +/// The WASM module serializes this struct and returns it from +/// [`Challenge::handle_route`]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WasmRouteResponse { + /// HTTP status code to return to the caller. + pub status: u16, + /// Raw response body bytes. + pub body: Vec, +}