use actix_web::{web, HttpResponse};
#[cfg(feature = "experimental")]
use tracing::warn;
use tracing::{info, instrument};
use super::{dto, ApiError, AppState};
use crate::api::error::ErrorResponse;
#[cfg(feature = "experimental")]
use crate::api::prices::{
price_to_f64, ComputationBlocks, IncludeField, PoolDepthEntry, PricesQuery, PricesResponse,
SpotPriceEntry, TokenPriceEntry,
};
pub(crate) fn configure_routes(cfg: &mut web::ServiceConfig) {
let scope = web::scope("/v1")
.route("/quote", web::post().to(quote))
.route("/health", web::get().to(health))
.route("/info", web::get().to(info));
#[cfg(feature = "experimental")]
let scope = scope.route("/prices", web::get().to(get_prices));
cfg.service(scope);
}
#[utoipa::path(
post,
path = "/v1/quote",
tag = "solver",
request_body = dto::QuoteRequest,
responses(
(status = 200, description = "Quote completed", body = dto::Quote),
(status = 400, description = "Invalid request", body = ErrorResponse),
(status = 422, description = "No route found", body = ErrorResponse),
(status = 503, description = "Service unavailable", body = ErrorResponse),
(status = 503, description = "Queue full, overloaded, stale data, or timeout", body = ErrorResponse),
)
)]
#[instrument(skip(state, request), fields(num_orders = request.orders().len()))]
pub(crate) async fn quote(
state: web::Data<AppState>,
request: web::Json<dto::QuoteRequest>,
) -> Result<HttpResponse, ApiError> {
let dto_request = request.into_inner();
if dto_request.orders().is_empty() {
return Err(ApiError::BadRequest("no orders provided".to_string()));
}
let core_request: fynd_core::QuoteRequest = dto_request.into();
for order in core_request.orders() {
if let Err(e) = order.validate() {
return Err(ApiError::BadRequest(format!("invalid order {}: {}", order.id(), e)));
}
}
let core_quote = state
.worker_router()
.quote(core_request)
.await?;
info!(
solve_time_ms = core_quote.solve_time_ms(),
num_orders = core_quote.orders().len(),
num_pools = state.worker_router().num_pools(),
"quote completed"
);
let dto_quote: dto::Quote = core_quote.into();
Ok(HttpResponse::Ok().json(dto_quote))
}
#[utoipa::path(
get,
path = "/v1/health",
tag = "health",
responses(
(status = 200, description = "Service healthy", body = dto::HealthStatus),
(status = 503, description = "Data stale", body = dto::HealthStatus),
)
)]
pub(crate) async fn health(state: web::Data<AppState>) -> HttpResponse {
let age_ms = state.health_tracker().age_ms().await;
let data_fresh = age_ms < 60_000; let derived_data_ready = state
.health_tracker()
.derived_data_ready()
.await;
let gas_price_age_ms = state
.health_tracker()
.gas_price_age_ms()
.await;
let gas_stale = state
.health_tracker()
.gas_price_stale()
.await;
let is_healthy = data_fresh && derived_data_ready && !gas_stale;
let status = dto::HealthStatus::new(
is_healthy,
age_ms,
state.worker_router().num_pools(),
derived_data_ready,
gas_price_age_ms,
);
if is_healthy {
HttpResponse::Ok().json(status)
} else {
HttpResponse::ServiceUnavailable().json(status)
}
}
#[utoipa::path(
get,
path = "/v1/info",
tag = "solver",
responses(
(status = 200, description = "Instance info", body = dto::InstanceInfo),
)
)]
pub(crate) async fn info(state: web::Data<AppState>) -> HttpResponse {
let body = dto::InstanceInfo::new(
state.chain_id(),
state.router_address().clone().into(),
state.permit2_address().clone().into(),
);
HttpResponse::Ok().json(body)
}
#[cfg(feature = "experimental")]
const DEFAULT_PRICES_LIMIT: usize = 1000;
#[cfg(feature = "experimental")]
#[utoipa::path(
get,
path = "/v1/prices",
tag = "prices",
params(PricesQuery),
responses(
(status = 200, description = "Prices returned", body = PricesResponse),
(status = 400, description = "Invalid query parameter", body = ErrorResponse),
(status = 503, description = "Data not yet available", body = ErrorResponse),
)
)]
#[instrument(skip(state))]
pub async fn get_prices(
state: web::Data<AppState>,
query: web::Query<PricesQuery>,
) -> Result<HttpResponse, ApiError> {
let include_fields = match &query.include {
Some(raw) => IncludeField::parse_include(raw).map_err(ApiError::BadRequest)?,
None => vec![],
};
let limit = query
.limit
.unwrap_or(DEFAULT_PRICES_LIMIT);
let want_depths = include_fields.contains(&IncludeField::Depths);
let want_spot = include_fields.contains(&IncludeField::SpotPrices);
let store = state.derived_data.read().await;
let token_prices_block = store
.token_prices_block()
.ok_or(ApiError::StaleData { age_ms: u64::MAX })?;
if want_spot && store.spot_prices_block().is_none() {
return Err(ApiError::StaleData { age_ms: u64::MAX });
}
if want_depths && store.pool_depths_block().is_none() {
return Err(ApiError::StaleData { age_ms: u64::MAX });
}
let spot_prices_block = store.spot_prices_block();
let pool_depths_block = store.pool_depths_block();
let token_prices = store.token_prices().cloned();
let spot_prices_data = if want_spot { store.spot_prices().cloned() } else { None };
let pool_depths_data = if want_depths { store.pool_depths().cloned() } else { None };
drop(store);
let mut prices = Vec::new();
if let Some(token_prices) = &token_prices {
for (address, price) in token_prices {
match price_to_f64(&price.numerator, &price.denominator) {
Some(f) => {
prices.push(TokenPriceEntry { token: address.clone(), price: f });
}
None => {
warn!(
token = %address,
"skipping token with unconvertible price (zero denom or overflow)"
);
}
}
}
}
let spot_prices = if want_spot {
let mut entries: Vec<SpotPriceEntry> = spot_prices_data
.into_iter()
.flatten()
.map(|((component_id, token_in, token_out), price)| SpotPriceEntry {
component_id,
token_in,
token_out,
price,
})
.collect();
entries.sort_by(|a, b| {
(&a.component_id, &a.token_in, &a.token_out).cmp(&(
&b.component_id,
&b.token_in,
&b.token_out,
))
});
entries.truncate(limit);
Some(entries)
} else {
None
};
let pool_depths = if want_depths {
let mut entries: Vec<PoolDepthEntry> = pool_depths_data
.into_iter()
.flatten()
.map(|((component_id, token_in, token_out), depth)| PoolDepthEntry {
component_id,
token_in,
token_out,
depth: depth.to_string(),
})
.collect();
entries.sort_by(|a, b| {
(&a.component_id, &a.token_in, &a.token_out).cmp(&(
&b.component_id,
&b.token_in,
&b.token_out,
))
});
entries.truncate(limit);
Some(entries)
} else {
None
};
let response = PricesResponse {
prices,
gas_token: state.gas_token.clone(),
blocks: ComputationBlocks {
token_prices: token_prices_block,
spot_prices: spot_prices_block,
pool_depths: pool_depths_block,
},
spot_prices,
pool_depths,
};
info!(
num_tokens = response.prices.len(),
has_spot = response.spot_prices.is_some(),
has_depths = response.pool_depths.is_some(),
"prices response"
);
Ok(HttpResponse::Ok().json(response))
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use actix_web::{test, web, App, HttpResponse};
use fynd_core::{
derived::SharedDerivedDataRef,
encoding::encoder::Encoder,
feed::market_data::{SharedMarketData, SharedMarketDataRef},
worker_pool_router::{config::WorkerPoolRouterConfig, WorkerPoolRouter},
};
use serde_json::Value;
use tycho_execution::encoding::evm::swap_encoder::swap_encoder_registry::SwapEncoderRegistry;
use tycho_simulation::tycho_common::{models::Chain, Bytes};
use crate::api::{dto::QuoteRequest, AppState, HealthTracker};
async fn echo_quote(_req: web::Json<QuoteRequest>) -> HttpResponse {
HttpResponse::Ok().finish()
}
macro_rules! make_test_app {
() => {
test::init_service(
App::new()
.configure(crate::api::configure_error_handlers)
.route("/v1/quote", web::post().to(echo_quote)),
)
.await
};
}
async fn body_json(resp: actix_web::dev::ServiceResponse) -> Value {
let bytes = test::read_body(resp).await;
serde_json::from_slice(&bytes)
.unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned()))
}
fn make_test_state() -> AppState {
let market_data: SharedMarketDataRef =
Arc::new(tokio::sync::RwLock::new(SharedMarketData::new()));
let derived_data: SharedDerivedDataRef =
Arc::new(tokio::sync::RwLock::new(Default::default()));
let registry = SwapEncoderRegistry::new(Chain::Ethereum)
.add_default_encoders(None)
.expect("default encoders should always succeed");
let encoder = Encoder::new(Chain::Ethereum, registry).expect("encoder should build");
let router = WorkerPoolRouter::new(vec![], WorkerPoolRouterConfig::default(), encoder);
let health_tracker =
HealthTracker::new(Arc::clone(&market_data), Arc::clone(&derived_data));
let router_address =
Bytes::from(hex::decode("fD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap());
let permit2_address =
Bytes::from(hex::decode("000000000022D473030F116dDEE9F6B43aC78BA3").unwrap());
AppState::new(
router,
health_tracker,
1,
router_address,
permit2_address,
#[cfg(feature = "experimental")]
derived_data,
#[cfg(feature = "experimental")]
tycho_simulation::tycho_common::models::Address::from([0u8; 20]),
)
}
#[actix_web::test]
async fn test_unknown_route_returns_json_404() {
use crate::api::error::ErrorResponse;
let app = test::init_service(
App::new()
.configure(crate::api::configure_error_handlers)
.route("/v1/quote", web::post().to(echo_quote))
.default_service(web::to(|| async {
let body = ErrorResponse::new("not found".into(), "NOT_FOUND".into());
HttpResponse::NotFound().json(body)
})),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/does-not-exist")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 404);
let body = body_json(resp).await;
assert_eq!(body["code"], "NOT_FOUND", "body was: {body}");
}
#[actix_web::test]
async fn test_malformed_json_returns_json_error() {
let app = make_test_app!();
let req = test::TestRequest::post()
.uri("/v1/quote")
.insert_header(("content-type", "application/json"))
.set_payload("{not valid json}")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 400);
let body = body_json(resp).await;
assert_eq!(body["code"], "BAD_REQUEST", "body was: {body}");
assert!(body["error"].is_string(), "body was: {body}");
}
#[actix_web::test]
async fn test_empty_body_returns_json_error() {
let app = make_test_app!();
let req = test::TestRequest::post()
.uri("/v1/quote")
.insert_header(("content-type", "application/json"))
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 400);
let body = body_json(resp).await;
assert_eq!(body["code"], "BAD_REQUEST", "body was: {body}");
assert!(body["error"].is_string(), "body was: {body}");
}
#[actix_web::test]
async fn test_wrong_content_type_returns_json_error() {
let app = make_test_app!();
let req = test::TestRequest::post()
.uri("/v1/quote")
.insert_header(("content-type", "text/plain"))
.set_payload(r#"{"orders":[]}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 400);
let body = body_json(resp).await;
assert_eq!(body["code"], "BAD_REQUEST", "body was: {body}");
assert!(body["error"].is_string(), "body was: {body}");
}
#[actix_web::test]
async fn test_invalid_query_param_returns_json_error() {
#[derive(serde::Deserialize)]
struct Params {
#[allow(dead_code)]
limit: usize,
}
async fn handler(_: web::Query<Params>) -> HttpResponse {
HttpResponse::Ok().finish()
}
let app = test::init_service(
App::new()
.configure(crate::api::configure_error_handlers)
.route("/v1/prices", web::get().to(handler)),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/prices?limit=not-a-number")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 400);
let body = body_json(resp).await;
assert_eq!(body["code"], "BAD_REQUEST", "body was: {body}");
assert!(body["error"].is_string(), "body was: {body}");
}
#[actix_web::test]
async fn test_invalid_field_type_returns_json_error() {
let app = make_test_app!();
let req = test::TestRequest::post()
.uri("/v1/quote")
.insert_header(("content-type", "application/json"))
.set_payload(r#"{"orders": "not-an-array"}"#)
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status().as_u16(), 400);
let body = body_json(resp).await;
assert_eq!(body["code"], "BAD_REQUEST", "body was: {body}");
assert!(body["error"].is_string(), "body was: {body}");
}
#[actix_web::test]
async fn test_info_returns_200_with_chain_id() {
let state = make_test_state();
let app = test::init_service(
App::new()
.app_data(web::Data::new(state))
.route("/v1/info", web::get().to(super::info)),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/info")
.to_request();
let resp = test::call_service(&app, req).await;
assert_eq!(resp.status(), 200);
}
#[actix_web::test]
async fn test_info_response_has_required_fields() {
let state = make_test_state();
let app = test::init_service(
App::new()
.app_data(web::Data::new(state))
.route("/v1/info", web::get().to(super::info)),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/info")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, req).await;
assert_eq!(body["chain_id"], 1);
assert!(body["router_address"].is_string(), "router_address must be a string");
assert!(body["permit2_address"].is_string(), "permit2_address must be a string");
}
#[actix_web::test]
async fn test_info_returns_correct_permit2_address() {
let state = make_test_state();
let app = test::init_service(
App::new()
.app_data(web::Data::new(state))
.route("/v1/info", web::get().to(super::info)),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/info")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, req).await;
let addr = body["permit2_address"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
addr.contains("000000000022d473030f116ddee9f6b43ac78ba3"),
"expected canonical Permit2 address, got {addr}"
);
}
#[actix_web::test]
async fn test_info_returns_correct_router_address() {
let state = make_test_state();
let app = test::init_service(
App::new()
.app_data(web::Data::new(state))
.route("/v1/info", web::get().to(super::info)),
)
.await;
let req = test::TestRequest::get()
.uri("/v1/info")
.to_request();
let body: serde_json::Value = test::call_and_read_body_json(&app, req).await;
let addr = body["router_address"]
.as_str()
.unwrap()
.to_lowercase();
assert!(
addr.contains("fd0b31d2e955fa55e3fa641fe90e08b677188d35"),
"expected Ethereum Tycho Router address, got {addr}"
);
}
}