diff --git a/crates/challenge-sdk/src/lib.rs b/crates/challenge-sdk/src/lib.rs index 5e5e52db..5fd393f6 100644 --- a/crates/challenge-sdk/src/lib.rs +++ b/crates/challenge-sdk/src/lib.rs @@ -1,4 +1,3 @@ -#![allow(dead_code, unused_variables, unused_imports)] //! Platform Challenge SDK //! //! SDK for developing challenges on Platform Network. @@ -112,9 +111,9 @@ pub use p2p_client::{ ValidatorEvaluationResult, }; pub use server::{ - ChallengeServer, ChallengeServerBuilder, ConfigLimits, ConfigResponse, EvaluationRequest, - EvaluationResponse, HealthResponse, ServerChallenge, ServerConfig, ValidationRequest, - ValidationResponse, + ChallengeContext, ChallengeServer, ChallengeServerBuilder, ConfigLimits, ConfigResponse, + EvaluationRequest, EvaluationResponse, HealthResponse, ServerChallenge, ServerConfig, + ValidationRequest, ValidationResponse, }; pub use data::*; @@ -130,9 +129,10 @@ pub use weights::*; /// Prelude for P2P challenge development pub mod prelude { pub use super::error::ChallengeError; + pub use super::routes::{ChallengeRoute, RouteRequest, RouteResponse}; pub use super::server::{ - ChallengeServer, EvaluationRequest, EvaluationResponse, ServerChallenge, ServerConfig, - ValidationRequest, ValidationResponse, + ChallengeContext, ChallengeServer, EvaluationRequest, EvaluationResponse, ServerChallenge, + ServerConfig, ValidationRequest, ValidationResponse, }; // P2P mode diff --git a/crates/challenge-sdk/src/routes.rs b/crates/challenge-sdk/src/routes.rs index 61f1ea7c..32094abb 100644 --- a/crates/challenge-sdk/src/routes.rs +++ b/crates/challenge-sdk/src/routes.rs @@ -1,14 +1,22 @@ -//! Challenge Custom Routes System +//! Provides the generic route infrastructure for challenges to define custom +//! HTTP routes that get mounted on the RPC server. Each challenge declares its +//! own routes and handlers via the `ServerChallenge` trait — the platform SDK +//! does NOT hardcode any challenge-specific routes. //! -//! Allows challenges to define custom HTTP routes that get mounted -//! on the RPC server. Each challenge can expose its own API endpoints. +//! The platform SDK provides the generic building blocks ([`ChallengeRoute`], +//! [`RouteRequest`], [`RouteResponse`], [`RouteRegistry`], [`RouteBuilder`], +//! [`RoutesManifest`], [`HttpMethod`]) — challenges use these to declare their +//! own routes. //! //! # Example //! //! ```text +//! use platform_challenge_sdk::server::{ServerChallenge, ChallengeContext}; //! use platform_challenge_sdk::routes::*; //! -//! impl Challenge for MyChallenge { +//! impl ServerChallenge for MyChallenge { +//! // ... challenge_id, name, version, evaluate ... +//! //! fn routes(&self) -> Vec { //! vec![ //! ChallengeRoute::get("/leaderboard", "Get current leaderboard"), @@ -18,7 +26,11 @@ //! ] //! } //! -//! async fn handle_route(&self, ctx: &ChallengeContext, req: RouteRequest) -> RouteResponse { +//! async fn handle_route( +//! &self, +//! ctx: &ChallengeContext, +//! req: RouteRequest, +//! ) -> RouteResponse { //! match (req.method.as_str(), req.path.as_str()) { //! ("GET", "/leaderboard") => { //! let data = self.get_leaderboard(ctx).await; @@ -103,18 +115,6 @@ impl RoutesManifest { self.metadata.insert(key.into(), value); self } - - /// Build standard routes that most challenges should implement - pub fn with_standard_routes(self) -> Self { - self.with_routes(vec![ - ChallengeRoute::post("/submit", "Submit an agent for evaluation"), - ChallengeRoute::get("/status/:hash", "Get agent evaluation status"), - ChallengeRoute::get("/leaderboard", "Get current leaderboard"), - ChallengeRoute::get("/config", "Get challenge configuration"), - ChallengeRoute::get("/stats", "Get challenge statistics"), - ChallengeRoute::get("/health", "Health check endpoint"), - ]) - } } /// HTTP method for routes @@ -591,16 +591,6 @@ mod tests { ); } - #[test] - fn test_routes_manifest_with_standard_routes() { - let manifest = RoutesManifest::new("test", "1.0").with_standard_routes(); - - assert!(manifest.routes.len() >= 6); - assert!(manifest.routes.iter().any(|r| r.path == "/submit")); - assert!(manifest.routes.iter().any(|r| r.path == "/leaderboard")); - assert!(manifest.routes.iter().any(|r| r.path == "/health")); - } - #[test] fn test_http_method_display() { assert_eq!(format!("{}", HttpMethod::Get), "GET"); diff --git a/crates/challenge-sdk/src/server.rs b/crates/challenge-sdk/src/server.rs index 844f58b6..5192e302 100644 --- a/crates/challenge-sdk/src/server.rs +++ b/crates/challenge-sdk/src/server.rs @@ -6,35 +6,74 @@ //! # Usage //! //! ```text -//! use platform_challenge_sdk::server::{ChallengeServer, ServerConfig}; +//! use platform_challenge_sdk::server::{ChallengeServer, ServerConfig, ChallengeContext}; +//! use platform_challenge_sdk::routes::{ChallengeRoute, RouteRequest, RouteResponse}; //! -//! let server = ChallengeServer::new(my_challenge) -//! .config(ServerConfig::default()) -//! .build(); +//! #[async_trait] +//! impl ServerChallenge for MyChallenge { +//! fn challenge_id(&self) -> &str { "my-challenge" } +//! fn name(&self) -> &str { "My Challenge" } +//! fn version(&self) -> &str { "0.1.0" } //! -//! server.run().await?; +//! async fn evaluate(&self, req: EvaluationRequest) -> Result { +//! // Your evaluation logic here +//! Ok(EvaluationResponse::success(&req.request_id, 0.95, json!({}))) +//! } +//! +//! // Declare custom routes this challenge exposes +//! fn routes(&self) -> Vec { +//! vec![ +//! ChallengeRoute::get("/leaderboard", "Get current leaderboard"), +//! ChallengeRoute::post("/submit", "Submit evaluation result"), +//! ] +//! } +//! +//! // Handle incoming route requests +//! async fn handle_route(&self, ctx: &ChallengeContext, req: RouteRequest) -> RouteResponse { +//! match (req.method.as_str(), req.path.as_str()) { +//! ("GET", "/leaderboard") => RouteResponse::json(json!({"entries": []})), +//! _ => RouteResponse::not_found(), +//! } +//! } +//! } +//! +//! #[tokio::main] +//! async fn main() -> Result<(), ChallengeError> { +//! ChallengeServer::builder(MyChallenge) +//! .port(8080) +//! .build() +//! .run() +//! .await +//! } //! ``` //! -//! # Endpoints +//! # Platform Endpoints //! -//! The server exposes: +//! The server exposes these platform-level endpoints: //! - `POST /evaluate` - Receive evaluation requests from platform //! - `GET /health` - Health check //! - `GET /config` - Challenge configuration schema //! - `POST /validate` - Quick validation without full evaluation +//! +//! Additionally, any custom routes declared by `ServerChallenge::routes()` are +//! mounted and handled via `ServerChallenge::handle_route()`. -use std::net::SocketAddr; use std::sync::Arc; use std::time::Instant; use serde::{Deserialize, Serialize}; use tokio::sync::RwLock; -use tracing::{debug, error, info, warn}; +use crate::database::ChallengeDatabase; use crate::error::ChallengeError; +use crate::routes::{ChallengeRoute, RouteRequest, RouteResponse}; #[cfg(feature = "http-server")] use axum::extract::State; +#[cfg(feature = "http-server")] +use std::sync::OnceLock; +#[cfg(feature = "http-server")] +use tracing::{debug, error, info}; /// Server configuration #[derive(Debug, Clone)] @@ -232,6 +271,25 @@ pub struct ConfigLimits { pub max_cost: Option, } +// ============================================================================ +// CHALLENGE CONTEXT +// ============================================================================ + +/// Context provided to route handlers, giving access to shared resources +/// +/// Route handlers receive this to access the local sled database and chain +/// state when handling custom routes. +pub struct ChallengeContext { + /// Local challenge database (sled) + pub db: Arc, + /// Challenge ID + pub challenge_id: String, + /// Current epoch + pub epoch: u64, + /// Current block height + pub block_height: u64, +} + // ============================================================================ // SERVER TRAIT // ============================================================================ @@ -257,7 +315,7 @@ pub trait ServerChallenge: Send + Sync { /// Validate submission data (quick check) async fn validate( &self, - request: ValidationRequest, + _request: ValidationRequest, ) -> Result { // Default: accept everything Ok(ValidationResponse { @@ -278,6 +336,24 @@ pub trait ServerChallenge: Send + Sync { limits: ConfigLimits::default(), } } + + /// Return the custom routes this challenge exposes. + /// + /// Challenges override this to declare their own API routes (e.g., + /// `/leaderboard`, `/submit`, `/stats`). The platform SDK does not + /// hardcode any challenge-specific routes. + fn routes(&self) -> Vec { + vec![] + } + + /// Handle an incoming route request. + /// + /// Called when a request matches one of the routes declared by + /// [`routes()`](Self::routes). The `ChallengeContext` provides access + /// to the local sled database and current chain state. + async fn handle_route(&self, _ctx: &ChallengeContext, _request: RouteRequest) -> RouteResponse { + RouteResponse::not_found() + } } // ============================================================================ @@ -362,9 +438,9 @@ impl ChallengeServer { /// Run the server (requires axum feature) #[cfg(feature = "http-server")] pub async fn run(&self) -> Result<(), ChallengeError> { + use std::net::SocketAddr; + use axum::{ - extract::{Json, State}, - http::StatusCode, routing::{get, post}, Router, }; @@ -374,11 +450,30 @@ impl ChallengeServer { .parse() .map_err(|e| ChallengeError::Config(format!("Invalid address: {}", e)))?; + // Log custom routes declared by the challenge + let custom_routes = state.challenge.routes(); + if !custom_routes.is_empty() { + info!( + "Challenge {} declares {} custom route(s)", + state.challenge.challenge_id(), + custom_routes.len() + ); + for route in &custom_routes { + debug!( + " {} {}: {}", + route.method.as_str(), + route.path, + route.description + ); + } + } + let app = Router::new() .route("/health", get(health_handler::)) .route("/config", get(config_handler::)) .route("/evaluate", post(evaluate_handler::)) .route("/validate", post(validate_handler::)) + .fallback(custom_route_handler::) .with_state(state); info!( @@ -485,6 +580,104 @@ async fn validate_handler( } } +/// Catch-all handler for custom challenge routes declared via `ServerChallenge::routes()` +#[cfg(feature = "http-server")] +async fn custom_route_handler( + State(state): State>>, + method: axum::http::Method, + uri: axum::http::Uri, + axum::extract::Query(query): axum::extract::Query>, + headers: axum::http::HeaderMap, + body: Option>, +) -> (axum::http::StatusCode, axum::Json) { + let path = uri.path().to_string(); + let method_str = method.as_str().to_string(); + + let custom_routes = state.challenge.routes(); + + // Find matching route + let mut matched_params = std::collections::HashMap::new(); + let mut found = false; + for route in &custom_routes { + if let Some(params) = route.matches(&method_str, &path) { + matched_params = params; + found = true; + break; + } + } + + if !found { + return ( + axum::http::StatusCode::NOT_FOUND, + axum::Json(serde_json::json!({ + "error": "not_found", + "message": format!("No route matches {} {}", method_str, path) + })), + ); + } + + // Build headers map + let mut headers_map = std::collections::HashMap::new(); + for (key, value) in headers.iter() { + if let Ok(v) = value.to_str() { + headers_map.insert(key.as_str().to_string(), v.to_string()); + } + } + + let request = RouteRequest { + method: method_str, + path, + params: matched_params, + query, + headers: headers_map, + body: body.map(|b| b.0).unwrap_or(serde_json::Value::Null), + auth_hotkey: None, + }; + + // Use a shared fallback database to avoid creating a new temp DB per request (DoS vector). + // In production, the ChallengeContext would be populated by the validator node. + static FALLBACK_DB: OnceLock> = OnceLock::new(); + + let db = match FALLBACK_DB.get() { + Some(db) => Arc::clone(db), + None => { + match ChallengeDatabase::open(std::env::temp_dir(), crate::types::ChallengeId::new()) { + Ok(db) => { + let db = Arc::new(db); + let _ = FALLBACK_DB.set(Arc::clone(&db)); + db + } + Err(e) => { + error!("Failed to open fallback challenge database: {}", e); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + axum::Json(serde_json::json!({ + "error": "internal_error", + "message": "Failed to initialize challenge database" + })), + ); + } + } + } + }; + + let ctx = ChallengeContext { + db, + challenge_id: state.challenge.challenge_id().to_string(), + epoch: 0, + block_height: 0, + }; + + let response = state.challenge.handle_route(&ctx, request).await; + + let status = match axum::http::StatusCode::from_u16(response.status) { + Ok(s) => s, + Err(_) => axum::http::StatusCode::INTERNAL_SERVER_ERROR, + }; + + (status, axum::Json(response.body)) +} + // ============================================================================ // MACROS FOR EASY IMPLEMENTATION // ============================================================================ diff --git a/crates/challenge-sdk/src/test_challenge.rs b/crates/challenge-sdk/src/test_challenge.rs index 12115adc..5d475592 100644 --- a/crates/challenge-sdk/src/test_challenge.rs +++ b/crates/challenge-sdk/src/test_challenge.rs @@ -9,10 +9,9 @@ use crate::{ EvaluationRequest, EvaluationResponse, ServerChallenge, ValidationRequest, ValidationResponse, }, - types::ChallengeId, }; use async_trait::async_trait; -use serde_json::{json, Value}; +use serde_json::json; /// Simple test challenge that returns scores based on submission data pub struct SimpleTestChallenge { diff --git a/crates/rpc-server/src/jsonrpc.rs b/crates/rpc-server/src/jsonrpc.rs index 1a2d45a3..8136c94e 100644 --- a/crates/rpc-server/src/jsonrpc.rs +++ b/crates/rpc-server/src/jsonrpc.rs @@ -337,6 +337,12 @@ impl RpcHandler { ["challenge", "get"] => self.challenge_get(req.id, req.params), ["challenge", "getRoutes"] => self.challenge_get_routes(req.id, req.params), ["challenge", "listAllRoutes"] => self.challenge_list_all_routes(req.id), + // challenge_call is handled asynchronously via handle_async() + ["challenge", "call"] => JsonRpcResponse::error( + req.id, + INTERNAL_ERROR, + "challenge_call must be invoked via handle_async()", + ), // Job namespace ["job", "list"] => self.job_list(req.id, req.params), @@ -387,7 +393,7 @@ impl RpcHandler { "validator_list", "validator_get", "validator_count", // Challenge "challenge_list", "challenge_get", "challenge_getRoutes", - "challenge_listAllRoutes", + "challenge_listAllRoutes", "challenge_call", // Job "job_list", "job_get", // Epoch @@ -1083,6 +1089,178 @@ impl RpcHandler { ) } + // ==================== Async Handler ==================== + + /// Handle a JSON-RPC request, supporting both sync and async methods. + /// + /// Methods like `challenge_call` require async execution (the route handler + /// callback is async). This method handles those asynchronously and delegates + /// all other methods to the synchronous [`handle()`](Self::handle). + pub async fn handle_async(&self, req: JsonRpcRequest) -> JsonRpcResponse { + let parts: Vec<&str> = req.method.splitn(2, '_').collect(); + match parts.as_slice() { + ["challenge", "call"] => self.challenge_call(req.id, req.params).await, + _ => self.handle(req), + } + } + + /// Allowed HTTP methods for challenge_call + const ALLOWED_METHODS: &'static [&'static str] = &["GET", "POST", "PUT", "DELETE", "PATCH"]; + + /// Maximum path length for challenge_call + const MAX_PATH_LEN: usize = 2048; + + /// Maximum number of query parameters for challenge_call + const MAX_QUERY_PARAMS: usize = 100; + + /// Maximum body size in bytes (1 MB) for challenge_call + const MAX_BODY_SIZE: usize = 1_048_576; + + /// Validate that a path has no traversal sequences and starts with '/' + fn validate_path(path: &str) -> bool { + if path.len() > Self::MAX_PATH_LEN { + return false; + } + if !path.starts_with('/') { + return false; + } + for segment in path.split('/') { + if segment == ".." { + return false; + } + } + true + } + + /// Call a challenge route handler + async fn challenge_call(&self, id: Value, params: Value) -> JsonRpcResponse { + let challenge_id = match self.get_param_str(¶ms, 0, "challengeId") { + Some(c) => c, + None => { + return JsonRpcResponse::error( + id, + INVALID_PARAMS, + "Missing 'challengeId' parameter", + ) + } + }; + + let method = self + .get_param_str(¶ms, 1, "method") + .unwrap_or_else(|| "GET".to_string()); + + if !Self::ALLOWED_METHODS.contains(&method.as_str()) { + return JsonRpcResponse::error( + id, + INVALID_PARAMS, + format!( + "Invalid HTTP method '{}'. Allowed: {}", + method, + Self::ALLOWED_METHODS.join(", ") + ), + ); + } + + let path = self + .get_param_str(¶ms, 2, "path") + .unwrap_or_else(|| "/".to_string()); + + if !Self::validate_path(&path) { + return JsonRpcResponse::error( + id, + INVALID_PARAMS, + "Invalid path: must start with '/', must not contain '..', and must be <= 2048 characters", + ); + } + + let body = params + .get("body") + .or_else(|| params.get(3)) + .cloned() + .unwrap_or(Value::Null); + + if body != Value::Null { + let body_size = serde_json::to_string(&body).map(|s| s.len()).unwrap_or(0); + if body_size > Self::MAX_BODY_SIZE { + return JsonRpcResponse::error( + id, + INVALID_PARAMS, + format!( + "Request body too large: {} bytes (max {})", + body_size, + Self::MAX_BODY_SIZE + ), + ); + } + } + + let query: std::collections::HashMap = params + .get("query") + .or_else(|| params.get(4)) + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + if query.len() > Self::MAX_QUERY_PARAMS { + return JsonRpcResponse::error( + id, + INVALID_PARAMS, + format!( + "Too many query parameters: {} (max {})", + query.len(), + Self::MAX_QUERY_PARAMS + ), + ); + } + + // Verify the challenge has registered routes + { + let routes = self.challenge_routes.read(); + if !routes.contains_key(&challenge_id) { + // Try to find by name + let chain = self.chain_state.read(); + let found = chain.challenges.values().any(|c| c.name == challenge_id); + if !found { + return JsonRpcResponse::error( + id, + CHALLENGE_NOT_FOUND, + format!("Challenge '{}' not found or has no routes", challenge_id), + ); + } + } + } + + let request = RouteRequest { + method, + path, + params: std::collections::HashMap::new(), + query, + headers: std::collections::HashMap::new(), + body, + auth_hotkey: None, + }; + + let maybe_handler = self.route_handler.read().clone(); + match maybe_handler { + Some(handler) => { + let response = handler(challenge_id.clone(), request).await; + JsonRpcResponse::result( + id, + json!({ + "challengeId": challenge_id, + "status": response.status, + "headers": response.headers, + "body": response.body, + }), + ) + } + None => JsonRpcResponse::error( + id, + INTERNAL_ERROR, + "No route handler registered. Challenge route handlers are not configured.", + ), + } + } + // ==================== Job Namespace ==================== fn job_list(&self, id: Value, params: Value) -> JsonRpcResponse { diff --git a/crates/rpc-server/src/server.rs b/crates/rpc-server/src/server.rs index 4933a77e..2523e731 100644 --- a/crates/rpc-server/src/server.rs +++ b/crates/rpc-server/src/server.rs @@ -39,7 +39,7 @@ pub struct RpcConfig { impl Default for RpcConfig { fn default() -> Self { Self { - addr: "0.0.0.0:8080".parse().unwrap(), + addr: SocketAddr::from(([0, 0, 0, 0], 8080)), netuid: 1, name: "Mini-Chain".to_string(), min_stake: 1_000_000_000_000, // 1000 TAO @@ -321,10 +321,11 @@ async fn challenge_route_handler( match maybe_handler { Some(handle) => { let response = handle(challenge_id.clone(), request).await; - ( - StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), - Json(response.body), - ) + let status = match StatusCode::from_u16(response.status) { + Ok(s) => s, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, Json(response.body)) } None => { // No handler registered - return info about the route @@ -353,7 +354,7 @@ async fn jsonrpc_handler( if let Some(arr) = body.as_array() { // For batch, we'd return an array - for now just handle first if let Some(first) = arr.first() { - return handle_single_request(first.clone(), &handler); + return handle_single_request(first.clone(), &handler).await; } return ( StatusCode::BAD_REQUEST, @@ -365,10 +366,13 @@ async fn jsonrpc_handler( ); } - handle_single_request(body, &handler) + handle_single_request(body, &handler).await } -fn handle_single_request(body: Value, handler: &RpcHandler) -> (StatusCode, Json) { +async fn handle_single_request( + body: Value, + handler: &RpcHandler, +) -> (StatusCode, Json) { // Parse the request let req: JsonRpcRequest = match serde_json::from_value(body) { Ok(r) => r, @@ -396,8 +400,8 @@ fn handle_single_request(body: Value, handler: &RpcHandler) -> (StatusCode, Json ); } - // Handle the request - let response = handler.handle(req); + // Handle the request (supports both sync and async methods like challenge_call) + let response = handler.handle_async(req).await; // JSON-RPC always returns 200 OK (errors are in the response body) (StatusCode::OK, Json(response)) @@ -628,7 +632,7 @@ mod tests { let handler = Arc::new(RpcHandler::new(state, 1)); let invalid_body = json!({"method": "test"}); // Missing required fields - let (status, resp) = handle_single_request(invalid_body, &handler); + let (status, resp) = handle_single_request(invalid_body, &handler).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(resp.0.error.is_some()); @@ -650,7 +654,7 @@ mod tests { "params": null, "id": 1 }); - let (status, resp) = handle_single_request(body, &handler); + let (status, resp) = handle_single_request(body, &handler).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(resp.0.error.is_some()); @@ -671,7 +675,7 @@ mod tests { "params": null, "id": 1 }); - let (status, resp) = handle_single_request(body, &handler); + let (status, resp) = handle_single_request(body, &handler).await; assert_eq!(status, StatusCode::OK); assert!(resp.0.result.is_some());