Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 177 additions & 3 deletions src/routes/swap/calldata.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
use super::{RaindexSwapDataSource, SwapCalldataDataSource};
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;
Expand All @@ -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),
)
Expand All @@ -31,11 +35,181 @@ pub async fn post_swap_calldata(
let req = request.into_inner();
async move {
tracing::info!(body = ?req, "request received");
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 SwapCalldataDataSource,
req: SwapCalldataRequest,
) -> Result<SwapCalldataResponse, ApiError> {
let take_req = TakeOrdersRequest {
taker: req.taker.to_string(),
chain_id: 8453,
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,
};
Comment on lines +55 to +63
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'chain_id' --type rust -C2

Repository: ST0x-Technology/st0x.rest.api

Length of output: 2299


🏁 Script executed:

# Check the context of the file and function signature
head -70 src/routes/swap/calldata.rs | tail -30

Repository: ST0x-Technology/st0x.rest.api

Length of output: 891


🏁 Script executed:

# Look for config patterns or constants related to chain
rg -n '8453|BASE|chain|config' --type rust src/ | head -40

Repository: ST0x-Technology/st0x.rest.api

Length of output: 3282


Use the existing TARGET_CHAIN_ID constant instead of hardcoding 8453.

The hardcoded chain_id: 8453 should be replaced with TARGET_CHAIN_ID (defined in src/routes/tokens.rs), which is already used for the same purpose elsewhere in the codebase. This same hardcoded pattern appears in multiple files (src/routes/order/mod.rs, src/routes/order/helpers.rs) and should be unified for maintainability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/swap/calldata.rs` around lines 55 - 63, Replace the hardcoded
chain_id value in the TakeOrdersRequest initializer with the existing
TARGET_CHAIN_ID constant: change chain_id: 8453 to chain_id: TARGET_CHAIN_ID,
and ensure TARGET_CHAIN_ID is brought into scope (add the appropriate use/import
for the tokens module where TARGET_CHAIN_ID is defined). Keep the rest of the
TakeOrdersRequest fields unchanged so behavior remains identical.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will be improved somewhere else.

ds.get_calldata(take_req).await
}

#[cfg(test)]
mod tests {
use super::*;
use crate::routes::swap::test_fixtures::MockSwapCalldataDataSource;
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 ORDERBOOK: Address = address!("d2938e7c9fe3597f78832ce780feb61945c377d7");
const TAKER: Address = address!("1111111111111111111111111111111111111111");

fn calldata_request() -> SwapCalldataRequest {
SwapCalldataRequest {
taker: TAKER,
input_token: USDC,
output_token: WETH,
output_amount: "100".to_string(),
maximum_io_ratio: "0.0006".to_string(),
}
}

fn ready_response() -> SwapCalldataResponse {
SwapCalldataResponse {
to: ORDERBOOK,
data: Bytes::from(vec![0x01, 0x02, 0x03]),
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]),
}],
}
}

#[rocket::async_test]
async fn test_process_swap_calldata_ready() {
let ds = MockSwapCalldataDataSource {
result: Ok(ready_response()),
};
let result = process_swap_calldata(&ds, calldata_request())
.await
.unwrap();

assert_eq!(result.to, ORDERBOOK);
assert_eq!(result.data, Bytes::from(vec![0x01, 0x02, 0x03]));
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 = MockSwapCalldataDataSource {
result: Ok(approval_response()),
};
let result = process_swap_calldata(&ds, calldata_request())
.await
.unwrap();

assert_eq!(result.approvals.len(), 1);
assert_eq!(result.approvals[0].token, USDC);
assert_eq!(result.approvals[0].spender, ORDERBOOK);
assert_eq!(result.approvals[0].amount, "1000");
assert!(result.data.is_empty());
}

#[rocket::async_test]
async fn test_process_swap_calldata_not_found() {
let ds = MockSwapCalldataDataSource {
result: Err(ApiError::NotFound("no liquidity".into())),
};
let result = process_swap_calldata(&ds, calldata_request()).await;
assert!(matches!(result, Err(ApiError::NotFound(_))));
}

#[rocket::async_test]
async fn test_process_swap_calldata_bad_request() {
let ds = MockSwapCalldataDataSource {
result: Err(ApiError::BadRequest("same token pair".into())),
};
let result = process_swap_calldata(&ds, calldata_request()).await;
assert!(matches!(result, Err(ApiError::BadRequest(_))));
}

#[rocket::async_test]
async fn test_process_swap_calldata_internal_error() {
let ds = MockSwapCalldataDataSource {
result: Err(ApiError::Internal("something broke".into())),
};
let result = process_swap_calldata(&ds, calldata_request()).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":"0.0006"}"#)
.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":"0.0006"}"#)
.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"
);
}
}
98 changes: 96 additions & 2 deletions src/routes/swap/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ mod calldata;
mod quote;

use crate::error::ApiError;
use alloy::primitives::Address;
use crate::types::swap::SwapCalldataResponse;
use alloy::primitives::{Address, Bytes, U256};
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::take_orders::{
build_take_order_candidates_for_pair, TakeOrderCandidate,
Expand Down Expand Up @@ -72,6 +74,79 @@ impl<'a> SwapDataSource for RaindexSwapDataSource<'a> {
}
}

#[async_trait(?Send)]
pub(crate) trait SwapCalldataDataSource {
async fn get_calldata(
&self,
request: TakeOrdersRequest,
) -> Result<SwapCalldataResponse, ApiError>;
}

#[async_trait(?Send)]
impl<'a> SwapCalldataDataSource for RaindexSwapDataSource<'a> {
async fn get_calldata(
&self,
request: TakeOrdersRequest,
) -> Result<SwapCalldataResponse, ApiError> {
use rain_orderbook_common::raindex_client::RaindexError;
let result = self
.client
.get_take_orders_calldata(request)
.await
.map_err(|e| match &e {
RaindexError::SameTokenPair
| RaindexError::NonPositiveAmount
| RaindexError::NegativePriceCap
| RaindexError::FromHexError(_)
| RaindexError::Float(_) => {
tracing::warn!(error = %e, "bad request for calldata");
ApiError::BadRequest(e.to_string())
}
RaindexError::NoLiquidity | RaindexError::InsufficientLiquidity { .. } => {
tracing::warn!(error = %e, "no liquidity for calldata");
ApiError::NotFound(e.to_string())
}
_ => {
tracing::error!(error = %e, "failed to generate calldata");
ApiError::Internal("failed to generate calldata".into())
}
})?;

if let Some(approval) = result.approval_info() {
let formatted = approval.formatted_amount().to_string();
Ok(SwapCalldataResponse {
to: approval.spender(),
data: Bytes::new(),
value: U256::ZERO,
estimated_input: formatted.clone(),
approvals: vec![crate::types::common::Approval {
token: approval.token(),
spender: approval.spender(),
amount: formatted,
symbol: String::new(),
approval_data: approval.calldata().clone(),
}],
})
Comment on lines +115 to +129
Copy link

@coderabbitai coderabbitai bot Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Approval branch: to is set to spender with empty data — verify this is the intended contract.

When an approval is needed, the top-level to is set to approval.spender() while data is empty. Since the client is expected to use the approvals array (which contains token, spender, and approval_data) to construct the approval transaction, the top-level to becomes a misleading vestige. Consider using Address::ZERO or documenting the convention that to/data are only meaningful when approvals is empty.

Also, symbol: String::new() will surface as an empty string in the JSON response — if the token symbol is unavailable from the upstream, consider using Option<String> to emit null instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/routes/swap/mod.rs` around lines 115 - 129, The approval branch currently
returns a SwapCalldataResponse where top-level to is set to approval.spender()
and data is Bytes::new(), which is misleading since the client should use the
approvals array; change the top-level to to a canonical "no-op" address (e.g.,
Address::zero()) or otherwise document the convention, and ensure data remains
empty only when appropriate; additionally, update the Approval type and this
construction to use Option<String> for symbol (emit None/null when upstream
symbol is missing) instead of symbol: String::new() so the JSON response conveys
absence of a symbol.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Intentional two-phase flow — when approvals is non-empty, the client approves first then calls again for swap calldata. to as spender signals the eventual target; empty data means "no tx yet." Address::ZERO would be less clear.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@findolor, thank you for clarifying the design—I understand the two-phase flow now. Setting to to the spender makes sense as a signal for the eventual target when approvals are pending. I appreciate the explanation!


✏️ Learnings added
Learnt from: findolor
Repo: ST0x-Technology/st0x.rest.api PR: 38
File: src/routes/swap/mod.rs:115-129
Timestamp: 2026-02-17T09:35:57.431Z
Learning: In the swap calldata endpoint (Rust, `src/routes/swap/mod.rs`), when the response includes a non-empty `approvals` array, the two-phase flow is intentional: the client performs the approval transaction first, then calls the endpoint again to retrieve the actual swap calldata. In the approval response, `to` is set to `approval.spender()` to signal the eventual target, and `data` is `Bytes::new()` (empty) to indicate "no transaction yet." This design is preferred over using `Address::ZERO` for clarity.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

} else if let Some(info) = result.take_orders_info() {
let estimated_input = 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: info.orderbook(),
data: info.calldata().clone(),
value: U256::ZERO,
estimated_input,
approvals: vec![],
})
} else {
Err(ApiError::Internal(
"unexpected calldata result state".into(),
))
}
}
}

pub use calldata::*;
pub use quote::*;

Expand All @@ -81,13 +156,15 @@ pub fn routes() -> Vec<Route> {

#[cfg(test)]
pub(crate) mod test_fixtures {
use super::SwapDataSource;
use super::{SwapCalldataDataSource, SwapDataSource};
use crate::error::ApiError;
use crate::types::swap::SwapCalldataResponse;
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::raindex_client::take_orders::TakeOrdersRequest;
use rain_orderbook_common::take_orders::TakeOrderCandidate;

pub struct MockSwapDataSource {
Expand Down Expand Up @@ -146,4 +223,21 @@ pub(crate) mod test_fixtures {
ratio: Float::parse(ratio.to_string()).unwrap(),
}
}

pub struct MockSwapCalldataDataSource {
pub result: Result<SwapCalldataResponse, ApiError>,
}

#[async_trait(?Send)]
impl SwapCalldataDataSource for MockSwapCalldataDataSource {
async fn get_calldata(
&self,
_request: TakeOrdersRequest,
) -> Result<SwapCalldataResponse, ApiError> {
match &self.result {
Ok(r) => Ok(r.clone()),
Err(e) => Err(e.clone()),
}
}
}
}
2 changes: 2 additions & 0 deletions src/types/swap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down