From 5147e7d7b138407b027f9031f1e6a523ca725067 Mon Sep 17 00:00:00 2001 From: Nicholas Rodrigues Lordello Date: Wed, 22 Sep 2021 12:21:08 +0200 Subject: [PATCH 1/4] New quote API --- orderbook/openapi.yml | 165 ++++++++++++++++++++++++++++---- orderbook/src/api.rs | 4 + orderbook/src/api/post_quote.rs | 92 ++++++++++++++++++ 3 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 orderbook/src/api/post_quote.rs diff --git a/orderbook/openapi.yml b/orderbook/openapi.yml index 5534a5117..764fbb05d 100644 --- a/orderbook/openapi.yml +++ b/orderbook/openapi.yml @@ -417,6 +417,49 @@ 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. + parameters: + - in: header + name: X-AppData + description: | + The front-end specific application data. This allows the backend to + differentiate the front-end used to quote the order even in the presence + of a referral. + $ref: "#/components/schemas/AppData" + required: false + 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 placements + 500: + description: Error quoting an order components: schemas: TransactionHash: @@ -493,8 +536,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 +576,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 +592,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 +819,86 @@ components: required: - errorType - description + OrderQuoteSide: + description: The buy or sell side when quoting an order. + oneOf: + - type: object + properties: + kind: + description: "A sell order" + type: string + enum: [sell] + totalSellAmount: + description: | + The total sell amount for the quoted order. The sell amount for the + returned orders will have fees deducted from this value. + $ref: "#/components/schemas/TokenAmount" + required: + - kind + - totalSellAmount + - type: object + properties: + kind: + description: "A buy order" + type: string + enum: [buy] + buyAmount: + description: The buy amount for the order. + $ref: "#/components/schemas/TokenAmount" + required: + - kind + - buyAmount + OrderQuoteRequest: + description: Request for generating a 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: 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 ed66f1d50..2eafbfc5e 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..d269e7529 --- /dev/null +++ b/orderbook/src/api/post_quote.rs @@ -0,0 +1,92 @@ +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)] +#[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)] +#[serde(tag = "kind")] +enum OrderQuoteSide { + #[serde(rename_all = "camelCase")] + Sell { + #[serde(with = "u256_decimal")] + total_sell_amount: U256, + }, + #[serde(rename_all = "camelCase")] + Buy { + #[serde(with = "u256_decimal")] + buy_amount: 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!("feeAndQuote" / "sell") + .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("NotYetImplemented", 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")))) + }) +} From e6ca8e1dbb8ea6a22ddaeef17907daa5555e802e Mon Sep 17 00:00:00 2001 From: Nicholas Rodrigues Lordello Date: Wed, 22 Sep 2021 15:23:46 +0200 Subject: [PATCH 2/4] quote sell before and after fee --- orderbook/openapi.yml | 40 ++++++++++++++++++--------------- orderbook/src/api/post_quote.rs | 19 +++++++++++++--- 2 files changed, 38 insertions(+), 21 deletions(-) diff --git a/orderbook/openapi.yml b/orderbook/openapi.yml index 764fbb05d..ee5ea87af 100644 --- a/orderbook/openapi.yml +++ b/orderbook/openapi.yml @@ -425,15 +425,6 @@ paths: 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. - parameters: - - in: header - name: X-AppData - description: | - The front-end specific application data. This allows the backend to - differentiate the front-end used to quote the order even in the presence - of a referral. - $ref: "#/components/schemas/AppData" - required: false requestBody: description: The order parameters to compute a quote for. required: true @@ -823,31 +814,43 @@ components: 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: - description: "A sell order" type: string enum: [sell] - totalSellAmount: + sellAmountBeforeFee: description: | - The total sell amount for the quoted order. The sell amount for the - returned orders will have fees deducted from this value. + 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 - - totalSellAmount + - 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: - description: "A buy order" type: string enum: [buy] - buyAmount: + buyAmountAfterFee: description: The buy amount for the order. $ref: "#/components/schemas/TokenAmount" required: - kind - - buyAmount + - buyAmountAfterFee OrderQuoteRequest: description: Request for generating a fee and price quote. allOf: @@ -898,7 +901,8 @@ components: - $ref: "#/components/schemas/OrderParameters" - type: object properties: - - from: The expected address of the signer + from: + description: The expected address of the signer $ref: "#/components/schemas/Address" required: - from diff --git a/orderbook/src/api/post_quote.rs b/orderbook/src/api/post_quote.rs index d269e7529..844083d45 100644 --- a/orderbook/src/api/post_quote.rs +++ b/orderbook/src/api/post_quote.rs @@ -35,13 +35,26 @@ struct OrderQuoteRequest { enum OrderQuoteSide { #[serde(rename_all = "camelCase")] Sell { - #[serde(with = "u256_decimal")] - total_sell_amount: U256, + #[serde(flatten)] + sell_amount: SellAmount, }, #[serde(rename_all = "camelCase")] Buy { #[serde(with = "u256_decimal")] - buy_amount: U256, + buy_amount_after_fee: U256, + }, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum SellAmount { + BeforeFee { + #[serde(rename = "sell_amount_before_fee")] + value: U256, + }, + AfterFee { + #[serde(rename = "sell_amount_after_fee")] + value: U256, }, } From c038b00cac55afc8e14fe942e274cdc94fe4c36f Mon Sep 17 00:00:00 2001 From: Nicholas Rodrigues Lordello Date: Wed, 22 Sep 2021 15:41:38 +0200 Subject: [PATCH 3/4] Comments --- orderbook/openapi.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orderbook/openapi.yml b/orderbook/openapi.yml index ee5ea87af..c80b79971 100644 --- a/orderbook/openapi.yml +++ b/orderbook/openapi.yml @@ -852,7 +852,7 @@ components: - kind - buyAmountAfterFee OrderQuoteRequest: - description: Request for generating a fee and price quote. + description: Request fee and price quote. allOf: - $ref: "#/components/schemas/OrderQuoteSide" - type: object From 91baeac79016442216c9d789293a96cdb348a9fb Mon Sep 17 00:00:00 2001 From: Nicholas Rodrigues Lordello Date: Fri, 24 Sep 2021 17:16:05 +0200 Subject: [PATCH 4/4] cleanup and add serialization unit tests --- orderbook/openapi.yml | 4 +- orderbook/src/api/post_quote.rs | 118 +++++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/orderbook/openapi.yml b/orderbook/openapi.yml index c80b79971..3dc2f441a 100644 --- a/orderbook/openapi.yml +++ b/orderbook/openapi.yml @@ -448,9 +448,9 @@ paths: 403: description: Forbidden, your account is deny-listed 429: - description: Too many order placements + description: Too many order quotes 500: - description: Error quoting an order + description: Unexpected error quoting an order components: schemas: TransactionHash: diff --git a/orderbook/src/api/post_quote.rs b/orderbook/src/api/post_quote.rs index 844083d45..8b8841def 100644 --- a/orderbook/src/api/post_quote.rs +++ b/orderbook/src/api/post_quote.rs @@ -11,7 +11,7 @@ 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)] +#[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] struct OrderQuoteRequest { from: H160, @@ -30,8 +30,8 @@ struct OrderQuoteRequest { buy_token_balance: BuyTokenDestination, } -#[derive(Debug, Deserialize)] -#[serde(tag = "kind")] +#[derive(Debug, Deserialize, PartialEq)] +#[serde(tag = "kind", rename_all = "lowercase")] enum OrderQuoteSide { #[serde(rename_all = "camelCase")] Sell { @@ -45,15 +45,15 @@ enum OrderQuoteSide { }, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Deserialize, PartialEq)] #[serde(untagged)] enum SellAmount { BeforeFee { - #[serde(rename = "sell_amount_before_fee")] + #[serde(rename = "sellAmountBeforeFee", with = "u256_decimal")] value: U256, }, AfterFee { - #[serde(rename = "sell_amount_after_fee")] + #[serde(rename = "sellAmountAfterFee", with = "u256_decimal")] value: U256, }, } @@ -82,7 +82,7 @@ struct OrderQuote { } fn post_quote_request() -> impl Filter + Clone { - warp::path!("feeAndQuote" / "sell") + warp::path!("quote") .and(warp::post()) .and(api::extract_payload()) } @@ -91,7 +91,7 @@ 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("NotYetImplemented", err.to_string()), + super::error("InternalServerError", err.to_string()), StatusCode::INTERNAL_SERVER_ERROR, ), } @@ -103,3 +103,105 @@ pub fn post_quote() -> impl Filter + 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, + } + ); + } +}