diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f02593f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +target/ +.git/ +.github/ +README.md \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index ea27cf2..86e7265 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1362,7 +1362,7 @@ dependencies = [ [[package]] name = "drift" version = "2.48.0" -source = "git+https://github.com/circuit-research/protocol-v2?branch=cargo-add-sdk#210b760830dc0f609687bd25c518824d16b8bf41" +source = "git+https://github.com/circuit-research/protocol-v2?branch=cargo-add-sdk#36545c2302af4ce16684f390ab78f4fa4402e137" dependencies = [ "anchor-lang", "anchor-spl", @@ -1402,7 +1402,7 @@ dependencies = [ [[package]] name = "drift-sdk" version = "0.1.0" -source = "git+https://github.com/circuit-research/protocol-v2?branch=cargo-add-sdk#210b760830dc0f609687bd25c518824d16b8bf41" +source = "git+https://github.com/circuit-research/protocol-v2?branch=cargo-add-sdk#36545c2302af4ce16684f390ab78f4fa4402e137" dependencies = [ "anchor-lang", "drift", diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5771b8a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM rust:1.73.0 as builder +WORKDIR /build +COPY . . +RUN cargo build --release + +FROM gcr.io/distroless/base-debian12 +COPY --from=builder /lib/x86_64-linux-gnu/libgcc_s.so.1 /lib/x86_64-linux-gnu/libgcc_s.so.1 +COPY --from=builder /build/target/release/drift-gateway /bin/drift-gateway +ENTRYPOINT ["/bin/drift-gateway"] \ No newline at end of file diff --git a/README.md b/README.md index 32ee596..d35d372 100644 --- a/README.md +++ b/README.md @@ -41,59 +41,69 @@ Options: --help display usage information ``` -## Examples +## API Examples ### Get Market Info ```bash -$> curl localhost:8080/v2/markets +$ curl localhost:8080/v2/markets ``` ### Get Orderbook +gets a full snapshot of the current orderbook ```bash -$> curl localhost:8080/v2/orderbook -X GET -H 'content-type: application/json' -d '{"market":{"id":0,"type":"perp"}}' +$ curl localhost:8080/v2/orderbook -X GET -H 'content-type: application/json' -d '{"marketIndex":0,"marketType":"perp"}' ``` -to stream orderbooks via websocket DLOB servers are available at: -devnet: `wss://master.dlob.drift.trade/ws` -mainnet: `wss://dlob.drift.trade/ws` + +to stream orderbooks via websocket public DLOB servers are available at: +- devnet: `wss://master.dlob.drift.trade/ws` +- mainnet: `wss://dlob.drift.trade/ws` see https://github.com/drift-labs/dlob-server/blob/master/example/wsClient.ts for usage example ### Get Orders get all orders ```bash -$> curl localhost:8080/v2/orders +$ curl localhost:8080/v2/orders ``` get orders by market ```bash -$> curl localhost:8080/v2/orders -X GET -H 'content-type: application/json' -d '{"market":{"id":0,"type":"perp"}}; +$ curl localhost:8080/v2/orders -X GET -H 'content-type: application/json' -d '{"marketIndex":1,"marketType":"spot"}' ``` ### Get Positions get all positions ```bash -$> curl localhost:8080/v2/positions +$ curl localhost:8080/v2/positions ``` get positions by market ```bash -$> curl localhost:8080/v2/positions -X GET -H 'content-type: application/json' -d '{"market":{"id":0,"type":"perp"}}; +$ curl localhost:8080/v2/positions -X GET -H 'content-type: application/json' -d '{"marketIndex":0,"marketType":"perp"}' ``` ### Place Orders + +- use sub-zero `amount` to indicate sell/offer order +- `userOrderId` is a uint in the range 1 <= x <= 255 which can be assigned by the client to help distinguish orders +- `orderType` only "limit" and "market" options are fully supported by the gateway + ```bash -$> curl localhost:8080/v2/orders -X POST -H 'content-type: application/json' -d '{ - "orders": [{ - "marketId": 1, +$ curl localhost:8080/v2/orders -X POST -H 'content-type: application/json' -d '{ + "orders": [ + { + "marketIndex": 1, "marketType": "spot", - "amount": 1.23, - "price": 40.55, + "amount": -1.23, + "price": 80.0, "postOnly": true, "orderType": "limit", "userOrderId": 101 + "immediateOrCancel": false, + "reduce_only": false, }, { - "marketId": 0, + "marketIndex": 0, "marketType": "perp", - "amount": -1.05, - "price": 80, + "amount": 1.23, + "price": 60.0, "postOnly": true, "orderType": "limit", "userOrderId": 102 @@ -102,38 +112,45 @@ $> curl localhost:8080/v2/orders -X POST -H 'content-type: application/json' -d ``` ### Modify Orders -like place orders but specify either `orderId` or `userOrderId` to indicate which order to modify +like place orders but caller must specify either `orderId` or `userOrderId` to indicate which order to modify. + +- `amount` can be modified to flip the order from long/short to bid/ask +- the order market cannot be modified. ```bash -$> curl localhost:8080/v2/orders -X PATCH -H 'content-type: application/json' -d '{ +$ curl localhost:8080/v2/orders -X PATCH -H 'content-type: application/json' -d '{ "orders": [{ - "marketId": 1, - "marketType": "spot", - "amount": 1.23, - "price": 40.55, - "postOnly": true, - "orderType": "limit", - "userOrderId": 5 + "amount": -1.1, + "price": 80.5, + "userOrderId": 101 }, { - "orderId": 555, - "marketId": 0, - "marketType": "perp", - "amount": -1.05, - "price": 80, - "postOnly": true, - "orderType": "limit" + "amount": 1.05, + "price": 61.0, + "orderId": 32 }] }' ``` -### Cancelling Orders +### Cancel Orders ```bash -# cancel by market id -$> curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"market":{"id":1,"type":"perp"}}' +# cancel all by market id +$ curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"marketIndex":1,"marketType":"spot"}}' # cancel by order ids -$> curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"ids":[1,2,3,4]}' +$ curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"ids":[1,2,3,4]}' # cancel by user assigned order ids -$> curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"userIds":[1,2,3,4]}' +$ curl localhost:8080/v2/orders -X DELETE -H 'content-type: application/json' -d '{"userIds":[1,2,3,4]}' # cancel all orders -$> curl localhost:8080/v2/orders -X DELETE -``` \ No newline at end of file +$ curl localhost:8080/v2/orders -X DELETE +``` + +### Errors +error responses have the following JSON structure: +```json +{ + "code": "", + "reason": "" +} +``` + +Some endpoints send transactions to the drift program and can return program error codes. +The full list of drift program error codes is available in the [API docs](https://drift-labs.github.io/v2-teacher/#errors) \ No newline at end of file diff --git a/src/controller.rs b/src/controller.rs index a124d42..6879730 100644 --- a/src/controller.rs +++ b/src/controller.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use drift_sdk::{ dlob::{DLOBClient, L2Orderbook}, - types::{Context, MarketType, OrderParams, SdkError, SdkResult}, + types::{Context, MarketType, ModifyOrderParams, SdkError, SdkResult}, DriftClient, Pubkey, TransactionBuilder, Wallet, WsAccountProvider, }; use futures_util::{stream::FuturesUnordered, StreamExt}; @@ -67,6 +67,7 @@ impl AppState { } else { "https://dlob.drift.trade" }; + Self { wallet, client: Arc::new(client), @@ -86,7 +87,7 @@ impl AppState { let builder = TransactionBuilder::new(&self.wallet, &user_data); let tx = if let Some(market) = req.market { - builder.cancel_orders((market.id, market.market_type), None) + builder.cancel_orders((market.market_index, market.market_type), None) } else if !req.user_ids.is_empty() { let order_ids = user_data .orders @@ -112,7 +113,7 @@ impl AppState { /// Return orders by position if given, otherwise return all positions pub async fn get_positions( &self, - req: GetPositionsRequest, + req: Option, ) -> GatewayResult { let (all_spot, all_perp) = self.client.all_positions(self.user()).await?; @@ -122,8 +123,9 @@ impl AppState { all_spot .iter() .filter(|p| { - if let Some(ref market) = req.market { - p.market_index == market.id && MarketType::Spot == market.market_type + if let Some(GetPositionsRequest { ref market }) = req { + p.market_index == market.market_index + && MarketType::Spot == market.market_type } else { true } @@ -142,8 +144,9 @@ impl AppState { perp: all_perp .iter() .filter(|p| { - if let Some(ref market) = req.market { - p.market_index == market.id && MarketType::Perp == market.market_type + if let Some(GetPositionsRequest { ref market }) = req { + p.market_index == market.market_index + && MarketType::Perp == market.market_type } else { true } @@ -154,14 +157,17 @@ impl AppState { } /// Return orders by market if given, otherwise return all orders - pub async fn get_orders(&self, req: GetOrdersRequest) -> GatewayResult { + pub async fn get_orders( + &self, + req: Option, + ) -> GatewayResult { let orders = self.client.all_orders(self.user()).await?; Ok(GetOrdersResponse { orders: orders .into_iter() .filter(|o| { - if let Some(ref market) = req.market { - o.market_index == market.id && o.market_type == market.market_type + if let Some(GetOrdersRequest { ref market }) = req { + o.market_index == market.market_index && o.market_type == market.market_type } else { true } @@ -203,25 +209,43 @@ impl AppState { pub async fn modify_orders(&self, req: ModifyOrdersRequest) -> GatewayResult { let user_data = &self.client.get_user_account(self.user()).await?; - - let mut params = Vec::<(u32, OrderParams)>::with_capacity(req.orders.len()); + // NB: its possible to let the drift program sort the modifications by userOrderId + // sorting it client side for simplicity + let mut params = Vec::<(u32, ModifyOrderParams)>::with_capacity(req.orders.len()); for order in req.orders { - if let Some(id) = order.order_id { - params.push((id, order.to_order_params(self.context()))); - } else if order.user_order_id > 0 { + if let Some(order_id) = order.order_id { + if let Some(onchain_order) = + user_data.orders.iter().find(|x| x.order_id == order_id) + { + params.push(( + order_id, + order.to_order_params( + onchain_order.market_index, + onchain_order.market_type, + self.context(), + ), + )); + } + } else if let Some(user_order_id) = order.user_order_id { if let Some(onchain_order) = user_data .orders .iter() - .find(|x| x.user_order_id == order.user_order_id) + .find(|x| x.user_order_id == user_order_id) { params.push(( onchain_order.order_id, - order.to_order_params(self.context()), + order.to_order_params( + onchain_order.market_index, + onchain_order.market_type, + self.context(), + ), )); } } else { return Err(ControllerError::UnknownOrderId( - order.order_id.unwrap_or(order.user_order_id as u32), + order + .order_id + .unwrap_or(order.user_order_id.unwrap_or(0) as u32), )); } } diff --git a/src/main.rs b/src/main.rs index 0add78e..2f2dfbf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,10 +8,7 @@ use log::{error, info}; use controller::{AppState, ControllerError}; use serde_json::json; -use types::{ - CancelOrdersRequest, GetOrderbookRequest, GetOrdersRequest, GetPositionsRequest, - ModifyOrdersRequest, PlaceOrdersRequest, -}; +use types::{CancelOrdersRequest, GetOrderbookRequest, ModifyOrdersRequest, PlaceOrdersRequest}; mod controller; mod types; @@ -23,22 +20,12 @@ async fn get_markets(controller: web::Data) -> impl Responder { } #[get("/orders")] -async fn get_orders( - controller: web::Data, - body: actix_web::web::Bytes, -) -> impl Responder { - let mut req = GetOrdersRequest::default(); +async fn get_orders(controller: web::Data, body: web::Bytes) -> impl Responder { + let mut req = None; if !body.is_empty() { match serde_json::from_slice(body.as_ref()) { - Ok(deser) => req = deser, - Err(err) => { - return Either::Left(HttpResponse::BadRequest().json(json!( - { - "code": 400, - "reason": err.to_string(), - } - ))) - } + Ok(deser) => req = Some(deser), + Err(err) => return handle_deser_error(err), } }; @@ -46,61 +33,42 @@ async fn get_orders( } #[post("/orders")] -async fn create_orders( - controller: web::Data, - req: Json, -) -> impl Responder { - handle_result(controller.place_orders(req.0).await) +async fn create_orders(controller: web::Data, body: web::Bytes) -> impl Responder { + match serde_json::from_slice::<'_, PlaceOrdersRequest>(body.as_ref()) { + Ok(req) => handle_result(controller.place_orders(req).await), + Err(err) => handle_deser_error(err), + } } #[patch("/orders")] -async fn modify_orders( - controller: web::Data, - req: Json, -) -> impl Responder { - handle_result(controller.modify_orders(req.0).await) +async fn modify_orders(controller: web::Data, body: web::Bytes) -> impl Responder { + match serde_json::from_slice::<'_, ModifyOrdersRequest>(body.as_ref()) { + Ok(req) => handle_result(controller.modify_orders(req).await), + Err(err) => handle_deser_error(err), + } } #[delete("/orders")] -async fn cancel_orders( - controller: web::Data, - body: actix_web::web::Bytes, -) -> impl Responder { +async fn cancel_orders(controller: web::Data, body: web::Bytes) -> impl Responder { let mut req = CancelOrdersRequest::default(); + // handle the body manually to allow empty payload `Json` requires some body is set if !body.is_empty() { match serde_json::from_slice(body.as_ref()) { Ok(deser) => req = deser, - Err(err) => { - return Either::Left(HttpResponse::BadRequest().json(json!( - { - "code": 400, - "reason": err.to_string(), - } - ))) - } + Err(err) => return handle_deser_error(err), } }; handle_result(controller.cancel_orders(req).await) } #[get("/positions")] -async fn get_positions( - controller: web::Data, - body: actix_web::web::Bytes, -) -> impl Responder { - let mut req = GetPositionsRequest::default(); +async fn get_positions(controller: web::Data, body: web::Bytes) -> impl Responder { + let mut req = None; // handle the body manually to allow empty payload `Json` requires some body is set if !body.is_empty() { match serde_json::from_slice(body.as_ref()) { - Ok(deser) => req = deser, - Err(err) => { - return Either::Left(HttpResponse::BadRequest().json(json!( - { - "code": 400, - "reason": err.to_string(), - } - ))) - } + Ok(deser) => req = Some(deser), + Err(err) => return handle_deser_error(err), } }; @@ -108,12 +76,11 @@ async fn get_positions( } #[get("/orderbook")] -async fn get_orderbook( - controller: web::Data, - req: Json, -) -> impl Responder { - let book = controller.get_orderbook(req.0).await; - handle_result(book) +async fn get_orderbook(controller: web::Data, body: web::Bytes) -> impl Responder { + match serde_json::from_slice::<'_, GetOrderbookRequest>(body.as_ref()) { + Ok(req) => handle_result(controller.get_orderbook(req).await), + Err(err) => handle_deser_error(err), + } } #[actix_web::main] @@ -199,3 +166,12 @@ fn handle_result(result: Result) -> Either(err: serde_json::Error) -> Either> { + Either::Left(HttpResponse::BadRequest().json(json!( + { + "code": 400, + "reason": err.to_string(), + } + ))) +} diff --git a/src/types.rs b/src/types.rs index c4824e8..6a69fcb 100644 --- a/src/types.rs +++ b/src/types.rs @@ -8,7 +8,8 @@ use drift_sdk::{ PRICE_PRECISION, }, types::{ - self as sdk_types, Context, MarketType, OrderParams, PositionDirection, PostOnlyParam, + self as sdk_types, Context, MarketType, ModifyOrderParams, OrderParams, PositionDirection, + PostOnlyParam, }, }; use rust_decimal::Decimal; @@ -44,11 +45,15 @@ impl Order { config.precision_exp as u32 }; + // 0 = long + // 1 = short + let to_sign = 1_i64 - 2 * (value.direction as i64); + Order { market_id: value.market_index, market_type: value.market_type, price: Decimal::new(value.price as i64, PRICE_PRECISION.ilog10()), - amount: Decimal::new(value.base_asset_amount as i64, precision), + amount: Decimal::new(value.base_asset_amount as i64 * to_sign, precision), filled: Decimal::new(value.base_asset_amount_filled as i64, precision), immediate_or_cancel: value.immediate_or_cancel, reduce_only: value.reduce_only, @@ -137,30 +142,87 @@ impl From for PerpPosition { } } -pub type ModifyOrdersRequest = PlaceOrdersRequest; +#[derive(Serialize, Deserialize, Debug)] +pub struct ModifyOrdersRequest { + pub orders: Vec, +} + +#[cfg_attr(test, derive(Default))] +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct ModifyOrder { + amount: Option, + price: Option, + pub user_order_id: Option, + pub order_id: Option, + pub reduce_only: Option, +} + +impl ModifyOrder { + pub fn to_order_params( + self, + market_index: u16, + market_type: MarketType, + context: Context, + ) -> ModifyOrderParams { + let target_scale = if let MarketType::Perp = market_type { + BASE_PRECISION as u32 + } else { + let config = spot_market_config_by_index(context, market_index).expect("market exists"); + config.precision as u32 + }; + + let (amount, direction) = if let Some(base_amount) = self.amount { + let direction = if base_amount.is_sign_negative() { + PositionDirection::Short + } else { + PositionDirection::Long + }; + ( + Some(scale_decimal_to_u64(base_amount.abs(), target_scale)), + Some(direction), + ) + } else { + (None, None) + }; + + let price = if let Some(price) = self.price { + Some(scale_decimal_to_u64(price, PRICE_PRECISION as u32)) + } else { + None + }; + + ModifyOrderParams { + base_asset_amount: amount, + direction, + price, + reduce_only: self.reduce_only, + ..Default::default() + } + } +} #[derive(Serialize, Deserialize, Debug)] pub struct PlaceOrdersRequest { pub orders: Vec, } -#[derive(Serialize, Deserialize, Default, Debug)] +#[cfg_attr(test, derive(Default))] +#[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "camelCase")] pub struct PlaceOrder { - market_id: u16, - #[serde( - serialize_with = "market_type_ser", - deserialize_with = "market_type_de" - )] - market_type: sdk_types::MarketType, + #[serde(flatten)] + market: Market, amount: Decimal, price: Decimal, /// 0 indicates it is not set (according to program) #[serde(default)] pub user_order_id: u8, - /// only used for modify orders - pub order_id: Option, - #[serde(serialize_with = "order_type_ser", deserialize_with = "order_type_de")] + #[serde( + serialize_with = "order_type_ser", + deserialize_with = "order_type_de", + default + )] order_type: sdk_types::OrderType, #[serde(default)] post_only: bool, @@ -201,19 +263,19 @@ fn scale_decimal_to_u64(x: Decimal, target: u32) -> u64 { impl PlaceOrder { pub fn to_order_params(self, context: Context) -> OrderParams { - let target_scale = if let MarketType::Perp = self.market_type { + let target_scale = if let MarketType::Perp = self.market.market_type { BASE_PRECISION as u32 } else { - let config = - spot_market_config_by_index(context, self.market_id).expect("market exists"); + let config = spot_market_config_by_index(context, self.market.market_index) + .expect("market exists"); config.precision as u32 }; - let base_amount = scale_decimal_to_u64(self.amount, target_scale); + let base_amount = scale_decimal_to_u64(self.amount.abs(), target_scale); let price = scale_decimal_to_u64(self.price, PRICE_PRECISION as u32); OrderParams { - market_index: self.market_id, - market_type: self.market_type, + market_index: self.market.market_index, + market_type: self.market.market_type, order_type: self.order_type, base_asset_amount: base_amount, direction: if self.amount.is_sign_negative() { @@ -235,12 +297,13 @@ impl PlaceOrder { } } -#[derive(Serialize, Deserialize)] +#[cfg_attr(test, derive(Default))] +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "camelCase")] pub struct Market { /// The market index - pub id: u16, + pub market_index: u16, #[serde( - rename = "type", serialize_with = "market_type_ser", deserialize_with = "market_type_de" )] @@ -249,23 +312,35 @@ pub struct Market { } impl Market { + pub fn spot(index: u16) -> Self { + Self { + market_index: index, + market_type: MarketType::Spot, + } + } + pub fn perp(index: u16) -> Self { + Self { + market_index: index, + market_type: MarketType::Perp, + } + } pub fn as_market_id(self) -> drift_sdk::types::MarketId { unsafe { std::mem::transmute(self) } } } -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetPositionsRequest { - #[serde(default)] - pub market: Option, + #[serde(flatten)] + pub market: Market, } -#[derive(Serialize, Deserialize, Default)] +#[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetOrdersRequest { - #[serde(default)] - pub market: Option, + #[serde(flatten)] + pub market: Market, } #[derive(Serialize, Deserialize)] @@ -316,7 +391,7 @@ pub struct AllMarketsResponse { #[serde(rename_all = "camelCase")] pub struct CancelOrdersRequest { /// Market to cancel orders - #[serde(default)] + #[serde(flatten, default)] pub market: Option, /// order Ids to cancel #[serde(default)] @@ -329,6 +404,7 @@ pub struct CancelOrdersRequest { #[derive(Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetOrderbookRequest { + #[serde(flatten)] pub market: Market, } @@ -337,7 +413,7 @@ mod tests { use drift_sdk::types::{Context, MarketType}; use std::str::FromStr; - use crate::types::Order; + use crate::types::{Market, Order}; use super::{Decimal, PlaceOrder}; @@ -355,8 +431,7 @@ mod tests { let p = PlaceOrder { amount: Decimal::from_str(input).unwrap(), price: Decimal::from_str(input).unwrap(), - market_id: 0, - market_type: MarketType::Perp, + market: Market::perp(0), ..Default::default() }; let order_params = p.to_order_params(Context::DevNet); @@ -380,8 +455,7 @@ mod tests { let p = PlaceOrder { amount: Decimal::from_str(input).unwrap(), price: Decimal::from_str(input).unwrap(), - market_id: market_index, - market_type: MarketType::Spot, + market: Market::spot(market_index), ..Default::default() }; let order_params = p.to_order_params(Context::MainNet); @@ -401,7 +475,7 @@ mod tests { base_asset_amount: input, price: input, market_index, - market_type: MarketType::Spot, + market_type: MarketType::Perp, ..Default::default() }; let gateway_order = Order::from_sdk_order(o, Context::MainNet);