From cec317ca53d59f5297af643ba01a0e3045953dd1 Mon Sep 17 00:00:00 2001 From: Arda Nakisci Date: Mon, 9 Mar 2026 12:12:51 +0300 Subject: [PATCH] feat: remove rain_orderbook_rest_api crate The REST API crate was standalone with no dependents, removing it to reduce workspace scope. --- Cargo.toml | 3 - crates/rest_api/Cargo.toml | 29 - crates/rest_api/src/error.rs | 225 -------- crates/rest_api/src/main.rs | 638 ---------------------- crates/rest_api/src/routes/mod.rs | 1 - crates/rest_api/src/routes/take_orders.rs | 514 ----------------- 6 files changed, 1410 deletions(-) delete mode 100644 crates/rest_api/Cargo.toml delete mode 100644 crates/rest_api/src/error.rs delete mode 100644 crates/rest_api/src/main.rs delete mode 100644 crates/rest_api/src/routes/mod.rs delete mode 100644 crates/rest_api/src/routes/take_orders.rs diff --git a/Cargo.toml b/Cargo.toml index 7c5872c632..b3737ad077 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,9 +90,6 @@ path = "crates/math" [workspace.dependencies.rain_orderbook_js_api] path = "crates/js_api" -[workspace.dependencies.rain_orderbook_rest_api] -path = "crates/rest_api" - # release profile for wasm build optimized for size reduction [profile.release-wasm] inherits = "release" diff --git a/crates/rest_api/Cargo.toml b/crates/rest_api/Cargo.toml deleted file mode 100644 index bb95ea3614..0000000000 --- a/crates/rest_api/Cargo.toml +++ /dev/null @@ -1,29 +0,0 @@ -[package] -name = "rain_orderbook_rest_api" -description = "REST API server for rain orderbook" -version.workspace = true -edition.workspace = true -license.workspace = true -homepage.workspace = true -publish = false - -[[bin]] -name = "rain-orderbook-api" -path = "src/main.rs" - -[dependencies] -rain_orderbook_common = { workspace = true } -rocket = { version = "0.5.1", features = ["json"] } -rocket_cors = "0.6" -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } -utoipa = { version = "5", features = ["rocket_extras"] } -utoipa-swagger-ui = { version = "9", features = ["rocket"] } - -[target.'cfg(not(target_family = "wasm"))'.dependencies] -tokio = { workspace = true, features = ["full"] } - -[dev-dependencies] -tokio = { workspace = true, features = ["full", "macros"] } -alloy = { workspace = true } diff --git a/crates/rest_api/src/error.rs b/crates/rest_api/src/error.rs deleted file mode 100644 index 71981ba305..0000000000 --- a/crates/rest_api/src/error.rs +++ /dev/null @@ -1,225 +0,0 @@ -use rain_orderbook_common::raindex_client::RaindexError; -use rocket::http::Status; -use rocket::response::{self, Responder}; -use rocket::serde::json::Json; -use rocket::Request; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -#[schema(example = json!({ - "error": "No liquidity available for the given token pair", - "readableMessage": "No liquidity available for the given token pair on the specified chain" -}))] -pub struct ApiErrorResponse { - #[schema(example = "No liquidity available for the given token pair")] - pub error: String, - #[schema(example = "No liquidity available for the given token pair on the specified chain")] - pub readable_message: String, -} - -#[derive(Error, Debug)] -pub enum ApiError { - #[error(transparent)] - Raindex(#[from] RaindexError), - - #[error("Internal server error: {0}")] - Internal(String), -} - -impl ApiError { - fn status_code(&self) -> Status { - match self { - ApiError::Raindex(e) => match e { - RaindexError::InvalidYamlConfig - | RaindexError::YamlError(_) - | RaindexError::FromHexError(_) - | RaindexError::U256ParseError(_) - | RaindexError::I256ParseError(_) - | RaindexError::ZeroAmount - | RaindexError::NegativeAmount - | RaindexError::NonPositiveAmount - | RaindexError::NegativePriceCap - | RaindexError::SameTokenPair - | RaindexError::Float(_) - | RaindexError::ParseInt(_) => Status::BadRequest, - - RaindexError::NoLiquidity | RaindexError::InsufficientLiquidity { .. } => { - Status::NotFound - } - - RaindexError::ChainIdNotFound(_) - | RaindexError::OrderbookNotFound(_, _) - | RaindexError::OrderNotFound(_, _, _) - | RaindexError::VaultNotFound(_, _, _) - | RaindexError::SubgraphNotFound(_, _) - | RaindexError::SubgraphNotConfigured(_) - | RaindexError::NoNetworksConfigured => Status::NotFound, - - _ => Status::InternalServerError, - }, - ApiError::Internal(_) => Status::InternalServerError, - } - } - - fn to_response(&self) -> ApiErrorResponse { - let readable_message = match self { - ApiError::Raindex(e) => e.to_readable_msg(), - ApiError::Internal(msg) => msg.clone(), - }; - - ApiErrorResponse { - error: self.to_string(), - readable_message, - } - } -} - -impl<'r> Responder<'r, 'static> for ApiError { - fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> { - let status = self.status_code(); - let body = self.to_response(); - - response::Response::build_from(Json(body).respond_to(request)?) - .status(status) - .ok() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy::primitives::B256; - - #[test] - fn test_status_code_bad_request_errors() { - let bad_request_errors = vec![ - ApiError::Raindex(RaindexError::InvalidYamlConfig), - ApiError::Raindex(RaindexError::ZeroAmount), - ApiError::Raindex(RaindexError::NegativeAmount), - ApiError::Raindex(RaindexError::NonPositiveAmount), - ApiError::Raindex(RaindexError::NegativePriceCap), - ApiError::Raindex(RaindexError::SameTokenPair), - ]; - - for error in bad_request_errors { - assert_eq!( - error.status_code(), - Status::BadRequest, - "Expected BadRequest for {:?}", - error - ); - } - } - - #[test] - fn test_status_code_not_found_liquidity_errors() { - let not_found_errors = vec![ - ApiError::Raindex(RaindexError::NoLiquidity), - ApiError::Raindex(RaindexError::InsufficientLiquidity { - requested: "100".to_string(), - available: "50".to_string(), - }), - ]; - - for error in not_found_errors { - assert_eq!( - error.status_code(), - Status::NotFound, - "Expected NotFound for {:?}", - error - ); - } - } - - #[test] - fn test_status_code_not_found_config_errors() { - let not_found_errors = vec![ - ApiError::Raindex(RaindexError::ChainIdNotFound(1)), - ApiError::Raindex(RaindexError::OrderbookNotFound("0x123".to_string(), 1)), - ApiError::Raindex(RaindexError::OrderNotFound( - "0x123".to_string(), - 1, - B256::ZERO, - )), - ApiError::Raindex(RaindexError::VaultNotFound( - "0x123".to_string(), - 1, - "1".to_string(), - )), - ApiError::Raindex(RaindexError::SubgraphNotFound( - "test".to_string(), - "order".to_string(), - )), - ApiError::Raindex(RaindexError::SubgraphNotConfigured("1".to_string())), - ApiError::Raindex(RaindexError::NoNetworksConfigured), - ]; - - for error in not_found_errors { - assert_eq!( - error.status_code(), - Status::NotFound, - "Expected NotFound for {:?}", - error - ); - } - } - - #[test] - fn test_status_code_internal_server_error() { - let internal_error = ApiError::Internal("Something went wrong".to_string()); - assert_eq!(internal_error.status_code(), Status::InternalServerError); - } - - #[test] - fn test_status_code_preflight_error_is_internal() { - let preflight_error = ApiError::Raindex(RaindexError::PreflightError( - "Simulation failed".to_string(), - )); - assert_eq!(preflight_error.status_code(), Status::InternalServerError); - } - - #[test] - fn test_to_response_raindex_error() { - let error = ApiError::Raindex(RaindexError::NoLiquidity); - let response = error.to_response(); - - assert!(response.error.contains("No liquidity")); - assert!(response.readable_message.contains("No liquidity available")); - } - - #[test] - fn test_to_response_internal_error() { - let error = ApiError::Internal("Custom error message".to_string()); - let response = error.to_response(); - - assert!(response.error.contains("Internal server error")); - assert_eq!(response.readable_message, "Custom error message"); - } - - #[test] - fn test_api_error_response_serialization() { - let response = ApiErrorResponse { - error: "Test error".to_string(), - readable_message: "A readable message".to_string(), - }; - - let json = serde_json::to_string(&response).unwrap(); - - assert!(json.contains("\"error\":\"Test error\"")); - assert!(json.contains("\"readableMessage\":\"A readable message\"")); - } - - #[test] - fn test_api_error_from_raindex_error() { - let raindex_error = RaindexError::NoLiquidity; - let api_error: ApiError = raindex_error.into(); - - assert!(matches!( - api_error, - ApiError::Raindex(RaindexError::NoLiquidity) - )); - } -} diff --git a/crates/rest_api/src/main.rs b/crates/rest_api/src/main.rs deleted file mode 100644 index 124685a80a..0000000000 --- a/crates/rest_api/src/main.rs +++ /dev/null @@ -1,638 +0,0 @@ -mod error; -mod routes; - -use error::ApiErrorResponse; -use rocket::http::Method; -use rocket::{launch, Build, Rocket}; -use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions}; -use routes::take_orders::{ - ApprovalApiResponse, BuyRequest, SellRequest, TakeOrdersApiResponse, TakeOrdersReadyResponse, -}; -use utoipa::OpenApi; -use utoipa_swagger_ui::SwaggerUi; - -#[derive(OpenApi)] -#[openapi( - info( - title = "Rain Orderbook API", - description = "REST API for interacting with Rain Orderbook." - ), - paths(routes::take_orders::buy, routes::take_orders::sell), - components(schemas( - BuyRequest, - SellRequest, - TakeOrdersApiResponse, - ApprovalApiResponse, - TakeOrdersReadyResponse, - ApiErrorResponse - )), - tags( - (name = "Take Orders", description = "Endpoints for generating take orders calldata") - ) -)] -struct ApiDoc; - -fn configure_cors() -> CorsOptions { - CorsOptions { - allowed_origins: AllowedOrigins::all(), - allowed_methods: vec![Method::Get, Method::Post, Method::Options] - .into_iter() - .map(From::from) - .collect(), - allowed_headers: AllowedHeaders::all(), - ..Default::default() - } -} - -fn rocket() -> Rocket { - let cors = configure_cors() - .to_cors() - .expect("CORS configuration failed"); - - rocket::build() - .attach(cors.clone()) - .mount("/", routes::take_orders::routes()) - .mount("/", rocket_cors::catch_all_options_routes()) - .mount( - "/", - SwaggerUi::new("/swagger/").url("/swagger/openapi.json", ApiDoc::openapi()), - ) - .manage(cors) -} - -#[launch] -fn launch() -> Rocket { - rocket() -} - -#[cfg(test)] -mod tests { - use super::*; - use rocket::http::{ContentType, Status}; - use rocket::local::blocking::Client; - - fn client() -> Client { - Client::tracked(rocket()).expect("valid rocket instance") - } - - #[test] - fn test_cors_preflight_buy() { - let client = client(); - let response = client - .options("/take-orders/buy") - .header(rocket::http::Header::new( - "Access-Control-Request-Method", - "POST", - )) - .header(rocket::http::Header::new( - "Access-Control-Request-Headers", - "content-type", - )) - .dispatch(); - - assert_eq!(response.status(), Status::Ok); - } - - #[test] - fn test_cors_preflight_sell() { - let client = client(); - let response = client - .options("/take-orders/sell") - .header(rocket::http::Header::new( - "Access-Control-Request-Method", - "POST", - )) - .header(rocket::http::Header::new( - "Access-Control-Request-Headers", - "content-type", - )) - .dispatch(); - - assert_eq!(response.status(), Status::Ok); - } - - #[test] - fn test_buy_missing_yaml() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_buy_invalid_address() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "invalid-address", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_buy_same_token_pair() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_buy_zero_amount() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "0", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_buy_negative_max_ratio() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "-1" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_buy_missing_field() { - let client = client(); - let response = client - .post("/take-orders/buy") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::UnprocessableEntity); - } - - #[test] - fn test_sell_missing_yaml() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_sell_same_token_pair() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_sell_invalid_address() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "invalid-address", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_sell_zero_amount() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "0", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_sell_negative_max_ratio() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "-1" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::BadRequest); - } - - #[test] - fn test_sell_missing_field() { - let client = client(); - let response = client - .post("/take-orders/sell") - .header(ContentType::JSON) - .body( - r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "amount": "100", - "maxRatio": "2.5" - }"#, - ) - .dispatch(); - - assert_eq!(response.status(), Status::UnprocessableEntity); - } - - #[test] - fn test_swagger_ui_returns_html() { - let client = client(); - let response = client.get("/swagger/").dispatch(); - - assert_eq!(response.status(), Status::Ok); - let body = response.into_string().unwrap(); - assert!(body.contains("")); - assert!(body.contains("swagger-ui")); - } - - #[test] - fn test_openapi_json_returns_valid_spec() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - - assert_eq!(response.status(), Status::Ok); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - assert_eq!(spec["openapi"], "3.1.0"); - assert_eq!(spec["info"]["title"], "Rain Orderbook API"); - } - - #[test] - fn test_openapi_json_contains_buy_and_sell_paths() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - assert!(spec["paths"]["/take-orders/buy"]["post"].is_object()); - assert_eq!( - spec["paths"]["/take-orders/buy"]["post"]["tags"][0], - "Take Orders" - ); - - assert!(spec["paths"]["/take-orders/sell"]["post"].is_object()); - assert_eq!( - spec["paths"]["/take-orders/sell"]["post"]["tags"][0], - "Take Orders" - ); - } - - #[test] - fn test_openapi_json_contains_schemas() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - let schemas = &spec["components"]["schemas"]; - assert!(schemas["BuyRequest"].is_object()); - assert!(schemas["SellRequest"].is_object()); - assert!(schemas["TakeOrdersApiResponse"].is_object()); - assert!(schemas["ApprovalApiResponse"].is_object()); - assert!(schemas["TakeOrdersReadyResponse"].is_object()); - assert!(schemas["ApiErrorResponse"].is_object()); - } - - #[test] - fn test_openapi_json_contains_response_codes() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - let buy_responses = &spec["paths"]["/take-orders/buy"]["post"]["responses"]; - assert!(buy_responses["200"].is_object()); - assert!(buy_responses["400"].is_object()); - assert!(buy_responses["404"].is_object()); - assert!(buy_responses["500"].is_object()); - - let sell_responses = &spec["paths"]["/take-orders/sell"]["post"]["responses"]; - assert!(sell_responses["200"].is_object()); - assert!(sell_responses["400"].is_object()); - assert!(sell_responses["404"].is_object()); - assert!(sell_responses["500"].is_object()); - } - - #[test] - fn test_openapi_buy_request_field_descriptions() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - let buy_schema = &spec["components"]["schemas"]["BuyRequest"]["properties"]; - - assert_eq!( - buy_schema["yamlContent"]["description"], - "YAML configuration containing network RPC endpoints, subgraph URLs, and orderbook addresses" - ); - assert_eq!( - buy_schema["taker"]["description"], - "Address that will execute the transaction" - ); - assert_eq!( - buy_schema["chainId"]["description"], - "Chain ID where the trade will be executed" - ); - assert_eq!( - buy_schema["tokenIn"]["description"], - "Token address you are giving (spending)" - ); - assert_eq!( - buy_schema["tokenOut"]["description"], - "Token address you are receiving (buying)" - ); - assert_eq!( - buy_schema["amount"]["description"], - "Amount of tokenOut to receive (human-readable decimal string)" - ); - assert_eq!( - buy_schema["maxRatio"]["description"], - "Maximum price ratio (tokenIn per 1 tokenOut). Trade fails if actual ratio exceeds this." - ); - assert_eq!( - buy_schema["exact"]["description"], - "If true, transaction reverts unless exactly the specified amount is received. If false (default), receives up to the specified amount." - ); - } - - #[test] - fn test_openapi_sell_request_field_descriptions() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - let sell_schema = &spec["components"]["schemas"]["SellRequest"]["properties"]; - - assert_eq!( - sell_schema["yamlContent"]["description"], - "YAML configuration containing network RPC endpoints, subgraph URLs, and orderbook addresses" - ); - assert_eq!( - sell_schema["taker"]["description"], - "Address that will execute the transaction" - ); - assert_eq!( - sell_schema["chainId"]["description"], - "Chain ID where the trade will be executed" - ); - assert_eq!( - sell_schema["tokenIn"]["description"], - "Token address you are giving (selling)" - ); - assert_eq!( - sell_schema["tokenOut"]["description"], - "Token address you are receiving" - ); - assert_eq!( - sell_schema["amount"]["description"], - "Amount of tokenIn to spend (human-readable decimal string)" - ); - assert_eq!( - sell_schema["maxRatio"]["description"], - "Maximum price ratio (tokenIn per 1 tokenOut). Trade fails if actual ratio exceeds this." - ); - assert_eq!( - sell_schema["exact"]["description"], - "If true, transaction reverts unless exactly the specified amount is spent. If false (default), spends up to the specified amount." - ); - } - - #[test] - fn test_openapi_response_field_descriptions() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - let ready_schema = &spec["components"]["schemas"]["TakeOrdersReadyResponse"]["properties"]; - - assert_eq!( - ready_schema["orderbook"]["description"], - "Address of the orderbook contract to call" - ); - assert_eq!( - ready_schema["calldata"]["description"], - "ABI-encoded calldata for the takeOrders4 function" - ); - assert_eq!( - ready_schema["effectivePrice"]["description"], - "Blended effective price across all selected orders (tokenIn per 1 tokenOut)" - ); - assert_eq!( - ready_schema["prices"]["description"], - "Individual prices for each order leg, sorted from best to worst" - ); - assert_eq!( - ready_schema["expectedSell"]["description"], - "Expected amount of tokenIn to spend based on current quotes" - ); - assert_eq!( - ready_schema["maxSellCap"]["description"], - "Maximum tokenIn that could be spent (worst-case based on maxRatio)" - ); - - let approval_schema = &spec["components"]["schemas"]["ApprovalApiResponse"]["properties"]; - - assert_eq!( - approval_schema["token"]["description"], - "Token address that needs approval" - ); - assert_eq!( - approval_schema["spender"]["description"], - "Spender address (the orderbook contract)" - ); - assert_eq!( - approval_schema["amount"]["description"], - "Amount to approve (raw value)" - ); - assert_eq!( - approval_schema["formattedAmount"]["description"], - "Human-readable formatted amount" - ); - assert_eq!( - approval_schema["calldata"]["description"], - "ABI-encoded approval calldata" - ); - } - - #[test] - fn test_openapi_json_contains_response_examples() { - let client = client(); - let response = client.get("/swagger/openapi.json").dispatch(); - let body = response.into_string().unwrap(); - let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - - for endpoint in ["/take-orders/buy", "/take-orders/sell"] { - let examples = &spec["paths"][endpoint]["post"]["responses"]["200"]["content"] - ["application/json"]["examples"]; - - assert!( - examples["Ready"].is_object(), - "Ready example should exist for {endpoint}" - ); - assert!( - examples["NeedsApproval"].is_object(), - "NeedsApproval example should exist for {endpoint}" - ); - - let ready_value = &examples["Ready"]["value"]; - assert_eq!(ready_value["status"], "ready"); - assert!(ready_value["data"]["orderbook"].is_string()); - assert!(ready_value["data"]["calldata"].is_string()); - assert!(ready_value["data"]["effectivePrice"].is_string()); - assert!(ready_value["data"]["prices"].is_array()); - assert!(ready_value["data"]["expectedSell"].is_string()); - assert!(ready_value["data"]["maxSellCap"].is_string()); - - let needs_approval_value = &examples["NeedsApproval"]["value"]; - assert_eq!(needs_approval_value["status"], "needsApproval"); - assert!(needs_approval_value["data"]["token"].is_string()); - assert!(needs_approval_value["data"]["spender"].is_string()); - assert!(needs_approval_value["data"]["amount"].is_string()); - assert!(needs_approval_value["data"]["formattedAmount"].is_string()); - assert!(needs_approval_value["data"]["calldata"].is_string()); - } - } -} diff --git a/crates/rest_api/src/routes/mod.rs b/crates/rest_api/src/routes/mod.rs deleted file mode 100644 index 47d76071b0..0000000000 --- a/crates/rest_api/src/routes/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod take_orders; diff --git a/crates/rest_api/src/routes/take_orders.rs b/crates/rest_api/src/routes/take_orders.rs deleted file mode 100644 index 026d99642e..0000000000 --- a/crates/rest_api/src/routes/take_orders.rs +++ /dev/null @@ -1,514 +0,0 @@ -use crate::error::{ApiError, ApiErrorResponse}; -use rain_orderbook_common::raindex_client::take_orders::TakeOrdersRequest; -use rain_orderbook_common::raindex_client::RaindexClient; -use rain_orderbook_common::take_orders::TakeOrdersMode; -use rocket::serde::json::Json; -use rocket::{post, Route}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct BuyRequest { - /// YAML configuration containing network RPC endpoints, subgraph URLs, and orderbook addresses - #[schema( - example = "networks:\n base:\n rpc: https://mainnet.base.org\n chain-id: 8453\nsubgraphs:\n base: https://api.goldsky.com/api/public/project_clv14x04y9kzi01saerx7bxpg/subgraphs/ob4-base/0.9/gn\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base" - )] - pub yaml_content: String, - /// Address that will execute the transaction - #[schema(example = "0x1111111111111111111111111111111111111111")] - pub taker: String, - /// Chain ID where the trade will be executed - #[schema(example = 8453)] - pub chain_id: u32, - /// Token address you are giving (spending) - #[schema(example = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")] - pub token_in: String, - /// Token address you are receiving (buying) - #[schema(example = "0x4200000000000000000000000000000000000006")] - pub token_out: String, - /// Amount of tokenOut to receive (human-readable decimal string) - #[schema(example = "1000")] - pub amount: String, - /// Maximum price ratio (tokenIn per 1 tokenOut). Trade fails if actual ratio exceeds this. - #[schema(example = "0.0005")] - pub max_ratio: String, - /// If true, transaction reverts unless exactly the specified amount is received. If false (default), receives up to the specified amount. - #[serde(default)] - #[schema(example = false)] - pub exact: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct SellRequest { - /// YAML configuration containing network RPC endpoints, subgraph URLs, and orderbook addresses - #[schema( - example = "networks:\n base:\n rpc: https://mainnet.base.org\n chain-id: 8453\nsubgraphs:\n base: https://api.goldsky.com/api/public/project_clv14x04y9kzi01saerx7bxpg/subgraphs/ob4-base/0.9/gn\norderbooks:\n base:\n address: 0xd2938e7c9fe3597f78832ce780feb61945c377d7\n network: base\n subgraph: base" - )] - pub yaml_content: String, - /// Address that will execute the transaction - #[schema(example = "0x1111111111111111111111111111111111111111")] - pub taker: String, - /// Chain ID where the trade will be executed - #[schema(example = 8453)] - pub chain_id: u32, - /// Token address you are giving (selling) - #[schema(example = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")] - pub token_in: String, - /// Token address you are receiving - #[schema(example = "0x4200000000000000000000000000000000000006")] - pub token_out: String, - /// Amount of tokenIn to spend (human-readable decimal string) - #[schema(example = "500")] - pub amount: String, - /// Maximum price ratio (tokenIn per 1 tokenOut). Trade fails if actual ratio exceeds this. - #[schema(example = "0.0005")] - pub max_ratio: String, - /// If true, transaction reverts unless exactly the specified amount is spent. If false (default), spends up to the specified amount. - #[serde(default)] - #[schema(example = false)] - pub exact: bool, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -#[schema(example = json!({ - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "spender": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "amount": "1000", - "formattedAmount": "1000", - "calldata": "0x095ea7b3..." -}))] -pub struct ApprovalApiResponse { - /// Token address that needs approval - #[schema(example = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")] - pub token: String, - /// Spender address (the orderbook contract) - #[schema(example = "0xd2938e7c9fe3597f78832ce780feb61945c377d7")] - pub spender: String, - /// Amount to approve (raw value) - #[schema(example = "1000")] - pub amount: String, - /// Human-readable formatted amount - #[schema(example = "1000")] - pub formatted_amount: String, - /// ABI-encoded approval calldata - #[schema(example = "0x095ea7b3...")] - pub calldata: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase")] -#[schema(example = json!({ - "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "calldata": "0x...", - "effectivePrice": "0.00045", - "prices": ["0.00044", "0.00046"], - "expectedSell": "450", - "maxSellCap": "500" -}))] -pub struct TakeOrdersReadyResponse { - /// Address of the orderbook contract to call - #[schema(example = "0xd2938e7c9fe3597f78832ce780feb61945c377d7")] - pub orderbook: String, - /// ABI-encoded calldata for the takeOrders4 function - #[schema(example = "0x...")] - pub calldata: String, - /// Blended effective price across all selected orders (tokenIn per 1 tokenOut) - #[schema(example = "0.00045")] - pub effective_price: String, - /// Individual prices for each order leg, sorted from best to worst - #[schema(example = json!(["0.00044", "0.00046"]))] - pub prices: Vec, - /// Expected amount of tokenIn to spend based on current quotes - #[schema(example = "450")] - pub expected_sell: String, - /// Maximum tokenIn that could be spent (worst-case based on maxRatio) - #[schema(example = "500")] - pub max_sell_cap: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase", tag = "status", content = "data")] -pub enum TakeOrdersApiResponse { - #[schema(title = "NeedsApproval")] - NeedsApproval(ApprovalApiResponse), - #[schema(title = "Ready")] - Ready(TakeOrdersReadyResponse), -} - -async fn execute_take_orders( - yaml_content: String, - request: TakeOrdersRequest, -) -> Result { - let client = RaindexClient::new(vec![yaml_content], None, None)?; - - let result = client.get_take_orders_calldata(request).await?; - - if let Some(approval_info) = result.approval_info() { - let amount = approval_info.amount().format().map_err(|e| { - ApiError::Raindex(rain_orderbook_common::raindex_client::RaindexError::Float( - e, - )) - })?; - - Ok(TakeOrdersApiResponse::NeedsApproval(ApprovalApiResponse { - token: approval_info.token().to_string(), - spender: approval_info.spender().to_string(), - amount, - formatted_amount: approval_info.formatted_amount().to_string(), - calldata: approval_info.calldata().to_string(), - })) - } else if let Some(take_orders_info) = result.take_orders_info() { - let effective_price = take_orders_info.effective_price().format().map_err(|e| { - ApiError::Raindex(rain_orderbook_common::raindex_client::RaindexError::Float( - e, - )) - })?; - - let prices: Result, _> = take_orders_info - .prices() - .iter() - .map(|p| { - p.format().map_err(|e| { - ApiError::Raindex(rain_orderbook_common::raindex_client::RaindexError::Float( - e, - )) - }) - }) - .collect(); - - let expected_sell = take_orders_info.expected_sell().format().map_err(|e| { - ApiError::Raindex(rain_orderbook_common::raindex_client::RaindexError::Float( - e, - )) - })?; - - let max_sell_cap = take_orders_info.max_sell_cap().format().map_err(|e| { - ApiError::Raindex(rain_orderbook_common::raindex_client::RaindexError::Float( - e, - )) - })?; - - Ok(TakeOrdersApiResponse::Ready(TakeOrdersReadyResponse { - orderbook: take_orders_info.orderbook().to_string(), - calldata: take_orders_info.calldata().to_string(), - effective_price, - prices: prices?, - expected_sell, - max_sell_cap, - })) - } else { - unreachable!("TakeOrdersCalldataResult must be either NeedsApproval or Ready") - } -} - -#[utoipa::path( - post, - path = "/take-orders/buy", - tag = "Take Orders", - request_body = BuyRequest, - responses( - (status = 200, description = "Successfully generated buy calldata. Returns either approval info if token approval is needed, or ready calldata if approval is sufficient.", body = TakeOrdersApiResponse, - examples( - ("Ready" = ( - summary = "Calldata ready to execute", - description = "Returned when the taker has sufficient token approval. The calldata can be submitted directly to the orderbook.", - value = json!({ - "status": "ready", - "data": { - "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "calldata": "0x...", - "effectivePrice": "0.00045", - "prices": ["0.00044", "0.00046"], - "expectedSell": "450", - "maxSellCap": "500" - } - }) - )), - ("NeedsApproval" = ( - summary = "Token approval required", - description = "Returned when the taker needs to approve token spending before executing. Submit the approval calldata first, then retry the request.", - value = json!({ - "status": "needsApproval", - "data": { - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "spender": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "amount": "1000", - "formattedAmount": "1000", - "calldata": "0x095ea7b3..." - } - }) - )) - ) - ), - (status = 400, description = "Invalid request parameters", body = ApiErrorResponse), - (status = 404, description = "No liquidity found or configuration not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse) - ) -)] -#[post("/take-orders/buy", data = "")] -pub async fn buy(request: Json) -> Result, ApiError> { - let mode = if request.exact { - TakeOrdersMode::BuyExact - } else { - TakeOrdersMode::BuyUpTo - }; - - let yaml_content = request.yaml_content.clone(); - let take_request = TakeOrdersRequest { - taker: request.taker.clone(), - chain_id: request.chain_id, - sell_token: request.token_in.clone(), - buy_token: request.token_out.clone(), - mode, - amount: request.amount.clone(), - price_cap: request.max_ratio.clone(), - }; - - // RaindexClient contains Rc> which is not Send, but Rocket requires - // Send futures. We use spawn_blocking with a dedicated runtime to run everything - // on a single thread where Rc is safe. - let response = tokio::task::spawn_blocking(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| ApiError::Internal(format!("Failed to create runtime: {}", e)))?; - - rt.block_on(execute_take_orders(yaml_content, take_request)) - }) - .await - .map_err(|e| ApiError::Internal(format!("Task execution failed: {}", e)))??; - - Ok(Json(response)) -} - -#[utoipa::path( - post, - path = "/take-orders/sell", - tag = "Take Orders", - request_body = SellRequest, - responses( - (status = 200, description = "Successfully generated sell calldata. Returns either approval info if token approval is needed, or ready calldata if approval is sufficient.", body = TakeOrdersApiResponse, - examples( - ("Ready" = ( - summary = "Calldata ready to execute", - description = "Returned when the taker has sufficient token approval. The calldata can be submitted directly to the orderbook.", - value = json!({ - "status": "ready", - "data": { - "orderbook": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "calldata": "0x...", - "effectivePrice": "0.00045", - "prices": ["0.00044", "0.00046"], - "expectedSell": "450", - "maxSellCap": "500" - } - }) - )), - ("NeedsApproval" = ( - summary = "Token approval required", - description = "Returned when the taker needs to approve token spending before executing. Submit the approval calldata first, then retry the request.", - value = json!({ - "status": "needsApproval", - "data": { - "token": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", - "spender": "0xd2938e7c9fe3597f78832ce780feb61945c377d7", - "amount": "1000", - "formattedAmount": "1000", - "calldata": "0x095ea7b3..." - } - }) - )) - ) - ), - (status = 400, description = "Invalid request parameters", body = ApiErrorResponse), - (status = 404, description = "No liquidity found or configuration not found", body = ApiErrorResponse), - (status = 500, description = "Internal server error", body = ApiErrorResponse) - ) -)] -#[post("/take-orders/sell", data = "")] -pub async fn sell(request: Json) -> Result, ApiError> { - let mode = if request.exact { - TakeOrdersMode::SpendExact - } else { - TakeOrdersMode::SpendUpTo - }; - - let yaml_content = request.yaml_content.clone(); - let take_request = TakeOrdersRequest { - taker: request.taker.clone(), - chain_id: request.chain_id, - sell_token: request.token_in.clone(), - buy_token: request.token_out.clone(), - mode, - amount: request.amount.clone(), - price_cap: request.max_ratio.clone(), - }; - - // RaindexClient contains Rc> which is not Send, but Rocket requires - // Send futures. We use spawn_blocking with a dedicated runtime to run everything - // on a single thread where Rc is safe. - let response = tokio::task::spawn_blocking(move || { - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|e| ApiError::Internal(format!("Failed to create runtime: {}", e)))?; - - rt.block_on(execute_take_orders(yaml_content, take_request)) - }) - .await - .map_err(|e| ApiError::Internal(format!("Task execution failed: {}", e)))??; - - Ok(Json(response)) -} - -pub fn routes() -> Vec { - rocket::routes![buy, sell] -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_buy_request_deserialization() { - let json = r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#; - - let request: BuyRequest = serde_json::from_str(json).unwrap(); - - assert_eq!(request.yaml_content, "version: 1"); - assert_eq!(request.taker, "0x1111111111111111111111111111111111111111"); - assert_eq!(request.chain_id, 1); - assert_eq!( - request.token_in, - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - assert_eq!( - request.token_out, - "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ); - assert_eq!(request.amount, "100"); - assert_eq!(request.max_ratio, "2.5"); - assert!(!request.exact); - } - - #[test] - fn test_buy_request_deserialization_exact() { - let json = r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 137, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "50.5", - "maxRatio": "1.0", - "exact": true - }"#; - - let request: BuyRequest = serde_json::from_str(json).unwrap(); - - assert!(request.exact); - } - - #[test] - fn test_sell_request_deserialization() { - let json = r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "100", - "maxRatio": "2.5" - }"#; - - let request: SellRequest = serde_json::from_str(json).unwrap(); - - assert_eq!(request.yaml_content, "version: 1"); - assert_eq!(request.taker, "0x1111111111111111111111111111111111111111"); - assert_eq!(request.chain_id, 1); - assert_eq!( - request.token_in, - "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - ); - assert_eq!( - request.token_out, - "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - ); - assert_eq!(request.amount, "100"); - assert_eq!(request.max_ratio, "2.5"); - assert!(!request.exact); - } - - #[test] - fn test_sell_request_deserialization_exact() { - let json = r#"{ - "yamlContent": "version: 1", - "taker": "0x1111111111111111111111111111111111111111", - "chainId": 1, - "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "amount": "75", - "maxRatio": "3.0", - "exact": true - }"#; - - let request: SellRequest = serde_json::from_str(json).unwrap(); - - assert!(request.exact); - } - - #[test] - fn test_ready_response_serialization() { - let response = TakeOrdersApiResponse::Ready(TakeOrdersReadyResponse { - orderbook: "0x1234567890123456789012345678901234567890".to_string(), - calldata: "0xabcdef".to_string(), - effective_price: "1.5".to_string(), - prices: vec!["1.4".to_string(), "1.6".to_string()], - expected_sell: "150".to_string(), - max_sell_cap: "200".to_string(), - }); - - let json = serde_json::to_string(&response).unwrap(); - - assert!(json.contains("\"status\":\"ready\"")); - assert!(json.contains("\"data\":")); - assert!(json.contains("\"orderbook\":")); - assert!(json.contains("\"calldata\":")); - assert!(json.contains("\"effectivePrice\":")); - assert!(json.contains("\"prices\":")); - assert!(json.contains("\"expectedSell\":")); - assert!(json.contains("\"maxSellCap\":")); - } - - #[test] - fn test_needs_approval_response_serialization() { - let response = TakeOrdersApiResponse::NeedsApproval(ApprovalApiResponse { - token: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), - spender: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), - amount: "1000".to_string(), - formatted_amount: "1000".to_string(), - calldata: "0xabcdef".to_string(), - }); - - let json = serde_json::to_string(&response).unwrap(); - - assert!(json.contains("\"status\":\"needsApproval\"")); - assert!(json.contains("\"data\":")); - assert!(json.contains("\"token\":")); - assert!(json.contains("\"spender\":")); - assert!(json.contains("\"amount\":")); - assert!(json.contains("\"formattedAmount\":")); - assert!(json.contains("\"calldata\":")); - } -}