diff --git a/crates/rest_api/src/main.rs b/crates/rest_api/src/main.rs index c8694c60c5..124685a80a 100644 --- a/crates/rest_api/src/main.rs +++ b/crates/rest_api/src/main.rs @@ -6,8 +6,7 @@ use rocket::http::Method; use rocket::{launch, Build, Rocket}; use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions}; use routes::take_orders::{ - ApprovalApiResponse, TakeOrdersApiRequest, TakeOrdersApiResponse, TakeOrdersMode, - TakeOrdersReadyResponse, + ApprovalApiResponse, BuyRequest, SellRequest, TakeOrdersApiResponse, TakeOrdersReadyResponse, }; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -18,13 +17,13 @@ use utoipa_swagger_ui::SwaggerUi; title = "Rain Orderbook API", description = "REST API for interacting with Rain Orderbook." ), - paths(routes::take_orders::take_orders), + paths(routes::take_orders::buy, routes::take_orders::sell), components(schemas( - TakeOrdersApiRequest, + BuyRequest, + SellRequest, TakeOrdersApiResponse, ApprovalApiResponse, TakeOrdersReadyResponse, - TakeOrdersMode, ApiErrorResponse )), tags( @@ -77,10 +76,10 @@ mod tests { } #[test] - fn test_cors_preflight() { + fn test_cors_preflight_buy() { let client = client(); let response = client - .options("/take-orders") + .options("/take-orders/buy") .header(rocket::http::Header::new( "Access-Control-Request-Method", "POST", @@ -95,21 +94,38 @@ mod tests { } #[test] - fn test_take_orders_missing_yaml() { + fn test_cors_preflight_sell() { let client = client(); let response = client - .post("/take-orders") + .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, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "100", - "priceCap": "2.5" + "maxRatio": "2.5" }"#, ) .dispatch(); @@ -118,21 +134,86 @@ mod tests { } #[test] - fn test_take_orders_invalid_address() { + fn test_buy_invalid_address() { let client = client(); let response = client - .post("/take-orders") + .post("/take-orders/buy") .header(ContentType::JSON) .body( r#"{ "yamlContent": "version: 1", "taker": "invalid-address", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyUpTo", + "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", - "priceCap": "2.5" + "maxRatio": "-1" }"#, ) .dispatch(); @@ -141,21 +222,85 @@ mod tests { } #[test] - fn test_take_orders_same_token_pair() { + fn test_buy_missing_field() { let client = client(); let response = client - .post("/take-orders") + .post("/take-orders/buy") .header(ContentType::JSON) .body( r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "mode": "buyUpTo", + "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", - "priceCap": "2.5" + "maxRatio": "2.5" }"#, ) .dispatch(); @@ -164,21 +309,20 @@ mod tests { } #[test] - fn test_take_orders_zero_amount() { + fn test_sell_zero_amount() { let client = client(); let response = client - .post("/take-orders") + .post("/take-orders/sell") .header(ContentType::JSON) .body( r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "0", - "priceCap": "2.5" + "maxRatio": "2.5" }"#, ) .dispatch(); @@ -187,21 +331,20 @@ mod tests { } #[test] - fn test_take_orders_negative_price_cap() { + fn test_sell_negative_max_ratio() { let client = client(); let response = client - .post("/take-orders") + .post("/take-orders/sell") .header(ContentType::JSON) .body( r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "100", - "priceCap": "-1" + "maxRatio": "-1" }"#, ) .dispatch(); @@ -210,20 +353,19 @@ mod tests { } #[test] - fn test_take_orders_missing_field() { + fn test_sell_missing_field() { let client = client(); let response = client - .post("/take-orders") + .post("/take-orders/sell") .header(ContentType::JSON) .body( r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "mode": "buyUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "amount": "100", - "priceCap": "2.5" + "maxRatio": "2.5" }"#, ) .dispatch(); @@ -256,15 +398,21 @@ mod tests { } #[test] - fn test_openapi_json_contains_take_orders_path() { + 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"]["post"].is_object()); + 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"]["post"]["tags"][0], + spec["paths"]["/take-orders/sell"]["post"]["tags"][0], "Take Orders" ); } @@ -277,11 +425,11 @@ mod tests { let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); let schemas = &spec["components"]["schemas"]; - assert!(schemas["TakeOrdersApiRequest"].is_object()); + 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["TakeOrdersMode"].is_object()); assert!(schemas["ApiErrorResponse"].is_object()); } @@ -292,44 +440,199 @@ mod tests { let body = response.into_string().unwrap(); let spec: serde_json::Value = serde_json::from_str(&body).unwrap(); - let responses = &spec["paths"]["/take-orders"]["post"]["responses"]; - assert!(responses["200"].is_object()); - assert!(responses["400"].is_object()); - assert!(responses["404"].is_object()); - assert!(responses["500"].is_object()); + 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_json_contains_response_examples() { + 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 examples = &spec["paths"]["/take-orders"]["post"]["responses"]["200"]["content"] - ["application/json"]["examples"]; + let ready_schema = &spec["components"]["schemas"]["TakeOrdersReadyResponse"]["properties"]; - assert!(examples["Ready"].is_object(), "Ready example should exist"); - assert!( - examples["NeedsApproval"].is_object(), - "NeedsApproval example should exist" + 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(); - 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()); + 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/take_orders.rs b/crates/rest_api/src/routes/take_orders.rs index 9d47de349d..10ed8a1ff6 100644 --- a/crates/rest_api/src/routes/take_orders.rs +++ b/crates/rest_api/src/routes/take_orders.rs @@ -3,51 +3,74 @@ use rain_orderbook_common::raindex_client::take_orders::{ TakeOrdersCalldataResult, 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, Copy, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] -pub enum TakeOrdersMode { - BuyExact, - BuyUpTo, - SpendExact, - SpendUpTo, -} - -impl From for rain_orderbook_common::take_orders::TakeOrdersMode { - fn from(mode: TakeOrdersMode) -> Self { - match mode { - TakeOrdersMode::BuyExact => Self::BuyExact, - TakeOrdersMode::BuyUpTo => Self::BuyUpTo, - TakeOrdersMode::SpendExact => Self::SpendExact, - TakeOrdersMode::SpendUpTo => Self::SpendUpTo, - } - } +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 TakeOrdersApiRequest { +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 sell_token: String, + pub token_in: String, + /// Token address you are receiving #[schema(example = "0x4200000000000000000000000000000000000006")] - pub buy_token: String, - pub mode: TakeOrdersMode, - #[schema(example = "1000")] + 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 price_cap: String, + 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)] @@ -60,14 +83,19 @@ pub struct TakeOrdersApiRequest { "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, } @@ -83,16 +111,22 @@ pub struct ApprovalApiResponse { "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, } @@ -175,11 +209,11 @@ async fn execute_take_orders( #[utoipa::path( post, - path = "/take-orders", + path = "/take-orders/buy", tag = "Take Orders", - request_body = TakeOrdersApiRequest, + request_body = BuyRequest, responses( - (status = 200, description = "Successfully processed take orders request. Returns either approval info if token approval is needed, or ready calldata if approval is sufficient.", body = TakeOrdersApiResponse, + (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", @@ -217,19 +251,103 @@ async fn execute_take_orders( (status = 500, description = "Internal server error", body = ApiErrorResponse) ) )] -#[post("/take-orders", data = "")] -pub async fn take_orders( - request: Json, -) -> Result, ApiError> { +#[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.sell_token.clone(), - buy_token: request.buy_token.clone(), - mode: request.mode.into(), + sell_token: request.token_in.clone(), + buy_token: request.token_out.clone(), + mode, amount: request.amount.clone(), - price_cap: request.price_cap.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 @@ -250,7 +368,7 @@ pub async fn take_orders( } pub fn routes() -> Vec { - rocket::routes![take_orders] + rocket::routes![buy, sell] } #[cfg(test)] @@ -258,88 +376,99 @@ mod tests { use super::*; #[test] - fn test_request_deserialization_buy_up_to() { + fn test_buy_request_deserialization() { let json = r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "100", - "priceCap": "2.5" + "maxRatio": "2.5" }"#; - let request: TakeOrdersApiRequest = serde_json::from_str(json).unwrap(); + 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.sell_token, + request.token_in, "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ); assert_eq!( - request.buy_token, + request.token_out, "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" ); assert_eq!(request.amount, "100"); - assert_eq!(request.price_cap, "2.5"); - assert!(matches!(request.mode, TakeOrdersMode::BuyUpTo)); + assert_eq!(request.max_ratio, "2.5"); + assert!(!request.exact); } #[test] - fn test_request_deserialization_buy_exact() { + fn test_buy_request_deserialization_exact() { let json = r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 137, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "buyExact", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "50.5", - "priceCap": "1.0" + "maxRatio": "1.0", + "exact": true }"#; - let request: TakeOrdersApiRequest = serde_json::from_str(json).unwrap(); + let request: BuyRequest = serde_json::from_str(json).unwrap(); - assert!(matches!(request.mode, TakeOrdersMode::BuyExact)); + assert!(request.exact); } #[test] - fn test_request_deserialization_spend_up_to() { + fn test_sell_request_deserialization() { let json = r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "spendUpTo", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "100", - "priceCap": "2.5" + "maxRatio": "2.5" }"#; - let request: TakeOrdersApiRequest = serde_json::from_str(json).unwrap(); + let request: SellRequest = serde_json::from_str(json).unwrap(); - assert!(matches!(request.mode, TakeOrdersMode::SpendUpTo)); + 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_request_deserialization_spend_exact() { + fn test_sell_request_deserialization_exact() { let json = r#"{ "yamlContent": "version: 1", "taker": "0x1111111111111111111111111111111111111111", "chainId": 1, - "sellToken": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "buyToken": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", - "mode": "spendExact", + "tokenIn": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "tokenOut": "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "amount": "75", - "priceCap": "3.0" + "maxRatio": "3.0", + "exact": true }"#; - let request: TakeOrdersApiRequest = serde_json::from_str(json).unwrap(); + let request: SellRequest = serde_json::from_str(json).unwrap(); - assert!(matches!(request.mode, TakeOrdersMode::SpendExact)); + assert!(request.exact); } #[test]