From 375b148275c21dc2f4fde723fa99eb67e4820f9c Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Mon, 16 Feb 2026 17:01:11 +0100 Subject: [PATCH 1/2] swap: implement POST /v1/swap/quote with capacity-aware simulation Refactor swap from flat file to module directory and implement the quote endpoint using the library's take_orders pipeline: orders are fetched, candidates built with pair-direction filtering, then simulated via simulate_buy_over_candidates for accurate multi-leg pricing. --- Cargo.lock | 1 + Cargo.toml | 1 + src/routes/swap.rs | 81 ----------- src/routes/swap/calldata.rs | 41 ++++++ src/routes/swap/mod.rs | 149 ++++++++++++++++++++ src/routes/swap/quote.rs | 272 ++++++++++++++++++++++++++++++++++++ 6 files changed, 464 insertions(+), 81 deletions(-) delete mode 100644 src/routes/swap.rs create mode 100644 src/routes/swap/calldata.rs create mode 100644 src/routes/swap/mod.rs create mode 100644 src/routes/swap/quote.rs diff --git a/Cargo.lock b/Cargo.lock index af7f090..a8ea0dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8873,6 +8873,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "clap", + "rain-math-float", "rain_orderbook_app_settings", "rain_orderbook_bindings", "rain_orderbook_common", diff --git a/Cargo.toml b/Cargo.toml index b2db764..9e229c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,4 +29,5 @@ rain_orderbook_js_api = { path = "lib/rain.orderbook/crates/js_api", default-fea rain_orderbook_common = { path = "lib/rain.orderbook/crates/common", default-features = false } rain_orderbook_app_settings = { path = "lib/rain.orderbook/crates/settings", default-features = false } rain_orderbook_bindings = { path = "lib/rain.orderbook/crates/bindings", default-features = false } +rain-math-float = { path = "lib/rain.orderbook/lib/rain.interpreter/lib/rain.interpreter.interface/lib/rain.math.float/crates/float" } wasm-bindgen = "=0.2.100" diff --git a/src/routes/swap.rs b/src/routes/swap.rs deleted file mode 100644 index e7ec8b3..0000000 --- a/src/routes/swap.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::auth::AuthenticatedKey; -use crate::error::{ApiError, ApiErrorResponse}; -use crate::fairings::{GlobalRateLimit, TracingSpan}; -use crate::types::swap::{ - SwapCalldataRequest, SwapCalldataResponse, SwapQuoteRequest, SwapQuoteResponse, -}; -use rocket::serde::json::Json; -use rocket::{Route, State}; -use tracing::Instrument; - -#[utoipa::path( - post, - path = "/v1/swap/quote", - tag = "Swap", - security(("basicAuth" = [])), - request_body = SwapQuoteRequest, - responses( - (status = 200, description = "Swap quote", body = SwapQuoteResponse), - (status = 400, description = "Bad request", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[post("/quote", data = "")] -pub async fn post_swap_quote( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - raindex: &State, - span: TracingSpan, - request: Json, -) -> Result, ApiError> { - let req = request.into_inner(); - async move { - tracing::info!(body = ?req, "request received"); - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -#[utoipa::path( - post, - path = "/v1/swap/calldata", - tag = "Swap", - security(("basicAuth" = [])), - request_body = SwapCalldataRequest, - responses( - (status = 200, description = "Swap calldata", body = SwapCalldataResponse), - (status = 400, description = "Bad request", body = ApiErrorResponse), - (status = 401, description = "Unauthorized", body = ApiErrorResponse), - (status = 429, description = "Rate limited", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse), - ) -)] -#[post("/calldata", data = "")] -pub async fn post_swap_calldata( - _global: GlobalRateLimit, - _key: AuthenticatedKey, - raindex: &State, - span: TracingSpan, - request: Json, -) -> Result, ApiError> { - let req = request.into_inner(); - async move { - tracing::info!(body = ?req, "request received"); - raindex - .run_with_client(move |_client| async move { todo!() }) - .await - .map_err(ApiError::from)? - } - .instrument(span.0) - .await -} - -pub fn routes() -> Vec { - rocket::routes![post_swap_quote, post_swap_calldata] -} diff --git a/src/routes/swap/calldata.rs b/src/routes/swap/calldata.rs new file mode 100644 index 0000000..f65e8d7 --- /dev/null +++ b/src/routes/swap/calldata.rs @@ -0,0 +1,41 @@ +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::swap::{SwapCalldataRequest, SwapCalldataResponse}; +use rocket::serde::json::Json; +use rocket::State; +use tracing::Instrument; + +#[utoipa::path( + post, + path = "/v1/swap/calldata", + tag = "Swap", + security(("basicAuth" = [])), + request_body = SwapCalldataRequest, + responses( + (status = 200, description = "Swap calldata", body = SwapCalldataResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[post("/calldata", data = "")] +pub async fn post_swap_calldata( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + raindex: &State, + span: TracingSpan, + request: Json, +) -> Result, ApiError> { + let req = request.into_inner(); + async move { + tracing::info!(body = ?req, "request received"); + raindex + .run_with_client(move |_client| async move { todo!() }) + .await + .map_err(ApiError::from)? + } + .instrument(span.0) + .await +} diff --git a/src/routes/swap/mod.rs b/src/routes/swap/mod.rs new file mode 100644 index 0000000..ebe74ee --- /dev/null +++ b/src/routes/swap/mod.rs @@ -0,0 +1,149 @@ +mod calldata; +mod quote; + +use crate::error::ApiError; +use alloy::primitives::Address; +use async_trait::async_trait; +use rain_orderbook_common::raindex_client::orders::{ + GetOrdersFilters, GetOrdersTokenFilter, RaindexOrder, +}; +use rain_orderbook_common::raindex_client::RaindexClient; +use rain_orderbook_common::take_orders::{ + build_take_order_candidates_for_pair, TakeOrderCandidate, +}; +use rocket::Route; + +#[async_trait(?Send)] +pub(crate) trait SwapDataSource { + async fn get_orders_for_pair( + &self, + input_token: Address, + output_token: Address, + ) -> Result, ApiError>; + + async fn build_candidates_for_pair( + &self, + orders: &[RaindexOrder], + input_token: Address, + output_token: Address, + ) -> Result, ApiError>; +} + +pub(crate) struct RaindexSwapDataSource<'a> { + pub client: &'a RaindexClient, +} + +#[async_trait(?Send)] +impl<'a> SwapDataSource for RaindexSwapDataSource<'a> { + async fn get_orders_for_pair( + &self, + input_token: Address, + output_token: Address, + ) -> Result, ApiError> { + let filters = GetOrdersFilters { + active: Some(true), + tokens: Some(GetOrdersTokenFilter { + inputs: Some(vec![input_token]), + outputs: Some(vec![output_token]), + }), + ..Default::default() + }; + self.client + .get_orders(None, Some(filters), None) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to query orders for pair"); + ApiError::Internal("failed to query orders".into()) + }) + } + + async fn build_candidates_for_pair( + &self, + orders: &[RaindexOrder], + input_token: Address, + output_token: Address, + ) -> Result, ApiError> { + build_take_order_candidates_for_pair(orders, input_token, output_token, None, None) + .await + .map_err(|e| { + tracing::error!(error = %e, "failed to build order candidates"); + ApiError::Internal("failed to build order candidates".into()) + }) + } +} + +pub use calldata::*; +pub use quote::*; + +pub fn routes() -> Vec { + rocket::routes![quote::post_swap_quote, calldata::post_swap_calldata] +} + +#[cfg(test)] +pub(crate) mod test_fixtures { + use super::SwapDataSource; + use crate::error::ApiError; + use alloy::primitives::{Address, U256}; + use async_trait::async_trait; + use rain_math_float::Float; + use rain_orderbook_bindings::IOrderBookV6::{EvaluableV4, OrderV4, IOV2}; + use rain_orderbook_common::raindex_client::orders::RaindexOrder; + use rain_orderbook_common::take_orders::TakeOrderCandidate; + + pub struct MockSwapDataSource { + pub orders: Result, ApiError>, + pub candidates: Vec, + } + + #[async_trait(?Send)] + impl SwapDataSource for MockSwapDataSource { + async fn get_orders_for_pair( + &self, + _input_token: Address, + _output_token: Address, + ) -> Result, ApiError> { + match &self.orders { + Ok(orders) => Ok(orders.clone()), + Err(_) => Err(ApiError::Internal("failed to query orders".into())), + } + } + + async fn build_candidates_for_pair( + &self, + _orders: &[RaindexOrder], + _input_token: Address, + _output_token: Address, + ) -> Result, ApiError> { + Ok(self.candidates.clone()) + } + } + + pub fn mock_candidate(max_output: &str, ratio: &str) -> TakeOrderCandidate { + let token_a = Address::from([4u8; 20]); + let token_b = Address::from([5u8; 20]); + TakeOrderCandidate { + orderbook: Address::from([0xAAu8; 20]), + order: OrderV4 { + owner: Address::from([1u8; 20]), + nonce: U256::from(1).into(), + evaluable: EvaluableV4 { + interpreter: Address::from([2u8; 20]), + store: Address::from([3u8; 20]), + bytecode: alloy::primitives::Bytes::from(vec![0x01, 0x02]), + }, + validInputs: vec![IOV2 { + token: token_a, + vaultId: U256::from(100).into(), + }], + validOutputs: vec![IOV2 { + token: token_b, + vaultId: U256::from(200).into(), + }], + }, + input_io_index: 0, + output_io_index: 0, + max_output: Float::parse(max_output.to_string()).unwrap(), + ratio: Float::parse(ratio.to_string()).unwrap(), + } + } +} diff --git a/src/routes/swap/quote.rs b/src/routes/swap/quote.rs new file mode 100644 index 0000000..4e90050 --- /dev/null +++ b/src/routes/swap/quote.rs @@ -0,0 +1,272 @@ +use super::{RaindexSwapDataSource, SwapDataSource}; +use crate::auth::AuthenticatedKey; +use crate::error::{ApiError, ApiErrorResponse}; +use crate::fairings::{GlobalRateLimit, TracingSpan}; +use crate::types::swap::{SwapQuoteRequest, SwapQuoteResponse}; +use rain_math_float::Float; +use rain_orderbook_common::take_orders::simulate_buy_over_candidates; +use rocket::serde::json::Json; +use rocket::State; +use std::ops::Div; +use tracing::Instrument; + +#[utoipa::path( + post, + path = "/v1/swap/quote", + tag = "Swap", + security(("basicAuth" = [])), + request_body = SwapQuoteRequest, + responses( + (status = 200, description = "Swap quote", body = SwapQuoteResponse), + (status = 400, description = "Bad request", body = ApiErrorResponse), + (status = 401, description = "Unauthorized", body = ApiErrorResponse), + (status = 404, description = "No liquidity found", body = ApiErrorResponse), + (status = 429, description = "Rate limited", body = ApiErrorResponse), + (status = 500, description = "Internal server error", body = ApiErrorResponse), + ) +)] +#[post("/quote", data = "")] +pub async fn post_swap_quote( + _global: GlobalRateLimit, + _key: AuthenticatedKey, + raindex: &State, + span: TracingSpan, + request: Json, +) -> Result, ApiError> { + let req = request.into_inner(); + async move { + tracing::info!(body = ?req, "request received"); + let response = raindex + .run_with_client(move |client| async move { + let ds = RaindexSwapDataSource { client: &client }; + process_swap_quote(&ds, req).await + }) + .await + .map_err(ApiError::from)??; + Ok(Json(response)) + } + .instrument(span.0) + .await +} + +async fn process_swap_quote( + ds: &dyn SwapDataSource, + req: SwapQuoteRequest, +) -> Result { + let orders = ds + .get_orders_for_pair(req.input_token, req.output_token) + .await?; + + if orders.is_empty() { + return Err(ApiError::NotFound( + "no liquidity found for this pair".into(), + )); + } + + let candidates = ds + .build_candidates_for_pair(&orders, req.input_token, req.output_token) + .await?; + + if candidates.is_empty() { + return Err(ApiError::NotFound("no valid quotes available".into())); + } + + let buy_target = Float::parse(req.output_amount.clone()).map_err(|e| { + tracing::error!(error = %e, "failed to parse output_amount"); + ApiError::BadRequest("invalid output_amount".into()) + })?; + + let price_cap = Float::max_positive_value().map_err(|e| { + tracing::error!(error = %e, "failed to create price cap"); + ApiError::Internal("failed to create price cap".into()) + })?; + + let sim = simulate_buy_over_candidates(candidates, buy_target, price_cap).map_err(|e| { + tracing::error!(error = %e, "failed to simulate swap"); + ApiError::Internal("failed to simulate swap".into()) + })?; + + if sim.legs.is_empty() { + return Err(ApiError::NotFound("no valid quotes available".into())); + } + + let blended_ratio = sim.total_input.div(sim.total_output).map_err(|e| { + tracing::error!(error = %e, "failed to compute blended ratio"); + ApiError::Internal("failed to compute ratio".into()) + })?; + + let formatted_input = sim.total_input.format().map_err(|e| { + tracing::error!(error = %e, "failed to format estimated input"); + ApiError::Internal("failed to format estimated input".into()) + })?; + + let formatted_ratio = blended_ratio.format().map_err(|e| { + tracing::error!(error = %e, "failed to format ratio"); + ApiError::Internal("failed to format ratio".into()) + })?; + + Ok(SwapQuoteResponse { + input_token: req.input_token, + output_token: req.output_token, + output_amount: req.output_amount, + estimated_input: formatted_input, + estimated_io_ratio: formatted_ratio, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::order::test_fixtures::mock_order; + use crate::routes::swap::test_fixtures::{mock_candidate, MockSwapDataSource}; + use crate::test_helpers::{ + basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, + }; + use alloy::primitives::address; + use rocket::http::{ContentType, Header, Status}; + + const USDC: alloy::primitives::Address = address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); + const WETH: alloy::primitives::Address = address!("4200000000000000000000000000000000000006"); + + fn quote_request(output_amount: &str) -> SwapQuoteRequest { + SwapQuoteRequest { + input_token: USDC, + output_token: WETH, + output_amount: output_amount.to_string(), + } + } + + #[rocket::async_test] + async fn test_process_swap_quote_success() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![mock_candidate("1000", "1.5")], + }; + let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + + assert_eq!(result.input_token, USDC); + assert_eq!(result.output_token, WETH); + assert_eq!(result.output_amount, "100"); + assert_eq!(result.estimated_input, "150"); + assert_eq!(result.estimated_io_ratio, "1.5"); + } + + #[rocket::async_test] + async fn test_process_swap_quote_multi_leg() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![mock_candidate("50", "2"), mock_candidate("50", "3")], + }; + let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + + assert_eq!(result.output_amount, "100"); + assert_eq!(result.estimated_input, "250"); + assert_eq!(result.estimated_io_ratio, "2.5"); + } + + #[rocket::async_test] + async fn test_process_swap_quote_partial_fill() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![mock_candidate("30", "2")], + }; + let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); + + assert_eq!(result.estimated_input, "60"); + assert_eq!(result.output_amount, "100"); + } + + #[rocket::async_test] + async fn test_process_swap_quote_picks_best_ratio() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![ + mock_candidate("1000", "3"), + mock_candidate("1000", "1.5"), + mock_candidate("1000", "2"), + ], + }; + let result = process_swap_quote(&ds, quote_request("10")).await.unwrap(); + + assert_eq!(result.estimated_io_ratio, "1.5"); + assert_eq!(result.estimated_input, "15"); + } + + #[rocket::async_test] + async fn test_process_swap_quote_no_liquidity() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + }; + let result = process_swap_quote(&ds, quote_request("100")).await; + assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no liquidity"))); + } + + #[rocket::async_test] + async fn test_process_swap_quote_no_candidates() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![], + }; + let result = process_swap_quote(&ds, quote_request("100")).await; + assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no valid quotes"))); + } + + #[rocket::async_test] + async fn test_process_swap_quote_invalid_output_amount() { + let ds = MockSwapDataSource { + orders: Ok(vec![mock_order()]), + candidates: vec![mock_candidate("1000", "1.5")], + }; + let result = process_swap_quote(&ds, quote_request("not-a-number")).await; + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + + #[rocket::async_test] + async fn test_process_swap_quote_query_failure() { + let ds = MockSwapDataSource { + orders: Err(ApiError::Internal("failed".into())), + candidates: vec![], + }; + let result = process_swap_quote(&ds, quote_request("100")).await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + #[rocket::async_test] + async fn test_swap_quote_401_without_auth() { + let client = TestClientBuilder::new().build().await; + let response = client + .post("/v1/swap/quote") + .header(ContentType::JSON) + .body(r#"{"inputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","outputToken":"0x4200000000000000000000000000000000000006","outputAmount":"100"}"#) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_swap_quote_500_when_client_init_fails() { + let config = mock_invalid_raindex_config().await; + let client = TestClientBuilder::new() + .raindex_config(config) + .build() + .await; + let (key_id, secret) = seed_api_key(&client).await; + let header = basic_auth_header(&key_id, &secret); + let response = client + .post("/v1/swap/quote") + .header(Header::new("Authorization", header)) + .header(ContentType::JSON) + .body(r#"{"inputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","outputToken":"0x4200000000000000000000000000000000000006","outputAmount":"100"}"#) + .dispatch() + .await; + assert_eq!(response.status(), Status::InternalServerError); + let body: serde_json::Value = + serde_json::from_str(&response.into_string().await.unwrap()).unwrap(); + assert_eq!(body["error"]["code"], "INTERNAL_ERROR"); + assert_eq!( + body["error"]["message"], + "failed to initialize orderbook client" + ); + } +} From 1aba53a282e18b7bfe749365d64c7a979c2242c3 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Mon, 16 Feb 2026 18:00:01 +0100 Subject: [PATCH 2/2] swap: add estimated_output field to quote response for partial fills --- src/routes/swap/quote.rs | 11 ++++++++++- src/types/swap.rs | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/routes/swap/quote.rs b/src/routes/swap/quote.rs index 4e90050..9167cbc 100644 --- a/src/routes/swap/quote.rs +++ b/src/routes/swap/quote.rs @@ -95,6 +95,11 @@ async fn process_swap_quote( ApiError::Internal("failed to compute ratio".into()) })?; + let formatted_output = sim.total_output.format().map_err(|e| { + tracing::error!(error = %e, "failed to format estimated output"); + ApiError::Internal("failed to format estimated output".into()) + })?; + let formatted_input = sim.total_input.format().map_err(|e| { tracing::error!(error = %e, "failed to format estimated input"); ApiError::Internal("failed to format estimated input".into()) @@ -109,6 +114,7 @@ async fn process_swap_quote( input_token: req.input_token, output_token: req.output_token, output_amount: req.output_amount, + estimated_output: formatted_output, estimated_input: formatted_input, estimated_io_ratio: formatted_ratio, }) @@ -147,6 +153,7 @@ mod tests { assert_eq!(result.input_token, USDC); assert_eq!(result.output_token, WETH); assert_eq!(result.output_amount, "100"); + assert_eq!(result.estimated_output, "100"); assert_eq!(result.estimated_input, "150"); assert_eq!(result.estimated_io_ratio, "1.5"); } @@ -160,6 +167,7 @@ mod tests { let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); assert_eq!(result.output_amount, "100"); + assert_eq!(result.estimated_output, "100"); assert_eq!(result.estimated_input, "250"); assert_eq!(result.estimated_io_ratio, "2.5"); } @@ -172,8 +180,9 @@ mod tests { }; let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); - assert_eq!(result.estimated_input, "60"); assert_eq!(result.output_amount, "100"); + assert_eq!(result.estimated_output, "30"); + assert_eq!(result.estimated_input, "60"); } #[rocket::async_test] diff --git a/src/types/swap.rs b/src/types/swap.rs index 3a3730e..b96cea3 100644 --- a/src/types/swap.rs +++ b/src/types/swap.rs @@ -23,6 +23,8 @@ pub struct SwapQuoteResponse { pub output_token: Address, #[schema(example = "1000000")] pub output_amount: String, + #[schema(example = "1000000")] + pub estimated_output: String, #[schema(example = "500000000000000")] pub estimated_input: String, #[schema(example = "0.0005")]