Skip to content
Closed
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
12 changes: 6 additions & 6 deletions crates/challenge-sdk/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#![allow(dead_code, unused_variables, unused_imports)]
//! Platform Challenge SDK
//!
//! SDK for developing challenges on Platform Network.
Expand Down Expand Up @@ -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::*;
Expand All @@ -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
Expand Down
44 changes: 17 additions & 27 deletions crates/challenge-sdk/src/routes.rs
Original file line number Diff line number Diff line change
@@ -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<ChallengeRoute> {
//! vec![
//! ChallengeRoute::get("/leaderboard", "Get current leaderboard"),
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
217 changes: 205 additions & 12 deletions crates/challenge-sdk/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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;
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)]
Expand Down Expand Up @@ -232,6 +271,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,
}

// ============================================================================
// SERVER TRAIT
// ============================================================================
Expand All @@ -257,7 +315,7 @@ pub trait ServerChallenge: Send + Sync {
/// Validate submission data (quick check)
async fn validate(
&self,
request: ValidationRequest,
_request: ValidationRequest,
) -> Result<ValidationResponse, ChallengeError> {
// Default: accept everything
Ok(ValidationResponse {
Expand All @@ -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<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 @@ -362,9 +438,9 @@ impl<C: ServerChallenge + 'static> ChallengeServer<C> {
/// 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,
};
Expand All @@ -374,11 +450,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 +580,104 @@ 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,
};

// 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<Arc<ChallengeDatabase>> = 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
// ============================================================================
Expand Down
Loading