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 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/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/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(_)))); 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 { 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")]