Skip to content

Commit bc186e0

Browse files
committed
feat(l2_diff): implement decoding of compressed l2 diff payload and add tests
1 parent 3cfcbb3 commit bc186e0

15 files changed

Lines changed: 208 additions & 6 deletions

CHANGELOG.md

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

1010
## [Unreleased]
1111

12+
### Added
13+
- `hyperliquid::decode_l2_diff(std::string_view)` — decodes the compact `data.c` binary payload from Hyperliquid's undocumented `l2` WebSocket channel into a `nlohmann::json` diff object. The payload is standard base64 → raw deflate; decoding uses `EVP_DecodeBlock` (OpenSSL, already linked) and zlib `inflate`.
14+
- ZLIB added as an explicit CMake dependency (`find_package(ZLIB REQUIRED)` / `ZLIB::ZLIB`); propagated to installed consumers via `find_dependency(ZLIB)` in `hyperliquid-config.cmake`.
15+
1216
### Changed
17+
- `market_data_websocket` example now connects to `MAINNET_API_URL` instead of `TESTNET_API_URL`.
1318
- Suppress ping message logging
1419

20+
### Tests
21+
- `L2DiffDecoder.DecodesCompressedDiffPayload` — round-trip decode of a captured mainnet `l2` channel payload; asserts coin, timestamp, bid/ask levels, and removed-level arrays.
22+
- `L2DiffDecoder.RejectsInvalidBase64` — invalid base64 characters throw `std::invalid_argument`.
23+
- `L2DiffDecoder.RejectsInvalidDeflateData` — valid base64 that is not raw deflate throws `std::runtime_error`.
24+
- `L2DiffDecoder.RejectsNonJsonDeflatePayload` — valid raw deflate that decompresses to non-JSON throws `std::runtime_error`.
25+
1526
## [0.2.1] - 2026-06-23
1627

1728
### Added

CMakeLists.txt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
cmake_minimum_required(VERSION 3.21)
2-
project(hyperliquid-cpp VERSION 0.2.1 LANGUAGES CXX)
2+
project(hyperliquid-cpp VERSION 0.3.0 LANGUAGES CXX)
33

44
set(CMAKE_CXX_STANDARD 20)
55
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -13,6 +13,7 @@ endif()
1313

1414
find_package(nlohmann_json CONFIG REQUIRED)
1515
find_package(OpenSSL CONFIG REQUIRED)
16+
find_package(ZLIB REQUIRED)
1617

1718
find_package(slick-net 3.0.0 CONFIG QUIET)
1819
if (NOT slick-net_FOUND)
@@ -35,6 +36,7 @@ add_library(hyperliquid STATIC
3536
src/info.cpp
3637
src/exchange.cpp
3738
src/websocket_manager.cpp
39+
src/utils/l2_diff.cpp
3840
src/utils/signing.cpp
3941
)
4042

@@ -49,6 +51,7 @@ target_link_libraries(hyperliquid PUBLIC
4951
nlohmann_json::nlohmann_json
5052
OpenSSL::SSL
5153
OpenSSL::Crypto
54+
ZLIB::ZLIB
5255
slick::net
5356
)
5457

cmake/hyperliquid-config.cmake.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
include(CMakeFindDependencyMacro)
44
find_dependency(nlohmann_json CONFIG)
55
find_dependency(OpenSSL CONFIG)
6+
find_dependency(ZLIB)
67
find_dependency(slick-net 3.0.0 CONFIG)
78

89
include("${CMAKE_CURRENT_LIST_DIR}/hyperliquid-targets.cmake")

examples/market_data_websocket.cpp

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// WebSocket market data example: subscribe to public testnet updates.
1+
// WebSocket market data example: subscribe to public mainnet updates.
22
// Usage: market_data_websocket [coin ...] [--seconds N]
33
// Defaults: coins=ETH, seconds=30
44

@@ -106,7 +106,7 @@ int main(int argc, char* argv[]) {
106106
}
107107

