Skip to content

refactor(challenge-sdk): remove hardcoded routes, add custom route handler support#47

Open
echobt wants to merge 5 commits intomainfrom
refactor/remove-hardcoded-routes-custom-handlers
Open

refactor(challenge-sdk): remove hardcoded routes, add custom route handler support#47
echobt wants to merge 5 commits intomainfrom
refactor/remove-hardcoded-routes-custom-handlers

Conversation

@echobt
Copy link
Contributor

@echobt echobt commented Feb 18, 2026

Summary

Removes hardcoded challenge-specific routes from the platform SDK and introduces a generic mechanism for challenges to declare and handle their own routes via the ServerChallenge trait.

The platform SDK should provide infrastructure — not opinionated challenge routes like /leaderboard or /submit. Each challenge (including WASM modules) should define its own API surface.

Changes

crates/challenge-sdk/src/routes.rs

  • Remove RoutesManifest::with_standard_routes() which hardcoded /submit, /status/:hash, /leaderboard, /config, /stats, /health
  • Remove associated test test_routes_manifest_with_standard_routes
  • Update module-level doc comments to emphasize that challenges define their own routes
  • Retain all generic route infrastructure (ChallengeRoute, RouteRequest, RouteResponse, RouteRegistry, RouteBuilder, RoutesManifest, HttpMethod)

crates/challenge-sdk/src/server.rs

  • Add ChallengeContext struct providing route handlers access to local sled database, challenge ID, epoch, and block height
  • Add routes() default method to ServerChallenge trait for declaring custom routes
  • Add handle_route(&self, ctx: &ChallengeContext, request: RouteRequest) -> RouteResponse default method for handling route requests
  • Add custom_route_handler fallback in ChallengeServer::run() that matches and dispatches to challenge-defined routes
  • Log custom routes at startup
  • Update doc comments with full example of custom route declaration and handling

crates/challenge-sdk/src/lib.rs

  • Re-export ChallengeContext from server module
  • Add ChallengeRoute, RouteRequest, RouteResponse to the prelude

crates/rpc-server/src/jsonrpc.rs

  • Implement challenge_call RPC method (was documented but missing from match arms)
  • Add handle_async() method to support async route handler invocation
  • Add challenge_call to the rpc_methods list
  • Params: challengeId, method, path, body, query

crates/rpc-server/src/server.rs

  • Convert handle_single_request from sync to async to support handle_async()
  • Update call sites (including batch handling and tests)

Breaking Changes

  • RoutesManifest::with_standard_routes() is removed. Challenges that relied on it should define their routes via ServerChallenge::routes() instead.

Summary by CodeRabbit

Release Notes

  • New Features

    • Custom route support: Challenges can now declare and handle custom routes via new routes() and handle_route() methods.
    • New RPC method challenge_call for invoking custom routes asynchronously.
    • Enhanced type exports for route handling and challenge context.
  • Breaking Changes

    • Removed with_standard_routes() convenience builder from routes API.

…lenge handlers

Remove challenge-specific hardcoded routes from the platform SDK and extend
the architecture so that each challenge defines its own routes and handlers.

The platform SDK should provide generic infrastructure, not dictate
challenge-specific API routes like /leaderboard, /submit, /status, etc.
Challenges now declare their own routes via ServerChallenge::routes() and
handle them via ServerChallenge::handle_route().

Key changes:

- Remove RoutesManifest::with_standard_routes() which hardcoded /submit,
  /status/:hash, /leaderboard, /config, /stats, /health challenge routes
  and its associated test (crates/challenge-sdk/src/routes.rs)

- Add ChallengeContext struct providing route handlers with access to the
  local sled database, challenge ID, epoch, and block height
  (crates/challenge-sdk/src/server.rs)

- Extend ServerChallenge trait with routes() and handle_route() default
  methods so challenges declare and handle their own custom routes without
  breaking existing implementations (crates/challenge-sdk/src/server.rs)

- Add fallback handler in ChallengeServer::run() that matches incoming
  requests against challenge-declared routes and delegates to
  handle_route() (crates/challenge-sdk/src/server.rs)

