Skip to content

Commit 9d2cc3b

Browse files
committed
Refactor Info and WebsocketManager for improved subscription handling and metadata loading
- Enhanced Info class to include separate methods for populating perpetual and spot metadata. - Introduced remapping of subscription identifiers in the WebsocketManager to ensure consistency. - Updated canonical_coin and name_to_asset methods to utilize new metadata structures. - Added error handling for float serialization in signing utilities to prevent rounding issues. - Improved unit tests for Info and WebsocketManager to cover new functionality and edge cases. - Introduced Mock classes for Info and Exchange to facilitate testing without external dependencies. - Refactored websocket message handling to align with Python SDK semantics for identifier mapping.
1 parent f6af2bb commit 9d2cc3b

15 files changed

Lines changed: 883 additions & 339 deletions

CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [0.1.2] - 2026-06-09
1111

12+
### Added
13+
- **`Exchange`: API-wallet account selection support** — added a constructor overload with optional `account_address` so authenticated flows can query the effective trading account separately from the signing key.
14+
- **`Info`: canonical asset lookup helpers** — added `name_to_coin`, `name_to_asset`, `asset_to_sz_decimals`, and `canonical_coin()` to resolve human-readable market names to Hyperliquid wire identifiers and asset ids.
15+
- **`WebsocketManager::message_to_identifier()`** — explicit inbound message routing helper for channel-to-handler lookup.
16+
- **Offline exchange parity tests** — added `tests/test_exchange.cpp` covering spot/perp mapping, payload shapes, price rounding, and effective-account selection.
17+
1218
### Fixed
1319
- **`WebsocketManager`: use-after-free / same-dispatch unsubscribe race** — callbacks now dispatch from a lock-free snapshot of shared handler state instead of copying `std::function`s under a mutex. Each callback entry carries an `active` flag and `in_flight` counter: `unsubscribe()` removes the callback from future snapshots, marks that specific callback inactive, and waits on its per-callback in-flight count with C++20 atomic wait/notify. This prevents a later callback from firing after being removed earlier in the same snapshot while still allowing self-unsubscribe without deadlocking.
20+
- **`updateIsolatedMargin` payload shape** — exchange requests now send the documented `ntli` field with `isBuy: true` instead of the previous mismatched parameters.
21+
- **`usdClassTransfer` formatting** — the signed action now uses the SDK-compatible `"amount"` string and appends `" subaccount:<address>"` when trading through a vault.
22+
- **Effective account selection for `market_close()`** — account state is now queried against `vault_address`, then `account_address`, then the signer wallet, matching Hyperliquid API-wallet semantics.
23+
- **Vault handling for user-signed transfers** — user-signed exchange payloads now include or omit `vaultAddress` consistently with the current Python SDK behavior.
24+
- **`basic_order.cpp` default credentials** — the example now uses the Hardhat private key instead of the Hardhat address.
1425

1526
### Changed
1627
- **`WebsocketManager`: removed `pending_subs_` pre-connection queue** — the underlying `slick::net::Websocket` already buffers outbound frames until the connection is established, making the manual pending-subscription queue redundant. `subscribe()` and `unsubscribe()` now call `ws_->send()` unconditionally; `flush_pending()`, `writer_mutex_`, and the `pending_subs_` vector are all removed.
28+
- **Metadata loading now matches the Hyperliquid SDK/docs** — perp assets use `meta` indices, spot assets use `10000 + spotMeta.index`, and spot aliases such as `PURR/USDC` and `@<index>` resolve to canonical wire coins.
29+
- **REST and WebSocket market-data requests now canonicalize coin names** before sending requests, so spot and perp subscriptions/queries use documented wire names instead of assuming perpetual-only symbols.
30+
- **Exchange precision handling was tightened to SDK behavior**`float_to_wire()` and `float_to_usd_int()` now reject silent rounding, and market-order slippage pricing now applies 5 significant figures with max decimals derived from `szDecimals`.
31+
- **WebSocket routing was rebuilt around explicit message identifiers**`trades`, `candle`, `activeAssetCtx`, `activeSpotAssetCtx`, `userEvents`, and `orderUpdates` now route using the actual inbound payload shape, with a 50-second heartbeat under the documented idle timeout.
32+
- **Internal identifier normalization is narrower** — coin-bearing websocket identifiers now preserve canonical coin casing, while address-bearing identifiers still lowercase the user/address portion for stable routing.
33+
- **Docs and examples were refreshed**`README.md` now documents the expanded asset lookup maps, `account_address`, and the updated `update_isolated_margin(coin, amount)` signature.
1734