108108
hyperliquid::Info info(
109-
hyperliquid::TESTNET_API_URL,
109+
hyperliquid::MAINNET_API_URL,
110110
/*skip_ws=*/false,
111111
/*user_thread_dispatch*/false,
112112
mux_record_size,
@@ -158,7 +158,7 @@ int main(int argc, char* argv[]) {
158158
std::cout << "Listening for";
159159
for (const auto& coin : options.coins)
160160
std::cout << " " << coin;
161-
std::cout << " market data on testnet for " << options.seconds << " seconds...\n";
161+
std::cout << " market data on mainnet for " << options.seconds << " seconds...\n";
162162
}
163163

164164
std::this_thread::sleep_for(std::chrono::seconds(options.seconds));

include/hyperliquid/api.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ class Api {
1515
virtual ~Api() = default;
1616

1717
// POST `endpoint` (e.g. "/info" or "/exchange") with `payload` as JSON body.
18-
// Throws std::runtime_error on HTTP 4xx/5xx.
18+
// Throws Api::HttpError on HTTP 4xx/5xx.
19+
// Throws std::runtime_error on network failure or malformed JSON response.
1920
virtual nlohmann::json post(std::string_view endpoint, const nlohmann::json& payload);
2021

2122
struct HttpError : std::runtime_error {

include/hyperliquid/exchange.hpp

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,18 @@ namespace hyperliquid {
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.
2424
// account_address: optional sub-account address; signs on behalf of it.
25+
//
26+
// All trading methods throw Api::HttpError on HTTP 4xx/5xx, std::runtime_error
27+
// on network or signing failure, and std::invalid_argument on non-finite or
28+
// unrepresentable price/size values (propagated from float_to_wire).
2529
class Exchange : public Api {
2630
public:
31+
// Throws std::runtime_error if private_key_hex is not a valid secp256k1 key.
2732
Exchange(std::string private_key_hex,
2833
std::string_view base_url,
2934
std::shared_ptr<Info> info,
3035
std::optional<std::string> vault_address = {});
36+
// Throws std::runtime_error if private_key_hex is not a valid secp256k1 key.
3137
Exchange(std::string private_key_hex,
3238
std::string_view base_url,
3339
std::shared_ptr<Info> info,
@@ -57,6 +63,7 @@ class Exchange : public Api {
5763
std::optional<Cloid> cloid = {});
5864

5965
// Close current position at mid-price +/- slippage. Queries user_state for sz.
66+
// Throws std::runtime_error if there is no open position for coin.
6067
nlohmann::json market_close(std::string_view coin,
6168
std::optional<double> sz = {},
6269
double slippage = 0.05,

include/hyperliquid/hyperliquid.hpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include <hyperliquid/utils/constants.hpp>
55
#include <hyperliquid/utils/types.hpp>
66
#include <hyperliquid/utils/keccak.hpp>
7+
#include <hyperliquid/utils/l2_diff.hpp>
78
#include <hyperliquid/utils/signing.hpp>
89
#include <hyperliquid/api.hpp>
910
#include <hyperliquid/websocket_manager.hpp>

include/hyperliquid/info.hpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ namespace hyperliquid {
1717

1818
// Read-only Hyperliquid API client.
1919
// Mirrors the Python SDK's Info class.
20+
// All REST methods throw Api::HttpError on HTTP 4xx/5xx and std::runtime_error
21+
// on network failure or malformed JSON response.
2022
class Info : public Api {
2123
public:
2224
explicit Info(
@@ -107,6 +109,8 @@ class Info : public Api {
107109
// Use the same JSON format as the Python SDK:
108110
// {"type": "l2Book", "coin": "ETH"}
109111
// {"type": "userFills", "user": "0x..."}
112+
// Throws std::runtime_error if the WebSocket was not started (skip_ws=true).
113+
// Throws std::runtime_error if the channel does not support multiple subscriptions.
110114
int subscribe(const nlohmann::json& subscription,
111115
std::function<void(const nlohmann::json&)> callback);
112116

@@ -149,9 +153,11 @@ class Info : public Api {
149153
std::string canonical_coin(std::string_view coin_or_name);
150154

151155
// Resolve a human-readable market name or canonical coin string to an asset id.
156+
// Throws std::invalid_argument if the coin is not found in the loaded metadata.
152157
int name_to_asset(std::string_view coin_or_name);
153158

154159
// Ensure all asset lookup maps are populated (call before using Exchange).
160+
// Throws Api::HttpError or std::runtime_error on network/HTTP failure.
155161
void load_meta();
156162

157163
private:
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#pragma once
2+
3+
#include <nlohmann/json.hpp>
4+
5+
#include <string_view>
6+
7+
namespace hyperliquid {
8+
9+
// Decode the compact `data.c` payload from Hyperliquid's undocumented `l2`
10+
// websocket channel into the JSON diff object used by the web app.
11+
// Throws std::invalid_argument on malformed base64 or empty payload.
12+
// Throws std::runtime_error on inflate failure or JSON parse failure.
13+
nlohmann::json decode_l2_diff(std::string_view compressed_diff);
14+
15+
} // namespace hyperliquid

include/hyperliquid/utils/signing.hpp

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,18 @@ namespace hyperliquid::signing {
1717
// ── Float serialisation (mirrors Python's float_to_wire) ─────────────────────
1818
// Formats to up to 8 decimal places, strips trailing zeros.
1919
// "1100.0" → "1100", "0.2" → "0.2"
20+
// Throws std::invalid_argument if x is non-finite or serialisation would require rounding.
2021
std::string float_to_wire(double x);
2122

22-
// USD amounts: multiply by 1e6 and round to integer
23+
// USD amounts: multiply by 1e6 and round to integer.
24+
// Throws std::invalid_argument if x is non-finite or conversion requires rounding.
2325
int64_t float_to_usd_int(double x);
2426

2527
// ── Wire-format helpers ───────────────────────────────────────────────────────
2628

2729
// Convert an OrderRequest to the compact wire JSON object {a,b,p,s,r,t,?c}.
2830
// Uses nlohmann::ordered_json to preserve key insertion order for msgpack hashing.
31+
// Throws std::invalid_argument (propagated from float_to_wire) on non-finite or unrepresentable prices/sizes.
2932
nlohmann::ordered_json order_request_to_wire(const OrderRequest& req, int asset);
3033

3134
// Build the "order" action {type, orders, grouping, ?builder}.
@@ -37,6 +40,7 @@ nlohmann::ordered_json order_wires_to_action(
3740
// ── Action hashing ────────────────────────────────────────────────────────────
3841
// Reproduces Python's action_hash():
3942
// keccak256( msgpack(action) || nonce_be8 || vault_flag || [vault_bytes] || [expires_flag_be8] )
43+
// Throws std::invalid_argument if vault_address is present but not exactly 20 hex-decoded bytes.
4044
std::array<uint8_t, 32> action_hash(
4145
const nlohmann::ordered_json& action,
4246
const std::optional<std::string>& vault_address,
@@ -47,6 +51,8 @@ std::array<uint8_t, 32> action_hash(
4751

4852
// Sign an L1 action (order/cancel/leverage/margin/…) using the phantom-agent
4953
// EIP-712 pattern with Exchange domain (chainId 1337).
54+
// Throws std::runtime_error on invalid private key or OpenSSL failure.
55+
// Throws std::invalid_argument if vault_address is malformed (propagated from action_hash).
5056
Signature sign_l1_action(
5157
std::string_view private_key_hex,
5258
const nlohmann::ordered_json& action,
@@ -59,6 +65,7 @@ Signature sign_l1_action(
5965
// with domain "HyperliquidSignTransaction" and chainId 0x66eee.
6066
// `action` is modified in-place to add signatureChainId and hyperliquidChain.
6167
// `payload_types` is a list of {field_name, solidity_type} pairs.
68+
// Throws std::runtime_error on invalid private key or OpenSSL failure.
6269
Signature sign_user_signed_action(
6370
std::string_view private_key_hex,
6471
nlohmann::ordered_json& action,
@@ -69,13 +76,15 @@ Signature sign_user_signed_action(
6976
// ── Key utilities ─────────────────────────────────────────────────────────────
7077

7178
// Derive Ethereum address (checksummed lower-case "0x...") from secp256k1 private key hex.
79+
// Throws std::runtime_error on invalid key or OpenSSL failure.
7280
std::string private_key_to_address(std::string_view private_key_hex);
7381

7482
// Current timestamp in milliseconds since UNIX epoch.
7583
int64_t get_timestamp_ms();
7684

7785
// Hex helpers
7886
std::string bytes_to_hex(const uint8_t* data, size_t len, bool prefix = true);
87+
// Throws std::invalid_argument if hex has odd length.
7988
std::vector<uint8_t> hex_to_bytes(std::string_view hex);
8089

8190
} // namespace hyperliquid::signing

0 commit comments

Comments
 (0)