Skip to content
Merged
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
11 changes: 6 additions & 5 deletions crates/challenge-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,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::*;
Expand All @@ -130,9 +130,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
Expand Down
46 changes: 21 additions & 25 deletions crates/challenge-sdk/src/routes.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
//! 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.
//! use platform_challenge_sdk::server::{ServerChallenge, ChallengeContext};
//! impl ServerChallenge for MyChallenge {
//! // ... challenge_id, name, version, evaluate ...
//!
//! Allows challenges to define custom HTTP routes that get mounted
//! on the RPC server. Each challenge can expose its own API endpoints.
//!
//! # Example
Expand All @@ -18,7 +23,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;
Expand All @@ -34,6 +43,15 @@
//! }
//! }
//! ```
//!
//! // The platform SDK provides the generic building blocks (ChallengeRoute,
//! // RouteRequest, RouteResponse, RouteRegistry, RouteBuilder, RoutesManifest,
//! // HttpMethod) — challenges use these to declare their own routes.
//! _ => RouteResponse::not_found()
//! }
//! }
//! }
//! ```

use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand Down Expand Up @@ -103,18 +121,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
Expand Down Expand Up @@ -591,16 +597,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");
Expand Down
185 changes: 178 additions & 7 deletions crates/challenge-sdk/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,57 @@
//! # 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<EvaluationResponse, ChallengeError> {
//! // Your evaluation logic here
//! Ok(EvaluationResponse::success(&req.request_id, 0.95, json!({})))
//! }
//!
//! // Declare custom routes this challenge exposes
//! fn routes(&self) -> Vec<ChallengeRoute> {
//! 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;
Expand All @@ -31,7 +66,9 @@ 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;
Expand Down Expand Up @@ -232,6 +269,25 @@ pub struct ConfigLimits {
pub max_cost: Option<f64>,
}

// ============================================================================
// 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<ChallengeDatabase>,
/// Challenge ID
pub challenge_id: String,
/// Current epoch
pub epoch: u64,
/// Current block height
pub block_height: u64,
}
Comment on lines +272 to +289

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ChallengeContext fields epoch and block_height are always zero in the fallback handler.

While the struct is well-designed, the fallback handler at line 647 hardcodes epoch: 0 and block_height: 0. Route handlers that rely on these fields will get stale/incorrect values. Consider plumbing actual chain state into the context, or documenting that these fields are placeholder in standalone server mode.


// ============================================================================
// SERVER TRAIT
// ============================================================================
Expand Down Expand Up @@ -278,6 +334,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<ChallengeRoute> {
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()
}
}

// ============================================================================
Expand Down Expand Up @@ -374,11 +448,30 @@ impl<C: ServerChallenge + 'static> ChallengeServer<C> {
.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::<C>))
.route("/config", get(config_handler::<C>))
.route("/evaluate", post(evaluate_handler::<C>))
.route("/validate", post(validate_handler::<C>))
.fallback(custom_route_handler::<C>)
.with_state(state);

info!(
Expand Down Expand Up @@ -485,6 +578,84 @@ async fn validate_handler<C: ServerChallenge + 'static>(
}
}

/// Catch-all handler for custom challenge routes declared via `ServerChallenge::routes()`
#[cfg(feature = "http-server")]
async fn custom_route_handler<C: ServerChallenge + 'static>(
State(state): State<Arc<ServerState<C>>>,
method: axum::http::Method,
uri: axum::http::Uri,
axum::extract::Query(query): axum::extract::Query<std::collections::HashMap<String, String>>,
headers: axum::http::HeaderMap,
body: Option<axum::Json<serde_json::Value>>,
) -> (axum::http::StatusCode, axum::Json<serde_json::Value>) {
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,
};

// Build a minimal ChallengeContext (no database in fallback handler)
// In production, the ChallengeContext would be populated by the validator node
let ctx = ChallengeContext {
db: Arc::new(
ChallengeDatabase::open(std::env::temp_dir(), crate::types::ChallengeId::new())
.unwrap_or_else(|_| {
ChallengeDatabase::open(std::env::temp_dir(), crate::types::ChallengeId::new())
.expect("Failed to open temporary challenge database")
}),
),
challenge_id: state.challenge.challenge_id().to_string(),
epoch: 0,
block_height: 0,
};
Comment on lines +635 to +648

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Per-request ephemeral database — data doesn't persist and sled files leak.

Every matched custom-route request opens a new sled database with a fresh ChallengeId::new() in temp_dir. This means:

  1. Data written by the handler is lost immediately after the response — successive requests never see each other's state.
  2. Disk resources leak — sled creates directories/files that are never cleaned up.
  3. The fallback in unwrap_or_else retries the identical operation with yet another random ID, which won't resolve the underlying failure.

The ChallengeContext.db should be created once (e.g., during ChallengeServer::build() or run()) and shared across requests via the ServerState, similar to how challenge and pending_count are already shared.

Sketch: lift DB into ServerState
 pub struct ServerState<C: ServerChallenge> {
     pub challenge: Arc<C>,
     pub config: ServerConfig,
     pub started_at: Instant,
     pub pending_count: Arc<RwLock<u32>>,
+    pub db: Arc<ChallengeDatabase>,
 }

Then in custom_route_handler, build the context from the shared state:

-    let ctx = ChallengeContext {
-        db: Arc::new(
-            ChallengeDatabase::open(std::env::temp_dir(), crate::types::ChallengeId::new())
-                .unwrap_or_else(|_| {
-                    ChallengeDatabase::open(std::env::temp_dir(), crate::types::ChallengeId::new())
-                        .expect("Failed to open temporary challenge database")
-                }),
-        ),
-        challenge_id: state.challenge.challenge_id().to_string(),
-        epoch: 0,
-        block_height: 0,
-    };
+    let ctx = ChallengeContext {
+        db: Arc::clone(&state.db),
+        challenge_id: state.challenge.challenge_id().to_string(),
+        epoch: 0,
+        block_height: 0,
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/challenge-sdk/src/server.rs` around lines 635 - 648, The current code
creates a new ephemeral sled DB per request by calling
ChallengeDatabase::open(...) with ChallengeId::new() inside the handler when
constructing ChallengeContext; move DB creation out of the handler and into the
server initialization (e.g., ChallengeServer::build() or run()) so a single
ChallengeDatabase instance is opened once and stored on ServerState (alongside
challenge and pending_count), then modify custom_route_handler to construct
ChallengeContext using the shared ServerState.db, preserving
challenge_id/epoch/block_height as before; also remove the redundant
unwrap_or_else retry and ensure the shared DB is opened with a deterministic
ChallengeId or configuration to avoid temp-file leaks.


let response = state.challenge.handle_route(&ctx, request).await;

(
axum::http::StatusCode::from_u16(response.status)
.unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
axum::Json(response.body),
)
}

// ============================================================================
// MACROS FOR EASY IMPLEMENTATION
// ============================================================================
Expand Down
Loading