1835
### Tests
1936
- **`UnsubscribeBlocksUntilCallbackCompletes`** (integration) — regression test for the above fix; subscribes with a callback that blocks until explicitly released, calls `unsubscribe()` concurrently from a second thread, and asserts that `unsubscribe()` does not return before the in-flight callback has finished.
2037
- **`CallbackCanRemoveLaterCallbackFromSameDispatch`** (integration) — regression test for the snapshot hazard where callback A unsubscribes callback B on the same channel before the dispatcher reaches B in the already-loaded snapshot.
38+
- **Signing regressions** — added tests asserting that `float_to_wire()` and `float_to_usd_int()` throw when serialization would require rounding.
39+
- **WebSocket routing regressions** — extended channel-identifier tests to cover inbound routing, snapshot-safe channel naming, spot/perp active-asset messages, address normalization, and preserved coin casing.
2140

2241
## [0.1.1] - 2026-06-08
2342

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ if(HYPERLIQUID_BUILD_TESTS)
6464
tests/test_keccak.cpp
6565
tests/test_types.cpp
6666
tests/test_signing.cpp
67+
tests/test_exchange.cpp
6768
tests/test_websocket_utils.cpp
6869
)
6970

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,9 +232,10 @@ Candle `interval` values: `"1m"` `"5m"` `"15m"` `"30m"` `"1h"` `"4h"` `"8h"` `"1
232232
|--------|-------------|
233233
| `subscribe(sub_json, callback)` | Subscribe to a channel; returns `subscription_id` (positive int) |
234234
| `unsubscribe(sub_json, subscription_id)` | Remove one callback; double-unsubscribe is a no-op |
235-
| `load_meta()` | Populate `coin_to_asset` (called automatically by `Exchange`) |
235+
| `load_meta()` | Populate `coin_to_asset`, `name_to_coin`, and `asset_to_sz_decimals` (called automatically by `Exchange`) |
236236

237-
`coin_to_asset` is a public `std::unordered_map<std::string, int>` mapping coin names to 0-based asset indices.
237+
`coin_to_asset` maps canonical wire coins to asset ids. Perps are 0-based; spot assets use `10000 + spot index`.
238+
`name_to_coin` maps human-readable names such as `PURR/USDC` back to canonical wire coins.
238239

239240
---
240241

@@ -247,7 +248,8 @@ hyperliquid::Exchange exchange(
247248
private_key_hex, // "0x..." or bare 64 hex chars
248249
base_url, // MAINNET_API_URL or TESTNET_API_URL
249250
info, // shared_ptr<Info>
250-
vault_address); // optional sub-account address
251+
vault_address, // optional sub-account address
252+
account_address); // optional account queried when using an API wallet
251253
```
252254
253255
The constructor derives `wallet_address()` from the private key and calls `info->load_meta()`.
@@ -285,7 +287,7 @@ All methods return `nlohmann::json` with the raw Hyperliquid API response.
285287
| Method | Description |
286288
|--------|-------------|
287289
| `update_leverage(coin, is_cross, leverage)` | Set cross or isolated leverage |
288-
| `update_isolated_margin(coin, is_buy, ntl)` | Add/remove USD from an isolated position |
290+
| `update_isolated_margin(coin, amount)` | Add/remove USD from an isolated position |
289291
290292
### Transfers (EIP-712 user-signed)
291293

examples/basic_order.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#include <string>
88

99
static constexpr const char* kDefaultKey =
10-
"0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266";
10+
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
1111

