From ba99665d24bb1f94ac2ecbcd1c845a8974be58e0 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 28 Jul 2026 13:54:02 -0600 Subject: [PATCH 1/5] feat(exchange): add typed order grouping --- src/exchange/actions.rs | 30 ++++++++++++++++++++++++++++++ src/exchange/exchange_client.rs | 29 +++++++++++++++++++++++++---- 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index 71e9f4f1..e0d22194 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", @@ -291,3 +309,15 @@ impl Eip712 for ApproveBuilderFee { keccak256(items.abi_encode()) } } + +#[cfg(test)] +mod tests { + use super::OrderGrouping; + + #[test] + fn order_grouping_uses_hyperliquid_wire_values() { + assert_eq!(OrderGrouping::Na.as_str(), "na"); + assert_eq!(OrderGrouping::NormalTpsl.as_str(), "normalTpsl"); + assert_eq!(OrderGrouping::PositionTpsl.as_str(), "positionTpsl"); + } +} diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 70b686b8..3b7c7050 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)?; From fea9dccfa0cd96f79b48b142085a9cc051fffb41 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 28 Jul 2026 13:58:32 -0600 Subject: [PATCH 2/5] feat(exchange): expose raw asset operations --- src/exchange/exchange_client.rs | 52 ++++++++++++++++++++++-------- src/exchange/exchange_responses.rs | 14 ++++---- src/exchange/mod.rs | 4 +-- 3 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 3b7c7050..27e50571 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -27,7 +27,7 @@ use crate::{ req::HttpClient, signature::{sign_l1_action, sign_typed_data}, BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, OrderGrouping, - SpotSend, SpotUser, VaultTransfer, Withdraw3, + OrderRequest, SpotSend, SpotUser, VaultTransfer, Withdraw3, }; #[derive(Debug)] @@ -495,17 +495,27 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, grouping: OrderGrouping, ) -> Result { - let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - let mut transformed_orders = Vec::new(); for order in orders { transformed_orders.push(order.convert(&self.coin_to_asset)?); } + self.bulk_order_raw_with_grouping(transformed_orders, wallet, grouping) + .await + } + + pub async fn bulk_order_raw_with_grouping( + &self, + orders: Vec, + wallet: Option<&PrivateKeySigner>, + grouping: OrderGrouping, + ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let timestamp = next_nonce(); + let action = Actions::Order(BulkOrder { - orders: transformed_orders, + orders, grouping: grouping.as_str().to_string(), builder: None, }); @@ -571,9 +581,6 @@ impl ExchangeClient { cancels: Vec, wallet: Option<&PrivateKeySigner>, ) -> Result { - let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - let mut transformed_cancels = Vec::new(); for cancel in cancels.into_iter() { let &asset = self @@ -586,9 +593,18 @@ impl ExchangeClient { }); } - let action = Actions::Cancel(BulkCancel { - cancels: transformed_cancels, - }); + self.bulk_cancel_raw(transformed_cancels, wallet).await + } + + pub async fn bulk_cancel_raw( + &self, + cancels: Vec, + wallet: Option<&PrivateKeySigner>, + ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let timestamp = next_nonce(); + + let action = Actions::Cancel(BulkCancel { cancels }); let connection_id = action.hash(timestamp, self.vault_address)?; let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; @@ -681,11 +697,21 @@ impl ExchangeClient { is_cross: bool, wallet: Option<&PrivateKeySigner>, ) -> Result { - let wallet = wallet.unwrap_or(&self.wallet); + let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; + self.update_leverage_by_asset_index(leverage, asset_index, is_cross, wallet) + .await + } + pub async fn update_leverage_by_asset_index( + &self, + leverage: u32, + asset_index: u32, + is_cross: bool, + wallet: Option<&PrivateKeySigner>, + ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); let timestamp = next_nonce(); - let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; let action = Actions::UpdateLeverage(UpdateLeverage { asset: asset_index, is_cross, diff --git a/src/exchange/exchange_responses.rs b/src/exchange/exchange_responses.rs index 63d3de5c..b264b5eb 100644 --- a/src/exchange/exchange_responses.rs +++ b/src/exchange/exchange_responses.rs @@ -1,11 +1,11 @@ -use serde::Deserialize; +use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct RestingOrder { pub oid: u64, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FilledOrder { pub total_sz: String, @@ -13,7 +13,7 @@ pub struct FilledOrder { pub oid: u64, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum ExchangeDataStatus { Success, @@ -24,19 +24,19 @@ pub enum ExchangeDataStatus { Filled(FilledOrder), } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct ExchangeDataStatuses { pub statuses: Vec, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct ExchangeResponse { #[serde(rename = "type")] pub response_type: String, pub data: Option, } -#[derive(Deserialize, Debug, Clone)] +#[derive(Deserialize, Serialize, Debug, Clone)] #[serde(rename_all = "camelCase")] #[serde(tag = "status", content = "response")] pub enum ExchangeResponseStatus { diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index 7b9e9042..56ef9c98 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -13,6 +13,6 @@ pub use exchange_client::*; pub use exchange_responses::*; pub use modify::{ClientModifyRequest, ModifyRequest}; pub use order::{ - ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, MarketCloseParams, - MarketOrderParams, Order, + ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, Limit, MarketCloseParams, + MarketOrderParams, Order, OrderRequest, Trigger, }; From 9dc8b253ebae65d8e6a07b074ea6806d7cf91189 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Tue, 28 Jul 2026 14:08:29 -0600 Subject: [PATCH 3/5] fix(exchange): export raw cancel request --- src/exchange/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index 56ef9c98..d19d87ee 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -8,7 +8,7 @@ mod order; pub use actions::*; pub use builder::*; -pub use cancel::{ClientCancelRequest, ClientCancelRequestCloid}; +pub use cancel::{CancelRequest, ClientCancelRequest, ClientCancelRequestCloid}; pub use exchange_client::*; pub use exchange_responses::*; pub use modify::{ClientModifyRequest, ModifyRequest}; From 31c91b079ca3130757047a1b044934c6f8a20a04 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 28 Aug 2026 12:11:37 -0700 Subject: [PATCH 4/5] revert(exchange): drop raw asset operations from this PR The pull request title promises typed order grouping only. The raw asset-index order, cancel, and leverage methods and the response Serialize derives are a separate feature. They move to their own pull request. This keeps the change focused on one thing. This reverts commit fea9dccfa0cd96f79b48b142085a9cc051fffb41. This reverts commit 9dc8b253ebae65d8e6a07b074ea6806d7cf91189. --- src/exchange/exchange_client.rs | 52 ++++++++---------------------- src/exchange/exchange_responses.rs | 14 ++++---- src/exchange/mod.rs | 6 ++-- 3 files changed, 23 insertions(+), 49 deletions(-) diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 27e50571..3b7c7050 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -27,7 +27,7 @@ use crate::{ req::HttpClient, signature::{sign_l1_action, sign_typed_data}, BaseUrl, BulkCancelCloid, ClassTransfer, Error, ExchangeResponseStatus, OrderGrouping, - OrderRequest, SpotSend, SpotUser, VaultTransfer, Withdraw3, + SpotSend, SpotUser, VaultTransfer, Withdraw3, }; #[derive(Debug)] @@ -495,27 +495,17 @@ impl ExchangeClient { wallet: Option<&PrivateKeySigner>, grouping: OrderGrouping, ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let timestamp = next_nonce(); + let mut transformed_orders = Vec::new(); for order in orders { transformed_orders.push(order.convert(&self.coin_to_asset)?); } - self.bulk_order_raw_with_grouping(transformed_orders, wallet, grouping) - .await - } - - pub async fn bulk_order_raw_with_grouping( - &self, - orders: Vec, - wallet: Option<&PrivateKeySigner>, - grouping: OrderGrouping, - ) -> Result { - let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - let action = Actions::Order(BulkOrder { - orders, + orders: transformed_orders, grouping: grouping.as_str().to_string(), builder: None, }); @@ -581,6 +571,9 @@ impl ExchangeClient { cancels: Vec, wallet: Option<&PrivateKeySigner>, ) -> Result { + let wallet = wallet.unwrap_or(&self.wallet); + let timestamp = next_nonce(); + let mut transformed_cancels = Vec::new(); for cancel in cancels.into_iter() { let &asset = self @@ -593,18 +586,9 @@ impl ExchangeClient { }); } - self.bulk_cancel_raw(transformed_cancels, wallet).await - } - - pub async fn bulk_cancel_raw( - &self, - cancels: Vec, - wallet: Option<&PrivateKeySigner>, - ) -> Result { - let wallet = wallet.unwrap_or(&self.wallet); - let timestamp = next_nonce(); - - let action = Actions::Cancel(BulkCancel { cancels }); + let action = Actions::Cancel(BulkCancel { + cancels: transformed_cancels, + }); let connection_id = action.hash(timestamp, self.vault_address)?; let action = serde_json::to_value(&action).map_err(|e| Error::JsonParse(e.to_string()))?; @@ -696,22 +680,12 @@ impl ExchangeClient { coin: &str, is_cross: bool, wallet: Option<&PrivateKeySigner>, - ) -> Result { - let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; - self.update_leverage_by_asset_index(leverage, asset_index, is_cross, wallet) - .await - } - - pub async fn update_leverage_by_asset_index( - &self, - leverage: u32, - asset_index: u32, - is_cross: bool, - wallet: Option<&PrivateKeySigner>, ) -> Result { let wallet = wallet.unwrap_or(&self.wallet); + let timestamp = next_nonce(); + let &asset_index = self.coin_to_asset.get(coin).ok_or(Error::AssetNotFound)?; let action = Actions::UpdateLeverage(UpdateLeverage { asset: asset_index, is_cross, diff --git a/src/exchange/exchange_responses.rs b/src/exchange/exchange_responses.rs index b264b5eb..63d3de5c 100644 --- a/src/exchange/exchange_responses.rs +++ b/src/exchange/exchange_responses.rs @@ -1,11 +1,11 @@ -use serde::{Deserialize, Serialize}; +use serde::Deserialize; -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] pub struct RestingOrder { pub oid: u64, } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub struct FilledOrder { pub total_sz: String, @@ -13,7 +13,7 @@ pub struct FilledOrder { pub oid: u64, } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] pub enum ExchangeDataStatus { Success, @@ -24,19 +24,19 @@ pub enum ExchangeDataStatus { Filled(FilledOrder), } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] pub struct ExchangeDataStatuses { pub statuses: Vec, } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] pub struct ExchangeResponse { #[serde(rename = "type")] pub response_type: String, pub data: Option, } -#[derive(Deserialize, Serialize, Debug, Clone)] +#[derive(Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase")] #[serde(tag = "status", content = "response")] pub enum ExchangeResponseStatus { diff --git a/src/exchange/mod.rs b/src/exchange/mod.rs index d19d87ee..7b9e9042 100644 --- a/src/exchange/mod.rs +++ b/src/exchange/mod.rs @@ -8,11 +8,11 @@ mod order; pub use actions::*; pub use builder::*; -pub use cancel::{CancelRequest, ClientCancelRequest, ClientCancelRequestCloid}; +pub use cancel::{ClientCancelRequest, ClientCancelRequestCloid}; pub use exchange_client::*; pub use exchange_responses::*; pub use modify::{ClientModifyRequest, ModifyRequest}; pub use order::{ - ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, Limit, MarketCloseParams, - MarketOrderParams, Order, OrderRequest, Trigger, + ClientLimit, ClientOrder, ClientOrderRequest, ClientTrigger, MarketCloseParams, + MarketOrderParams, Order, }; From 08eea952fafab69e5ba2cf3690a80c10aa9b4697 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 28 Aug 2026 12:16:37 -0700 Subject: [PATCH 5/5] test(exchange): prove typed grouping matches the previous wire strings Add two tests that answer the compatibility question directly. The first builds one bulk order twice, once from OrderGrouping and once from the grouping string that the SDK used before. It asserts an equal action hash, an equal JSON body, and that the derived Serialize agrees with as_str. The second asserts that OrderGrouping::Na still produces the two signatures that test_limit_order_action_hashing already pinned. Both tests fail if any wire value changes. --- src/exchange/actions.rs | 12 ----- src/exchange/exchange_client.rs | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/exchange/actions.rs b/src/exchange/actions.rs index e0d22194..d42ff925 100644 --- a/src/exchange/actions.rs +++ b/src/exchange/actions.rs @@ -309,15 +309,3 @@ impl Eip712 for ApproveBuilderFee { keccak256(items.abi_encode()) } } - -#[cfg(test)] -mod tests { - use super::OrderGrouping; - - #[test] - fn order_grouping_uses_hyperliquid_wire_values() { - assert_eq!(OrderGrouping::Na.as_str(), "na"); - assert_eq!(OrderGrouping::NormalTpsl.as_str(), "normalTpsl"); - assert_eq!(OrderGrouping::PositionTpsl.as_str(), "positionTpsl"); - } -} diff --git a/src/exchange/exchange_client.rs b/src/exchange/exchange_client.rs index 3b7c7050..0e345148 100644 --- a/src/exchange/exchange_client.rs +++ b/src/exchange/exchange_client.rs @@ -951,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")