- Implement challenge_call RPC method in the JSON-RPC handler, enabling
  route invocation via JSON-RPC with params {challengeId, method, path,
  body, query}. Add handle_async() to support async route handler
  callbacks (crates/rpc-server/src/jsonrpc.rs)

- Convert handle_single_request to async and wire it through
  handle_async() so challenge_call works end-to-end. Update test call
  sites accordingly (crates/rpc-server/src/server.rs)

- Export ChallengeContext, ChallengeRoute, RouteRequest, RouteResponse
  from lib.rs and prelude (crates/challenge-sdk/src/lib.rs)

- Update doc comments in routes.rs and server.rs to show the
  challenge-defines-its-own-routes pattern as the primary usage example
@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Warning

Rate limit exceeded

@echobt has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minutes and 27 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

The changes introduce custom route handling for challenges and asynchronous RPC request processing. A new ChallengeContext is added to encapsulate shared state for route handlers, the ServerChallenge trait gains routes() and handle_route() methods for custom routing, and RPC request handling is converted to async with a new challenge_call endpoint. The RoutesManifest::with_standard_routes() builder is removed.

Changes

Cohort / File(s) Summary
SDK Public Exports
crates/challenge-sdk/src/lib.rs
Publicly exports ChallengeContext, ChallengeRoute, RouteRequest, and RouteResponse in prelude for consumer access.
Routes Configuration
crates/challenge-sdk/src/routes.rs
Removes RoutesManifest::with_standard_routes() builder method and its associated test.
Challenge Server Custom Routes
crates/challenge-sdk/src/server.rs
Introduces ChallengeContext struct with database and challenge metadata. Extends ServerChallenge trait with routes() and handle_route() methods. Adds custom_route_handler function to match incoming requests against declared routes and delegate to challenge handlers.
RPC Async Handler
crates/rpc-server/src/jsonrpc.rs
Adds public async handle_async() method to RpcHandler. Introduces private challenge_call() async method to handle challenge routing RPC requests with parameter validation and route delegation.
RPC Server Async Integration
crates/rpc-server/src/server.rs
Converts handle_single_request to async function. Updates all call sites to await results. Adds explicit JSON-RPC version validation and empty batch error handling.

Sequence Diagram

sequenceDiagram
    participant Client
    participant HTTPServer as HTTP Server<br/>(Challenge)
    participant Handler as custom_route_handler
    participant RouteMatch as Route Matcher
    participant Challenge as ServerChallenge<br/>Implementation
    participant DB as ChallengeDatabase

    Client->>HTTPServer: HTTP Request<br/>(path, method, body)
    HTTPServer->>Handler: Fallback handler invoked
    Handler->>RouteMatch: Match request against<br/>declared routes
    RouteMatch-->>Handler: Route found
    Handler->>DB: Create temporary<br/>ChallengeDatabase
    Handler->>Handler: Build ChallengeContext<br/>(db, challenge_id, epoch, height)
    Handler->>Handler: Build RouteRequest<br/>(method, path, body, query)
    Handler->>Challenge: handle_route(&context,<br/>request)
    Challenge-->>Handler: RouteResponse
    Handler->>HTTPServer: Convert to HTTP<br/>response
    HTTPServer-->>Client: HTTP Response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 Routes now custom-flow with graceful flair,
Async RPC whispers through the air,
Context carries state with careful grace,
Handlers dance at their rightful place,
Challenge SDK spreads its wings so wide!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: removing hardcoded routes and adding custom route handler support, which are the primary objectives of this refactoring PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/remove-hardcoded-routes-custom-handlers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/challenge-sdk/src/routes.rs (1)

1-54: ⚠️ Potential issue | 🟡 Minor

Module doc comment is malformed — duplicate fragments and code outside fenced blocks.

