From 28a20daaec9a05b927614b59fb2402fde82368cf Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Mon, 23 Feb 2026 21:13:03 +0000 Subject: [PATCH 01/15] update to handle subscription responses --- include/hyperliquid/types/RequestTypes.h | 25 +++++++++++++++++++ include/hyperliquid/types/ResponseTypes.h | 20 +++++++++++++++ .../hyperliquid/websocket/WSMessageHandler.h | 1 + src/websocket/WSMessageParser.cpp | 24 ++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 8f42368..fc68d04 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -64,6 +64,7 @@ namespace hyperliquid ActiveAssetData, UserTwapSliceFills, UserTwapHistory, + Unknown, }; inline std::string toString(SubscriptionType type) @@ -93,6 +94,30 @@ namespace hyperliquid } } + inline SubscriptionType stringToSubscriptionType(std::string type) + { + if (type == "l2Book") return SubscriptionType::L2Book; + if (type == "bbo") return SubscriptionType::Bbo; + if (type == "trades") return SubscriptionType::Trades; + if (type == "candle") return SubscriptionType::Candle; + if (type == "allMids") return SubscriptionType::AllMids; + if (type == "notification") return SubscriptionType::Notification; + if (type == "webData3") return SubscriptionType::WebData3; + if (type == "twapStates") return SubscriptionType::TwapStates; + if (type == "clearingHouseState") return SubscriptionType::ClearingHouseState; + if (type == "openOrders") return SubscriptionType::OpenOrders; + if (type == "orderUpdates") return SubscriptionType::OrderUpdates; + if (type == "userEvents") return SubscriptionType::UserEvents; + if (type == "userFills") return SubscriptionType::UserFills; + if (type == "userFundings") return SubscriptionType::UserFundings; + if (type == "userNonFundingLedgerUpdates") return SubscriptionType::UserNonFundingLedgerUpdates; + if (type == "activeAssetCtx") return SubscriptionType::ActiveAssetCtx; + if (type == "activeAssetData") return SubscriptionType::ActiveAssetData; + if (type == "userTwapSliceFills") return SubscriptionType::UserTwapSliceFills; + if (type == "userTwapHistory") return SubscriptionType::UserTwapHistory; + return SubscriptionType::Unknown; + } + // --- REST info endpoint types --- enum class InfoEndpointType diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 0ac9e9b..cacf648 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -3,8 +3,28 @@ #include #include +#include "RequestTypes.h" + namespace hyperliquid { + + // --- Subscription types --- + + enum class SubscriptionMethod { Subscribe, Unsubscribe }; + + + struct Subscription + { + SubscriptionType type; + }; + + struct SubscriptionResponse + { + SubscriptionMethod method; + Subscription subscription; + }; + + // --- Market Data types (no auth required) --- enum class Side { Bid, Ask }; diff --git a/include/hyperliquid/websocket/WSMessageHandler.h b/include/hyperliquid/websocket/WSMessageHandler.h index 5fe6189..aec98d9 100644 --- a/include/hyperliquid/websocket/WSMessageHandler.h +++ b/include/hyperliquid/websocket/WSMessageHandler.h @@ -8,6 +8,7 @@ class WSMessageHandler { public: virtual ~WSMessageHandler() = default; + virtual void onSubscriptionResponse(const SubscriptionResponse& response) {} virtual void onL2BookLevel(const L2BookUpdate& book, const PriceLevel& level) {} virtual void onBbo(const BboUpdate& update) {} virtual void onTrade(const Trade& trade) {} diff --git a/src/websocket/WSMessageParser.cpp b/src/websocket/WSMessageParser.cpp index 02bd2d5..30fc672 100644 --- a/src/websocket/WSMessageParser.cpp +++ b/src/websocket/WSMessageParser.cpp @@ -3,6 +3,8 @@ #include #include +#include "../../include/hyperliquid/types/ResponseTypes.h" + namespace hyperliquid { struct WSMessageParser::Impl @@ -73,6 +75,11 @@ namespace hyperliquid auto data = doc["data"].get_object().value(); crackActiveAssetCtx(data, listener); } + else if (channel == "subscriptionResponse") + { + auto data = doc["data"].get_object().value(); + crackSubscriptionResponse(data, listener); + } else if (channel == "error") { std::cerr << "Error: " << message << std::endl; @@ -88,6 +95,23 @@ namespace hyperliquid } } + void crackSubscriptionResponse(simdjson::ondemand::object& data, WSMessageHandler& listener) + { + SubscriptionResponse response; + if (data["type"].get_string().value() == "subscribe") + { + response.method = SubscriptionMethod::Subscribe; + } else + { + response.method = SubscriptionMethod::Unsubscribe; + } + Subscription subscription; + subscription.type = stringToSubscriptionType(data["subscription"].get_object()["type"].get_string().value()); + response.subscription = subscription; + listener.onSubscriptionResponse(response); + } + + void crackL2Book(simdjson::ondemand::object& data, WSMessageHandler& listener) { L2BookUpdate book; From 3ce8acede14cd2c82e5a5ed8b232591f0880ae7f Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Mon, 23 Feb 2026 21:57:58 +0000 Subject: [PATCH 02/15] small fix to remove string view --- include/hyperliquid/types/RequestTypes.h | 2 +- src/websocket/WSMessageParser.cpp | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index fc68d04..1b65280 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -94,7 +94,7 @@ namespace hyperliquid } } - inline SubscriptionType stringToSubscriptionType(std::string type) + inline SubscriptionType stringToSubscriptionType(const std::string& type) { if (type == "l2Book") return SubscriptionType::L2Book; if (type == "bbo") return SubscriptionType::Bbo; diff --git a/src/websocket/WSMessageParser.cpp b/src/websocket/WSMessageParser.cpp index 30fc672..bc013ea 100644 --- a/src/websocket/WSMessageParser.cpp +++ b/src/websocket/WSMessageParser.cpp @@ -98,7 +98,7 @@ namespace hyperliquid void crackSubscriptionResponse(simdjson::ondemand::object& data, WSMessageHandler& listener) { SubscriptionResponse response; - if (data["type"].get_string().value() == "subscribe") + if (data["method"].get_string().value() == "subscribe") { response.method = SubscriptionMethod::Subscribe; } else @@ -106,7 +106,7 @@ namespace hyperliquid response.method = SubscriptionMethod::Unsubscribe; } Subscription subscription; - subscription.type = stringToSubscriptionType(data["subscription"].get_object()["type"].get_string().value()); + subscription.type = stringToSubscriptionType(std::string(data["subscription"].get_object().value()["type"].get_string().value())); response.subscription = subscription; listener.onSubscriptionResponse(response); } From 5a2aee236e5ac0a5cf9ed0af4dceff465d9e23e3 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Mon, 9 Mar 2026 19:15:54 +0000 Subject: [PATCH 03/15] refactor naming and approach --- CMakeLists.txt | 28 +++--- examples/{print_book.cpp => basic_book.cpp} | 25 ++--- examples/{fetch_meta.cpp => basic_meta.cpp} | 21 ++-- examples/basic_orders.cpp | 53 ++++++++++ include/hyperliquid/rest/InfoApi.h | 28 ------ .../hyperliquid/rest/InfoEndpointListener.h | 14 --- include/hyperliquid/rest/RestApi.h | 29 ++++++ include/hyperliquid/rest/RestApiListener.h | 15 +++ .../hyperliquid/rest/RestApiMessageParser.h | 27 +++++ .../hyperliquid/rest/RestEndpointListener.h | 14 +++ include/hyperliquid/rest/RestListener.h | 15 --- include/hyperliquid/rest/RestMessageParser.h | 27 ----- include/hyperliquid/signing/Wallet.h | 11 +++ include/hyperliquid/types/InfoEndpointTypes.h | 20 ---- include/hyperliquid/types/RequestTypes.h | 65 +++++++++--- include/hyperliquid/types/ResponseTypes.h | 23 ++++- .../hyperliquid/websocket/WSMessageParser.h | 26 ----- .../{MarketData.h => WebsocketApi.h} | 16 +-- ...ocketListener.h => WebsocketApiListener.h} | 4 +- ...ageHandler.h => WebsocketMessageHandler.h} | 4 +- .../websocket/WebsocketMessageParser.h | 26 +++++ src/rest/{InfoApi.cpp => RestApi.cpp} | 56 ++++++++--- ...ageParser.cpp => RestApiMessageParser.cpp} | 22 ++--- src/signing/Signing.cpp | 1 + src/signing/Signing.h | 5 + src/websocket/MarketData.cpp | 81 --------------- src/websocket/WebsocketApi.cpp | 99 +++++++++++++++++++ ...eParser.cpp => WebsocketMessageParser.cpp} | 30 +++--- .../{WSRunner.cpp => WebsocketRunner.cpp} | 44 ++++----- .../{WSRunner.h => WebsocketRunner.h} | 6 +- 30 files changed, 501 insertions(+), 334 deletions(-) rename examples/{print_book.cpp => basic_book.cpp} (68%) rename examples/{fetch_meta.cpp => basic_meta.cpp} (65%) create mode 100644 examples/basic_orders.cpp delete mode 100644 include/hyperliquid/rest/InfoApi.h delete mode 100644 include/hyperliquid/rest/InfoEndpointListener.h create mode 100644 include/hyperliquid/rest/RestApi.h create mode 100644 include/hyperliquid/rest/RestApiListener.h create mode 100644 include/hyperliquid/rest/RestApiMessageParser.h create mode 100644 include/hyperliquid/rest/RestEndpointListener.h delete mode 100644 include/hyperliquid/rest/RestListener.h delete mode 100644 include/hyperliquid/rest/RestMessageParser.h create mode 100644 include/hyperliquid/signing/Wallet.h delete mode 100644 include/hyperliquid/types/InfoEndpointTypes.h delete mode 100644 include/hyperliquid/websocket/WSMessageParser.h rename include/hyperliquid/websocket/{MarketData.h => WebsocketApi.h} (53%) rename include/hyperliquid/websocket/{WebsocketListener.h => WebsocketApiListener.h} (76%) rename include/hyperliquid/websocket/{WSMessageHandler.h => WebsocketMessageHandler.h} (87%) create mode 100644 include/hyperliquid/websocket/WebsocketMessageParser.h rename src/rest/{InfoApi.cpp => RestApi.cpp} (80%) rename src/rest/{RestMessageParser.cpp => RestApiMessageParser.cpp} (70%) create mode 100644 src/signing/Signing.cpp create mode 100644 src/signing/Signing.h delete mode 100644 src/websocket/MarketData.cpp create mode 100644 src/websocket/WebsocketApi.cpp rename src/websocket/{WSMessageParser.cpp => WebsocketMessageParser.cpp} (91%) rename src/websocket/{WSRunner.cpp => WebsocketRunner.cpp} (86%) rename src/websocket/{WSRunner.h => WebsocketRunner.h} (93%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 30c4b12..9e1d723 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,12 +10,18 @@ find_package(simdjson CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) set(SDK_SOURCES - src/websocket/WSRunner.cpp - src/websocket/MarketData.cpp - src/websocket/WSMessageParser.cpp - src/rest/RestMessageParser.cpp - src/rest/InfoApi.cpp + src/rest/RestApiMessageParser.cpp + src/rest/RestApi.cpp + + src/websocket/WebsocketRunner.cpp + src/websocket/WebsocketApi.cpp + src/websocket/WebsocketMessageParser.cpp + + src/signing/Signing.cpp + src/signing/Signing.h + include/hyperliquid/types/RequestTypes.h + include/hyperliquid/signing/Wallet.h ) add_library(hyperliquid-sdk STATIC ${SDK_SOURCES}) @@ -25,7 +31,6 @@ target_include_directories(hyperliquid-sdk ${CMAKE_CURRENT_SOURCE_DIR}/include PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src - ${CMAKE_CURRENT_SOURCE_DIR}/src/websocket ) target_link_libraries(hyperliquid-sdk @@ -50,9 +55,10 @@ set_target_properties(hyperliquid-sdk PROPERTIES option(HYPERLIQUID_BUILD_EXAMPLES "Build example programs" OFF) if(HYPERLIQUID_BUILD_EXAMPLES) - add_executable(print_book examples/print_book.cpp) - target_link_libraries(print_book PRIVATE hyperliquid-sdk) - - add_executable(fetch_meta examples/fetch_meta.cpp) - target_link_libraries(fetch_meta PRIVATE hyperliquid-sdk) + add_executable(basic_book examples/basic_book.cpp) + target_link_libraries(basic_book PRIVATE hyperliquid-sdk) + add_executable(basic_orders examples/basic_orders.cpp) + target_link_libraries(basic_orders PRIVATE hyperliquid-sdk) + add_executable(basic_meta examples/basic_meta.cpp) + target_link_libraries(basic_meta PRIVATE hyperliquid-sdk) endif() diff --git a/examples/print_book.cpp b/examples/basic_book.cpp similarity index 68% rename from examples/print_book.cpp rename to examples/basic_book.cpp index 45420e7..db7bba4 100644 --- a/examples/print_book.cpp +++ b/examples/basic_book.cpp @@ -1,13 +1,14 @@ -#include -#include -#include -#include #include #include #include #include -class BookPrinter : public hyperliquid::WSMessageHandler, public hyperliquid::WebsocketListener { +#include "hyperliquid/websocket/WebsocketApi.h" +#include "hyperliquid/websocket/WebsocketApiListener.h" +#include "hyperliquid/websocket/WebsocketMessageHandler.h" +#include "hyperliquid/websocket/WebsocketMessageParser.h" + +class BookPrinter : public hyperliquid::WebsocketMessageHandler, public hyperliquid::WebsocketApiListener { public: // hyperliquid::WebsocketListener void onMessage(const std::string& message) override { @@ -46,22 +47,22 @@ class BookPrinter : public hyperliquid::WSMessageHandler, public hyperliquid::We } private: - hyperliquid::WSMessageParser messageParser; + hyperliquid::WebsocketMessageParser messageParser; }; int main() { BookPrinter printer; - hyperliquid::MarketData md(hyperliquid::Environment::Mainnet, printer); + hyperliquid::WebsocketApi websocket(hyperliquid::Environment::Mainnet, printer); std::cout << "Subscribing to BTC l2Book + bbo + trades for 5 seconds..." << std::endl; - md.start(); + websocket.start(); - md.subscribe(hyperliquid::SubscriptionType::L2Book, {{"coin", "BTC"}}); - md.subscribe(hyperliquid::SubscriptionType::Bbo, {{"coin", "BTC"}}); - md.subscribe(hyperliquid::SubscriptionType::Trades, {{"coin", "BTC"}}); + websocket.subscribe(hyperliquid::SubscriptionType::L2Book, {{"coin", "BTC"}}); + websocket.subscribe(hyperliquid::SubscriptionType::Bbo, {{"coin", "BTC"}}); + websocket.subscribe(hyperliquid::SubscriptionType::Trades, {{"coin", "BTC"}}); std::this_thread::sleep_for(std::chrono::seconds(5)); - md.stop(); + websocket.stop(); return 0; } diff --git a/examples/fetch_meta.cpp b/examples/basic_meta.cpp similarity index 65% rename from examples/fetch_meta.cpp rename to examples/basic_meta.cpp index 5bd29e4..372347b 100644 --- a/examples/fetch_meta.cpp +++ b/examples/basic_meta.cpp @@ -1,20 +1,21 @@ -#include -#include -#include -#include -#include +#include +#include +#include +#include #include #include #include #include -class MetaPrinter : public hyperliquid::RestListener, public hyperliquid::InfoEndpointListener { +#include "hyperliquid/types/ResponseTypes.h" + +class MetaPrinter : public hyperliquid::RestApiListener, public hyperliquid::RestEndpointListener { public: std::atomic done{false}; // hyperliquid::RestListener — raw message arrives here - void onMessage(const std::string& message, hyperliquid::InfoEndpointType type) override { - hyperliquid::RestMessageParser parser(*this); + void onMessage(const std::string& message, hyperliquid::RestEndpointType type) override { + hyperliquid::RestApiMessageParser parser(*this); parser.parse(message, type); } @@ -34,10 +35,10 @@ class MetaPrinter : public hyperliquid::RestListener, public hyperliquid::InfoEn int main() { MetaPrinter printer; - hyperliquid::InfoApi api(hyperliquid::Environment::Mainnet, printer); + hyperliquid::RestApi api(hyperliquid::Environment::Mainnet, printer); std::cout << "Fetching meta from Hyperliquid... (dex=xyz)" << std::endl; - api.sendRequest(hyperliquid::InfoEndpointType::Meta, {{"dex", "xyz"}}); + api.sendRequest(hyperliquid::RestEndpointType::Meta, {{"dex", "xyz"}}); // Wait for the async response while (!printer.done) { diff --git a/examples/basic_orders.cpp b/examples/basic_orders.cpp new file mode 100644 index 0000000..9336df9 --- /dev/null +++ b/examples/basic_orders.cpp @@ -0,0 +1,53 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +class OrderSender : public hyperliquid::RestApiListener, public hyperliquid::RestEndpointListener { +public: + std::atomic done{false}; + + // hyperliquid::RestListener — raw message arrives here + void onMessage(const std::string& message, hyperliquid::RestEndpointType type) override { + hyperliquid::RestApiMessageParser parser(*this); + parser.parse(message, type); + } + + // hyperliquid::InfoEndpointListener — parsed response arrives here + void onMeta(const hyperliquid::MetaResponse& response) override { + std::cout << "Universe: " << response.universe.size() << " assets" << std::endl; + std::cout << std::endl; + for (const auto& asset : response.universe) { + std::cout << asset.name + << " szDecimals=" << asset.szDecimals + << " maxLeverage=" << asset.maxLeverage + << std::endl; + } + done = true; + } +}; + +int main() +{ + OrderSender printer; + hyperliquid::Wallet wallet{"", ""}; + hyperliquid::RestApi api(hyperliquid::Environment::Mainnet, printer, wallet); + + std::cout << "Sending order to Hyperliquid..." << std::endl; + api.sendRequest(hyperliquid::RestEndpointType::PlaceOrder, { + {"orders", [ + + ]} + }); + + // Wait for the async response + while (!printer.done) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + + return 0; +} diff --git a/include/hyperliquid/rest/InfoApi.h b/include/hyperliquid/rest/InfoApi.h deleted file mode 100644 index 0beb2f5..0000000 --- a/include/hyperliquid/rest/InfoApi.h +++ /dev/null @@ -1,28 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "../types/RequestTypes.h" -#include "RestListener.h" - -namespace hyperliquid { - -class InfoApi { -public: - InfoApi(Environment env, RestListener& listener); - ~InfoApi(); - - InfoApi(const InfoApi&) = delete; - InfoApi& operator=(const InfoApi&) = delete; - - void sendRequest(InfoEndpointType type, - const std::map& params = {}); - -private: - struct Impl; - std::unique_ptr impl_; -}; - -} // namespace hyperliquid diff --git a/include/hyperliquid/rest/InfoEndpointListener.h b/include/hyperliquid/rest/InfoEndpointListener.h deleted file mode 100644 index 8b8342a..0000000 --- a/include/hyperliquid/rest/InfoEndpointListener.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include "../types/InfoEndpointTypes.h" - -namespace hyperliquid { - -class InfoEndpointListener { -public: - virtual ~InfoEndpointListener() = default; - - virtual void onMeta(const MetaResponse& response) {} -}; - -} diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h new file mode 100644 index 0000000..955f568 --- /dev/null +++ b/include/hyperliquid/rest/RestApi.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include +#include + +#include "../types/RequestTypes.h" +#include "RestApiListener.h" +#include "hyperliquid/signing/Wallet.h" + +namespace hyperliquid { + +class RestApi { +public: + RestApi(Environment env, RestApiListener& listener, Wallet wallet); + RestApi(Environment env, RestApiListener& listener); + ~RestApi(); + + RestApi(const RestApi&) = delete; + RestApi& operator=(const RestApi&) = delete; + + void sendRequest(RestEndpointType type, const std::map& params = {}); + +private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace hyperliquid diff --git a/include/hyperliquid/rest/RestApiListener.h b/include/hyperliquid/rest/RestApiListener.h new file mode 100644 index 0000000..01e1489 --- /dev/null +++ b/include/hyperliquid/rest/RestApiListener.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include "../types/RequestTypes.h" + +namespace hyperliquid { + +class RestApiListener { +public: + virtual ~RestApiListener() = default; + + virtual void onMessage(const std::string& message, RestEndpointType type) {} +}; + +} diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h new file mode 100644 index 0000000..d5ab2d2 --- /dev/null +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include "../types/RequestTypes.h" +#include "RestEndpointListener.h" + +namespace hyperliquid +{ + class RestApiMessageParser + { + public: + explicit RestApiMessageParser(RestEndpointListener& listener); + ~RestApiMessageParser(); + + RestApiMessageParser(RestApiMessageParser&&) noexcept; + RestApiMessageParser& operator=(RestApiMessageParser&&) noexcept; + RestApiMessageParser(const RestApiMessageParser&) = delete; + RestApiMessageParser& operator=(const RestApiMessageParser&) = delete; + + void parse(const std::string& message, RestEndpointType type); + + private: + struct Impl; + std::unique_ptr impl_; + }; +} diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h new file mode 100644 index 0000000..b8425a6 --- /dev/null +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -0,0 +1,14 @@ +#pragma once + +#include "../types/ResponseTypes.h" + +namespace hyperliquid { + +class RestEndpointListener { +public: + virtual ~RestEndpointListener() = default; + + virtual void onMeta(const MetaResponse& response) {} +}; + +} diff --git a/include/hyperliquid/rest/RestListener.h b/include/hyperliquid/rest/RestListener.h deleted file mode 100644 index fa7d8e5..0000000 --- a/include/hyperliquid/rest/RestListener.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include -#include "../types/RequestTypes.h" - -namespace hyperliquid { - -class RestListener { -public: - virtual ~RestListener() = default; - - virtual void onMessage(const std::string& message, InfoEndpointType type) {} -}; - -} diff --git a/include/hyperliquid/rest/RestMessageParser.h b/include/hyperliquid/rest/RestMessageParser.h deleted file mode 100644 index 37218ef..0000000 --- a/include/hyperliquid/rest/RestMessageParser.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include -#include "../types/RequestTypes.h" -#include "InfoEndpointListener.h" - -namespace hyperliquid -{ - class RestMessageParser - { - public: - explicit RestMessageParser(InfoEndpointListener& listener); - ~RestMessageParser(); - - RestMessageParser(RestMessageParser&&) noexcept; - RestMessageParser& operator=(RestMessageParser&&) noexcept; - RestMessageParser(const RestMessageParser&) = delete; - RestMessageParser& operator=(const RestMessageParser&) = delete; - - void parse(const std::string& message, InfoEndpointType type); - - private: - struct Impl; - std::unique_ptr impl_; - }; -} diff --git a/include/hyperliquid/signing/Wallet.h b/include/hyperliquid/signing/Wallet.h new file mode 100644 index 0000000..d7efe9c --- /dev/null +++ b/include/hyperliquid/signing/Wallet.h @@ -0,0 +1,11 @@ +#pragma once +#include + +namespace hyperliquid +{ + struct Wallet + { + std::string accountAddress; + std::string privateKey; + }; +} \ No newline at end of file diff --git a/include/hyperliquid/types/InfoEndpointTypes.h b/include/hyperliquid/types/InfoEndpointTypes.h deleted file mode 100644 index ce9229c..0000000 --- a/include/hyperliquid/types/InfoEndpointTypes.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include -#include -#include - -namespace hyperliquid -{ - struct AssetMeta - { - std::string name; - int szDecimals; - int maxLeverage; - }; - - struct MetaResponse - { - std::vector universe; - }; -} diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 1b65280..b306e45 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -118,10 +118,11 @@ namespace hyperliquid return SubscriptionType::Unknown; } - // --- REST info endpoint types --- + // --- Rest endpoint types --- - enum class InfoEndpointType + enum class RestEndpointType { + // Info endpoints Meta, MetaAndAssetCtxs, AllMids, @@ -132,23 +133,61 @@ namespace hyperliquid UserFillsByTime, OrderStatus, UserRateLimit, + + // Exchange endpoints (signed) + PlaceOrder, + CancelOrder, + CancelOrderByCloid, + ScheduleCancel, + ModifyOrder, + BatchModifyOrder, }; - inline std::string toString(InfoEndpointType type) + inline std::string toString(RestEndpointType type) { switch (type) { - case InfoEndpointType::Meta: return "meta"; - case InfoEndpointType::MetaAndAssetCtxs: return "metaAndAssetCtxs"; - case InfoEndpointType::AllMids: return "allMids"; - case InfoEndpointType::L2Book: return "l2Book"; - case InfoEndpointType::CandleSnapshot: return "candleSnapshot"; - case InfoEndpointType::OpenOrders: return "openOrders"; - case InfoEndpointType::UserFills: return "userFills"; - case InfoEndpointType::UserFillsByTime: return "userFillsByTime"; - case InfoEndpointType::OrderStatus: return "orderStatus"; - case InfoEndpointType::UserRateLimit: return "userRateLimit"; + case RestEndpointType::Meta: return "meta"; + case RestEndpointType::MetaAndAssetCtxs: return "metaAndAssetCtxs"; + case RestEndpointType::AllMids: return "allMids"; + case RestEndpointType::L2Book: return "l2Book"; + case RestEndpointType::CandleSnapshot: return "candleSnapshot"; + case RestEndpointType::OpenOrders: return "openOrders"; + case RestEndpointType::UserFills: return "userFills"; + case RestEndpointType::UserFillsByTime: return "userFillsByTime"; + case RestEndpointType::OrderStatus: return "orderStatus"; + case RestEndpointType::UserRateLimit: return "userRateLimit"; + case RestEndpointType::PlaceOrder: return "order"; + case RestEndpointType::CancelOrder: return "cancel"; + case RestEndpointType::CancelOrderByCloid: return "cancelByCloid"; + case RestEndpointType::ScheduleCancel: return "scheduleCancel"; + case RestEndpointType::ModifyOrder: return "modify"; + case RestEndpointType::BatchModifyOrder: return "batchModify"; default: throw std::invalid_argument("Unknown InfoEndpointType"); } } + + inline bool isAuthenticated(RestEndpointType type) + { + switch (type) + { + case RestEndpointType::Meta: return false; + case RestEndpointType::MetaAndAssetCtxs: return false; + case RestEndpointType::AllMids: return false; + case RestEndpointType::L2Book: return false; + case RestEndpointType::CandleSnapshot: return false; + case RestEndpointType::OpenOrders: return false; + case RestEndpointType::UserFills: return false; + case RestEndpointType::UserFillsByTime: return false; + case RestEndpointType::OrderStatus: return false; + case RestEndpointType::UserRateLimit: return false; + case RestEndpointType::PlaceOrder: return true; + case RestEndpointType::CancelOrder: return true; + case RestEndpointType::CancelOrderByCloid: return true; + case RestEndpointType::ScheduleCancel: return true; + case RestEndpointType::ModifyOrder: return true; + case RestEndpointType::BatchModifyOrder: return true; + default: throw std::invalid_argument("Unknown RestEndpointType"); + } + } } diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index cacf648..fadefc6 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -2,6 +2,7 @@ #include #include +#include #include "RequestTypes.h" @@ -12,7 +13,6 @@ namespace hyperliquid enum class SubscriptionMethod { Subscribe, Unsubscribe }; - struct Subscription { SubscriptionType type; @@ -24,8 +24,7 @@ namespace hyperliquid Subscription subscription; }; - - // --- Market Data types (no auth required) --- + // --- Websocket data types (unauthenticated) --- enum class Side { Bid, Ask }; @@ -110,7 +109,7 @@ namespace hyperliquid double circulatingSupply; }; - // --- User / Trading types (require user address) --- + // --- Websocket data types (authenticated) --- struct Fill { @@ -291,4 +290,20 @@ namespace hyperliquid { std::string notification; }; + + // --- Rest endpoint types (unauthenticated) --- + + struct AssetMeta + { + std::string name; + int szDecimals; + int maxLeverage; + }; + + struct MetaResponse + { + std::vector universe; + }; + + // --- Rest endpoint types (authenticated) --- } diff --git a/include/hyperliquid/websocket/WSMessageParser.h b/include/hyperliquid/websocket/WSMessageParser.h deleted file mode 100644 index cd735cf..0000000 --- a/include/hyperliquid/websocket/WSMessageParser.h +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include -#include -#include "WSMessageHandler.h" - -namespace hyperliquid -{ - class WSMessageParser - { - public: - WSMessageParser(); - ~WSMessageParser(); - - WSMessageParser(WSMessageParser&&) noexcept; - WSMessageParser& operator=(WSMessageParser&&) noexcept; - WSMessageParser(const WSMessageParser&) = delete; - WSMessageParser& operator=(const WSMessageParser&) = delete; - - void crack(const std::string& message, WSMessageHandler& listener); - - private: - struct Impl; - std::unique_ptr impl_; - }; -} diff --git a/include/hyperliquid/websocket/MarketData.h b/include/hyperliquid/websocket/WebsocketApi.h similarity index 53% rename from include/hyperliquid/websocket/MarketData.h rename to include/hyperliquid/websocket/WebsocketApi.h index 7783dc9..e7b8ac1 100644 --- a/include/hyperliquid/websocket/MarketData.h +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -4,23 +4,27 @@ #include #include -#include "WebsocketListener.h" +#include "WebsocketApiListener.h" #include "../types/RequestTypes.h" namespace hyperliquid { - class MarketData + class WebsocketApi { public: - explicit MarketData(Environment env, WebsocketListener& listener); - ~MarketData(); + explicit WebsocketApi(Environment env, WebsocketApiListener& listener); + ~WebsocketApi(); - MarketData(const MarketData&) = delete; - MarketData& operator=(const MarketData&) = delete; + WebsocketApi(const WebsocketApi&) = delete; + WebsocketApi& operator=(const WebsocketApi&) = delete; + // void subscribe(SubscriptionType type, const std::map& filters = {}); void unsubscribe(SubscriptionType type, const std::map& filters = {}); + // Post requests over websocket + void sendRequest(RestEndpointType type, const std::map& params = {}); + void start(); void stop(); diff --git a/include/hyperliquid/websocket/WebsocketListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h similarity index 76% rename from include/hyperliquid/websocket/WebsocketListener.h rename to include/hyperliquid/websocket/WebsocketApiListener.h index fa61ee5..8c6d5ec 100644 --- a/include/hyperliquid/websocket/WebsocketListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -4,9 +4,9 @@ namespace hyperliquid { -class WebsocketListener { +class WebsocketApiListener { public: - virtual ~WebsocketListener() = default; + virtual ~WebsocketApiListener() = default; virtual void onMessage(const std::string& message) {} virtual void onConnected() {} diff --git a/include/hyperliquid/websocket/WSMessageHandler.h b/include/hyperliquid/websocket/WebsocketMessageHandler.h similarity index 87% rename from include/hyperliquid/websocket/WSMessageHandler.h rename to include/hyperliquid/websocket/WebsocketMessageHandler.h index aec98d9..4dd17e0 100644 --- a/include/hyperliquid/websocket/WSMessageHandler.h +++ b/include/hyperliquid/websocket/WebsocketMessageHandler.h @@ -4,9 +4,9 @@ namespace hyperliquid { -class WSMessageHandler { +class WebsocketMessageHandler { public: - virtual ~WSMessageHandler() = default; + virtual ~WebsocketMessageHandler() = default; virtual void onSubscriptionResponse(const SubscriptionResponse& response) {} virtual void onL2BookLevel(const L2BookUpdate& book, const PriceLevel& level) {} diff --git a/include/hyperliquid/websocket/WebsocketMessageParser.h b/include/hyperliquid/websocket/WebsocketMessageParser.h new file mode 100644 index 0000000..3a6d6d0 --- /dev/null +++ b/include/hyperliquid/websocket/WebsocketMessageParser.h @@ -0,0 +1,26 @@ +#pragma once + +#include +#include +#include "WebsocketMessageHandler.h" + +namespace hyperliquid +{ + class WebsocketMessageParser + { + public: + WebsocketMessageParser(); + ~WebsocketMessageParser(); + + WebsocketMessageParser(WebsocketMessageParser&&) noexcept; + WebsocketMessageParser& operator=(WebsocketMessageParser&&) noexcept; + WebsocketMessageParser(const WebsocketMessageParser&) = delete; + WebsocketMessageParser& operator=(const WebsocketMessageParser&) = delete; + + void crack(const std::string& message, WebsocketMessageHandler& listener); + + private: + struct Impl; + std::unique_ptr impl_; + }; +} diff --git a/src/rest/InfoApi.cpp b/src/rest/RestApi.cpp similarity index 80% rename from src/rest/InfoApi.cpp rename to src/rest/RestApi.cpp index 00d0c24..325a2c0 100644 --- a/src/rest/InfoApi.cpp +++ b/src/rest/RestApi.cpp @@ -1,5 +1,5 @@ -#include "hyperliquid/rest/InfoApi.h" -#include "hyperliquid/rest/RestListener.h" +#include "hyperliquid/rest/RestApi.h" +#include "hyperliquid/rest/RestApiListener.h" #include #include @@ -28,7 +28,7 @@ class HttpSession : public std::enable_shared_from_this { public: HttpSession(net::io_context& ioc, ssl::context& sslCtx, const std::string& host, const std::string& port, - RestListener& listener, InfoEndpointType type) + RestApiListener& listener, RestEndpointType type) : resolver_(net::make_strand(ioc)) , stream_(net::make_strand(ioc), sslCtx) , host_(host) @@ -143,21 +143,37 @@ class HttpSession : public std::enable_shared_from_this { http::response res_; std::string host_; std::string port_; - RestListener& listener_; - InfoEndpointType type_; + RestApiListener& listener_; + RestEndpointType type_; }; // InfoApi pimpl -struct InfoApi::Impl { +struct RestApi::Impl { net::io_context ioc; net::executor_work_guard work; ssl::context sslCtx; std::thread thread; std::string host; std::string port; - RestListener& listener; + RestApiListener& listener; + Wallet wallet; + bool authenticated = false; - Impl(Environment env, RestListener& listener) + Impl(Environment env, RestApiListener& listener, Wallet wallet) + : work(net::make_work_guard(ioc)) + , sslCtx(ssl::context::tlsv12_client) + , host(toInfoEndpoint(env).host) + , port(toInfoEndpoint(env).port) + , listener(listener) + , wallet(wallet) + { + sslCtx.set_default_verify_paths(); + sslCtx.set_verify_mode(ssl::verify_peer); + thread = std::thread([this]() { ioc.run(); }); + authenticated = true; + } + + Impl(Environment env, RestApiListener& listener) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) , host(toInfoEndpoint(env).host) @@ -177,14 +193,19 @@ struct InfoApi::Impl { } }; -InfoApi::InfoApi(Environment env, RestListener& listener) +RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet) + : impl_(std::make_unique(env, listener)) +{ +} + +RestApi::RestApi(Environment env, RestApiListener& listener) : impl_(std::make_unique(env, listener)) { } -InfoApi::~InfoApi() = default; +RestApi::~RestApi() = default; -void InfoApi::sendRequest(InfoEndpointType type, const std::map& params) +void RestApi::sendRequest(RestEndpointType type, const std::map& params) { nlohmann::json body; body["type"] = toString(type); @@ -192,6 +213,17 @@ void InfoApi::sendRequest(InfoEndpointType type, const std::mapauthenticated) + { + std::cerr << "RestApi: Not authenticated - rejecting " << toString(type) << std::endl; + return; + } + + // TODO: Add the signature in order to send the request + } + auto session = std::make_shared( impl_->ioc, impl_->sslCtx, impl_->host, impl_->port, @@ -200,4 +232,4 @@ void InfoApi::sendRequest(InfoEndpointType type, const std::maprun(body.dump()); } -} // namespace hyperliquid +} diff --git a/src/rest/RestMessageParser.cpp b/src/rest/RestApiMessageParser.cpp similarity index 70% rename from src/rest/RestMessageParser.cpp rename to src/rest/RestApiMessageParser.cpp index b302f7b..46215e4 100644 --- a/src/rest/RestMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -1,22 +1,22 @@ -#include "hyperliquid/rest/RestMessageParser.h" +#include "hyperliquid/rest/RestApiMessageParser.h" #include #include namespace hyperliquid { - struct RestMessageParser::Impl + struct RestApiMessageParser::Impl { - InfoEndpointListener& listener; + RestEndpointListener& listener; simdjson::ondemand::parser parser; simdjson::padded_string padded; - explicit Impl(InfoEndpointListener& listener) : listener(listener) {} + explicit Impl(RestEndpointListener& listener) : listener(listener) {} - void parse(const std::string& message, InfoEndpointType type) + void parse(const std::string& message, RestEndpointType type) { switch (type) { - case InfoEndpointType::Meta: + case RestEndpointType::Meta: listener.onMeta(parseMeta(message)); break; default: @@ -54,14 +54,14 @@ namespace hyperliquid } }; - RestMessageParser::RestMessageParser(InfoEndpointListener& listener) + RestApiMessageParser::RestApiMessageParser(RestEndpointListener& listener) : impl_(std::make_unique(listener)) {} - RestMessageParser::~RestMessageParser() = default; - RestMessageParser::RestMessageParser(RestMessageParser&&) noexcept = default; - RestMessageParser& RestMessageParser::operator=(RestMessageParser&&) noexcept = default; + RestApiMessageParser::~RestApiMessageParser() = default; + RestApiMessageParser::RestApiMessageParser(RestApiMessageParser&&) noexcept = default; + RestApiMessageParser& RestApiMessageParser::operator=(RestApiMessageParser&&) noexcept = default; - void RestMessageParser::parse(const std::string& message, InfoEndpointType type) + void RestApiMessageParser::parse(const std::string& message, RestEndpointType type) { impl_->parse(message, type); } diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp new file mode 100644 index 0000000..ca4aeb7 --- /dev/null +++ b/src/signing/Signing.cpp @@ -0,0 +1 @@ +#include "Signing.h" \ No newline at end of file diff --git a/src/signing/Signing.h b/src/signing/Signing.h new file mode 100644 index 0000000..f71b6b3 --- /dev/null +++ b/src/signing/Signing.h @@ -0,0 +1,5 @@ +# pragma once + +class Signing +{ +}; diff --git a/src/websocket/MarketData.cpp b/src/websocket/MarketData.cpp deleted file mode 100644 index 793c75c..0000000 --- a/src/websocket/MarketData.cpp +++ /dev/null @@ -1,81 +0,0 @@ -#include "hyperliquid/websocket/MarketData.h" -#include "hyperliquid/websocket/WebsocketListener.h" -#include "WSRunner.h" -#include -#include - -namespace hyperliquid { - -struct MarketData::Impl : internal::WSListener { - internal::WSRunner ws; - WebsocketListener& listener; - bool stopping = false; - std::thread thread; - - Impl(Environment env, WebsocketListener& listener) - : ws(toWsEndpoint(env).host, toWsEndpoint(env).port, toWsEndpoint(env).path, *this), listener(listener) {} - - ~Impl() { - ws.stop(); - if (thread.joinable()) thread.join(); - } - - void onWsMessage(std::string& message) override { - if (stopping) return; - listener.onMessage(message); - } - - void onWsConnected() override { - listener.onConnected(); - } - - void onWsDisconnected(bool hasError, const std::string& errMsg) override { - listener.onDisconnected(hasError, errMsg); - } -}; - -MarketData::MarketData(Environment env, WebsocketListener& listener) : impl_(std::make_unique(env, listener)) {} - -MarketData::~MarketData() = default; - -void MarketData::subscribe(const SubscriptionType type, const std::map& filters) -{ - nlohmann::json subscribeMsg = { - {"method", "subscribe"}, - {"subscription", { - {"type", toString(type)}, - }} - }; - for (const auto& [key, value] : filters) { - subscribeMsg["subscription"][key] = value; - } - impl_->ws.send(subscribeMsg.dump()); -} - -void MarketData::unsubscribe(const SubscriptionType type, const std::map& filters) -{ - nlohmann::json unsubscribeMsg = { - {"method", "unsubscribe"}, - {"subscription", { - {"type", toString(type)}, - }} - }; - for (const auto& [key, value] : filters) { - unsubscribeMsg["subscription"][key] = value; - } - impl_->ws.send(unsubscribeMsg.dump()); -} - -void MarketData::start() { - impl_->thread = std::thread([this]() { - impl_->ws.start(); - }); -} - -void MarketData::stop() { - impl_->stopping = true; - impl_->ws.stop(); - if (impl_->thread.joinable()) impl_->thread.join(); -} - -} diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp new file mode 100644 index 0000000..0d000cb --- /dev/null +++ b/src/websocket/WebsocketApi.cpp @@ -0,0 +1,99 @@ +#include "hyperliquid/websocket/WebsocketApi.h" +#include "hyperliquid/websocket/WebsocketApiListener.h" +#include "WebsocketRunner.h" +#include +#include + +namespace hyperliquid +{ + struct WebsocketApi::Impl : internal::WSListener + { + internal::WebsocketRunner ws; + WebsocketApiListener& listener; + bool stopping = false; + std::thread thread; + + Impl(Environment env, WebsocketApiListener& listener) + : ws(toWsEndpoint(env).host, toWsEndpoint(env).port, toWsEndpoint(env).path, *this), listener(listener) + { + } + + ~Impl() + { + ws.stop(); + if (thread.joinable()) thread.join(); + } + + void onWsMessage(std::string& message) override + { + if (stopping) return; + listener.onMessage(message); + } + + void onWsConnected() override + { + listener.onConnected(); + } + + void onWsDisconnected(bool hasError, const std::string& errMsg) override + { + listener.onDisconnected(hasError, errMsg); + } + }; + + WebsocketApi::WebsocketApi(Environment env, WebsocketApiListener& listener) : impl_( + std::make_unique(env, listener)) + { + } + + WebsocketApi::~WebsocketApi() = default; + + void WebsocketApi::subscribe(const SubscriptionType type, const std::map& filters) + { + nlohmann::json subscribeMsg = { + {"method", "subscribe"}, + { + "subscription", { + {"type", toString(type)}, + } + } + }; + for (const auto& [key, value] : filters) + { + subscribeMsg["subscription"][key] = value; + } + impl_->ws.send(subscribeMsg.dump()); + } + + void WebsocketApi::unsubscribe(const SubscriptionType type, const std::map& filters) + { + nlohmann::json unsubscribeMsg = { + {"method", "unsubscribe"}, + { + "subscription", { + {"type", toString(type)}, + } + } + }; + for (const auto& [key, value] : filters) + { + unsubscribeMsg["subscription"][key] = value; + } + impl_->ws.send(unsubscribeMsg.dump()); + } + + void WebsocketApi::start() + { + impl_->thread = std::thread([this]() + { + impl_->ws.start(); + }); + } + + void WebsocketApi::stop() + { + impl_->stopping = true; + impl_->ws.stop(); + if (impl_->thread.joinable()) impl_->thread.join(); + } +} diff --git a/src/websocket/WSMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp similarity index 91% rename from src/websocket/WSMessageParser.cpp rename to src/websocket/WebsocketMessageParser.cpp index bc013ea..11b9401 100644 --- a/src/websocket/WSMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -1,4 +1,4 @@ -#include "hyperliquid/websocket/WSMessageParser.h" +#include "hyperliquid/websocket/WebsocketMessageParser.h" #include #include #include @@ -7,7 +7,7 @@ namespace hyperliquid { - struct WSMessageParser::Impl + struct WebsocketMessageParser::Impl { simdjson::ondemand::parser parser; simdjson::padded_string padded; @@ -19,7 +19,7 @@ namespace hyperliquid return val; } - void crack(const std::string& message, WSMessageHandler& listener) + void crack(const std::string& message, WebsocketMessageHandler& listener) { padded = simdjson::padded_string(message.data(), message.size()); auto doc = parser.iterate(padded); @@ -95,7 +95,7 @@ namespace hyperliquid } } - void crackSubscriptionResponse(simdjson::ondemand::object& data, WSMessageHandler& listener) + void crackSubscriptionResponse(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { SubscriptionResponse response; if (data["method"].get_string().value() == "subscribe") @@ -112,7 +112,7 @@ namespace hyperliquid } - void crackL2Book(simdjson::ondemand::object& data, WSMessageHandler& listener) + void crackL2Book(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { L2BookUpdate book; book.coin = std::string(data["coin"].get_string().value()); @@ -149,7 +149,7 @@ namespace hyperliquid } } - void crackBbo(simdjson::ondemand::object& data, WSMessageHandler& listener) + void crackBbo(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { BboUpdate update; update.coin = std::string(data["coin"].get_string().value()); @@ -190,7 +190,7 @@ namespace hyperliquid listener.onBbo(update); } - void crackTrades(simdjson::ondemand::array& data, WSMessageHandler& listener) + void crackTrades(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) { Trade trade; for (auto entry : data) @@ -230,7 +230,7 @@ namespace hyperliquid } } - void crackCandles(simdjson::ondemand::array& data, WSMessageHandler& listener) + void crackCandles(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) { Candle candle; for (auto entry : data) @@ -257,7 +257,7 @@ namespace hyperliquid } } - void crackAllMids(simdjson::ondemand::object& data, WSMessageHandler& listener) + void crackAllMids(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { auto mids = data["mids"].get_object().value(); AllMidsEntry entry; @@ -276,7 +276,7 @@ namespace hyperliquid } } - void crackActiveAssetCtx(simdjson::ondemand::object& data, WSMessageHandler& listener) + void crackActiveAssetCtx(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { std::string coin(data["coin"].get_string().value()); auto ctx = data["ctx"].get_object().value(); @@ -315,13 +315,13 @@ namespace hyperliquid } }; - WSMessageParser::WSMessageParser() : impl_(std::make_unique()) {} + WebsocketMessageParser::WebsocketMessageParser() : impl_(std::make_unique()) {} - WSMessageParser::~WSMessageParser() = default; - WSMessageParser::WSMessageParser(WSMessageParser&&) noexcept = default; - WSMessageParser& WSMessageParser::operator=(WSMessageParser&&) noexcept = default; + WebsocketMessageParser::~WebsocketMessageParser() = default; + WebsocketMessageParser::WebsocketMessageParser(WebsocketMessageParser&&) noexcept = default; + WebsocketMessageParser& WebsocketMessageParser::operator=(WebsocketMessageParser&&) noexcept = default; - void WSMessageParser::crack(const std::string& message, WSMessageHandler& listener) + void WebsocketMessageParser::crack(const std::string& message, WebsocketMessageHandler& listener) { impl_->crack(message, listener); } diff --git a/src/websocket/WSRunner.cpp b/src/websocket/WebsocketRunner.cpp similarity index 86% rename from src/websocket/WSRunner.cpp rename to src/websocket/WebsocketRunner.cpp index cafca69..3c007c3 100644 --- a/src/websocket/WSRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -1,11 +1,11 @@ -#include "WSRunner.h" +#include "WebsocketRunner.h" #include #include namespace hyperliquid { namespace internal { -WSRunner::WSRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener) +WebsocketRunner::WebsocketRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener) : listener_(listener) , sslCtx_(ssl::context::tlsv12_client) , resolver_(net::make_strand(ioc_)) @@ -18,13 +18,13 @@ WSRunner::WSRunner(const std::string& host, const std::string& port, const std:: sslCtx_.set_verify_mode(ssl::verify_peer); } -WSRunner::~WSRunner() { +WebsocketRunner::~WebsocketRunner() { stop(); } -net::io_context& WSRunner::getIoContext() { return ioc_; } +net::io_context& WebsocketRunner::getIoContext() { return ioc_; } -void WSRunner::send(const std::string& message) { +void WebsocketRunner::send(const std::string& message) { net::post(ws_->get_executor(), [this, msg = message]() { writeQueue_.push(msg); if (connected_ && !writing_) { @@ -33,12 +33,12 @@ void WSRunner::send(const std::string& message) { }); } -void WSRunner::start() { +void WebsocketRunner::start() { doResolve(); ioc_.run(); } -void WSRunner::stop() { +void WebsocketRunner::stop() { if (reconnectTimer_) reconnectTimer_->cancel(); if (pingTimer_) pingTimer_->cancel(); if (!ioc_.stopped() && !stopping_) { @@ -46,14 +46,14 @@ void WSRunner::stop() { } } -void WSRunner::doResolve() { +void WebsocketRunner::doResolve() { resolver_.async_resolve(host_, port_, [this](beast::error_code ec, tcp::resolver::results_type results) { onResolve(ec, results); }); } -void WSRunner::onResolve(beast::error_code ec, tcp::resolver::results_type results) { +void WebsocketRunner::onResolve(beast::error_code ec, tcp::resolver::results_type results) { if (ec) { std::cerr << "resolve error: " << ec.message() << std::endl; scheduleReconnect(); @@ -68,7 +68,7 @@ void WSRunner::onResolve(beast::error_code ec, tcp::resolver::results_type resul }); } -void WSRunner::onConnect(beast::error_code ec, tcp::resolver::results_type::endpoint_type) { +void WebsocketRunner::onConnect(beast::error_code ec, tcp::resolver::results_type::endpoint_type) { if (ec) { std::cerr << "connect error: " << ec.message() << std::endl; scheduleReconnect(); @@ -88,7 +88,7 @@ void WSRunner::onConnect(beast::error_code ec, tcp::resolver::results_type::endp [this](beast::error_code ec) { onSslHandshake(ec); }); } -void WSRunner::onSslHandshake(beast::error_code ec) { +void WebsocketRunner::onSslHandshake(beast::error_code ec) { if (ec) { std::cerr << "SSL handshake error: " << ec.message() << std::endl; scheduleReconnect(); @@ -106,7 +106,7 @@ void WSRunner::onSslHandshake(beast::error_code ec) { [this](beast::error_code ec) { onWsHandshake(ec); }); } -void WSRunner::onWsHandshake(beast::error_code ec) { +void WebsocketRunner::onWsHandshake(beast::error_code ec) { if (ec) { std::cerr << "WebSocket handshake error: " << ec.message() << std::endl; scheduleReconnect(); @@ -129,14 +129,14 @@ void WSRunner::onWsHandshake(beast::error_code ec) { doRead(); } -void WSRunner::doRead() { +void WebsocketRunner::doRead() { ws_->async_read(readBuf_, [this](beast::error_code ec, std::size_t bytesTransferred) { onRead(ec, bytesTransferred); }); } -void WSRunner::onRead(beast::error_code errorCode, std::size_t) { +void WebsocketRunner::onRead(beast::error_code errorCode, std::size_t) { if (errorCode) { if (stopping_) { // read cancelled, now safe to close @@ -173,7 +173,7 @@ void WSRunner::onRead(beast::error_code errorCode, std::size_t) { doRead(); } -void WSRunner::doWrite() { +void WebsocketRunner::doWrite() { if (writeQueue_.empty()) { writing_ = false; return; @@ -186,7 +186,7 @@ void WSRunner::doWrite() { }); } -void WSRunner::onWrite(beast::error_code ec, std::size_t) { +void WebsocketRunner::onWrite(beast::error_code ec, std::size_t) { if (ec) { std::cerr << "write error: " << ec.message() << std::endl; return; @@ -196,7 +196,7 @@ void WSRunner::onWrite(beast::error_code ec, std::size_t) { doWrite(); } -void WSRunner::doClose() { +void WebsocketRunner::doClose() { if (!connected_ || stopping_) return; if (pingTimer_) pingTimer_->cancel(); stopping_ = true; @@ -204,7 +204,7 @@ void WSRunner::doClose() { beast::get_lowest_layer(*ws_).cancel(); } -void WSRunner::scheduleReconnect() { +void WebsocketRunner::scheduleReconnect() { if (stopping_) return; reconnectAttempts_++; @@ -219,14 +219,14 @@ void WSRunner::scheduleReconnect() { }); } -void WSRunner::doReconnect() { +void WebsocketRunner::doReconnect() { pendingPong_ = false; ws_ = std::make_unique>>( net::make_strand(ioc_), sslCtx_); doResolve(); } -void WSRunner::setupControlCallback() { +void WebsocketRunner::setupControlCallback() { ws_->control_callback( [this](websocket::frame_type kind, beast::string_view) { if (kind == websocket::frame_type::pong) { @@ -236,7 +236,7 @@ void WSRunner::setupControlCallback() { }); } -void WSRunner::startPingTimer() { +void WebsocketRunner::startPingTimer() { if (stopping_ || !connected_) return; pingTimer_ = std::make_unique(ioc_, std::chrono::seconds(PING_INTERVAL_SECS)); @@ -247,7 +247,7 @@ void WSRunner::startPingTimer() { }); } -void WSRunner::doPing() { +void WebsocketRunner::doPing() { if (stopping_ || !connected_) return; if (pendingPong_) { diff --git a/src/websocket/WSRunner.h b/src/websocket/WebsocketRunner.h similarity index 93% rename from src/websocket/WSRunner.h rename to src/websocket/WebsocketRunner.h index 44b98a0..53c4c95 100644 --- a/src/websocket/WSRunner.h +++ b/src/websocket/WebsocketRunner.h @@ -35,10 +35,10 @@ class WSListener { virtual void onWsDisconnected(bool hasError, const std::string& errMsg) = 0; }; -class WSRunner { +class WebsocketRunner { public: - WSRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener); - ~WSRunner(); + WebsocketRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener); + ~WebsocketRunner(); void send(const std::string& message); From 8d001cbedf9e4cbaa4c262b22d6d1214e726d396 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Mon, 9 Mar 2026 22:09:14 +0000 Subject: [PATCH 04/15] add support for fetching all spot/perp metadata async/sync --- .gitignore | 9 +- CMakeLists.txt | 13 +- examples/basic_meta.cpp | 72 +++--- examples/basic_orders.cpp | 53 ++-- examples/test_config.h | 21 ++ include/hyperliquid/rest/RestApi.h | 18 +- .../hyperliquid/rest/RestApiMessageParser.h | 6 + .../hyperliquid/rest/RestEndpointListener.h | 3 + include/hyperliquid/types/RequestTypes.h | 104 +++++++- include/hyperliquid/types/ResponseTypes.h | 66 +++++ src/rest/RestApi.cpp | 242 +++++++++++++++--- src/rest/RestApiMessageParser.cpp | 200 ++++++++++++++- 12 files changed, 706 insertions(+), 101 deletions(-) create mode 100644 examples/test_config.h diff --git a/.gitignore b/.gitignore index b2262d0..171ce04 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ *.dll *.so.* - # Fortran module files *.mod *.smod @@ -66,4 +65,10 @@ vcpkg_installed/ # test output & cache Testing/ -.cache/ \ No newline at end of file +.cache/ + +# IDE +.idea/ + +# example config with secrets +test.json \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 9e1d723..8ee1a53 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,10 +55,11 @@ set_target_properties(hyperliquid-sdk PROPERTIES option(HYPERLIQUID_BUILD_EXAMPLES "Build example programs" OFF) if(HYPERLIQUID_BUILD_EXAMPLES) - add_executable(basic_book examples/basic_book.cpp) - target_link_libraries(basic_book PRIVATE hyperliquid-sdk) - add_executable(basic_orders examples/basic_orders.cpp) - target_link_libraries(basic_orders PRIVATE hyperliquid-sdk) - add_executable(basic_meta examples/basic_meta.cpp) - target_link_libraries(basic_meta PRIVATE hyperliquid-sdk) + file(GLOB EXAMPLE_SOURCES examples/*.cpp) + foreach(EXAMPLE_SOURCE ${EXAMPLE_SOURCES}) + get_filename_component(EXAMPLE_NAME ${EXAMPLE_SOURCE} NAME_WE) + add_executable(${EXAMPLE_NAME} ${EXAMPLE_SOURCE}) + target_link_libraries(${EXAMPLE_NAME} PRIVATE hyperliquid-sdk nlohmann_json::nlohmann_json) + target_compile_definitions(${EXAMPLE_NAME} PRIVATE EXAMPLES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/") + endforeach() endif() diff --git a/examples/basic_meta.cpp b/examples/basic_meta.cpp index 372347b..7ac85a8 100644 --- a/examples/basic_meta.cpp +++ b/examples/basic_meta.cpp @@ -1,48 +1,54 @@ #include -#include #include -#include #include -#include -#include -#include -#include "hyperliquid/types/ResponseTypes.h" +int main() { + hyperliquid::RestApi api(hyperliquid::Environment::Mainnet); + hyperliquid::RestApiMessageParser parser; -class MetaPrinter : public hyperliquid::RestApiListener, public hyperliquid::RestEndpointListener { -public: - std::atomic done{false}; + std::cout << "=== Spot ===" << std::endl; + auto spotMeta = parser.parseSpotMeta(api.spotMeta()); - // hyperliquid::RestListener — raw message arrives here - void onMessage(const std::string& message, hyperliquid::RestEndpointType type) override { - hyperliquid::RestApiMessageParser parser(*this); - parser.parse(message, type); + std::cout << spotMeta.tokens.size() << " assets:" << std::endl; + for (const auto& asset : spotMeta.tokens) { + std::cout << " " << asset.name + << " szDecimals=" << asset.szDecimals + << std::endl; } - // hyperliquid::InfoEndpointListener — parsed response arrives here - void onMeta(const hyperliquid::MetaResponse& response) override { - std::cout << "Universe: " << response.universe.size() << " assets" << std::endl; - std::cout << std::endl; - for (const auto& asset : response.universe) { - std::cout << asset.name - << " szDecimals=" << asset.szDecimals - << " maxLeverage=" << asset.maxLeverage - << std::endl; - } - done = true; + auto dexes = parser.parsePerpDexs(api.perpDexs()); + + std::cout << "=== Perps ===" << std::endl; + auto defaultMeta = parser.parseMeta(api.meta()); + + std::cout << defaultMeta.universe.size() << " assets:" << std::endl; + for (const auto& asset : defaultMeta.universe) { + std::cout << " " << asset.name + << " szDecimals=" << asset.szDecimals + << " maxLeverage=" << asset.maxLeverage + << std::endl; } -}; + std::cout << std::endl; -int main() { - MetaPrinter printer; - hyperliquid::RestApi api(hyperliquid::Environment::Mainnet, printer); + std::cout << "Found " << dexes.dexes.size() << " HIP-3 perp dexes:" << std::endl; + for (const auto& dex : dexes.dexes) { + std::cout << " " << dex.name << " (" << dex.fullName << ")" + << " deployer=" << dex.deployer << std::endl; + } + std::cout << std::endl; - std::cout << "Fetching meta from Hyperliquid... (dex=xyz)" << std::endl; - api.sendRequest(hyperliquid::RestEndpointType::Meta, {{"dex", "xyz"}}); + for (const auto& dex : dexes.dexes) { + std::cout << "=== " << dex.name << " ===" << std::endl; + auto meta = parser.parseMeta(api.meta(dex.name)); - // Wait for the async response - while (!printer.done) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + std::cout << meta.universe.size() << " assets:" << std::endl; + for (const auto& asset : meta.universe) { + std::cout << " " << asset.name + << " szDecimals=" << asset.szDecimals + << " maxLeverage=" << asset.maxLeverage + << std::endl; + } + std::cout << std::endl; } return 0; diff --git a/examples/basic_orders.cpp b/examples/basic_orders.cpp index 9336df9..b975bf0 100644 --- a/examples/basic_orders.cpp +++ b/examples/basic_orders.cpp @@ -1,3 +1,5 @@ +#include "test_config.h" + #include #include #include @@ -11,21 +13,23 @@ class OrderSender : public hyperliquid::RestApiListener, public hyperliquid::Res public: std::atomic done{false}; - // hyperliquid::RestListener — raw message arrives here void onMessage(const std::string& message, hyperliquid::RestEndpointType type) override { hyperliquid::RestApiMessageParser parser(*this); parser.parse(message, type); } - // hyperliquid::InfoEndpointListener — parsed response arrives here - void onMeta(const hyperliquid::MetaResponse& response) override { - std::cout << "Universe: " << response.universe.size() << " assets" << std::endl; - std::cout << std::endl; - for (const auto& asset : response.universe) { - std::cout << asset.name - << " szDecimals=" << asset.szDecimals - << " maxLeverage=" << asset.maxLeverage - << std::endl; + void onPlaceOrder(const hyperliquid::PlaceOrderResponse& response) override { + std::cout << "Order response status: " << response.status << std::endl; + for (const auto& status : response.statuses) { + if (status.resting) { + std::cout << " Resting oid=" << status.resting->oid << std::endl; + } else if (status.filled) { + std::cout << " Filled oid=" << status.filled->oid + << " avgPx=" << status.filled->avgPx + << " totalSz=" << status.filled->totalSz << std::endl; + } else if (status.error) { + std::cout << " Error: " << *status.error << std::endl; + } } done = true; } @@ -33,19 +37,22 @@ class OrderSender : public hyperliquid::RestApiListener, public hyperliquid::Res int main() { - OrderSender printer; - hyperliquid::Wallet wallet{"", ""}; - hyperliquid::RestApi api(hyperliquid::Environment::Mainnet, printer, wallet); - - std::cout << "Sending order to Hyperliquid..." << std::endl; - api.sendRequest(hyperliquid::RestEndpointType::PlaceOrder, { - {"orders", [ - - ]} - }); - - // Wait for the async response - while (!printer.done) { + OrderSender sender; + auto wallet = loadWalletFromConfig(); + hyperliquid::RestApi api(hyperliquid::Environment::Testnet, sender, wallet); + + hyperliquid::OrderRequest order; + order.asset = "BTC"; + order.isBuy = true; + order.price = "1800.0"; + order.size = "0.01"; + order.reduceOnly = false; + order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + + std::cout << "Placing order on Hyperliquid testnet..." << std::endl; + api.placeOrderAsync({order}, hyperliquid::Grouping::Na); + + while (!sender.done) { std::this_thread::sleep_for(std::chrono::milliseconds(100)); } diff --git a/examples/test_config.h b/examples/test_config.h new file mode 100644 index 0000000..662f47b --- /dev/null +++ b/examples/test_config.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include +#include + +inline hyperliquid::Wallet loadWalletFromConfig(const std::string& path = EXAMPLES_DIR "test.json") +{ + std::ifstream file(path); + if (!file.is_open()) + throw std::runtime_error("Could not open config file: " + path); + + auto config = nlohmann::json::parse(file); + return hyperliquid::Wallet{ + config.at("wallet").get(), + config.at("privateKey").get() + }; +} diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index 955f568..e95afdf 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -1,8 +1,9 @@ #pragma once -#include #include +#include #include +#include #include "../types/RequestTypes.h" #include "RestApiListener.h" @@ -14,12 +15,25 @@ class RestApi { public: RestApi(Environment env, RestApiListener& listener, Wallet wallet); RestApi(Environment env, RestApiListener& listener); + RestApi(Environment env); ~RestApi(); RestApi(const RestApi&) = delete; RestApi& operator=(const RestApi&) = delete; - void sendRequest(RestEndpointType type, const std::map& params = {}); + std::string spotMeta(); + std::string meta(const std::optional& dex = std::nullopt); + std::string perpDexs(); + std::string placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder = std::nullopt); + + void spotMetaAsync(); + void metaAsync(const std::optional& dex = std::nullopt); + void perpDexsAsync(); + void placeOrderAsync(const std::vector& orders, + Grouping grouping, + const std::optional& builder = std::nullopt); private: struct Impl; diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h index d5ab2d2..cf2817a 100644 --- a/include/hyperliquid/rest/RestApiMessageParser.h +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -10,6 +10,7 @@ namespace hyperliquid class RestApiMessageParser { public: + RestApiMessageParser(); explicit RestApiMessageParser(RestEndpointListener& listener); ~RestApiMessageParser(); @@ -20,6 +21,11 @@ namespace hyperliquid void parse(const std::string& message, RestEndpointType type); + SpotMetaResponse parseSpotMeta(const std::string& message); + MetaResponse parseMeta(const std::string& message); + PerpDexsResponse parsePerpDexs(const std::string& message); + PlaceOrderResponse parsePlaceOrder(const std::string& message); + private: struct Impl; std::unique_ptr impl_; diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h index b8425a6..92759de 100644 --- a/include/hyperliquid/rest/RestEndpointListener.h +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -8,7 +8,10 @@ class RestEndpointListener { public: virtual ~RestEndpointListener() = default; + virtual void onSpotMeta(const SpotMetaResponse& response) {} virtual void onMeta(const MetaResponse& response) {} + virtual void onPerpDexs(const PerpDexsResponse& response) {} + virtual void onPlaceOrder(const PlaceOrderResponse& response) {} }; } diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index b306e45..2ed7502 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -1,6 +1,9 @@ #pragma once +#include +#include #include #include +#include namespace hyperliquid { @@ -122,7 +125,7 @@ namespace hyperliquid enum class RestEndpointType { - // Info endpoints + // Info endpoints (Perpetuals) Meta, MetaAndAssetCtxs, AllMids, @@ -133,6 +136,14 @@ namespace hyperliquid UserFillsByTime, OrderStatus, UserRateLimit, + PerpDexs, + // Info endpoints (Spot) + SpotMeta, + SpotMetaAndAssetCtxs, + SpotClearinghouseState, + SpotDeployState, + SpotPairDeployAuctionStatus, + TokenDetails, // Exchange endpoints (signed) PlaceOrder, @@ -157,6 +168,15 @@ namespace hyperliquid case RestEndpointType::UserFillsByTime: return "userFillsByTime"; case RestEndpointType::OrderStatus: return "orderStatus"; case RestEndpointType::UserRateLimit: return "userRateLimit"; + case RestEndpointType::PerpDexs: return "perpDexs"; + + case RestEndpointType::SpotMeta: return "spotMeta"; + case RestEndpointType::SpotMetaAndAssetCtxs: return "spotMetaAndAssetCtxs"; + case RestEndpointType::SpotClearinghouseState: return "spotClearinghouseState"; + case RestEndpointType::SpotDeployState: return "spotDeployState"; + case RestEndpointType::SpotPairDeployAuctionStatus: return "spotPairDeployAuctionStatus"; + case RestEndpointType::TokenDetails: return "tokenDetails"; + case RestEndpointType::PlaceOrder: return "order"; case RestEndpointType::CancelOrder: return "cancel"; case RestEndpointType::CancelOrderByCloid: return "cancelByCloid"; @@ -181,6 +201,15 @@ namespace hyperliquid case RestEndpointType::UserFillsByTime: return false; case RestEndpointType::OrderStatus: return false; case RestEndpointType::UserRateLimit: return false; + case RestEndpointType::PerpDexs: return false; + + case RestEndpointType::SpotMeta: return false; + case RestEndpointType::SpotMetaAndAssetCtxs: return false; + case RestEndpointType::SpotClearinghouseState: return false; + case RestEndpointType::SpotDeployState: return false; + case RestEndpointType::SpotPairDeployAuctionStatus: return false; + case RestEndpointType::TokenDetails: return false; + case RestEndpointType::PlaceOrder: return true; case RestEndpointType::CancelOrder: return true; case RestEndpointType::CancelOrderByCloid: return true; @@ -190,4 +219,77 @@ namespace hyperliquid default: throw std::invalid_argument("Unknown RestEndpointType"); } } + + inline std::string toPath(RestEndpointType type) + { + return isAuthenticated(type) ? "/exchange" : "/info"; + } + + enum class Tif { Alo, Ioc, Gtc }; + + inline std::string toString(Tif tif) + { + switch (tif) + { + case Tif::Alo: return "Alo"; + case Tif::Ioc: return "Ioc"; + case Tif::Gtc: return "Gtc"; + default: throw std::invalid_argument("Unknown Tif"); + } + } + + enum class TpSl { Tp, Sl }; + + inline std::string toString(TpSl tpsl) + { + switch (tpsl) + { + case TpSl::Tp: return "tp"; + case TpSl::Sl: return "sl"; + default: throw std::invalid_argument("Unknown TpSl"); + } + } + + struct LimitOrderType + { + Tif tif; + }; + + struct TriggerOrderType + { + bool isMarket; + std::string triggerPx; + TpSl tpsl; + }; + + struct OrderRequest + { + std::string asset; + bool isBuy; + std::string price; + std::string size; + bool reduceOnly; + std::optional limit; + std::optional trigger; + std::optional cloid; + }; + + enum class Grouping { Na, NormalTpsl, PositionTpsl }; + + inline std::string toString(Grouping grouping) + { + switch (grouping) + { + case Grouping::Na: return "na"; + case Grouping::NormalTpsl: return "normalTpsl"; + case Grouping::PositionTpsl: return "positionTpsl"; + default: throw std::invalid_argument("Unknown Grouping"); + } + } + + struct Builder + { + std::string address; + int fee; + }; } diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index fadefc6..8ea3304 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -305,5 +306,70 @@ namespace hyperliquid std::vector universe; }; + struct EvmContract + { + std::string address; + int evm_extra_wei_decimals; + }; + + struct SpotAssetMeta + { + std::string name; + int szDecimals; + int weiDecimals; + int index; + std::string tokenId; + bool isCanonical; + std::optional evmContract; + std::optional fullName; + }; + + struct SpotMetaResponse + { + std::vector tokens; + }; + + struct PerpDex + { + std::string name; + std::string fullName; + std::string deployer; + std::optional oracleUpdater; + std::optional feeRecipient; + std::vector> assetToStreamingOiCap; + std::vector> assetToFundingMultiplier; + }; + + struct PerpDexsResponse + { + std::vector dexes; + }; + // --- Rest endpoint types (authenticated) --- + + struct OrderStatusResting + { + uint64_t oid; + }; + + struct OrderStatusFilled + { + std::string totalSz; + std::string avgPx; + uint64_t oid; + }; + + struct OrderStatusResult + { + std::optional resting; + std::optional filled; + std::optional error; + }; + + struct PlaceOrderResponse + { + std::string status; + std::string type; + std::vector statuses; + }; } diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 325a2c0..681b2ac 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -1,6 +1,9 @@ #include "hyperliquid/rest/RestApi.h" #include "hyperliquid/rest/RestApiListener.h" +#include +#include +#include #include #include #include @@ -23,25 +26,27 @@ namespace net = boost::asio; namespace ssl = boost::asio::ssl; using tcp = boost::asio::ip::tcp; -// Per-request session that lives through the async chain via shared_ptr +using OnComplete = std::function; + class HttpSession : public std::enable_shared_from_this { public: HttpSession(net::io_context& ioc, ssl::context& sslCtx, const std::string& host, const std::string& port, - RestApiListener& listener, RestEndpointType type) + const std::string& path, + OnComplete onComplete) : resolver_(net::make_strand(ioc)) , stream_(net::make_strand(ioc), sslCtx) , host_(host) , port_(port) - , listener_(listener) - , type_(type) + , path_(path) + , onComplete_(onComplete) { } void run(const std::string& body) { req_.method(http::verb::post); - req_.target("/info"); + req_.target(path_); req_.version(11); req_.set(http::field::host, host_); req_.set(http::field::content_type, "application/json"); @@ -59,13 +64,13 @@ class HttpSession : public std::enable_shared_from_this { void onResolve(beast::error_code ec, tcp::resolver::results_type results) { if (ec) { - std::cerr << "InfoApi resolve error: " << ec.message() << std::endl; + onComplete_("", ec); return; } if (!SSL_set_tlsext_host_name(stream_.native_handle(), host_.c_str())) { ec = beast::error_code(static_cast(::ERR_get_error()), net::error::get_ssl_category()); - std::cerr << "InfoApi SNI error: " << ec.message() << std::endl; + onComplete_("", ec); return; } @@ -79,7 +84,7 @@ class HttpSession : public std::enable_shared_from_this { void onConnect(beast::error_code ec) { if (ec) { - std::cerr << "InfoApi connect error: " << ec.message() << std::endl; + onComplete_("", ec); return; } @@ -93,7 +98,7 @@ class HttpSession : public std::enable_shared_from_this { void onSslHandshake(beast::error_code ec) { if (ec) { - std::cerr << "InfoApi SSL handshake error: " << ec.message() << std::endl; + onComplete_("", ec); return; } @@ -107,7 +112,7 @@ class HttpSession : public std::enable_shared_from_this { void onWrite(beast::error_code ec) { if (ec) { - std::cerr << "InfoApi write error: " << ec.message() << std::endl; + onComplete_("", ec); return; } @@ -120,11 +125,11 @@ class HttpSession : public std::enable_shared_from_this { void onRead(beast::error_code ec) { if (ec) { - std::cerr << "InfoApi read error: " << ec.message() << std::endl; + onComplete_("", ec); return; } - listener_.onMessage(res_.body(), type_); + onComplete_(res_.body(), {}); beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(5)); stream_.async_shutdown( @@ -143,11 +148,12 @@ class HttpSession : public std::enable_shared_from_this { http::response res_; std::string host_; std::string port_; - RestApiListener& listener_; - RestEndpointType type_; + std::string path_; + OnComplete onComplete_; }; -// InfoApi pimpl +static RestApiListener defaultListener; + struct RestApi::Impl { net::io_context ioc; net::executor_work_guard work; @@ -185,12 +191,87 @@ struct RestApi::Impl { thread = std::thread([this]() { ioc.run(); }); } + Impl(Environment env) + : work(net::make_work_guard(ioc)) + , sslCtx(ssl::context::tlsv12_client) + , host(toInfoEndpoint(env).host) + , port(toInfoEndpoint(env).port) + , listener(defaultListener) + { + sslCtx.set_default_verify_paths(); + sslCtx.set_verify_mode(ssl::verify_peer); + thread = std::thread([this]() { ioc.run(); }); + } + ~Impl() { work.reset(); ioc.stop(); if (thread.joinable()) thread.join(); } + + nlohmann::json prepareBody(RestEndpointType type, nlohmann::json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + if (vaultAddress) body["vaultAddress"] = *vaultAddress; + if (expiresAfter) body["expiresAfter"] = *expiresAfter; + + if (isAuthenticated(type)) + { + body["nonce"] = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + body["signature"] = "xyz"; + } + + return body; + } + + void signAndSend(RestEndpointType type, nlohmann::json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + auto prepared = prepareBody(type, body, vaultAddress, expiresAfter); + std::string serialized = prepared.dump(); + + auto session = std::make_shared( + ioc, sslCtx, host, port, toPath(type), + [this, type](const std::string& responseBody, beast::error_code ec) { + if (ec) { + std::cerr << "RestApi: error for " << toString(type) << ": " << ec.message() << std::endl; + return; + } + listener.onMessage(responseBody, type); + }); + + session->run(serialized); + } + + std::string signAndSendSync(RestEndpointType type, nlohmann::json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + auto prepared = prepareBody(type, body, vaultAddress, expiresAfter); + std::string serialized = prepared.dump(); + + auto promise = std::make_shared>(); + auto future = promise->get_future(); + + auto session = std::make_shared( + ioc, sslCtx, host, port, toPath(type), + [promise](const std::string& responseBody, beast::error_code ec) { + if (ec) { + promise->set_exception(std::make_exception_ptr( + std::runtime_error("RestApi: " + ec.message()))); + return; + } + promise->set_value(responseBody); + }); + + session->run(serialized); + return future.get(); + } }; RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet) @@ -203,33 +284,130 @@ RestApi::RestApi(Environment env, RestApiListener& listener) { } +RestApi::RestApi(Environment env) + : impl_(std::make_unique(env)) +{ +} + RestApi::~RestApi() = default; -void RestApi::sendRequest(RestEndpointType type, const std::map& params) +static nlohmann::json buildSpotMetaBody() { nlohmann::json body; - body["type"] = toString(type); - for (const auto& [key, value] : params) { - body[key] = value; - } + body["type"] = toString(RestEndpointType::SpotMeta); + return body; +} + +static nlohmann::json buildMetaBody(const std::optional& dex) +{ + nlohmann::json body; + body["type"] = toString(RestEndpointType::Meta); + if (dex) body["dex"] = *dex; + return body; +} - if (isAuthenticated(type)) +static nlohmann::json buildPerpDexsBody() +{ + nlohmann::json body; + body["type"] = toString(RestEndpointType::PerpDexs); + return body; +} + +static nlohmann::json buildPlaceOrderBody(const std::vector& orders, + Grouping grouping, + const std::optional& builder) +{ + nlohmann::json ordersJson = nlohmann::json::array(); + for (const auto& order : orders) { - if (!impl_->authenticated) - { - std::cerr << "RestApi: Not authenticated - rejecting " << toString(type) << std::endl; - return; - } + nlohmann::json orderJson; + orderJson["a"] = order.asset; + orderJson["b"] = order.isBuy; + orderJson["p"] = order.price; + orderJson["s"] = order.size; + orderJson["r"] = order.reduceOnly; + + if (order.limit) + { + orderJson["t"] = {{"limit", {{"tif", toString(order.limit->tif)}}}}; + } + else if (order.trigger) + { + orderJson["t"] = {{"trigger", { + {"isMarket", order.trigger->isMarket}, + {"triggerPx", order.trigger->triggerPx}, + {"tpsl", toString(order.trigger->tpsl)} + }}}; + } + + if (order.cloid) orderJson["c"] = *order.cloid; - // TODO: Add the signature in order to send the request + ordersJson.push_back(orderJson); } - auto session = std::make_shared( - impl_->ioc, impl_->sslCtx, - impl_->host, impl_->port, - impl_->listener, type); + nlohmann::json action; + action["type"] = "order"; + action["orders"] = ordersJson; + action["grouping"] = toString(grouping); + + if (builder) + { + action["builder"] = {{"b", builder->address}, {"f", builder->fee}}; + } + + nlohmann::json body; + body["action"] = action; + return body; +} + +// Sync methods + +std::string RestApi::spotMeta() +{ + return impl_->signAndSendSync(RestEndpointType::SpotMeta, buildSpotMetaBody()); +} + +std::string RestApi::meta(const std::optional& dex) +{ + return impl_->signAndSendSync(RestEndpointType::Meta, buildMetaBody(dex)); +} + +std::string RestApi::perpDexs() +{ + return impl_->signAndSendSync(RestEndpointType::PerpDexs, buildPerpDexsBody()); +} - session->run(body.dump()); +std::string RestApi::placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder) +{ + return impl_->signAndSendSync(RestEndpointType::PlaceOrder, + buildPlaceOrderBody(orders, grouping, builder)); +} + +// Async methods + +void RestApi::spotMetaAsync() +{ + return impl_->signAndSend(RestEndpointType::SpotMeta, buildSpotMetaBody()); +} + +void RestApi::metaAsync(const std::optional& dex) +{ + impl_->signAndSend(RestEndpointType::Meta, buildMetaBody(dex)); +} + +void RestApi::perpDexsAsync() +{ + impl_->signAndSend(RestEndpointType::PerpDexs, buildPerpDexsBody()); +} + +void RestApi::placeOrderAsync(const std::vector& orders, + Grouping grouping, + const std::optional& builder) +{ + impl_->signAndSend(RestEndpointType::PlaceOrder, + buildPlaceOrderBody(orders, grouping, builder)); } } diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index 46215e4..cc29d41 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -16,16 +16,186 @@ namespace hyperliquid { switch (type) { + case RestEndpointType::SpotMeta: + listener.onSpotMeta(parseSpotMeta(message)); + break; case RestEndpointType::Meta: listener.onMeta(parseMeta(message)); break; + case RestEndpointType::PerpDexs: + listener.onPerpDexs(parsePerpDexs(message)); + break; + case RestEndpointType::PlaceOrder: + listener.onPlaceOrder(parsePlaceOrder(message)); + break; default: - std::cerr << "RestMessageParser: unhandled InfoEndpointType: " + std::cerr << "RestMessageParser: unhandled RestEndpointType: " << toString(type) << std::endl; break; } } + PlaceOrderResponse parsePlaceOrder(const std::string& message) + { + PlaceOrderResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + response.status = std::string(doc["status"].get_string().value()); + + if (response.status != "ok") return response; + + auto resp = doc["response"].get_object().value(); + response.type = std::string(resp["type"].get_string().value()); + + auto statuses = resp["data"]["statuses"].get_array().value(); + for (auto entry : statuses) + { + auto obj = entry.get_object().value(); + OrderStatusResult result; + + simdjson::ondemand::value resting; + if (obj["resting"].get(resting) == simdjson::SUCCESS) + { + auto restingObj = resting.get_object().value(); + OrderStatusResting rest; + rest.oid = restingObj["oid"].get_uint64().value(); + result.resting = std::move(rest); + } + + simdjson::ondemand::value filled; + if (obj["filled"].get(filled) == simdjson::SUCCESS) + { + auto filledObj = filled.get_object().value(); + OrderStatusFilled fill; + fill.totalSz = std::string(filledObj["totalSz"].get_string().value()); + fill.avgPx = std::string(filledObj["avgPx"].get_string().value()); + fill.oid = filledObj["oid"].get_uint64().value(); + result.filled = std::move(fill); + } + + simdjson::ondemand::value error; + if (obj["error"].get(error) == simdjson::SUCCESS) + { + result.error = std::string(error.get_string().value()); + } + + response.statuses.push_back(std::move(result)); + } + } + catch (const simdjson::simdjson_error& err) + { + std::cerr << "RestMessageParser: parse error in placeOrder: " << err.what() + << "\n raw: " << message << std::endl; + } + + return response; + } + + PerpDexsResponse parsePerpDexs(const std::string& message) + { + PerpDexsResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + auto arr = doc.get_array().value(); + bool firstElement = true; + for (auto entry : arr) + { + if (firstElement) { + firstElement = false; // Always null + continue; + } + auto obj = entry.get_object().value(); + PerpDex dex; + dex.name = std::string(obj["name"].get_string().value()); + dex.fullName = std::string(obj["fullName"].get_string().value()); + dex.deployer = std::string(obj["deployer"].get_string().value()); + simdjson::ondemand::value oracleUpdater; + if (obj["oracleUpdater"].get(oracleUpdater) == simdjson::SUCCESS && !oracleUpdater.is_null()) + dex.oracleUpdater = std::string(oracleUpdater.get_string().value()); + + simdjson::ondemand::value feeRecipient; + if (obj["feeRecipient"].get(feeRecipient) == simdjson::SUCCESS && !feeRecipient.is_null()) + dex.feeRecipient = std::string(feeRecipient.get_string().value()); + + auto oiCaps = obj["assetToStreamingOiCap"].get_array().value(); + for (auto pair : oiCaps) + { + auto pairArr = pair.get_array().value(); + auto iter = pairArr.begin(); + std::string asset = std::string((*iter).get_string().value()); + ++iter; + std::string cap = std::string((*iter).get_string().value()); + dex.assetToStreamingOiCap.emplace_back(asset, cap); + } + + auto fundingMults = obj["assetToFundingMultiplier"].get_array().value(); + for (auto pair : fundingMults) + { + auto pairArr = pair.get_array().value(); + auto iter = pairArr.begin(); + std::string asset = std::string((*iter).get_string().value()); + ++iter; + std::string multiplier = std::string((*iter).get_string().value()); + dex.assetToFundingMultiplier.emplace_back(asset, multiplier); + } + + response.dexes.push_back(std::move(dex)); + } + } + catch (const simdjson::simdjson_error& err) + { + std::cerr << "RestMessageParser: parse error in perpDexs: " << err.what() + << "\n raw: " << message << std::endl; + } + + return response; + } + + SpotMetaResponse parseSpotMeta(const std::string& message) + { + SpotMetaResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + auto tokens = doc["tokens"].get_array().value(); + for (auto entry : tokens) + { + auto obj = entry.get_object().value(); + SpotAssetMeta token; + token.name = std::string(obj["name"].get_string().value()); + token.szDecimals = static_cast(obj["szDecimals"].get_int64().value()); + token.weiDecimals = static_cast(obj["weiDecimals"].get_int64().value()); + token.index = static_cast(obj["index"].get_int64().value()); + token.tokenId = std::string(obj["tokenId"].get_string().value()); + simdjson::ondemand::value evmContract; + if (obj["evmContract"].get(evmContract) == simdjson::SUCCESS && !evmContract.is_null()) { + auto address = std::string(evmContract["address"].get_string().value()); + auto evmExtraWeiDecimals = static_cast(evmContract["evm_extra_wei_decimals"].get_int64().value()); + token.evmContract = EvmContract{address, evmExtraWeiDecimals}; + } + simdjson::ondemand::value fullName; + if (obj["fullName"].get(fullName) == simdjson::SUCCESS && !fullName.is_null()) + token.fullName = std::string(fullName.get_string().value()); + response.tokens.push_back(token); + } + } + catch (const simdjson::simdjson_error& e) + { + std::cerr << "RestMessageParser: parse error in spotMeta: " << e.what() + << "\n raw: " << message << std::endl; + } + + return response; + } + MetaResponse parseMeta(const std::string& message) { MetaResponse response; @@ -47,13 +217,19 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "RestMessageParser: parse error in meta: " << e.what() << std::endl; + std::cerr << "RestMessageParser: parse error in meta: " << e.what() + << "\n raw: " << message << std::endl; } return response; } }; + static RestEndpointListener defaultEndpointListener; + + RestApiMessageParser::RestApiMessageParser() + : impl_(std::make_unique(defaultEndpointListener)) {} + RestApiMessageParser::RestApiMessageParser(RestEndpointListener& listener) : impl_(std::make_unique(listener)) {} @@ -65,4 +241,24 @@ namespace hyperliquid { impl_->parse(message, type); } + + SpotMetaResponse RestApiMessageParser::parseSpotMeta(const std::string& message) + { + return impl_->parseSpotMeta(message); + } + + MetaResponse RestApiMessageParser::parseMeta(const std::string& message) + { + return impl_->parseMeta(message); + } + + PerpDexsResponse RestApiMessageParser::parsePerpDexs(const std::string& message) + { + return impl_->parsePerpDexs(message); + } + + PlaceOrderResponse RestApiMessageParser::parsePlaceOrder(const std::string& message) + { + return impl_->parsePlaceOrder(message); + } } // namespace hyperliquid From 5bd21cafe73375f0b24b4aabf546b5f3df06c991 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Mon, 9 Mar 2026 22:46:44 +0000 Subject: [PATCH 05/15] build symbol to hyperliquid security id map --- CMakeLists.txt | 4 + src/rest/ExchangeRequestBuilder.cpp | 59 ++++++ src/rest/ExchangeRequestBuilder.h | 25 +++ src/rest/HttpSession.cpp | 118 ++++++++++++ src/rest/HttpSession.h | 51 +++++ src/rest/InfoRequestBuilder.cpp | 27 +++ src/rest/InfoRequestBuilder.h | 19 ++ src/rest/RestApi.cpp | 277 ++++++---------------------- src/rest/SymbolMap.cpp | 20 ++ src/rest/SymbolMap.h | 18 ++ 10 files changed, 401 insertions(+), 217 deletions(-) create mode 100644 src/rest/ExchangeRequestBuilder.cpp create mode 100644 src/rest/ExchangeRequestBuilder.h create mode 100644 src/rest/HttpSession.cpp create mode 100644 src/rest/HttpSession.h create mode 100644 src/rest/InfoRequestBuilder.cpp create mode 100644 src/rest/InfoRequestBuilder.h create mode 100644 src/rest/SymbolMap.cpp create mode 100644 src/rest/SymbolMap.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 8ee1a53..8f91a26 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -12,6 +12,10 @@ find_package(nlohmann_json CONFIG REQUIRED) set(SDK_SOURCES src/rest/RestApiMessageParser.cpp src/rest/RestApi.cpp + src/rest/HttpSession.cpp + src/rest/InfoRequestBuilder.cpp + src/rest/ExchangeRequestBuilder.cpp + src/rest/SymbolMap.cpp src/websocket/WebsocketRunner.cpp src/websocket/WebsocketApi.cpp diff --git a/src/rest/ExchangeRequestBuilder.cpp b/src/rest/ExchangeRequestBuilder.cpp new file mode 100644 index 0000000..7094687 --- /dev/null +++ b/src/rest/ExchangeRequestBuilder.cpp @@ -0,0 +1,59 @@ +#include "ExchangeRequestBuilder.h" + +#include + +namespace hyperliquid { + +ExchangeRequestBuilder::ExchangeRequestBuilder(const SymbolMap& symbolMap) + : symbolMap_(symbolMap) +{ +} + +nlohmann::json ExchangeRequestBuilder::placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder) const +{ + nlohmann::json ordersJson = nlohmann::json::array(); + for (const auto& order : orders) + { + nlohmann::json orderJson; + orderJson["a"] = symbolMap_.resolve(order.asset); + orderJson["b"] = order.isBuy; + orderJson["p"] = order.price; + orderJson["s"] = order.size; + orderJson["r"] = order.reduceOnly; + + if (order.limit) + { + orderJson["t"] = {{"limit", {{"tif", toString(order.limit->tif)}}}}; + } + else if (order.trigger) + { + orderJson["t"] = {{"trigger", { + {"isMarket", order.trigger->isMarket}, + {"triggerPx", order.trigger->triggerPx}, + {"tpsl", toString(order.trigger->tpsl)} + }}}; + } + + if (order.cloid) orderJson["c"] = *order.cloid; + + ordersJson.push_back(orderJson); + } + + nlohmann::json action; + action["type"] = "order"; + action["orders"] = ordersJson; + action["grouping"] = toString(grouping); + + if (builder) + { + action["builder"] = {{"b", builder->address}, {"f", builder->fee}}; + } + + nlohmann::json body; + body["action"] = action; + return body; +} + +} diff --git a/src/rest/ExchangeRequestBuilder.h b/src/rest/ExchangeRequestBuilder.h new file mode 100644 index 0000000..ed12b02 --- /dev/null +++ b/src/rest/ExchangeRequestBuilder.h @@ -0,0 +1,25 @@ +#pragma once + +#include +#include + +#include + +#include "SymbolMap.h" +#include "hyperliquid/types/RequestTypes.h" + +namespace hyperliquid { + +class ExchangeRequestBuilder { +public: + explicit ExchangeRequestBuilder(const SymbolMap& symbolMap); + + nlohmann::json placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder = std::nullopt) const; + +private: + const SymbolMap& symbolMap_; +}; + +} // namespace hyperliquid diff --git a/src/rest/HttpSession.cpp b/src/rest/HttpSession.cpp new file mode 100644 index 0000000..05fe34e --- /dev/null +++ b/src/rest/HttpSession.cpp @@ -0,0 +1,118 @@ +#include "HttpSession.h" + +#include +#include + +namespace hyperliquid { + +HttpSession::HttpSession(net::io_context& ioc, ssl::context& sslCtx, + const std::string& host, const std::string& port, + const std::string& path, + OnComplete onComplete) + : resolver_(net::make_strand(ioc)) + , stream_(net::make_strand(ioc), sslCtx) + , host_(host) + , port_(port) + , path_(path) + , onComplete_(onComplete) +{ +} + +void HttpSession::run(const std::string& body) +{ + req_.method(http::verb::post); + req_.target(path_); + req_.version(11); + req_.set(http::field::host, host_); + req_.set(http::field::content_type, "application/json"); + req_.set(http::field::user_agent, "hyperliquid-sdk-cpp/0.1"); + req_.body() = body; + req_.prepare_payload(); + + resolver_.async_resolve(host_, port_, + [self = shared_from_this()](beast::error_code ec, tcp::resolver::results_type results) { + self->onResolve(ec, results); + }); +} + +void HttpSession::onResolve(beast::error_code ec, tcp::resolver::results_type results) +{ + if (ec) { + onComplete_("", ec); + return; + } + + if (!SSL_set_tlsext_host_name(stream_.native_handle(), host_.c_str())) { + ec = beast::error_code(static_cast(::ERR_get_error()), net::error::get_ssl_category()); + onComplete_("", ec); + return; + } + + beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); + beast::get_lowest_layer(stream_).async_connect(results, + [self = shared_from_this()](beast::error_code ec, tcp::resolver::results_type::endpoint_type) { + self->onConnect(ec); + }); +} + +void HttpSession::onConnect(beast::error_code ec) +{ + if (ec) { + onComplete_("", ec); + return; + } + + beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); + stream_.async_handshake(ssl::stream_base::client, + [self = shared_from_this()](beast::error_code ec) { + self->onSslHandshake(ec); + }); +} + +void HttpSession::onSslHandshake(beast::error_code ec) +{ + if (ec) { + onComplete_("", ec); + return; + } + + beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); + http::async_write(stream_, req_, + [self = shared_from_this()](beast::error_code ec, std::size_t) { + self->onWrite(ec); + }); +} + +void HttpSession::onWrite(beast::error_code ec) +{ + if (ec) { + onComplete_("", ec); + return; + } + + http::async_read(stream_, buffer_, res_, + [self = shared_from_this()](beast::error_code ec, std::size_t) { + self->onRead(ec); + }); +} + +void HttpSession::onRead(beast::error_code ec) +{ + if (ec) { + onComplete_("", ec); + return; + } + + onComplete_(res_.body(), {}); + + beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(5)); + stream_.async_shutdown( + [self = shared_from_this()](beast::error_code ec) { + if (ec && ec != net::error::eof && ec != beast::errc::not_connected + && ec != net::ssl::error::stream_truncated) { + std::cerr << "HttpSession shutdown error: " << ec.message() << std::endl; + } + }); +} + +} diff --git a/src/rest/HttpSession.h b/src/rest/HttpSession.h new file mode 100644 index 0000000..0958ee1 --- /dev/null +++ b/src/rest/HttpSession.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace hyperliquid { + +namespace beast = boost::beast; +namespace http = beast::http; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; +using tcp = boost::asio::ip::tcp; + +using OnComplete = std::function; + +class HttpSession : public std::enable_shared_from_this { +public: + HttpSession(net::io_context& ioc, ssl::context& sslCtx, + const std::string& host, const std::string& port, + const std::string& path, + OnComplete onComplete); + + void run(const std::string& body); + +private: + void onResolve(beast::error_code ec, tcp::resolver::results_type results); + void onConnect(beast::error_code ec); + void onSslHandshake(beast::error_code ec); + void onWrite(beast::error_code ec); + void onRead(beast::error_code ec); + + tcp::resolver resolver_; + beast::ssl_stream stream_; + beast::flat_buffer buffer_; + http::request req_; + http::response res_; + std::string host_; + std::string port_; + std::string path_; + OnComplete onComplete_; +}; + +} // namespace hyperliquid diff --git a/src/rest/InfoRequestBuilder.cpp b/src/rest/InfoRequestBuilder.cpp new file mode 100644 index 0000000..98b5f5d --- /dev/null +++ b/src/rest/InfoRequestBuilder.cpp @@ -0,0 +1,27 @@ +#include "InfoRequestBuilder.h" + +namespace hyperliquid { + +nlohmann::json InfoRequestBuilder::spotMeta() +{ + nlohmann::json body; + body["type"] = toString(RestEndpointType::SpotMeta); + return body; +} + +nlohmann::json InfoRequestBuilder::meta(const std::optional& dex) +{ + nlohmann::json body; + body["type"] = toString(RestEndpointType::Meta); + if (dex) body["dex"] = *dex; + return body; +} + +nlohmann::json InfoRequestBuilder::perpDexs() +{ + nlohmann::json body; + body["type"] = toString(RestEndpointType::PerpDexs); + return body; +} + +} diff --git a/src/rest/InfoRequestBuilder.h b/src/rest/InfoRequestBuilder.h new file mode 100644 index 0000000..6f79eb4 --- /dev/null +++ b/src/rest/InfoRequestBuilder.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include + +#include "hyperliquid/types/RequestTypes.h" + +namespace hyperliquid { + +class InfoRequestBuilder { +public: + static nlohmann::json spotMeta(); + static nlohmann::json meta(const std::optional& dex = std::nullopt); + static nlohmann::json perpDexs(); +}; + +} // namespace hyperliquid diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 681b2ac..a650a41 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -1,19 +1,17 @@ #include "hyperliquid/rest/RestApi.h" #include "hyperliquid/rest/RestApiListener.h" +#include "hyperliquid/rest/RestApiMessageParser.h" +#include "HttpSession.h" +#include "InfoRequestBuilder.h" +#include "ExchangeRequestBuilder.h" +#include "SymbolMap.h" #include -#include #include #include #include #include -#include -#include -#include -#include -#include -#include #include #include @@ -21,136 +19,8 @@ namespace hyperliquid { namespace beast = boost::beast; -namespace http = beast::http; namespace net = boost::asio; namespace ssl = boost::asio::ssl; -using tcp = boost::asio::ip::tcp; - -using OnComplete = std::function; - -class HttpSession : public std::enable_shared_from_this { -public: - HttpSession(net::io_context& ioc, ssl::context& sslCtx, - const std::string& host, const std::string& port, - const std::string& path, - OnComplete onComplete) - : resolver_(net::make_strand(ioc)) - , stream_(net::make_strand(ioc), sslCtx) - , host_(host) - , port_(port) - , path_(path) - , onComplete_(onComplete) - { - } - - void run(const std::string& body) - { - req_.method(http::verb::post); - req_.target(path_); - req_.version(11); - req_.set(http::field::host, host_); - req_.set(http::field::content_type, "application/json"); - req_.set(http::field::user_agent, "hyperliquid-sdk-cpp/0.1"); - req_.body() = body; - req_.prepare_payload(); - - resolver_.async_resolve(host_, port_, - [self = shared_from_this()](beast::error_code ec, tcp::resolver::results_type results) { - self->onResolve(ec, results); - }); - } - -private: - void onResolve(beast::error_code ec, tcp::resolver::results_type results) - { - if (ec) { - onComplete_("", ec); - return; - } - - if (!SSL_set_tlsext_host_name(stream_.native_handle(), host_.c_str())) { - ec = beast::error_code(static_cast(::ERR_get_error()), net::error::get_ssl_category()); - onComplete_("", ec); - return; - } - - beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); - beast::get_lowest_layer(stream_).async_connect(results, - [self = shared_from_this()](beast::error_code ec, tcp::resolver::results_type::endpoint_type) { - self->onConnect(ec); - }); - } - - void onConnect(beast::error_code ec) - { - if (ec) { - onComplete_("", ec); - return; - } - - beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); - stream_.async_handshake(ssl::stream_base::client, - [self = shared_from_this()](beast::error_code ec) { - self->onSslHandshake(ec); - }); - } - - void onSslHandshake(beast::error_code ec) - { - if (ec) { - onComplete_("", ec); - return; - } - - beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(10)); - http::async_write(stream_, req_, - [self = shared_from_this()](beast::error_code ec, std::size_t) { - self->onWrite(ec); - }); - } - - void onWrite(beast::error_code ec) - { - if (ec) { - onComplete_("", ec); - return; - } - - http::async_read(stream_, buffer_, res_, - [self = shared_from_this()](beast::error_code ec, std::size_t) { - self->onRead(ec); - }); - } - - void onRead(beast::error_code ec) - { - if (ec) { - onComplete_("", ec); - return; - } - - onComplete_(res_.body(), {}); - - beast::get_lowest_layer(stream_).expires_after(std::chrono::seconds(5)); - stream_.async_shutdown( - [self = shared_from_this()](beast::error_code ec) { - if (ec && ec != net::error::eof && ec != beast::errc::not_connected - && ec != net::ssl::error::stream_truncated) { - std::cerr << "InfoApi shutdown error: " << ec.message() << std::endl; - } - }); - } - - tcp::resolver resolver_; - beast::ssl_stream stream_; - beast::flat_buffer buffer_; - http::request req_; - http::response res_; - std::string host_; - std::string port_; - std::string path_; - OnComplete onComplete_; -}; static RestApiListener defaultListener; @@ -164,6 +34,8 @@ struct RestApi::Impl { RestApiListener& listener; Wallet wallet; bool authenticated = false; + SymbolMap symbolMap; + ExchangeRequestBuilder exchangeRequestBuilder; Impl(Environment env, RestApiListener& listener, Wallet wallet) : work(net::make_work_guard(ioc)) @@ -172,11 +44,13 @@ struct RestApi::Impl { , port(toInfoEndpoint(env).port) , listener(listener) , wallet(wallet) + , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); sslCtx.set_verify_mode(ssl::verify_peer); thread = std::thread([this]() { ioc.run(); }); authenticated = true; + buildSymbolMap(); } Impl(Environment env, RestApiListener& listener) @@ -185,10 +59,12 @@ struct RestApi::Impl { , host(toInfoEndpoint(env).host) , port(toInfoEndpoint(env).port) , listener(listener) + , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); sslCtx.set_verify_mode(ssl::verify_peer); thread = std::thread([this]() { ioc.run(); }); + buildSymbolMap(); } Impl(Environment env) @@ -197,10 +73,12 @@ struct RestApi::Impl { , host(toInfoEndpoint(env).host) , port(toInfoEndpoint(env).port) , listener(defaultListener) + , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); sslCtx.set_verify_mode(ssl::verify_peer); thread = std::thread([this]() { ioc.run(); }); + buildSymbolMap(); } ~Impl() @@ -210,6 +88,44 @@ struct RestApi::Impl { if (thread.joinable()) thread.join(); } + void buildSymbolMap() + { + RestApiMessageParser parser; + + auto spotMetaResponse = parser.parseSpotMeta( + signAndSendSync(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta())); + for (const auto& token : spotMetaResponse.tokens) + { + symbolMap.add(token.name, token.index + 10000); + } + + auto dexesResponse = parser.parsePerpDexs( + signAndSendSync(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs())); + + auto defaultMetaResponse = parser.parseMeta( + signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta())); + int index = 0; + for (const auto& asset : defaultMetaResponse.universe) + { + symbolMap.add(asset.name, index); + index++; + } + + int perpIdx = 0; + for (const auto& dex : dexesResponse.dexes) + { + auto dexMetaResponse = parser.parseMeta( + signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta(dex.name))); + index = 0; + for (const auto& asset : dexMetaResponse.universe) + { + symbolMap.add(asset.name, 100000 + (perpIdx * 10000) + index); + index++; + } + perpIdx++; + } + } + nlohmann::json prepareBody(RestEndpointType type, nlohmann::json body, const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) @@ -275,7 +191,7 @@ struct RestApi::Impl { }; RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet) - : impl_(std::make_unique(env, listener)) + : impl_(std::make_unique(env, listener, wallet)) { } @@ -291,90 +207,19 @@ RestApi::RestApi(Environment env) RestApi::~RestApi() = default; -static nlohmann::json buildSpotMetaBody() -{ - nlohmann::json body; - body["type"] = toString(RestEndpointType::SpotMeta); - return body; -} - -static nlohmann::json buildMetaBody(const std::optional& dex) -{ - nlohmann::json body; - body["type"] = toString(RestEndpointType::Meta); - if (dex) body["dex"] = *dex; - return body; -} - -static nlohmann::json buildPerpDexsBody() -{ - nlohmann::json body; - body["type"] = toString(RestEndpointType::PerpDexs); - return body; -} - -static nlohmann::json buildPlaceOrderBody(const std::vector& orders, - Grouping grouping, - const std::optional& builder) -{ - nlohmann::json ordersJson = nlohmann::json::array(); - for (const auto& order : orders) - { - nlohmann::json orderJson; - orderJson["a"] = order.asset; - orderJson["b"] = order.isBuy; - orderJson["p"] = order.price; - orderJson["s"] = order.size; - orderJson["r"] = order.reduceOnly; - - if (order.limit) - { - orderJson["t"] = {{"limit", {{"tif", toString(order.limit->tif)}}}}; - } - else if (order.trigger) - { - orderJson["t"] = {{"trigger", { - {"isMarket", order.trigger->isMarket}, - {"triggerPx", order.trigger->triggerPx}, - {"tpsl", toString(order.trigger->tpsl)} - }}}; - } - - if (order.cloid) orderJson["c"] = *order.cloid; - - ordersJson.push_back(orderJson); - } - - nlohmann::json action; - action["type"] = "order"; - action["orders"] = ordersJson; - action["grouping"] = toString(grouping); - - if (builder) - { - action["builder"] = {{"b", builder->address}, {"f", builder->fee}}; - } - - nlohmann::json body; - body["action"] = action; - return body; -} - -// Sync methods - std::string RestApi::spotMeta() { - return impl_->signAndSendSync(RestEndpointType::SpotMeta, buildSpotMetaBody()); + return impl_->signAndSendSync(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); } std::string RestApi::meta(const std::optional& dex) { - return impl_->signAndSendSync(RestEndpointType::Meta, buildMetaBody(dex)); + return impl_->signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); } std::string RestApi::perpDexs() { - return impl_->signAndSendSync(RestEndpointType::PerpDexs, buildPerpDexsBody()); + return impl_->signAndSendSync(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); } std::string RestApi::placeOrder(const std::vector& orders, @@ -382,24 +227,22 @@ std::string RestApi::placeOrder(const std::vector& orders, const std::optional& builder) { return impl_->signAndSendSync(RestEndpointType::PlaceOrder, - buildPlaceOrderBody(orders, grouping, builder)); + impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } -// Async methods - void RestApi::spotMetaAsync() { - return impl_->signAndSend(RestEndpointType::SpotMeta, buildSpotMetaBody()); + impl_->signAndSend(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); } void RestApi::metaAsync(const std::optional& dex) { - impl_->signAndSend(RestEndpointType::Meta, buildMetaBody(dex)); + impl_->signAndSend(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); } void RestApi::perpDexsAsync() { - impl_->signAndSend(RestEndpointType::PerpDexs, buildPerpDexsBody()); + impl_->signAndSend(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); } void RestApi::placeOrderAsync(const std::vector& orders, @@ -407,7 +250,7 @@ void RestApi::placeOrderAsync(const std::vector& orders, const std::optional& builder) { impl_->signAndSend(RestEndpointType::PlaceOrder, - buildPlaceOrderBody(orders, grouping, builder)); + impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } } diff --git a/src/rest/SymbolMap.cpp b/src/rest/SymbolMap.cpp new file mode 100644 index 0000000..c27aa66 --- /dev/null +++ b/src/rest/SymbolMap.cpp @@ -0,0 +1,20 @@ +#include "SymbolMap.h" + +namespace hyperliquid { + +void SymbolMap::add(const std::string& symbol, int securityId) +{ + symbolToId_.insert({symbol, securityId}); +} + +int SymbolMap::resolve(const std::string& symbol) const +{ + auto iter = symbolToId_.find(symbol); + if (iter == symbolToId_.end()) + { + throw std::invalid_argument("Unknown symbol: " + symbol); + } + return iter->second; +} + +} // namespace hyperliquid diff --git a/src/rest/SymbolMap.h b/src/rest/SymbolMap.h new file mode 100644 index 0000000..8aa1984 --- /dev/null +++ b/src/rest/SymbolMap.h @@ -0,0 +1,18 @@ +#pragma once + +#include +#include +#include + +namespace hyperliquid { + +class SymbolMap { +public: + void add(const std::string& symbol, int securityId); + int resolve(const std::string& symbol) const; + +private: + std::unordered_map symbolToId_; +}; + +} // namespace hyperliquid From 392de4d41f8866fb915c06c22177a745ae9429d7 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Tue, 10 Mar 2026 22:49:52 +0000 Subject: [PATCH 06/15] add sign l1 request and place order implementation --- CMakeLists.txt | 18 +- examples/basic_orders.cpp | 65 ++-- include/hyperliquid/rest/RestApi.h | 12 +- include/hyperliquid/types/RequestTypes.h | 6 +- src/rest/ExchangeRequestBuilder.cpp | 65 +++- src/rest/ExchangeRequestBuilder.h | 4 +- src/rest/InfoRequestBuilder.cpp | 12 +- src/rest/InfoRequestBuilder.h | 6 +- src/rest/RestApi.cpp | 107 +++++-- src/signing/Signing.cpp | 375 ++++++++++++++++++++++- src/signing/Signing.h | 77 ++++- src/signing/sha3.c | 323 +++++++++++++++++++ src/signing/sha3.h | 73 +++++ tests/signing_test.cpp | 358 ++++++++++++++++++++++ 14 files changed, 1400 insertions(+), 101 deletions(-) create mode 100644 src/signing/sha3.c create mode 100644 src/signing/sha3.h create mode 100644 tests/signing_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 8f91a26..f23669b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(hyperliquid-sdk-cpp VERSION 0.1.0 LANGUAGES CXX) +project(hyperliquid-sdk-cpp VERSION 0.1.0 LANGUAGES C CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -8,6 +8,7 @@ find_package(OpenSSL REQUIRED) find_package(Boost REQUIRED COMPONENTS system) find_package(simdjson CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) +find_package(unofficial-secp256k1 CONFIG REQUIRED) set(SDK_SOURCES src/rest/RestApiMessageParser.cpp @@ -22,7 +23,7 @@ set(SDK_SOURCES src/websocket/WebsocketMessageParser.cpp src/signing/Signing.cpp - src/signing/Signing.h + src/signing/sha3.c include/hyperliquid/types/RequestTypes.h include/hyperliquid/signing/Wallet.h @@ -44,6 +45,8 @@ target_link_libraries(hyperliquid-sdk OpenSSL::Crypto nlohmann_json::nlohmann_json simdjson::simdjson + unofficial::secp256k1 + unofficial::secp256k1_precomputed ) if(NOT MSVC) @@ -57,6 +60,17 @@ set_target_properties(hyperliquid-sdk PROPERTIES ) option(HYPERLIQUID_BUILD_EXAMPLES "Build example programs" OFF) +option(HYPERLIQUID_BUILD_TESTS "Build tests" OFF) + +if(HYPERLIQUID_BUILD_TESTS) + enable_testing() + find_package(GTest CONFIG REQUIRED) + + add_executable(signing_test tests/signing_test.cpp) + target_link_libraries(signing_test PRIVATE hyperliquid-sdk GTest::gtest GTest::gtest_main nlohmann_json::nlohmann_json) + target_include_directories(signing_test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + add_test(NAME signing_test COMMAND signing_test) +endif() if(HYPERLIQUID_BUILD_EXAMPLES) file(GLOB EXAMPLE_SOURCES examples/*.cpp) diff --git a/examples/basic_orders.cpp b/examples/basic_orders.cpp index b975bf0..0924e04 100644 --- a/examples/basic_orders.cpp +++ b/examples/basic_orders.cpp @@ -1,59 +1,44 @@ #include "test_config.h" #include -#include #include -#include #include -#include -#include -#include - -class OrderSender : public hyperliquid::RestApiListener, public hyperliquid::RestEndpointListener { -public: - std::atomic done{false}; - - void onMessage(const std::string& message, hyperliquid::RestEndpointType type) override { - hyperliquid::RestApiMessageParser parser(*this); - parser.parse(message, type); - } - - void onPlaceOrder(const hyperliquid::PlaceOrderResponse& response) override { - std::cout << "Order response status: " << response.status << std::endl; - for (const auto& status : response.statuses) { - if (status.resting) { - std::cout << " Resting oid=" << status.resting->oid << std::endl; - } else if (status.filled) { - std::cout << " Filled oid=" << status.filled->oid - << " avgPx=" << status.filled->avgPx - << " totalSz=" << status.filled->totalSz << std::endl; - } else if (status.error) { - std::cout << " Error: " << *status.error << std::endl; - } - } - done = true; - } -}; int main() { - OrderSender sender; auto wallet = loadWalletFromConfig(); - hyperliquid::RestApi api(hyperliquid::Environment::Testnet, sender, wallet); + hyperliquid::RestApi api(hyperliquid::Environment::Testnet, wallet); + hyperliquid::RestApiMessageParser parser; hyperliquid::OrderRequest order; - order.asset = "BTC"; + order.asset = "ETH"; order.isBuy = true; - order.price = "1800.0"; - order.size = "0.01"; + order.price = 1800.0; + order.size = 0.01; order.reduceOnly = false; order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; std::cout << "Placing order on Hyperliquid testnet..." << std::endl; - api.placeOrderAsync({order}, hyperliquid::Grouping::Na); - - while (!sender.done) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); + auto response = parser.parsePlaceOrder( + api.placeOrder({order}, hyperliquid::Grouping::Na)); + + std::cout << "Order response status: " << response.status << std::endl; + for (const auto& status : response.statuses) + { + if (status.resting) + { + std::cout << " Resting oid=" << status.resting->oid << std::endl; + } + else if (status.filled) + { + std::cout << " Filled oid=" << status.filled->oid + << " avgPx=" << status.filled->avgPx + << " totalSz=" << status.filled->totalSz << std::endl; + } + else if (status.error) + { + std::cout << " Error: " << *status.error << std::endl; + } } return 0; diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index e95afdf..c96f1b0 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -2,6 +2,7 @@ #include #include +#include #include #include @@ -13,9 +14,14 @@ namespace hyperliquid { class RestApi { public: - RestApi(Environment env, RestApiListener& listener, Wallet wallet); - RestApi(Environment env, RestApiListener& listener); - RestApi(Environment env); + RestApi(Environment env, RestApiListener& listener, Wallet wallet, + const std::set& dexes = {}); + RestApi(Environment env, Wallet wallet, + const std::set& dexes = {}); + RestApi(Environment env, RestApiListener& listener, + const std::set& dexes = {}); + RestApi(Environment env, + const std::set& dexes = {}); ~RestApi(); RestApi(const RestApi&) = delete; diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 2ed7502..90e4dc3 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -258,7 +258,7 @@ namespace hyperliquid struct TriggerOrderType { bool isMarket; - std::string triggerPx; + double triggerPx; TpSl tpsl; }; @@ -266,8 +266,8 @@ namespace hyperliquid { std::string asset; bool isBuy; - std::string price; - std::string size; + double price; + double size; bool reduceOnly; std::optional limit; std::optional trigger; diff --git a/src/rest/ExchangeRequestBuilder.cpp b/src/rest/ExchangeRequestBuilder.cpp index 7094687..232f9e8 100644 --- a/src/rest/ExchangeRequestBuilder.cpp +++ b/src/rest/ExchangeRequestBuilder.cpp @@ -1,39 +1,71 @@ #include "ExchangeRequestBuilder.h" -#include +#include +#include +#include +#include namespace hyperliquid { +// Matches Python SDK's float_to_wire: Decimal(f"{x:.8f}").normalize() formatted with :f +static std::string floatToWire(double x) +{ + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.8f", x); + std::string s(buf); + + if (std::abs(std::stod(s) - x) >= 1e-12) + throw std::invalid_argument("floatToWire causes rounding: " + std::to_string(x)); + + // Normalize: strip trailing zeros, then trailing dot + auto dot = s.find('.'); + if (dot != std::string::npos) + { + auto last = s.find_last_not_of('0'); + if (last == dot) + s = s.substr(0, dot); // integer, drop the dot + else + s = s.substr(0, last + 1); + } + return s; +} + ExchangeRequestBuilder::ExchangeRequestBuilder(const SymbolMap& symbolMap) : symbolMap_(symbolMap) { } -nlohmann::json ExchangeRequestBuilder::placeOrder(const std::vector& orders, +nlohmann::ordered_json ExchangeRequestBuilder::placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder) const { - nlohmann::json ordersJson = nlohmann::json::array(); + nlohmann::ordered_json ordersJson = nlohmann::ordered_json::array(); for (const auto& order : orders) { - nlohmann::json orderJson; + nlohmann::ordered_json orderJson; orderJson["a"] = symbolMap_.resolve(order.asset); orderJson["b"] = order.isBuy; - orderJson["p"] = order.price; - orderJson["s"] = order.size; + orderJson["p"] = floatToWire(order.price); + orderJson["s"] = floatToWire(order.size); orderJson["r"] = order.reduceOnly; if (order.limit) { - orderJson["t"] = {{"limit", {{"tif", toString(order.limit->tif)}}}}; + nlohmann::ordered_json limitInner; + limitInner["tif"] = toString(order.limit->tif); + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderJson["t"] = limitOuter; } else if (order.trigger) { - orderJson["t"] = {{"trigger", { - {"isMarket", order.trigger->isMarket}, - {"triggerPx", order.trigger->triggerPx}, - {"tpsl", toString(order.trigger->tpsl)} - }}}; + nlohmann::ordered_json triggerInner; + triggerInner["isMarket"] = order.trigger->isMarket; + triggerInner["triggerPx"] = floatToWire(order.trigger->triggerPx); + triggerInner["tpsl"] = toString(order.trigger->tpsl); + nlohmann::ordered_json triggerOuter; + triggerOuter["trigger"] = triggerInner; + orderJson["t"] = triggerOuter; } if (order.cloid) orderJson["c"] = *order.cloid; @@ -41,17 +73,20 @@ nlohmann::json ExchangeRequestBuilder::placeOrder(const std::vectoraddress}, {"f", builder->fee}}; + nlohmann::ordered_json builderJson; + builderJson["b"] = builder->address; + builderJson["f"] = builder->fee; + action["builder"] = builderJson; } - nlohmann::json body; + nlohmann::ordered_json body; body["action"] = action; return body; } diff --git a/src/rest/ExchangeRequestBuilder.h b/src/rest/ExchangeRequestBuilder.h index ed12b02..b671c30 100644 --- a/src/rest/ExchangeRequestBuilder.h +++ b/src/rest/ExchangeRequestBuilder.h @@ -14,7 +14,7 @@ class ExchangeRequestBuilder { public: explicit ExchangeRequestBuilder(const SymbolMap& symbolMap); - nlohmann::json placeOrder(const std::vector& orders, + nlohmann::ordered_json placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder = std::nullopt) const; @@ -22,4 +22,4 @@ class ExchangeRequestBuilder { const SymbolMap& symbolMap_; }; -} // namespace hyperliquid +} diff --git a/src/rest/InfoRequestBuilder.cpp b/src/rest/InfoRequestBuilder.cpp index 98b5f5d..b69ea8a 100644 --- a/src/rest/InfoRequestBuilder.cpp +++ b/src/rest/InfoRequestBuilder.cpp @@ -2,24 +2,24 @@ namespace hyperliquid { -nlohmann::json InfoRequestBuilder::spotMeta() +nlohmann::ordered_json InfoRequestBuilder::spotMeta() { - nlohmann::json body; + nlohmann::ordered_json body; body["type"] = toString(RestEndpointType::SpotMeta); return body; } -nlohmann::json InfoRequestBuilder::meta(const std::optional& dex) +nlohmann::ordered_json InfoRequestBuilder::meta(const std::optional& dex) { - nlohmann::json body; + nlohmann::ordered_json body; body["type"] = toString(RestEndpointType::Meta); if (dex) body["dex"] = *dex; return body; } -nlohmann::json InfoRequestBuilder::perpDexs() +nlohmann::ordered_json InfoRequestBuilder::perpDexs() { - nlohmann::json body; + nlohmann::ordered_json body; body["type"] = toString(RestEndpointType::PerpDexs); return body; } diff --git a/src/rest/InfoRequestBuilder.h b/src/rest/InfoRequestBuilder.h index 6f79eb4..b195087 100644 --- a/src/rest/InfoRequestBuilder.h +++ b/src/rest/InfoRequestBuilder.h @@ -11,9 +11,9 @@ namespace hyperliquid { class InfoRequestBuilder { public: - static nlohmann::json spotMeta(); - static nlohmann::json meta(const std::optional& dex = std::nullopt); - static nlohmann::json perpDexs(); + static nlohmann::ordered_json spotMeta(); + static nlohmann::ordered_json meta(const std::optional& dex = std::nullopt); + static nlohmann::ordered_json perpDexs(); }; } // namespace hyperliquid diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index a650a41..5634bbd 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -5,11 +5,13 @@ #include "InfoRequestBuilder.h" #include "ExchangeRequestBuilder.h" #include "SymbolMap.h" +#include "signing/Signing.h" #include #include #include #include +#include #include #include @@ -34,16 +36,21 @@ struct RestApi::Impl { RestApiListener& listener; Wallet wallet; bool authenticated = false; + Environment env; + std::set enabledDexes; SymbolMap symbolMap; ExchangeRequestBuilder exchangeRequestBuilder; - Impl(Environment env, RestApiListener& listener, Wallet wallet) + Impl(Environment env, RestApiListener& listener, Wallet wallet, + const std::set& dexes) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) , host(toInfoEndpoint(env).host) , port(toInfoEndpoint(env).port) , listener(listener) , wallet(wallet) + , env(env) + , enabledDexes(dexes) , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); @@ -53,12 +60,34 @@ struct RestApi::Impl { buildSymbolMap(); } - Impl(Environment env, RestApiListener& listener) + Impl(Environment env, Wallet wallet, + const std::set& dexes) + : work(net::make_work_guard(ioc)) + , sslCtx(ssl::context::tlsv12_client) + , host(toInfoEndpoint(env).host) + , port(toInfoEndpoint(env).port) + , listener(defaultListener) + , wallet(wallet) + , env(env) + , enabledDexes(dexes) + , exchangeRequestBuilder(symbolMap) + { + sslCtx.set_default_verify_paths(); + sslCtx.set_verify_mode(ssl::verify_peer); + thread = std::thread([this]() { ioc.run(); }); + authenticated = true; + buildSymbolMap(); + } + + Impl(Environment env, RestApiListener& listener, + const std::set& dexes) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) , host(toInfoEndpoint(env).host) , port(toInfoEndpoint(env).port) , listener(listener) + , env(env) + , enabledDexes(dexes) , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); @@ -67,12 +96,14 @@ struct RestApi::Impl { buildSymbolMap(); } - Impl(Environment env) + Impl(Environment env, const std::set& dexes) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) , host(toInfoEndpoint(env).host) , port(toInfoEndpoint(env).port) , listener(defaultListener) + , env(env) + , enabledDexes(dexes) , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); @@ -92,6 +123,15 @@ struct RestApi::Impl { { RestApiMessageParser parser; + auto defaultMetaResponse = parser.parseMeta( + signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta())); + int index = 0; + for (const auto& asset : defaultMetaResponse.universe) + { + symbolMap.add(asset.name, index); + index++; + } + auto spotMetaResponse = parser.parseSpotMeta( signAndSendSync(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta())); for (const auto& token : spotMetaResponse.tokens) @@ -99,21 +139,20 @@ struct RestApi::Impl { symbolMap.add(token.name, token.index + 10000); } + if (enabledDexes.empty()) return; + auto dexesResponse = parser.parsePerpDexs( signAndSendSync(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs())); - auto defaultMetaResponse = parser.parseMeta( - signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta())); - int index = 0; - for (const auto& asset : defaultMetaResponse.universe) - { - symbolMap.add(asset.name, index); - index++; - } - int perpIdx = 0; for (const auto& dex : dexesResponse.dexes) { + if (enabledDexes.count(dex.name) == 0) + { + perpIdx++; + continue; + } + auto dexMetaResponse = parser.parseMeta( signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta(dex.name))); index = 0; @@ -126,7 +165,7 @@ struct RestApi::Impl { } } - nlohmann::json prepareBody(RestEndpointType type, nlohmann::json body, + nlohmann::ordered_json prepareBody(RestEndpointType type, nlohmann::ordered_json body, const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) { @@ -135,16 +174,27 @@ struct RestApi::Impl { if (isAuthenticated(type)) { - body["nonce"] = static_cast( + uint64_t nonce = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); - body["signature"] = "xyz"; + body["nonce"] = nonce; + + bool isMainnet = (env == Environment::Mainnet); + auto action = body["action"]; + auto signature = Signing::signL1Action( + wallet, action, vaultAddress, nonce, expiresAfter, isMainnet); + + nlohmann::ordered_json signatureJson; + signatureJson["r"] = signature.r; + signatureJson["s"] = signature.s; + signatureJson["v"] = signature.v; + body["signature"] = signatureJson; } return body; } - void signAndSend(RestEndpointType type, nlohmann::json body, + void signAndSend(RestEndpointType type, nlohmann::ordered_json body, const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) { @@ -164,7 +214,7 @@ struct RestApi::Impl { session->run(serialized); } - std::string signAndSendSync(RestEndpointType type, nlohmann::json body, + std::string signAndSendSync(RestEndpointType type, nlohmann::ordered_json body, const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) { @@ -190,18 +240,26 @@ struct RestApi::Impl { } }; -RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet) - : impl_(std::make_unique(env, listener, wallet)) +RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet, + const std::set& dexes) + : impl_(std::make_unique(env, listener, wallet, dexes)) +{ +} + +RestApi::RestApi(Environment env, Wallet wallet, + const std::set& dexes) + : impl_(std::make_unique(env, wallet, dexes)) { } -RestApi::RestApi(Environment env, RestApiListener& listener) - : impl_(std::make_unique(env, listener)) +RestApi::RestApi(Environment env, RestApiListener& listener, + const std::set& dexes) + : impl_(std::make_unique(env, listener, dexes)) { } -RestApi::RestApi(Environment env) - : impl_(std::make_unique(env)) +RestApi::RestApi(Environment env, const std::set& dexes) + : impl_(std::make_unique(env, dexes)) { } @@ -249,8 +307,7 @@ void RestApi::placeOrderAsync(const std::vector& orders, Grouping grouping, const std::optional& builder) { - impl_->signAndSend(RestEndpointType::PlaceOrder, - impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); + impl_->signAndSend(RestEndpointType::PlaceOrder, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } } diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp index ca4aeb7..8de4920 100644 --- a/src/signing/Signing.cpp +++ b/src/signing/Signing.cpp @@ -1 +1,374 @@ -#include "Signing.h" \ No newline at end of file +#include "Signing.h" + +extern "C" { +#include "sha3.h" +} + +#include +#include +#include +#include + +#include +#include + +namespace hyperliquid { + +namespace { + +std::array keccak256(const uint8_t* data, size_t length) +{ + std::array output; + sha3_HashBuffer(256, SHA3_FLAGS_KECCAK, data, static_cast(length), + output.data(), 32); + return output; +} + +std::array keccak256(const std::vector& data) +{ + return keccak256(data.data(), data.size()); +} + +uint8_t hexCharToNibble(char ch) +{ + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + throw std::invalid_argument(std::string("Invalid hex character: ") + ch); +} + +std::vector hexToBytes(const std::string& hex) +{ + std::string clean = hex; + if (clean.size() >= 2 && clean[0] == '0' && (clean[1] == 'x' || clean[1] == 'X')) + clean = clean.substr(2); + + if (clean.size() % 2 != 0) + clean = "0" + clean; + + std::vector bytes(clean.size() / 2); + for (size_t idx = 0; idx < bytes.size(); idx++) + bytes[idx] = (hexCharToNibble(clean[idx * 2]) << 4) | hexCharToNibble(clean[idx * 2 + 1]); + + return bytes; +} + +// Encode a uint256 value (left-padded to 32 bytes, big-endian) +std::array encodeUint256(uint64_t value) +{ + std::array result = {}; + for (int idx = 0; idx < 8; idx++) + result[31 - idx] = static_cast((value >> (idx * 8)) & 0xFF); + return result; +} + +// Encode an address as uint256 (left-padded to 32 bytes) +std::array encodeAddress(const std::vector& addressBytes) +{ + std::array result = {}; + if (addressBytes.size() == 20) + std::memcpy(result.data() + 12, addressBytes.data(), 20); + return result; +} + +} + +std::string Signing::toHex(const uint8_t* data, size_t length) +{ + std::ostringstream stream; + stream << "0x"; + for (size_t idx = 0; idx < length; idx++) + stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); + + std::string result = stream.str(); + size_t firstNonZero = result.find_first_not_of('0', 2); + if (firstNonZero == std::string::npos) return "0x0"; + return "0x" + result.substr(firstNonZero); +} + +std::string Signing::toHexPadded(const uint8_t* data, size_t length) +{ + std::ostringstream stream; + stream << "0x"; + for (size_t idx = 0; idx < length; idx++) + stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); + return stream.str(); +} + +std::array Signing::actionHash( + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter) +{ + std::vector data = nlohmann::ordered_json::to_msgpack(action); + for (int idx = 7; idx >= 0; idx--) + data.push_back(static_cast((nonce >> (idx * 8)) & 0xFF)); + if (!vaultAddress) + { + data.push_back(0x00); + } + else + { + data.push_back(0x01); + + auto addrBytes = hexToBytes(*vaultAddress); + data.insert(data.end(), addrBytes.begin(), addrBytes.end()); + } + + if (expiresAfter) + { + data.push_back(0x00); + uint64_t expires = *expiresAfter; + for (int idx = 7; idx >= 0; idx--) + data.push_back(static_cast((expires >> (idx * 8)) & 0xFF)); + } + + return keccak256(data); +} + +std::array Signing::domainSeparatorHash() +{ + // EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) + auto typeHash = keccak256( + reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); + + auto nameHash = keccak256(reinterpret_cast("Exchange"), strlen("Exchange")); + auto versionHash = keccak256(reinterpret_cast("1"), strlen("1")); + auto chainId = encodeUint256(1337); + auto verifyingContract = encodeAddress(std::vector(20, 0)); // address(0) + + // Concatenate: typeHash || nameHash || versionHash || chainId || verifyingContract + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); + encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); + encoded.insert(encoded.end(), chainId.begin(), chainId.end()); + encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); + + return keccak256(encoded); +} + +std::array Signing::agentStructHash( + const std::string& source, + const std::array& connectionId) +{ + // Agent(string source,bytes32 connectionId) + auto typeHash = keccak256( + reinterpret_cast("Agent(string source,bytes32 connectionId)"), + strlen("Agent(string source,bytes32 connectionId)")); + + auto sourceHash = keccak256( + reinterpret_cast(source.c_str()), source.size()); + + // Concatenate: typeHash || keccak256(source) || connectionId + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), sourceHash.begin(), sourceHash.end()); + encoded.insert(encoded.end(), connectionId.begin(), connectionId.end()); + + return keccak256(encoded); +} + +std::array Signing::eip712Hash( + const std::array& domainSeparator, + const std::array& structHash) +{ + // "\x19\x01" || domainSeparator || structHash + std::vector encoded; + encoded.push_back(0x19); + encoded.push_back(0x01); + encoded.insert(encoded.end(), domainSeparator.begin(), domainSeparator.end()); + encoded.insert(encoded.end(), structHash.begin(), structHash.end()); + + return keccak256(encoded); +} + +Signature Signing::ecdsaSign( + const Wallet& wallet, + const std::array& messageHash) +{ + auto privateKeyBytes = hexToBytes(wallet.privateKey); + if (privateKeyBytes.size() != 32) + throw std::invalid_argument("Private key must be 32 bytes"); + + secp256k1_context* context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + if (!context) + throw std::runtime_error("Failed to create secp256k1 context"); + + secp256k1_ecdsa_recoverable_signature signature; + if (!secp256k1_ecdsa_sign_recoverable(context, &signature, + messageHash.data(), privateKeyBytes.data(), nullptr, nullptr)) + { + secp256k1_context_destroy(context); + throw std::runtime_error("secp256k1 signing failed"); + } + + uint8_t serialized[64]; + int recid; + secp256k1_ecdsa_recoverable_signature_serialize_compact(context, serialized, &recid, &signature); + if (recid != 0 && recid != 1) + throw std::runtime_error("Unexpected recovery id: " + std::to_string(recid)); + + secp256k1_context_destroy(context); + + Signature result; + result.r = toHex(serialized, 32); + result.s = toHex(serialized + 32, 32); + result.v = recid + 27; + return result; +} + +std::string Signing::addressFromPrivateKey(const std::string& privateKey) +{ + auto privateKeyBytes = hexToBytes(privateKey); + if (privateKeyBytes.size() != 32) + throw std::invalid_argument("Private key must be 32 bytes"); + + secp256k1_context* context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + if (!context) + throw std::runtime_error("Failed to create secp256k1 context"); + + secp256k1_pubkey pubkey; + if (!secp256k1_ec_pubkey_create(context, &pubkey, privateKeyBytes.data())) + { + secp256k1_context_destroy(context); + throw std::runtime_error("Failed to derive public key"); + } + + uint8_t uncompressed[65]; + size_t len = 65; + secp256k1_ec_pubkey_serialize(context, uncompressed, &len, &pubkey, SECP256K1_EC_UNCOMPRESSED); + secp256k1_context_destroy(context); + + // keccak256 of the 64-byte public key (skip the 0x04 prefix) + auto hash = keccak256(uncompressed + 1, 64); + + // Ethereum address is the last 20 bytes + std::ostringstream stream; + stream << "0x"; + for (int idx = 12; idx < 32; idx++) + stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(hash[idx]); + return stream.str(); +} + +std::array Signing::domainSeparatorHash( + const std::string& name, const std::string& version, uint64_t chainId) +{ + auto typeHash = keccak256( + reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); + + auto nameHash = keccak256(reinterpret_cast(name.c_str()), name.size()); + auto versionHash = keccak256(reinterpret_cast(version.c_str()), version.size()); + auto chainIdEncoded = encodeUint256(chainId); + auto verifyingContract = encodeAddress(std::vector(20, 0)); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); + encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); + encoded.insert(encoded.end(), chainIdEncoded.begin(), chainIdEncoded.end()); + encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); + + return keccak256(encoded); +} + +std::array Signing::userSignedStructHash( + const std::string& primaryType, + const std::vector& payloadTypes, + const nlohmann::ordered_json& action) +{ + // Build type string: "PrimaryType(type1 name1,type2 name2,...)" + std::string typeString = primaryType + "("; + for (size_t idx = 0; idx < payloadTypes.size(); idx++) + { + if (idx > 0) typeString += ","; + typeString += payloadTypes[idx].type + " " + payloadTypes[idx].name; + } + typeString += ")"; + + auto typeHash = keccak256( + reinterpret_cast(typeString.c_str()), typeString.size()); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + + // Encode each field according to its EIP-712 type + for (const auto& field : payloadTypes) + { + if (field.type == "string") + { + auto val = action[field.name].get(); + auto valHash = keccak256(reinterpret_cast(val.c_str()), val.size()); + encoded.insert(encoded.end(), valHash.begin(), valHash.end()); + } + else if (field.type == "uint64") + { + auto val = action[field.name].get(); + auto valEncoded = encodeUint256(val); + encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); + } + else if (field.type == "address") + { + auto val = action[field.name].get(); + auto addrBytes = hexToBytes(val); + auto addrEncoded = encodeAddress(addrBytes); + encoded.insert(encoded.end(), addrEncoded.begin(), addrEncoded.end()); + } + else if (field.type == "bool") + { + auto val = action[field.name].get(); + auto valEncoded = encodeUint256(val ? 1 : 0); + encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); + } + } + + return keccak256(encoded); +} + +Signature Signing::signUserSignedAction( + const Wallet& wallet, + const nlohmann::ordered_json& action, + const std::vector& payloadTypes, + const std::string& primaryType, + bool isMainnet) +{ + // signatureChainId: 0x66eee = 421614 + uint64_t chainId = 0x66eee; + + auto structHash = userSignedStructHash(primaryType, payloadTypes, action); + auto domainSeparator = domainSeparatorHash("HyperliquidSignTransaction", "1", chainId); + auto finalHash = eip712Hash(domainSeparator, structHash); + + return ecdsaSign(wallet, finalHash); +} + +Signature Signing::signL1Action( + const Wallet& wallet, + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter, + bool isMainnet) +{ + auto hash = actionHash(action, vaultAddress, nonce, expiresAfter); + + // Step 2: Construct phantom agent + std::string source = isMainnet ? "a" : "b"; + + // Step 3: Build EIP-712 struct hash for the Agent + auto structHash = agentStructHash(source, hash); + + // Step 4: Build the domain separator (can be cached but computed fresh for clarity) + auto domainSeparator = domainSeparatorHash(); + + // Step 5: Compute the final EIP-712 hash + auto finalHash = eip712Hash(domainSeparator, structHash); + + // Step 6: Sign with secp256k1 + return ecdsaSign(wallet, finalHash); +} + +} // namespace hyperliquid diff --git a/src/signing/Signing.h b/src/signing/Signing.h index f71b6b3..7b5f1a7 100644 --- a/src/signing/Signing.h +++ b/src/signing/Signing.h @@ -1,5 +1,80 @@ -# pragma once +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "hyperliquid/signing/Wallet.h" + +namespace hyperliquid { + +struct Signature +{ + std::string r; + std::string s; + int v; +}; + +struct EIP712Field +{ + std::string name; + std::string type; +}; class Signing { +public: + static Signature signL1Action( + const Wallet& wallet, + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter, + bool isMainnet); + + static Signature signUserSignedAction( + const Wallet& wallet, + const nlohmann::ordered_json& action, + const std::vector& payloadTypes, + const std::string& primaryType, + bool isMainnet); + + static std::array actionHash( + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter); + + static std::string addressFromPrivateKey(const std::string& privateKey); + + static std::string toHex(const uint8_t* data, size_t length); + static std::string toHexPadded(const uint8_t* data, size_t length); + +private: + static std::array eip712Hash( + const std::array& domainSeparator, + const std::array& structHash); + + static std::array domainSeparatorHash(); + static std::array domainSeparatorHash( + const std::string& name, const std::string& version, uint64_t chainId); + + static std::array agentStructHash( + const std::string& source, + const std::array& connectionId); + + static std::array userSignedStructHash( + const std::string& primaryType, + const std::vector& payloadTypes, + const nlohmann::ordered_json& action); + + static Signature ecdsaSign( + const Wallet& wallet, + const std::array& messageHash); }; + +} diff --git a/src/signing/sha3.c b/src/signing/sha3.c new file mode 100644 index 0000000..ffc030e --- /dev/null +++ b/src/signing/sha3.c @@ -0,0 +1,323 @@ +/* ------------------------------------------------------------------------- + * Works when compiled for either 32-bit or 64-bit targets, optimized for + * 64 bit. + * + * Canonical implementation of Init/Update/Finalize for SHA-3 byte input. + * + * SHA3-256, SHA3-384, SHA-512 are implemented. SHA-224 can easily be added. + * + * Based on code from http://keccak.noekeon.org/ . + * + * I place the code that I wrote into public domain, free to use. + * + * I would appreciate if you give credits to this work if you used it to + * write or test * your code. + * + * Aug 2015. Andrey Jivsov. crypto@brainhub.org + * ---------------------------------------------------------------------- */ + +#include +#include +#include + +#include "sha3.h" + +#define SHA3_ASSERT( x ) +#define SHA3_TRACE( format, ...) +#define SHA3_TRACE_BUF(format, buf, l) + +/* + * This flag is used to configure "pure" Keccak, as opposed to NIST SHA3. + */ +#define SHA3_USE_KECCAK_FLAG 0x80000000 +#define SHA3_CW(x) ((x) & (~SHA3_USE_KECCAK_FLAG)) + + +#if defined(_MSC_VER) +#define SHA3_CONST(x) x +#else +#define SHA3_CONST(x) x##L +#endif + +#ifndef SHA3_ROTL64 +#define SHA3_ROTL64(x, y) \ + (((x) << (y)) | ((x) >> ((sizeof(uint64_t)*8) - (y)))) +#endif + +static const uint64_t keccakf_rndc[24] = { + SHA3_CONST(0x0000000000000001UL), SHA3_CONST(0x0000000000008082UL), + SHA3_CONST(0x800000000000808aUL), SHA3_CONST(0x8000000080008000UL), + SHA3_CONST(0x000000000000808bUL), SHA3_CONST(0x0000000080000001UL), + SHA3_CONST(0x8000000080008081UL), SHA3_CONST(0x8000000000008009UL), + SHA3_CONST(0x000000000000008aUL), SHA3_CONST(0x0000000000000088UL), + SHA3_CONST(0x0000000080008009UL), SHA3_CONST(0x000000008000000aUL), + SHA3_CONST(0x000000008000808bUL), SHA3_CONST(0x800000000000008bUL), + SHA3_CONST(0x8000000000008089UL), SHA3_CONST(0x8000000000008003UL), + SHA3_CONST(0x8000000000008002UL), SHA3_CONST(0x8000000000000080UL), + SHA3_CONST(0x000000000000800aUL), SHA3_CONST(0x800000008000000aUL), + SHA3_CONST(0x8000000080008081UL), SHA3_CONST(0x8000000000008080UL), + SHA3_CONST(0x0000000080000001UL), SHA3_CONST(0x8000000080008008UL) +}; + +static const unsigned keccakf_rotc[24] = { + 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, + 18, 39, 61, 20, 44 +}; + +static const unsigned keccakf_piln[24] = { + 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, + 14, 22, 9, 6, 1 +}; + +/* generally called after SHA3_KECCAK_SPONGE_WORDS-ctx->capacityWords words + * are XORed into the state s + */ +static void +keccakf(uint64_t s[25]) +{ + int i, j, round; + uint64_t t, bc[5]; +#define KECCAK_ROUNDS 24 + + for(round = 0; round < KECCAK_ROUNDS; round++) { + + /* Theta */ + for(i = 0; i < 5; i++) + bc[i] = s[i] ^ s[i + 5] ^ s[i + 10] ^ s[i + 15] ^ s[i + 20]; + + for(i = 0; i < 5; i++) { + t = bc[(i + 4) % 5] ^ SHA3_ROTL64(bc[(i + 1) % 5], 1); + for(j = 0; j < 25; j += 5) + s[j + i] ^= t; + } + + /* Rho Pi */ + t = s[1]; + for(i = 0; i < 24; i++) { + j = keccakf_piln[i]; + bc[0] = s[j]; + s[j] = SHA3_ROTL64(t, keccakf_rotc[i]); + t = bc[0]; + } + + /* Chi */ + for(j = 0; j < 25; j += 5) { + for(i = 0; i < 5; i++) + bc[i] = s[j + i]; + for(i = 0; i < 5; i++) + s[j + i] ^= (~bc[(i + 1) % 5]) & bc[(i + 2) % 5]; + } + + /* Iota */ + s[0] ^= keccakf_rndc[round]; + } +} + +/* *************************** Public Inteface ************************ */ + +/* For Init or Reset call these: */ +sha3_return_t +sha3_Init(void *priv, unsigned bitSize) { + sha3_context *ctx = (sha3_context *) priv; + if( bitSize != 256 && bitSize != 384 && bitSize != 512 ) + return SHA3_RETURN_BAD_PARAMS; + memset(ctx, 0, sizeof(*ctx)); + ctx->capacityWords = 2 * bitSize / (8 * sizeof(uint64_t)); + return SHA3_RETURN_OK; +} + +void +sha3_Init256(void *priv) +{ + sha3_Init(priv, 256); +} + +void +sha3_Init384(void *priv) +{ + sha3_Init(priv, 384); +} + +void +sha3_Init512(void *priv) +{ + sha3_Init(priv, 512); +} + +enum SHA3_FLAGS +sha3_SetFlags(void *priv, enum SHA3_FLAGS flags) +{ + sha3_context *ctx = (sha3_context *) priv; + flags &= SHA3_FLAGS_KECCAK; + ctx->capacityWords |= (flags == SHA3_FLAGS_KECCAK ? SHA3_USE_KECCAK_FLAG : 0); + return flags; +} + + +void +sha3_Update(void *priv, void const *bufIn, size_t len) +{ + sha3_context *ctx = (sha3_context *) priv; + + /* 0...7 -- how much is needed to have a word */ + unsigned old_tail = (8 - ctx->byteIndex) & 7; + + size_t words; + unsigned tail; + size_t i; + + const uint8_t *buf = bufIn; + + SHA3_TRACE_BUF("called to update with:", buf, len); + + SHA3_ASSERT(ctx->byteIndex < 8); + SHA3_ASSERT(ctx->wordIndex < sizeof(ctx->u.s) / sizeof(ctx->u.s[0])); + + if(len < old_tail) { /* have no complete word or haven't started + * the word yet */ + SHA3_TRACE("because %d<%d, store it and return", (unsigned)len, + (unsigned)old_tail); + /* endian-independent code follows: */ + while (len--) + ctx->saved |= (uint64_t) (*(buf++)) << ((ctx->byteIndex++) * 8); + SHA3_ASSERT(ctx->byteIndex < 8); + return; + } + + if(old_tail) { /* will have one word to process */ + SHA3_TRACE("completing one word with %d bytes", (unsigned)old_tail); + /* endian-independent code follows: */ + len -= old_tail; + while (old_tail--) + ctx->saved |= (uint64_t) (*(buf++)) << ((ctx->byteIndex++) * 8); + + /* now ready to add saved to the sponge */ + ctx->u.s[ctx->wordIndex] ^= ctx->saved; + SHA3_ASSERT(ctx->byteIndex == 8); + ctx->byteIndex = 0; + ctx->saved = 0; + if(++ctx->wordIndex == + (SHA3_KECCAK_SPONGE_WORDS - SHA3_CW(ctx->capacityWords))) { + keccakf(ctx->u.s); + ctx->wordIndex = 0; + } + } + + /* now work in full words directly from input */ + + SHA3_ASSERT(ctx->byteIndex == 0); + + words = len / sizeof(uint64_t); + tail = len - words * sizeof(uint64_t); + + SHA3_TRACE("have %d full words to process", (unsigned)words); + + for(i = 0; i < words; i++, buf += sizeof(uint64_t)) { + const uint64_t t = (uint64_t) (buf[0]) | + ((uint64_t) (buf[1]) << 8 * 1) | + ((uint64_t) (buf[2]) << 8 * 2) | + ((uint64_t) (buf[3]) << 8 * 3) | + ((uint64_t) (buf[4]) << 8 * 4) | + ((uint64_t) (buf[5]) << 8 * 5) | + ((uint64_t) (buf[6]) << 8 * 6) | + ((uint64_t) (buf[7]) << 8 * 7); +#if defined(__x86_64__ ) || defined(__i386__) + SHA3_ASSERT(memcmp(&t, buf, 8) == 0); +#endif + ctx->u.s[ctx->wordIndex] ^= t; + if(++ctx->wordIndex == + (SHA3_KECCAK_SPONGE_WORDS - SHA3_CW(ctx->capacityWords))) { + keccakf(ctx->u.s); + ctx->wordIndex = 0; + } + } + + SHA3_TRACE("have %d bytes left to process, save them", (unsigned)tail); + + /* finally, save the partial word */ + SHA3_ASSERT(ctx->byteIndex == 0 && tail < 8); + while (tail--) { + SHA3_TRACE("Store byte %02x '%c'", *buf, *buf); + ctx->saved |= (uint64_t) (*(buf++)) << ((ctx->byteIndex++) * 8); + } + SHA3_ASSERT(ctx->byteIndex < 8); + SHA3_TRACE("Have saved=0x%016" PRIx64 " at the end", ctx->saved); +} + +/* This is simply the 'update' with the padding block. + * The padding block is 0x01 || 0x00* || 0x80. First 0x01 and last 0x80 + * bytes are always present, but they can be the same byte. + */ +void const * +sha3_Finalize(void *priv) +{ + sha3_context *ctx = (sha3_context *) priv; + + SHA3_TRACE("called with %d bytes in the buffer", ctx->byteIndex); + + /* Append 2-bit suffix 01, per SHA-3 spec. Instead of 1 for padding we + * use 1<<2 below. The 0x02 below corresponds to the suffix 01. + * Overall, we feed 0, then 1, and finally 1 to start padding. Without + * M || 01, we would simply use 1 to start padding. */ + + uint64_t t; + + if( ctx->capacityWords & SHA3_USE_KECCAK_FLAG ) { + /* Keccak version */ + t = (uint64_t)(((uint64_t) 1) << (ctx->byteIndex * 8)); + } + else { + /* SHA3 version */ + t = (uint64_t)(((uint64_t)(0x02 | (1 << 2))) << ((ctx->byteIndex) * 8)); + } + + ctx->u.s[ctx->wordIndex] ^= ctx->saved ^ t; + + ctx->u.s[SHA3_KECCAK_SPONGE_WORDS - SHA3_CW(ctx->capacityWords) - 1] ^= + SHA3_CONST(0x8000000000000000UL); + keccakf(ctx->u.s); + + /* Return first bytes of the ctx->s. This conversion is not needed for + * little-endian platforms e.g. wrap with #if !defined(__BYTE_ORDER__) + * || !defined(__ORDER_LITTLE_ENDIAN__) || __BYTE_ORDER__!=__ORDER_LITTLE_ENDIAN__ + * ... the conversion below ... + * #endif */ + { + unsigned i; + for(i = 0; i < SHA3_KECCAK_SPONGE_WORDS; i++) { + const unsigned t1 = (uint32_t) ctx->u.s[i]; + const unsigned t2 = (uint32_t) ((ctx->u.s[i] >> 16) >> 16); + ctx->u.sb[i * 8 + 0] = (uint8_t) (t1); + ctx->u.sb[i * 8 + 1] = (uint8_t) (t1 >> 8); + ctx->u.sb[i * 8 + 2] = (uint8_t) (t1 >> 16); + ctx->u.sb[i * 8 + 3] = (uint8_t) (t1 >> 24); + ctx->u.sb[i * 8 + 4] = (uint8_t) (t2); + ctx->u.sb[i * 8 + 5] = (uint8_t) (t2 >> 8); + ctx->u.sb[i * 8 + 6] = (uint8_t) (t2 >> 16); + ctx->u.sb[i * 8 + 7] = (uint8_t) (t2 >> 24); + } + } + + SHA3_TRACE_BUF("Hash: (first 32 bytes)", ctx->u.sb, 256 / 8); + + return (ctx->u.sb); +} + +sha3_return_t sha3_HashBuffer( unsigned bitSize, enum SHA3_FLAGS flags, const void *in, unsigned inBytes, void *out, unsigned outBytes ) { + sha3_return_t err; + sha3_context c; + + err = sha3_Init(&c, bitSize); + if( err != SHA3_RETURN_OK ) + return err; + if( sha3_SetFlags(&c, flags) != flags ) { + return SHA3_RETURN_BAD_PARAMS; + } + sha3_Update(&c, in, inBytes); + const void *h = sha3_Finalize(&c); + + if(outBytes > bitSize/8) + outBytes = bitSize/8; + memcpy(out, h, outBytes); + return SHA3_RETURN_OK; +} diff --git a/src/signing/sha3.h b/src/signing/sha3.h new file mode 100644 index 0000000..7ad98f2 --- /dev/null +++ b/src/signing/sha3.h @@ -0,0 +1,73 @@ +#ifndef SHA3_H +#define SHA3_H + +#include + +/* ------------------------------------------------------------------------- + * Works when compiled for either 32-bit or 64-bit targets, optimized for + * 64 bit. + * + * Canonical implementation of Init/Update/Finalize for SHA-3 byte input. + * + * SHA3-256, SHA3-384, SHA-512 are implemented. SHA-224 can easily be added. + * + * Based on code from http://keccak.noekeon.org/ . + * + * I place the code that I wrote into public domain, free to use. + * + * I would appreciate if you give credits to this work if you used it to + * write or test * your code. + * + * Aug 2015. Andrey Jivsov. crypto@brainhub.org + * ---------------------------------------------------------------------- */ + +/* 'Words' here refers to uint64_t */ +#define SHA3_KECCAK_SPONGE_WORDS \ + (((1600)/8/*bits to byte*/)/sizeof(uint64_t)) +typedef struct sha3_context_ { + uint64_t saved; /* the portion of the input message that we + * didn't consume yet */ + union { /* Keccak's state */ + uint64_t s[SHA3_KECCAK_SPONGE_WORDS]; + uint8_t sb[SHA3_KECCAK_SPONGE_WORDS * 8]; + } u; + unsigned byteIndex; /* 0..7--the next byte after the set one + * (starts from 0; 0--none are buffered) */ + unsigned wordIndex; /* 0..24--the next word to integrate input + * (starts from 0) */ + unsigned capacityWords; /* the double size of the hash output in + * words (e.g. 16 for Keccak 512) */ +} sha3_context; + +enum SHA3_FLAGS { + SHA3_FLAGS_NONE=0, + SHA3_FLAGS_KECCAK=1 +}; + +enum SHA3_RETURN { + SHA3_RETURN_OK=0, + SHA3_RETURN_BAD_PARAMS=1 +}; +typedef enum SHA3_RETURN sha3_return_t; + +/* For Init or Reset call these: */ +sha3_return_t sha3_Init(void *priv, unsigned bitSize); + +void sha3_Init256(void *priv); +void sha3_Init384(void *priv); +void sha3_Init512(void *priv); + +enum SHA3_FLAGS sha3_SetFlags(void *priv, enum SHA3_FLAGS); + +void sha3_Update(void *priv, void const *bufIn, size_t len); + +void const *sha3_Finalize(void *priv); + +/* Single-call hashing */ +sha3_return_t sha3_HashBuffer( + unsigned bitSize, /* 256, 384, 512 */ + enum SHA3_FLAGS flags, /* SHA3_FLAGS_NONE or SHA3_FLAGS_KECCAK */ + const void *in, unsigned inBytes, + void *out, unsigned outBytes ); /* up to bitSize/8; truncation OK */ + +#endif diff --git a/tests/signing_test.cpp b/tests/signing_test.cpp new file mode 100644 index 0000000..e66238f --- /dev/null +++ b/tests/signing_test.cpp @@ -0,0 +1,358 @@ +// Tests ported from hyperliquid-python-sdk/tests/signing_test.py +#include + +#include "signing/Signing.h" + +#include + +using namespace hyperliquid; + +static const std::string TEST_PRIVATE_KEY = "0123456789012345678901234567890123456789012345678901234567890123"; + +static Wallet testWallet() +{ + return Wallet{"", TEST_PRIVATE_KEY}; +} + +static uint64_t floatToIntForHashing(double value) +{ + double withDecimals = value * 1e8; + return static_cast(std::round(withDecimals)); +} + +// Python: order_request_to_order_wire + order_wires_to_order_action +// Builds the action exactly as the Python SDK does +static nlohmann::ordered_json orderWireToAction(const nlohmann::ordered_json& orderWire, + const std::string& grouping = "na") +{ + nlohmann::ordered_json action; + action["type"] = "order"; + action["orders"] = nlohmann::ordered_json::array({orderWire}); + action["grouping"] = grouping; + return action; +} + +// Python: float_to_wire(x) -> Decimal(f"{x:.8f}").normalize() as string +static std::string floatToWire(double x) +{ + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.8f", x); + std::string s(buf); + // Normalize: strip trailing zeros after decimal point + if (s.find('.') != std::string::npos) + { + size_t last = s.find_last_not_of('0'); + if (s[last] == '.') last--; // remove trailing dot too? No, Python Decimal keeps it as integer + // Actually Python Decimal("100.00000000").normalize() = "1E+2" -> f format = "100" + // Python Decimal("1670.10000000").normalize() = "1670.1" -> f format = "1670.1" + // Python Decimal("0.01470000").normalize() = "0.0147" + if (s[last] == '.') + s = s.substr(0, last); // drop the dot for integers + else + s = s.substr(0, last + 1); + } + return s; +} + +// test_phantom_agent_creation_matches_production +TEST(SigningTest, PhantomAgentCreationMatchesProduction) +{ + uint64_t timestamp = 1677777606040; + + // order_request_to_order_wire(order_request, 4) + nlohmann::ordered_json orderWire; + orderWire["a"] = 4; + orderWire["b"] = true; + orderWire["p"] = floatToWire(1670.1); // "1670.1" + orderWire["s"] = floatToWire(0.0147); // "0.0147" + orderWire["r"] = false; + nlohmann::ordered_json limitInner; + limitInner["tif"] = "Ioc"; + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderWire["t"] = limitOuter; + + auto action = orderWireToAction(orderWire); + + // action_hash(action, None, timestamp, None) + auto hash = Signing::actionHash(action, std::nullopt, timestamp, std::nullopt); + + // construct_phantom_agent connectionId check + std::string connectionIdHex = Signing::toHexPadded(hash.data(), 32); + EXPECT_EQ(connectionIdHex, "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908"); +} + +// test_l1_action_signing_matches +TEST(SigningTest, L1ActionSigningMatches) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "dummy"; + action["num"] = floatToIntForHashing(1000); + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x53749d5b30552aeb2fca34b530185976545bb22d0b3ce6f62e31be961a59298"); + EXPECT_EQ(sigMainnet.s, "0x755c40ba9bf05223521753995abb2f73ab3229be8ec921f350cb447e384d8ed8"); + EXPECT_EQ(sigMainnet.v, 27); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0x542af61ef1f429707e3c76c5293c80d01f74ef853e34b76efffcb57e574f9510"); + EXPECT_EQ(sigTestnet.s, "0x17b8b32f086e8cdede991f1e2c529f5dd5297cbe8128500e00cbaf766204a613"); + EXPECT_EQ(sigTestnet.v, 28); +} + +// test_l1_action_signing_order_matches +TEST(SigningTest, L1ActionSigningOrderMatches) +{ + auto wallet = testWallet(); + + // order_request_to_order_wire({coin: ETH, is_buy: True, sz: 100, limit_px: 100, ...}, 1) + nlohmann::ordered_json orderWire; + orderWire["a"] = 1; + orderWire["b"] = true; + orderWire["p"] = floatToWire(100); // "100" + orderWire["s"] = floatToWire(100); // "100" + orderWire["r"] = false; + nlohmann::ordered_json limitInner; + limitInner["tif"] = "Gtc"; + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderWire["t"] = limitOuter; + + auto action = orderWireToAction(orderWire); + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0xd65369825a9df5d80099e513cce430311d7d26ddf477f5b3a33d2806b100d78e"); + EXPECT_EQ(sigMainnet.s, "0x2b54116ff64054968aa237c20ca9ff68000f977c93289157748a3162b6ea940e"); + EXPECT_EQ(sigMainnet.v, 28); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0x82b2ba28e76b3d761093aaded1b1cdad4960b3af30212b343fb2e6cdfa4e3d54"); + EXPECT_EQ(sigTestnet.s, "0x6b53878fc99d26047f4d7e8c90eb98955a109f44209163f52d8dc4278cbbd9f5"); + EXPECT_EQ(sigTestnet.v, 27); +} + +// test_l1_action_signing_order_with_cloid_matches +TEST(SigningTest, L1ActionSigningOrderWithCloidMatches) +{ + auto wallet = testWallet(); + + nlohmann::ordered_json orderWire; + orderWire["a"] = 1; + orderWire["b"] = true; + orderWire["p"] = floatToWire(100); + orderWire["s"] = floatToWire(100); + orderWire["r"] = false; + nlohmann::ordered_json limitInner; + limitInner["tif"] = "Gtc"; + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderWire["t"] = limitOuter; + orderWire["c"] = "0x00000000000000000000000000000001"; + + auto action = orderWireToAction(orderWire); + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x41ae18e8239a56cacbc5dad94d45d0b747e5da11ad564077fcac71277a946e3"); + EXPECT_EQ(sigMainnet.s, "0x3c61f667e747404fe7eea8f90ab0e76cc12ce60270438b2058324681a00116da"); + EXPECT_EQ(sigMainnet.v, 27); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0xeba0664bed2676fc4e5a743bf89e5c7501aa6d870bdb9446e122c9466c5cd16d"); + EXPECT_EQ(sigTestnet.s, "0x7f3e74825c9114bc59086f1eebea2928c190fdfbfde144827cb02b85bbe90988"); + EXPECT_EQ(sigTestnet.v, 28); +} + +// test_l1_action_signing_matches_with_vault +TEST(SigningTest, L1ActionSigningMatchesWithVault) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "dummy"; + action["num"] = floatToIntForHashing(1000); + + std::string vaultAddress = "0x1719884eb866cb12b2287399b15f7db5e7d775ea"; + + auto sigMainnet = Signing::signL1Action(wallet, action, vaultAddress, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x3c548db75e479f8012acf3000ca3a6b05606bc2ec0c29c50c515066a326239"); + EXPECT_EQ(sigMainnet.s, "0x4d402be7396ce74fbba3795769cda45aec00dc3125a984f2a9f23177b190da2c"); + EXPECT_EQ(sigMainnet.v, 28); + + auto sigTestnet = Signing::signL1Action(wallet, action, vaultAddress, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0xe281d2fb5c6e25ca01601f878e4d69c965bb598b88fac58e475dd1f5e56c362b"); + EXPECT_EQ(sigTestnet.s, "0x7ddad27e9a238d045c035bc606349d075d5c5cd00a6cd1da23ab5c39d4ef0f60"); + EXPECT_EQ(sigTestnet.v, 27); +} + +// test_l1_action_signing_tpsl_order_matches +TEST(SigningTest, L1ActionSigningTpslOrderMatches) +{ + auto wallet = testWallet(); + + // order_type: {"trigger": {"triggerPx": 103, "isMarket": True, "tpsl": "sl"}} + // order_type_to_wire produces: {"trigger": {"isMarket": True, "triggerPx": "103", "tpsl": "sl"}} + nlohmann::ordered_json orderWire; + orderWire["a"] = 1; + orderWire["b"] = true; + orderWire["p"] = floatToWire(100); + orderWire["s"] = floatToWire(100); + orderWire["r"] = false; + nlohmann::ordered_json triggerInner; + triggerInner["isMarket"] = true; + triggerInner["triggerPx"] = floatToWire(103); // "103" + triggerInner["tpsl"] = "sl"; + nlohmann::ordered_json triggerOuter; + triggerOuter["trigger"] = triggerInner; + orderWire["t"] = triggerOuter; + + auto action = orderWireToAction(orderWire); + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x98343f2b5ae8e26bb2587daad3863bc70d8792b09af1841b6fdd530a2065a3f9"); + EXPECT_EQ(sigMainnet.s, "0x6b5bb6bb0633b710aa22b721dd9dee6d083646a5f8e581a20b545be6c1feb405"); + EXPECT_EQ(sigMainnet.v, 27); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0x971c554d917c44e0e1b6cc45d8f9404f32172a9d3b3566262347d0302896a2e4"); + EXPECT_EQ(sigTestnet.s, "0x206257b104788f80450f8e786c329daa589aa0b32ba96948201ae556d5637eac"); + EXPECT_EQ(sigTestnet.v, 28); +} + +// test_float_to_int_for_hashing +TEST(SigningTest, FloatToIntForHashing) +{ + // 123123123123 * 1e8 exceeds double precision (~15 digits), skip that case + EXPECT_EQ(floatToIntForHashing(0.00001231), 1231ULL); + EXPECT_EQ(floatToIntForHashing(1.033), 103300000ULL); +} + +// test_sign_usd_transfer_action +TEST(SigningTest, SignUsdTransferAction) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "usdSend"; + action["hyperliquidChain"] = "Testnet"; + action["signatureChainId"] = "0x66eee"; + action["destination"] = "0x5e9ee1089755c3435139848e47e6635505d5a13a"; + action["amount"] = "1"; + action["time"] = 1687816341423; + + auto sig = Signing::signUserSignedAction( + wallet, action, + { + {"hyperliquidChain", "string"}, + {"destination", "string"}, + {"amount", "string"}, + {"time", "uint64"}, + }, + "HyperliquidTransaction:UsdSend", + false); + + EXPECT_EQ(sig.r, "0x637b37dd731507cdd24f46532ca8ba6eec616952c56218baeff04144e4a77073"); + EXPECT_EQ(sig.s, "0x11a6a24900e6e314136d2592e2f8d502cd89b7c15b198e1bee043c9589f9fad7"); + EXPECT_EQ(sig.v, 27); +} + +// test_sign_withdraw_from_bridge_action +TEST(SigningTest, SignWithdrawFromBridgeAction) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "withdraw"; + action["hyperliquidChain"] = "Testnet"; + action["signatureChainId"] = "0x66eee"; + action["destination"] = "0x5e9ee1089755c3435139848e47e6635505d5a13a"; + action["amount"] = "1"; + action["time"] = 1687816341423; + + auto sig = Signing::signUserSignedAction( + wallet, action, + { + {"hyperliquidChain", "string"}, + {"destination", "string"}, + {"amount", "string"}, + {"time", "uint64"}, + }, + "HyperliquidTransaction:Withdraw", + false); + + EXPECT_EQ(sig.r, "0x8363524c799e90ce9bc41022f7c39b4e9bdba786e5f9c72b20e43e1462c37cf9"); + EXPECT_EQ(sig.s, "0x58b1411a775938b83e29182e8ef74975f9054c8e97ebf5ec2dc8d51bfc893881"); + EXPECT_EQ(sig.v, 28); +} + +// test_create_sub_account_action +TEST(SigningTest, CreateSubAccountAction) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "createSubAccount"; + action["name"] = "example"; + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x51096fe3239421d16b671e192f574ae24ae14329099b6db28e479b86cdd6caa7"); + EXPECT_EQ(sigMainnet.s, "0xb71f7d293af92d3772572afb8b102d167a7cef7473388286bc01f52a5c5b423"); + EXPECT_EQ(sigMainnet.v, 27); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0xa699e3ed5c2b89628c746d3298b5dc1cca604694c2c855da8bb8250ec8014a5b"); + EXPECT_EQ(sigTestnet.s, "0x53f1b8153a301c72ecc655b1c315d64e1dcea3ee58921fd7507e35818fcc1584"); + EXPECT_EQ(sigTestnet.v, 28); +} + +// test_sub_account_transfer_action +TEST(SigningTest, SubAccountTransferAction) +{ + auto wallet = testWallet(); + nlohmann::ordered_json action; + action["type"] = "subAccountTransfer"; + action["subAccountUser"] = "0x1d9470d4b963f552e6f671a81619d395877bf409"; + action["isDeposit"] = true; + action["usd"] = 10; + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x43592d7c6c7d816ece2e206f174be61249d651944932b13343f4d13f306ae602"); + EXPECT_EQ(sigMainnet.s, "0x71a926cb5c9a7c01c3359ec4c4c34c16ff8107d610994d4de0e6430e5cc0f4c9"); + EXPECT_EQ(sigMainnet.v, 28); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0xe26574013395ad55ee2f4e0575310f003c5bb3351b5425482e2969fa51543927"); + EXPECT_EQ(sigTestnet.s, "0xefb08999196366871f919fd0e138b3a7f30ee33e678df7cfaf203e25f0a4278"); + EXPECT_EQ(sigTestnet.v, 28); +} + +// test_schedule_cancel_action (both with and without time) +TEST(SigningTest, ScheduleCancelAction) +{ + auto wallet = testWallet(); + + // Without time + nlohmann::ordered_json action; + action["type"] = "scheduleCancel"; + + auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x6cdfb286702f5917e76cd9b3b8bf678fcc49aec194c02a73e6d4f16891195df9"); + EXPECT_EQ(sigMainnet.s, "0x6557ac307fa05d25b8d61f21fb8a938e703b3d9bf575f6717ba21ec61261b2a0"); + EXPECT_EQ(sigMainnet.v, 27); + + auto sigTestnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0xc75bb195c3f6a4e06b7d395acc20bbb224f6d23ccff7c6a26d327304e6efaeed"); + EXPECT_EQ(sigTestnet.s, "0x342f8ede109a29f2c0723bd5efb9e9100e3bbb493f8fb5164ee3d385908233df"); + EXPECT_EQ(sigTestnet.v, 28); + + // With time + nlohmann::ordered_json actionWithTime; + actionWithTime["type"] = "scheduleCancel"; + actionWithTime["time"] = 123456789; + + sigMainnet = Signing::signL1Action(wallet, actionWithTime, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sigMainnet.r, "0x609cb20c737945d070716dcc696ba030e9976fcf5edad87afa7d877493109d55"); + EXPECT_EQ(sigMainnet.s, "0x16c685d63b5c7a04512d73f183b3d7a00da5406ff1f8aad33f8ae2163bab758b"); + EXPECT_EQ(sigMainnet.v, 28); + + sigTestnet = Signing::signL1Action(wallet, actionWithTime, std::nullopt, 0, std::nullopt, false); + EXPECT_EQ(sigTestnet.r, "0x4e4f2dbd4107c69783e251b7e1057d9f2b9d11cee213441ccfa2be63516dc5bc"); + EXPECT_EQ(sigTestnet.s, "0x706c656b23428c8ba356d68db207e11139ede1670481a9e01ae2dfcdb0e1a678"); + EXPECT_EQ(sigTestnet.v, 27); +} From 609dae35599a8246e2703c08d8e0754600e32e60 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sat, 14 Mar 2026 09:43:11 +0000 Subject: [PATCH 07/15] implement rest place and cancel orders --- CMakeLists.txt | 5 +- examples/basic_book.cpp | 28 ++-- examples/basic_meta.cpp | 44 +++--- examples/basic_orders.cpp | 146 ++++++++++++++--- include/hyperliquid/Logger.h | 9 ++ include/hyperliquid/rest/RestApi.h | 25 ++- .../hyperliquid/rest/RestApiMessageParser.h | 2 + .../hyperliquid/rest/RestEndpointListener.h | 2 + include/hyperliquid/types/RequestTypes.h | 41 +++++ include/hyperliquid/types/ResponseTypes.h | 19 +++ src/Logger.cpp | 26 ++++ src/Logger.h | 21 +++ src/rest/ExchangeRequestBuilder.cpp | 147 ++++++++++++++---- src/rest/ExchangeRequestBuilder.h | 9 ++ src/rest/HttpSession.cpp | 4 +- src/rest/RestApi.cpp | 139 ++++++++--------- src/rest/RestApiMessageParser.cpp | 105 +++++++++++-- src/websocket/WebsocketMessageParser.cpp | 18 +-- src/websocket/WebsocketRunner.cpp | 28 ++-- 19 files changed, 606 insertions(+), 212 deletions(-) create mode 100644 include/hyperliquid/Logger.h create mode 100644 src/Logger.cpp create mode 100644 src/Logger.h diff --git a/CMakeLists.txt b/CMakeLists.txt index f23669b..f50f9f0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ find_package(Boost REQUIRED COMPONENTS system) find_package(simdjson CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) find_package(unofficial-secp256k1 CONFIG REQUIRED) +find_package(spdlog CONFIG REQUIRED) set(SDK_SOURCES src/rest/RestApiMessageParser.cpp @@ -17,6 +18,7 @@ set(SDK_SOURCES src/rest/InfoRequestBuilder.cpp src/rest/ExchangeRequestBuilder.cpp src/rest/SymbolMap.cpp + src/Logger.cpp src/websocket/WebsocketRunner.cpp src/websocket/WebsocketApi.cpp @@ -47,6 +49,7 @@ target_link_libraries(hyperliquid-sdk simdjson::simdjson unofficial::secp256k1 unofficial::secp256k1_precomputed + spdlog::spdlog ) if(NOT MSVC) @@ -77,7 +80,7 @@ if(HYPERLIQUID_BUILD_EXAMPLES) foreach(EXAMPLE_SOURCE ${EXAMPLE_SOURCES}) get_filename_component(EXAMPLE_NAME ${EXAMPLE_SOURCE} NAME_WE) add_executable(${EXAMPLE_NAME} ${EXAMPLE_SOURCE}) - target_link_libraries(${EXAMPLE_NAME} PRIVATE hyperliquid-sdk nlohmann_json::nlohmann_json) + target_link_libraries(${EXAMPLE_NAME} PRIVATE hyperliquid-sdk nlohmann_json::nlohmann_json spdlog::spdlog) target_compile_definitions(${EXAMPLE_NAME} PRIVATE EXAMPLES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/") endforeach() endif() diff --git a/examples/basic_book.cpp b/examples/basic_book.cpp index db7bba4..e629f9f 100644 --- a/examples/basic_book.cpp +++ b/examples/basic_book.cpp @@ -1,8 +1,9 @@ #include -#include #include #include +#include + #include "hyperliquid/websocket/WebsocketApi.h" #include "hyperliquid/websocket/WebsocketApiListener.h" #include "hyperliquid/websocket/WebsocketMessageHandler.h" @@ -16,34 +17,31 @@ class BookPrinter : public hyperliquid::WebsocketMessageHandler, public hyperliq } void onConnected() override { - std::cout << "Connected" << std::endl; + spdlog::info("Connected"); } void onDisconnected(bool hasError, const std::string& errMsg) override { - std::cout << "Disconnected" << std::endl; + spdlog::info("Disconnected"); } // hyperliquid::WSMessageHandler void onL2BookLevel(const hyperliquid::L2BookUpdate& book, const hyperliquid::PriceLevel& level) override { - std::cout << book.time << " [L2Book] " << book.coin - << (level.side == hyperliquid::Side::Bid ? " BID " : " ASK ") - << level.sz << " @ " << level.px - << " (" << level.n << ")" << std::endl; + spdlog::info("{} [L2Book] {} {} {} @ {} ({})", book.time, book.coin, + level.side == hyperliquid::Side::Bid ? "BID" : "ASK", + level.sz, level.px, level.n); } void onBbo(const hyperliquid::BboUpdate& bbo) override { - std::cout << bbo.time << " [BBO] " << bbo.coin; + std::string msg = fmt::format("{} [BBO] {}", bbo.time, bbo.coin); if (bbo.hasBid) - std::cout << " BID " << bbo.bid.sz << " @ " << bbo.bid.px; + msg += fmt::format(" BID {} @ {}", bbo.bid.sz, bbo.bid.px); if (bbo.hasAsk) - std::cout << " ASK " << bbo.ask.sz << " @ " << bbo.ask.px; - std::cout << std::endl; + msg += fmt::format(" ASK {} @ {}", bbo.ask.sz, bbo.ask.px); + spdlog::info(msg); } void onTrade(const hyperliquid::Trade& trade) override { - std::cout << trade.time << " [TRADE] " << trade.coin - << " " << trade.side - << " " << trade.sz << " @ " << trade.px << std::endl; + spdlog::info("{} [TRADE] {} {} {} @ {}", trade.time, trade.coin, trade.side, trade.sz, trade.px); } private: @@ -54,7 +52,7 @@ int main() { BookPrinter printer; hyperliquid::WebsocketApi websocket(hyperliquid::Environment::Mainnet, printer); - std::cout << "Subscribing to BTC l2Book + bbo + trades for 5 seconds..." << std::endl; + spdlog::info("Subscribing to BTC l2Book + bbo + trades for 5 seconds..."); websocket.start(); websocket.subscribe(hyperliquid::SubscriptionType::L2Book, {{"coin", "BTC"}}); diff --git a/examples/basic_meta.cpp b/examples/basic_meta.cpp index 7ac85a8..88e2b6a 100644 --- a/examples/basic_meta.cpp +++ b/examples/basic_meta.cpp @@ -1,54 +1,48 @@ #include #include -#include +#include +#include int main() { - hyperliquid::RestApi api(hyperliquid::Environment::Mainnet); + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::RestApiConfig config; + config.env = hyperliquid::Environment::Mainnet; + + hyperliquid::RestApi api(config); hyperliquid::RestApiMessageParser parser; - std::cout << "=== Spot ===" << std::endl; + spdlog::info("=== Spot ==="); auto spotMeta = parser.parseSpotMeta(api.spotMeta()); - std::cout << spotMeta.tokens.size() << " assets:" << std::endl; + spdlog::info("{} assets:", spotMeta.tokens.size()); for (const auto& asset : spotMeta.tokens) { - std::cout << " " << asset.name - << " szDecimals=" << asset.szDecimals - << std::endl; + spdlog::info(" {} szDecimals={}", asset.name, asset.szDecimals); } auto dexes = parser.parsePerpDexs(api.perpDexs()); - std::cout << "=== Perps ===" << std::endl; + spdlog::info("=== Perps ==="); auto defaultMeta = parser.parseMeta(api.meta()); - std::cout << defaultMeta.universe.size() << " assets:" << std::endl; + spdlog::info("{} assets:", defaultMeta.universe.size()); for (const auto& asset : defaultMeta.universe) { - std::cout << " " << asset.name - << " szDecimals=" << asset.szDecimals - << " maxLeverage=" << asset.maxLeverage - << std::endl; + spdlog::info(" {} szDecimals={} maxLeverage={}", asset.name, asset.szDecimals, asset.maxLeverage); } - std::cout << std::endl; - std::cout << "Found " << dexes.dexes.size() << " HIP-3 perp dexes:" << std::endl; + spdlog::info("Found {} HIP-3 perp dexes:", dexes.dexes.size()); for (const auto& dex : dexes.dexes) { - std::cout << " " << dex.name << " (" << dex.fullName << ")" - << " deployer=" << dex.deployer << std::endl; + spdlog::info(" {} ({}) deployer={}", dex.name, dex.fullName, dex.deployer); } - std::cout << std::endl; for (const auto& dex : dexes.dexes) { - std::cout << "=== " << dex.name << " ===" << std::endl; + spdlog::info("=== {} ===", dex.name); auto meta = parser.parseMeta(api.meta(dex.name)); - std::cout << meta.universe.size() << " assets:" << std::endl; + spdlog::info("{} assets:", meta.universe.size()); for (const auto& asset : meta.universe) { - std::cout << " " << asset.name - << " szDecimals=" << asset.szDecimals - << " maxLeverage=" << asset.maxLeverage - << std::endl; + spdlog::info(" {} szDecimals={} maxLeverage={}", asset.name, asset.szDecimals, asset.maxLeverage); } - std::cout << std::endl; } return 0; diff --git a/examples/basic_orders.cpp b/examples/basic_orders.cpp index 0924e04..86549b5 100644 --- a/examples/basic_orders.cpp +++ b/examples/basic_orders.cpp @@ -2,44 +2,152 @@ #include #include -#include +#include +#include int main() { auto wallet = loadWalletFromConfig(); - hyperliquid::RestApi api(hyperliquid::Environment::Testnet, wallet); + + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::RestApiConfig config; + config.env = hyperliquid::Environment::Testnet; + config.wallet = wallet; + + hyperliquid::RestApi api(config); hyperliquid::RestApiMessageParser parser; - hyperliquid::OrderRequest order; - order.asset = "ETH"; - order.isBuy = true; - order.price = 1800.0; - order.size = 0.01; - order.reduceOnly = false; - order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + // ========================================================= + // Approach 1: Place and cancel by oid + // ========================================================= + + spdlog::info("=== Approach 1: Place and cancel by oid ==="); - std::cout << "Placing order on Hyperliquid testnet..." << std::endl; - auto response = parser.parsePlaceOrder( - api.placeOrder({order}, hyperliquid::Grouping::Na)); + hyperliquid::OrderRequest order1; + order1.asset = "ETH"; + order1.isBuy = true; + order1.price = 1800.0; + order1.size = 0.01; + order1.reduceOnly = false; + order1.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; - std::cout << "Order response status: " << response.status << std::endl; - for (const auto& status : response.statuses) + spdlog::info("Placing order..."); + auto placeRaw1 = api.placeOrder({order1}, hyperliquid::Grouping::Na); + spdlog::info("Place response: {}", placeRaw1); + auto placeResp1 = parser.parsePlaceOrder(placeRaw1); + spdlog::info("Place order status: {}", placeResp1.status); + + uint64_t oid = 0; + for (const auto& status : placeResp1.statuses) { if (status.resting) { - std::cout << " Resting oid=" << status.resting->oid << std::endl; + oid = status.resting->oid; + spdlog::info(" Resting oid={}", oid); } else if (status.filled) { - std::cout << " Filled oid=" << status.filled->oid - << " avgPx=" << status.filled->avgPx - << " totalSz=" << status.filled->totalSz << std::endl; + oid = status.filled->oid; + spdlog::info(" Filled oid={} avgPx={} totalSz={}", status.filled->oid, status.filled->avgPx, status.filled->totalSz); } else if (status.error) { - std::cout << " Error: " << *status.error << std::endl; + spdlog::info(" Error: {}", *status.error); + } + } + + if (oid != 0) + { + spdlog::info("Cancelling order oid={}...", oid); + + hyperliquid::CancelRequest cancel1; + cancel1.asset = "ETH"; + cancel1.oid = oid; + + auto cancelRaw1 = api.cancelOrder({cancel1}); + spdlog::info("Cancel response: {}", cancelRaw1); + auto cancelResp1 = parser.parseCancelOrder(cancelRaw1); + spdlog::info("Cancel order status: {}", cancelResp1.status); + for (const auto& status : cancelResp1.statuses) + { + if (status.success) + spdlog::info(" Success: {}", *status.success); + else if (status.error) + spdlog::info(" Error: {}", *status.error); } } + // ========================================================= + // Approach 2: Place with cloid, modify, cancel by cloid + // ========================================================= + + spdlog::info("=== Approach 2: Place with cloid, modify, cancel by cloid ==="); + + std::string cloid = hyperliquid::generateCloid(); + spdlog::info("Generated cloid: {}", cloid); + + hyperliquid::OrderRequest order2; + order2.asset = "ETH"; + order2.isBuy = true; + order2.price = 1800.0; + order2.size = 0.01; + order2.reduceOnly = false; + order2.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + order2.cloid = cloid; + + spdlog::info("Placing order with cloid..."); + auto placeRaw2 = api.placeOrder({order2}, hyperliquid::Grouping::Na); + spdlog::info("Place response: {}", placeRaw2); + auto placeResp2 = parser.parsePlaceOrder(placeRaw2); + spdlog::info("Place order status: {}", placeResp2.status); + for (const auto& status : placeResp2.statuses) + { + if (status.resting) + spdlog::info(" Resting oid={}", status.resting->oid); + else if (status.filled) + spdlog::info(" Filled oid={}", status.filled->oid); + else if (status.error) + spdlog::info(" Error: {}", *status.error); + } + + spdlog::info("Modifying order (changing price to 1750.0)..."); + + hyperliquid::OrderRequest modifiedOrder; + modifiedOrder.asset = "ETH"; + modifiedOrder.isBuy = true; + modifiedOrder.price = 1750.0; + modifiedOrder.size = 0.01; + modifiedOrder.reduceOnly = false; + modifiedOrder.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + modifiedOrder.cloid = cloid; + + hyperliquid::ModifyRequest modify; + modify.cloid = cloid; + modify.order = modifiedOrder; + + auto modifyRaw = api.modifyOrder(modify); + spdlog::info("Modify response: {}", modifyRaw); + auto modifyResp = parser.parseModifyOrder(modifyRaw); + spdlog::info("Modify order status: {}", modifyResp.status); + + spdlog::info("Cancelling order by cloid={}...", cloid); + + hyperliquid::CancelByCloidRequest cancel2; + cancel2.asset = "ETH"; + cancel2.cloid = cloid; + + auto cancelRaw2 = api.cancelOrderByCloid({cancel2}); + spdlog::info("Cancel response: {}", cancelRaw2); + auto cancelResp2 = parser.parseCancelOrder(cancelRaw2); + spdlog::info("Cancel order status: {}", cancelResp2.status); + for (const auto& status : cancelResp2.statuses) + { + if (status.success) + spdlog::info(" Success: {}", *status.success); + else if (status.error) + spdlog::info(" Error: {}", *status.error); + } + return 0; } diff --git a/include/hyperliquid/Logger.h b/include/hyperliquid/Logger.h new file mode 100644 index 0000000..81d0401 --- /dev/null +++ b/include/hyperliquid/Logger.h @@ -0,0 +1,9 @@ +#pragma once + +namespace hyperliquid { + +enum class LogLevel { Trace, Debug, Info, Warn, Error, Critical, Off }; + +void setLogLevel(LogLevel level); + +} // namespace hyperliquid diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index c96f1b0..f0f2e18 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -12,16 +12,17 @@ namespace hyperliquid { +struct RestApiConfig +{ + Environment env; + std::optional wallet; + std::set dexes; +}; + class RestApi { public: - RestApi(Environment env, RestApiListener& listener, Wallet wallet, - const std::set& dexes = {}); - RestApi(Environment env, Wallet wallet, - const std::set& dexes = {}); - RestApi(Environment env, RestApiListener& listener, - const std::set& dexes = {}); - RestApi(Environment env, - const std::set& dexes = {}); + explicit RestApi(const RestApiConfig& config); + RestApi(const RestApiConfig& config, RestApiListener& listener); ~RestApi(); RestApi(const RestApi&) = delete; @@ -33,6 +34,10 @@ class RestApi { std::string placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder = std::nullopt); + std::string cancelOrder(const std::vector& cancels); + std::string cancelOrderByCloid(const std::vector& cancels); + std::string modifyOrder(const ModifyRequest& modify); + std::string batchModifyOrder(const std::vector& modifies); void spotMetaAsync(); void metaAsync(const std::optional& dex = std::nullopt); @@ -40,6 +45,10 @@ class RestApi { void placeOrderAsync(const std::vector& orders, Grouping grouping, const std::optional& builder = std::nullopt); + void cancelOrderAsync(const std::vector& cancels); + void cancelOrderByCloidAsync(const std::vector& cancels); + void modifyOrderAsync(const ModifyRequest& modify); + void batchModifyOrderAsync(const std::vector& modifies); private: struct Impl; diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h index cf2817a..4509935 100644 --- a/include/hyperliquid/rest/RestApiMessageParser.h +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -25,6 +25,8 @@ namespace hyperliquid MetaResponse parseMeta(const std::string& message); PerpDexsResponse parsePerpDexs(const std::string& message); PlaceOrderResponse parsePlaceOrder(const std::string& message); + CancelOrderResponse parseCancelOrder(const std::string& message); + ModifyOrderResponse parseModifyOrder(const std::string& message); private: struct Impl; diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h index 92759de..1f4de3d 100644 --- a/include/hyperliquid/rest/RestEndpointListener.h +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -12,6 +12,8 @@ class RestEndpointListener { virtual void onMeta(const MetaResponse& response) {} virtual void onPerpDexs(const PerpDexsResponse& response) {} virtual void onPlaceOrder(const PlaceOrderResponse& response) {} + virtual void onCancelOrder(const CancelOrderResponse& response) {} + virtual void onModifyOrder(const ModifyOrderResponse& response) {} }; } diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 90e4dc3..ff80dfe 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -1,6 +1,9 @@ #pragma once #include +#include #include +#include +#include #include #include #include @@ -20,6 +23,8 @@ namespace hyperliquid Testnet }; + + inline const Endpoint& toWsEndpoint(Environment env) { static const Endpoint mainnet{"api.hyperliquid.xyz", "443", "/ws"}; @@ -287,9 +292,45 @@ namespace hyperliquid } } + struct CancelRequest + { + std::string asset; + uint64_t oid; + }; + + struct CancelByCloidRequest + { + std::string asset; + std::string cloid; + }; + + struct ModifyRequest + { + std::optional oid; + std::optional cloid; + OrderRequest order; + }; + struct Builder { std::string address; int fee; }; + + inline std::string generateCloid() + { + std::random_device rd; + std::mt19937_64 gen(rd()); + std::uniform_int_distribution dist; + + uint64_t hi = dist(gen); + uint64_t lo = dist(gen); + + std::ostringstream oss; + oss << "0x" + << std::hex << std::setfill('0') + << std::setw(16) << hi + << std::setw(16) << lo; + return oss.str(); + } } diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 8ea3304..4acb43a 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -372,4 +372,23 @@ namespace hyperliquid std::string type; std::vector statuses; }; + + struct CancelStatusResult + { + std::optional success; + std::optional error; + }; + + struct CancelOrderResponse + { + std::string status; + std::string type; + std::vector statuses; + }; + + struct ModifyOrderResponse + { + std::string status; + std::string type; + }; } diff --git a/src/Logger.cpp b/src/Logger.cpp new file mode 100644 index 0000000..f5582c7 --- /dev/null +++ b/src/Logger.cpp @@ -0,0 +1,26 @@ +#include "hyperliquid/Logger.h" +#include "Logger.h" + +namespace hyperliquid { + +static spdlog::level::level_enum toSpdlogLevel(LogLevel level) +{ + switch (level) + { + case LogLevel::Trace: return spdlog::level::trace; + case LogLevel::Debug: return spdlog::level::debug; + case LogLevel::Info: return spdlog::level::info; + case LogLevel::Warn: return spdlog::level::warn; + case LogLevel::Error: return spdlog::level::err; + case LogLevel::Critical: return spdlog::level::critical; + case LogLevel::Off: return spdlog::level::off; + default: return spdlog::level::info; + } +} + +void setLogLevel(LogLevel level) +{ + getLogger()->set_level(toSpdlogLevel(level)); +} + +} // namespace hyperliquid diff --git a/src/Logger.h b/src/Logger.h new file mode 100644 index 0000000..cfdc1d5 --- /dev/null +++ b/src/Logger.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +namespace hyperliquid { + +inline std::shared_ptr getLogger() { + auto logger = spdlog::get("hyperliquid"); + if (!logger) { + try { + logger = spdlog::stdout_color_mt("hyperliquid"); + } catch (const spdlog::spdlog_ex&) { + logger = spdlog::get("hyperliquid"); + } + } + return logger; +} + +} // namespace hyperliquid diff --git a/src/rest/ExchangeRequestBuilder.cpp b/src/rest/ExchangeRequestBuilder.cpp index 232f9e8..5b3da7f 100644 --- a/src/rest/ExchangeRequestBuilder.cpp +++ b/src/rest/ExchangeRequestBuilder.cpp @@ -35,43 +35,46 @@ ExchangeRequestBuilder::ExchangeRequestBuilder(const SymbolMap& symbolMap) { } +nlohmann::ordered_json ExchangeRequestBuilder::buildOrderWire(const OrderRequest& order) const +{ + nlohmann::ordered_json orderJson; + orderJson["a"] = symbolMap_.resolve(order.asset); + orderJson["b"] = order.isBuy; + orderJson["p"] = floatToWire(order.price); + orderJson["s"] = floatToWire(order.size); + orderJson["r"] = order.reduceOnly; + + if (order.limit) + { + nlohmann::ordered_json limitInner; + limitInner["tif"] = toString(order.limit->tif); + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderJson["t"] = limitOuter; + } + else if (order.trigger) + { + nlohmann::ordered_json triggerInner; + triggerInner["isMarket"] = order.trigger->isMarket; + triggerInner["triggerPx"] = floatToWire(order.trigger->triggerPx); + triggerInner["tpsl"] = toString(order.trigger->tpsl); + nlohmann::ordered_json triggerOuter; + triggerOuter["trigger"] = triggerInner; + orderJson["t"] = triggerOuter; + } + + if (order.cloid) orderJson["c"] = *order.cloid; + + return orderJson; +} + nlohmann::ordered_json ExchangeRequestBuilder::placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder) const { nlohmann::ordered_json ordersJson = nlohmann::ordered_json::array(); for (const auto& order : orders) - { - nlohmann::ordered_json orderJson; - orderJson["a"] = symbolMap_.resolve(order.asset); - orderJson["b"] = order.isBuy; - orderJson["p"] = floatToWire(order.price); - orderJson["s"] = floatToWire(order.size); - orderJson["r"] = order.reduceOnly; - - if (order.limit) - { - nlohmann::ordered_json limitInner; - limitInner["tif"] = toString(order.limit->tif); - nlohmann::ordered_json limitOuter; - limitOuter["limit"] = limitInner; - orderJson["t"] = limitOuter; - } - else if (order.trigger) - { - nlohmann::ordered_json triggerInner; - triggerInner["isMarket"] = order.trigger->isMarket; - triggerInner["triggerPx"] = floatToWire(order.trigger->triggerPx); - triggerInner["tpsl"] = toString(order.trigger->tpsl); - nlohmann::ordered_json triggerOuter; - triggerOuter["trigger"] = triggerInner; - orderJson["t"] = triggerOuter; - } - - if (order.cloid) orderJson["c"] = *order.cloid; - - ordersJson.push_back(orderJson); - } + ordersJson.push_back(buildOrderWire(order)); nlohmann::ordered_json action; action["type"] = "order"; @@ -91,4 +94,86 @@ nlohmann::ordered_json ExchangeRequestBuilder::placeOrder(const std::vector& cancels) const +{ + nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); + for (const auto& cancel : cancels) + { + nlohmann::ordered_json cancelJson; + cancelJson["a"] = symbolMap_.resolve(cancel.asset); + cancelJson["o"] = cancel.oid; + cancelsJson.push_back(cancelJson); + } + + nlohmann::ordered_json action; + action["type"] = "cancel"; + action["cancels"] = cancelsJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; +} + +nlohmann::ordered_json ExchangeRequestBuilder::cancelOrderByCloid(const std::vector& cancels) const +{ + nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); + for (const auto& cancel : cancels) + { + nlohmann::ordered_json cancelJson; + cancelJson["asset"] = symbolMap_.resolve(cancel.asset); + cancelJson["cloid"] = cancel.cloid; + cancelsJson.push_back(cancelJson); + } + + nlohmann::ordered_json action; + action["type"] = "cancelByCloid"; + action["cancels"] = cancelsJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; +} + +static void setModifyOid(nlohmann::ordered_json& json, const ModifyRequest& modify) +{ + if (modify.oid) + json["oid"] = *modify.oid; + else if (modify.cloid) + json["oid"] = *modify.cloid; + else + throw std::invalid_argument("ModifyRequest requires either oid or cloid"); +} + +nlohmann::ordered_json ExchangeRequestBuilder::modifyOrder(const ModifyRequest& modify) const +{ + nlohmann::ordered_json action; + action["type"] = "modify"; + setModifyOid(action, modify); + action["order"] = buildOrderWire(modify.order); + + nlohmann::ordered_json body; + body["action"] = action; + return body; +} + +nlohmann::ordered_json ExchangeRequestBuilder::batchModifyOrder(const std::vector& modifies) const +{ + nlohmann::ordered_json modifiesJson = nlohmann::ordered_json::array(); + for (const auto& modify : modifies) + { + nlohmann::ordered_json modifyJson; + setModifyOid(modifyJson, modify); + modifyJson["order"] = buildOrderWire(modify.order); + modifiesJson.push_back(modifyJson); + } + + nlohmann::ordered_json action; + action["type"] = "batchModify"; + action["modifies"] = modifiesJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; +} + } diff --git a/src/rest/ExchangeRequestBuilder.h b/src/rest/ExchangeRequestBuilder.h index b671c30..491dd33 100644 --- a/src/rest/ExchangeRequestBuilder.h +++ b/src/rest/ExchangeRequestBuilder.h @@ -18,7 +18,16 @@ class ExchangeRequestBuilder { Grouping grouping, const std::optional& builder = std::nullopt) const; + nlohmann::ordered_json cancelOrder(const std::vector& cancels) const; + + nlohmann::ordered_json cancelOrderByCloid(const std::vector& cancels) const; + + nlohmann::ordered_json modifyOrder(const ModifyRequest& modify) const; + + nlohmann::ordered_json batchModifyOrder(const std::vector& modifies) const; + private: + nlohmann::ordered_json buildOrderWire(const OrderRequest& order) const; const SymbolMap& symbolMap_; }; diff --git a/src/rest/HttpSession.cpp b/src/rest/HttpSession.cpp index 05fe34e..188ea66 100644 --- a/src/rest/HttpSession.cpp +++ b/src/rest/HttpSession.cpp @@ -1,7 +1,7 @@ #include "HttpSession.h" #include -#include +#include "Logger.h" namespace hyperliquid { @@ -110,7 +110,7 @@ void HttpSession::onRead(beast::error_code ec) [self = shared_from_this()](beast::error_code ec) { if (ec && ec != net::error::eof && ec != beast::errc::not_connected && ec != net::ssl::error::stream_truncated) { - std::cerr << "HttpSession shutdown error: " << ec.message() << std::endl; + getLogger()->error("HttpSession shutdown error: {}", ec.message()); } }); } diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 5634bbd..faf4b43 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -9,7 +9,6 @@ #include #include -#include #include #include #include @@ -18,6 +17,8 @@ #include +#include "Logger.h" + namespace hyperliquid { namespace beast = boost::beast; @@ -41,74 +42,26 @@ struct RestApi::Impl { SymbolMap symbolMap; ExchangeRequestBuilder exchangeRequestBuilder; - Impl(Environment env, RestApiListener& listener, Wallet wallet, - const std::set& dexes) + Impl(const RestApiConfig& config, RestApiListener& listener) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) - , host(toInfoEndpoint(env).host) - , port(toInfoEndpoint(env).port) + , host(toInfoEndpoint(config.env).host) + , port(toInfoEndpoint(config.env).port) , listener(listener) - , wallet(wallet) - , env(env) - , enabledDexes(dexes) - , exchangeRequestBuilder(symbolMap) - { - sslCtx.set_default_verify_paths(); - sslCtx.set_verify_mode(ssl::verify_peer); - thread = std::thread([this]() { ioc.run(); }); - authenticated = true; - buildSymbolMap(); - } - - Impl(Environment env, Wallet wallet, - const std::set& dexes) - : work(net::make_work_guard(ioc)) - , sslCtx(ssl::context::tlsv12_client) - , host(toInfoEndpoint(env).host) - , port(toInfoEndpoint(env).port) - , listener(defaultListener) - , wallet(wallet) - , env(env) - , enabledDexes(dexes) + , env(config.env) + , enabledDexes(config.dexes) , exchangeRequestBuilder(symbolMap) { sslCtx.set_default_verify_paths(); sslCtx.set_verify_mode(ssl::verify_peer); thread = std::thread([this]() { ioc.run(); }); - authenticated = true; - buildSymbolMap(); - } - Impl(Environment env, RestApiListener& listener, - const std::set& dexes) - : work(net::make_work_guard(ioc)) - , sslCtx(ssl::context::tlsv12_client) - , host(toInfoEndpoint(env).host) - , port(toInfoEndpoint(env).port) - , listener(listener) - , env(env) - , enabledDexes(dexes) - , exchangeRequestBuilder(symbolMap) - { - sslCtx.set_default_verify_paths(); - sslCtx.set_verify_mode(ssl::verify_peer); - thread = std::thread([this]() { ioc.run(); }); - buildSymbolMap(); - } + if (config.wallet) + { + wallet = *config.wallet; + authenticated = true; + } - Impl(Environment env, const std::set& dexes) - : work(net::make_work_guard(ioc)) - , sslCtx(ssl::context::tlsv12_client) - , host(toInfoEndpoint(env).host) - , port(toInfoEndpoint(env).port) - , listener(defaultListener) - , env(env) - , enabledDexes(dexes) - , exchangeRequestBuilder(symbolMap) - { - sslCtx.set_default_verify_paths(); - sslCtx.set_verify_mode(ssl::verify_peer); - thread = std::thread([this]() { ioc.run(); }); buildSymbolMap(); } @@ -205,7 +158,7 @@ struct RestApi::Impl { ioc, sslCtx, host, port, toPath(type), [this, type](const std::string& responseBody, beast::error_code ec) { if (ec) { - std::cerr << "RestApi: error for " << toString(type) << ": " << ec.message() << std::endl; + getLogger()->error("RestApi: error for {}: {}", toString(type), ec.message()); return; } listener.onMessage(responseBody, type); @@ -220,6 +173,7 @@ struct RestApi::Impl { { auto prepared = prepareBody(type, body, vaultAddress, expiresAfter); std::string serialized = prepared.dump(); + getLogger()->debug("{}", serialized); auto promise = std::make_shared>(); auto future = promise->get_future(); @@ -240,26 +194,13 @@ struct RestApi::Impl { } }; -RestApi::RestApi(Environment env, RestApiListener& listener, Wallet wallet, - const std::set& dexes) - : impl_(std::make_unique(env, listener, wallet, dexes)) -{ -} - -RestApi::RestApi(Environment env, Wallet wallet, - const std::set& dexes) - : impl_(std::make_unique(env, wallet, dexes)) +RestApi::RestApi(const RestApiConfig& config) + : impl_(std::make_unique(config, defaultListener)) { } -RestApi::RestApi(Environment env, RestApiListener& listener, - const std::set& dexes) - : impl_(std::make_unique(env, listener, dexes)) -{ -} - -RestApi::RestApi(Environment env, const std::set& dexes) - : impl_(std::make_unique(env, dexes)) +RestApi::RestApi(const RestApiConfig& config, RestApiListener& listener) + : impl_(std::make_unique(config, listener)) { } @@ -310,4 +251,48 @@ void RestApi::placeOrderAsync(const std::vector& orders, impl_->signAndSend(RestEndpointType::PlaceOrder, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } +std::string RestApi::cancelOrder(const std::vector& cancels) +{ + return impl_->signAndSendSync(RestEndpointType::CancelOrder, + impl_->exchangeRequestBuilder.cancelOrder(cancels)); +} + +std::string RestApi::cancelOrderByCloid(const std::vector& cancels) +{ + return impl_->signAndSendSync(RestEndpointType::CancelOrderByCloid, + impl_->exchangeRequestBuilder.cancelOrderByCloid(cancels)); +} + +std::string RestApi::modifyOrder(const ModifyRequest& modify) +{ + return impl_->signAndSendSync(RestEndpointType::ModifyOrder, + impl_->exchangeRequestBuilder.modifyOrder(modify)); +} + +std::string RestApi::batchModifyOrder(const std::vector& modifies) +{ + return impl_->signAndSendSync(RestEndpointType::BatchModifyOrder, + impl_->exchangeRequestBuilder.batchModifyOrder(modifies)); +} + +void RestApi::cancelOrderAsync(const std::vector& cancels) +{ + impl_->signAndSend(RestEndpointType::CancelOrder, impl_->exchangeRequestBuilder.cancelOrder(cancels)); +} + +void RestApi::cancelOrderByCloidAsync(const std::vector& cancels) +{ + impl_->signAndSend(RestEndpointType::CancelOrderByCloid, impl_->exchangeRequestBuilder.cancelOrderByCloid(cancels)); +} + +void RestApi::modifyOrderAsync(const ModifyRequest& modify) +{ + impl_->signAndSend(RestEndpointType::ModifyOrder, impl_->exchangeRequestBuilder.modifyOrder(modify)); +} + +void RestApi::batchModifyOrderAsync(const std::vector& modifies) +{ + impl_->signAndSend(RestEndpointType::BatchModifyOrder, impl_->exchangeRequestBuilder.batchModifyOrder(modifies)); +} + } diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index cc29d41..b1288b9 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -1,6 +1,6 @@ #include "hyperliquid/rest/RestApiMessageParser.h" -#include #include +#include "Logger.h" namespace hyperliquid { @@ -28,9 +28,16 @@ namespace hyperliquid case RestEndpointType::PlaceOrder: listener.onPlaceOrder(parsePlaceOrder(message)); break; + case RestEndpointType::CancelOrder: + case RestEndpointType::CancelOrderByCloid: + listener.onCancelOrder(parseCancelOrder(message)); + break; + case RestEndpointType::ModifyOrder: + case RestEndpointType::BatchModifyOrder: + listener.onModifyOrder(parseModifyOrder(message)); + break; default: - std::cerr << "RestMessageParser: unhandled RestEndpointType: " - << toString(type) << std::endl; + getLogger()->error("RestMessageParser: unhandled RestEndpointType: {}", toString(type)); break; } } @@ -87,8 +94,77 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& err) { - std::cerr << "RestMessageParser: parse error in placeOrder: " << err.what() - << "\n raw: " << message << std::endl; + getLogger()->error("RestMessageParser: parse error in placeOrder: {}\n raw: {}", err.what(), message); + } + + return response; + } + + CancelOrderResponse parseCancelOrder(const std::string& message) + { + CancelOrderResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + response.status = std::string(doc["status"].get_string().value()); + + if (response.status != "ok") return response; + + auto resp = doc["response"].get_object().value(); + response.type = std::string(resp["type"].get_string().value()); + + auto statuses = resp["data"]["statuses"].get_array().value(); + for (auto entry : statuses) + { + CancelStatusResult result; + + // statuses can be either a string "success" or an object with "error" + simdjson::ondemand::json_type entryType = entry.type().value(); + if (entryType == simdjson::ondemand::json_type::string) + { + result.success = std::string(entry.get_string().value()); + } + else if (entryType == simdjson::ondemand::json_type::object) + { + auto obj = entry.get_object().value(); + simdjson::ondemand::value error; + if (obj["error"].get(error) == simdjson::SUCCESS) + { + result.error = std::string(error.get_string().value()); + } + } + + response.statuses.push_back(std::move(result)); + } + } + catch (const simdjson::simdjson_error& err) + { + getLogger()->error("RestMessageParser: parse error in cancelOrder: {}\n raw: {}", err.what(), message); + } + + return response; + } + + ModifyOrderResponse parseModifyOrder(const std::string& message) + { + ModifyOrderResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + response.status = std::string(doc["status"].get_string().value()); + + if (response.status != "ok") return response; + + auto resp = doc["response"].get_object().value(); + response.type = std::string(resp["type"].get_string().value()); + } + catch (const simdjson::simdjson_error& err) + { + getLogger()->error("RestMessageParser: parse error in modifyOrder: {}\n raw: {}", err.what(), message); } return response; @@ -150,8 +226,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& err) { - std::cerr << "RestMessageParser: parse error in perpDexs: " << err.what() - << "\n raw: " << message << std::endl; + getLogger()->error("RestMessageParser: parse error in perpDexs: {}\n raw: {}", err.what(), message); } return response; @@ -189,8 +264,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "RestMessageParser: parse error in spotMeta: " << e.what() - << "\n raw: " << message << std::endl; + getLogger()->error("RestMessageParser: parse error in spotMeta: {}\n raw: {}", e.what(), message); } return response; @@ -217,8 +291,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "RestMessageParser: parse error in meta: " << e.what() - << "\n raw: " << message << std::endl; + getLogger()->error("RestMessageParser: parse error in meta: {}\n raw: {}", e.what(), message); } return response; @@ -261,4 +334,14 @@ namespace hyperliquid { return impl_->parsePlaceOrder(message); } + + CancelOrderResponse RestApiMessageParser::parseCancelOrder(const std::string& message) + { + return impl_->parseCancelOrder(message); + } + + ModifyOrderResponse RestApiMessageParser::parseModifyOrder(const std::string& message) + { + return impl_->parseModifyOrder(message); + } } // namespace hyperliquid diff --git a/src/websocket/WebsocketMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp index 11b9401..be4f830 100644 --- a/src/websocket/WebsocketMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -1,7 +1,7 @@ #include "hyperliquid/websocket/WebsocketMessageParser.h" #include -#include #include +#include "Logger.h" #include "../../include/hyperliquid/types/ResponseTypes.h" @@ -82,16 +82,16 @@ namespace hyperliquid } else if (channel == "error") { - std::cerr << "Error: " << message << std::endl; + getLogger()->error("Websocket error: {}", message); } else { - std::cerr << "Unhandled message: " << message << std::endl; + getLogger()->warn("Unhandled message: {}", message); } } catch (const simdjson::simdjson_error& e) { - std::cerr << "parse error: " << e.what() << std::endl; + getLogger()->error("parse error: {}", e.what()); } } @@ -124,7 +124,7 @@ namespace hyperliquid { if (sideIdx > 1) { - std::cerr << "unexpected l2Book side index: " << sideIdx << std::endl; + getLogger()->error("unexpected l2Book side index: {}", sideIdx); break; } Side s = (sideIdx == 0) ? Side::Bid : Side::Ask; @@ -142,7 +142,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "parse error in l2Book level: " << e.what() << std::endl; + getLogger()->error("parse error in l2Book level: {}", e.what()); } } sideIdx++; @@ -225,7 +225,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "parse error in trade: " << e.what() << std::endl; + getLogger()->error("parse error in trade: {}", e.what()); } } } @@ -252,7 +252,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "parse error in candle: " << e.what() << std::endl; + getLogger()->error("parse error in candle: {}", e.what()); } } } @@ -271,7 +271,7 @@ namespace hyperliquid } catch (const simdjson::simdjson_error& e) { - std::cerr << "parse error in allMids entry: " << e.what() << std::endl; + getLogger()->error("parse error in allMids entry: {}", e.what()); } } } diff --git a/src/websocket/WebsocketRunner.cpp b/src/websocket/WebsocketRunner.cpp index 3c007c3..a0e113b 100644 --- a/src/websocket/WebsocketRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -1,6 +1,6 @@ #include "WebsocketRunner.h" -#include #include +#include "Logger.h" namespace hyperliquid { namespace internal { @@ -55,7 +55,7 @@ void WebsocketRunner::doResolve() { void WebsocketRunner::onResolve(beast::error_code ec, tcp::resolver::results_type results) { if (ec) { - std::cerr << "resolve error: " << ec.message() << std::endl; + getLogger()->error("resolve error: {}", ec.message()); scheduleReconnect(); return; } @@ -70,14 +70,14 @@ void WebsocketRunner::onResolve(beast::error_code ec, tcp::resolver::results_typ void WebsocketRunner::onConnect(beast::error_code ec, tcp::resolver::results_type::endpoint_type) { if (ec) { - std::cerr << "connect error: " << ec.message() << std::endl; + getLogger()->error("connect error: {}", ec.message()); scheduleReconnect(); return; } if (!SSL_set_tlsext_host_name(ws_->next_layer().native_handle(), host_.c_str())) { ec = beast::error_code(static_cast(::ERR_get_error()), net::error::get_ssl_category()); - std::cerr << "SNI error: " << ec.message() << std::endl; + getLogger()->error("SNI error: {}", ec.message()); scheduleReconnect(); return; } @@ -90,7 +90,7 @@ void WebsocketRunner::onConnect(beast::error_code ec, tcp::resolver::results_typ void WebsocketRunner::onSslHandshake(beast::error_code ec) { if (ec) { - std::cerr << "SSL handshake error: " << ec.message() << std::endl; + getLogger()->error("SSL handshake error: {}", ec.message()); scheduleReconnect(); return; } @@ -108,7 +108,7 @@ void WebsocketRunner::onSslHandshake(beast::error_code ec) { void WebsocketRunner::onWsHandshake(beast::error_code ec) { if (ec) { - std::cerr << "WebSocket handshake error: " << ec.message() << std::endl; + getLogger()->error("WebSocket handshake error: {}", ec.message()); scheduleReconnect(); return; } @@ -140,7 +140,7 @@ void WebsocketRunner::onRead(beast::error_code errorCode, std::size_t) { if (errorCode) { if (stopping_) { // read cancelled, now safe to close - std::cout << "Closing websocket..." << std::endl; + getLogger()->info("Closing websocket..."); ws_->async_close(websocket::close_code::normal, [this](beast::error_code ec) { if (ec) { @@ -152,12 +152,12 @@ void WebsocketRunner::onRead(beast::error_code errorCode, std::size_t) { stopping_ = false; }); } else if (errorCode == websocket::error::closed) { - std::cerr << "Unexpected websocket close" << std::endl; + getLogger()->error("Unexpected websocket close"); listener_.onWsDisconnected(true, errorCode.message()); connected_ = false; scheduleReconnect(); } else { - std::cerr << "Unexpected error: " << errorCode.message() << std::endl; + getLogger()->error("Unexpected error: {}", errorCode.message()); listener_.onWsDisconnected(true, errorCode.message()); connected_ = false; scheduleReconnect(); @@ -188,7 +188,7 @@ void WebsocketRunner::doWrite() { void WebsocketRunner::onWrite(beast::error_code ec, std::size_t) { if (ec) { - std::cerr << "write error: " << ec.message() << std::endl; + getLogger()->error("write error: {}", ec.message()); return; } @@ -200,7 +200,7 @@ void WebsocketRunner::doClose() { if (!connected_ || stopping_) return; if (pingTimer_) pingTimer_->cancel(); stopping_ = true; - std::cout << "Waiting to close websocket..." << std::endl; + getLogger()->info("Waiting to close websocket..."); beast::get_lowest_layer(*ws_).cancel(); } @@ -209,7 +209,7 @@ void WebsocketRunner::scheduleReconnect() { reconnectAttempts_++; int delaySecs = std::min(1 << reconnectAttempts_, MAX_BACKOFF_SECS); - std::cout << "Reconnecting in " << delaySecs << "s (attempt " << reconnectAttempts_ << ")" << std::endl; + getLogger()->info("Reconnecting in {}s (attempt {})", delaySecs, reconnectAttempts_); reconnectTimer_ = std::make_unique(ioc_, std::chrono::seconds(delaySecs)); reconnectTimer_->async_wait([this](const boost::system::error_code& ec) { @@ -253,7 +253,7 @@ void WebsocketRunner::doPing() { if (pendingPong_) { auto elapsed = std::chrono::steady_clock::now() - lastPongTime_; if (elapsed > std::chrono::seconds(PONG_TIMEOUT_SECS)) { - std::cerr << "Pong timeout, reconnecting..." << std::endl; + getLogger()->error("Pong timeout, reconnecting..."); connected_ = false; listener_.onWsDisconnected(true, "Pong timeout"); scheduleReconnect(); @@ -265,7 +265,7 @@ void WebsocketRunner::doPing() { ws_->async_ping({}, [this](beast::error_code ec) { if (ec) { if (!stopping_) { - std::cerr << "Ping failed: " << ec.message() << std::endl; + getLogger()->error("Ping failed: {}", ec.message()); connected_ = false; listener_.onWsDisconnected(true, "Ping failed"); scheduleReconnect(); From 1fcf33fa95452d95768eba2dd1f8db797cade7a7 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sat, 14 Mar 2026 10:31:58 +0000 Subject: [PATCH 08/15] resolve build issues --- CMakeLists.txt | 17 ++++++++++++++--- CMakePresets.json | 2 +- vcpkg.json | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) create mode 100644 vcpkg.json diff --git a/CMakeLists.txt b/CMakeLists.txt index f50f9f0..9c5f961 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,9 +8,21 @@ find_package(OpenSSL REQUIRED) find_package(Boost REQUIRED COMPONENTS system) find_package(simdjson CONFIG REQUIRED) find_package(nlohmann_json CONFIG REQUIRED) -find_package(unofficial-secp256k1 CONFIG REQUIRED) find_package(spdlog CONFIG REQUIRED) +include(FetchContent) +FetchContent_Declare( + secp256k1 + GIT_REPOSITORY https://github.com/bitcoin-core/secp256k1.git + GIT_TAG v0.7.1 +) +set(SECP256K1_ENABLE_MODULE_RECOVERY ON CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_TESTS OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_EXHAUSTIVE_TESTS OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(secp256k1) + set(SDK_SOURCES src/rest/RestApiMessageParser.cpp src/rest/RestApi.cpp @@ -47,8 +59,7 @@ target_link_libraries(hyperliquid-sdk OpenSSL::Crypto nlohmann_json::nlohmann_json simdjson::simdjson - unofficial::secp256k1 - unofficial::secp256k1_precomputed + secp256k1 spdlog::spdlog ) diff --git a/CMakePresets.json b/CMakePresets.json index 74fde8b..3de86d6 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -5,7 +5,7 @@ "name": "default", "binaryDir": "${sourceDir}/build", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "$env{HOME}/.vcpkg-clion/vcpkg/scripts/buildsystems/vcpkg.cmake", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "HYPERLIQUID_BUILD_EXAMPLES": "ON" } } diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..0e57a10 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,13 @@ +{ + "name": "hyperliquid-sdk-cpp", + "version": "0.1.0", + "dependencies": [ + "openssl", + "boost-asio", + "boost-beast", + "boost-system", + "simdjson", + "nlohmann-json", + "spdlog" + ] +} From 7fc4b217082e08f4e50391236739a3b85b39e45e Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sat, 14 Mar 2026 12:53:53 +0000 Subject: [PATCH 09/15] Allow websocket to submit post requests --- CMakeLists.txt | 8 +- examples/{basic_meta.cpp => rest_meta.cpp} | 4 +- .../{basic_orders.cpp => rest_orders.cpp} | 4 +- examples/test_config.h | 2 +- examples/{basic_book.cpp => ws_book.cpp} | 5 +- examples/ws_orders.cpp | 113 ++++++ include/hyperliquid/Logger.h | 9 - include/hyperliquid/config/Config.h | 26 ++ include/hyperliquid/rest/RestApi.h | 16 +- include/hyperliquid/signing/Wallet.h | 11 - include/hyperliquid/websocket/WebsocketApi.h | 18 +- .../websocket/WebsocketApiListener.h | 3 + src/{Logger.cpp => config/Config.cpp} | 5 +- src/{ => config}/Logger.h | 0 src/messages/ExchangeRequestBuilder.cpp | 233 +++++++++++ .../ExchangeRequestBuilder.h | 9 +- src/{rest => messages}/InfoRequestBuilder.cpp | 2 +- src/{rest => messages}/InfoRequestBuilder.h | 0 src/rest/ExchangeRequestBuilder.cpp | 179 --------- src/rest/HttpSession.cpp | 2 +- src/rest/RestApi.cpp | 161 ++------ src/rest/RestApiMessageParser.cpp | 2 +- src/rest/SymbolMap.h | 2 +- src/signing/Signing.cpp | 372 ++---------------- src/signing/Signing.h | 42 +- src/signing/SigningHelpers.cpp | 284 +++++++++++++ src/signing/SigningHelpers.h | 60 +++ src/websocket/WebsocketApi.cpp | 126 +++++- src/websocket/WebsocketMessageParser.cpp | 2 +- src/websocket/WebsocketRunner.cpp | 13 +- src/websocket/WebsocketRunner.h | 7 +- tests/signing_test.cpp | 5 +- 32 files changed, 985 insertions(+), 740 deletions(-) rename examples/{basic_meta.cpp => rest_meta.cpp} (94%) rename examples/{basic_orders.cpp => rest_orders.cpp} (98%) rename examples/{basic_book.cpp => ws_book.cpp} (93%) create mode 100644 examples/ws_orders.cpp delete mode 100644 include/hyperliquid/Logger.h create mode 100644 include/hyperliquid/config/Config.h delete mode 100644 include/hyperliquid/signing/Wallet.h rename src/{Logger.cpp => config/Config.cpp} (92%) rename src/{ => config}/Logger.h (100%) create mode 100644 src/messages/ExchangeRequestBuilder.cpp rename src/{rest => messages}/ExchangeRequestBuilder.h (81%) rename src/{rest => messages}/InfoRequestBuilder.cpp (93%) rename src/{rest => messages}/InfoRequestBuilder.h (100%) delete mode 100644 src/rest/ExchangeRequestBuilder.cpp create mode 100644 src/signing/SigningHelpers.cpp create mode 100644 src/signing/SigningHelpers.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9c5f961..c975a52 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,20 +27,20 @@ set(SDK_SOURCES src/rest/RestApiMessageParser.cpp src/rest/RestApi.cpp src/rest/HttpSession.cpp - src/rest/InfoRequestBuilder.cpp - src/rest/ExchangeRequestBuilder.cpp + src/messages/InfoRequestBuilder.cpp + src/messages/ExchangeRequestBuilder.cpp src/rest/SymbolMap.cpp - src/Logger.cpp + src/config/Config.cpp src/websocket/WebsocketRunner.cpp src/websocket/WebsocketApi.cpp src/websocket/WebsocketMessageParser.cpp src/signing/Signing.cpp + src/signing/SigningHelpers.cpp src/signing/sha3.c include/hyperliquid/types/RequestTypes.h - include/hyperliquid/signing/Wallet.h ) add_library(hyperliquid-sdk STATIC ${SDK_SOURCES}) diff --git a/examples/basic_meta.cpp b/examples/rest_meta.cpp similarity index 94% rename from examples/basic_meta.cpp rename to examples/rest_meta.cpp index 88e2b6a..e0dfa9b 100644 --- a/examples/basic_meta.cpp +++ b/examples/rest_meta.cpp @@ -1,12 +1,12 @@ #include #include -#include +#include <../include/hyperliquid/config/Config.h> #include int main() { hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); - hyperliquid::RestApiConfig config; + hyperliquid::ApiConfig config; config.env = hyperliquid::Environment::Mainnet; hyperliquid::RestApi api(config); diff --git a/examples/basic_orders.cpp b/examples/rest_orders.cpp similarity index 98% rename from examples/basic_orders.cpp rename to examples/rest_orders.cpp index 86549b5..8376832 100644 --- a/examples/basic_orders.cpp +++ b/examples/rest_orders.cpp @@ -2,7 +2,7 @@ #include #include -#include +#include <../include/hyperliquid/config/Config.h> #include int main() @@ -11,7 +11,7 @@ int main() hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); - hyperliquid::RestApiConfig config; + hyperliquid::ApiConfig config; config.env = hyperliquid::Environment::Testnet; config.wallet = wallet; diff --git a/examples/test_config.h b/examples/test_config.h index 662f47b..d953ba1 100644 --- a/examples/test_config.h +++ b/examples/test_config.h @@ -5,7 +5,7 @@ #include #include -#include +#include "hyperliquid/config/Config.h" inline hyperliquid::Wallet loadWalletFromConfig(const std::string& path = EXAMPLES_DIR "test.json") { diff --git a/examples/basic_book.cpp b/examples/ws_book.cpp similarity index 93% rename from examples/basic_book.cpp rename to examples/ws_book.cpp index e629f9f..2fced42 100644 --- a/examples/basic_book.cpp +++ b/examples/ws_book.cpp @@ -11,7 +11,6 @@ class BookPrinter : public hyperliquid::WebsocketMessageHandler, public hyperliquid::WebsocketApiListener { public: - // hyperliquid::WebsocketListener void onMessage(const std::string& message) override { messageParser.crack(message, *this); } @@ -50,7 +49,9 @@ class BookPrinter : public hyperliquid::WebsocketMessageHandler, public hyperliq int main() { BookPrinter printer; - hyperliquid::WebsocketApi websocket(hyperliquid::Environment::Mainnet, printer); + hyperliquid::ApiConfig config; + config.env = hyperliquid::Environment::Mainnet; + hyperliquid::WebsocketApi websocket(config, printer); spdlog::info("Subscribing to BTC l2Book + bbo + trades for 5 seconds..."); websocket.start(); diff --git a/examples/ws_orders.cpp b/examples/ws_orders.cpp new file mode 100644 index 0000000..e6ee27b --- /dev/null +++ b/examples/ws_orders.cpp @@ -0,0 +1,113 @@ +#include +#include + +#include + +#include "test_config.h" +#include "hyperliquid/websocket/WebsocketApi.h" +#include "hyperliquid/websocket/WebsocketApiListener.h" +#include "hyperliquid/websocket/WebsocketMessageHandler.h" +#include "hyperliquid/websocket/WebsocketMessageParser.h" +#include "hyperliquid/rest/RestApiMessageParser.h" +#include "hyperliquid/rest/RestEndpointListener.h" + +class OrderListener : public hyperliquid::WebsocketMessageHandler, + public hyperliquid::WebsocketApiListener, + public hyperliquid::RestEndpointListener { +public: + OrderListener() : restParser(*this), ws_(nullptr), cloid_(hyperliquid::generateCloid()) {} + void setWebsocket(hyperliquid::WebsocketApi& ws) { ws_ = &ws; } + + void onMessage(const std::string& message) override { + messageParser.crack(message, *this); + } + + void onPostResponse(const std::string& message, hyperliquid::RestEndpointType type) override { + restParser.parse(message, type); + } + + void onPlaceOrder(const hyperliquid::PlaceOrderResponse& response) override { + spdlog::info("Place order: status={}", response.status); + if (response.status != "ok" || response.statuses.empty()) return; + + auto& first = response.statuses[0]; + if (first.resting) + { + spdlog::info("Order resting oid={}, modifying...", first.resting->oid); + hyperliquid::OrderRequest modified; + modified.asset = "ETH"; + modified.isBuy = true; + modified.price = 1801.0; + modified.size = 0.01; + modified.reduceOnly = false; + modified.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + modified.cloid = cloid_; + + hyperliquid::ModifyRequest modify; + modify.oid = first.resting->oid; + modify.order = modified; + ws_->modifyOrder(modify); + } + } + + void onModifyOrder(const hyperliquid::ModifyOrderResponse& response) override { + spdlog::info("Modify order: status={}", response.status); + if (response.status != "ok") return; + + spdlog::info("Modified, cancelling by cloid..."); + hyperliquid::CancelByCloidRequest cancel; + cancel.asset = "ETH"; + cancel.cloid = cloid_; + ws_->cancelOrderByCloid({cancel}); + } + + void onCancelOrder(const hyperliquid::CancelOrderResponse& response) override { + spdlog::info("Cancel order: status={}", response.status); + } + + void onConnected() override { + spdlog::info("Connected, placing order..."); + + hyperliquid::OrderRequest order; + order.asset = "ETH"; + order.isBuy = true; + order.price = 1800.0; + order.size = 0.01; + order.reduceOnly = false; + order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + order.cloid = cloid_; + + ws_->placeOrder({order}, hyperliquid::Grouping::Na); + } + + void onDisconnected(bool hasError, const std::string& errMsg) override { + spdlog::info("Disconnected"); + } + +private: + hyperliquid::WebsocketMessageParser messageParser; + hyperliquid::RestApiMessageParser restParser; + hyperliquid::WebsocketApi* ws_; + std::string cloid_; +}; + +int main() { + auto wallet = loadWalletFromConfig(); + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::ApiConfig config; + config.env = hyperliquid::Environment::Testnet; + config.wallet = wallet; + + OrderListener listener; + hyperliquid::WebsocketApi websocket(config, listener); + listener.setWebsocket(websocket); + + spdlog::info("Starting websocket..."); + websocket.start(); + + std::this_thread::sleep_for(std::chrono::seconds(10)); + websocket.stop(); + + return 0; +} diff --git a/include/hyperliquid/Logger.h b/include/hyperliquid/Logger.h deleted file mode 100644 index 81d0401..0000000 --- a/include/hyperliquid/Logger.h +++ /dev/null @@ -1,9 +0,0 @@ -#pragma once - -namespace hyperliquid { - -enum class LogLevel { Trace, Debug, Info, Warn, Error, Critical, Off }; - -void setLogLevel(LogLevel level); - -} // namespace hyperliquid diff --git a/include/hyperliquid/config/Config.h b/include/hyperliquid/config/Config.h new file mode 100644 index 0000000..edfe1eb --- /dev/null +++ b/include/hyperliquid/config/Config.h @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "../types/RequestTypes.h" + +namespace hyperliquid +{ + enum class LogLevel { Trace, Debug, Info, Warn, Error, Critical, Off }; + + void setLogLevel(LogLevel level); + + struct Wallet + { + std::string accountAddress; + std::string privateKey; + }; + + struct ApiConfig + { + Environment env; + std::optional wallet; + std::set dexes; + bool skipBuildingSymbolMap; + }; +} diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index f0f2e18..4122223 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -2,27 +2,19 @@ #include #include -#include #include #include #include "../types/RequestTypes.h" #include "RestApiListener.h" -#include "hyperliquid/signing/Wallet.h" +#include "hyperliquid/config/Config.h" namespace hyperliquid { -struct RestApiConfig -{ - Environment env; - std::optional wallet; - std::set dexes; -}; - class RestApi { public: - explicit RestApi(const RestApiConfig& config); - RestApi(const RestApiConfig& config, RestApiListener& listener); + explicit RestApi(const ApiConfig& config); + RestApi(const ApiConfig& config, RestApiListener& listener); ~RestApi(); RestApi(const RestApi&) = delete; @@ -55,4 +47,4 @@ class RestApi { std::unique_ptr impl_; }; -} // namespace hyperliquid +} diff --git a/include/hyperliquid/signing/Wallet.h b/include/hyperliquid/signing/Wallet.h deleted file mode 100644 index d7efe9c..0000000 --- a/include/hyperliquid/signing/Wallet.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once -#include - -namespace hyperliquid -{ - struct Wallet - { - std::string accountAddress; - std::string privateKey; - }; -} \ No newline at end of file diff --git a/include/hyperliquid/websocket/WebsocketApi.h b/include/hyperliquid/websocket/WebsocketApi.h index e7b8ac1..ec3432f 100644 --- a/include/hyperliquid/websocket/WebsocketApi.h +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -6,24 +6,34 @@ #include "WebsocketApiListener.h" #include "../types/RequestTypes.h" +#include "hyperliquid/config/Config.h" namespace hyperliquid { class WebsocketApi { public: - explicit WebsocketApi(Environment env, WebsocketApiListener& listener); + explicit WebsocketApi(ApiConfig& config, WebsocketApiListener& listener); ~WebsocketApi(); WebsocketApi(const WebsocketApi&) = delete; WebsocketApi& operator=(const WebsocketApi&) = delete; - // + // Subscription requests void subscribe(SubscriptionType type, const std::map& filters = {}); void unsubscribe(SubscriptionType type, const std::map& filters = {}); // Post requests over websocket - void sendRequest(RestEndpointType type, const std::map& params = {}); + void spotMeta(); + void meta(const std::optional& dex = std::nullopt); + void perpDexs(); + void placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder = std::nullopt); + void cancelOrder(const std::vector& cancels); + void cancelOrderByCloid(const std::vector& cancels); + void modifyOrder(const ModifyRequest& modify); + void batchModifyOrder(const std::vector& modifies); void start(); void stop(); @@ -32,4 +42,4 @@ namespace hyperliquid struct Impl; std::unique_ptr impl_; }; -} // namespace hyperliquid +} diff --git a/include/hyperliquid/websocket/WebsocketApiListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h index 8c6d5ec..996939f 100644 --- a/include/hyperliquid/websocket/WebsocketApiListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -2,6 +2,8 @@ #include +#include "hyperliquid/types/RequestTypes.h" + namespace hyperliquid { class WebsocketApiListener { @@ -9,6 +11,7 @@ class WebsocketApiListener { virtual ~WebsocketApiListener() = default; virtual void onMessage(const std::string& message) {} + virtual void onPostResponse(const std::string& message, RestEndpointType type) {} virtual void onConnected() {} virtual void onDisconnected(bool hasError, const std::string& errMsg) {} }; diff --git a/src/Logger.cpp b/src/config/Config.cpp similarity index 92% rename from src/Logger.cpp rename to src/config/Config.cpp index f5582c7..a05852b 100644 --- a/src/Logger.cpp +++ b/src/config/Config.cpp @@ -1,4 +1,5 @@ -#include "hyperliquid/Logger.h" +#include "../../include/hyperliquid/config/Config.h" + #include "Logger.h" namespace hyperliquid { @@ -23,4 +24,4 @@ void setLogLevel(LogLevel level) getLogger()->set_level(toSpdlogLevel(level)); } -} // namespace hyperliquid +} diff --git a/src/Logger.h b/src/config/Logger.h similarity index 100% rename from src/Logger.h rename to src/config/Logger.h diff --git a/src/messages/ExchangeRequestBuilder.cpp b/src/messages/ExchangeRequestBuilder.cpp new file mode 100644 index 0000000..82e8206 --- /dev/null +++ b/src/messages/ExchangeRequestBuilder.cpp @@ -0,0 +1,233 @@ +#include "../messages/ExchangeRequestBuilder.h" + +#include +#include +#include +#include + +#include "InfoRequestBuilder.h" +#include "config/Logger.h" +#include "hyperliquid/rest/RestApiMessageParser.h" + +namespace hyperliquid +{ + void ExchangeRequestBuilder::initializeMapping(const ApiConfig& config, RestApi* api) + { + RestApiMessageParser parser; + if (!config.wallet.has_value()) + { + // Symbol map only needed for authenticated responses + return; + } + + auto metaRaw = api->meta(); + getLogger()->debug("meta response: {}", metaRaw); + auto defaultMetaResponse = parser.parseMeta(metaRaw); + int index = 0; + for (const auto& asset : defaultMetaResponse.universe) + { + symbolMap_.add(asset.name, index); + index++; + } + + auto spotMetaRaw = api->spotMeta(); + getLogger()->debug("spotMeta response: {}", spotMetaRaw); + auto spotMetaResponse = parser.parseSpotMeta(spotMetaRaw); + for (const auto& token : spotMetaResponse.tokens) + { + symbolMap_.add(token.name, token.index + 10000); + } + + if (config.dexes.empty()) return; + + auto dexesRaw = api->perpDexs(); + getLogger()->debug("perpDexs response: {}", dexesRaw); + auto dexesResponse = parser.parsePerpDexs(dexesRaw); + int perpIdx = 0; + for (const auto& dex : dexesResponse.dexes) + { + if (config.dexes.count(dex.name) == 0) + { + perpIdx++; + continue; + } + + auto dexMetaRaw = api->meta(dex.name); + getLogger()->debug("dex meta response for '{}': {}", dex.name, dexMetaRaw); + auto dexMetaResponse = parser.parseMeta(dexMetaRaw); + index = 0; + for (const auto& asset : dexMetaResponse.universe) + { + symbolMap_.add(asset.name, 100000 + (perpIdx * 10000) + index); + index++; + } + perpIdx++; + } + } + + + // Matches Python SDK's float_to_wire: Decimal(f"{x:.8f}").normalize() formatted with :f + static std::string floatToWire(double x) + { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.8f", x); + std::string s(buf); + + if (std::abs(std::stod(s) - x) >= 1e-12) + throw std::invalid_argument("floatToWire causes rounding: " + std::to_string(x)); + + // Normalize: strip trailing zeros, then trailing dot + auto dot = s.find('.'); + if (dot != std::string::npos) + { + auto last = s.find_last_not_of('0'); + if (last == dot) + s = s.substr(0, dot); // integer, drop the dot + else + s = s.substr(0, last + 1); + } + return s; + } + + nlohmann::ordered_json ExchangeRequestBuilder::buildOrderWire(const OrderRequest& order) const + { + nlohmann::ordered_json orderJson; + orderJson["a"] = symbolMap_.resolve(order.asset); + orderJson["b"] = order.isBuy; + orderJson["p"] = floatToWire(order.price); + orderJson["s"] = floatToWire(order.size); + orderJson["r"] = order.reduceOnly; + + if (order.limit) + { + nlohmann::ordered_json limitInner; + limitInner["tif"] = toString(order.limit->tif); + nlohmann::ordered_json limitOuter; + limitOuter["limit"] = limitInner; + orderJson["t"] = limitOuter; + } + else if (order.trigger) + { + nlohmann::ordered_json triggerInner; + triggerInner["isMarket"] = order.trigger->isMarket; + triggerInner["triggerPx"] = floatToWire(order.trigger->triggerPx); + triggerInner["tpsl"] = toString(order.trigger->tpsl); + nlohmann::ordered_json triggerOuter; + triggerOuter["trigger"] = triggerInner; + orderJson["t"] = triggerOuter; + } + + if (order.cloid) orderJson["c"] = *order.cloid; + + return orderJson; + } + + nlohmann::ordered_json ExchangeRequestBuilder::placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder) const + { + nlohmann::ordered_json ordersJson = nlohmann::ordered_json::array(); + for (const auto& order : orders) + ordersJson.push_back(buildOrderWire(order)); + + nlohmann::ordered_json action; + action["type"] = "order"; + action["orders"] = ordersJson; + action["grouping"] = toString(grouping); + + if (builder) + { + nlohmann::ordered_json builderJson; + builderJson["b"] = builder->address; + builderJson["f"] = builder->fee; + action["builder"] = builderJson; + } + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + + nlohmann::ordered_json ExchangeRequestBuilder::cancelOrder(const std::vector& cancels) const + { + nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); + for (const auto& cancel : cancels) + { + nlohmann::ordered_json cancelJson; + cancelJson["a"] = symbolMap_.resolve(cancel.asset); + cancelJson["o"] = cancel.oid; + cancelsJson.push_back(cancelJson); + } + + nlohmann::ordered_json action; + action["type"] = "cancel"; + action["cancels"] = cancelsJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + + nlohmann::ordered_json ExchangeRequestBuilder::cancelOrderByCloid( + const std::vector& cancels) const + { + nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); + for (const auto& cancel : cancels) + { + nlohmann::ordered_json cancelJson; + cancelJson["asset"] = symbolMap_.resolve(cancel.asset); + cancelJson["cloid"] = cancel.cloid; + cancelsJson.push_back(cancelJson); + } + + nlohmann::ordered_json action; + action["type"] = "cancelByCloid"; + action["cancels"] = cancelsJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + + static void setModifyOid(nlohmann::ordered_json& json, const ModifyRequest& modify) + { + if (modify.oid) + json["oid"] = *modify.oid; + else if (modify.cloid) + json["oid"] = *modify.cloid; + else + throw std::invalid_argument("ModifyRequest requires either oid or cloid"); + } + + nlohmann::ordered_json ExchangeRequestBuilder::modifyOrder(const ModifyRequest& modify) const + { + nlohmann::ordered_json action; + action["type"] = "modify"; + setModifyOid(action, modify); + action["order"] = buildOrderWire(modify.order); + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + + nlohmann::ordered_json ExchangeRequestBuilder::batchModifyOrder(const std::vector& modifies) const + { + nlohmann::ordered_json modifiesJson = nlohmann::ordered_json::array(); + for (const auto& modify : modifies) + { + nlohmann::ordered_json modifyJson; + setModifyOid(modifyJson, modify); + modifyJson["order"] = buildOrderWire(modify.order); + modifiesJson.push_back(modifyJson); + } + + nlohmann::ordered_json action; + action["type"] = "batchModify"; + action["modifies"] = modifiesJson; + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } +} diff --git a/src/rest/ExchangeRequestBuilder.h b/src/messages/ExchangeRequestBuilder.h similarity index 81% rename from src/rest/ExchangeRequestBuilder.h rename to src/messages/ExchangeRequestBuilder.h index 491dd33..6cafdb5 100644 --- a/src/rest/ExchangeRequestBuilder.h +++ b/src/messages/ExchangeRequestBuilder.h @@ -5,14 +5,17 @@ #include -#include "SymbolMap.h" +#include "../rest/SymbolMap.h" +#include "hyperliquid/rest/RestApi.h" #include "hyperliquid/types/RequestTypes.h" namespace hyperliquid { class ExchangeRequestBuilder { public: - explicit ExchangeRequestBuilder(const SymbolMap& symbolMap); + ExchangeRequestBuilder() = default; + + void initializeMapping(const ApiConfig& config, RestApi* api); nlohmann::ordered_json placeOrder(const std::vector& orders, Grouping grouping, @@ -28,7 +31,7 @@ class ExchangeRequestBuilder { private: nlohmann::ordered_json buildOrderWire(const OrderRequest& order) const; - const SymbolMap& symbolMap_; + SymbolMap symbolMap_; }; } diff --git a/src/rest/InfoRequestBuilder.cpp b/src/messages/InfoRequestBuilder.cpp similarity index 93% rename from src/rest/InfoRequestBuilder.cpp rename to src/messages/InfoRequestBuilder.cpp index b69ea8a..5cc721b 100644 --- a/src/rest/InfoRequestBuilder.cpp +++ b/src/messages/InfoRequestBuilder.cpp @@ -1,4 +1,4 @@ -#include "InfoRequestBuilder.h" +#include "../messages/InfoRequestBuilder.h" namespace hyperliquid { diff --git a/src/rest/InfoRequestBuilder.h b/src/messages/InfoRequestBuilder.h similarity index 100% rename from src/rest/InfoRequestBuilder.h rename to src/messages/InfoRequestBuilder.h diff --git a/src/rest/ExchangeRequestBuilder.cpp b/src/rest/ExchangeRequestBuilder.cpp deleted file mode 100644 index 5b3da7f..0000000 --- a/src/rest/ExchangeRequestBuilder.cpp +++ /dev/null @@ -1,179 +0,0 @@ -#include "ExchangeRequestBuilder.h" - -#include -#include -#include -#include - -namespace hyperliquid { - -// Matches Python SDK's float_to_wire: Decimal(f"{x:.8f}").normalize() formatted with :f -static std::string floatToWire(double x) -{ - char buf[64]; - std::snprintf(buf, sizeof(buf), "%.8f", x); - std::string s(buf); - - if (std::abs(std::stod(s) - x) >= 1e-12) - throw std::invalid_argument("floatToWire causes rounding: " + std::to_string(x)); - - // Normalize: strip trailing zeros, then trailing dot - auto dot = s.find('.'); - if (dot != std::string::npos) - { - auto last = s.find_last_not_of('0'); - if (last == dot) - s = s.substr(0, dot); // integer, drop the dot - else - s = s.substr(0, last + 1); - } - return s; -} - -ExchangeRequestBuilder::ExchangeRequestBuilder(const SymbolMap& symbolMap) - : symbolMap_(symbolMap) -{ -} - -nlohmann::ordered_json ExchangeRequestBuilder::buildOrderWire(const OrderRequest& order) const -{ - nlohmann::ordered_json orderJson; - orderJson["a"] = symbolMap_.resolve(order.asset); - orderJson["b"] = order.isBuy; - orderJson["p"] = floatToWire(order.price); - orderJson["s"] = floatToWire(order.size); - orderJson["r"] = order.reduceOnly; - - if (order.limit) - { - nlohmann::ordered_json limitInner; - limitInner["tif"] = toString(order.limit->tif); - nlohmann::ordered_json limitOuter; - limitOuter["limit"] = limitInner; - orderJson["t"] = limitOuter; - } - else if (order.trigger) - { - nlohmann::ordered_json triggerInner; - triggerInner["isMarket"] = order.trigger->isMarket; - triggerInner["triggerPx"] = floatToWire(order.trigger->triggerPx); - triggerInner["tpsl"] = toString(order.trigger->tpsl); - nlohmann::ordered_json triggerOuter; - triggerOuter["trigger"] = triggerInner; - orderJson["t"] = triggerOuter; - } - - if (order.cloid) orderJson["c"] = *order.cloid; - - return orderJson; -} - -nlohmann::ordered_json ExchangeRequestBuilder::placeOrder(const std::vector& orders, - Grouping grouping, - const std::optional& builder) const -{ - nlohmann::ordered_json ordersJson = nlohmann::ordered_json::array(); - for (const auto& order : orders) - ordersJson.push_back(buildOrderWire(order)); - - nlohmann::ordered_json action; - action["type"] = "order"; - action["orders"] = ordersJson; - action["grouping"] = toString(grouping); - - if (builder) - { - nlohmann::ordered_json builderJson; - builderJson["b"] = builder->address; - builderJson["f"] = builder->fee; - action["builder"] = builderJson; - } - - nlohmann::ordered_json body; - body["action"] = action; - return body; -} - -nlohmann::ordered_json ExchangeRequestBuilder::cancelOrder(const std::vector& cancels) const -{ - nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); - for (const auto& cancel : cancels) - { - nlohmann::ordered_json cancelJson; - cancelJson["a"] = symbolMap_.resolve(cancel.asset); - cancelJson["o"] = cancel.oid; - cancelsJson.push_back(cancelJson); - } - - nlohmann::ordered_json action; - action["type"] = "cancel"; - action["cancels"] = cancelsJson; - - nlohmann::ordered_json body; - body["action"] = action; - return body; -} - -nlohmann::ordered_json ExchangeRequestBuilder::cancelOrderByCloid(const std::vector& cancels) const -{ - nlohmann::ordered_json cancelsJson = nlohmann::ordered_json::array(); - for (const auto& cancel : cancels) - { - nlohmann::ordered_json cancelJson; - cancelJson["asset"] = symbolMap_.resolve(cancel.asset); - cancelJson["cloid"] = cancel.cloid; - cancelsJson.push_back(cancelJson); - } - - nlohmann::ordered_json action; - action["type"] = "cancelByCloid"; - action["cancels"] = cancelsJson; - - nlohmann::ordered_json body; - body["action"] = action; - return body; -} - -static void setModifyOid(nlohmann::ordered_json& json, const ModifyRequest& modify) -{ - if (modify.oid) - json["oid"] = *modify.oid; - else if (modify.cloid) - json["oid"] = *modify.cloid; - else - throw std::invalid_argument("ModifyRequest requires either oid or cloid"); -} - -nlohmann::ordered_json ExchangeRequestBuilder::modifyOrder(const ModifyRequest& modify) const -{ - nlohmann::ordered_json action; - action["type"] = "modify"; - setModifyOid(action, modify); - action["order"] = buildOrderWire(modify.order); - - nlohmann::ordered_json body; - body["action"] = action; - return body; -} - -nlohmann::ordered_json ExchangeRequestBuilder::batchModifyOrder(const std::vector& modifies) const -{ - nlohmann::ordered_json modifiesJson = nlohmann::ordered_json::array(); - for (const auto& modify : modifies) - { - nlohmann::ordered_json modifyJson; - setModifyOid(modifyJson, modify); - modifyJson["order"] = buildOrderWire(modify.order); - modifiesJson.push_back(modifyJson); - } - - nlohmann::ordered_json action; - action["type"] = "batchModify"; - action["modifies"] = modifiesJson; - - nlohmann::ordered_json body; - body["action"] = action; - return body; -} - -} diff --git a/src/rest/HttpSession.cpp b/src/rest/HttpSession.cpp index 188ea66..8ef9eba 100644 --- a/src/rest/HttpSession.cpp +++ b/src/rest/HttpSession.cpp @@ -1,7 +1,7 @@ #include "HttpSession.h" #include -#include "Logger.h" +#include "../config/Logger.h" namespace hyperliquid { diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index faf4b43..8d34831 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -2,8 +2,8 @@ #include "hyperliquid/rest/RestApiListener.h" #include "hyperliquid/rest/RestApiMessageParser.h" #include "HttpSession.h" -#include "InfoRequestBuilder.h" -#include "ExchangeRequestBuilder.h" +#include "../messages/InfoRequestBuilder.h" +#include "../messages/ExchangeRequestBuilder.h" #include "SymbolMap.h" #include "signing/Signing.h" @@ -17,7 +17,8 @@ #include -#include "Logger.h" +#include "../config/Logger.h" +#include "hyperliquid/config/Config.h" namespace hyperliquid { @@ -35,34 +36,20 @@ struct RestApi::Impl { std::string host; std::string port; RestApiListener& listener; - Wallet wallet; - bool authenticated = false; - Environment env; - std::set enabledDexes; - SymbolMap symbolMap; + const ApiConfig& config; ExchangeRequestBuilder exchangeRequestBuilder; - Impl(const RestApiConfig& config, RestApiListener& listener) + Impl(const ApiConfig& config, RestApiListener& listener) : work(net::make_work_guard(ioc)) , sslCtx(ssl::context::tlsv12_client) , host(toInfoEndpoint(config.env).host) , port(toInfoEndpoint(config.env).port) , listener(listener) - , env(config.env) - , enabledDexes(config.dexes) - , exchangeRequestBuilder(symbolMap) + , config(config) { sslCtx.set_default_verify_paths(); sslCtx.set_verify_mode(ssl::verify_peer); thread = std::thread([this]() { ioc.run(); }); - - if (config.wallet) - { - wallet = *config.wallet; - authenticated = true; - } - - buildSymbolMap(); } ~Impl() @@ -72,86 +59,11 @@ struct RestApi::Impl { if (thread.joinable()) thread.join(); } - void buildSymbolMap() - { - RestApiMessageParser parser; - - auto defaultMetaResponse = parser.parseMeta( - signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta())); - int index = 0; - for (const auto& asset : defaultMetaResponse.universe) - { - symbolMap.add(asset.name, index); - index++; - } - - auto spotMetaResponse = parser.parseSpotMeta( - signAndSendSync(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta())); - for (const auto& token : spotMetaResponse.tokens) - { - symbolMap.add(token.name, token.index + 10000); - } - - if (enabledDexes.empty()) return; - - auto dexesResponse = parser.parsePerpDexs( - signAndSendSync(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs())); - - int perpIdx = 0; - for (const auto& dex : dexesResponse.dexes) - { - if (enabledDexes.count(dex.name) == 0) - { - perpIdx++; - continue; - } - - auto dexMetaResponse = parser.parseMeta( - signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta(dex.name))); - index = 0; - for (const auto& asset : dexMetaResponse.universe) - { - symbolMap.add(asset.name, 100000 + (perpIdx * 10000) + index); - index++; - } - perpIdx++; - } - } - - nlohmann::ordered_json prepareBody(RestEndpointType type, nlohmann::ordered_json body, - const std::optional& vaultAddress = std::nullopt, - const std::optional& expiresAfter = std::nullopt) - { - if (vaultAddress) body["vaultAddress"] = *vaultAddress; - if (expiresAfter) body["expiresAfter"] = *expiresAfter; - - if (isAuthenticated(type)) - { - uint64_t nonce = static_cast( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); - body["nonce"] = nonce; - - bool isMainnet = (env == Environment::Mainnet); - auto action = body["action"]; - auto signature = Signing::signL1Action( - wallet, action, vaultAddress, nonce, expiresAfter, isMainnet); - - nlohmann::ordered_json signatureJson; - signatureJson["r"] = signature.r; - signatureJson["s"] = signature.s; - signatureJson["v"] = signature.v; - body["signature"] = signatureJson; - } - - return body; - } - void signAndSend(RestEndpointType type, nlohmann::ordered_json body, const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) { - auto prepared = prepareBody(type, body, vaultAddress, expiresAfter); + auto prepared = Signing::prepareBody(config, type, std::move(body), vaultAddress, expiresAfter); std::string serialized = prepared.dump(); auto session = std::make_shared( @@ -171,7 +83,7 @@ struct RestApi::Impl { const std::optional& vaultAddress = std::nullopt, const std::optional& expiresAfter = std::nullopt) { - auto prepared = prepareBody(type, body, vaultAddress, expiresAfter); + auto prepared = Signing::prepareBody(config, type, std::move(body), vaultAddress, expiresAfter); std::string serialized = prepared.dump(); getLogger()->debug("{}", serialized); @@ -194,14 +106,20 @@ struct RestApi::Impl { } }; -RestApi::RestApi(const RestApiConfig& config) +RestApi::RestApi(const ApiConfig& config) : impl_(std::make_unique(config, defaultListener)) { + if (!config.skipBuildingSymbolMap) { + impl_->exchangeRequestBuilder.initializeMapping(config, this); + } } -RestApi::RestApi(const RestApiConfig& config, RestApiListener& listener) +RestApi::RestApi(const ApiConfig& config, RestApiListener& listener) : impl_(std::make_unique(config, listener)) { + if (!config.skipBuildingSymbolMap) { + impl_->exchangeRequestBuilder.initializeMapping(config, this); + } } RestApi::~RestApi() = default; @@ -229,50 +147,51 @@ std::string RestApi::placeOrder(const std::vector& orders, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } -void RestApi::spotMetaAsync() +std::string RestApi::cancelOrder(const std::vector& cancels) { - impl_->signAndSend(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); + return impl_->signAndSendSync(RestEndpointType::CancelOrder, + impl_->exchangeRequestBuilder.cancelOrder(cancels)); } -void RestApi::metaAsync(const std::optional& dex) + std::string RestApi::cancelOrderByCloid(const std::vector& cancels) { - impl_->signAndSend(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); + return impl_->signAndSendSync(RestEndpointType::CancelOrderByCloid, + impl_->exchangeRequestBuilder.cancelOrderByCloid(cancels)); } -void RestApi::perpDexsAsync() + std::string RestApi::modifyOrder(const ModifyRequest& modify) { - impl_->signAndSend(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); + return impl_->signAndSendSync(RestEndpointType::ModifyOrder, + impl_->exchangeRequestBuilder.modifyOrder(modify)); } -void RestApi::placeOrderAsync(const std::vector& orders, - Grouping grouping, - const std::optional& builder) + std::string RestApi::batchModifyOrder(const std::vector& modifies) { - impl_->signAndSend(RestEndpointType::PlaceOrder, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); + return impl_->signAndSendSync(RestEndpointType::BatchModifyOrder, + impl_->exchangeRequestBuilder.batchModifyOrder(modifies)); } -std::string RestApi::cancelOrder(const std::vector& cancels) + +void RestApi::spotMetaAsync() { - return impl_->signAndSendSync(RestEndpointType::CancelOrder, - impl_->exchangeRequestBuilder.cancelOrder(cancels)); + impl_->signAndSend(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); } -std::string RestApi::cancelOrderByCloid(const std::vector& cancels) +void RestApi::metaAsync(const std::optional& dex) { - return impl_->signAndSendSync(RestEndpointType::CancelOrderByCloid, - impl_->exchangeRequestBuilder.cancelOrderByCloid(cancels)); + impl_->signAndSend(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); } -std::string RestApi::modifyOrder(const ModifyRequest& modify) +void RestApi::perpDexsAsync() { - return impl_->signAndSendSync(RestEndpointType::ModifyOrder, - impl_->exchangeRequestBuilder.modifyOrder(modify)); + impl_->signAndSend(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); } -std::string RestApi::batchModifyOrder(const std::vector& modifies) +void RestApi::placeOrderAsync(const std::vector& orders, + Grouping grouping, + const std::optional& builder) { - return impl_->signAndSendSync(RestEndpointType::BatchModifyOrder, - impl_->exchangeRequestBuilder.batchModifyOrder(modifies)); + impl_->signAndSend(RestEndpointType::PlaceOrder, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); } void RestApi::cancelOrderAsync(const std::vector& cancels) diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index b1288b9..780a8e2 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -1,6 +1,6 @@ #include "hyperliquid/rest/RestApiMessageParser.h" #include -#include "Logger.h" +#include "../config/Logger.h" namespace hyperliquid { diff --git a/src/rest/SymbolMap.h b/src/rest/SymbolMap.h index 8aa1984..762a6bf 100644 --- a/src/rest/SymbolMap.h +++ b/src/rest/SymbolMap.h @@ -15,4 +15,4 @@ class SymbolMap { std::unordered_map symbolToId_; }; -} // namespace hyperliquid +} diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp index 8de4920..3c2d688 100644 --- a/src/signing/Signing.cpp +++ b/src/signing/Signing.cpp @@ -1,331 +1,46 @@ #include "Signing.h" +#include "SigningHelpers.h" -extern "C" { -#include "sha3.h" -} - -#include -#include -#include -#include - -#include -#include +#include +#include namespace hyperliquid { -namespace { - -std::array keccak256(const uint8_t* data, size_t length) -{ - std::array output; - sha3_HashBuffer(256, SHA3_FLAGS_KECCAK, data, static_cast(length), - output.data(), 32); - return output; -} - -std::array keccak256(const std::vector& data) -{ - return keccak256(data.data(), data.size()); -} - -uint8_t hexCharToNibble(char ch) -{ - if (ch >= '0' && ch <= '9') return ch - '0'; - if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; - if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; - throw std::invalid_argument(std::string("Invalid hex character: ") + ch); -} - -std::vector hexToBytes(const std::string& hex) -{ - std::string clean = hex; - if (clean.size() >= 2 && clean[0] == '0' && (clean[1] == 'x' || clean[1] == 'X')) - clean = clean.substr(2); - - if (clean.size() % 2 != 0) - clean = "0" + clean; - - std::vector bytes(clean.size() / 2); - for (size_t idx = 0; idx < bytes.size(); idx++) - bytes[idx] = (hexCharToNibble(clean[idx * 2]) << 4) | hexCharToNibble(clean[idx * 2 + 1]); - - return bytes; -} - -// Encode a uint256 value (left-padded to 32 bytes, big-endian) -std::array encodeUint256(uint64_t value) -{ - std::array result = {}; - for (int idx = 0; idx < 8; idx++) - result[31 - idx] = static_cast((value >> (idx * 8)) & 0xFF); - return result; -} - -// Encode an address as uint256 (left-padded to 32 bytes) -std::array encodeAddress(const std::vector& addressBytes) -{ - std::array result = {}; - if (addressBytes.size() == 20) - std::memcpy(result.data() + 12, addressBytes.data(), 20); - return result; -} - -} - -std::string Signing::toHex(const uint8_t* data, size_t length) -{ - std::ostringstream stream; - stream << "0x"; - for (size_t idx = 0; idx < length; idx++) - stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); - - std::string result = stream.str(); - size_t firstNonZero = result.find_first_not_of('0', 2); - if (firstNonZero == std::string::npos) return "0x0"; - return "0x" + result.substr(firstNonZero); -} - -std::string Signing::toHexPadded(const uint8_t* data, size_t length) -{ - std::ostringstream stream; - stream << "0x"; - for (size_t idx = 0; idx < length; idx++) - stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); - return stream.str(); -} - -std::array Signing::actionHash( - const nlohmann::ordered_json& action, +nlohmann::ordered_json Signing::prepareBody( + const ApiConfig& config, + RestEndpointType type, + nlohmann::ordered_json body, const std::optional& vaultAddress, - uint64_t nonce, const std::optional& expiresAfter) { - std::vector data = nlohmann::ordered_json::to_msgpack(action); - for (int idx = 7; idx >= 0; idx--) - data.push_back(static_cast((nonce >> (idx * 8)) & 0xFF)); - if (!vaultAddress) - { - data.push_back(0x00); - } - else - { - data.push_back(0x01); - - auto addrBytes = hexToBytes(*vaultAddress); - data.insert(data.end(), addrBytes.begin(), addrBytes.end()); - } + if (vaultAddress) body["vaultAddress"] = *vaultAddress; + if (expiresAfter) body["expiresAfter"] = *expiresAfter; - if (expiresAfter) + if (isAuthenticated(type)) { - data.push_back(0x00); - uint64_t expires = *expiresAfter; - for (int idx = 7; idx >= 0; idx--) - data.push_back(static_cast((expires >> (idx * 8)) & 0xFF)); - } - - return keccak256(data); -} - -std::array Signing::domainSeparatorHash() -{ - // EIP712Domain(string name,string version,uint256 chainId,address verifyingContract) - auto typeHash = keccak256( - reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); - - auto nameHash = keccak256(reinterpret_cast("Exchange"), strlen("Exchange")); - auto versionHash = keccak256(reinterpret_cast("1"), strlen("1")); - auto chainId = encodeUint256(1337); - auto verifyingContract = encodeAddress(std::vector(20, 0)); // address(0) - - // Concatenate: typeHash || nameHash || versionHash || chainId || verifyingContract - std::vector encoded; - encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); - encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); - encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); - encoded.insert(encoded.end(), chainId.begin(), chainId.end()); - encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); - - return keccak256(encoded); -} - -std::array Signing::agentStructHash( - const std::string& source, - const std::array& connectionId) -{ - // Agent(string source,bytes32 connectionId) - auto typeHash = keccak256( - reinterpret_cast("Agent(string source,bytes32 connectionId)"), - strlen("Agent(string source,bytes32 connectionId)")); - - auto sourceHash = keccak256( - reinterpret_cast(source.c_str()), source.size()); - - // Concatenate: typeHash || keccak256(source) || connectionId - std::vector encoded; - encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); - encoded.insert(encoded.end(), sourceHash.begin(), sourceHash.end()); - encoded.insert(encoded.end(), connectionId.begin(), connectionId.end()); - - return keccak256(encoded); -} - -std::array Signing::eip712Hash( - const std::array& domainSeparator, - const std::array& structHash) -{ - // "\x19\x01" || domainSeparator || structHash - std::vector encoded; - encoded.push_back(0x19); - encoded.push_back(0x01); - encoded.insert(encoded.end(), domainSeparator.begin(), domainSeparator.end()); - encoded.insert(encoded.end(), structHash.begin(), structHash.end()); - - return keccak256(encoded); -} - -Signature Signing::ecdsaSign( - const Wallet& wallet, - const std::array& messageHash) -{ - auto privateKeyBytes = hexToBytes(wallet.privateKey); - if (privateKeyBytes.size() != 32) - throw std::invalid_argument("Private key must be 32 bytes"); - - secp256k1_context* context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); - if (!context) - throw std::runtime_error("Failed to create secp256k1 context"); - - secp256k1_ecdsa_recoverable_signature signature; - if (!secp256k1_ecdsa_sign_recoverable(context, &signature, - messageHash.data(), privateKeyBytes.data(), nullptr, nullptr)) - { - secp256k1_context_destroy(context); - throw std::runtime_error("secp256k1 signing failed"); - } - - uint8_t serialized[64]; - int recid; - secp256k1_ecdsa_recoverable_signature_serialize_compact(context, serialized, &recid, &signature); - if (recid != 0 && recid != 1) - throw std::runtime_error("Unexpected recovery id: " + std::to_string(recid)); - - secp256k1_context_destroy(context); - - Signature result; - result.r = toHex(serialized, 32); - result.s = toHex(serialized + 32, 32); - result.v = recid + 27; - return result; -} - -std::string Signing::addressFromPrivateKey(const std::string& privateKey) -{ - auto privateKeyBytes = hexToBytes(privateKey); - if (privateKeyBytes.size() != 32) - throw std::invalid_argument("Private key must be 32 bytes"); - - secp256k1_context* context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); - if (!context) - throw std::runtime_error("Failed to create secp256k1 context"); - - secp256k1_pubkey pubkey; - if (!secp256k1_ec_pubkey_create(context, &pubkey, privateKeyBytes.data())) - { - secp256k1_context_destroy(context); - throw std::runtime_error("Failed to derive public key"); - } - - uint8_t uncompressed[65]; - size_t len = 65; - secp256k1_ec_pubkey_serialize(context, uncompressed, &len, &pubkey, SECP256K1_EC_UNCOMPRESSED); - secp256k1_context_destroy(context); - - // keccak256 of the 64-byte public key (skip the 0x04 prefix) - auto hash = keccak256(uncompressed + 1, 64); - - // Ethereum address is the last 20 bytes - std::ostringstream stream; - stream << "0x"; - for (int idx = 12; idx < 32; idx++) - stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(hash[idx]); - return stream.str(); -} - -std::array Signing::domainSeparatorHash( - const std::string& name, const std::string& version, uint64_t chainId) -{ - auto typeHash = keccak256( - reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), - strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); - - auto nameHash = keccak256(reinterpret_cast(name.c_str()), name.size()); - auto versionHash = keccak256(reinterpret_cast(version.c_str()), version.size()); - auto chainIdEncoded = encodeUint256(chainId); - auto verifyingContract = encodeAddress(std::vector(20, 0)); - - std::vector encoded; - encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); - encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); - encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); - encoded.insert(encoded.end(), chainIdEncoded.begin(), chainIdEncoded.end()); - encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); - - return keccak256(encoded); -} - -std::array Signing::userSignedStructHash( - const std::string& primaryType, - const std::vector& payloadTypes, - const nlohmann::ordered_json& action) -{ - // Build type string: "PrimaryType(type1 name1,type2 name2,...)" - std::string typeString = primaryType + "("; - for (size_t idx = 0; idx < payloadTypes.size(); idx++) - { - if (idx > 0) typeString += ","; - typeString += payloadTypes[idx].type + " " + payloadTypes[idx].name; - } - typeString += ")"; - - auto typeHash = keccak256( - reinterpret_cast(typeString.c_str()), typeString.size()); - - std::vector encoded; - encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); - - // Encode each field according to its EIP-712 type - for (const auto& field : payloadTypes) - { - if (field.type == "string") - { - auto val = action[field.name].get(); - auto valHash = keccak256(reinterpret_cast(val.c_str()), val.size()); - encoded.insert(encoded.end(), valHash.begin(), valHash.end()); - } - else if (field.type == "uint64") - { - auto val = action[field.name].get(); - auto valEncoded = encodeUint256(val); - encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); - } - else if (field.type == "address") + if (!config.wallet.has_value()) { - auto val = action[field.name].get(); - auto addrBytes = hexToBytes(val); - auto addrEncoded = encodeAddress(addrBytes); - encoded.insert(encoded.end(), addrEncoded.begin(), addrEncoded.end()); - } - else if (field.type == "bool") - { - auto val = action[field.name].get(); - auto valEncoded = encodeUint256(val ? 1 : 0); - encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); + spdlog::error("Wallet not configured, can't send authenticated request: {}", toString(type)); + return body; } + uint64_t nonce = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + body["nonce"] = nonce; + + bool isMainnet = (config.env == Environment::Mainnet); + auto action = body["action"]; + auto signature = signL1Action( + config.wallet.value(), action, vaultAddress, nonce, expiresAfter, isMainnet); + + nlohmann::ordered_json signatureJson; + signatureJson["r"] = signature.r; + signatureJson["s"] = signature.s; + signatureJson["v"] = signature.v; + body["signature"] = signatureJson; } - return keccak256(encoded); + return body; } Signature Signing::signUserSignedAction( @@ -335,14 +50,13 @@ Signature Signing::signUserSignedAction( const std::string& primaryType, bool isMainnet) { - // signatureChainId: 0x66eee = 421614 uint64_t chainId = 0x66eee; - auto structHash = userSignedStructHash(primaryType, payloadTypes, action); - auto domainSeparator = domainSeparatorHash("HyperliquidSignTransaction", "1", chainId); - auto finalHash = eip712Hash(domainSeparator, structHash); + auto structHash = SigningHelpers::userSignedStructHash(primaryType, payloadTypes, action); + auto domSep = SigningHelpers::domainSeparatorHash("HyperliquidSignTransaction", "1", chainId); + auto finalHash = SigningHelpers::eip712Hash(domSep, structHash); - return ecdsaSign(wallet, finalHash); + return SigningHelpers::ecdsaSign(wallet, finalHash); } Signature Signing::signL1Action( @@ -353,22 +67,14 @@ Signature Signing::signL1Action( const std::optional& expiresAfter, bool isMainnet) { - auto hash = actionHash(action, vaultAddress, nonce, expiresAfter); + auto hash = SigningHelpers::actionHash(action, vaultAddress, nonce, expiresAfter); - // Step 2: Construct phantom agent std::string source = isMainnet ? "a" : "b"; + auto structHash = SigningHelpers::agentStructHash(source, hash); + auto domSep = SigningHelpers::domainSeparatorHash(); + auto finalHash = SigningHelpers::eip712Hash(domSep, structHash); - // Step 3: Build EIP-712 struct hash for the Agent - auto structHash = agentStructHash(source, hash); - - // Step 4: Build the domain separator (can be cached but computed fresh for clarity) - auto domainSeparator = domainSeparatorHash(); - - // Step 5: Compute the final EIP-712 hash - auto finalHash = eip712Hash(domainSeparator, structHash); - - // Step 6: Sign with secp256k1 - return ecdsaSign(wallet, finalHash); + return SigningHelpers::ecdsaSign(wallet, finalHash); } -} // namespace hyperliquid +} diff --git a/src/signing/Signing.h b/src/signing/Signing.h index 7b5f1a7..c432709 100644 --- a/src/signing/Signing.h +++ b/src/signing/Signing.h @@ -1,6 +1,5 @@ #pragma once -#include #include #include #include @@ -8,7 +7,8 @@ #include -#include "hyperliquid/signing/Wallet.h" +#include "hyperliquid/config/Config.h" +#include "hyperliquid/types/RequestTypes.h" namespace hyperliquid { @@ -43,38 +43,12 @@ class Signing const std::string& primaryType, bool isMainnet); - static std::array actionHash( - const nlohmann::ordered_json& action, - const std::optional& vaultAddress, - uint64_t nonce, - const std::optional& expiresAfter); - - static std::string addressFromPrivateKey(const std::string& privateKey); - - static std::string toHex(const uint8_t* data, size_t length); - static std::string toHexPadded(const uint8_t* data, size_t length); - -private: - static std::array eip712Hash( - const std::array& domainSeparator, - const std::array& structHash); - - static std::array domainSeparatorHash(); - static std::array domainSeparatorHash( - const std::string& name, const std::string& version, uint64_t chainId); - - static std::array agentStructHash( - const std::string& source, - const std::array& connectionId); - - static std::array userSignedStructHash( - const std::string& primaryType, - const std::vector& payloadTypes, - const nlohmann::ordered_json& action); - - static Signature ecdsaSign( - const Wallet& wallet, - const std::array& messageHash); + static nlohmann::ordered_json prepareBody( + const ApiConfig& config, + RestEndpointType type, + nlohmann::ordered_json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt); }; } diff --git a/src/signing/SigningHelpers.cpp b/src/signing/SigningHelpers.cpp new file mode 100644 index 0000000..94b7c7a --- /dev/null +++ b/src/signing/SigningHelpers.cpp @@ -0,0 +1,284 @@ +#include "SigningHelpers.h" +#include "Signing.h" + +extern "C" { +#include "sha3.h" +} + +#include +#include +#include +#include + +#include +#include + +namespace hyperliquid { + +std::array SigningHelpers::keccak256(const uint8_t* data, size_t length) +{ + std::array output; + sha3_HashBuffer(256, SHA3_FLAGS_KECCAK, data, static_cast(length), + output.data(), 32); + return output; +} + +std::array SigningHelpers::keccak256(const std::vector& data) +{ + return keccak256(data.data(), data.size()); +} + +std::vector SigningHelpers::hexToBytes(const std::string& hex) +{ + auto hexCharToNibble = [](char ch) -> uint8_t { + if (ch >= '0' && ch <= '9') return ch - '0'; + if (ch >= 'a' && ch <= 'f') return ch - 'a' + 10; + if (ch >= 'A' && ch <= 'F') return ch - 'A' + 10; + throw std::invalid_argument(std::string("Invalid hex character: ") + ch); + }; + + std::string clean = hex; + if (clean.size() >= 2 && clean[0] == '0' && (clean[1] == 'x' || clean[1] == 'X')) + clean = clean.substr(2); + + if (clean.size() % 2 != 0) + clean = "0" + clean; + + std::vector bytes(clean.size() / 2); + for (size_t idx = 0; idx < bytes.size(); idx++) + bytes[idx] = (hexCharToNibble(clean[idx * 2]) << 4) | hexCharToNibble(clean[idx * 2 + 1]); + + return bytes; +} + +std::string SigningHelpers::toHex(const uint8_t* data, size_t length) +{ + std::ostringstream stream; + stream << "0x"; + for (size_t idx = 0; idx < length; idx++) + stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); + + std::string result = stream.str(); + size_t firstNonZero = result.find_first_not_of('0', 2); + if (firstNonZero == std::string::npos) return "0x0"; + return "0x" + result.substr(firstNonZero); +} + +std::string SigningHelpers::toHexPadded(const uint8_t* data, size_t length) +{ + std::ostringstream stream; + stream << "0x"; + for (size_t idx = 0; idx < length; idx++) + stream << std::hex << std::setfill('0') << std::setw(2) << static_cast(data[idx]); + return stream.str(); +} + +std::array SigningHelpers::encodeUint256(uint64_t value) +{ + std::array result = {}; + for (int idx = 0; idx < 8; idx++) + result[31 - idx] = static_cast((value >> (idx * 8)) & 0xFF); + return result; +} + +std::array SigningHelpers::encodeAddress(const std::vector& addressBytes) +{ + std::array result = {}; + if (addressBytes.size() == 20) + std::memcpy(result.data() + 12, addressBytes.data(), 20); + return result; +} + +std::array SigningHelpers::actionHash( + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter) +{ + std::vector data = nlohmann::ordered_json::to_msgpack(action); + for (int idx = 7; idx >= 0; idx--) + data.push_back(static_cast((nonce >> (idx * 8)) & 0xFF)); + if (!vaultAddress) + { + data.push_back(0x00); + } + else + { + data.push_back(0x01); + auto addrBytes = hexToBytes(*vaultAddress); + data.insert(data.end(), addrBytes.begin(), addrBytes.end()); + } + + if (expiresAfter) + { + data.push_back(0x00); + uint64_t expires = *expiresAfter; + for (int idx = 7; idx >= 0; idx--) + data.push_back(static_cast((expires >> (idx * 8)) & 0xFF)); + } + + return keccak256(data); +} + +std::array SigningHelpers::eip712Hash( + const std::array& domainSeparator, + const std::array& structHash) +{ + std::vector encoded; + encoded.push_back(0x19); + encoded.push_back(0x01); + encoded.insert(encoded.end(), domainSeparator.begin(), domainSeparator.end()); + encoded.insert(encoded.end(), structHash.begin(), structHash.end()); + + return keccak256(encoded); +} + +std::array SigningHelpers::domainSeparatorHash() +{ + auto typeHash = keccak256( + reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); + + auto nameHash = keccak256(reinterpret_cast("Exchange"), strlen("Exchange")); + auto versionHash = keccak256(reinterpret_cast("1"), strlen("1")); + auto chainId = encodeUint256(1337); + auto verifyingContract = encodeAddress(std::vector(20, 0)); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); + encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); + encoded.insert(encoded.end(), chainId.begin(), chainId.end()); + encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); + + return keccak256(encoded); +} + +std::array SigningHelpers::domainSeparatorHash( + const std::string& name, const std::string& version, uint64_t chainId) +{ + auto typeHash = keccak256( + reinterpret_cast("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + strlen("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")); + + auto nameHash = keccak256(reinterpret_cast(name.c_str()), name.size()); + auto versionHash = keccak256(reinterpret_cast(version.c_str()), version.size()); + auto chainIdEncoded = encodeUint256(chainId); + auto verifyingContract = encodeAddress(std::vector(20, 0)); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), nameHash.begin(), nameHash.end()); + encoded.insert(encoded.end(), versionHash.begin(), versionHash.end()); + encoded.insert(encoded.end(), chainIdEncoded.begin(), chainIdEncoded.end()); + encoded.insert(encoded.end(), verifyingContract.begin(), verifyingContract.end()); + + return keccak256(encoded); +} + +std::array SigningHelpers::agentStructHash( + const std::string& source, + const std::array& connectionId) +{ + auto typeHash = keccak256( + reinterpret_cast("Agent(string source,bytes32 connectionId)"), + strlen("Agent(string source,bytes32 connectionId)")); + + auto sourceHash = keccak256( + reinterpret_cast(source.c_str()), source.size()); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + encoded.insert(encoded.end(), sourceHash.begin(), sourceHash.end()); + encoded.insert(encoded.end(), connectionId.begin(), connectionId.end()); + + return keccak256(encoded); +} + +std::array SigningHelpers::userSignedStructHash( + const std::string& primaryType, + const std::vector& payloadTypes, + const nlohmann::ordered_json& action) +{ + std::string typeString = primaryType + "("; + for (size_t idx = 0; idx < payloadTypes.size(); idx++) + { + if (idx > 0) typeString += ","; + typeString += payloadTypes[idx].type + " " + payloadTypes[idx].name; + } + typeString += ")"; + + auto typeHash = keccak256( + reinterpret_cast(typeString.c_str()), typeString.size()); + + std::vector encoded; + encoded.insert(encoded.end(), typeHash.begin(), typeHash.end()); + + for (const auto& field : payloadTypes) + { + if (field.type == "string") + { + auto val = action[field.name].get(); + auto valHash = keccak256(reinterpret_cast(val.c_str()), val.size()); + encoded.insert(encoded.end(), valHash.begin(), valHash.end()); + } + else if (field.type == "uint64") + { + auto val = action[field.name].get(); + auto valEncoded = encodeUint256(val); + encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); + } + else if (field.type == "address") + { + auto val = action[field.name].get(); + auto addrBytes = hexToBytes(val); + auto addrEncoded = encodeAddress(addrBytes); + encoded.insert(encoded.end(), addrEncoded.begin(), addrEncoded.end()); + } + else if (field.type == "bool") + { + auto val = action[field.name].get(); + auto valEncoded = encodeUint256(val ? 1 : 0); + encoded.insert(encoded.end(), valEncoded.begin(), valEncoded.end()); + } + } + + return keccak256(encoded); +} + +Signature SigningHelpers::ecdsaSign( + const Wallet& wallet, + const std::array& messageHash) +{ + auto privateKeyBytes = hexToBytes(wallet.privateKey); + if (privateKeyBytes.size() != 32) + throw std::invalid_argument("Private key must be 32 bytes"); + + secp256k1_context* context = secp256k1_context_create(SECP256K1_CONTEXT_SIGN); + if (!context) + throw std::runtime_error("Failed to create secp256k1 context"); + + secp256k1_ecdsa_recoverable_signature signature; + if (!secp256k1_ecdsa_sign_recoverable(context, &signature, + messageHash.data(), privateKeyBytes.data(), nullptr, nullptr)) + { + secp256k1_context_destroy(context); + throw std::runtime_error("secp256k1 signing failed"); + } + + uint8_t serialized[64]; + int recid; + secp256k1_ecdsa_recoverable_signature_serialize_compact(context, serialized, &recid, &signature); + if (recid != 0 && recid != 1) + throw std::runtime_error("Unexpected recovery id: " + std::to_string(recid)); + + secp256k1_context_destroy(context); + + Signature result; + result.r = toHex(serialized, 32); + result.s = toHex(serialized + 32, 32); + result.v = recid + 27; + return result; +} + +} diff --git a/src/signing/SigningHelpers.h b/src/signing/SigningHelpers.h new file mode 100644 index 0000000..3d0b9ba --- /dev/null +++ b/src/signing/SigningHelpers.h @@ -0,0 +1,60 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "hyperliquid/config/Config.h" + +namespace hyperliquid { + +struct Signature; +struct EIP712Field; + +class SigningHelpers +{ +public: + static std::array keccak256(const uint8_t* data, size_t length); + static std::array keccak256(const std::vector& data); + + static std::vector hexToBytes(const std::string& hex); + static std::string toHex(const uint8_t* data, size_t length); + static std::string toHexPadded(const uint8_t* data, size_t length); + + static std::array encodeUint256(uint64_t value); + static std::array encodeAddress(const std::vector& addressBytes); + + static std::array actionHash( + const nlohmann::ordered_json& action, + const std::optional& vaultAddress, + uint64_t nonce, + const std::optional& expiresAfter); + + static std::array eip712Hash( + const std::array& domainSeparator, + const std::array& structHash); + + static std::array domainSeparatorHash(); + static std::array domainSeparatorHash( + const std::string& name, const std::string& version, uint64_t chainId); + + static std::array agentStructHash( + const std::string& source, + const std::array& connectionId); + + static std::array userSignedStructHash( + const std::string& primaryType, + const std::vector& payloadTypes, + const nlohmann::ordered_json& action); + + static Signature ecdsaSign( + const Wallet& wallet, + const std::array& messageHash); + +}; + +} diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index 0d000cb..b92a62c 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -1,9 +1,18 @@ #include "hyperliquid/websocket/WebsocketApi.h" + +#include + #include "hyperliquid/websocket/WebsocketApiListener.h" #include "WebsocketRunner.h" #include #include +#include "config/Logger.h" +#include "hyperliquid/rest/RestApi.h" +#include "messages/ExchangeRequestBuilder.h" +#include "messages/InfoRequestBuilder.h" +#include "signing/Signing.h" + namespace hyperliquid { struct WebsocketApi::Impl : internal::WSListener @@ -12,10 +21,19 @@ namespace hyperliquid WebsocketApiListener& listener; bool stopping = false; std::thread thread; + ExchangeRequestBuilder exchangeRequestBuilder; + const ApiConfig& config; + std::atomic postRequestCounter; + std::unordered_map postRequestIdToType; + simdjson::ondemand::parser sjParser; + simdjson::padded_string sjPadded; - Impl(Environment env, WebsocketApiListener& listener) - : ws(toWsEndpoint(env).host, toWsEndpoint(env).port, toWsEndpoint(env).path, *this), listener(listener) + Impl(ApiConfig& config, WebsocketApiListener& listener) + : ws(config, *this), listener(listener), config(config), postRequestCounter(0) { + config.skipBuildingSymbolMap = true; + RestApi restApi(config); + exchangeRequestBuilder.initializeMapping(config, &restApi); } ~Impl() @@ -27,6 +45,40 @@ namespace hyperliquid void onWsMessage(std::string& message) override { if (stopping) return; + getLogger()->debug("ws recv: {}", message); + + try + { + sjPadded = simdjson::padded_string(message.data(), message.size()); + auto doc = sjParser.iterate(sjPadded); + std::string_view channel = doc["channel"].get_string().value(); + + if (channel == "post") + { + auto data = doc["data"].get_object().value(); + uint64_t id = data["id"].get_uint64().value(); + auto payload = simdjson::to_json_string(data["response"]["payload"]); + auto it = postRequestIdToType.find(id); + if (it != postRequestIdToType.end()) + { + auto type = it->second; + postRequestIdToType.erase(it); + std::string payloadStr(payload.value()); + listener.onPostResponse(payloadStr, type); + } + else + { + getLogger()->error("post response with unknown id: {}", id); + listener.onMessage(message); + } + return; + } + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("failed to parse ws message: {}", e.what()); + } + listener.onMessage(message); } @@ -39,10 +91,29 @@ namespace hyperliquid { listener.onDisconnected(hasError, errMsg); } + + void signAndSend(RestEndpointType type, nlohmann::ordered_json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + auto payload= Signing::prepareBody(config, type, std::move(body), vaultAddress, expiresAfter); + auto payloadType = isAuthenticated(type) ? "action" : "info"; + int postRequestId = postRequestCounter.fetch_add(1); + postRequestIdToType[postRequestId] = type; + nlohmann::ordered_json wrapped = { + {"method", "post"}, + {"id", postRequestId}, + {"request", { + {"type", payloadType}, + {"payload", payload} + }} + }; + ws.send(wrapped.dump()); + } }; - WebsocketApi::WebsocketApi(Environment env, WebsocketApiListener& listener) : impl_( - std::make_unique(env, listener)) + WebsocketApi::WebsocketApi(ApiConfig& config, WebsocketApiListener& listener) : impl_( + std::make_unique(config, listener)) { } @@ -96,4 +167,51 @@ namespace hyperliquid impl_->ws.stop(); if (impl_->thread.joinable()) impl_->thread.join(); } + + void WebsocketApi::spotMeta() + { + return impl_->signAndSend(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); + } + + void WebsocketApi::meta(const std::optional& dex) + { + return impl_->signAndSend(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); + } + + void WebsocketApi::perpDexs() + { + return impl_->signAndSend(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); + } + + void WebsocketApi::placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder) + { + return impl_->signAndSend(RestEndpointType::PlaceOrder, + impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); + } + + void WebsocketApi::cancelOrder(const std::vector& cancels) + { + return impl_->signAndSend(RestEndpointType::CancelOrder, + impl_->exchangeRequestBuilder.cancelOrder(cancels)); + } + + void WebsocketApi::cancelOrderByCloid(const std::vector& cancels) + { + return impl_->signAndSend(RestEndpointType::CancelOrderByCloid, + impl_->exchangeRequestBuilder.cancelOrderByCloid(cancels)); + } + + void WebsocketApi::modifyOrder(const ModifyRequest& modify) + { + return impl_->signAndSend(RestEndpointType::ModifyOrder, + impl_->exchangeRequestBuilder.modifyOrder(modify)); + } + + void WebsocketApi::batchModifyOrder(const std::vector& modifies) + { + return impl_->signAndSend(RestEndpointType::BatchModifyOrder, + impl_->exchangeRequestBuilder.batchModifyOrder(modifies)); + } } diff --git a/src/websocket/WebsocketMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp index be4f830..cf36186 100644 --- a/src/websocket/WebsocketMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -1,7 +1,7 @@ #include "hyperliquid/websocket/WebsocketMessageParser.h" #include #include -#include "Logger.h" +#include "../config/Logger.h" #include "../../include/hyperliquid/types/ResponseTypes.h" diff --git a/src/websocket/WebsocketRunner.cpp b/src/websocket/WebsocketRunner.cpp index a0e113b..dcdc6c0 100644 --- a/src/websocket/WebsocketRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -1,18 +1,19 @@ #include "WebsocketRunner.h" #include -#include "Logger.h" +#include "../config/Logger.h" namespace hyperliquid { namespace internal { -WebsocketRunner::WebsocketRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener) +WebsocketRunner::WebsocketRunner(ApiConfig& config, WSListener& listener) : listener_(listener) , sslCtx_(ssl::context::tlsv12_client) , resolver_(net::make_strand(ioc_)) - , ws_(std::make_unique>>(net::make_strand(ioc_), sslCtx_)) - , host_(host) - , port_(port) - , path_(path) { + , ws_(std::make_unique>>(net::make_strand(ioc_), sslCtx_)) { + auto endpoint = toWsEndpoint(config.env); + host_ = endpoint.host; + port_ = endpoint.port; + path_ = endpoint.path; sslCtx_.set_default_verify_paths(); sslCtx_.set_verify_mode(ssl::verify_peer); diff --git a/src/websocket/WebsocketRunner.h b/src/websocket/WebsocketRunner.h index 53c4c95..d16cca1 100644 --- a/src/websocket/WebsocketRunner.h +++ b/src/websocket/WebsocketRunner.h @@ -1,8 +1,5 @@ #pragma once -// INTERNAL HEADER — not part of the public API -// Uses C++17 and Boost.Beast - #include #include #include @@ -18,6 +15,8 @@ #include #include +#include "hyperliquid/config/Config.h" + namespace hyperliquid { namespace internal { @@ -37,7 +36,7 @@ class WSListener { class WebsocketRunner { public: - WebsocketRunner(const std::string& host, const std::string& port, const std::string& path, WSListener& listener); + WebsocketRunner(ApiConfig& config, WSListener& listener); ~WebsocketRunner(); void send(const std::string& message); diff --git a/tests/signing_test.cpp b/tests/signing_test.cpp index e66238f..c66a37b 100644 --- a/tests/signing_test.cpp +++ b/tests/signing_test.cpp @@ -2,6 +2,7 @@ #include #include "signing/Signing.h" +#include "signing/SigningHelpers.h" #include @@ -75,10 +76,10 @@ TEST(SigningTest, PhantomAgentCreationMatchesProduction) auto action = orderWireToAction(orderWire); // action_hash(action, None, timestamp, None) - auto hash = Signing::actionHash(action, std::nullopt, timestamp, std::nullopt); + auto hash = SigningHelpers::actionHash(action, std::nullopt, timestamp, std::nullopt); // construct_phantom_agent connectionId check - std::string connectionIdHex = Signing::toHexPadded(hash.data(), 32); + std::string connectionIdHex = SigningHelpers::toHexPadded(hash.data(), 32); EXPECT_EQ(connectionIdHex, "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908"); } From 5b526d8292ae255f0227e153a52a03c4c8e46df3 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sun, 15 Mar 2026 14:36:31 +0000 Subject: [PATCH 10/15] small fixes and test fill scenario --- CMakePresets.json | 3 +- examples/rest_orders.cpp | 122 ++++----- examples/ws_fills.cpp | 128 ++++++++++ examples/ws_orders.cpp | 62 +++-- include/hyperliquid/types/ResponseTypes.h | 52 +++- .../websocket/WebsocketMessageHandler.h | 5 + src/websocket/WebsocketApi.cpp | 33 +++ src/websocket/WebsocketMessageParser.cpp | 232 ++++++++++++++++-- tests/signing_test.cpp | 94 ++----- vcpkg.json | 3 +- 10 files changed, 544 insertions(+), 190 deletions(-) create mode 100644 examples/ws_fills.cpp diff --git a/CMakePresets.json b/CMakePresets.json index 3de86d6..9896413 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -6,7 +6,8 @@ "binaryDir": "${sourceDir}/build", "cacheVariables": { "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", - "HYPERLIQUID_BUILD_EXAMPLES": "ON" + "HYPERLIQUID_BUILD_EXAMPLES": "ON", + "HYPERLIQUID_BUILD_TESTS": "ON" } } ] diff --git a/examples/rest_orders.cpp b/examples/rest_orders.cpp index 8376832..bcd5993 100644 --- a/examples/rest_orders.cpp +++ b/examples/rest_orders.cpp @@ -2,13 +2,38 @@ #include #include -#include <../include/hyperliquid/config/Config.h> +#include #include +void logPlaceOrder(const hyperliquid::PlaceOrderResponse& resp) +{ + spdlog::info("Place order: status={}", resp.status); + for (const auto& s : resp.statuses) + { + if (s.resting) + spdlog::info(" Resting oid={}", s.resting->oid); + else if (s.filled) + spdlog::info(" Filled oid={} avgPx={} totalSz={}", s.filled->oid, s.filled->avgPx, s.filled->totalSz); + else if (s.error) + spdlog::info(" Error: {}", *s.error); + } +} + +void logCancelOrder(const hyperliquid::CancelOrderResponse& resp) +{ + spdlog::info("Cancel order: status={}", resp.status); + for (const auto& s : resp.statuses) + { + if (s.success) + spdlog::info(" Success: {}", *s.success); + else if (s.error) + spdlog::info(" Error: {}", *s.error); + } +} + int main() { auto wallet = loadWalletFromConfig(); - hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); hyperliquid::ApiConfig config; @@ -18,11 +43,8 @@ int main() hyperliquid::RestApi api(config); hyperliquid::RestApiMessageParser parser; - // ========================================================= - // Approach 1: Place and cancel by oid - // ========================================================= - - spdlog::info("=== Approach 1: Place and cancel by oid ==="); + // Place and cancel by oid + spdlog::info("=== Place and cancel by oid ==="); hyperliquid::OrderRequest order1; order1.asset = "ETH"; @@ -33,56 +55,27 @@ int main() order1.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; spdlog::info("Placing order..."); - auto placeRaw1 = api.placeOrder({order1}, hyperliquid::Grouping::Na); - spdlog::info("Place response: {}", placeRaw1); - auto placeResp1 = parser.parsePlaceOrder(placeRaw1); - spdlog::info("Place order status: {}", placeResp1.status); + auto placeResp1 = parser.parsePlaceOrder(api.placeOrder({order1}, hyperliquid::Grouping::Na)); + logPlaceOrder(placeResp1); uint64_t oid = 0; - for (const auto& status : placeResp1.statuses) + for (const auto& s : placeResp1.statuses) { - if (status.resting) - { - oid = status.resting->oid; - spdlog::info(" Resting oid={}", oid); - } - else if (status.filled) - { - oid = status.filled->oid; - spdlog::info(" Filled oid={} avgPx={} totalSz={}", status.filled->oid, status.filled->avgPx, status.filled->totalSz); - } - else if (status.error) - { - spdlog::info(" Error: {}", *status.error); - } + if (s.resting) oid = s.resting->oid; + else if (s.filled) oid = s.filled->oid; } if (oid != 0) { - spdlog::info("Cancelling order oid={}...", oid); - + spdlog::info("Cancelling oid={}...", oid); hyperliquid::CancelRequest cancel1; cancel1.asset = "ETH"; cancel1.oid = oid; - - auto cancelRaw1 = api.cancelOrder({cancel1}); - spdlog::info("Cancel response: {}", cancelRaw1); - auto cancelResp1 = parser.parseCancelOrder(cancelRaw1); - spdlog::info("Cancel order status: {}", cancelResp1.status); - for (const auto& status : cancelResp1.statuses) - { - if (status.success) - spdlog::info(" Success: {}", *status.success); - else if (status.error) - spdlog::info(" Error: {}", *status.error); - } + logCancelOrder(parser.parseCancelOrder(api.cancelOrder({cancel1}))); } - // ========================================================= - // Approach 2: Place with cloid, modify, cancel by cloid - // ========================================================= - - spdlog::info("=== Approach 2: Place with cloid, modify, cancel by cloid ==="); + // Place with cloid, modify, cancel by cloid + spdlog::info("=== Place with cloid, modify, cancel by cloid ==="); std::string cloid = hyperliquid::generateCloid(); spdlog::info("Generated cloid: {}", cloid); @@ -97,22 +90,9 @@ int main() order2.cloid = cloid; spdlog::info("Placing order with cloid..."); - auto placeRaw2 = api.placeOrder({order2}, hyperliquid::Grouping::Na); - spdlog::info("Place response: {}", placeRaw2); - auto placeResp2 = parser.parsePlaceOrder(placeRaw2); - spdlog::info("Place order status: {}", placeResp2.status); - for (const auto& status : placeResp2.statuses) - { - if (status.resting) - spdlog::info(" Resting oid={}", status.resting->oid); - else if (status.filled) - spdlog::info(" Filled oid={}", status.filled->oid); - else if (status.error) - spdlog::info(" Error: {}", *status.error); - } - - spdlog::info("Modifying order (changing price to 1750.0)..."); + logPlaceOrder(parser.parsePlaceOrder(api.placeOrder({order2}, hyperliquid::Grouping::Na))); + spdlog::info("Modifying order (price -> 1750.0)..."); hyperliquid::OrderRequest modifiedOrder; modifiedOrder.asset = "ETH"; modifiedOrder.isBuy = true; @@ -126,28 +106,14 @@ int main() modify.cloid = cloid; modify.order = modifiedOrder; - auto modifyRaw = api.modifyOrder(modify); - spdlog::info("Modify response: {}", modifyRaw); - auto modifyResp = parser.parseModifyOrder(modifyRaw); - spdlog::info("Modify order status: {}", modifyResp.status); - - spdlog::info("Cancelling order by cloid={}...", cloid); + auto modifyResp = parser.parseModifyOrder(api.modifyOrder(modify)); + spdlog::info("Modify order: status={}", modifyResp.status); + spdlog::info("Cancelling by cloid={}...", cloid); hyperliquid::CancelByCloidRequest cancel2; cancel2.asset = "ETH"; cancel2.cloid = cloid; - - auto cancelRaw2 = api.cancelOrderByCloid({cancel2}); - spdlog::info("Cancel response: {}", cancelRaw2); - auto cancelResp2 = parser.parseCancelOrder(cancelRaw2); - spdlog::info("Cancel order status: {}", cancelResp2.status); - for (const auto& status : cancelResp2.statuses) - { - if (status.success) - spdlog::info(" Success: {}", *status.success); - else if (status.error) - spdlog::info(" Error: {}", *status.error); - } + logCancelOrder(parser.parseCancelOrder(api.cancelOrderByCloid({cancel2}))); return 0; } diff --git a/examples/ws_fills.cpp b/examples/ws_fills.cpp new file mode 100644 index 0000000..7b6380d --- /dev/null +++ b/examples/ws_fills.cpp @@ -0,0 +1,128 @@ +#include +#include +#include + +#include + +#include "test_config.h" +#include "hyperliquid/websocket/WebsocketApi.h" +#include "hyperliquid/websocket/WebsocketApiListener.h" +#include "hyperliquid/websocket/WebsocketMessageHandler.h" +#include "hyperliquid/websocket/WebsocketMessageParser.h" +#include "hyperliquid/rest/RestApiMessageParser.h" +#include "hyperliquid/rest/RestEndpointListener.h" + +class FillListener : public hyperliquid::WebsocketMessageHandler, + public hyperliquid::WebsocketApiListener, + public hyperliquid::RestEndpointListener { +public: + FillListener() : restParser(*this), ws_(nullptr) {} + void setWebsocket(hyperliquid::WebsocketApi& ws) { ws_ = &ws; } + + void onMessage(const std::string& message) override { + messageParser.crack(message, *this); + } + + void onPostResponse(const std::string& message, hyperliquid::RestEndpointType type) override { + restParser.parse(message, type); + } + + void onPerpAssetCtx(const hyperliquid::PerpAssetCtx& ctx) override { + if (ctx.coin != "ETH" || orderPlaced_ || !ctx.hasMidPx) return; + orderPlaced_ = true; + + // IOC sell at bid- to cross the spread immediately + double crossPx = std::floor(ctx.midPx * 0.99 * 10.0) / 10.0; + spdlog::info("ETH mid={}, sending IOC sell at {} to cross...", ctx.midPx, crossPx); + + hyperliquid::OrderRequest order; + order.asset = "ETH"; + order.isBuy = false; + order.price = crossPx; + order.size = 0.01; + order.reduceOnly = false; + order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Ioc}; + + ws_->placeOrder({order}, hyperliquid::Grouping::Na); + } + + void onPlaceOrder(const hyperliquid::PlaceOrderResponse& response) override { + spdlog::info("Place order: status={}", response.status); + for (const auto& s : response.statuses) + { + if (s.filled) + spdlog::info(" Filled oid={} avgPx={} totalSz={}", s.filled->oid, s.filled->avgPx, s.filled->totalSz); + else if (s.resting) + spdlog::info(" Resting oid={}", s.resting->oid); + else if (s.error) + spdlog::info(" Error: {}", *s.error); + } + } + + void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); + } + + void onUserFill(const hyperliquid::Fill& fill) override { + spdlog::info("Fill: coin={} side={} px={} sz={} dir={} closedPnl={} fee={} oid={}", + fill.coin, fill.side, fill.px, fill.sz, fill.dir, fill.closedPnl, fill.fee, fill.oid); + + // Close the position with an IOC buy + if (!closeSent_) + { + closeSent_ = true; + double closePx = std::ceil(fill.px * 1.01 * 10.0) / 10.0; + spdlog::info("Closing position with IOC buy at {}...", closePx); + + hyperliquid::OrderRequest close; + close.asset = "ETH"; + close.isBuy = true; + close.price = closePx; + close.size = fill.sz; + close.reduceOnly = true; + close.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Ioc}; + + ws_->placeOrder({close}, hyperliquid::Grouping::Na); + } + } + + void onConnected() override { + spdlog::info("Connected, subscribing..."); + ws_->subscribe(hyperliquid::SubscriptionType::OrderUpdates); + ws_->subscribe(hyperliquid::SubscriptionType::UserFills); + ws_->subscribe(hyperliquid::SubscriptionType::ActiveAssetCtx, {{"coin", "ETH"}}); + } + + void onDisconnected(bool hasError, const std::string& errMsg) override { + spdlog::info("Disconnected"); + } + +private: + hyperliquid::WebsocketMessageParser messageParser; + hyperliquid::RestApiMessageParser restParser; + hyperliquid::WebsocketApi* ws_; + bool orderPlaced_ = false; + bool closeSent_ = false; +}; + +int main() { + auto wallet = loadWalletFromConfig(); + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::ApiConfig config; + config.env = hyperliquid::Environment::Testnet; + config.wallet = wallet; + + FillListener listener; + hyperliquid::WebsocketApi websocket(config, listener); + listener.setWebsocket(websocket); + + spdlog::info("Starting websocket..."); + websocket.start(); + + std::this_thread::sleep_for(std::chrono::seconds(15)); + websocket.stop(); + + return 0; +} diff --git a/examples/ws_orders.cpp b/examples/ws_orders.cpp index e6ee27b..c6874a0 100644 --- a/examples/ws_orders.cpp +++ b/examples/ws_orders.cpp @@ -1,5 +1,6 @@ #include #include +#include #include @@ -26,6 +27,27 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, restParser.parse(message, type); } + void onPerpAssetCtx(const hyperliquid::PerpAssetCtx& ctx) override { + if (ctx.coin != "ETH" || orderPlaced_ || !ctx.hasMidPx) return; + orderPlaced_ = true; + + midPx_ = ctx.midPx; + spdlog::info("ETH mid={}, placing order 5% below...", midPx_); + + double orderPx = std::floor(midPx_ * 0.95 * 10.0) / 10.0; + + hyperliquid::OrderRequest order; + order.asset = "ETH"; + order.isBuy = true; + order.price = orderPx; + order.size = 0.01; + order.reduceOnly = false; + order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Alo}; + order.cloid = cloid_; + + ws_->placeOrder({order}, hyperliquid::Grouping::Na); + } + void onPlaceOrder(const hyperliquid::PlaceOrderResponse& response) override { spdlog::info("Place order: status={}", response.status); if (response.status != "ok" || response.statuses.empty()) return; @@ -33,14 +55,16 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, auto& first = response.statuses[0]; if (first.resting) { - spdlog::info("Order resting oid={}, modifying...", first.resting->oid); + double modifyPx = std::floor(midPx_ * 0.94 * 10.0) / 10.0; + spdlog::info("Order resting oid={}, modifying to {}...", first.resting->oid, modifyPx); + hyperliquid::OrderRequest modified; modified.asset = "ETH"; modified.isBuy = true; - modified.price = 1801.0; + modified.price = modifyPx; modified.size = 0.01; modified.reduceOnly = false; - modified.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; + modified.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Alo}; modified.cloid = cloid_; hyperliquid::ModifyRequest modify; @@ -63,21 +87,27 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, void onCancelOrder(const hyperliquid::CancelOrderResponse& response) override { spdlog::info("Cancel order: status={}", response.status); + spdlog::info("Unsubscribing..."); + ws_->unsubscribe(hyperliquid::SubscriptionType::OrderUpdates); + ws_->unsubscribe(hyperliquid::SubscriptionType::UserFills); + ws_->unsubscribe(hyperliquid::SubscriptionType::ActiveAssetCtx, {{"coin", "ETH"}}); } - void onConnected() override { - spdlog::info("Connected, placing order..."); + void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); + } - hyperliquid::OrderRequest order; - order.asset = "ETH"; - order.isBuy = true; - order.price = 1800.0; - order.size = 0.01; - order.reduceOnly = false; - order.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Gtc}; - order.cloid = cloid_; + void onUserFill(const hyperliquid::Fill& fill) override { + spdlog::info("Fill: coin={} side={} px={} sz={} oid={} fee={}", + fill.coin, fill.side, fill.px, fill.sz, fill.oid, fill.fee); + } - ws_->placeOrder({order}, hyperliquid::Grouping::Na); + void onConnected() override { + spdlog::info("Connected, subscribing..."); + ws_->subscribe(hyperliquid::SubscriptionType::OrderUpdates); + ws_->subscribe(hyperliquid::SubscriptionType::UserFills); + ws_->subscribe(hyperliquid::SubscriptionType::ActiveAssetCtx, {{"coin", "ETH"}}); } void onDisconnected(bool hasError, const std::string& errMsg) override { @@ -89,6 +119,8 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, hyperliquid::RestApiMessageParser restParser; hyperliquid::WebsocketApi* ws_; std::string cloid_; + double midPx_ = 0.0; + bool orderPlaced_ = false; }; int main() { @@ -106,7 +138,7 @@ int main() { spdlog::info("Starting websocket..."); websocket.start(); - std::this_thread::sleep_for(std::chrono::seconds(10)); + std::this_thread::sleep_for(std::chrono::seconds(15)); websocket.stop(); return 0; diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 4acb43a..4410984 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -112,6 +112,54 @@ namespace hyperliquid // --- Websocket data types (authenticated) --- + enum class OrderStatus { Open, Filled, Canceled, Triggered, Rejected, MarginCanceled, OracleRejected, Unknown }; + + inline OrderStatus stringToOrderStatus(std::string_view s) + { + if (s == "open") return OrderStatus::Open; + if (s == "filled") return OrderStatus::Filled; + if (s == "canceled") return OrderStatus::Canceled; + if (s == "triggered") return OrderStatus::Triggered; + if (s == "rejected") return OrderStatus::Rejected; + if (s == "marginCanceled") return OrderStatus::MarginCanceled; + if (s == "oracleRejected") return OrderStatus::OracleRejected; + return OrderStatus::Unknown; + } + + inline std::string toString(OrderStatus status) + { + switch (status) + { + case OrderStatus::Open: return "open"; + case OrderStatus::Filled: return "filled"; + case OrderStatus::Canceled: return "canceled"; + case OrderStatus::Triggered: return "triggered"; + case OrderStatus::Rejected: return "rejected"; + case OrderStatus::MarginCanceled: return "marginCanceled"; + case OrderStatus::OracleRejected: return "oracleRejected"; + default: return "unknown"; + } + } + + enum class LiquidationMethod { Market, Backstop, Unknown }; + + inline LiquidationMethod stringToLiquidationMethod(std::string_view s) + { + if (s == "market") return LiquidationMethod::Market; + if (s == "backstop") return LiquidationMethod::Backstop; + return LiquidationMethod::Unknown; + } + + inline std::string toString(LiquidationMethod method) + { + switch (method) + { + case LiquidationMethod::Market: return "market"; + case LiquidationMethod::Backstop: return "backstop"; + default: return "unknown"; + } + } + struct Fill { std::string coin; @@ -133,7 +181,7 @@ namespace hyperliquid bool isLiquidation; std::string liquidatedUser; double liquidationMarkPx; - std::string liquidationMethod; + LiquidationMethod liquidationMethod; }; struct OrderUpdate @@ -146,7 +194,7 @@ namespace hyperliquid uint64_t timestamp; double origSz; std::string cloid; - std::string status; + OrderStatus status; uint64_t statusTimestamp; }; diff --git a/include/hyperliquid/websocket/WebsocketMessageHandler.h b/include/hyperliquid/websocket/WebsocketMessageHandler.h index 4dd17e0..ff4447c 100644 --- a/include/hyperliquid/websocket/WebsocketMessageHandler.h +++ b/include/hyperliquid/websocket/WebsocketMessageHandler.h @@ -16,6 +16,11 @@ class WebsocketMessageHandler { virtual void onAllMidsEntry(const AllMidsEntry& entry) {} virtual void onPerpAssetCtx(const PerpAssetCtx& ctx) {} virtual void onSpotAssetCtx(const SpotAssetCtx& ctx) {} + virtual void onOrderUpdate(const OrderUpdate& update) {} + virtual void onUserFill(const Fill& fill) {} + virtual void onUserFunding(const UserFunding& funding) {} + virtual void onLiquidation(const Liquidation& liquidation) {} + virtual void onNonUserCancel(const NonUserCancel& cancel) {} }; } diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index b92a62c..87b9fcd 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -119,6 +119,29 @@ namespace hyperliquid WebsocketApi::~WebsocketApi() = default; + static bool isUserSubscription(SubscriptionType type) + { + switch (type) + { + case SubscriptionType::OrderUpdates: + case SubscriptionType::UserEvents: + case SubscriptionType::UserFills: + case SubscriptionType::UserFundings: + case SubscriptionType::UserNonFundingLedgerUpdates: + case SubscriptionType::Notification: + case SubscriptionType::WebData3: + case SubscriptionType::TwapStates: + case SubscriptionType::ClearingHouseState: + case SubscriptionType::OpenOrders: + case SubscriptionType::ActiveAssetData: + case SubscriptionType::UserTwapSliceFills: + case SubscriptionType::UserTwapHistory: + return true; + default: + return false; + } + } + void WebsocketApi::subscribe(const SubscriptionType type, const std::map& filters) { nlohmann::json subscribeMsg = { @@ -129,6 +152,11 @@ namespace hyperliquid } } }; + if (isUserSubscription(type) && impl_->config.wallet.has_value() + && filters.find("user") == filters.end()) + { + subscribeMsg["subscription"]["user"] = impl_->config.wallet->accountAddress; + } for (const auto& [key, value] : filters) { subscribeMsg["subscription"][key] = value; @@ -146,6 +174,11 @@ namespace hyperliquid } } }; + if (isUserSubscription(type) && impl_->config.wallet.has_value() + && filters.find("user") == filters.end()) + { + unsubscribeMsg["subscription"]["user"] = impl_->config.wallet->accountAddress; + } for (const auto& [key, value] : filters) { unsubscribeMsg["subscription"][key] = value; diff --git a/src/websocket/WebsocketMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp index cf36186..cc9ee6a 100644 --- a/src/websocket/WebsocketMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -75,6 +75,21 @@ namespace hyperliquid auto data = doc["data"].get_object().value(); crackActiveAssetCtx(data, listener); } + else if (channel == "orderUpdates") + { + auto data = doc["data"].get_array().value(); + crackOrderUpdates(data, listener); + } + else if (channel == "userFills") + { + auto data = doc["data"].get_object().value(); + crackUserFills(data, listener); + } + else if (channel == "userEvents") + { + auto data = doc["data"].get_object().value(); + crackUserEvents(data, listener); + } else if (channel == "subscriptionResponse") { auto data = doc["data"].get_object().value(); @@ -276,43 +291,224 @@ namespace hyperliquid } } + // Helper: parse a field that may be a string or a double + double toDoubleField(simdjson::ondemand::object& obj, std::string_view key) + { + auto val = obj[key]; + std::string_view sv; + if (!val.get_string().get(sv)) + return toDouble(sv); + return val.get_double().value(); + } + void crackActiveAssetCtx(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { std::string coin(data["coin"].get_string().value()); auto ctx = data["ctx"].get_object().value(); - simdjson::ondemand::value fundingVal; - bool isPerp = !ctx["funding"].get(fundingVal); + std::string_view fundingStr; + bool isPerp = !ctx["funding"].get_string().get(fundingStr); if (isPerp) { PerpAssetCtx perp; perp.coin = coin; - perp.dayNtlVlm = ctx["dayNtlVlm"].get_double().value(); - perp.prevDayPx = ctx["prevDayPx"].get_double().value(); - perp.markPx = ctx["markPx"].get_double().value(); - double midPx; - perp.hasMidPx = !ctx["midPx"].get_double().get(midPx); - perp.midPx = perp.hasMidPx ? midPx : 0.0; - perp.funding = fundingVal.get_double().value(); - perp.openInterest = ctx["openInterest"].get_double().value(); - perp.oraclePx = ctx["oraclePx"].get_double().value(); + perp.funding = toDouble(fundingStr); + perp.dayNtlVlm = toDoubleField(ctx, "dayNtlVlm"); + perp.prevDayPx = toDoubleField(ctx, "prevDayPx"); + perp.markPx = toDoubleField(ctx, "markPx"); + std::string_view midStr; + perp.hasMidPx = !ctx["midPx"].get_string().get(midStr); + perp.midPx = perp.hasMidPx ? toDouble(midStr) : 0.0; + perp.openInterest = toDoubleField(ctx, "openInterest"); + perp.oraclePx = toDoubleField(ctx, "oraclePx"); listener.onPerpAssetCtx(perp); } else { SpotAssetCtx spot; spot.coin = coin; - spot.dayNtlVlm = ctx["dayNtlVlm"].get_double().value(); - spot.prevDayPx = ctx["prevDayPx"].get_double().value(); - spot.markPx = ctx["markPx"].get_double().value(); - double midPx; - spot.hasMidPx = !ctx["midPx"].get_double().get(midPx); - spot.midPx = spot.hasMidPx ? midPx : 0.0; - spot.circulatingSupply = ctx["circulatingSupply"].get_double().value(); + spot.dayNtlVlm = toDoubleField(ctx, "dayNtlVlm"); + spot.prevDayPx = toDoubleField(ctx, "prevDayPx"); + spot.markPx = toDoubleField(ctx, "markPx"); + std::string_view midStr; + spot.hasMidPx = !ctx["midPx"].get_string().get(midStr); + spot.midPx = spot.hasMidPx ? toDouble(midStr) : 0.0; + spot.circulatingSupply = toDoubleField(ctx, "circulatingSupply"); listener.onSpotAssetCtx(spot); } } + + void crackFill(simdjson::ondemand::object& obj, WebsocketMessageHandler& listener) + { + Fill fill; + fill.coin = std::string(obj["coin"].get_string().value()); + fill.px = toDouble(obj["px"].get_string().value()); + fill.sz = toDouble(obj["sz"].get_string().value()); + auto sideStr = obj["side"].get_string().value(); + fill.side = sideStr.size() > 0 ? sideStr[0] : '?'; + fill.time = obj["time"].get_uint64().value(); + fill.startPosition = toDouble(obj["startPosition"].get_string().value()); + fill.dir = std::string(obj["dir"].get_string().value()); + fill.closedPnl = toDouble(obj["closedPnl"].get_string().value()); + fill.hash = std::string(obj["hash"].get_string().value()); + fill.oid = obj["oid"].get_uint64().value(); + fill.crossed = obj["crossed"].get_bool().value(); + fill.fee = toDouble(obj["fee"].get_string().value()); + fill.tid = obj["tid"].get_uint64().value(); + fill.feeToken = std::string(obj["feeToken"].get_string().value()); + + // Optional builderFee + double builderFee; + fill.hasBuilderFee = !obj["builderFee"].get_double().get(builderFee); + if (!fill.hasBuilderFee) + { + // Try string form + std::string_view bfStr; + fill.hasBuilderFee = !obj["builderFee"].get_string().get(bfStr); + if (fill.hasBuilderFee) builderFee = toDouble(bfStr); + } + fill.builderFee = fill.hasBuilderFee ? builderFee : 0.0; + + // Optional liquidation + simdjson::ondemand::object liqObj; + fill.isLiquidation = !obj["liquidation"].get_object().get(liqObj); + if (fill.isLiquidation) + { + fill.liquidatedUser = std::string(liqObj["liquidatedUser"].get_string().value()); + fill.liquidationMarkPx = toDouble(liqObj["markPx"].get_string().value()); + fill.liquidationMethod = stringToLiquidationMethod(liqObj["method"].get_string().value()); + } + else + { + fill.liquidatedUser.clear(); + fill.liquidationMarkPx = 0.0; + fill.liquidationMethod = LiquidationMethod::Unknown; + } + + listener.onUserFill(fill); + } + + void crackOrderUpdates(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) + { + for (auto entry : data) + { + try + { + auto obj = entry.get_object().value(); + auto order = obj["order"].get_object().value(); + + OrderUpdate update; + update.coin = std::string(order["coin"].get_string().value()); + auto sideStr = order["side"].get_string().value(); + update.side = sideStr.size() > 0 ? sideStr[0] : '?'; + update.limitPx = toDouble(order["limitPx"].get_string().value()); + update.sz = toDouble(order["sz"].get_string().value()); + update.oid = order["oid"].get_uint64().value(); + update.timestamp = order["timestamp"].get_uint64().value(); + update.origSz = toDouble(order["origSz"].get_string().value()); + + std::string_view cloidStr; + if (!order["cloid"].get_string().get(cloidStr)) + update.cloid = std::string(cloidStr); + + update.status = stringToOrderStatus(obj["status"].get_string().value()); + update.statusTimestamp = obj["statusTimestamp"].get_uint64().value(); + + listener.onOrderUpdate(update); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in orderUpdate: {}", e.what()); + } + } + } + + void crackUserFills(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + auto fills = data["fills"].get_array().value(); + for (auto entry : fills) + { + try + { + auto obj = entry.get_object().value(); + crackFill(obj, listener); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in userFill: {}", e.what()); + } + } + } + + void crackUserEvents(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + // Discriminated union — check which key exists + simdjson::ondemand::array fillsArr; + if (!data["fills"].get_array().get(fillsArr)) + { + for (auto entry : fillsArr) + { + try + { + auto obj = entry.get_object().value(); + crackFill(obj, listener); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in userEvents fill: {}", e.what()); + } + } + return; + } + + simdjson::ondemand::object fundingObj; + if (!data["funding"].get_object().get(fundingObj)) + { + UserFunding funding; + funding.time = fundingObj["time"].get_uint64().value(); + funding.coin = std::string(fundingObj["coin"].get_string().value()); + funding.usdc = toDouble(fundingObj["usdc"].get_string().value()); + funding.szi = toDouble(fundingObj["szi"].get_string().value()); + funding.fundingRate = toDouble(fundingObj["fundingRate"].get_string().value()); + listener.onUserFunding(funding); + return; + } + + simdjson::ondemand::object liqObj; + if (!data["liquidation"].get_object().get(liqObj)) + { + Liquidation liq; + liq.lid = liqObj["lid"].get_uint64().value(); + liq.liquidator = std::string(liqObj["liquidator"].get_string().value()); + liq.liquidatedUser = std::string(liqObj["liquidatedUser"].get_string().value()); + liq.liquidatedNtlPos = toDouble(liqObj["liquidatedNtlPos"].get_string().value()); + liq.liquidatedAccountValue = toDouble(liqObj["liquidatedAccountValue"].get_string().value()); + listener.onLiquidation(liq); + return; + } + + simdjson::ondemand::array cancelArr; + if (!data["nonUserCancel"].get_array().get(cancelArr)) + { + for (auto entry : cancelArr) + { + try + { + auto obj = entry.get_object().value(); + NonUserCancel cancel; + cancel.coin = std::string(obj["coin"].get_string().value()); + cancel.oid = obj["oid"].get_uint64().value(); + listener.onNonUserCancel(cancel); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in userEvents nonUserCancel: {}", e.what()); + } + } + return; + } + } }; WebsocketMessageParser::WebsocketMessageParser() : impl_(std::make_unique()) {} diff --git a/tests/signing_test.cpp b/tests/signing_test.cpp index c66a37b..b057ddf 100644 --- a/tests/signing_test.cpp +++ b/tests/signing_test.cpp @@ -1,4 +1,4 @@ -// Tests ported from hyperliquid-python-sdk/tests/signing_test.py +// Signing tests ported from hyperliquid-python-sdk #include #include "signing/Signing.h" @@ -21,8 +21,6 @@ static uint64_t floatToIntForHashing(double value) return static_cast(std::round(withDecimals)); } -// Python: order_request_to_order_wire + order_wires_to_order_action -// Builds the action exactly as the Python SDK does static nlohmann::ordered_json orderWireToAction(const nlohmann::ordered_json& orderWire, const std::string& grouping = "na") { @@ -33,57 +31,47 @@ static nlohmann::ordered_json orderWireToAction(const nlohmann::ordered_json& or return action; } -// Python: float_to_wire(x) -> Decimal(f"{x:.8f}").normalize() as string +// Mirrors Python's float_to_wire: Decimal(f"{x:.8f}").normalize() static std::string floatToWire(double x) { char buf[64]; std::snprintf(buf, sizeof(buf), "%.8f", x); std::string s(buf); - // Normalize: strip trailing zeros after decimal point if (s.find('.') != std::string::npos) { size_t last = s.find_last_not_of('0'); - if (s[last] == '.') last--; // remove trailing dot too? No, Python Decimal keeps it as integer - // Actually Python Decimal("100.00000000").normalize() = "1E+2" -> f format = "100" - // Python Decimal("1670.10000000").normalize() = "1670.1" -> f format = "1670.1" - // Python Decimal("0.01470000").normalize() = "0.0147" if (s[last] == '.') - s = s.substr(0, last); // drop the dot for integers + s = s.substr(0, last); else s = s.substr(0, last + 1); } return s; } -// test_phantom_agent_creation_matches_production +static nlohmann::ordered_json makeLimitOrderWire(int asset, bool isBuy, double price, double size, const std::string& tif) +{ + nlohmann::ordered_json wire; + wire["a"] = asset; + wire["b"] = isBuy; + wire["p"] = floatToWire(price); + wire["s"] = floatToWire(size); + wire["r"] = false; + wire["t"] = {{"limit", {{"tif", tif}}}}; + return wire; +} + TEST(SigningTest, PhantomAgentCreationMatchesProduction) { uint64_t timestamp = 1677777606040; - // order_request_to_order_wire(order_request, 4) - nlohmann::ordered_json orderWire; - orderWire["a"] = 4; - orderWire["b"] = true; - orderWire["p"] = floatToWire(1670.1); // "1670.1" - orderWire["s"] = floatToWire(0.0147); // "0.0147" - orderWire["r"] = false; - nlohmann::ordered_json limitInner; - limitInner["tif"] = "Ioc"; - nlohmann::ordered_json limitOuter; - limitOuter["limit"] = limitInner; - orderWire["t"] = limitOuter; - + auto orderWire = makeLimitOrderWire(4, true, 1670.1, 0.0147, "Ioc"); auto action = orderWireToAction(orderWire); - - // action_hash(action, None, timestamp, None) auto hash = SigningHelpers::actionHash(action, std::nullopt, timestamp, std::nullopt); - // construct_phantom_agent connectionId check std::string connectionIdHex = SigningHelpers::toHexPadded(hash.data(), 32); EXPECT_EQ(connectionIdHex, "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908"); } -// test_l1_action_signing_matches TEST(SigningTest, L1ActionSigningMatches) { auto wallet = testWallet(); @@ -102,24 +90,10 @@ TEST(SigningTest, L1ActionSigningMatches) EXPECT_EQ(sigTestnet.v, 28); } -// test_l1_action_signing_order_matches TEST(SigningTest, L1ActionSigningOrderMatches) { auto wallet = testWallet(); - - // order_request_to_order_wire({coin: ETH, is_buy: True, sz: 100, limit_px: 100, ...}, 1) - nlohmann::ordered_json orderWire; - orderWire["a"] = 1; - orderWire["b"] = true; - orderWire["p"] = floatToWire(100); // "100" - orderWire["s"] = floatToWire(100); // "100" - orderWire["r"] = false; - nlohmann::ordered_json limitInner; - limitInner["tif"] = "Gtc"; - nlohmann::ordered_json limitOuter; - limitOuter["limit"] = limitInner; - orderWire["t"] = limitOuter; - + auto orderWire = makeLimitOrderWire(1, true, 100, 100, "Gtc"); auto action = orderWireToAction(orderWire); auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); @@ -133,24 +107,11 @@ TEST(SigningTest, L1ActionSigningOrderMatches) EXPECT_EQ(sigTestnet.v, 27); } -// test_l1_action_signing_order_with_cloid_matches TEST(SigningTest, L1ActionSigningOrderWithCloidMatches) { auto wallet = testWallet(); - - nlohmann::ordered_json orderWire; - orderWire["a"] = 1; - orderWire["b"] = true; - orderWire["p"] = floatToWire(100); - orderWire["s"] = floatToWire(100); - orderWire["r"] = false; - nlohmann::ordered_json limitInner; - limitInner["tif"] = "Gtc"; - nlohmann::ordered_json limitOuter; - limitOuter["limit"] = limitInner; - orderWire["t"] = limitOuter; + auto orderWire = makeLimitOrderWire(1, true, 100, 100, "Gtc"); orderWire["c"] = "0x00000000000000000000000000000001"; - auto action = orderWireToAction(orderWire); auto sigMainnet = Signing::signL1Action(wallet, action, std::nullopt, 0, std::nullopt, true); @@ -164,7 +125,6 @@ TEST(SigningTest, L1ActionSigningOrderWithCloidMatches) EXPECT_EQ(sigTestnet.v, 28); } -// test_l1_action_signing_matches_with_vault TEST(SigningTest, L1ActionSigningMatchesWithVault) { auto wallet = testWallet(); @@ -185,26 +145,17 @@ TEST(SigningTest, L1ActionSigningMatchesWithVault) EXPECT_EQ(sigTestnet.v, 27); } -// test_l1_action_signing_tpsl_order_matches TEST(SigningTest, L1ActionSigningTpslOrderMatches) { auto wallet = testWallet(); - // order_type: {"trigger": {"triggerPx": 103, "isMarket": True, "tpsl": "sl"}} - // order_type_to_wire produces: {"trigger": {"isMarket": True, "triggerPx": "103", "tpsl": "sl"}} nlohmann::ordered_json orderWire; orderWire["a"] = 1; orderWire["b"] = true; orderWire["p"] = floatToWire(100); orderWire["s"] = floatToWire(100); orderWire["r"] = false; - nlohmann::ordered_json triggerInner; - triggerInner["isMarket"] = true; - triggerInner["triggerPx"] = floatToWire(103); // "103" - triggerInner["tpsl"] = "sl"; - nlohmann::ordered_json triggerOuter; - triggerOuter["trigger"] = triggerInner; - orderWire["t"] = triggerOuter; + orderWire["t"] = {{"trigger", {{"isMarket", true}, {"triggerPx", floatToWire(103)}, {"tpsl", "sl"}}}}; auto action = orderWireToAction(orderWire); @@ -219,15 +170,12 @@ TEST(SigningTest, L1ActionSigningTpslOrderMatches) EXPECT_EQ(sigTestnet.v, 28); } -// test_float_to_int_for_hashing TEST(SigningTest, FloatToIntForHashing) { - // 123123123123 * 1e8 exceeds double precision (~15 digits), skip that case EXPECT_EQ(floatToIntForHashing(0.00001231), 1231ULL); EXPECT_EQ(floatToIntForHashing(1.033), 103300000ULL); } -// test_sign_usd_transfer_action TEST(SigningTest, SignUsdTransferAction) { auto wallet = testWallet(); @@ -255,7 +203,6 @@ TEST(SigningTest, SignUsdTransferAction) EXPECT_EQ(sig.v, 27); } -// test_sign_withdraw_from_bridge_action TEST(SigningTest, SignWithdrawFromBridgeAction) { auto wallet = testWallet(); @@ -283,7 +230,6 @@ TEST(SigningTest, SignWithdrawFromBridgeAction) EXPECT_EQ(sig.v, 28); } -// test_create_sub_account_action TEST(SigningTest, CreateSubAccountAction) { auto wallet = testWallet(); @@ -302,7 +248,6 @@ TEST(SigningTest, CreateSubAccountAction) EXPECT_EQ(sigTestnet.v, 28); } -// test_sub_account_transfer_action TEST(SigningTest, SubAccountTransferAction) { auto wallet = testWallet(); @@ -323,7 +268,6 @@ TEST(SigningTest, SubAccountTransferAction) EXPECT_EQ(sigTestnet.v, 28); } -// test_schedule_cancel_action (both with and without time) TEST(SigningTest, ScheduleCancelAction) { auto wallet = testWallet(); diff --git a/vcpkg.json b/vcpkg.json index 0e57a10..8a70a44 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -8,6 +8,7 @@ "boost-system", "simdjson", "nlohmann-json", - "spdlog" + "spdlog", + "gtest" ] } From f77c76e4c367b1c76111a2b75cee5294f2707881 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sun, 15 Mar 2026 17:18:43 +0000 Subject: [PATCH 11/15] dont leak the test targets --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index c975a52..238550d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ set(SECP256K1_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(SECP256K1_BUILD_EXHAUSTIVE_TESTS OFF CACHE BOOL "" FORCE) set(SECP256K1_BUILD_BENCHMARK OFF CACHE BOOL "" FORCE) set(SECP256K1_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +set(SECP256K1_BUILD_CTIME_TESTS OFF CACHE BOOL "" FORCE) FetchContent_MakeAvailable(secp256k1) set(SDK_SOURCES From ae308ffdec83c92685934c466bdb2fc4a4eb6387 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sun, 15 Mar 2026 22:20:51 +0000 Subject: [PATCH 12/15] track if the message is a snapshot for fills and order updates --- examples/ws_fills.cpp | 8 +++--- examples/ws_orders.cpp | 12 ++++----- .../websocket/WebsocketMessageHandler.h | 4 +-- .../websocket/WebsocketMessageParser.h | 1 + src/websocket/WebsocketMessageParser.cpp | 25 ++++++++++++++----- 5 files changed, 32 insertions(+), 18 deletions(-) diff --git a/examples/ws_fills.cpp b/examples/ws_fills.cpp index 7b6380d..d49e499 100644 --- a/examples/ws_fills.cpp +++ b/examples/ws_fills.cpp @@ -59,12 +59,12 @@ class FillListener : public hyperliquid::WebsocketMessageHandler, } } - void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { - spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", - update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); + void onOrderUpdate(const hyperliquid::OrderUpdate& update, bool isSnapshot) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={} snapshot={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx, isSnapshot); } - void onUserFill(const hyperliquid::Fill& fill) override { + void onUserFill(const hyperliquid::Fill& fill, bool isSnapshot) override { spdlog::info("Fill: coin={} side={} px={} sz={} dir={} closedPnl={} fee={} oid={}", fill.coin, fill.side, fill.px, fill.sz, fill.dir, fill.closedPnl, fill.fee, fill.oid); diff --git a/examples/ws_orders.cpp b/examples/ws_orders.cpp index c6874a0..69449a0 100644 --- a/examples/ws_orders.cpp +++ b/examples/ws_orders.cpp @@ -93,14 +93,14 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, ws_->unsubscribe(hyperliquid::SubscriptionType::ActiveAssetCtx, {{"coin", "ETH"}}); } - void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { - spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", - update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); + void onOrderUpdate(const hyperliquid::OrderUpdate& update, bool isSnapshot) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={} snapshot={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx, isSnapshot); } - void onUserFill(const hyperliquid::Fill& fill) override { - spdlog::info("Fill: coin={} side={} px={} sz={} oid={} fee={}", - fill.coin, fill.side, fill.px, fill.sz, fill.oid, fill.fee); + void onUserFill(const hyperliquid::Fill& fill, bool isSnapshot) override { + spdlog::info("Fill: coin={} side={} px={} sz={} oid={} fee={} snapshot={}", + fill.coin, fill.side, fill.px, fill.sz, fill.oid, fill.fee, isSnapshot); } void onConnected() override { diff --git a/include/hyperliquid/websocket/WebsocketMessageHandler.h b/include/hyperliquid/websocket/WebsocketMessageHandler.h index ff4447c..4798302 100644 --- a/include/hyperliquid/websocket/WebsocketMessageHandler.h +++ b/include/hyperliquid/websocket/WebsocketMessageHandler.h @@ -16,8 +16,8 @@ class WebsocketMessageHandler { virtual void onAllMidsEntry(const AllMidsEntry& entry) {} virtual void onPerpAssetCtx(const PerpAssetCtx& ctx) {} virtual void onSpotAssetCtx(const SpotAssetCtx& ctx) {} - virtual void onOrderUpdate(const OrderUpdate& update) {} - virtual void onUserFill(const Fill& fill) {} + virtual void onOrderUpdate(const OrderUpdate& update, bool isSnapshot = false) {} + virtual void onUserFill(const Fill& fill, bool isSnapshot = false) {} virtual void onUserFunding(const UserFunding& funding) {} virtual void onLiquidation(const Liquidation& liquidation) {} virtual void onNonUserCancel(const NonUserCancel& cancel) {} diff --git a/include/hyperliquid/websocket/WebsocketMessageParser.h b/include/hyperliquid/websocket/WebsocketMessageParser.h index 3a6d6d0..bc23419 100644 --- a/include/hyperliquid/websocket/WebsocketMessageParser.h +++ b/include/hyperliquid/websocket/WebsocketMessageParser.h @@ -18,6 +18,7 @@ namespace hyperliquid WebsocketMessageParser& operator=(const WebsocketMessageParser&) = delete; void crack(const std::string& message, WebsocketMessageHandler& listener); + void reset(); private: struct Impl; diff --git a/src/websocket/WebsocketMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp index cc9ee6a..37f1988 100644 --- a/src/websocket/WebsocketMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -11,6 +11,7 @@ namespace hyperliquid { simdjson::ondemand::parser parser; simdjson::padded_string padded; + bool orderUpdatesSnapshotReceived = false; static double toDouble(std::string_view sv) { @@ -77,8 +78,10 @@ namespace hyperliquid } else if (channel == "orderUpdates") { + bool isSnapshot = !orderUpdatesSnapshotReceived; + orderUpdatesSnapshotReceived = true; auto data = doc["data"].get_array().value(); - crackOrderUpdates(data, listener); + crackOrderUpdates(data, listener, isSnapshot); } else if (channel == "userFills") { @@ -339,7 +342,7 @@ namespace hyperliquid } } - void crackFill(simdjson::ondemand::object& obj, WebsocketMessageHandler& listener) + void crackFill(simdjson::ondemand::object& obj, WebsocketMessageHandler& listener, bool isSnapshot = false) { Fill fill; fill.coin = std::string(obj["coin"].get_string().value()); @@ -386,10 +389,10 @@ namespace hyperliquid fill.liquidationMethod = LiquidationMethod::Unknown; } - listener.onUserFill(fill); + listener.onUserFill(fill, isSnapshot); } - void crackOrderUpdates(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) + void crackOrderUpdates(simdjson::ondemand::array& data, WebsocketMessageHandler& listener, bool isSnapshot) { for (auto entry : data) { @@ -415,7 +418,7 @@ namespace hyperliquid update.status = stringToOrderStatus(obj["status"].get_string().value()); update.statusTimestamp = obj["statusTimestamp"].get_uint64().value(); - listener.onOrderUpdate(update); + listener.onOrderUpdate(update, isSnapshot); } catch (const simdjson::simdjson_error& e) { @@ -426,13 +429,18 @@ namespace hyperliquid void crackUserFills(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) { + bool isSnapshot = false; + auto snapshotVal = data["isSnapshot"]; + if (!snapshotVal.error()) + isSnapshot = snapshotVal.get_bool().value(); + auto fills = data["fills"].get_array().value(); for (auto entry : fills) { try { auto obj = entry.get_object().value(); - crackFill(obj, listener); + crackFill(obj, listener, isSnapshot); } catch (const simdjson::simdjson_error& e) { @@ -521,4 +529,9 @@ namespace hyperliquid { impl_->crack(message, listener); } + + void WebsocketMessageParser::reset() + { + impl_->orderUpdatesSnapshotReceived = false; + } } // namespace hyperliquid From 44c40cb18f2ed955a4aad932ce3b36fee38709c3 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sun, 15 Mar 2026 22:41:31 +0000 Subject: [PATCH 13/15] fix ping pong --- src/websocket/WebsocketApi.cpp | 6 ++++++ src/websocket/WebsocketRunner.cpp | 23 ++++++++++++++--------- src/websocket/WebsocketRunner.h | 1 + 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index 87b9fcd..1a9c663 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -53,6 +53,12 @@ namespace hyperliquid auto doc = sjParser.iterate(sjPadded); std::string_view channel = doc["channel"].get_string().value(); + if (channel == "pong") + { + ws.onPongReceived(); + return; + } + if (channel == "post") { auto data = doc["data"].get_object().value(); diff --git a/src/websocket/WebsocketRunner.cpp b/src/websocket/WebsocketRunner.cpp index dcdc6c0..16b3384 100644 --- a/src/websocket/WebsocketRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -25,6 +25,12 @@ WebsocketRunner::~WebsocketRunner() { net::io_context& WebsocketRunner::getIoContext() { return ioc_; } +void WebsocketRunner::onPongReceived() { + getLogger()->debug("Pong received"); + lastPongTime_ = std::chrono::steady_clock::now(); + pendingPong_ = false; +} + void WebsocketRunner::send(const std::string& message) { net::post(ws_->get_executor(), [this, msg = message]() { writeQueue_.push(msg); @@ -240,6 +246,7 @@ void WebsocketRunner::setupControlCallback() { void WebsocketRunner::startPingTimer() { if (stopping_ || !connected_) return; + getLogger()->debug("Scheduling next ping in {}s", PING_INTERVAL_SECS); pingTimer_ = std::make_unique(ioc_, std::chrono::seconds(PING_INTERVAL_SECS)); pingTimer_->async_wait([this](const boost::system::error_code& ec) { if (!ec && !stopping_ && connected_) { @@ -262,16 +269,14 @@ void WebsocketRunner::doPing() { } } + // Hyperliquid expects JSON ping, not WebSocket protocol ping + getLogger()->debug("Sending JSON ping"); pendingPong_ = true; - ws_->async_ping({}, [this](beast::error_code ec) { - if (ec) { - if (!stopping_) { - getLogger()->error("Ping failed: {}", ec.message()); - connected_ = false; - listener_.onWsDisconnected(true, "Ping failed"); - scheduleReconnect(); - } - return; + std::string pingMsg = R"({"method":"ping"})"; + net::post(ws_->get_executor(), [this, pingMsg]() { + writeQueue_.push(pingMsg); + if (connected_ && !writing_) { + doWrite(); } startPingTimer(); }); diff --git a/src/websocket/WebsocketRunner.h b/src/websocket/WebsocketRunner.h index d16cca1..d7d17f8 100644 --- a/src/websocket/WebsocketRunner.h +++ b/src/websocket/WebsocketRunner.h @@ -40,6 +40,7 @@ class WebsocketRunner { ~WebsocketRunner(); void send(const std::string& message); + void onPongReceived(); net::io_context& getIoContext(); From a79bc0e36b8ae57f8aeefb48d19f00936db383c7 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Sun, 15 Mar 2026 22:55:49 +0000 Subject: [PATCH 14/15] add debug output logging --- src/websocket/WebsocketRunner.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/src/websocket/WebsocketRunner.cpp b/src/websocket/WebsocketRunner.cpp index 16b3384..95f0557 100644 --- a/src/websocket/WebsocketRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -32,6 +32,7 @@ void WebsocketRunner::onPongReceived() { } void WebsocketRunner::send(const std::string& message) { + getLogger()->debug("ws send: {}", message); net::post(ws_->get_executor(), [this, msg = message]() { writeQueue_.push(msg); if (connected_ && !writing_) { From ee938b8cc3d4cbf322e84b398f014d2fdc46890d Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Wed, 18 Mar 2026 22:05:12 +0000 Subject: [PATCH 15/15] cleanup a few small nits --- examples/ws_fills.cpp | 8 ++++---- examples/ws_orders.cpp | 10 +++++----- include/hyperliquid/types/ResponseTypes.h | 1 + .../websocket/WebsocketMessageHandler.h | 4 ++-- src/websocket/WebsocketMessageParser.cpp | 17 +++++++---------- src/websocket/WebsocketRunner.cpp | 4 ---- 6 files changed, 19 insertions(+), 25 deletions(-) diff --git a/examples/ws_fills.cpp b/examples/ws_fills.cpp index d49e499..7b6380d 100644 --- a/examples/ws_fills.cpp +++ b/examples/ws_fills.cpp @@ -59,12 +59,12 @@ class FillListener : public hyperliquid::WebsocketMessageHandler, } } - void onOrderUpdate(const hyperliquid::OrderUpdate& update, bool isSnapshot) override { - spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={} snapshot={}", - update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx, isSnapshot); + void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); } - void onUserFill(const hyperliquid::Fill& fill, bool isSnapshot) override { + void onUserFill(const hyperliquid::Fill& fill) override { spdlog::info("Fill: coin={} side={} px={} sz={} dir={} closedPnl={} fee={} oid={}", fill.coin, fill.side, fill.px, fill.sz, fill.dir, fill.closedPnl, fill.fee, fill.oid); diff --git a/examples/ws_orders.cpp b/examples/ws_orders.cpp index 69449a0..07b716f 100644 --- a/examples/ws_orders.cpp +++ b/examples/ws_orders.cpp @@ -93,14 +93,14 @@ class OrderListener : public hyperliquid::WebsocketMessageHandler, ws_->unsubscribe(hyperliquid::SubscriptionType::ActiveAssetCtx, {{"coin", "ETH"}}); } - void onOrderUpdate(const hyperliquid::OrderUpdate& update, bool isSnapshot) override { - spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={} snapshot={}", - update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx, isSnapshot); + void onOrderUpdate(const hyperliquid::OrderUpdate& update) override { + spdlog::info("Order update: coin={} side={} status={} oid={} sz={} limitPx={}", + update.coin, update.side, hyperliquid::toString(update.status), update.oid, update.sz, update.limitPx); } - void onUserFill(const hyperliquid::Fill& fill, bool isSnapshot) override { + void onUserFill(const hyperliquid::Fill& fill) override { spdlog::info("Fill: coin={} side={} px={} sz={} oid={} fee={} snapshot={}", - fill.coin, fill.side, fill.px, fill.sz, fill.oid, fill.fee, isSnapshot); + fill.coin, fill.side, fill.px, fill.sz, fill.oid, fill.fee, fill.isSnapshot); } void onConnected() override { diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 4410984..e352298 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -182,6 +182,7 @@ namespace hyperliquid std::string liquidatedUser; double liquidationMarkPx; LiquidationMethod liquidationMethod; + bool isSnapshot; }; struct OrderUpdate diff --git a/include/hyperliquid/websocket/WebsocketMessageHandler.h b/include/hyperliquid/websocket/WebsocketMessageHandler.h index 4798302..ff4447c 100644 --- a/include/hyperliquid/websocket/WebsocketMessageHandler.h +++ b/include/hyperliquid/websocket/WebsocketMessageHandler.h @@ -16,8 +16,8 @@ class WebsocketMessageHandler { virtual void onAllMidsEntry(const AllMidsEntry& entry) {} virtual void onPerpAssetCtx(const PerpAssetCtx& ctx) {} virtual void onSpotAssetCtx(const SpotAssetCtx& ctx) {} - virtual void onOrderUpdate(const OrderUpdate& update, bool isSnapshot = false) {} - virtual void onUserFill(const Fill& fill, bool isSnapshot = false) {} + virtual void onOrderUpdate(const OrderUpdate& update) {} + virtual void onUserFill(const Fill& fill) {} virtual void onUserFunding(const UserFunding& funding) {} virtual void onLiquidation(const Liquidation& liquidation) {} virtual void onNonUserCancel(const NonUserCancel& cancel) {} diff --git a/src/websocket/WebsocketMessageParser.cpp b/src/websocket/WebsocketMessageParser.cpp index 37f1988..c59cda9 100644 --- a/src/websocket/WebsocketMessageParser.cpp +++ b/src/websocket/WebsocketMessageParser.cpp @@ -11,7 +11,6 @@ namespace hyperliquid { simdjson::ondemand::parser parser; simdjson::padded_string padded; - bool orderUpdatesSnapshotReceived = false; static double toDouble(std::string_view sv) { @@ -78,10 +77,8 @@ namespace hyperliquid } else if (channel == "orderUpdates") { - bool isSnapshot = !orderUpdatesSnapshotReceived; - orderUpdatesSnapshotReceived = true; auto data = doc["data"].get_array().value(); - crackOrderUpdates(data, listener, isSnapshot); + crackOrderUpdates(data, listener); } else if (channel == "userFills") { @@ -342,7 +339,7 @@ namespace hyperliquid } } - void crackFill(simdjson::ondemand::object& obj, WebsocketMessageHandler& listener, bool isSnapshot = false) + void crackFill(simdjson::ondemand::object& obj, WebsocketMessageHandler& listener, bool isSnapshot) { Fill fill; fill.coin = std::string(obj["coin"].get_string().value()); @@ -389,10 +386,11 @@ namespace hyperliquid fill.liquidationMethod = LiquidationMethod::Unknown; } - listener.onUserFill(fill, isSnapshot); + fill.isSnapshot = isSnapshot; + listener.onUserFill(fill); } - void crackOrderUpdates(simdjson::ondemand::array& data, WebsocketMessageHandler& listener, bool isSnapshot) + void crackOrderUpdates(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) { for (auto entry : data) { @@ -418,7 +416,7 @@ namespace hyperliquid update.status = stringToOrderStatus(obj["status"].get_string().value()); update.statusTimestamp = obj["statusTimestamp"].get_uint64().value(); - listener.onOrderUpdate(update, isSnapshot); + listener.onOrderUpdate(update); } catch (const simdjson::simdjson_error& e) { @@ -460,7 +458,7 @@ namespace hyperliquid try { auto obj = entry.get_object().value(); - crackFill(obj, listener); + crackFill(obj, listener, false); } catch (const simdjson::simdjson_error& e) { @@ -532,6 +530,5 @@ namespace hyperliquid void WebsocketMessageParser::reset() { - impl_->orderUpdatesSnapshotReceived = false; } } // namespace hyperliquid diff --git a/src/websocket/WebsocketRunner.cpp b/src/websocket/WebsocketRunner.cpp index 95f0557..1ee3f30 100644 --- a/src/websocket/WebsocketRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -26,7 +26,6 @@ WebsocketRunner::~WebsocketRunner() { net::io_context& WebsocketRunner::getIoContext() { return ioc_; } void WebsocketRunner::onPongReceived() { - getLogger()->debug("Pong received"); lastPongTime_ = std::chrono::steady_clock::now(); pendingPong_ = false; } @@ -247,7 +246,6 @@ void WebsocketRunner::setupControlCallback() { void WebsocketRunner::startPingTimer() { if (stopping_ || !connected_) return; - getLogger()->debug("Scheduling next ping in {}s", PING_INTERVAL_SECS); pingTimer_ = std::make_unique(ioc_, std::chrono::seconds(PING_INTERVAL_SECS)); pingTimer_->async_wait([this](const boost::system::error_code& ec) { if (!ec && !stopping_ && connected_) { @@ -270,8 +268,6 @@ void WebsocketRunner::doPing() { } } - // Hyperliquid expects JSON ping, not WebSocket protocol ping - getLogger()->debug("Sending JSON ping"); pendingPong_ = true; std::string pingMsg = R"({"method":"ping"})"; net::post(ws_->get_executor(), [this, pingMsg]() {