From 6d912c382f4a799bb9c6bddb28a738f7568ca437 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:17:43 +0900 Subject: [PATCH 1/5] feat(hypercore): add HIP-3 builder dex perps support - Fan out perpetual API calls across active builder dexes via `for_each_dex` helper - Add `PerpDex` model and `perpDexs` RPC endpoint - Map HIP-3 asset indices with offset encoding (100k + dex_index * 10k + meta_index) - Format display names as `SYMBOL (dex)` for builder dex assets - Add `only_isolated` to `Perpetual` and `margin_type` to `PerpetualConfirmData` - Use margin type from confirm data in signer instead of hardcoded cross margin - Merge positions, portfolios, and account summaries across dexes - Extract HIP-3 specific logic into `hip3_mapper` module - Extract `with_dex` helper to deduplicate RPC client dex injection - Add `UniverseAsset::mock()` and `AssetMetadata::mock()` to testkit - Make `ReferrerState.data` optional for `needToCreateCode` stage --- crates/gem_hypercore/src/models/mod.rs | 1 + crates/gem_hypercore/src/models/perp_dex.rs | 8 + .../src/provider/fee_calculator.rs | 15 +- .../gem_hypercore/src/provider/hip3_mapper.rs | 199 ++++++++++++++++++ crates/gem_hypercore/src/provider/mod.rs | 1 + .../gem_hypercore/src/provider/perpetual.rs | 161 +++++++++++++- .../src/provider/perpetual_mapper.rs | 97 +++++---- .../src/provider/transactions_mapper.rs | 11 +- .../src/provider/websocket_mapper.rs | 10 +- crates/gem_hypercore/src/rpc/client.rs | 48 +++-- .../gem_hypercore/src/signer/core_signer.rs | 8 +- crates/gem_hypercore/src/testkit.rs | 29 +++ crates/primitives/src/chain_nft.rs | 2 +- crates/primitives/src/perpetual.rs | 4 +- crates/primitives/src/testkit/mod.rs | 1 + .../primitives/src/testkit/perpetual_mock.rs | 3 +- .../primitives/src/testkit/portfolio_mock.rs | 25 +++ crates/storage/src/models/perpetual.rs | 1 + gemstone/src/models/perpetual.rs | 1 + gemstone/src/models/transaction.rs | 9 +- 20 files changed, 526 insertions(+), 108 deletions(-) create mode 100644 crates/gem_hypercore/src/models/perp_dex.rs create mode 100644 crates/gem_hypercore/src/provider/hip3_mapper.rs create mode 100644 crates/primitives/src/testkit/portfolio_mock.rs diff --git a/crates/gem_hypercore/src/models/mod.rs b/crates/gem_hypercore/src/models/mod.rs index 1eeb3ce3d9..a450ac98a5 100644 --- a/crates/gem_hypercore/src/models/mod.rs +++ b/crates/gem_hypercore/src/models/mod.rs @@ -3,6 +3,7 @@ pub mod balance; pub mod candlestick; pub mod metadata; pub mod order; +pub mod perp_dex; pub mod portfolio; pub mod position; pub mod referral; diff --git a/crates/gem_hypercore/src/models/perp_dex.rs b/crates/gem_hypercore/src/models/perp_dex.rs new file mode 100644 index 0000000000..ea48f6214c --- /dev/null +++ b/crates/gem_hypercore/src/models/perp_dex.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerpDex { + pub name: String, + pub is_active: Option, +} diff --git a/crates/gem_hypercore/src/provider/fee_calculator.rs b/crates/gem_hypercore/src/provider/fee_calculator.rs index ae25aebca9..fc827dd9c3 100644 --- a/crates/gem_hypercore/src/provider/fee_calculator.rs +++ b/crates/gem_hypercore/src/provider/fee_calculator.rs @@ -16,13 +16,7 @@ pub fn calculate_perpetual_fee_amount(fiat_value: f64, fee_rate: i64) -> BigInt BigInt::from(result as i64) } -pub fn calculate_spot_fee_amount( - swap_data: &SwapData, - from_asset: &Asset, - to_asset: &Asset, - fee_rate: i64, - builder_fee_bps: u32, -) -> Result> { +pub fn calculate_spot_fee_amount(swap_data: &SwapData, from_asset: &Asset, to_asset: &Asset, fee_rate: i64, builder_fee_bps: u32) -> Result> { let fiat_value = calculate_spot_usdc_value(swap_data, from_asset, to_asset, builder_fee_bps)?; let usdc_decimals = spot_usdc_decimals(from_asset, to_asset)?; let trade_fee = calculate_perpetual_fee_amount(fiat_value * decimal_scale(usdc_decimals - HYPERCORE_PERPETUAL_USDC_DECIMALS), fee_rate); @@ -31,12 +25,7 @@ pub fn calculate_spot_fee_amount( Ok(trade_fee + builder_fee) } -fn calculate_spot_usdc_value( - swap_data: &SwapData, - from_asset: &Asset, - to_asset: &Asset, - builder_fee_bps: u32, -) -> Result> { +fn calculate_spot_usdc_value(swap_data: &SwapData, from_asset: &Asset, to_asset: &Asset, builder_fee_bps: u32) -> Result> { let usdc_from = from_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID; let usdc_to = to_asset.id == *HYPERCORE_SPOT_USDC_ASSET_ID; diff --git a/crates/gem_hypercore/src/provider/hip3_mapper.rs b/crates/gem_hypercore/src/provider/hip3_mapper.rs new file mode 100644 index 0000000000..81d5cc521e --- /dev/null +++ b/crates/gem_hypercore/src/provider/hip3_mapper.rs @@ -0,0 +1,199 @@ +use std::collections::BTreeMap; + +use primitives::{ + chart::ChartDateValue, + perpetual::{PerpetualBalance, PerpetualPositionsSummary}, + portfolio::{PerpetualAccountSummary, PerpetualPortfolio, PerpetualPortfolioTimeframeData}, +}; + +use crate::models::position::AssetPositions; + +const HIP3_PERP_ASSET_OFFSET: u32 = 100_000; +const HIP3_PERP_ASSET_STRIDE: u32 = 10_000; + +pub fn perp_asset_index(perp_dex_index: u32, meta_index: u32) -> u32 { + if perp_dex_index == 0 { + meta_index + } else { + HIP3_PERP_ASSET_OFFSET + perp_dex_index * HIP3_PERP_ASSET_STRIDE + meta_index + } +} + +pub fn format_display_name(name: &str) -> String { + match name.split_once(':') { + Some((dex, symbol)) => format!("{symbol} ({dex})"), + None => name.to_string(), + } +} + +pub fn map_account_summary_aggregate(positions: &[AssetPositions]) -> PerpetualAccountSummary { + let account_value: f64 = positions.iter().map(|p| p.margin_summary.account_value.parse().unwrap_or(0.0)).sum(); + let total_ntl_pos: f64 = positions.iter().map(|p| p.margin_summary.total_ntl_pos.parse().unwrap_or(0.0)).sum(); + let total_margin_used: f64 = positions.iter().map(|p| p.margin_summary.total_margin_used.parse().unwrap_or(0.0)).sum(); + let unrealized_pnl: f64 = positions + .iter() + .flat_map(|p| &p.asset_positions) + .map(|p| p.position.unrealized_pnl.parse().unwrap_or(0.0)) + .sum(); + + let account_leverage = if account_value > 0.0 { total_ntl_pos / account_value } else { 0.0 }; + let margin_usage = if account_value > 0.0 { total_margin_used / account_value } else { 0.0 }; + + PerpetualAccountSummary { + account_value, + account_leverage, + margin_usage, + unrealized_pnl, + } +} + +pub fn merge_positions_summaries(summaries: Vec) -> PerpetualPositionsSummary { + let (positions, balance) = summaries.into_iter().fold( + (Vec::new(), PerpetualBalance { available: 0.0, reserved: 0.0, withdrawable: 0.0 }), + |(mut acc_pos, mut acc_bal), summary| { + acc_pos.extend(summary.positions); + acc_bal.available += summary.balance.available; + acc_bal.reserved += summary.balance.reserved; + acc_bal.withdrawable += summary.balance.withdrawable; + (acc_pos, acc_bal) + }, + ); + PerpetualPositionsSummary { positions, balance } +} + +pub fn merge_perpetual_portfolios(portfolios: Vec, account_summary: Option) -> PerpetualPortfolio { + let mut day = Vec::new(); + let mut week = Vec::new(); + let mut month = Vec::new(); + let mut all_time = Vec::new(); + + for portfolio in portfolios { + day.extend(portfolio.day); + week.extend(portfolio.week); + month.extend(portfolio.month); + all_time.extend(portfolio.all_time); + } + + PerpetualPortfolio { + day: merge_portfolio_timeframes(day), + week: merge_portfolio_timeframes(week), + month: merge_portfolio_timeframes(month), + all_time: merge_portfolio_timeframes(all_time), + account_summary, + } +} + +fn merge_portfolio_timeframes(values: Vec) -> Option { + if values.is_empty() { + return None; + } + + let mut account_value_histories = Vec::new(); + let mut pnl_histories = Vec::new(); + let mut volume = 0.0; + + for value in values { + account_value_histories.push(value.account_value_history); + pnl_histories.push(value.pnl_history); + volume += value.volume; + } + + Some(PerpetualPortfolioTimeframeData { + account_value_history: merge_chart_histories(account_value_histories), + pnl_history: merge_chart_histories(pnl_histories), + volume, + }) +} + +fn merge_chart_histories(values: Vec>) -> Vec { + let mut grouped = BTreeMap::new(); + for history in values { + for point in history { + let entry = grouped.entry(point.date).or_insert(0.0); + *entry += point.value; + } + } + + grouped.into_iter().map(|(date, value)| ChartDateValue { date, value }).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use primitives::portfolio::PerpetualAccountSummary; + + #[test] + fn test_perp_asset_index() { + assert_eq!(perp_asset_index(0, 0), 0); + assert_eq!(perp_asset_index(0, 5), 5); + assert_eq!(perp_asset_index(1, 0), 110_000); + assert_eq!(perp_asset_index(1, 3), 110_003); + assert_eq!(perp_asset_index(2, 0), 120_000); + assert_eq!(perp_asset_index(2, 7), 120_007); + } + + #[test] + fn test_format_display_name() { + assert_eq!(format_display_name("xyz:GOLD"), "GOLD (xyz)"); + assert_eq!(format_display_name("BTC"), "BTC"); + } + + #[test] + fn test_map_account_summary_aggregate() { + let positions = vec![AssetPositions::mock(), AssetPositions::mock()]; + let summary = map_account_summary_aggregate(&positions); + + assert_eq!(summary.account_value, 20000.0); + assert_eq!(summary.account_leverage, 0.5); + assert_eq!(summary.margin_usage, 0.2); + assert_eq!(summary.unrealized_pnl, 0.0); + } + + #[test] + fn test_merge_chart_histories() { + use chrono::{TimeZone, Utc}; + + let d1 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let d2 = Utc.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(); + let d3 = Utc.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(); + + let histories = vec![ + vec![ChartDateValue { date: d1, value: 100.0 }, ChartDateValue { date: d2, value: 200.0 }], + vec![ChartDateValue { date: d1, value: 50.0 }, ChartDateValue { date: d3, value: 300.0 }], + ]; + + let merged = merge_chart_histories(histories); + assert_eq!(merged.len(), 3); + assert_eq!(merged[0].value, 150.0); + assert_eq!(merged[1].value, 200.0); + assert_eq!(merged[2].value, 300.0); + } + + #[test] + fn test_merge_perpetual_portfolios() { + use chrono::{TimeZone, Utc}; + + let d1 = Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); + let portfolios = vec![ + PerpetualPortfolio::mock_with_day(d1, 100.0, 10.0, 500.0), + PerpetualPortfolio::mock_with_day(d1, 200.0, 20.0, 300.0), + ]; + let summary = PerpetualAccountSummary { account_value: 1000.0, account_leverage: 2.0, margin_usage: 0.5, unrealized_pnl: 30.0 }; + + let merged = merge_perpetual_portfolios(portfolios, Some(summary)); + + let day = merged.day.unwrap(); + assert_eq!(day.volume, 800.0); + assert_eq!(day.account_value_history.len(), 1); + assert_eq!(day.account_value_history[0].value, 300.0); + assert_eq!(day.pnl_history[0].value, 30.0); + assert!(merged.week.is_none()); + assert_eq!(merged.account_summary.unwrap().account_value, 1000.0); + } + + #[test] + fn test_merge_portfolio_timeframes_empty() { + let result = merge_portfolio_timeframes(vec![]); + assert!(result.is_none()); + } +} diff --git a/crates/gem_hypercore/src/provider/mod.rs b/crates/gem_hypercore/src/provider/mod.rs index 834e26c77c..7111a15645 100644 --- a/crates/gem_hypercore/src/provider/mod.rs +++ b/crates/gem_hypercore/src/provider/mod.rs @@ -5,6 +5,7 @@ use gem_client::Client; pub mod balances; pub mod balances_mapper; pub mod fee_calculator; +pub mod hip3_mapper; pub mod perpetual; pub mod perpetual_mapper; pub mod preload; diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index 4aea4be108..d0bf00fca5 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -2,7 +2,7 @@ use std::error::Error; use async_trait::async_trait; use chain_traits::{ChainAddressStatus, ChainPerpetual}; -use futures::try_join; +use futures::{future::try_join_all, try_join}; use gem_client::Client; use primitives::{ ChartPeriod, @@ -13,20 +13,74 @@ use primitives::{ use crate::{ config::HypercoreConfig, - provider::perpetual_mapper::{map_candlesticks, map_perpetual_portfolio, map_perpetuals_data, map_positions}, + models::{perp_dex::PerpDex, position::AssetPositions}, + provider::{ + hip3_mapper::{map_account_summary_aggregate, merge_perpetual_portfolios, merge_positions_summaries}, + perpetual_mapper::{map_candlesticks, map_perpetual_portfolio, map_perpetuals_data, map_positions}, + }, rpc::client::HyperCoreClient, }; +fn filter_active_dex(perp_dexs: &[Option]) -> Vec<(u32, Option)> { + perp_dexs + .iter() + .enumerate() + .filter_map(|(index, entry)| { + if index == 0 { + return Some((0, None)); + } + let dex = entry.as_ref()?; + if dex.is_active == Some(false) || dex.name.is_empty() { + return None; + } + Some((index as u32, Some(dex.name.clone()))) + }) + .collect() +} + +impl HyperCoreClient { + async fn get_active_dexes(&self) -> Vec<(u32, Option)> { + self.get_perp_dexs().await.map(|dexs| filter_active_dex(&dexs)).unwrap_or_else(|_| vec![(0, None)]) + } + + async fn for_each_dex(&self, f: F) -> Result, Box> + where + F: Fn(u32, Option) -> Fut, + Fut: std::future::Future>>, + { + let dex_entries = self.get_active_dexes().await; + let requests: Vec<_> = dex_entries.into_iter().map(|(index, dex)| f(index, dex)).collect(); + try_join_all(requests).await + } + + async fn fetch_positions_for_dex(&self, address: String, dex: Option) -> Result> { + let (positions, orders) = try_join!(self.get_clearinghouse_state_with_dex(&address, dex.clone()), self.get_open_orders_with_dex(&address, dex))?; + Ok(map_positions(positions, address, &orders)) + } + + async fn fetch_portfolio_for_dex(&self, address: String, dex: Option) -> Result<(PerpetualPortfolio, AssetPositions), Box> { + let (response, positions) = try_join!( + self.get_perpetual_portfolio_with_dex(&address, dex.clone()), + self.get_clearinghouse_state_with_dex(&address, dex) + )?; + Ok((map_perpetual_portfolio(response, &positions), positions)) + } +} + #[async_trait] impl ChainPerpetual for HyperCoreClient { async fn get_positions(&self, address: String) -> Result> { - let (positions, orders) = try_join!(self.get_clearinghouse_state(&address), self.get_open_orders(&address))?; - Ok(map_positions(positions, address, &orders)) + let summaries = self.for_each_dex(|_, dex| self.fetch_positions_for_dex(address.clone(), dex)).await?; + Ok(merge_positions_summaries(summaries)) } async fn get_perpetuals_data(&self) -> Result, Box> { - let metadata = self.get_metadata().await?; - Ok(map_perpetuals_data(metadata)) + let results = self.for_each_dex(|index, dex| async move { + let metadata = self.get_metadata_with_dex(dex).await?; + Ok(map_perpetuals_data(metadata, index)) + }).await?; + + Ok(results.into_iter().flatten().collect()) } async fn get_perpetual_candlesticks(&self, symbol: String, period: ChartPeriod) -> Result, Box> { @@ -54,8 +108,10 @@ impl ChainPerpetual for HyperCoreClient { } async fn get_perpetual_portfolio(&self, address: String) -> Result> { - let (response, positions) = try_join!(self.get_perpetual_portfolio(&address), self.get_clearinghouse_state(&address))?; - Ok(map_perpetual_portfolio(response, &positions)) + let results = self.for_each_dex(|_, dex| self.fetch_portfolio_for_dex(address.clone(), dex)).await?; + let (portfolios, positions): (Vec<_>, Vec<_>) = results.into_iter().unzip(); + let account_summary = Some(map_account_summary_aggregate(&positions)); + Ok(merge_perpetual_portfolios(portfolios, account_summary)) } async fn get_perpetual_referred_addresses(&self) -> Result, Box> { @@ -69,12 +125,101 @@ impl ChainPerpetual for HyperCoreClient { #[async_trait] impl ChainAddressStatus for HyperCoreClient {} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_active_dex_filters_inactive() { + let dexs = vec![ + None, + Some(PerpDex { name: "dex1".to_string(), is_active: Some(true) }), + Some(PerpDex { name: "dex2".to_string(), is_active: Some(false) }), + Some(PerpDex { name: "dex3".to_string(), is_active: None }), + ]; + + let entries = filter_active_dex(&dexs); + assert_eq!(entries.len(), 3); + assert_eq!(entries[0], (0, None)); + assert_eq!(entries[1], (1, Some("dex1".to_string()))); + assert_eq!(entries[2], (3, Some("dex3".to_string()))); + } + + #[test] + fn test_filter_active_dex_skips_empty_names() { + let dexs = vec![ + None, + Some(PerpDex { name: "".to_string(), is_active: Some(true) }), + ]; + + let entries = filter_active_dex(&dexs); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0], (0, None)); + } +} + #[cfg(all(test, feature = "chain_integration_tests"))] mod integration_tests { use crate::provider::testkit::{TEST_ADDRESS, create_hypercore_test_client}; use chain_traits::ChainPerpetual; use primitives::ChartPeriod; + #[tokio::test] + async fn test_hypercore_get_perp_dexs() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let dexs = client.get_perp_dexs().await?; + + assert!(!dexs.is_empty()); + + println!("Perp DEXs count: {}", dexs.len()); + for (i, dex) in dexs.iter().enumerate() { + println!(" DEX {}: {:?}", i, dex.as_ref().map(|d| (&d.name, &d.is_active))); + } + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_positions() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let summary = client.get_positions(TEST_ADDRESS.to_string()).await?; + + println!("Positions count: {}", summary.positions.len()); + println!( + "Balance: available={}, reserved={}, withdrawable={}", + summary.balance.available, summary.balance.reserved, summary.balance.withdrawable + ); + + for pos in &summary.positions { + println!(" {} {:?} size={} leverage={}", pos.perpetual_id, pos.direction, pos.size, pos.leverage); + } + Ok(()) + } + + #[tokio::test] + async fn test_hypercore_get_perpetuals_data() -> Result<(), Box> { + let client = create_hypercore_test_client(); + let data = client.get_perpetuals_data().await?; + + assert!(!data.is_empty()); + + println!("Perpetuals count: {}", data.len()); + for d in data.iter().take(5) { + println!( + " {} identifier={} price={} leverage={}", + d.perpetual.name, d.perpetual.identifier, d.perpetual.price, d.perpetual.max_leverage + ); + } + + let btc = data.iter().find(|d| d.perpetual.name == "BTC"); + assert!(btc.is_some(), "BTC perpetual should exist"); + assert_eq!(btc.unwrap().perpetual.identifier, "0"); + + let builder_assets: Vec<_> = data.iter().filter(|d| d.perpetual.identifier.parse::().unwrap_or(0) >= 100_000).collect(); + println!("Builder DEX assets: {}", builder_assets.len()); + + Ok(()) + } + #[tokio::test] async fn test_hypercore_get_perpetual_portfolio() -> Result<(), Box> { let client = create_hypercore_test_client(); diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index 73acc98711..b6eb0ade37 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -1,10 +1,3 @@ -use crate::models::{ - candlestick::Candlestick, - metadata::HypercoreMetadataResponse, - order::OpenOrder, - portfolio::HypercorePortfolioResponse, - position::{AssetPositions, LeverageType, Position}, -}; use primitives::{ Asset, AssetId, AssetType, Chain, Perpetual, PerpetualBalance, PerpetualDirection, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, @@ -13,6 +6,16 @@ use primitives::{ portfolio::{PerpetualAccountSummary, PerpetualPortfolio}, }; +use crate::models::{ + candlestick::Candlestick, + metadata::HypercoreMetadataResponse, + order::OpenOrder, + portfolio::HypercorePortfolioResponse, + position::{AssetPositions, LeverageType, Position}, +}; + +use crate::provider::hip3_mapper::{format_display_name, perp_asset_index}; + pub fn create_perpetual_asset_id(coin: &str) -> AssetId { crate::models::metadata::perpetual_asset_id(coin) } @@ -83,7 +86,7 @@ pub fn map_position(position: Position, address: String, orders: &[OpenOrder]) - } } -pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse) -> Vec { +pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: u32) -> Vec { let universe = metadata.universe(); let asset_metadata = metadata.asset_metadata(); @@ -95,6 +98,7 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse) -> Vec Vec, last_fill: &UserFill }) .ok()?; - build_fill_transaction( - address, - last_fill, - from_asset, - TransactionType::Swap, - fee, - fee_asset_id, - from_value, - metadata, - ) + build_fill_transaction(address, last_fill, from_asset, TransactionType::Swap, fee, fee_asset_id, from_value, metadata) } fn map_spot_fee(fills: &[UserFill], base_token: &SpotToken, quote_token: &SpotToken) -> Option<(String, primitives::AssetId)> { diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index d5e65cba2c..b49fc1d5d2 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -49,11 +49,19 @@ pub fn diff_clearinghouse_positions(new_positions: Vec, exist .collect(); let new_ids: HashSet<&str> = positions.iter().map(|p| p.id.as_str()).collect(); - let delete_position_ids: Vec = existing_positions.iter().filter(|p| !new_ids.contains(p.id.as_str())).map(|p| p.id.clone()).collect(); + let delete_position_ids: Vec = existing_positions + .iter() + .filter(|p| !is_builder_dex_position(p) && !new_ids.contains(p.id.as_str())) + .map(|p| p.id.clone()) + .collect(); PositionsDiff { delete_position_ids, positions } } +fn is_builder_dex_position(position: &PerpetualPosition) -> bool { + position.perpetual_id.contains(':') +} + pub fn diff_open_orders_positions(orders: &[OpenOrder], existing_positions: Vec) -> PositionsDiff { let positions: Vec = existing_positions .into_iter() diff --git a/crates/gem_hypercore/src/rpc/client.rs b/crates/gem_hypercore/src/rpc/client.rs index bf7094349f..245601f129 100644 --- a/crates/gem_hypercore/src/rpc/client.rs +++ b/crates/gem_hypercore/src/rpc/client.rs @@ -3,6 +3,7 @@ use crate::models::{ candlestick::Candlestick, metadata::HypercoreMetadataResponse, order::{OpenOrder, UserFill}, + perp_dex::PerpDex, portfolio::HypercorePortfolioResponse, position::AssetPositions, referral::Referral, @@ -149,15 +150,23 @@ impl HyperCoreClient { } pub async fn get_clearinghouse_state(&self, user: &str) -> Result> { - self.info(json!({ - "type": "clearinghouseState", - "user": user - })) - .await + self.get_clearinghouse_state_with_dex(user, None).await + } + + pub async fn get_clearinghouse_state_with_dex(&self, user: &str, dex: Option) -> Result> { + self.info(with_dex(json!({"type": "clearinghouseState", "user": user}), dex)).await } pub async fn get_metadata(&self) -> Result> { - self.info(json!({"type": "metaAndAssetCtxs"})).await + self.get_metadata_with_dex(None).await + } + + pub async fn get_metadata_with_dex(&self, dex: Option) -> Result> { + self.info(with_dex(json!({"type": "metaAndAssetCtxs"}), dex)).await + } + + pub async fn get_perp_dexs(&self) -> Result>, Box> { + self.info(json!({"type": "perpDexs"})).await } pub async fn get_spot_meta(&self) -> Result> { @@ -243,20 +252,27 @@ impl HyperCoreClient { } pub async fn get_open_orders(&self, user: &str) -> Result, Box> { - self.info(json!({ - "type": "frontendOpenOrders", - "user": user - })) - .await + self.get_open_orders_with_dex(user, None).await + } + + pub async fn get_open_orders_with_dex(&self, user: &str, dex: Option) -> Result, Box> { + self.info(with_dex(json!({"type": "frontendOpenOrders", "user": user}), dex)).await } pub async fn get_perpetual_portfolio(&self, user: &str) -> Result> { - self.info(json!({ - "type": "portfolio", - "user": user - })) - .await + self.get_perpetual_portfolio_with_dex(user, None).await + } + + pub async fn get_perpetual_portfolio_with_dex(&self, user: &str, dex: Option) -> Result> { + self.info(with_dex(json!({"type": "portfolio", "user": user}), dex)).await + } +} + +fn with_dex(mut payload: serde_json::Value, dex: Option) -> serde_json::Value { + if let Some(dex) = dex.filter(|d| !d.is_empty()) { + payload["dex"] = json!(dex); } + payload } impl ChainTraits for HyperCoreClient {} diff --git a/crates/gem_hypercore/src/signer/core_signer.rs b/crates/gem_hypercore/src/signer/core_signer.rs index 42469f894e..543e9f776a 100644 --- a/crates/gem_hypercore/src/signer/core_signer.rs +++ b/crates/gem_hypercore/src/signer/core_signer.rs @@ -2,8 +2,9 @@ use ::signer::Signer; use alloy_primitives::hex; use number_formatter::BigNumberFormatter; use primitives::{ - ChainSigner, HyperliquidOrder, NumberIncrementer, PerpetualConfirmData, PerpetualDirection, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualType, - SignerError, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, stake_type::StakeType, swap::SwapData, + ChainSigner, HyperliquidOrder, NumberIncrementer, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualModifyConfirmData, PerpetualModifyPositionType, + PerpetualType, SignerError, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, stake_type::StakeType, + swap::SwapData, }; use serde::Serialize; use serde_json::{self, Value}; @@ -215,7 +216,8 @@ impl HyperCoreSigner { let is_buy = data.direction == PerpetualDirection::Long; let asset = data.asset_index as u32; - let leverage = self.sign_update_leverage(UpdateLeverage::new(asset, true, data.leverage), timestamp_incrementer.next_val(), agent_key)?; + let is_cross = data.margin_type == PerpetualMarginType::Cross; + let leverage = self.sign_update_leverage(UpdateLeverage::new(asset, is_cross, data.leverage), timestamp_incrementer.next_val(), agent_key)?; let market = self.sign_place_order( make_market_order(asset, is_buy, &data.price, &data.size, false, builder.cloned()), timestamp_incrementer.next_val(), diff --git a/crates/gem_hypercore/src/testkit.rs b/crates/gem_hypercore/src/testkit.rs index fa5263f377..6c0a57db75 100644 --- a/crates/gem_hypercore/src/testkit.rs +++ b/crates/gem_hypercore/src/testkit.rs @@ -1,3 +1,4 @@ +pub use crate::models::metadata::{AssetMetadata, HypercoreMetadataResponse, HypercoreUniverseResponse, UniverseAsset}; pub use crate::models::order::OpenOrder; pub use crate::models::portfolio::{HypercoreDataPoint, HypercorePortfolioResponse, HypercorePortfolioTimeframeData}; pub use crate::models::position::{AssetPositions, MarginSummary}; @@ -24,6 +25,34 @@ impl AssetPositions { } } +impl UniverseAsset { + pub fn mock(name: &str) -> Self { + Self { + name: name.to_string(), + sz_decimals: 4, + max_leverage: 50, + only_isolated: None, + } + } +} + +impl AssetMetadata { + pub fn mock() -> Self { + Self { + funding: "0".to_string(), + open_interest: "0".to_string(), + prev_day_px: "1".to_string(), + day_ntl_vlm: "0".to_string(), + premium: None, + oracle_px: "1".to_string(), + mark_px: "1".to_string(), + mid_px: Some("1".to_string()), + impact_pxs: None, + day_base_vlm: "0".to_string(), + } + } +} + impl OpenOrder { pub fn mock(coin: &str, oid: u64, order_type: &str, trigger_px: f64, limit_px: Option) -> Self { Self { diff --git a/crates/primitives/src/chain_nft.rs b/crates/primitives/src/chain_nft.rs index f4a3a6ccb6..110fb557ad 100644 --- a/crates/primitives/src/chain_nft.rs +++ b/crates/primitives/src/chain_nft.rs @@ -1,6 +1,6 @@ +use crate::Chain; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, EnumIter, EnumString, IntoEnumIterator}; -use crate::Chain; #[derive(Copy, Clone, Debug, Serialize, Deserialize, EnumIter, AsRefStr, EnumString, PartialEq, Eq, Hash)] #[serde(rename_all = "lowercase")] diff --git a/crates/primitives/src/perpetual.rs b/crates/primitives/src/perpetual.rs index c5b1f80d32..8b4a9e0d02 100644 --- a/crates/primitives/src/perpetual.rs +++ b/crates/primitives/src/perpetual.rs @@ -1,4 +1,4 @@ -use crate::{Asset, AssetId, PerpetualPosition, PerpetualProvider, UInt64}; +use crate::{Asset, AssetId, PerpetualMarginType, PerpetualPosition, PerpetualProvider, UInt64}; use serde::{Deserialize, Serialize}; use strum::{AsRefStr, EnumString}; use typeshare::typeshare; @@ -18,6 +18,7 @@ pub struct Perpetual { pub volume_24h: f64, pub funding: f64, pub max_leverage: u8, + pub only_isolated: bool, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -98,6 +99,7 @@ pub struct PerpetualMetadata { #[serde(rename_all = "camelCase")] pub struct PerpetualConfirmData { pub direction: PerpetualDirection, + pub margin_type: PerpetualMarginType, pub base_asset: Asset, pub asset_index: i32, pub price: String, diff --git a/crates/primitives/src/testkit/mod.rs b/crates/primitives/src/testkit/mod.rs index 8c74d67d50..ebd465b7fe 100644 --- a/crates/primitives/src/testkit/mod.rs +++ b/crates/primitives/src/testkit/mod.rs @@ -6,6 +6,7 @@ pub mod fiat_mock; pub mod gorush_mock; pub mod json_rpc; pub mod perpetual_mock; +pub mod portfolio_mock; pub mod swap_mock; pub mod transaction_load_input_mock; pub mod transaction_load_metadata_mock; diff --git a/crates/primitives/src/testkit/perpetual_mock.rs b/crates/primitives/src/testkit/perpetual_mock.rs index da62bc8b76..36a247fd28 100644 --- a/crates/primitives/src/testkit/perpetual_mock.rs +++ b/crates/primitives/src/testkit/perpetual_mock.rs @@ -1,9 +1,10 @@ -use crate::{Asset, Chain, PerpetualConfirmData, PerpetualDirection}; +use crate::{Asset, Chain, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType}; impl PerpetualConfirmData { pub fn mock(direction: PerpetualDirection, asset_index: u32, take_profit: Option, stop_loss: Option) -> Self { Self { direction, + margin_type: PerpetualMarginType::Cross, base_asset: Asset::from_chain(Chain::HyperCore), asset_index: asset_index as i32, price: "123.45".to_string(), diff --git a/crates/primitives/src/testkit/portfolio_mock.rs b/crates/primitives/src/testkit/portfolio_mock.rs new file mode 100644 index 0000000000..371d03ec6d --- /dev/null +++ b/crates/primitives/src/testkit/portfolio_mock.rs @@ -0,0 +1,25 @@ +use chrono::{DateTime, Utc}; + +use crate::{ + chart::ChartDateValue, + portfolio::{PerpetualPortfolio, PerpetualPortfolioTimeframeData}, +}; + +impl PerpetualPortfolioTimeframeData { + pub fn mock(date: DateTime, account_value: f64, pnl: f64, volume: f64) -> Self { + Self { + account_value_history: vec![ChartDateValue { date, value: account_value }], + pnl_history: vec![ChartDateValue { date, value: pnl }], + volume, + } + } +} + +impl PerpetualPortfolio { + pub fn mock_with_day(date: DateTime, account_value: f64, pnl: f64, volume: f64) -> Self { + Self { + day: Some(PerpetualPortfolioTimeframeData::mock(date, account_value, pnl, volume)), + ..Default::default() + } + } +} diff --git a/crates/storage/src/models/perpetual.rs b/crates/storage/src/models/perpetual.rs index 5fab24c21e..73348b025f 100644 --- a/crates/storage/src/models/perpetual.rs +++ b/crates/storage/src/models/perpetual.rs @@ -91,6 +91,7 @@ impl PerpetualRow { volume_24h: self.volume_24h, funding: self.funding, max_leverage: self.leverage.first().and_then(|v| v.and_then(|i| u8::try_from(i).ok())).unwrap_or(1), + only_isolated: false, } } diff --git a/gemstone/src/models/perpetual.rs b/gemstone/src/models/perpetual.rs index ee8b240373..854560f22e 100644 --- a/gemstone/src/models/perpetual.rs +++ b/gemstone/src/models/perpetual.rs @@ -96,6 +96,7 @@ pub struct GemPerpetual { pub volume_24h: f64, pub funding: f64, pub max_leverage: u8, + pub only_isolated: bool, } #[uniffi::remote(Record)] diff --git a/gemstone/src/models/transaction.rs b/gemstone/src/models/transaction.rs index 3001296257..780f039471 100644 --- a/gemstone/src/models/transaction.rs +++ b/gemstone/src/models/transaction.rs @@ -3,10 +3,10 @@ use num_bigint::BigInt; use primitives::contract_call_data::ContractCallData; use primitives::stake_type::FreezeData; use primitives::{ - AccountDataType, Asset, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualProvider, PerpetualType, Resource, StakeType, - TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, TransactionState, - TransactionStateRequest, TransactionType, TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, TronUnfreeze, TronVote, - UInt64, WalletConnectionSessionAppMetadata, + AccountDataType, Asset, EarnType, FeeOption, GasPriceType, HyperliquidOrder, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualProvider, PerpetualType, + Resource, StakeType, TransactionChange, TransactionFee, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, TransactionMetadata, TransactionPerpetualMetadata, + TransactionState, TransactionStateRequest, TransactionType, TransactionUpdate, TransferDataExtra, TransferDataOutputAction, TransferDataOutputType, TronStakeData, + TronUnfreeze, TronVote, UInt64, WalletConnectionSessionAppMetadata, perpetual::{CancelOrderData, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualReduceData, TPSLOrderData}, }; use std::collections::HashMap; @@ -218,6 +218,7 @@ pub struct GemTransferDataExtra { #[uniffi::remote(Record)] pub struct PerpetualConfirmData { pub direction: PerpetualDirection, + pub margin_type: PerpetualMarginType, pub base_asset: Asset, pub asset_index: i32, pub price: String, From 8e1c4be72d1e1555dac0f3673d6d0257f3f66217 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 24 Mar 2026 08:49:44 +0900 Subject: [PATCH 2/5] fix: address PR review comments - Collapse _with_dex method pairs into single methods with Option<&str> dex param - Add InfoPayload struct with serde skip_serializing_if for clean request building - Add UpdateLeverage::from_margin_type() with unit tests for is_cross - Use from_margin_type in signer instead of inline comparison --- .../src/core/actions/agent/update_leverage.rs | 23 ++++ .../gem_hypercore/src/models/info_request.rs | 21 ++++ crates/gem_hypercore/src/models/mod.rs | 1 + .../gem_hypercore/src/provider/perpetual.rs | 11 +- crates/gem_hypercore/src/rpc/client.rs | 103 +++++++----------- .../gem_hypercore/src/signer/core_signer.rs | 10 +- 6 files changed, 96 insertions(+), 73 deletions(-) create mode 100644 crates/gem_hypercore/src/models/info_request.rs diff --git a/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs b/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs index 79edaa1970..e9d02ad7e3 100644 --- a/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs +++ b/crates/gem_hypercore/src/core/actions/agent/update_leverage.rs @@ -1,3 +1,5 @@ +use primitives::PerpetualMarginType; + // IMPORTANT: Field order matters for msgpack serialization and hash calculation // Do not change field order unless you know the exact order in Python SDK. #[derive(Clone, serde::Serialize)] @@ -18,4 +20,25 @@ impl UpdateLeverage { leverage, } } + + pub fn from_margin_type(asset: u32, margin_type: &PerpetualMarginType, leverage: u8) -> Self { + Self::new(asset, *margin_type == PerpetualMarginType::Cross, leverage) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_margin_type_cross() { + let leverage = UpdateLeverage::from_margin_type(1, &PerpetualMarginType::Cross, 10); + assert!(leverage.is_cross); + } + + #[test] + fn test_from_margin_type_isolated() { + let leverage = UpdateLeverage::from_margin_type(1, &PerpetualMarginType::Isolated, 10); + assert!(!leverage.is_cross); + } } diff --git a/crates/gem_hypercore/src/models/info_request.rs b/crates/gem_hypercore/src/models/info_request.rs new file mode 100644 index 0000000000..cb528c7a2f --- /dev/null +++ b/crates/gem_hypercore/src/models/info_request.rs @@ -0,0 +1,21 @@ +use serde::Serialize; + +#[derive(Serialize)] +pub struct InfoPayload<'a> { + #[serde(rename = "type")] + request_type: &'a str, + user: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + dex: Option<&'a str>, +} + +impl<'a> InfoPayload<'a> { + pub fn new(request_type: &'a str, user: &'a str) -> Self { + Self { request_type, user, dex: None } + } + + pub fn dex(mut self, dex: Option<&'a str>) -> Self { + self.dex = dex; + self + } +} diff --git a/crates/gem_hypercore/src/models/mod.rs b/crates/gem_hypercore/src/models/mod.rs index a450ac98a5..c430a67ade 100644 --- a/crates/gem_hypercore/src/models/mod.rs +++ b/crates/gem_hypercore/src/models/mod.rs @@ -1,6 +1,7 @@ pub mod action; pub mod balance; pub mod candlestick; +pub mod info_request; pub mod metadata; pub mod order; pub mod perp_dex; diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index d0bf00fca5..af77b83c88 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -54,15 +54,14 @@ impl HyperCoreClient { } async fn fetch_positions_for_dex(&self, address: String, dex: Option) -> Result> { - let (positions, orders) = try_join!(self.get_clearinghouse_state_with_dex(&address, dex.clone()), self.get_open_orders_with_dex(&address, dex))?; + let dex = dex.as_deref(); + let (positions, orders) = try_join!(self.get_clearinghouse_state(&address, dex), self.get_open_orders(&address, dex))?; Ok(map_positions(positions, address, &orders)) } async fn fetch_portfolio_for_dex(&self, address: String, dex: Option) -> Result<(PerpetualPortfolio, AssetPositions), Box> { - let (response, positions) = try_join!( - self.get_perpetual_portfolio_with_dex(&address, dex.clone()), - self.get_clearinghouse_state_with_dex(&address, dex) - )?; + let dex = dex.as_deref(); + let (response, positions) = try_join!(self.get_perpetual_portfolio(&address, dex), self.get_clearinghouse_state(&address, dex))?; Ok((map_perpetual_portfolio(response, &positions), positions)) } } @@ -76,7 +75,7 @@ impl ChainPerpetual for HyperCoreClient { async fn get_perpetuals_data(&self) -> Result, Box> { let results = self.for_each_dex(|index, dex| async move { - let metadata = self.get_metadata_with_dex(dex).await?; + let metadata = self.get_metadata(dex.as_deref()).await?; Ok(map_perpetuals_data(metadata, index)) }).await?; diff --git a/crates/gem_hypercore/src/rpc/client.rs b/crates/gem_hypercore/src/rpc/client.rs index 245601f129..76999b70e2 100644 --- a/crates/gem_hypercore/src/rpc/client.rs +++ b/crates/gem_hypercore/src/rpc/client.rs @@ -1,6 +1,20 @@ +use std::{ + collections::HashMap, + error::Error, + sync::{Arc, Mutex}, +}; + +use chain_traits::ChainTraits; +use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType, X_CACHE_TTL}; +use primitives::{Chain, Preferences}; +use serde::Serialize; +use serde_json::json; + +use crate::config::HypercoreConfig; use crate::models::{ balance::{Balances, DelegationBalance, StakeBalance, Validator}, candlestick::Candlestick, + info_request::InfoPayload, metadata::HypercoreMetadataResponse, order::{OpenOrder, UserFill}, perp_dex::PerpDex, @@ -10,18 +24,6 @@ use crate::models::{ spot::{OrderbookResponse, SpotMeta}, user::{AgentSession, LedgerUpdate, UserFee, UserRole}, }; -use chain_traits::ChainTraits; -use gem_client::{CONTENT_TYPE, Client, ClientExt, ContentType}; -use std::{ - collections::HashMap, - error::Error, - sync::{Arc, Mutex}, -}; - -use crate::config::HypercoreConfig; -use gem_client::X_CACHE_TTL; -use primitives::{Chain, Preferences}; -use serde_json::json; const SPOT_META_CACHE_TTL_SECS: u64 = 3600; @@ -100,11 +102,8 @@ impl HyperCoreClient { } } - async fn info(&self, payload: serde_json::Value) -> Result> - where - T: serde::de::DeserializeOwned + Send, - { - Ok(self.client.post("/info", &payload).await?) + async fn info(&self, payload: &P) -> Result> { + Ok(self.client.post("/info", payload).await?) } pub async fn exchange(&self, payload: serde_json::Value) -> Result> { @@ -112,11 +111,11 @@ impl HyperCoreClient { } pub async fn get_validators(&self) -> Result, Box> { - self.info(json!({"type": "validatorSummaries"})).await + self.info(&json!({"type": "validatorSummaries"})).await } pub async fn get_staking_delegations(&self, user: &str) -> Result, Box> { - self.info(json!({"type": "delegations", "user": user})).await + self.info(&json!({"type": "delegations", "user": user})).await } pub async fn get_staking_apy(&self) -> Result> { @@ -125,7 +124,7 @@ impl HyperCoreClient { } pub async fn get_spot_balances(&self, user: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "spotClearinghouseState", "user": user })) @@ -133,7 +132,7 @@ impl HyperCoreClient { } pub async fn get_stake_balance(&self, user: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "delegatorSummary", "user": user })) @@ -141,7 +140,7 @@ impl HyperCoreClient { } pub async fn get_user_fills_by_time(&self, user: &str, start_time: i64) -> Result, Box> { - self.info(json!({ + self.info(&json!({ "type": "userFillsByTime", "user": user, "startTime": start_time @@ -149,24 +148,19 @@ impl HyperCoreClient { .await } - pub async fn get_clearinghouse_state(&self, user: &str) -> Result> { - self.get_clearinghouse_state_with_dex(user, None).await - } - - pub async fn get_clearinghouse_state_with_dex(&self, user: &str, dex: Option) -> Result> { - self.info(with_dex(json!({"type": "clearinghouseState", "user": user}), dex)).await - } - - pub async fn get_metadata(&self) -> Result> { - self.get_metadata_with_dex(None).await + pub async fn get_clearinghouse_state(&self, user: &str, dex: Option<&str>) -> Result> { + self.info(&InfoPayload::new("clearinghouseState", user).dex(dex)).await } - pub async fn get_metadata_with_dex(&self, dex: Option) -> Result> { - self.info(with_dex(json!({"type": "metaAndAssetCtxs"}), dex)).await + pub async fn get_metadata(&self, dex: Option<&str>) -> Result> { + match dex { + Some(dex) => self.info(&json!({"type": "metaAndAssetCtxs", "dex": dex})).await, + None => self.info(&json!({"type": "metaAndAssetCtxs"})).await, + } } pub async fn get_perp_dexs(&self) -> Result>, Box> { - self.info(json!({"type": "perpDexs"})).await + self.info(&json!({"type": "perpDexs"})).await } pub async fn get_spot_meta(&self) -> Result> { @@ -179,12 +173,12 @@ impl HyperCoreClient { } pub async fn get_spot_orderbook(&self, coin: &str) -> Result> { - let response = self.info(json!({ "type": "l2Book", "coin": coin })).await?; + let response = self.info(&json!({ "type": "l2Book", "coin": coin })).await?; Ok(serde_json::from_value(response)?) } pub async fn get_candlesticks(&self, coin: &str, interval: &str, start_time: i64, end_time: i64) -> Result, Box> { - self.info(json!({ + self.info(&json!({ "type": "candleSnapshot", "req": { "coin": coin, @@ -197,7 +191,7 @@ impl HyperCoreClient { } pub async fn get_user_role(&self, user: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "userRole", "user": user })) @@ -205,7 +199,7 @@ impl HyperCoreClient { } pub async fn get_referral(&self, user: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "referral", "user": user })) @@ -213,7 +207,7 @@ impl HyperCoreClient { } pub async fn get_extra_agents(&self, user: &str) -> Result, Box> { - self.info(json!({ + self.info(&json!({ "type": "extraAgents", "user": user })) @@ -221,7 +215,7 @@ impl HyperCoreClient { } pub async fn get_builder_fee(&self, user: &str, builder: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "maxBuilderFee", "user": user, "builder": builder @@ -230,7 +224,7 @@ impl HyperCoreClient { } pub async fn get_user_fees(&self, user: &str) -> Result> { - self.info(json!({ + self.info(&json!({ "type": "userFees", "user": user })) @@ -238,7 +232,7 @@ impl HyperCoreClient { } pub async fn get_ledger_updates(&self, user: &str) -> Result, Box> { - self.info(json!({ + self.info(&json!({ "type": "userNonFundingLedgerUpdates", "user": user })) @@ -251,28 +245,13 @@ impl HyperCoreClient { Ok(update.hash.clone()) } - pub async fn get_open_orders(&self, user: &str) -> Result, Box> { - self.get_open_orders_with_dex(user, None).await - } - - pub async fn get_open_orders_with_dex(&self, user: &str, dex: Option) -> Result, Box> { - self.info(with_dex(json!({"type": "frontendOpenOrders", "user": user}), dex)).await - } - - pub async fn get_perpetual_portfolio(&self, user: &str) -> Result> { - self.get_perpetual_portfolio_with_dex(user, None).await - } - - pub async fn get_perpetual_portfolio_with_dex(&self, user: &str, dex: Option) -> Result> { - self.info(with_dex(json!({"type": "portfolio", "user": user}), dex)).await + pub async fn get_open_orders(&self, user: &str, dex: Option<&str>) -> Result, Box> { + self.info(&InfoPayload::new("frontendOpenOrders", user).dex(dex)).await } -} -fn with_dex(mut payload: serde_json::Value, dex: Option) -> serde_json::Value { - if let Some(dex) = dex.filter(|d| !d.is_empty()) { - payload["dex"] = json!(dex); + pub async fn get_perpetual_portfolio(&self, user: &str, dex: Option<&str>) -> Result> { + self.info(&InfoPayload::new("portfolio", user).dex(dex)).await } - payload } impl ChainTraits for HyperCoreClient {} diff --git a/crates/gem_hypercore/src/signer/core_signer.rs b/crates/gem_hypercore/src/signer/core_signer.rs index 543e9f776a..bb9b959a45 100644 --- a/crates/gem_hypercore/src/signer/core_signer.rs +++ b/crates/gem_hypercore/src/signer/core_signer.rs @@ -2,8 +2,7 @@ use ::signer::Signer; use alloy_primitives::hex; use number_formatter::BigNumberFormatter; use primitives::{ - ChainSigner, HyperliquidOrder, NumberIncrementer, PerpetualConfirmData, PerpetualDirection, PerpetualMarginType, PerpetualModifyConfirmData, PerpetualModifyPositionType, - PerpetualType, SignerError, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, stake_type::StakeType, + ChainSigner, HyperliquidOrder, NumberIncrementer, PerpetualConfirmData, PerpetualDirection, PerpetualModifyConfirmData, PerpetualModifyPositionType, PerpetualType, SignerError, TransactionInputType, TransactionLoadInput, TransactionLoadMetadata, asset_constants::HYPERCORE_CORE_HYPE_TOKEN_ID, stake_type::StakeType, swap::SwapData, }; use serde::Serialize; @@ -114,7 +113,9 @@ impl HyperCoreSigner { let withdraw_action = self.sign_c_withdraw(withdraw_request, private_key)?; Ok(vec![undelegate_action, withdraw_action]) } - _ => Err(SignerError::SigningError("Stake type not supported".to_string())), + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + Err(SignerError::SigningError("Stake type not supported".to_string())) + } } } @@ -216,8 +217,7 @@ impl HyperCoreSigner { let is_buy = data.direction == PerpetualDirection::Long; let asset = data.asset_index as u32; - let is_cross = data.margin_type == PerpetualMarginType::Cross; - let leverage = self.sign_update_leverage(UpdateLeverage::new(asset, is_cross, data.leverage), timestamp_incrementer.next_val(), agent_key)?; + let leverage = self.sign_update_leverage(UpdateLeverage::from_margin_type(asset, &data.margin_type, data.leverage), timestamp_incrementer.next_val(), agent_key)?; let market = self.sign_place_order( make_market_order(asset, is_buy, &data.price, &data.size, false, builder.cloned()), timestamp_incrementer.next_val(), From 3a3d18ae8c2f75e3938c42d77ae0cb451b64c7a0 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:39:26 +0900 Subject: [PATCH 3/5] fix build --- crates/gem_hypercore/src/provider/perpetual.rs | 12 ++++++------ crates/gem_hypercore/src/signer/core_signer.rs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index af77b83c88..0c5f83358e 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -1,4 +1,4 @@ -use std::error::Error; +use std::{error::Error, future::Future}; use async_trait::async_trait; use chain_traits::{ChainAddressStatus, ChainPerpetual}; @@ -46,20 +46,20 @@ impl HyperCoreClient { async fn for_each_dex(&self, f: F) -> Result, Box> where F: Fn(u32, Option) -> Fut, - Fut: std::future::Future>>, + Fut: Future>>, { let dex_entries = self.get_active_dexes().await; let requests: Vec<_> = dex_entries.into_iter().map(|(index, dex)| f(index, dex)).collect(); try_join_all(requests).await } - async fn fetch_positions_for_dex(&self, address: String, dex: Option) -> Result> { + async fn fetch_positions(&self, address: String, dex: Option) -> Result> { let dex = dex.as_deref(); let (positions, orders) = try_join!(self.get_clearinghouse_state(&address, dex), self.get_open_orders(&address, dex))?; Ok(map_positions(positions, address, &orders)) } - async fn fetch_portfolio_for_dex(&self, address: String, dex: Option) -> Result<(PerpetualPortfolio, AssetPositions), Box> { + async fn fetch_portfolio(&self, address: String, dex: Option) -> Result<(PerpetualPortfolio, AssetPositions), Box> { let dex = dex.as_deref(); let (response, positions) = try_join!(self.get_perpetual_portfolio(&address, dex), self.get_clearinghouse_state(&address, dex))?; Ok((map_perpetual_portfolio(response, &positions), positions)) @@ -69,7 +69,7 @@ impl HyperCoreClient { #[async_trait] impl ChainPerpetual for HyperCoreClient { async fn get_positions(&self, address: String) -> Result> { - let summaries = self.for_each_dex(|_, dex| self.fetch_positions_for_dex(address.clone(), dex)).await?; + let summaries = self.for_each_dex(|_, dex| self.fetch_positions(address.clone(), dex)).await?; Ok(merge_positions_summaries(summaries)) } @@ -107,7 +107,7 @@ impl ChainPerpetual for HyperCoreClient { } async fn get_perpetual_portfolio(&self, address: String) -> Result> { - let results = self.for_each_dex(|_, dex| self.fetch_portfolio_for_dex(address.clone(), dex)).await?; + let results = self.for_each_dex(|_, dex| self.fetch_portfolio(address.clone(), dex)).await?; let (portfolios, positions): (Vec<_>, Vec<_>) = results.into_iter().unzip(); let account_summary = Some(map_account_summary_aggregate(&positions)); Ok(merge_perpetual_portfolios(portfolios, account_summary)) diff --git a/crates/gem_hypercore/src/signer/core_signer.rs b/crates/gem_hypercore/src/signer/core_signer.rs index bb9b959a45..18826d4b32 100644 --- a/crates/gem_hypercore/src/signer/core_signer.rs +++ b/crates/gem_hypercore/src/signer/core_signer.rs @@ -113,7 +113,7 @@ impl HyperCoreSigner { let withdraw_action = self.sign_c_withdraw(withdraw_request, private_key)?; Ok(vec![undelegate_action, withdraw_action]) } - StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) | StakeType::Unfreeze(_) => { + StakeType::Redelegate(_) | StakeType::Rewards(_) | StakeType::Withdraw(_) | StakeType::Freeze(_) => { Err(SignerError::SigningError("Stake type not supported".to_string())) } } From fccdc7825a0ad71caa33b8417d038182c2f88103 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:24:04 +0900 Subject: [PATCH 4/5] replace display_name with universe_asset --- crates/gem_hypercore/src/provider/hip3_mapper.rs | 13 ------------- .../gem_hypercore/src/provider/perpetual_mapper.rs | 9 ++++----- 2 files changed, 4 insertions(+), 18 deletions(-) diff --git a/crates/gem_hypercore/src/provider/hip3_mapper.rs b/crates/gem_hypercore/src/provider/hip3_mapper.rs index 81d5cc521e..fdd67a401e 100644 --- a/crates/gem_hypercore/src/provider/hip3_mapper.rs +++ b/crates/gem_hypercore/src/provider/hip3_mapper.rs @@ -19,13 +19,6 @@ pub fn perp_asset_index(perp_dex_index: u32, meta_index: u32) -> u32 { } } -pub fn format_display_name(name: &str) -> String { - match name.split_once(':') { - Some((dex, symbol)) => format!("{symbol} ({dex})"), - None => name.to_string(), - } -} - pub fn map_account_summary_aggregate(positions: &[AssetPositions]) -> PerpetualAccountSummary { let account_value: f64 = positions.iter().map(|p| p.margin_summary.account_value.parse().unwrap_or(0.0)).sum(); let total_ntl_pos: f64 = positions.iter().map(|p| p.margin_summary.total_ntl_pos.parse().unwrap_or(0.0)).sum(); @@ -132,12 +125,6 @@ mod tests { assert_eq!(perp_asset_index(2, 7), 120_007); } - #[test] - fn test_format_display_name() { - assert_eq!(format_display_name("xyz:GOLD"), "GOLD (xyz)"); - assert_eq!(format_display_name("BTC"), "BTC"); - } - #[test] fn test_map_account_summary_aggregate() { let positions = vec![AssetPositions::mock(), AssetPositions::mock()]; diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index b6eb0ade37..0704f668cb 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -14,7 +14,7 @@ use crate::models::{ position::{AssetPositions, LeverageType, Position}, }; -use crate::provider::hip3_mapper::{format_display_name, perp_asset_index}; +use crate::provider::hip3_mapper::perp_asset_index; pub fn create_perpetual_asset_id(coin: &str) -> AssetId { crate::models::metadata::perpetual_asset_id(coin) @@ -114,10 +114,9 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: let open_interest_usd = open_interest_coins * current_price; let perpetual_id = create_perpetual_id(&universe_asset.name); - let display_name = format_display_name(&universe_asset.name); let perpetual = Perpetual { id: perpetual_id, - name: display_name.clone(), + name: universe_asset.name.clone(), provider: PerpetualProvider::Hypercore, asset_id: asset_id.clone(), identifier: asset_index.to_string(), @@ -134,8 +133,8 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: id: asset_id, chain: Chain::HyperCore, token_id: Some(universe_asset.name.clone()), - name: display_name.clone(), - symbol: display_name, + name: universe_asset.name.clone(), + symbol: universe_asset.name.clone(), decimals: universe_asset.sz_decimals, asset_type: AssetType::PERPETUAL, }; From a24d0467b741815338a7ca15c47a2eecffa756c3 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:15:35 +0900 Subject: [PATCH 5/5] reformat builder dex name --- crates/gem_hypercore/src/provider/perpetual_mapper.rs | 9 ++++++--- crates/gem_hypercore/src/provider/websocket_mapper.rs | 8 ++++++-- crates/primitives/src/perpetual.rs | 7 +++++++ crates/storage/src/models/perpetual.rs | 4 ++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index 0704f668cb..2dc19f151d 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -14,6 +14,8 @@ use crate::models::{ position::{AssetPositions, LeverageType, Position}, }; +use primitives::perpetual::format_builder_dex_name; + use crate::provider::hip3_mapper::perp_asset_index; pub fn create_perpetual_asset_id(coin: &str) -> AssetId { @@ -114,9 +116,10 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: let open_interest_usd = open_interest_coins * current_price; let perpetual_id = create_perpetual_id(&universe_asset.name); + let display_name = format_builder_dex_name(&universe_asset.name); let perpetual = Perpetual { id: perpetual_id, - name: universe_asset.name.clone(), + name: display_name.clone(), provider: PerpetualProvider::Hypercore, asset_id: asset_id.clone(), identifier: asset_index.to_string(), @@ -133,8 +136,8 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse, perp_dex_index: id: asset_id, chain: Chain::HyperCore, token_id: Some(universe_asset.name.clone()), - name: universe_asset.name.clone(), - symbol: universe_asset.name.clone(), + name: display_name.clone(), + symbol: display_name, decimals: universe_asset.sz_decimals, asset_type: AssetType::PERPETUAL, }; diff --git a/crates/gem_hypercore/src/provider/websocket_mapper.rs b/crates/gem_hypercore/src/provider/websocket_mapper.rs index b49fc1d5d2..e204be73a1 100644 --- a/crates/gem_hypercore/src/provider/websocket_mapper.rs +++ b/crates/gem_hypercore/src/provider/websocket_mapper.rs @@ -1,6 +1,6 @@ use std::collections::{HashMap, HashSet}; -use primitives::{AssetId, PerpetualPosition}; +use primitives::{AssetId, PerpetualPosition, perpetual::format_builder_dex_name}; use crate::models::{ order::OpenOrder, @@ -23,7 +23,11 @@ pub fn parse_websocket_data(data: &[u8]) -> Result Ok(HyperliquidSocketMessage::OpenOrders { orders: data.orders }), RawSocketMessage::Candle(candlestick) => Ok(HyperliquidSocketMessage::Candle { candle: candlestick.into() }), RawSocketMessage::AllMids(data) => { - let prices = data.mids.into_iter().filter_map(|(k, v)| v.parse::().ok().map(|p| (k, p))).collect(); + let prices = data + .mids + .into_iter() + .filter_map(|(k, v)| v.parse::().ok().map(|p| (format_builder_dex_name(&k), p))) + .collect(); Ok(HyperliquidSocketMessage::AllMids { prices }) } RawSocketMessage::SubscriptionResponse(data) => Ok(HyperliquidSocketMessage::SubscriptionResponse { diff --git a/crates/primitives/src/perpetual.rs b/crates/primitives/src/perpetual.rs index 8b4a9e0d02..f0c2e441b5 100644 --- a/crates/primitives/src/perpetual.rs +++ b/crates/primitives/src/perpetual.rs @@ -1,4 +1,11 @@ use crate::{Asset, AssetId, PerpetualMarginType, PerpetualPosition, PerpetualProvider, UInt64}; + +pub fn format_builder_dex_name(name: &str) -> String { + match name.split_once(':') { + Some((dex, symbol)) => format!("{symbol} ({dex})"), + None => name.to_string(), + } +} use serde::{Deserialize, Serialize}; use strum::{AsRefStr, EnumString}; use typeshare::typeshare; diff --git a/crates/storage/src/models/perpetual.rs b/crates/storage/src/models/perpetual.rs index dd53845877..2bd17191f9 100644 --- a/crates/storage/src/models/perpetual.rs +++ b/crates/storage/src/models/perpetual.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use diesel::prelude::*; use primitives::{ AssetId as PrimitiveAssetId, - perpetual::{Perpetual as PrimitivePerpetual, PerpetualBasic}, + perpetual::{Perpetual as PrimitivePerpetual, PerpetualBasic, format_builder_dex_name}, }; use serde::{Deserialize, Serialize}; @@ -82,7 +82,7 @@ impl PerpetualRow { pub fn as_primitive(&self) -> PrimitivePerpetual { PrimitivePerpetual { id: self.id.clone(), - name: self.name.clone(), + name: format_builder_dex_name(&self.name), provider: self.provider.0.clone(), asset_id: self.asset_id.0.clone(), identifier: self.identifier.clone(),