diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index 71e9f4f1..d42ff925 100644 --- a/src/exchange/actions.rs +++ b/src/exchange/actions.rs @@ -11,6 +11,24 @@ use crate::{ exchange::{cancel::CancelRequest, modify::ModifyRequest, order::OrderRequest}, }; +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum OrderGrouping { + Na, + NormalTpsl, + PositionTpsl, +} + +impl OrderGrouping { + pub const fn as_str(self) -> &'static str { + match self { + Self::Na => "na", + Self::NormalTpsl => "normalTpsl", + Self::PositionTpsl => "positionTpsl", + } + } +} + fn eip_712_domain(chain_id: u64) -> Eip712Domain { eip712_domain! { name: "HyperliquidSignTransaction", diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b8..0e345148 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -26,8 +26,8 @@ use crate::{ prelude::*, req::HttpClient, signature::{sign_l1_action, sign_typed_data}, - BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, SpotSend, SpotUser, - VaultTransfer, Withdraw3, + BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, OrderGrouping, + SpotSend, SpotUser, VaultTransfer, Withdraw3, }; #[derive(Debug)] @@ -484,6 +484,16 @@ impl ExchangeClient { &self, orders: Vec, wallet: Option<&PrivateKeySigner>, + ) -> Result { + self.bulk_order_with_grouping(orders, wallet, OrderGrouping::Na) + .await + } + + pub async fn bulk_order_with_grouping( + &self, + orders: Vec, + wallet: Option<&PrivateKeySigner>, + grouping: OrderGrouping, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); let timestamp = next_nonce(); @@ -496,7 +506,7 @@ impl ExchangeClient { let action = Actions::Order(BulkOrder { orders: transformed_orders, - grouping: "na".to_string(), + grouping: grouping.as_str().to_string(), builder: None, }); let connection_id = action.hash(timestamp, self.vault_address)?; @@ -508,10 +518,21 @@ impl ExchangeClient { } pub async fn bulk_order_with_builder( + &self, + orders: Vec, + wallet: Option<&PrivateKeySigner>, + builder: BuilderInfo, + ) -> Result { + self.bulk_order_with_builder_and_grouping(orders, wallet, builder, OrderGrouping::Na) + .await + } + + pub async fn bulk_order_with_builder_and_grouping( &self, orders: Vec, wallet: Option<&PrivateKeySigner>, mut builder: BuilderInfo, + grouping: OrderGrouping, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); let timestamp = next_nonce(); @@ -526,7 +547,7 @@ impl ExchangeClient { let action = Actions::Order(BulkOrder { orders: transformed_orders, - grouping: "na".to_string(), + grouping: grouping.as_str().to_string(), builder: Some(builder), }); let connection_id = action.hash(timestamp, self.vault_address)?; @@ -930,6 +951,84 @@ mod tests { Ok(()) } + /// Builds the same order as `test_limit_order_action_hashing`, but lets the + /// caller supply the grouping field. + fn limit_order_action(grouping: String) -> Actions { + Actions::Order(BulkOrder { + orders: vec![OrderRequest { + asset: 1, + is_buy: true, + limit_px: "2000.0".to_string(), + sz: "3.5".to_string(), + reduce_only: false, + order_type: Order::Limit(Limit { + tif: "Ioc".to_string(), + }), + cloid: None, + }], + grouping, + builder: None, + }) + } + + /// `OrderGrouping` must put the same bytes on the wire as the grouping + /// strings that the SDK used before. The action hash is the important one: + /// the L1 signature commits to it, so a difference breaks every order. + #[test] + fn test_typed_grouping_serializes_like_the_string_it_replaces() -> Result<()> { + for (grouping, previous_string) in [ + (OrderGrouping::Na, "na"), + (OrderGrouping::NormalTpsl, "normalTpsl"), + (OrderGrouping::PositionTpsl, "positionTpsl"), + ] { + assert_eq!(grouping.as_str(), previous_string); + + let typed_action = limit_order_action(grouping.as_str().to_string()); + let string_action = limit_order_action(previous_string.to_string()); + + assert_eq!( + typed_action.hash(1583838, None)?, + string_action.hash(1583838, None)?, + "action hash changed for grouping {previous_string}" + ); + + let typed_json = + serde_json::to_value(&typed_action).map_err(|e| Error::JsonParse(e.to_string()))?; + let string_json = serde_json::to_value(&string_action) + .map_err(|e| Error::JsonParse(e.to_string()))?; + assert_eq!( + typed_json, string_json, + "request body changed for grouping {previous_string}" + ); + + // The derived Serialize is part of the public API. Keep it in step + // with `as_str`, so the two cannot drift apart. + let enum_json = + serde_json::to_string(&grouping).map_err(|e| Error::JsonParse(e.to_string()))?; + assert_eq!(enum_json, format!("\"{previous_string}\"")); + } + + Ok(()) + } + + /// The default path stays byte for byte what it was. These two signatures + /// are the ones that `test_limit_order_action_hashing` pinned before this + /// change, and `OrderGrouping::Na` must still produce them. + #[test] + fn test_default_grouping_keeps_the_known_signatures() -> Result<()> { + let wallet = get_wallet()?; + let connection_id = + limit_order_action(OrderGrouping::Na.as_str().to_string()).hash(1583838, None)?; + + let signature = sign_l1_action(&wallet, connection_id, true)?; + assert_eq!(signature.to_string(), "0x77957e58e70f43b6b68581f2dc42011fc384538a2e5b7bf42d5b936f19fbb67360721a8598727230f67080efee48c812a6a4442013fd3b0eed509171bef9f23f1c"); + + let signature = sign_l1_action(&wallet, connection_id, false)?; + assert_eq!(signature.to_string(), "0xcd0925372ff1ed499e54883e9a6205ecfadec748f80ec463fe2f84f1209648776377961965cb7b12414186b1ea291e95fd512722427efcbcfb3b0b2bcd4d79d01c"); + + Ok(()) + } + #[test] fn test_limit_order_action_hashing_with_cloid() -> Result<()> { let cloid = uuid::Uuid::from_str("1e60610f-0b3d-4205-97c8-8c1fed2ad5ee")