Skip to main content

hypercall_api/
routes.rs

1use axum::{
2    extract::DefaultBodyLimit,
3    middleware as axum_middleware,
4    response::IntoResponse,
5    routing::{delete, get, post, put},
6    Router,
7};
8use hypercall_types::{
9    API_ROUTE_APPROVE_AGENT, API_ROUTE_BULK_ORDER, API_ROUTE_BULK_ORDER_CLOID,
10    API_ROUTE_MARGIN_MODE, API_ROUTE_NOTIFICATIONS_MARK_ALL_READ,
11    API_ROUTE_NOTIFICATIONS_MARK_READ, API_ROUTE_ORDER, API_ROUTE_ORDERBOOK, API_ROUTE_ORDERS,
12    API_ROUTE_ORDER_CLOID, API_ROUTE_PROFILE_IMAGE, API_ROUTE_PUSH_PREFERENCES,
13    API_ROUTE_PUSH_SUBSCRIBE, API_ROUTE_PUSH_UNSUBSCRIBE, API_ROUTE_REVOKE_AGENT,
14    API_ROUTE_RFQ_ACCEPT, API_ROUTE_RFQ_REQUEST, API_ROUTE_SETTLEMENT_PAYOUTS,
15    API_ROUTE_SETTLEMENT_PAYOUTS_SEEN, API_ROUTE_USERNAME,
16};
17#[cfg(feature = "api-reference")]
18use scalar_api_reference::scalar_html_default;
19use std::sync::Arc;
20use std::time::Duration;
21use tower_http::{
22    catch_panic::CatchPanicLayer,
23    cors::CorsLayer,
24    trace::{self, TraceLayer},
25};
26use tracing::Level;
27#[cfg(feature = "api-reference")]
28use utoipa_swagger_ui::SwaggerUi;
29
30use super::{directives, handlers, middleware, websocket};
31use crate::rfq::handler_state::RfqHandlerState;
32use crate::rfq::qp_ws_state::QpWsState;
33use crate::runtime_status::{ReadinessGate, StandbyReplayProgress};
34
35type PromoteSender = Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>>;
36
37pub struct BuildAppParams {
38    pub app_state: handlers::AppState,
39    pub rate_limit_state: middleware::RateLimitState,
40    pub ws_state: websocket::WsState,
41    pub qp_ws_state: QpWsState,
42    pub rfq_handler_state: RfqHandlerState,
43    pub readiness: Arc<dyn ReadinessGate>,
44    pub standby_progress: Option<Arc<dyn StandbyReplayProgress>>,
45    pub standby_promote: Option<PromoteSender>,
46    /// Pre-composed admin router (already merged with the api-owned competition
47    /// routes and wrapped in the admin auth layer by the server).
48    pub admin_router: Router,
49    /// OpenAPI spec served by the live Swagger/Scalar routes. The server merges
50    /// `hypercall_admin::admin_openapi()` into the api spec (it cannot be merged
51    /// inside this crate without an api -> admin dependency), so the live
52    /// `/api-docs/openapi.json` matches the committed spec the exporter produces.
53    pub openapi_doc: utoipa::openapi::OpenApi,
54}
55
56#[cfg(feature = "otel-tracing")]
57fn with_trace_context_layer(app: Router) -> Router {
58    // Extract W3C Trace Context from incoming requests for distributed tracing.
59    app.layer(axum_middleware::from_fn(
60        middleware::trace_context_middleware,
61    ))
62}
63
64#[cfg(not(feature = "otel-tracing"))]
65fn with_trace_context_layer(app: Router) -> Router {
66    app
67}
68
69/// Handler for Prometheus metrics endpoint.
70async fn metrics_handler(
71    axum::extract::State(app_state): axum::extract::State<handlers::AppState>,
72) -> impl IntoResponse {
73    (
74        [(
75            axum::http::header::CONTENT_TYPE,
76            "text/plain; version=0.0.4; charset=utf-8",
77        )],
78        app_state.metrics_renderer.render_metrics(),
79    )
80}
81
82pub fn build_app(params: BuildAppParams) -> Router {
83    let BuildAppParams {
84        app_state,
85        rate_limit_state,
86        ws_state,
87        qp_ws_state,
88        rfq_handler_state,
89        readiness,
90        standby_progress,
91        standby_promote,
92        admin_router,
93        openapi_doc,
94    } = params;
95
96    let app = Router::new().merge(public_routes(&app_state, rate_limit_state.clone()));
97    #[cfg(feature = "rsm-state")]
98    let app = app.merge(rsm_rpc_routes(&app_state));
99    let app = app
100        .merge(write_routes(&app_state, rate_limit_state))
101        .merge(bulk_routes(&app_state))
102        .merge(directives_routes(&app_state))
103        .merge(market_support_routes(&app_state))
104        .merge(websocket_routes(ws_state, qp_ws_state))
105        .merge(rfq_routes(rfq_handler_state.clone()))
106        .merge(admin_router);
107
108    let app = with_api_reference_routes(app, openapi_doc);
109    with_global_layers(app, readiness, standby_progress, standby_promote)
110}
111
112fn public_routes(
113    app_state: &handlers::AppState,
114    rate_limit_state: middleware::RateLimitState,
115) -> Router {
116    // Public routes (no authentication required)
117    // Wallet-scoped read endpoints get rate limiting via api_rate_limit_middleware.
118    let rate_limited_public_routes = Router::new()
119        .route("/portfolio", get(handlers::account::get_portfolio))
120        .route(
121            "/portfolio/greeks",
122            get(handlers::greeks::get_portfolio_greeks),
123        )
124        .route("/profile", get(handlers::competition::get_profile))
125        .route(
126            "/profile/trades",
127            get(handlers::competition::get_profile_trades),
128        )
129        .route(
130            "/profile/realized-pnl",
131            get(handlers::competition::get_realized_pnl),
132        )
133        .route(
134            "/competitions/leaderboard",
135            get(handlers::competition::get_competition_leaderboard),
136        )
137        .route("/risk/grid", get(handlers::account::get_risk_grid))
138        .route("/fills", get(handlers::account::get_fills))
139        .route(API_ROUTE_ORDERS, get(handlers::account::get_orders))
140        .route(
141            "/notifications",
142            get(handlers::notifications::list_notifications),
143        )
144        .route(
145            "/historical-pnl",
146            get(handlers::historical_pnl::get_historical_pnl),
147        )
148        .route("/mmp-config", get(handlers::admin::get_mmp_config))
149        .route("/user-tier", get(handlers::admin::get_user_tier))
150        // Username lookup (public, rate-limited)
151        .route(API_ROUTE_USERNAME, get(handlers::username::get_username))
152        .layer(axum_middleware::from_fn_with_state(
153            rate_limit_state,
154            middleware::api_rate_limit_middleware,
155        ))
156        .with_state(app_state.clone());
157
158    let public_routes = Router::new()
159        .route("/trades", get(handlers::account::get_trades))
160        .route("/markets", get(handlers::account::get_markets))
161        .route("/health", get(handlers::health::health))
162        .route("/version", get(handlers::health::version))
163        .route("/ready", get(handlers::health::ready))
164        .route("/exchange-info", get(handlers::health::exchange_info))
165        .route("/standby-ready", get(handlers::health::standby_ready))
166        .route("/metrics", get(metrics_handler))
167        .route("/instruments", get(handlers::market_data::get_instruments))
168        .route(
169            "/instrument-specs",
170            get(handlers::market_data::get_instrument_specs),
171        )
172        .route(
173            "/gas-provider/:chain_id",
174            get(handlers::gas_provider::get_gas_provider),
175        )
176        .route("/candles", get(handlers::candles::get_candles))
177        .route(
178            "/options-summary",
179            get(handlers::market_data::get_options_summary),
180        )
181        .route(
182            "/expiry-summary",
183            get(handlers::market_data::get_expiry_summary),
184        )
185        .route(
186            API_ROUTE_ORDERBOOK,
187            get(handlers::options_chain::get_orderbook),
188        )
189        .route(
190            "/historical-theos",
191            get(handlers::historical_theos::get_historical_theos),
192        )
193        .route(
194            "/historical-theos/batch",
195            get(handlers::historical_theos::get_historical_theos_batch),
196        )
197        .route(
198            "/competitions",
199            get(handlers::competition::list_competitions),
200        )
201        .route(
202            "/competitions/:id",
203            get(handlers::competition::get_competition),
204        )
205        // Agent authorization management
206        .route(
207            API_ROUTE_APPROVE_AGENT,
208            post(handlers::agents::approve_agent),
209        )
210        .route(
211            API_ROUTE_REVOKE_AGENT,
212            delete(handlers::agents::revoke_agent),
213        )
214        .route(
215            "/authorized-agents",
216            get(handlers::agents::get_authorized_agents),
217        );
218
219    #[cfg(feature = "faucet")]
220    let public_routes = public_routes.route("/faucet", post(handlers::testnet::faucet));
221
222    #[cfg(feature = "test-endpoints")]
223    let public_routes = public_routes
224        .route("/test/spot-price", post(handlers::testnet::set_spot_price))
225        .route("/test/option-iv", post(handlers::testnet::set_option_iv))
226        .route(
227            "/test/balance-ledger",
228            post(handlers::testnet::get_balance_ledger),
229        )
230        .route(
231            "/test/option-deposit",
232            post(handlers::testnet::apply_option_deposit),
233        )
234        .route(
235            "/test/cancel-all-orders",
236            post(handlers::admin::cancel_all_orders),
237        )
238        .route(
239            "/test/expire-instrument",
240            post(handlers::testnet::expire_instrument),
241        );
242
243    public_routes
244        .merge(rate_limited_public_routes)
245        .with_state(app_state.clone())
246}
247
248#[cfg(feature = "rsm-state")]
249fn rsm_rpc_routes(app_state: &handlers::AppState) -> Router {
250    if std::env::var("RSM_RPC_ENABLED")
251        .map(|value| value == "true" || value == "1")
252        .unwrap_or(false)
253    {
254        tracing::info!("RSM block JSON-RPC enabled at /rsm/rpc");
255        Router::new()
256            .route("/rsm/rpc", post(handlers::rsm_rpc::rsm_rpc))
257            .with_state(app_state.clone())
258    } else {
259        tracing::info!("RSM block JSON-RPC disabled (set RSM_RPC_ENABLED=true to enable)");
260        Router::new().with_state(app_state.clone())
261    }
262}
263
264fn write_routes(
265    app_state: &handlers::AppState,
266    rate_limit_state: middleware::RateLimitState,
267) -> Router {
268    // Write endpoints (require EIP-712 signature authentication)
269    // Middleware order: signature_and_agent_middleware (outer, runs first) -> write_route_rate_limit (inner, runs second)
270    Router::new()
271        .route(API_ROUTE_ORDER, post(handlers::orders::place_order))
272        .route(
273            API_ROUTE_ORDER,
274            put(handlers::replace_orders::replace_order),
275        )
276        .route(API_ROUTE_ORDER, delete(handlers::orders::cancel_order))
277        .route(
278            API_ROUTE_ORDER_CLOID,
279            delete(handlers::orders::cancel_order_by_cloid),
280        )
281        .route(
282            API_ROUTE_SETTLEMENT_PAYOUTS_SEEN,
283            post(handlers::settlement::mark_settlement_payouts_seen),
284        )
285        .route("/withdraw/option", post(handlers::account::withdraw_option))
286        .route("/withdraw/usdc", post(handlers::account::withdraw_usdc))
287        .route(
288            API_ROUTE_PROFILE_IMAGE,
289            post(handlers::profile_image::set_profile_image)
290                .layer(DefaultBodyLimit::max(7_000_000)),
291        )
292        // MMP (Market Maker Protection) endpoints
293        .route("/mmp-config", post(handlers::admin::set_mmp_config))
294        .route("/mmp-config", delete(handlers::admin::delete_mmp_config))
295        .route("/mmp-config/reset", post(handlers::admin::reset_mmp))
296        // User tier management endpoints (admin only)
297        .route("/user-tier", post(handlers::admin::set_user_tier))
298        .route("/user-tier", delete(handlers::admin::delete_user_tier))
299        // Username management endpoints
300        .route(API_ROUTE_USERNAME, post(handlers::username::set_username))
301        .route(
302            API_ROUTE_USERNAME,
303            delete(handlers::username::delete_username),
304        )
305        // Push notification subscription management
306        .route(
307            API_ROUTE_PUSH_SUBSCRIBE,
308            post(handlers::push::push_subscribe),
309        )
310        .route(
311            API_ROUTE_PUSH_UNSUBSCRIBE,
312            post(handlers::push::push_unsubscribe),
313        )
314        .route(
315            API_ROUTE_PUSH_PREFERENCES,
316            post(handlers::push::push_preferences),
317        )
318        // Persisted notification feed (mark-read requires signature; list is
319        // unsigned-with-wallet-query, registered on the public router above)
320        .route(
321            API_ROUTE_NOTIFICATIONS_MARK_READ,
322            post(handlers::notifications::mark_read),
323        )
324        .route(
325            API_ROUTE_NOTIFICATIONS_MARK_ALL_READ,
326            post(handlers::notifications::mark_all_read),
327        )
328        // Margin mode management (requires zero positions)
329        .route(
330            API_ROUTE_MARGIN_MODE,
331            post(handlers::account::set_margin_mode),
332        )
333        // Rate limiting layer (runs after signature middleware extracts wallet)
334        .layer(axum_middleware::from_fn_with_state(
335            rate_limit_state,
336            middleware::write_route_rate_limit_middleware,
337        ))
338        // Signature authentication layer (runs first, sets SignerContext)
339        .layer(axum_middleware::from_fn_with_state(
340            middleware::SignatureMiddlewareState {
341                agent_auth: app_state.agent_auth.clone(),
342                auth_failure_recorder: app_state.auth_failure_recorder.clone(),
343                signing_chain_id: app_state.runtime_config.signing_chain_id,
344            },
345            middleware::signature_and_agent_middleware,
346        ))
347        .with_state(app_state.clone())
348}
349
350fn bulk_routes(app_state: &handlers::AppState) -> Router {
351    // Bulk order endpoints (handle signature verification internally for each order)
352    Router::new()
353        .route(
354            API_ROUTE_BULK_ORDER,
355            post(handlers::bulk_orders::bulk_place_order),
356        )
357        .route(
358            API_ROUTE_BULK_ORDER,
359            put(handlers::replace_orders::bulk_replace_order),
360        )
361        .route(
362            API_ROUTE_BULK_ORDER,
363            delete(handlers::bulk_orders::bulk_cancel_order),
364        )
365        .route(
366            API_ROUTE_BULK_ORDER_CLOID,
367            delete(handlers::bulk_orders::bulk_cancel_order_by_cloid),
368        )
369        .with_state(app_state.clone())
370}
371
372fn directives_routes(app_state: &handlers::AppState) -> Router {
373    directives::router()
374        .route(
375            "/v1/directives/:directive_id",
376            get(handlers::directives::get_directive_status),
377        )
378        .route(
379            "/v1/withdrawals",
380            get(handlers::directives::get_withdrawal_history),
381        )
382        .with_state(app_state.clone())
383}
384
385fn market_support_routes(app_state: &handlers::AppState) -> Router {
386    // Greeks endpoint (public, no auth required)
387    let greeks_routes = Router::new()
388        .route("/greeks", get(handlers::greeks::get_greeks))
389        .with_state(app_state.clone());
390
391    // Liquidation endpoints (public, wallet is passed as query param)
392    let liquidation_routes = Router::new()
393        .route(
394            "/liquidation/status",
395            get(handlers::liquidation::get_liquidation_status),
396        )
397        .route(
398            "/liquidation/history",
399            get(handlers::liquidation::get_liquidation_history),
400        )
401        .route(
402            "/liquidation/auction/:auction_id",
403            get(handlers::liquidation::get_liquidation_auction),
404        )
405        .with_state(app_state.clone());
406
407    // Settlement payout history endpoint (public, wallet is passed as query param)
408    let settlement_routes = Router::new()
409        .route(
410            API_ROUTE_SETTLEMENT_PAYOUTS,
411            get(handlers::settlement::get_settlement_payouts),
412        )
413        .with_state(app_state.clone());
414
415    Router::new()
416        .merge(greeks_routes)
417        .merge(liquidation_routes)
418        .merge(settlement_routes)
419}
420
421fn websocket_routes(ws_state: websocket::WsState, qp_ws_state: QpWsState) -> Router {
422    // WebSocket route
423    let ws_routes = Router::new()
424        .route("/ws", get(websocket::websocket_handler))
425        .with_state(ws_state);
426
427    // QP WebSocket route for /ws/quotes
428    let qp_ws_routes = Router::new()
429        .route("/ws/quotes", get(websocket::rfq::qp_websocket_handler))
430        .with_state(qp_ws_state);
431
432    Router::new().merge(ws_routes).merge(qp_ws_routes)
433}
434
435fn rfq_routes(rfq_handler_state: RfqHandlerState) -> Router {
436    // RFQ routes (public: submit, get status, history).
437    // The admin quote-provider routes now live in the `hypercall-admin` crate's
438    // `admin_router`, composed at the server root.
439    Router::new()
440        .route(API_ROUTE_RFQ_REQUEST, post(handlers::rfq::submit_rfq))
441        .route(API_ROUTE_RFQ_ACCEPT, post(handlers::rfq::accept_rfq_quote))
442        .route("/rfq/:rfq_id", get(handlers::rfq::get_rfq))
443        .route("/rfq/history", get(handlers::rfq::get_rfq_history))
444        .with_state(rfq_handler_state)
445}
446
447#[cfg(feature = "api-reference")]
448fn with_api_reference_routes(mut app: Router, openapi_doc: utoipa::openapi::OpenApi) -> Router {
449    // These are public docs routes, so always strip the hidden Monitoring/Admin
450    // tags here regardless of what the caller passed. This keeps the guarantee
451    // local to the public route even though the server already filters.
452    let openapi_doc = crate::openapi::filter_hidden_tags(openapi_doc);
453
454    // Always enable Swagger UI. The spec is provided by the server, which merges
455    // the admin crate's paths in, so the live spec matches the exporter output.
456    tracing::info!("Swagger UI enabled at /swagger-ui");
457    app = app.merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", openapi_doc));
458
459    // Redirect / and /docs to the hosted API reference
460    let docs_redirect =
461        || async { axum::response::Redirect::to("https://docs.hypercall.xyz/api-reference") };
462    app = app
463        .route("/", axum::routing::get(docs_redirect))
464        .route("/docs", axum::routing::get(docs_redirect));
465
466    // Scalar API Reference at /scalar
467    tracing::info!("Scalar API Reference enabled at /scalar");
468    let scalar_config = serde_json::json!({
469        "url": "/api-docs/openapi.json",
470        "theme": "purple"
471    });
472    let scalar_html = scalar_html_default(&scalar_config);
473    app.route(
474        "/scalar",
475        axum::routing::get(move || {
476            let html = scalar_html.clone();
477            async move { axum::response::Html(html) }
478        }),
479    )
480}
481
482#[cfg(not(feature = "api-reference"))]
483fn with_api_reference_routes(app: Router, _openapi_doc: utoipa::openapi::OpenApi) -> Router {
484    app
485}
486
487fn with_global_layers(
488    app: Router,
489    readiness: Arc<dyn ReadinessGate>,
490    standby_progress: Option<Arc<dyn StandbyReplayProgress>>,
491    standby_promote: Option<PromoteSender>,
492) -> Router {
493    let app = app.layer(axum_middleware::from_fn_with_state(
494        middleware::ReadinessMiddlewareState::new(readiness, standby_progress, standby_promote),
495        middleware::readiness_middleware,
496    ));
497    let app = with_trace_context_layer(app);
498
499    app.layer(
500        TraceLayer::new_for_http()
501            .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO))
502            .on_response(trace::DefaultOnResponse::new().level(Level::INFO))
503            .on_failure(trace::DefaultOnFailure::new().level(Level::ERROR)),
504    )
505    .layer(CatchPanicLayer::new())
506    .layer(CorsLayer::permissive().max_age(Duration::from_secs(86400)))
507}