diff --git a/orderbook/openapi.yml b/orderbook/openapi.yml index 5534a5117..3dc2f441a 100644 --- a/orderbook/openapi.yml +++ b/orderbook/openapi.yml @@ -417,6 +417,40 @@ paths: $ref: "#/components/schemas/Order" 400: description: Problem with parameters like limit being too large. + /api/v1/quote: + post: + summary: Quotes a price and fee for the specified order parameters. + description: | + This API endpoint accepts a partial order and computes the minimum fee and + a price estimate for the order. It returns a full order that can be used + directly for signing, and with an included signature, passed directly to + the order creation endpoint. + requestBody: + description: The order parameters to compute a quote for. + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/OrderQuoteRequest" + responses: + 200: + description: Quoted order. + content: + application/json: + schema: + $ref: "#/components/schemas/OrderQuote" + 400: + description: Error quoting order. + content: + application/json: + schema: + $ref: "#/components/schemas/FeeAndQuoteError" + 403: + description: Forbidden, your account is deny-listed + 429: + description: Too many order quotes + 500: + description: Unexpected error quoting an order components: schemas: TransactionHash: @@ -493,8 +527,8 @@ components: description: The current order status type: string enum: [presignaturePending, open, fulfilled, cancelled, expired] - OrderCreation: - description: Data a user provides when creating a new order. + OrderParameters: + description: Order parameters. type: object properties: sellToken: @@ -533,24 +567,12 @@ components: partiallyFillable: description: Is this a fill-or-kill order or a partially fillable order? type: boolean - signature: - $ref: "#/components/schemas/Signature" - signingScheme: - $ref: "#/components/schemas/SigningScheme" sellTokenBalance: $ref: "#/components/schemas/SellTokenSource" default: "erc20" buyTokenBalance: $ref: "#/components/schemas/BuyTokenDestination" default: "erc20" - from: - description: | - If set, the backend enforces that this address matches what is decoded as the signer of - the signature. This helps catch errors with invalid signature encodings as the backend - might otherwise silently work with an unexpected address that for example does not have - any balance. - $ref: "#/components/schemas/Address" - nullable: true required: - sellToken - buyToken @@ -561,8 +583,27 @@ components: - feeAmount - kind - partiallyFillable - - signature - - signingScheme + OrderCreation: + description: Data a user provides when creating a new order. + allOf: + - $ref: "#/components/schemas/OrderParameters" + - type: object + properties: + signingScheme: + $ref: "#/components/schemas/SigningScheme" + signature: + $ref: "#/components/schemas/Signature" + from: + description: | + If set, the backend enforces that this address matches what is decoded as the signer of + the signature. This helps catch errors with invalid signature encodings as the backend + might otherwise silently work with an unexpected address that for example does not have + any balance. + $ref: "#/components/schemas/Address" + nullable: true + required: + - signingScheme + - signature OrderMetaData: description: | Extra order data that is returned to users when querying orders @@ -769,3 +810,99 @@ components: required: - errorType - description + OrderQuoteSide: + description: The buy or sell side when quoting an order. + oneOf: + - type: object + description: Quote a sell order given the final total sell amount including fees + properties: + kind: + type: string + enum: [sell] + sellAmountBeforeFee: + description: | + The total amount that is available for the order. From this value, the fee + is deducted and the buy amount is calculated. + $ref: "#/components/schemas/TokenAmount" + required: + - kind + - sellAmountBeforeFee + - type: object + description: Quote a sell order given the sell amount. + properties: + kind: + type: string + enum: [sell] + sellAmountAfterFee: + description: The sell amount for the order. + $ref: "#/components/schemas/TokenAmount" + required: + - kind + - sellAmountAfterFee + - type: object + description: Quote a buy order given an exact buy amount. + properties: + kind: + type: string + enum: [buy] + buyAmountAfterFee: + description: The buy amount for the order. + $ref: "#/components/schemas/TokenAmount" + required: + - kind + - buyAmountAfterFee + OrderQuoteRequest: + description: Request fee and price quote. + allOf: + - $ref: "#/components/schemas/OrderQuoteSide" + - type: object + properties: + sellToken: + description: "ERC20 token to be sold" + $ref: "#/components/schemas/Address" + buyToken: + description: "ERC20 token to be bought" + $ref: "#/components/schemas/Address" + receiver: + description: | + An optional address to receive the proceeds of the trade instead of the + owner (i.e. the order signer). + $ref: "#/components/schemas/Address" + nullable: true + validTo: + description: Unix timestamp until the order is valid. uint32. + type: integer + appData: + description: | + Arbitrary application specific data that can be added to an order. This can + also be used to ensure uniqueness between two orders with otherwise the + exact same parameters. + $ref: "#/components/schemas/AppData" + partiallyFillable: + description: Is this a fill-or-kill order or a partially fillable order? + type: boolean + sellTokenBalance: + $ref: "#/components/schemas/SellTokenSource" + default: "erc20" + buyTokenBalance: + $ref: "#/components/schemas/BuyTokenDestination" + default: "erc20" + required: + - sellToken + - buyToken + - validTo + - appData + - partiallyFillable + OrderQuote: + description: | + An order quoted by the back end that can be directly signed and + submitted to the order creation backend. + allOf: + - $ref: "#/components/schemas/OrderParameters" + - type: object + properties: + from: + description: The expected address of the signer + $ref: "#/components/schemas/Address" + required: + - from diff --git a/orderbook/src/api.rs b/orderbook/src/api.rs index 70baa93bc..0919011b1 100644 --- a/orderbook/src/api.rs +++ b/orderbook/src/api.rs @@ -8,6 +8,7 @@ mod get_orders; mod get_solvable_orders; mod get_trades; mod get_user_orders; +mod post_quote; use crate::{ database::trades::TradeRetrieving, @@ -48,6 +49,7 @@ pub fn handle_all_routes( let get_fee_and_quote_buy = get_fee_and_quote::get_fee_and_quote_buy(fee_calculator, price_estimator.clone()); let get_user_orders = get_user_orders::get_user_orders(orderbook); + let post_quote = post_quote::post_quote(); let cors = warp::cors() .allow_any_origin() .allow_methods(vec!["GET", "POST", "DELETE", "OPTIONS", "PUT", "PATCH"]) @@ -77,6 +79,8 @@ pub fn handle_all_routes( .map(|reply| LabelledReply::new(reply, "get_fee_and_quote_buy"))) .unify() .or(get_user_orders.map(|reply| LabelledReply::new(reply, "get_user_orders"))) + .unify() + .or(post_quote.map(|reply| LabelledReply::new(reply, "get_user_orders"))) .unify(), ); routes_with_labels diff --git a/orderbook/src/api/post_quote.rs b/orderbook/src/api/post_quote.rs new file mode 100644 index 000000000..8b8841def --- /dev/null +++ b/orderbook/src/api/post_quote.rs @@ -0,0 +1,207 @@ +use crate::api; +use anyhow::{anyhow, Result}; +use ethcontract::{H160, U256}; +use model::{ + appdata_hexadecimal, + order::{BuyTokenDestination, OrderKind, SellTokenSource}, + u256_decimal, +}; +use serde::{Deserialize, Serialize}; +use std::convert::Infallible; +use warp::{hyper::StatusCode, reply, Filter, Rejection, Reply}; + +/// The order parameters to quote a price and fee for. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct OrderQuoteRequest { + from: H160, + sell_token: H160, + buy_token: H160, + receiver: Option, + #[serde(flatten)] + side: OrderQuoteSide, + valid_to: u32, + #[serde(with = "appdata_hexadecimal")] + app_data: [u8; 32], + partially_fillable: bool, + #[serde(default)] + sell_token_balance: SellTokenSource, + #[serde(default)] + buy_token_balance: BuyTokenDestination, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "lowercase")] +enum OrderQuoteSide { + #[serde(rename_all = "camelCase")] + Sell { + #[serde(flatten)] + sell_amount: SellAmount, + }, + #[serde(rename_all = "camelCase")] + Buy { + #[serde(with = "u256_decimal")] + buy_amount_after_fee: U256, + }, +} + +#[derive(Debug, Deserialize, PartialEq)] +#[serde(untagged)] +enum SellAmount { + BeforeFee { + #[serde(rename = "sellAmountBeforeFee", with = "u256_decimal")] + value: U256, + }, + AfterFee { + #[serde(rename = "sellAmountAfterFee", with = "u256_decimal")] + value: U256, + }, +} + +/// The quoted order by the service. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct OrderQuote { + from: H160, + sell_token: H160, + buy_token: H160, + receiver: Option, + #[serde(with = "u256_decimal")] + sell_amount: U256, + #[serde(with = "u256_decimal")] + buy_amount: U256, + valid_to: u32, + #[serde(with = "appdata_hexadecimal")] + app_data: [u8; 32], + #[serde(with = "u256_decimal")] + fee_amount: U256, + kind: OrderKind, + partially_fillable: bool, + sell_token_balance: SellTokenSource, + buy_token_balance: BuyTokenDestination, +} + +fn post_quote_request() -> impl Filter + Clone { + warp::path!("quote") + .and(warp::post()) + .and(api::extract_payload()) +} + +fn post_order_response(result: Result) -> impl Reply { + match result { + Ok(response) => reply::with_status(reply::json(&response), StatusCode::OK), + Err(err) => reply::with_status( + super::error("InternalServerError", err.to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + } +} + +pub fn post_quote() -> impl Filter + Clone { + post_quote_request().and_then(move |request| async move { + tracing::warn!("unimplemented request {:#?}", request); + Result::<_, Infallible>::Ok(post_order_response(Err(anyhow!("not yet implemented")))) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn deserializes_sell_after_fees_quote_request() { + assert_eq!( + serde_json::from_value::(json!({ + "from": "0x0101010101010101010101010101010101010101", + "sellToken": "0x0202020202020202020202020202020202020202", + "buyToken": "0x0303030303030303030303030303030303030303", + "kind": "sell", + "sellAmountAfterFee": "1337", + "validTo": 0x12345678, + "appData": "0x9090909090909090909090909090909090909090909090909090909090909090", + "partiallyFillable": false, + "buyTokenBalance": "internal", + })) + .unwrap(), + OrderQuoteRequest { + from: H160([0x01; 20]), + sell_token: H160([0x02; 20]), + buy_token: H160([0x03; 20]), + receiver: None, + side: OrderQuoteSide::Sell { + sell_amount: SellAmount::AfterFee { value: 1337.into() }, + }, + valid_to: 0x12345678, + app_data: [0x90; 32], + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Internal, + } + ); + } + + #[test] + fn deserializes_sell_before_fees_quote_request() { + assert_eq!( + serde_json::from_value::(json!({ + "from": "0x0101010101010101010101010101010101010101", + "sellToken": "0x0202020202020202020202020202020202020202", + "buyToken": "0x0303030303030303030303030303030303030303", + "kind": "sell", + "sellAmountBeforeFee": "1337", + "validTo": 0x12345678, + "appData": "0x9090909090909090909090909090909090909090909090909090909090909090", + "partiallyFillable": false, + "sellTokenBalance": "external", + })) + .unwrap(), + OrderQuoteRequest { + from: H160([0x01; 20]), + sell_token: H160([0x02; 20]), + buy_token: H160([0x03; 20]), + receiver: None, + side: OrderQuoteSide::Sell { + sell_amount: SellAmount::BeforeFee { value: 1337.into() }, + }, + valid_to: 0x12345678, + app_data: [0x90; 32], + partially_fillable: false, + sell_token_balance: SellTokenSource::External, + buy_token_balance: BuyTokenDestination::Erc20, + } + ); + } + + #[test] + fn deserializes_buy_quote_request() { + assert_eq!( + serde_json::from_value::(json!({ + "from": "0x0101010101010101010101010101010101010101", + "sellToken": "0x0202020202020202020202020202020202020202", + "buyToken": "0x0303030303030303030303030303030303030303", + "receiver": "0x0404040404040404040404040404040404040404", + "kind": "buy", + "buyAmountAfterFee": "1337", + "validTo": 0x12345678, + "appData": "0x9090909090909090909090909090909090909090909090909090909090909090", + "partiallyFillable": false, + })) + .unwrap(), + OrderQuoteRequest { + from: H160([0x01; 20]), + sell_token: H160([0x02; 20]), + buy_token: H160([0x03; 20]), + receiver: Some(H160([0x04; 20])), + side: OrderQuoteSide::Buy { + buy_amount_after_fee: U256::from(1337), + }, + valid_to: 0x12345678, + app_data: [0x90; 32], + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + } + ); + } +}