The doc comment has several issues:

  1. Lines 5–7 contain raw Rust code (use ..., impl ServerChallenge ...) that sit outside the ```text fence starting at line 13.
  2. Line 9 is a dangling sentence fragment ("on the RPC server. Each challenge can expose its own API endpoints.") left over from the previous version.
  3. Lines 46–54 duplicate the tail of the example (the _ => RouteResponse::not_found() arm and closing braces) plus an extra closing code fence, resulting in doubled output in rustdoc.

This will render as garbled documentation. Please consolidate into a single, well-formed doc block.

Suggested structure
-//! 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 ...
-//!
-//! on the RPC server. Each challenge can expose its own API endpoints.
-//!
-//! # Example
-//!
-//! ```text
-//! use platform_challenge_sdk::routes::*;
-//!
-//! impl Challenge for MyChallenge {
+//! 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.
+//!
+//! # Example
+//!
+//! ```text
+//! use platform_challenge_sdk::server::{ServerChallenge, ChallengeContext};
+//! use platform_challenge_sdk::routes::*;
+//!
+//! impl ServerChallenge for MyChallenge {
+//!     // ... challenge_id, name, version, evaluate ...
+//!
 //!     fn routes(&self) -> Vec<ChallengeRoute> {

And remove the duplicated closing block at lines 46–54.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/challenge-sdk/src/routes.rs` around lines 1 - 54, The module-level doc
comment is malformed: move the stray raw Rust lines (the use/import and impl
ServerChallenge/ChallengeContext examples) inside the existing fenced code
block, remove the dangling sentence fragment ("on the RPC server...") and delete
the duplicated tail lines that repeat the `_ => RouteResponse::not_found()` arm
and closing braces so the example is a single well-formed fenced block; ensure
the example references the actual symbols used (ServerChallenge,
ChallengeContext, ChallengeRoute, RouteResponse) and that only one closing ```
is present.
🧹 Nitpick comments (3)
crates/challenge-sdk/src/server.rs (2)

272-289: ChallengeContext should derive Clone (and optionally Debug) for ergonomic use in handlers.

All fields are cheaply cloneable (Arc, String, u64). Deriving Clone would allow handlers to move the context into spawned tasks, and Debug aids troubleshooting.

