diff --git a/Cargo.lock b/Cargo.lock index 21a0864..3407fea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8870,8 +8870,11 @@ version = "0.1.0" dependencies = [ "alloy", "argon2", + "async-trait", "base64 0.22.1", "clap", + "rain-math-float", + "rain_orderbook_bindings", "rain_orderbook_common", "rain_orderbook_js_api", "rand 0.9.1", diff --git a/Cargo.toml b/Cargo.toml index 1b3516c..7efa2e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,9 @@ clap = { version = "4.5.58", features = ["derive"] } reqwest = { version = "0.13.2", features = ["json"] } rain_orderbook_js_api = { path = "lib/rain.orderbook/crates/js_api", default-features = false } rain_orderbook_common = { path = "lib/rain.orderbook/crates/common", 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" } +async-trait = "0.1" wasm-bindgen = "=0.2.100" [dev-dependencies] diff --git a/src/routes/swap.rs b/src/routes/swap.rs deleted file mode 100644 index 37bc3a0..0000000 --- a/src/routes/swap.rs +++ /dev/null @@ -1,83 +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, - shared_raindex: &State, - span: TracingSpan, - request: Json, -) -> Result, ApiError> { - let req = request.into_inner(); - async move { - tracing::info!(body = ?req, "request received"); - let raindex = shared_raindex.read().await; - 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, - shared_raindex: &State, - span: TracingSpan, - request: Json, -) -> Result, ApiError> { - let req = request.into_inner(); - async move { - tracing::info!(body = ?req, "request received"); - let raindex = shared_raindex.read().await; - 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..4f3d7fa --- /dev/null +++ b/src/routes/swap/calldata.rs @@ -0,0 +1,42 @@ +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, + shared_raindex: &State, + span: TracingSpan, + request: Json, +) -> Result, ApiError> { + let req = request.into_inner(); + async move { + tracing::info!(body = ?req, "request received"); + let raindex = shared_raindex.read().await; + 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..fa3612c --- /dev/null +++ b/src/routes/swap/mod.rs @@ -0,0 +1,118 @@ +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; + use async_trait::async_trait; + 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()) + } + } +} diff --git a/src/routes/swap/quote.rs b/src/routes/swap/quote.rs new file mode 100644 index 0000000..b3ef498 --- /dev/null +++ b/src/routes/swap/quote.rs @@ -0,0 +1,282 @@ +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, + shared_raindex: &State, + span: TracingSpan, + request: Json, +) -> Result, ApiError> { + let req = request.into_inner(); + async move { + tracing::info!(body = ?req, "request received"); + let raindex = shared_raindex.read().await; + 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_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()) + })?; + + 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_output: formatted_output, + estimated_input: formatted_input, + estimated_io_ratio: formatted_ratio, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes::swap::test_fixtures::MockSwapDataSource; + use crate::test_helpers::{ + basic_auth_header, mock_candidate, mock_invalid_raindex_config, mock_order, 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_output, "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_output, "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.output_amount, "100"); + assert_eq!(result.estimated_output, "30"); + assert_eq!(result.estimated_input, "60"); + } + + #[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" + ); + } +} diff --git a/src/test_helpers.rs b/src/test_helpers.rs index 595bfe8..7781e71 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -1,5 +1,11 @@ +use alloy::primitives::{Address, U256}; use base64::Engine; +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; use rocket::local::asynchronous::Client; +use serde_json::json; pub(crate) async fn client() -> Client { TestClientBuilder::new().build().await @@ -242,3 +248,112 @@ pub(crate) fn basic_auth_header(key_id: &str, secret: &str) -> String { let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{key_id}:{secret}")); format!("Basic {encoded}") } + +fn stub_raindex_client() -> serde_json::Value { + json!({ + "orderbook_yaml": { + "documents": ["version: 4\nnetworks:\n base:\n rpcs:\n - https://mainnet.base.org\n chain-id: 8453\n currency: ETH\nsubgraphs:\n base: https://example.com/sg\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base\n deployment-block: 0\ndeployers:\n base:\n address: 0xC1A14cE2fd58A3A2f99deCb8eDd866204eE07f8D\n network: base\n"], + "profile": "strict" + } + }) +} + +fn order_json() -> serde_json::Value { + let rc = stub_raindex_client(); + json!({ + "raindexClient": rc, + "chainId": 8453, + "id": "0x0000000000000000000000000000000000000000000000000000000000000001", + "orderBytes": "0x01", + "orderHash": "0x000000000000000000000000000000000000000000000000000000000000abcd", + "owner": "0x0000000000000000000000000000000000000001", + "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", + "active": true, + "timestampAdded": "0x000000000000000000000000000000000000000000000000000000006553f100", + "meta": null, + "parsedMeta": [], + "rainlang": null, + "transaction": { + "id": "0x0000000000000000000000000000000000000000000000000000000000000099", + "from": "0x0000000000000000000000000000000000000001", + "blockNumber": "0x0000000000000000000000000000000000000000000000000000000000000001", + "timestamp": "0x000000000000000000000000000000000000000000000000000000006553f100" + }, + "tradesCount": 0, + "inputs": [{ + "raindexClient": rc, + "chainId": 8453, + "vaultType": "input", + "id": "0x01", + "owner": "0x0000000000000000000000000000000000000001", + "vaultId": "0x0000000000000000000000000000000000000000000000000000000000000001", + "balance": "0x0000000000000000000000000000000000000000000000000000000000000001", + "formattedBalance": "1.000000", + "token": { + "chainId": 8453, + "id": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913", + "name": "USD Coin", + "symbol": "USDC", + "decimals": 6 + }, + "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", + "ordersAsInputs": [], + "ordersAsOutputs": [] + }], + "outputs": [{ + "raindexClient": rc, + "chainId": 8453, + "vaultType": "output", + "id": "0x02", + "owner": "0x0000000000000000000000000000000000000001", + "vaultId": "0x0000000000000000000000000000000000000000000000000000000000000002", + "balance": "0xffffffff00000000000000000000000000000000000000000000000000000005", + "formattedBalance": "0.500000000000000000", + "token": { + "chainId": 8453, + "id": "0x4200000000000000000000000000000000000006", + "address": "0x4200000000000000000000000000000000000006", + "name": "Wrapped Ether", + "symbol": "WETH", + "decimals": 18 + }, + "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", + "ordersAsInputs": [], + "ordersAsOutputs": [] + }] + }) +} + +pub(crate) fn mock_order() -> RaindexOrder { + serde_json::from_value(order_json()).expect("deserialize mock RaindexOrder") +} + +pub(crate) 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/types/swap.rs b/src/types/swap.rs index 3a3730e..bbb34fb 100644 --- a/src/types/swap.rs +++ b/src/types/swap.rs @@ -10,7 +10,7 @@ pub struct SwapQuoteRequest { pub input_token: Address, #[schema(value_type = String, example = "0x4200000000000000000000000000000000000006")] pub output_token: Address, - #[schema(example = "1000000")] + #[schema(example = "0.5")] pub output_amount: String, } @@ -21,11 +21,13 @@ pub struct SwapQuoteResponse { pub input_token: Address, #[schema(value_type = String, example = "0x4200000000000000000000000000000000000006")] pub output_token: Address, - #[schema(example = "1000000")] + #[schema(example = "0.5")] pub output_amount: String, - #[schema(example = "500000000000000")] + #[schema(example = "0.5")] + pub estimated_output: String, + #[schema(example = "1250.75")] pub estimated_input: String, - #[schema(example = "0.0005")] + #[schema(example = "2501.5")] pub estimated_io_ratio: String, } @@ -36,9 +38,9 @@ pub struct SwapCalldataRequest { pub input_token: Address, #[schema(value_type = String, example = "0x4200000000000000000000000000000000000006")] pub output_token: Address, - #[schema(example = "1000000")] + #[schema(example = "0.5")] pub output_amount: String, - #[schema(example = "0.0006")] + #[schema(example = "2600")] pub maximum_io_ratio: String, } @@ -51,7 +53,7 @@ pub struct SwapCalldataResponse { pub data: Bytes, #[schema(value_type = String, example = "0x0")] pub value: U256, - #[schema(example = "500000000000000")] + #[schema(example = "1250.75")] pub estimated_input: String, pub approvals: Vec, }