From 4603674b363a2b3dec0c08661ce1627eaf4a7e0f Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:46:27 +0900 Subject: [PATCH 1/6] feat(hypercore): add HIP-3 perps support --- crates/gem_hypercore/src/models/mod.rs | 1 + crates/gem_hypercore/src/models/perp_dex.rs | 12 ++ .../gem_hypercore/src/provider/perpetual.rs | 128 +++++++++++++++- .../src/provider/perpetual_mapper.rs | 143 +++++++++++++++++- crates/gem_hypercore/src/rpc/client.rs | 62 ++++++-- 5 files changed, 327 insertions(+), 19 deletions(-) create mode 100644 crates/gem_hypercore/src/models/perp_dex.rs diff --git a/crates/gem_hypercore/src/models/mod.rs b/crates/gem_hypercore/src/models/mod.rs index 1eeb3ce3d..a450ac98a 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 000000000..cb02d75c7 --- /dev/null +++ b/crates/gem_hypercore/src/models/perp_dex.rs @@ -0,0 +1,12 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PerpDex { + pub name: Option, + pub full_name: Option, + pub deployer: Option, + pub oracle_updater: Option, + pub chain_id: Option, + pub is_active: Option, +} diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index 0c92e0fa8..c1fbee2e7 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -2,30 +2,115 @@ 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, chart::ChartCandleStick, - perpetual::{PerpetualData, PerpetualPositionsSummary}, + perpetual::{PerpetualBalance, PerpetualData, PerpetualPositionsSummary}, portfolio::PerpetualPortfolio, }; use crate::{ - provider::perpetual_mapper::{map_candlesticks, map_perpetual_portfolio, map_perpetuals_data, map_positions}, + models::position::AssetPositions, + provider::perpetual_mapper::{map_account_summary_aggregate, map_candlesticks, map_perpetual_portfolio, map_perpetuals_data, map_positions, merge_perpetual_portfolios}, rpc::client::HyperCoreClient, }; +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))?; + 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 perp_dexs = self.get_perp_dexs().await; + if let Ok(perp_dexs) = perp_dexs { + let mut requests = Vec::new(); + for (index, entry) in perp_dexs.iter().enumerate() { + if index == 0 { + requests.push(self.fetch_positions_for_dex(address.clone(), None)); + continue; + } + + let dex = entry + .as_ref() + .and_then(|dex| dex.name.as_ref()) + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()); + + if let Some(dex) = dex { + requests.push(self.fetch_positions_for_dex(address.clone(), Some(dex))); + } + } + + if !requests.is_empty() { + let summaries = try_join_all(requests).await?; + let mut positions = Vec::new(); + let mut balance = PerpetualBalance { + available: 0.0, + reserved: 0.0, + withdrawable: 0.0, + }; + for summary in summaries { + positions.extend(summary.positions); + balance.available += summary.balance.available; + balance.reserved += summary.balance.reserved; + balance.withdrawable += summary.balance.withdrawable; + } + return Ok(PerpetualPositionsSummary { positions, balance }); + } + } + let (positions, orders) = try_join!(self.get_clearinghouse_state(&address), self.get_open_orders(&address))?; Ok(map_positions(positions, address, &orders)) } async fn get_perpetuals_data(&self) -> Result, Box> { + let perp_dexs = self.get_perp_dexs().await; + if let Ok(perp_dexs) = perp_dexs { + let mut requests = Vec::new(); + let mut dex_indexes = Vec::new(); + + for (index, entry) in perp_dexs.iter().enumerate() { + if index == 0 { + requests.push(self.get_metadata_with_dex(None)); + dex_indexes.push(index as u32); + continue; + } + + let name = entry.as_ref().and_then(|dex| dex.name.as_ref()); + if let Some(name) = name + && !name.is_empty() + { + requests.push(self.get_metadata_with_dex(Some(name.to_string()))); + dex_indexes.push(index as u32); + } + } + + if !requests.is_empty() { + let metadata = try_join_all(requests).await?; + let mut result = Vec::new(); + for (dex_index, meta) in dex_indexes.into_iter().zip(metadata) { + result.extend(map_perpetuals_data(meta, dex_index)); + } + return Ok(result); + } + } + let metadata = self.get_metadata().await?; - Ok(map_perpetuals_data(metadata)) + Ok(map_perpetuals_data(metadata, 0)) } async fn get_perpetual_candlesticks(&self, symbol: String, period: ChartPeriod) -> Result, Box> { @@ -53,6 +138,41 @@ impl ChainPerpetual for HyperCoreClient { } async fn get_perpetual_portfolio(&self, address: String) -> Result> { + let perp_dexs = self.get_perp_dexs().await; + if let Ok(perp_dexs) = perp_dexs { + let mut requests = Vec::new(); + + for (index, entry) in perp_dexs.iter().enumerate() { + if index == 0 { + requests.push(self.fetch_portfolio_for_dex(address.clone(), None)); + continue; + } + + let dex = entry + .as_ref() + .and_then(|dex| dex.name.as_ref()) + .map(|name| name.to_string()) + .filter(|name| !name.is_empty()); + + if let Some(dex) = dex { + requests.push(self.fetch_portfolio_for_dex(address.clone(), Some(dex))); + } + } + + if !requests.is_empty() { + let results = try_join_all(requests).await?; + let mut portfolios = Vec::new(); + let mut positions = Vec::new(); + for (portfolio, asset_positions) in results { + portfolios.push(portfolio); + positions.push(asset_positions); + } + + let account_summary = Some(map_account_summary_aggregate(&positions)); + return Ok(merge_perpetual_portfolios(portfolios, account_summary)); + } + } + let (response, positions) = try_join!(self.get_perpetual_portfolio(&address), self.get_clearinghouse_state(&address))?; Ok(map_perpetual_portfolio(response, &positions)) } diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index 73acc9871..ef3cf7838 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -8,10 +8,14 @@ use crate::models::{ use primitives::{ Asset, AssetId, AssetType, Chain, Perpetual, PerpetualBalance, PerpetualDirection, PerpetualMarginType, PerpetualOrderType, PerpetualPosition, PerpetualProvider, PerpetualTriggerOrder, - chart::ChartCandleStick, + chart::{ChartCandleStick, ChartDateValue}, perpetual::{PerpetualData, PerpetualMetadata, PerpetualPositionsSummary}, - portfolio::{PerpetualAccountSummary, PerpetualPortfolio}, + portfolio::{PerpetualAccountSummary, PerpetualPortfolio, PerpetualPortfolioTimeframeData}, }; +use std::collections::BTreeMap; + +const HIP3_PERP_ASSET_OFFSET: u32 = 100_000; +const HIP3_PERP_ASSET_STRIDE: u32 = 10_000; pub fn create_perpetual_asset_id(coin: &str) -> AssetId { crate::models::metadata::perpetual_asset_id(coin) @@ -83,7 +87,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 +99,7 @@ pub fn map_perpetuals_data(metadata: HypercoreMetadataResponse) -> Vec Vec Vec u32 { + if perp_dex_index == 0 { + meta_index + } else { + HIP3_PERP_ASSET_OFFSET + perp_dex_index * HIP3_PERP_ASSET_STRIDE + meta_index + } +} + pub fn map_candlesticks(candlesticks: Vec) -> Vec { candlesticks.iter().map(ChartCandleStick::from).collect() } @@ -163,6 +176,32 @@ pub fn map_account_summary(positions: &AssetPositions) -> PerpetualAccountSummar } } +pub fn map_account_summary_aggregate(positions: &[AssetPositions]) -> PerpetualAccountSummary { + let mut account_value = 0.0; + let mut total_ntl_pos = 0.0; + let mut total_margin_used = 0.0; + let mut unrealized_pnl = 0.0; + + for positions in positions { + account_value += positions.margin_summary.account_value.parse::().unwrap_or(0.0); + total_ntl_pos += positions.margin_summary.total_ntl_pos.parse::().unwrap_or(0.0); + total_margin_used += positions.margin_summary.total_margin_used.parse::().unwrap_or(0.0); + for position in &positions.asset_positions { + unrealized_pnl += position.position.unrealized_pnl.parse::().unwrap_or(0.0); + } + } + + 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 map_perpetual_portfolio(response: HypercorePortfolioResponse, positions: &AssetPositions) -> PerpetualPortfolio { let (day, week, month, all_time) = response .timeframes @@ -184,6 +223,70 @@ pub fn map_perpetual_portfolio(response: HypercorePortfolioResponse, positions: } } +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 { + if let Some(value) = portfolio.day { + day.push(value); + } + if let Some(value) = portfolio.week { + week.push(value); + } + if let Some(value) = portfolio.month { + month.push(value); + } + if let Some(value) = portfolio.all_time { + all_time.push(value); + } + } + + 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() +} + fn determine_order_type(order_type_str: &str) -> PerpetualOrderType { if order_type_str.to_lowercase().contains("market") { PerpetualOrderType::Market @@ -308,7 +411,7 @@ mod tests { }]; let metadata_response = HypercoreMetadataResponse(universe_response, asset_metadata); - let result = map_perpetuals_data(metadata_response); + let result = map_perpetuals_data(metadata_response, 0); assert_eq!(result.len(), 1); @@ -327,6 +430,36 @@ mod tests { assert_eq!(eth_data.asset.id.to_string(), "hypercore_perpetual::ETH"); } + #[test] + fn test_map_perpetuals_data_builder_asset_index() { + let universe_response = HypercoreUniverseResponse { + universe: vec![UniverseAsset { + name: "FOO".to_string(), + sz_decimals: 1, + max_leverage: 10, + only_isolated: Some(false), + }], + }; + + let asset_metadata = vec![AssetMetadata { + 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(), + }]; + + let metadata_response = HypercoreMetadataResponse(universe_response, asset_metadata); + let result = map_perpetuals_data(metadata_response, 2); + + assert_eq!(result[0].perpetual.identifier, "120000"); + } + #[test] fn test_map_candlesticks() { use crate::models::candlestick::Candlestick; diff --git a/crates/gem_hypercore/src/rpc/client.rs b/crates/gem_hypercore/src/rpc/client.rs index f4648e4d3..232d012a0 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, PerpetualFill}, + perp_dex::PerpDex, portfolio::HypercorePortfolioResponse, position::AssetPositions, referral::Referral, @@ -149,15 +150,38 @@ impl HyperCoreClient { } pub async fn get_clearinghouse_state(&self, user: &str) -> Result> { - self.info(json!({ + self.get_clearinghouse_state_with_dex(user, None).await + } + + pub async fn get_clearinghouse_state_with_dex(&self, user: &str, dex: Option) -> Result> { + let mut payload = json!({ "type": "clearinghouseState", "user": user - })) - .await + }); + if let Some(dex) = dex + && !dex.is_empty() + { + payload["dex"] = json!(dex); + } + self.info(payload).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> { + let mut payload = json!({"type": "metaAndAssetCtxs"}); + if let Some(dex) = dex + && !dex.is_empty() + { + payload["dex"] = json!(dex); + } + self.info(payload).await + } + + pub async fn get_perp_dexs(&self) -> Result>, Box> { + self.info(json!({"type": "perpDexs"})).await } pub async fn get_spot_meta(&self) -> Result> { @@ -244,19 +268,37 @@ impl HyperCoreClient { } pub async fn get_open_orders(&self, user: &str) -> Result, Box> { - self.info(json!({ + self.get_open_orders_with_dex(user, None).await + } + + pub async fn get_open_orders_with_dex(&self, user: &str, dex: Option) -> Result, Box> { + let mut payload = json!({ "type": "frontendOpenOrders", "user": user - })) - .await + }); + if let Some(dex) = dex + && !dex.is_empty() + { + payload["dex"] = json!(dex); + } + self.info(payload).await } pub async fn get_perpetual_portfolio(&self, user: &str) -> Result> { - self.info(json!({ + self.get_perpetual_portfolio_with_dex(user, None).await + } + + pub async fn get_perpetual_portfolio_with_dex(&self, user: &str, dex: Option) -> Result> { + let mut payload = json!({ "type": "portfolio", "user": user - })) - .await + }); + if let Some(dex) = dex + && !dex.is_empty() + { + payload["dex"] = json!(dex); + } + self.info(payload).await } } From c6b2f055f399ca542d2935ff4543cb3fa418900d Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Wed, 4 Feb 2026 21:53:40 +0900 Subject: [PATCH 2/6] chore: apply review suggestions --- crates/gem_hypercore/src/provider/perpetual.rs | 7 +------ .../src/provider/perpetual_mapper.rs | 16 ++++------------ 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index c1fbee2e7..c67f9a11c 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -161,12 +161,7 @@ impl ChainPerpetual for HyperCoreClient { if !requests.is_empty() { let results = try_join_all(requests).await?; - let mut portfolios = Vec::new(); - let mut positions = Vec::new(); - for (portfolio, asset_positions) in results { - portfolios.push(portfolio); - positions.push(asset_positions); - } + let (portfolios, positions): (Vec<_>, Vec<_>) = results.into_iter().unzip(); let account_summary = Some(map_account_summary_aggregate(&positions)); return Ok(merge_perpetual_portfolios(portfolios, account_summary)); diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index ef3cf7838..251aa1e3a 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -230,18 +230,10 @@ pub fn merge_perpetual_portfolios(portfolios: Vec, account_s let mut all_time = Vec::new(); for portfolio in portfolios { - if let Some(value) = portfolio.day { - day.push(value); - } - if let Some(value) = portfolio.week { - week.push(value); - } - if let Some(value) = portfolio.month { - month.push(value); - } - if let Some(value) = portfolio.all_time { - all_time.push(value); - } + day.extend(portfolio.day); + week.extend(portfolio.week); + month.extend(portfolio.month); + all_time.extend(portfolio.all_time); } PerpetualPortfolio { From dff178179cccff02ef2bb4184dc13688a627d7d8 Mon Sep 17 00:00:00 2001 From: 0xh3rman <119309671+0xh3rman@users.noreply.github.com> Date: Fri, 13 Mar 2026 12:01:32 +0900 Subject: [PATCH 3/6] refactor(hypercore): improve HIP-3 perps implementation Extract active_dex_entries helper to deduplicate DEX iteration across get_positions, get_perpetuals_data, and get_perpetual_portfolio. Add is_active filtering to skip inactive builder DEXs. Add unit tests for merge functions, perp_asset_index, and integration tests for new RPC endpoints. --- .../gem_hypercore/src/provider/perpetual.rs | 278 +++++++++++------- .../src/provider/perpetual_mapper.rs | 101 +++++++ 2 files changed, 275 insertions(+), 104 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index c67f9a11c..80960d171 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -12,12 +12,34 @@ use primitives::{ }; use crate::{ - models::position::AssetPositions, + models::{perp_dex::PerpDex, position::AssetPositions}, provider::perpetual_mapper::{map_account_summary_aggregate, map_candlesticks, map_perpetual_portfolio, map_perpetuals_data, map_positions, merge_perpetual_portfolios}, rpc::client::HyperCoreClient, }; +fn active_dex_entries(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) { + return None; + } + let name = dex.name.as_ref().filter(|n| !n.is_empty())?.to_string(); + Some((index as u32, Some(name))) + }) + .collect() +} + impl HyperCoreClient { + async fn get_active_dex_entries(&self) -> Vec<(u32, Option)> { + self.get_perp_dexs().await.map(|dexs| active_dex_entries(&dexs)).unwrap_or_else(|_| vec![(0, None)]) + } + 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)) @@ -35,82 +57,31 @@ impl HyperCoreClient { #[async_trait] impl ChainPerpetual for HyperCoreClient { async fn get_positions(&self, address: String) -> Result> { - let perp_dexs = self.get_perp_dexs().await; - if let Ok(perp_dexs) = perp_dexs { - let mut requests = Vec::new(); - for (index, entry) in perp_dexs.iter().enumerate() { - if index == 0 { - requests.push(self.fetch_positions_for_dex(address.clone(), None)); - continue; - } - - let dex = entry - .as_ref() - .and_then(|dex| dex.name.as_ref()) - .map(|name| name.to_string()) - .filter(|name| !name.is_empty()); - - if let Some(dex) = dex { - requests.push(self.fetch_positions_for_dex(address.clone(), Some(dex))); - } - } - - if !requests.is_empty() { - let summaries = try_join_all(requests).await?; - let mut positions = Vec::new(); - let mut balance = PerpetualBalance { - available: 0.0, - reserved: 0.0, - withdrawable: 0.0, - }; - for summary in summaries { - positions.extend(summary.positions); - balance.available += summary.balance.available; - balance.reserved += summary.balance.reserved; - balance.withdrawable += summary.balance.withdrawable; - } - return Ok(PerpetualPositionsSummary { positions, balance }); - } + let dex_entries = self.get_active_dex_entries().await; + let requests: Vec<_> = dex_entries.iter().map(|(_, dex)| self.fetch_positions_for_dex(address.clone(), dex.clone())).collect(); + let summaries = try_join_all(requests).await?; + + let mut positions = Vec::new(); + let mut balance = PerpetualBalance { + available: 0.0, + reserved: 0.0, + withdrawable: 0.0, + }; + for summary in summaries { + positions.extend(summary.positions); + balance.available += summary.balance.available; + balance.reserved += summary.balance.reserved; + balance.withdrawable += summary.balance.withdrawable; } - - let (positions, orders) = try_join!(self.get_clearinghouse_state(&address), self.get_open_orders(&address))?; - Ok(map_positions(positions, address, &orders)) + Ok(PerpetualPositionsSummary { positions, balance }) } async fn get_perpetuals_data(&self) -> Result, Box> { - let perp_dexs = self.get_perp_dexs().await; - if let Ok(perp_dexs) = perp_dexs { - let mut requests = Vec::new(); - let mut dex_indexes = Vec::new(); - - for (index, entry) in perp_dexs.iter().enumerate() { - if index == 0 { - requests.push(self.get_metadata_with_dex(None)); - dex_indexes.push(index as u32); - continue; - } - - let name = entry.as_ref().and_then(|dex| dex.name.as_ref()); - if let Some(name) = name - && !name.is_empty() - { - requests.push(self.get_metadata_with_dex(Some(name.to_string()))); - dex_indexes.push(index as u32); - } - } + let dex_entries = self.get_active_dex_entries().await; + let requests: Vec<_> = dex_entries.iter().map(|(_, dex)| self.get_metadata_with_dex(dex.clone())).collect(); + let metadata = try_join_all(requests).await?; - if !requests.is_empty() { - let metadata = try_join_all(requests).await?; - let mut result = Vec::new(); - for (dex_index, meta) in dex_indexes.into_iter().zip(metadata) { - result.extend(map_perpetuals_data(meta, dex_index)); - } - return Ok(result); - } - } - - let metadata = self.get_metadata().await?; - Ok(map_perpetuals_data(metadata, 0)) + Ok(dex_entries.iter().zip(metadata).flat_map(|((index, _), meta)| map_perpetuals_data(meta, *index)).collect()) } async fn get_perpetual_candlesticks(&self, symbol: String, period: ChartPeriod) -> Result, Box> { @@ -138,50 +109,149 @@ impl ChainPerpetual for HyperCoreClient { } async fn get_perpetual_portfolio(&self, address: String) -> Result> { - let perp_dexs = self.get_perp_dexs().await; - if let Ok(perp_dexs) = perp_dexs { - let mut requests = Vec::new(); - - for (index, entry) in perp_dexs.iter().enumerate() { - if index == 0 { - requests.push(self.fetch_portfolio_for_dex(address.clone(), None)); - continue; - } - - let dex = entry - .as_ref() - .and_then(|dex| dex.name.as_ref()) - .map(|name| name.to_string()) - .filter(|name| !name.is_empty()); - - if let Some(dex) = dex { - requests.push(self.fetch_portfolio_for_dex(address.clone(), Some(dex))); - } - } - - if !requests.is_empty() { - let results = try_join_all(requests).await?; - let (portfolios, positions): (Vec<_>, Vec<_>) = results.into_iter().unzip(); - - let account_summary = Some(map_account_summary_aggregate(&positions)); - return Ok(merge_perpetual_portfolios(portfolios, account_summary)); - } - } - - let (response, positions) = try_join!(self.get_perpetual_portfolio(&address), self.get_clearinghouse_state(&address))?; - Ok(map_perpetual_portfolio(response, &positions)) + let dex_entries = self.get_active_dex_entries().await; + let requests: Vec<_> = dex_entries.iter().map(|(_, dex)| self.fetch_portfolio_for_dex(address.clone(), dex.clone())).collect(); + let results = try_join_all(requests).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_trait] impl ChainAddressStatus for HyperCoreClient {} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_active_dex_entries_filters_inactive() { + let dexs = vec![ + None, // index 0: main DEX (always included) + Some(PerpDex { + name: Some("dex1".to_string()), + full_name: None, + deployer: None, + oracle_updater: None, + chain_id: None, + is_active: Some(true), + }), + Some(PerpDex { + name: Some("dex2".to_string()), + full_name: None, + deployer: None, + oracle_updater: None, + chain_id: None, + is_active: Some(false), + }), + Some(PerpDex { + name: Some("dex3".to_string()), + full_name: None, + deployer: None, + oracle_updater: None, + chain_id: None, + is_active: None, + }), + ]; + + let entries = active_dex_entries(&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_active_dex_entries_skips_empty_names() { + let dexs = vec![ + None, + Some(PerpDex { + name: Some("".to_string()), + full_name: None, + deployer: None, + oracle_updater: None, + chain_id: None, + is_active: Some(true), + }), + Some(PerpDex { + name: None, + full_name: None, + deployer: None, + oracle_updater: None, + chain_id: None, + is_active: Some(true), + }), + ]; + + let entries = active_dex_entries(&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 251aa1e3a..4bea2541f 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -805,4 +805,105 @@ mod tests { assert_eq!(summary.margin_usage, 0.2); assert_eq!(summary.unrealized_pnl, 0.0); } + + #[test] + fn test_map_account_summary_aggregate() { + use crate::testkit::*; + + 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_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_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 { + day: Some(PerpetualPortfolioTimeframeData { + account_value_history: vec![ChartDateValue { date: d1, value: 100.0 }], + pnl_history: vec![ChartDateValue { date: d1, value: 10.0 }], + volume: 500.0, + }), + week: None, + month: None, + all_time: None, + account_summary: None, + }, + PerpetualPortfolio { + day: Some(PerpetualPortfolioTimeframeData { + account_value_history: vec![ChartDateValue { date: d1, value: 200.0 }], + pnl_history: vec![ChartDateValue { date: d1, value: 20.0 }], + volume: 300.0, + }), + week: None, + month: None, + all_time: None, + account_summary: None, + }, + ]; + + 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()); + + let summary = merged.account_summary.unwrap(); + assert_eq!(summary.account_value, 1000.0); + } + + #[test] + fn test_merge_portfolio_timeframes_empty() { + let result = merge_portfolio_timeframes(vec![]); + assert!(result.is_none()); + } } From 7da2811eeffa8778db25c49604f692de0c0adfe0 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:15:24 +0000 Subject: [PATCH 4/6] Refactor `map_account_summary_aggregate` to use idiomatic iterator methods (#1012) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> --- .../src/provider/perpetual_mapper.rs | 21 +++++++------------ 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual_mapper.rs b/crates/gem_hypercore/src/provider/perpetual_mapper.rs index 4bea2541f..8de6ddd5b 100644 --- a/crates/gem_hypercore/src/provider/perpetual_mapper.rs +++ b/crates/gem_hypercore/src/provider/perpetual_mapper.rs @@ -177,19 +177,14 @@ pub fn map_account_summary(positions: &AssetPositions) -> PerpetualAccountSummar } pub fn map_account_summary_aggregate(positions: &[AssetPositions]) -> PerpetualAccountSummary { - let mut account_value = 0.0; - let mut total_ntl_pos = 0.0; - let mut total_margin_used = 0.0; - let mut unrealized_pnl = 0.0; - - for positions in positions { - account_value += positions.margin_summary.account_value.parse::().unwrap_or(0.0); - total_ntl_pos += positions.margin_summary.total_ntl_pos.parse::().unwrap_or(0.0); - total_margin_used += positions.margin_summary.total_margin_used.parse::().unwrap_or(0.0); - for position in &positions.asset_positions { - unrealized_pnl += position.position.unrealized_pnl.parse::().unwrap_or(0.0); - } - } + 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 }; From b064ba2ebd44262dd5fc2d2f79c4aa514b1688c7 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:15:37 +0000 Subject: [PATCH 5/6] Fix multiline `if let` chains in gem_hypercore client (#1014) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> --- crates/gem_hypercore/src/rpc/client.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/crates/gem_hypercore/src/rpc/client.rs b/crates/gem_hypercore/src/rpc/client.rs index 232d012a0..fa98fe68e 100644 --- a/crates/gem_hypercore/src/rpc/client.rs +++ b/crates/gem_hypercore/src/rpc/client.rs @@ -158,9 +158,7 @@ impl HyperCoreClient { "type": "clearinghouseState", "user": user }); - if let Some(dex) = dex - && !dex.is_empty() - { + if let Some(dex) = dex && !dex.is_empty() { payload["dex"] = json!(dex); } self.info(payload).await @@ -172,9 +170,7 @@ impl HyperCoreClient { pub async fn get_metadata_with_dex(&self, dex: Option) -> Result> { let mut payload = json!({"type": "metaAndAssetCtxs"}); - if let Some(dex) = dex - && !dex.is_empty() - { + if let Some(dex) = dex && !dex.is_empty() { payload["dex"] = json!(dex); } self.info(payload).await @@ -276,9 +272,7 @@ impl HyperCoreClient { "type": "frontendOpenOrders", "user": user }); - if let Some(dex) = dex - && !dex.is_empty() - { + if let Some(dex) = dex && !dex.is_empty() { payload["dex"] = json!(dex); } self.info(payload).await @@ -293,9 +287,7 @@ impl HyperCoreClient { "type": "portfolio", "user": user }); - if let Some(dex) = dex - && !dex.is_empty() - { + if let Some(dex) = dex && !dex.is_empty() { payload["dex"] = json!(dex); } self.info(payload).await From 9d722edfad22140e07568efa95e2605967ee305f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:15:49 +0000 Subject: [PATCH 6/6] refactor(gem_hypercore): use fold for position aggregation in get_positions (#1013) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gemcoder21 <104884878+gemcoder21@users.noreply.github.com> --- .../gem_hypercore/src/provider/perpetual.rs | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/crates/gem_hypercore/src/provider/perpetual.rs b/crates/gem_hypercore/src/provider/perpetual.rs index 80960d171..ebb9add80 100644 --- a/crates/gem_hypercore/src/provider/perpetual.rs +++ b/crates/gem_hypercore/src/provider/perpetual.rs @@ -61,18 +61,16 @@ impl ChainPerpetual for HyperCoreClient { let requests: Vec<_> = dex_entries.iter().map(|(_, dex)| self.fetch_positions_for_dex(address.clone(), dex.clone())).collect(); let summaries = try_join_all(requests).await?; - let mut positions = Vec::new(); - let mut balance = PerpetualBalance { - available: 0.0, - reserved: 0.0, - withdrawable: 0.0, - }; - for summary in summaries { - positions.extend(summary.positions); - balance.available += summary.balance.available; - balance.reserved += summary.balance.reserved; - balance.withdrawable += summary.balance.withdrawable; - } + 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) + }, + ); Ok(PerpetualPositionsSummary { positions, balance }) }