From 0c458d1ce88135e8c5fd8dd9fcb37ded4a63fbd5 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Fri, 20 Feb 2026 11:52:24 +0300 Subject: [PATCH 1/4] chore: update rain.orderbook submodule to latest main Brings in TakeOrdersRequest, TakeOrdersCalldataResult, and approval_info/take_orders_info APIs needed for the calldata endpoint. --- Cargo.lock | 21 ++++++++++++++++----- lib/rain.orderbook | 2 +- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3407fea..8fde1df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1579,13 +1579,12 @@ dependencies = [ [[package]] name = "backon" -version = "0.4.4" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d67782c3f868daa71d3533538e98a8e13713231969def7536e8039606fc46bf0" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", - "futures-core", - "pin-project", + "gloo-timers 0.3.0", "tokio", ] @@ -3975,6 +3974,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "graphql-introspection-query" version = "0.2.0" @@ -6610,7 +6621,7 @@ dependencies = [ "flate2", "futures", "getrandom 0.2.16", - "gloo-timers", + "gloo-timers 0.2.6", "itertools 0.14.0", "once_cell", "proptest", diff --git a/lib/rain.orderbook b/lib/rain.orderbook index 420bab7..d9fff37 160000 --- a/lib/rain.orderbook +++ b/lib/rain.orderbook @@ -1 +1 @@ -Subproject commit 420bab786c49c3f2fb94e19d15826b0f79608525 +Subproject commit d9fff371d75d9d3d25087d20c77209b430a5c248 From 5624d256a568e97137f0158801ead6033d4e3d8c Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Fri, 20 Feb 2026 11:52:28 +0300 Subject: [PATCH 2/4] refactor: extract CHAIN_ID constant for Base network Add pub(crate) CHAIN_ID constant in main.rs and use it in tokens.rs instead of a hardcoded value. --- src/main.rs | 2 ++ src/routes/tokens.rs | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 746ed28..e6fea08 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ mod routes; mod telemetry; mod types; +pub(crate) const CHAIN_ID: u32 = 8453; + #[cfg(test)] mod test_helpers; diff --git a/src/routes/tokens.rs b/src/routes/tokens.rs index 804a121..c54e92f 100644 --- a/src/routes/tokens.rs +++ b/src/routes/tokens.rs @@ -9,7 +9,7 @@ use std::time::Duration; use tracing::Instrument; const TOKEN_LIST_URL: &str = "https://raw.githubusercontent.com/S01-Issuer/st0x-tokens/ad1a637a79d5a220ad089aecdc5b7239d3473f6e/src/st0xTokens.json"; -const TARGET_CHAIN_ID: u32 = 8453; +const TARGET_CHAIN_ID: u32 = crate::CHAIN_ID; const TOKEN_LIST_TIMEOUT_SECS: u64 = 10; pub(crate) struct TokensConfig { From 1725e3ae4ce43c2f4e4d24a22eff0596641b0a0a Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Fri, 20 Feb 2026 11:52:32 +0300 Subject: [PATCH 3/4] refactor: add Clone to ApiError, add taker field to SwapCalldataRequest Clone is needed for the mock data source to return cloned error results. The taker field identifies who will execute the swap on-chain. --- src/error.rs | 2 +- src/types/swap.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 3bd6e9e..1e78064 100644 --- a/src/error.rs +++ b/src/error.rs @@ -20,7 +20,7 @@ pub struct ApiErrorResponse { pub error: ApiErrorDetail, } -#[derive(Debug, thiserror::Error)] +#[derive(Debug, Clone, thiserror::Error)] pub enum ApiError { #[error("Bad request: {0}")] BadRequest(String), diff --git a/src/types/swap.rs b/src/types/swap.rs index bbb34fb..9f83f2e 100644 --- a/src/types/swap.rs +++ b/src/types/swap.rs @@ -34,6 +34,8 @@ pub struct SwapQuoteResponse { #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SwapCalldataRequest { + #[schema(value_type = String, example = "0x1234567890abcdef1234567890abcdef12345678")] + pub taker: Address, #[schema(value_type = String, example = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")] pub input_token: Address, #[schema(value_type = String, example = "0x4200000000000000000000000000000000000006")] From 1c7ef7725653aac699617800d06b07ef6284dcc3 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Fri, 20 Feb 2026 11:52:39 +0300 Subject: [PATCH 4/4] feat: implement POST /v1/swap/calldata with approval-aware response Add get_calldata to SwapDataSource trait backed by the library's get_take_orders_calldata. The handler builds a TakeOrdersRequest from the caller's parameters and returns either approval instructions or ready-to-submit takeOrders4 calldata. Includes 7 unit tests covering ready, needs-approval, not-found, bad-request, internal-error, 401 auth, and 500 client init scenarios. --- src/routes/swap/calldata.rs | 193 +++++++++++++++++++++++++++++++++++- src/routes/swap/mod.rs | 83 ++++++++++++++++ src/routes/swap/quote.rs | 8 ++ 3 files changed, 281 insertions(+), 3 deletions(-) diff --git a/src/routes/swap/calldata.rs b/src/routes/swap/calldata.rs index 4f3d7fa..cdd827e 100644 --- a/src/routes/swap/calldata.rs +++ b/src/routes/swap/calldata.rs @@ -1,7 +1,10 @@ +use super::{RaindexSwapDataSource, SwapDataSource}; use crate::auth::AuthenticatedKey; use crate::error::{ApiError, ApiErrorResponse}; use crate::fairings::{GlobalRateLimit, TracingSpan}; use crate::types::swap::{SwapCalldataRequest, SwapCalldataResponse}; +use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; +use rain_orderbook_common::take_orders::TakeOrdersMode; use rocket::serde::json::Json; use rocket::State; use tracing::Instrument; @@ -16,6 +19,7 @@ use tracing::Instrument; (status = 200, description = "Swap calldata", body = SwapCalldataResponse), (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), ) @@ -32,11 +36,194 @@ pub async fn post_swap_calldata( async move { tracing::info!(body = ?req, "request received"); let raindex = shared_raindex.read().await; - raindex - .run_with_client(move |_client| async move { todo!() }) + let response = raindex + .run_with_client(move |client| async move { + let ds = RaindexSwapDataSource { client: &client }; + process_swap_calldata(&ds, req).await + }) .await - .map_err(ApiError::from)? + .map_err(ApiError::from)??; + Ok(Json(response)) } .instrument(span.0) .await } + +async fn process_swap_calldata( + ds: &dyn SwapDataSource, + req: SwapCalldataRequest, +) -> Result { + let take_req = TakeOrdersRequest { + taker: req.taker.to_string(), + chain_id: crate::CHAIN_ID, + sell_token: req.input_token.to_string(), + buy_token: req.output_token.to_string(), + mode: TakeOrdersMode::BuyUpTo, + amount: req.output_amount, + price_cap: req.maximum_io_ratio, + }; + + ds.get_calldata(take_req).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::swap::test_fixtures::MockSwapDataSource; + use crate::test_helpers::{ + basic_auth_header, mock_invalid_raindex_config, seed_api_key, TestClientBuilder, + }; + use crate::types::common::Approval; + use alloy::primitives::{address, Address, Bytes, U256}; + use rocket::http::{ContentType, Header, Status}; + + const USDC: Address = address!("833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"); + const WETH: Address = address!("4200000000000000000000000000000000000006"); + const TAKER: Address = address!("1111111111111111111111111111111111111111"); + const ORDERBOOK: Address = address!("d2938e7c9fe3597f78832ce780feb61945c377d7"); + + fn calldata_request(output_amount: &str, max_ratio: &str) -> SwapCalldataRequest { + SwapCalldataRequest { + taker: TAKER, + input_token: USDC, + output_token: WETH, + output_amount: output_amount.to_string(), + maximum_io_ratio: max_ratio.to_string(), + } + } + + fn ready_response() -> SwapCalldataResponse { + SwapCalldataResponse { + to: ORDERBOOK, + data: Bytes::from(vec![0xab, 0xcd, 0xef]), + value: U256::ZERO, + estimated_input: "150".to_string(), + approvals: vec![], + } + } + + fn approval_response() -> SwapCalldataResponse { + SwapCalldataResponse { + to: ORDERBOOK, + data: Bytes::new(), + value: U256::ZERO, + estimated_input: "1000".to_string(), + approvals: vec![Approval { + token: USDC, + spender: ORDERBOOK, + amount: "1000".to_string(), + symbol: String::new(), + approval_data: Bytes::from(vec![0x09, 0x5e, 0xa7, 0xb3]), + }], + } + } + + #[rocket::async_test] + async fn test_process_swap_calldata_ready() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + calldata_result: Ok(ready_response()), + }; + let result = process_swap_calldata(&ds, calldata_request("100", "2.5")) + .await + .unwrap(); + + assert_eq!(result.to, ORDERBOOK); + assert!(!result.data.is_empty()); + assert_eq!(result.value, U256::ZERO); + assert_eq!(result.estimated_input, "150"); + assert!(result.approvals.is_empty()); + } + + #[rocket::async_test] + async fn test_process_swap_calldata_needs_approval() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + calldata_result: Ok(approval_response()), + }; + let result = process_swap_calldata(&ds, calldata_request("100", "2.5")) + .await + .unwrap(); + + assert_eq!(result.to, ORDERBOOK); + assert!(result.data.is_empty()); + assert_eq!(result.approvals.len(), 1); + assert_eq!(result.approvals[0].token, USDC); + assert_eq!(result.approvals[0].spender, ORDERBOOK); + } + + #[rocket::async_test] + async fn test_process_swap_calldata_not_found() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + calldata_result: Err(ApiError::NotFound( + "no liquidity found for this pair".into(), + )), + }; + let result = process_swap_calldata(&ds, calldata_request("100", "2.5")).await; + assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no liquidity"))); + } + + #[rocket::async_test] + async fn test_process_swap_calldata_bad_request() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + calldata_result: Err(ApiError::BadRequest("invalid parameters".into())), + }; + let result = process_swap_calldata(&ds, calldata_request("not-a-number", "2.5")).await; + assert!(matches!(result, Err(ApiError::BadRequest(_)))); + } + + #[rocket::async_test] + async fn test_process_swap_calldata_internal_error() { + let ds = MockSwapDataSource { + orders: Ok(vec![]), + candidates: vec![], + calldata_result: Err(ApiError::Internal("failed to generate calldata".into())), + }; + let result = process_swap_calldata(&ds, calldata_request("100", "2.5")).await; + assert!(matches!(result, Err(ApiError::Internal(_)))); + } + + #[rocket::async_test] + async fn test_swap_calldata_401_without_auth() { + let client = TestClientBuilder::new().build().await; + let response = client + .post("/v1/swap/calldata") + .header(ContentType::JSON) + .body(r#"{"taker":"0x1111111111111111111111111111111111111111","inputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","outputToken":"0x4200000000000000000000000000000000000006","outputAmount":"100","maximumIoRatio":"2.5"}"#) + .dispatch() + .await; + assert_eq!(response.status(), Status::Unauthorized); + } + + #[rocket::async_test] + async fn test_swap_calldata_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/calldata") + .header(Header::new("Authorization", header)) + .header(ContentType::JSON) + .body(r#"{"taker":"0x1111111111111111111111111111111111111111","inputToken":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","outputToken":"0x4200000000000000000000000000000000000006","outputAmount":"100","maximumIoRatio":"2.5"}"#) + .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" + ); + } +} diff --git a/src/routes/swap/mod.rs b/src/routes/swap/mod.rs index fa3612c..2cffbce 100644 --- a/src/routes/swap/mod.rs +++ b/src/routes/swap/mod.rs @@ -2,12 +2,15 @@ mod calldata; mod quote; use crate::error::ApiError; +use crate::types::swap::SwapCalldataResponse; 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::take_orders::TakeOrdersRequest; use rain_orderbook_common::raindex_client::RaindexClient; +use rain_orderbook_common::raindex_client::RaindexError; use rain_orderbook_common::take_orders::{ build_take_order_candidates_for_pair, TakeOrderCandidate, }; @@ -27,6 +30,11 @@ pub(crate) trait SwapDataSource { input_token: Address, output_token: Address, ) -> Result, ApiError>; + + async fn get_calldata( + &self, + request: TakeOrdersRequest, + ) -> Result; } pub(crate) struct RaindexSwapDataSource<'a> { @@ -70,6 +78,71 @@ impl<'a> SwapDataSource for RaindexSwapDataSource<'a> { ApiError::Internal("failed to build order candidates".into()) }) } + + async fn get_calldata( + &self, + request: TakeOrdersRequest, + ) -> Result { + let result = self + .client + .get_take_orders_calldata(request) + .await + .map_err(map_raindex_error)?; + + if let Some(approval_info) = result.approval_info() { + let formatted_amount = approval_info.formatted_amount().to_string(); + Ok(SwapCalldataResponse { + to: approval_info.spender(), + data: alloy::primitives::Bytes::new(), + value: alloy::primitives::U256::ZERO, + estimated_input: formatted_amount.clone(), + approvals: vec![crate::types::common::Approval { + token: approval_info.token(), + spender: approval_info.spender(), + amount: formatted_amount, + symbol: String::new(), + approval_data: approval_info.calldata().clone(), + }], + }) + } else if let Some(take_orders_info) = result.take_orders_info() { + let expected_sell = take_orders_info.expected_sell().format().map_err(|e| { + tracing::error!(error = %e, "failed to format expected sell"); + ApiError::Internal("failed to format expected sell".into()) + })?; + Ok(SwapCalldataResponse { + to: take_orders_info.orderbook(), + data: take_orders_info.calldata().clone(), + value: alloy::primitives::U256::ZERO, + estimated_input: expected_sell, + approvals: vec![], + }) + } else { + Err(ApiError::Internal( + "unexpected calldata result state".into(), + )) + } + } +} + +fn map_raindex_error(e: RaindexError) -> ApiError { + match &e { + RaindexError::NoLiquidity | RaindexError::InsufficientLiquidity { .. } => { + tracing::warn!(error = %e, "no liquidity found"); + ApiError::NotFound("no liquidity found for this pair".into()) + } + RaindexError::SameTokenPair + | RaindexError::NonPositiveAmount + | RaindexError::NegativePriceCap + | RaindexError::FromHexError(_) + | RaindexError::Float(_) => { + tracing::warn!(error = %e, "invalid request parameters"); + ApiError::BadRequest(e.to_string()) + } + _ => { + tracing::error!(error = %e, "calldata generation failed"); + ApiError::Internal("failed to generate calldata".into()) + } + } } pub use calldata::*; @@ -83,14 +156,17 @@ pub fn routes() -> Vec { pub(crate) mod test_fixtures { use super::SwapDataSource; use crate::error::ApiError; + use crate::types::swap::SwapCalldataResponse; use alloy::primitives::Address; use async_trait::async_trait; use rain_orderbook_common::raindex_client::orders::RaindexOrder; + use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; use rain_orderbook_common::take_orders::TakeOrderCandidate; pub struct MockSwapDataSource { pub orders: Result, ApiError>, pub candidates: Vec, + pub calldata_result: Result, } #[async_trait(?Send)] @@ -114,5 +190,12 @@ pub(crate) mod test_fixtures { ) -> Result, ApiError> { Ok(self.candidates.clone()) } + + async fn get_calldata( + &self, + _request: TakeOrdersRequest, + ) -> Result { + self.calldata_result.clone() + } } } diff --git a/src/routes/swap/quote.rs b/src/routes/swap/quote.rs index b3ef498..fb031e2 100644 --- a/src/routes/swap/quote.rs +++ b/src/routes/swap/quote.rs @@ -148,6 +148,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![mock_order()]), candidates: vec![mock_candidate("1000", "1.5")], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); @@ -164,6 +165,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![mock_order()]), candidates: vec![mock_candidate("50", "2"), mock_candidate("50", "3")], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); @@ -178,6 +180,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![mock_order()]), candidates: vec![mock_candidate("30", "2")], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await.unwrap(); @@ -195,6 +198,7 @@ mod tests { mock_candidate("1000", "1.5"), mock_candidate("1000", "2"), ], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("10")).await.unwrap(); @@ -207,6 +211,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![]), candidates: vec![], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await; assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no liquidity"))); @@ -217,6 +222,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![mock_order()]), candidates: vec![], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await; assert!(matches!(result, Err(ApiError::NotFound(msg)) if msg.contains("no valid quotes"))); @@ -227,6 +233,7 @@ mod tests { let ds = MockSwapDataSource { orders: Ok(vec![mock_order()]), candidates: vec![mock_candidate("1000", "1.5")], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("not-a-number")).await; assert!(matches!(result, Err(ApiError::BadRequest(_)))); @@ -237,6 +244,7 @@ mod tests { let ds = MockSwapDataSource { orders: Err(ApiError::Internal("failed".into())), candidates: vec![], + calldata_result: Err(ApiError::Internal("unused".into())), }; let result = process_swap_quote(&ds, quote_request("100")).await; assert!(matches!(result, Err(ApiError::Internal(_))));