1212
int main(int argc, char* argv[]) {
1313
const std::string private_key = (argc >= 2) ? argv[1] : kDefaultKey;

include/hyperliquid/api.hpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ class Api {
1616

1717
// POST `endpoint` (e.g. "/info" or "/exchange") with `payload` as JSON body.
1818
// Throws std::runtime_error on HTTP 4xx/5xx.
19-
nlohmann::json post(std::string_view endpoint, const nlohmann::json& payload);
19+
virtual nlohmann::json post(std::string_view endpoint, const nlohmann::json& payload);
2020

2121
struct HttpError : std::runtime_error {
2222
int status_code;

include/hyperliquid/exchange.hpp

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ namespace hyperliquid {
2121
// base_url: MAINNET_API_URL or TESTNET_API_URL.
2222
// info: shared Info instance for asset-index lookups and market data.
2323
// vault_address: optional sub-account / vault address; signs on behalf of it.
24+
// account_address: optional sub-account address; signs on behalf of it.
2425
class Exchange : public Api {
2526
public:
2627
Exchange(std::string private_key_hex,
2728
std::string_view base_url,
2829
std::shared_ptr<Info> info,
2930
std::optional<std::string> vault_address = {});
31+
Exchange(std::string private_key_hex,
32+
std::string_view base_url,
33+
std::shared_ptr<Info> info,
34+
std::optional<std::string> vault_address,
35+
std::optional<std::string> account_address);
3036

3137
// Ethereum address derived from private_key_hex.
3238
const std::string& wallet_address() const noexcept { return wallet_; }
@@ -75,9 +81,8 @@ class Exchange : public Api {
7581

7682
nlohmann::json update_leverage(std::string_view coin, bool is_cross, int leverage);
7783

78-
// ntl: notional USD amount for isolated margin adjustment.
79-
nlohmann::json update_isolated_margin(std::string_view coin, bool is_buy,
80-
double ntl);
84+
// amount: signed USD amount for isolated margin adjustment.
85+
nlohmann::json update_isolated_margin(std::string_view coin, double amount);
8186

8287
// ── Transfers (user-signed) ───────────────────────────────────────────────
8388

@@ -113,23 +118,33 @@ class Exchange : public Api {
113118
std::string private_key_;
114119
std::string wallet_;
115120
std::optional<std::string> vault_address_;
121+
std::optional<std::string> account_address_;
116122
std::shared_ptr<Info> info_;
117123
bool is_mainnet_;
118124

119125
int asset_index(std::string_view coin);
126+
std::string effective_account_address() const;
120127

121128
// Sign an L1 action and POST to /exchange.
129+
nlohmann::json post_action(nlohmann::ordered_json action);
122130
nlohmann::json post_action(nlohmann::ordered_json action,
123-
std::optional<std::string> vault_override = {});
131+
std::optional<std::string> signing_vault_address,
132+
std::optional<std::string> payload_vault_address);
124133

125134
// Sign a user-signed action and POST to /exchange.
126135
nlohmann::json post_user_signed_action(
127136
nlohmann::ordered_json action,
128137
const std::vector<std::pair<std::string, std::string>>& payload_types,
129138
std::string_view primary_type);
139+
nlohmann::json post_user_signed_action(
140+
nlohmann::ordered_json action,
141+
const std::vector<std::pair<std::string, std::string>>& payload_types,
142+
std::string_view primary_type,
143+
std::optional<std::string> payload_vault_address);
130144

131145
// Apply slippage to mid-price and round to asset precision.
132-
double slippage_price(std::string_view coin, bool is_buy, double slippage);
146+
double slippage_price(std::string_view coin, bool is_buy, double slippage,
147+
std::optional<double> px = {});
133148
};
134149

135150
} // namespace hyperliquid

include/hyperliquid/info.hpp

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,33 @@ class Info : public Api {
9292

9393
// ── Asset index lookup ────────────────────────────────────────────────────
9494

95-
// coin name → perpetual asset index (0-based). Populated on first call to meta().
95+
// Canonical coin string -> asset index. Perp assets are 0-based; spot assets
96+
// use 10000 + spot index to match the official Python SDK.
9697
std::unordered_map<std::string, int> coin_to_asset;
9798

98-
// Ensure coin_to_asset is populated (call before using Exchange).
99+
// Human-readable name -> canonical coin string (e.g. "PURR/USDC" -> "@0").
100+
std::unordered_map<std::string, std::string> name_to_coin;
101+
102+
// Asset index -> size decimals.
103+
std::unordered_map<int, int> asset_to_sz_decimals;
104+
105+
// Resolve a human-readable market name or canonical coin string to the wire coin.
106+
std::string canonical_coin(std::string_view coin_or_name);
107+
108+
// Resolve a human-readable market name or canonical coin string to an asset id.
109+
int name_to_asset(std::string_view coin_or_name);
110+
111+
// Ensure all asset lookup maps are populated (call before using Exchange).
99112
void load_meta();
100113

101114
private:
102115
nlohmann::json info_post(const nlohmann::json& payload);
116+
void populate_perp_meta(const nlohmann::json& meta);
117+
void populate_spot_meta(const nlohmann::json& spot_meta);
118+
nlohmann::json remap_subscription(const nlohmann::json& subscription);
103119

104-
bool meta_loaded_ = false;
120+
bool perp_meta_loaded_ = false;
121+
bool spot_meta_loaded_ = false;
105122
std::unique_ptr<WebsocketManager> ws_;
106123
};
107124

include/hyperliquid/websocket_manager.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#include <atomic>
44
#include <functional>
55
#include <memory>
6+
#include <optional>
67
#include <string>
78
#include <string_view>
89
#include <thread>
@@ -39,6 +40,9 @@ class WebsocketManager {
3940
// e.g. l2Book+ETH → "l2Book:ETH", candle+BTC+5m → "candle:BTC,5m"
4041
static std::string to_identifier(const nlohmann::json& sub);
4142

43+
// Build a channel identifier string from an inbound WebSocket message.
44+
static std::optional<std::string> message_to_identifier(const nlohmann::json& msg);
45+
4246
// Convert "https://..." → "wss://.../ws"
4347
static std::string http_to_ws_url(std::string_view http_url);
4448

0 commit comments

Comments
 (0)