Suggested change
-pub struct ChallengeContext {
+#[derive(Clone, Debug)]
+pub struct ChallengeContext {
🤖 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 272 - 289, The
ChallengeContext struct lacks derived traits; add at least #[derive(Clone)] (and
optionally #[derive(Debug, Clone)]) to the ChallengeContext definition so
instances can be cheaply cloned and moved into spawned tasks—this affects the
struct named ChallengeContext and its fields db (Arc<ChallengeDatabase>),
challenge_id (String), epoch (u64), and block_height (u64).

594-605: state.challenge.routes() is called on every fallback request — consider caching.

Since routes() is called for every unmatched request (including 404s for genuinely unknown paths), and the route list is unlikely to change at runtime, you could cache the result once at startup (it's already computed at line 452) and store it in ServerState.

🤖 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 594 - 605, Replace the
per-request call to state.challenge.routes() with a cached routes collection on
ServerState: add a field (e.g., routes_cache: Arc<Vec<Route>> or similar) to
ServerState, initialize it at startup where the routes are originally computed,
and then use that cached field (e.g., state.routes_cache) instead of calling
state.challenge.routes() in the fallback handler; ensure the cached container is
thread-safe (Arc/clone) and update the fallback loop to iterate over the cached
routes to populate matched_params.
crates/rpc-server/src/server.rs (1)

353-366: Batch JSON-RPC only processes the first request — consider adding a TODO or tracking issue.

The comment at line 354 ("For batch, we'd return an array - for now just handle first") reflects a pre-existing limitation. Per the JSON-RPC 2.0 spec, batch requests should process all elements and return an array of responses. Now that processing is async, it would be natural to handle the full batch with concurrent execution.

Would you like me to open an issue to track full batch JSON-RPC support?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/rpc-server/src/server.rs` around lines 353 - 366, The batch branch
currently only processes the first element (body.as_array() -> arr.first()) and
returns a single response; change it to iterate over all items, spawn or collect
async tasks (e.g., via FuturesUnordered or join_all) calling
handle_single_request for each Value, await all concurrently, collect individual
JsonRpcResponse results into a Vec<Value>, preserve request ordering (or
document ordering behavior), filter out notifications (null responses) per
JSON‑RPC spec, and return a Json array of responses instead of a single element
(use JsonRpcResponse conversions and StatusCode handling as needed); if you
can't fully implement now add a TODO referencing batch support and open a
tracking issue.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/challenge-sdk/src/server.rs`:
- Around line 635-648: The fallback handler currently constructs a new temporary
sled DB on every request by calling ChallengeDatabase::open(...) inside the
creation of ChallengeContext, causing lack of persistence and resource leaks;
instead, create/open the ChallengeDatabase once at server startup (e.g., in
ChallengeServer::run or ChallengeServerBuilder::build), store it in shared state
(add a db: Arc<ChallengeDatabase> to ServerState or ChallengeServer), and change
custom_route_handler to clone/share that Arc when building the ChallengeContext
rather than calling ChallengeDatabase::open per request; remove the per-request
retry logic that re-creates a fresh DB and use the single shared DB instance for
all fallback requests.

In `@crates/rpc-server/src/jsonrpc.rs`:
- Around line 1140-1165: The JSON-RPC challenge_call must resolve a challenge
name to its UUID and run route matching to populate path params like the HTTP
path does; update the code that currently checks self.challenge_routes and
chain_state so that if challenge_id is a name you look up the actual UUID
(mirror the logic in server.rs::challenge_route_handler to produce resolved_id)
and use resolved_id when invoking the handler, and then perform the same route
lookup/matching against the ChallengeRoute(s) for that resolved_id to fill
RouteRequest.params (and any derived headers/query handling) instead of leaving
params empty so JSON-RPC handlers receive the same params as HTTP calls;
reference challenge_call, self.challenge_routes, self.chain_state, RouteRequest
and the matching logic from challenge_route_handler to implement this.

---

Outside diff comments:
In `@crates/challenge-sdk/src/routes.rs`:
- Around line 1-54: The module-level doc comment is malformed: move the stray
raw Rust lines (the use/import and impl ServerChallenge/ChallengeContext
examples) inside the existing fenced code block, remove the dangling sentence
fragment ("on the RPC server...") and delete the duplicated tail lines that
repeat the `_ => RouteResponse::not_found()` arm and closing braces so the
example is a single well-formed fenced block; ensure the example references the
actual symbols used (ServerChallenge, ChallengeContext, ChallengeRoute,
RouteResponse) and that only one closing ``` is present.

---

Nitpick comments:
In `@crates/challenge-sdk/src/server.rs`:
- Around line 272-289: The ChallengeContext struct lacks derived traits; add at
least #[derive(Clone)] (and optionally #[derive(Debug, Clone)]) to the
ChallengeContext definition so instances can be cheaply cloned and moved into
spawned tasks—this affects the struct named ChallengeContext and its fields db
(Arc<ChallengeDatabase>), challenge_id (String), epoch (u64), and block_height
(u64).
- Around line 594-605: Replace the per-request call to state.challenge.routes()
with a cached routes collection on ServerState: add a field (e.g., routes_cache:
Arc<Vec<Route>> or similar) to ServerState, initialize it at startup where the
routes are originally computed, and then use that cached field (e.g.,
state.routes_cache) instead of calling state.challenge.routes() in the fallback
handler; ensure the cached container is thread-safe (Arc/clone) and update the
fallback loop to iterate over the cached routes to populate matched_params.

In `@crates/rpc-server/src/server.rs`:
- Around line 353-366: The batch branch currently only processes the first
element (body.as_array() -> arr.first()) and returns a single response; change
it to iterate over all items, spawn or collect async tasks (e.g., via
FuturesUnordered or join_all) calling handle_single_request for each Value,
await all concurrently, collect individual JsonRpcResponse results into a
Vec<Value>, preserve request ordering (or document ordering behavior), filter
out notifications (null responses) per JSON‑RPC spec, and return a Json array of
responses instead of a single element (use JsonRpcResponse conversions and
StatusCode handling as needed); if you can't fully implement now add a TODO
referencing batch support and open a tracking issue.

Comment on lines +1140 to +1165
// 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,
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

challenge_call doesn't resolve challenge name → ID, nor populate path params — inconsistent with the HTTP handler.

Two issues in the challenge lookup and request construction:

  1. Name-to-ID resolution missing: If the caller passes a challenge name instead of its UUID, lines 1145-1153 verify it exists but never resolve the actual ID. The handler is then invoked with the raw name. Compare with challenge_route_handler in server.rs (lines 240–258) which properly resolves actual_id from the name.

  2. Route matching not performed: params is always an empty HashMap (line 1160). The HTTP path (challenge_route_handler in server.rs) performs route matching against declared ChallengeRoutes and populates path params (e.g., :hash"abc123"). Challenge handlers that rely on request.params will receive different inputs depending on whether the call came via HTTP or JSON-RPC.

Suggested fix sketch
-        // 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,
-        };
+        // Resolve challenge name → ID and look up registered routes
+        let (resolved_id, challenge_routes) = {
+            let routes = self.challenge_routes.read();
+            if let Some(r) = routes.get(&challenge_id) {
+                (challenge_id.clone(), r.clone())
+            } else {
+                // Try to find by name
+                let chain = self.chain_state.read();
+                let actual_id = chain
+                    .challenges
+                    .values()
+                    .find(|c| c.name == challenge_id)
+                    .map(|c| c.id.to_string());
+                match actual_id.and_then(|id| routes.get(&id).cloned().map(|r| (id, r))) {
+                    Some((id, r)) => (id, r),
+                    None => {
+                        return JsonRpcResponse::error(
+                            id,
+                            CHALLENGE_NOT_FOUND,
+                            format!("Challenge '{}' not found or has no routes", challenge_id),
+                        );
+                    }
+                }
+            }
+        };
+
+        // Perform route matching to populate path params
+        let mut matched_params = std::collections::HashMap::new();
+        for route in &challenge_routes {
+            if let Some(params) = route.matches(&method, &path) {
+                matched_params = params;
+                break;
+            }
+        }
+
+        let request = RouteRequest {
+            method,
+            path,
+            params: matched_params,
+            query,
+            headers: std::collections::HashMap::new(),
+            body,
+            auth_hotkey: None,
+        };

Then use resolved_id when invoking the handler.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/rpc-server/src/jsonrpc.rs` around lines 1140 - 1165, The JSON-RPC
challenge_call must resolve a challenge name to its UUID and run route matching
to populate path params like the HTTP path does; update the code that currently
checks self.challenge_routes and chain_state so that if challenge_id is a name
you look up the actual UUID (mirror the logic in
server.rs::challenge_route_handler to produce resolved_id) and use resolved_id
when invoking the handler, and then perform the same route lookup/matching
against the ChallengeRoute(s) for that resolved_id to fill RouteRequest.params
(and any derived headers/query handling) instead of leaving params empty so
JSON-RPC handlers receive the same params as HTTP calls; reference
challenge_call, self.challenge_routes, self.chain_state, RouteRequest and the
matching logic from challenge_route_handler to implement this.

…lidation

- Remove #![allow(dead_code, unused_variables, unused_imports)] from challenge-sdk lib.rs
- Replace .expect() and .unwrap_or_else() in custom_route_handler with proper error handling
- Fix per-request temp database creation (DoS vector) using OnceLock for shared fallback DB
- Replace .unwrap_or() on StatusCode::from_u16 with explicit match in both server.rs files
- Add HTTP method validation in challenge_call (only GET/POST/PUT/DELETE/PATCH allowed)
- Add path traversal protection in challenge_call (reject '..' segments, require '/' prefix)
- Add input length limits: path <= 2048 chars, body <= 1MB, query params <= 100
- Fix unused import warnings exposed by removing blanket allow attribute
…server.rs

- Add #[cfg(feature = "http-server")] gated imports for tracing macros
  (debug, error, info) used in run() and custom_route_handler
- Add #[cfg(feature = "http-server")] gated import for std::sync::OnceLock
  used in custom_route_handler's static FALLBACK_DB
- Remove unused local imports (Json, State, StatusCode) from run() method
  that shadow top-level imports and are not used locally

These fixes resolve compilation errors when building with the http-server
feature flag and eliminate unused import warnings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments