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 30c4b12..238550d 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,13 +8,39 @@ find_package(OpenSSL REQUIRED) find_package(Boost REQUIRED COMPONENTS system) find_package(simdjson CONFIG REQUIRED) find_package(nlohmann_json 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) +set(SECP256K1_BUILD_CTIME_TESTS OFF CACHE BOOL "" FORCE) +FetchContent_MakeAvailable(secp256k1) 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/rest/HttpSession.cpp + src/messages/InfoRequestBuilder.cpp + src/messages/ExchangeRequestBuilder.cpp + src/rest/SymbolMap.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 ) @@ -25,7 +51,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 @@ -35,6 +60,8 @@ target_link_libraries(hyperliquid-sdk OpenSSL::Crypto nlohmann_json::nlohmann_json simdjson::simdjson + secp256k1 + spdlog::spdlog ) if(NOT MSVC) @@ -48,11 +75,24 @@ set_target_properties(hyperliquid-sdk PROPERTIES ) option(HYPERLIQUID_BUILD_EXAMPLES "Build example programs" OFF) +option(HYPERLIQUID_BUILD_TESTS "Build tests" OFF) -if(HYPERLIQUID_BUILD_EXAMPLES) - add_executable(print_book examples/print_book.cpp) - target_link_libraries(print_book PRIVATE hyperliquid-sdk) +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() - add_executable(fetch_meta examples/fetch_meta.cpp) - target_link_libraries(fetch_meta PRIVATE hyperliquid-sdk) +if(HYPERLIQUID_BUILD_EXAMPLES) + 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 spdlog::spdlog) + target_compile_definitions(${EXAMPLE_NAME} PRIVATE EXAMPLES_DIR="${CMAKE_CURRENT_SOURCE_DIR}/examples/") + endforeach() endif() diff --git a/CMakePresets.json b/CMakePresets.json index 74fde8b..9896413 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -5,8 +5,9 @@ "name": "default", "binaryDir": "${sourceDir}/build", "cacheVariables": { - "CMAKE_TOOLCHAIN_FILE": "$env{HOME}/.vcpkg-clion/vcpkg/scripts/buildsystems/vcpkg.cmake", - "HYPERLIQUID_BUILD_EXAMPLES": "ON" + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "HYPERLIQUID_BUILD_EXAMPLES": "ON", + "HYPERLIQUID_BUILD_TESTS": "ON" } } ] diff --git a/examples/fetch_meta.cpp b/examples/fetch_meta.cpp deleted file mode 100644 index 5bd29e4..0000000 --- a/examples/fetch_meta.cpp +++ /dev/null @@ -1,48 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -class MetaPrinter : public hyperliquid::RestListener, public hyperliquid::InfoEndpointListener { -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); - 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() { - MetaPrinter printer; - hyperliquid::InfoApi api(hyperliquid::Environment::Mainnet, printer); - - std::cout << "Fetching meta from Hyperliquid... (dex=xyz)" << std::endl; - api.sendRequest(hyperliquid::InfoEndpointType::Meta, {{"dex", "xyz"}}); - - // Wait for the async response - while (!printer.done) { - std::this_thread::sleep_for(std::chrono::milliseconds(100)); - } - - return 0; -} diff --git a/examples/print_book.cpp b/examples/print_book.cpp deleted file mode 100644 index 45420e7..0000000 --- a/examples/print_book.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -class BookPrinter : public hyperliquid::WSMessageHandler, public hyperliquid::WebsocketListener { -public: - // hyperliquid::WebsocketListener - void onMessage(const std::string& message) override { - messageParser.crack(message, *this); - } - - void onConnected() override { - std::cout << "Connected" << std::endl; - } - - void onDisconnected(bool hasError, const std::string& errMsg) override { - std::cout << "Disconnected" << std::endl; - } - - // 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; - } - - void onBbo(const hyperliquid::BboUpdate& bbo) override { - std::cout << bbo.time << " [BBO] " << bbo.coin; - if (bbo.hasBid) - std::cout << " BID " << bbo.bid.sz << " @ " << bbo.bid.px; - if (bbo.hasAsk) - std::cout << " ASK " << bbo.ask.sz << " @ " << bbo.ask.px; - std::cout << std::endl; - } - - void onTrade(const hyperliquid::Trade& trade) override { - std::cout << trade.time << " [TRADE] " << trade.coin - << " " << trade.side - << " " << trade.sz << " @ " << trade.px << std::endl; - } - -private: - hyperliquid::WSMessageParser messageParser; -}; - -int main() { - BookPrinter printer; - hyperliquid::MarketData md(hyperliquid::Environment::Mainnet, printer); - - std::cout << "Subscribing to BTC l2Book + bbo + trades for 5 seconds..." << std::endl; - md.start(); - - md.subscribe(hyperliquid::SubscriptionType::L2Book, {{"coin", "BTC"}}); - md.subscribe(hyperliquid::SubscriptionType::Bbo, {{"coin", "BTC"}}); - md.subscribe(hyperliquid::SubscriptionType::Trades, {{"coin", "BTC"}}); - - std::this_thread::sleep_for(std::chrono::seconds(5)); - md.stop(); - - return 0; -} diff --git a/examples/rest_meta.cpp b/examples/rest_meta.cpp new file mode 100644 index 0000000..e0dfa9b --- /dev/null +++ b/examples/rest_meta.cpp @@ -0,0 +1,49 @@ +#include +#include +#include <../include/hyperliquid/config/Config.h> +#include + +int main() { + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::ApiConfig config; + config.env = hyperliquid::Environment::Mainnet; + + hyperliquid::RestApi api(config); + hyperliquid::RestApiMessageParser parser; + + spdlog::info("=== Spot ==="); + auto spotMeta = parser.parseSpotMeta(api.spotMeta()); + + spdlog::info("{} assets:", spotMeta.tokens.size()); + for (const auto& asset : spotMeta.tokens) { + spdlog::info(" {} szDecimals={}", asset.name, asset.szDecimals); + } + + auto dexes = parser.parsePerpDexs(api.perpDexs()); + + spdlog::info("=== Perps ==="); + auto defaultMeta = parser.parseMeta(api.meta()); + + spdlog::info("{} assets:", defaultMeta.universe.size()); + for (const auto& asset : defaultMeta.universe) { + spdlog::info(" {} szDecimals={} maxLeverage={}", asset.name, asset.szDecimals, asset.maxLeverage); + } + + spdlog::info("Found {} HIP-3 perp dexes:", dexes.dexes.size()); + for (const auto& dex : dexes.dexes) { + spdlog::info(" {} ({}) deployer={}", dex.name, dex.fullName, dex.deployer); + } + + for (const auto& dex : dexes.dexes) { + spdlog::info("=== {} ===", dex.name); + auto meta = parser.parseMeta(api.meta(dex.name)); + + spdlog::info("{} assets:", meta.universe.size()); + for (const auto& asset : meta.universe) { + spdlog::info(" {} szDecimals={} maxLeverage={}", asset.name, asset.szDecimals, asset.maxLeverage); + } + } + + return 0; +} diff --git a/examples/rest_orders.cpp b/examples/rest_orders.cpp new file mode 100644 index 0000000..bcd5993 --- /dev/null +++ b/examples/rest_orders.cpp @@ -0,0 +1,119 @@ +#include "test_config.h" + +#include +#include +#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; + config.env = hyperliquid::Environment::Testnet; + config.wallet = wallet; + + hyperliquid::RestApi api(config); + hyperliquid::RestApiMessageParser parser; + + // Place and cancel by oid + spdlog::info("=== Place and cancel by oid ==="); + + 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}; + + spdlog::info("Placing order..."); + auto placeResp1 = parser.parsePlaceOrder(api.placeOrder({order1}, hyperliquid::Grouping::Na)); + logPlaceOrder(placeResp1); + + uint64_t oid = 0; + for (const auto& s : placeResp1.statuses) + { + if (s.resting) oid = s.resting->oid; + else if (s.filled) oid = s.filled->oid; + } + + if (oid != 0) + { + spdlog::info("Cancelling oid={}...", oid); + hyperliquid::CancelRequest cancel1; + cancel1.asset = "ETH"; + cancel1.oid = oid; + logCancelOrder(parser.parseCancelOrder(api.cancelOrder({cancel1}))); + } + + // 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); + + 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..."); + 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; + 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 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; + logCancelOrder(parser.parseCancelOrder(api.cancelOrderByCloid({cancel2}))); + + return 0; +} diff --git a/examples/test_config.h b/examples/test_config.h new file mode 100644 index 0000000..d953ba1 --- /dev/null +++ b/examples/test_config.h @@ -0,0 +1,21 @@ +#pragma once + +#include +#include +#include + +#include +#include "hyperliquid/config/Config.h" + +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/examples/ws_book.cpp b/examples/ws_book.cpp new file mode 100644 index 0000000..2fced42 --- /dev/null +++ b/examples/ws_book.cpp @@ -0,0 +1,67 @@ +#include +#include +#include + +#include + +#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: + void onMessage(const std::string& message) override { + messageParser.crack(message, *this); + } + + void onConnected() override { + spdlog::info("Connected"); + } + + void onDisconnected(bool hasError, const std::string& errMsg) override { + spdlog::info("Disconnected"); + } + + // hyperliquid::WSMessageHandler + void onL2BookLevel(const hyperliquid::L2BookUpdate& book, const hyperliquid::PriceLevel& level) override { + 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::string msg = fmt::format("{} [BBO] {}", bbo.time, bbo.coin); + if (bbo.hasBid) + msg += fmt::format(" BID {} @ {}", bbo.bid.sz, bbo.bid.px); + if (bbo.hasAsk) + msg += fmt::format(" ASK {} @ {}", bbo.ask.sz, bbo.ask.px); + spdlog::info(msg); + } + + void onTrade(const hyperliquid::Trade& trade) override { + spdlog::info("{} [TRADE] {} {} {} @ {}", trade.time, trade.coin, trade.side, trade.sz, trade.px); + } + +private: + hyperliquid::WebsocketMessageParser messageParser; +}; + +int main() { + BookPrinter 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(); + + 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)); + websocket.stop(); + + 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 new file mode 100644 index 0000000..07b716f --- /dev/null +++ b/examples/ws_orders.cpp @@ -0,0 +1,145 @@ +#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 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 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; + + auto& first = response.statuses[0]; + if (first.resting) + { + 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 = modifyPx; + modified.size = 0.01; + modified.reduceOnly = false; + modified.limit = hyperliquid::LimitOrderType{hyperliquid::Tif::Alo}; + 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); + spdlog::info("Unsubscribing..."); + ws_->unsubscribe(hyperliquid::SubscriptionType::OrderUpdates); + ws_->unsubscribe(hyperliquid::SubscriptionType::UserFills); + 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 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, fill.isSnapshot); + } + + 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_; + std::string cloid_; + double midPx_ = 0.0; + bool orderPlaced_ = false; +}; + +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(15)); + websocket.stop(); + + return 0; +} 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/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..4122223 --- /dev/null +++ b/include/hyperliquid/rest/RestApi.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include + +#include "../types/RequestTypes.h" +#include "RestApiListener.h" +#include "hyperliquid/config/Config.h" + +namespace hyperliquid { + +class RestApi { +public: + explicit RestApi(const ApiConfig& config); + RestApi(const ApiConfig& config, RestApiListener& listener); + ~RestApi(); + + RestApi(const RestApi&) = delete; + RestApi& operator=(const RestApi&) = delete; + + 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); + 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); + void perpDexsAsync(); + 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; + std::unique_ptr impl_; +}; + +} 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..4509935 --- /dev/null +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include +#include "../types/RequestTypes.h" +#include "RestEndpointListener.h" + +namespace hyperliquid +{ + class RestApiMessageParser + { + public: + RestApiMessageParser(); + 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); + + SpotMetaResponse parseSpotMeta(const std::string& message); + 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; + std::unique_ptr impl_; + }; +} diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h new file mode 100644 index 0000000..1f4de3d --- /dev/null +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -0,0 +1,19 @@ +#pragma once + +#include "../types/ResponseTypes.h" + +namespace hyperliquid { + +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) {} + virtual void onCancelOrder(const CancelOrderResponse& response) {} + virtual void onModifyOrder(const ModifyOrderResponse& 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/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 8f42368..ff80dfe 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -1,6 +1,12 @@ #pragma once +#include +#include +#include +#include +#include #include #include +#include namespace hyperliquid { @@ -17,6 +23,8 @@ namespace hyperliquid Testnet }; + + inline const Endpoint& toWsEndpoint(Environment env) { static const Endpoint mainnet{"api.hyperliquid.xyz", "443", "/ws"}; @@ -64,6 +72,7 @@ namespace hyperliquid ActiveAssetData, UserTwapSliceFills, UserTwapHistory, + Unknown, }; inline std::string toString(SubscriptionType type) @@ -93,10 +102,35 @@ namespace hyperliquid } } - // --- REST info endpoint types --- + inline SubscriptionType stringToSubscriptionType(const 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 endpoint types --- - enum class InfoEndpointType + enum class RestEndpointType { + // Info endpoints (Perpetuals) Meta, MetaAndAssetCtxs, AllMids, @@ -107,23 +141,196 @@ namespace hyperliquid UserFillsByTime, OrderStatus, UserRateLimit, + PerpDexs, + // Info endpoints (Spot) + SpotMeta, + SpotMetaAndAssetCtxs, + SpotClearinghouseState, + SpotDeployState, + SpotPairDeployAuctionStatus, + TokenDetails, + + // 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::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"; + 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::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; + case RestEndpointType::ScheduleCancel: return true; + case RestEndpointType::ModifyOrder: return true; + case RestEndpointType::BatchModifyOrder: return true; + 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; + double triggerPx; + TpSl tpsl; + }; + + struct OrderRequest + { + std::string asset; + bool isBuy; + double price; + double 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 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 0ac9e9b..e352298 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -1,11 +1,31 @@ #pragma once #include +#include #include +#include + +#include "RequestTypes.h" namespace hyperliquid { - // --- Market Data types (no auth required) --- + + // --- Subscription types --- + + enum class SubscriptionMethod { Subscribe, Unsubscribe }; + + struct Subscription + { + SubscriptionType type; + }; + + struct SubscriptionResponse + { + SubscriptionMethod method; + Subscription subscription; + }; + + // --- Websocket data types (unauthenticated) --- enum class Side { Bid, Ask }; @@ -90,7 +110,55 @@ namespace hyperliquid double circulatingSupply; }; - // --- User / Trading types (require user address) --- + // --- 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 { @@ -113,7 +181,8 @@ namespace hyperliquid bool isLiquidation; std::string liquidatedUser; double liquidationMarkPx; - std::string liquidationMethod; + LiquidationMethod liquidationMethod; + bool isSnapshot; }; struct OrderUpdate @@ -126,7 +195,7 @@ namespace hyperliquid uint64_t timestamp; double origSz; std::string cloid; - std::string status; + OrderStatus status; uint64_t statusTimestamp; }; @@ -271,4 +340,104 @@ namespace hyperliquid { std::string notification; }; + + // --- Rest endpoint types (unauthenticated) --- + + struct AssetMeta + { + std::string name; + int szDecimals; + int maxLeverage; + }; + + struct MetaResponse + { + 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; + }; + + 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/include/hyperliquid/websocket/MarketData.h b/include/hyperliquid/websocket/MarketData.h deleted file mode 100644 index 7783dc9..0000000 --- a/include/hyperliquid/websocket/MarketData.h +++ /dev/null @@ -1,31 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "WebsocketListener.h" -#include "../types/RequestTypes.h" - -namespace hyperliquid -{ - class MarketData - { - public: - explicit MarketData(Environment env, WebsocketListener& listener); - ~MarketData(); - - MarketData(const MarketData&) = delete; - MarketData& operator=(const MarketData&) = delete; - - void subscribe(SubscriptionType type, const std::map& filters = {}); - void unsubscribe(SubscriptionType type, const std::map& filters = {}); - - void start(); - void stop(); - - private: - struct Impl; - std::unique_ptr impl_; - }; -} // namespace hyperliquid 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/WebsocketApi.h b/include/hyperliquid/websocket/WebsocketApi.h new file mode 100644 index 0000000..ec3432f --- /dev/null +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include "WebsocketApiListener.h" +#include "../types/RequestTypes.h" +#include "hyperliquid/config/Config.h" + +namespace hyperliquid +{ + class WebsocketApi + { + public: + 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 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(); + + private: + struct Impl; + std::unique_ptr impl_; + }; +} diff --git a/include/hyperliquid/websocket/WebsocketListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h similarity index 53% rename from include/hyperliquid/websocket/WebsocketListener.h rename to include/hyperliquid/websocket/WebsocketApiListener.h index fa61ee5..996939f 100644 --- a/include/hyperliquid/websocket/WebsocketListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -2,13 +2,16 @@ #include +#include "hyperliquid/types/RequestTypes.h" + namespace hyperliquid { -class WebsocketListener { +class WebsocketApiListener { public: - virtual ~WebsocketListener() = default; + 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/include/hyperliquid/websocket/WSMessageHandler.h b/include/hyperliquid/websocket/WebsocketMessageHandler.h similarity index 52% rename from include/hyperliquid/websocket/WSMessageHandler.h rename to include/hyperliquid/websocket/WebsocketMessageHandler.h index 5fe6189..ff4447c 100644 --- a/include/hyperliquid/websocket/WSMessageHandler.h +++ b/include/hyperliquid/websocket/WebsocketMessageHandler.h @@ -4,10 +4,11 @@ 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) {} virtual void onBbo(const BboUpdate& update) {} virtual void onTrade(const Trade& trade) {} @@ -15,6 +16,11 @@ class WSMessageHandler { 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/include/hyperliquid/websocket/WebsocketMessageParser.h b/include/hyperliquid/websocket/WebsocketMessageParser.h new file mode 100644 index 0000000..bc23419 --- /dev/null +++ b/include/hyperliquid/websocket/WebsocketMessageParser.h @@ -0,0 +1,27 @@ +#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); + void reset(); + + private: + struct Impl; + std::unique_ptr impl_; + }; +} diff --git a/src/config/Config.cpp b/src/config/Config.cpp new file mode 100644 index 0000000..a05852b --- /dev/null +++ b/src/config/Config.cpp @@ -0,0 +1,27 @@ +#include "../../include/hyperliquid/config/Config.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)); +} + +} diff --git a/src/config/Logger.h b/src/config/Logger.h new file mode 100644 index 0000000..cfdc1d5 --- /dev/null +++ b/src/config/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/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/messages/ExchangeRequestBuilder.h b/src/messages/ExchangeRequestBuilder.h new file mode 100644 index 0000000..6cafdb5 --- /dev/null +++ b/src/messages/ExchangeRequestBuilder.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include + +#include + +#include "../rest/SymbolMap.h" +#include "hyperliquid/rest/RestApi.h" +#include "hyperliquid/types/RequestTypes.h" + +namespace hyperliquid { + +class ExchangeRequestBuilder { +public: + ExchangeRequestBuilder() = default; + + void initializeMapping(const ApiConfig& config, RestApi* api); + + nlohmann::ordered_json placeOrder(const std::vector& orders, + 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; + SymbolMap symbolMap_; +}; + +} diff --git a/src/messages/InfoRequestBuilder.cpp b/src/messages/InfoRequestBuilder.cpp new file mode 100644 index 0000000..5cc721b --- /dev/null +++ b/src/messages/InfoRequestBuilder.cpp @@ -0,0 +1,27 @@ +#include "../messages/InfoRequestBuilder.h" + +namespace hyperliquid { + +nlohmann::ordered_json InfoRequestBuilder::spotMeta() +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::SpotMeta); + return body; +} + +nlohmann::ordered_json InfoRequestBuilder::meta(const std::optional& dex) +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::Meta); + if (dex) body["dex"] = *dex; + return body; +} + +nlohmann::ordered_json InfoRequestBuilder::perpDexs() +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::PerpDexs); + return body; +} + +} diff --git a/src/messages/InfoRequestBuilder.h b/src/messages/InfoRequestBuilder.h new file mode 100644 index 0000000..b195087 --- /dev/null +++ b/src/messages/InfoRequestBuilder.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include + +#include "hyperliquid/types/RequestTypes.h" + +namespace hyperliquid { + +class InfoRequestBuilder { +public: + 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/HttpSession.cpp b/src/rest/HttpSession.cpp new file mode 100644 index 0000000..8ef9eba --- /dev/null +++ b/src/rest/HttpSession.cpp @@ -0,0 +1,118 @@ +#include "HttpSession.h" + +#include +#include "../config/Logger.h" + +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) { + getLogger()->error("HttpSession shutdown error: {}", ec.message()); + } + }); +} + +} 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/InfoApi.cpp b/src/rest/InfoApi.cpp deleted file mode 100644 index 00d0c24..0000000 --- a/src/rest/InfoApi.cpp +++ /dev/null @@ -1,203 +0,0 @@ -#include "hyperliquid/rest/InfoApi.h" -#include "hyperliquid/rest/RestListener.h" - -#include -#include -#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; - -// Per-request session that lives through the async chain via shared_ptr -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) - : resolver_(net::make_strand(ioc)) - , stream_(net::make_strand(ioc), sslCtx) - , host_(host) - , port_(port) - , listener_(listener) - , type_(type) - { - } - - void run(const std::string& body) - { - req_.method(http::verb::post); - req_.target("/info"); - 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) { - std::cerr << "InfoApi resolve error: " << ec.message() << std::endl; - 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; - 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) { - std::cerr << "InfoApi connect error: " << ec.message() << std::endl; - 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) { - std::cerr << "InfoApi SSL handshake error: " << ec.message() << std::endl; - 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) { - std::cerr << "InfoApi write error: " << ec.message() << std::endl; - 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) { - std::cerr << "InfoApi read error: " << ec.message() << std::endl; - return; - } - - listener_.onMessage(res_.body(), type_); - - 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_; - RestListener& listener_; - InfoEndpointType type_; -}; - -// InfoApi pimpl -struct InfoApi::Impl { - net::io_context ioc; - net::executor_work_guard work; - ssl::context sslCtx; - std::thread thread; - std::string host; - std::string port; - RestListener& listener; - - Impl(Environment env, RestListener& listener) - : work(net::make_work_guard(ioc)) - , sslCtx(ssl::context::tlsv12_client) - , host(toInfoEndpoint(env).host) - , port(toInfoEndpoint(env).port) - , listener(listener) - { - 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(); - } -}; - -InfoApi::InfoApi(Environment env, RestListener& listener) - : impl_(std::make_unique(env, listener)) -{ -} - -InfoApi::~InfoApi() = default; - -void InfoApi::sendRequest(InfoEndpointType type, const std::map& params) -{ - nlohmann::json body; - body["type"] = toString(type); - for (const auto& [key, value] : params) { - body[key] = value; - } - - auto session = std::make_shared( - impl_->ioc, impl_->sslCtx, - impl_->host, impl_->port, - impl_->listener, type); - - session->run(body.dump()); -} - -} // namespace hyperliquid diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp new file mode 100644 index 0000000..8d34831 --- /dev/null +++ b/src/rest/RestApi.cpp @@ -0,0 +1,217 @@ +#include "hyperliquid/rest/RestApi.h" +#include "hyperliquid/rest/RestApiListener.h" +#include "hyperliquid/rest/RestApiMessageParser.h" +#include "HttpSession.h" +#include "../messages/InfoRequestBuilder.h" +#include "../messages/ExchangeRequestBuilder.h" +#include "SymbolMap.h" +#include "signing/Signing.h" + +#include +#include +#include +#include +#include + +#include + +#include + +#include "../config/Logger.h" +#include "hyperliquid/config/Config.h" + +namespace hyperliquid { + +namespace beast = boost::beast; +namespace net = boost::asio; +namespace ssl = boost::asio::ssl; + +static RestApiListener defaultListener; + +struct RestApi::Impl { + net::io_context ioc; + net::executor_work_guard work; + ssl::context sslCtx; + std::thread thread; + std::string host; + std::string port; + RestApiListener& listener; + const ApiConfig& config; + ExchangeRequestBuilder exchangeRequestBuilder; + + 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) + , config(config) + { + 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(); + } + + void signAndSend(RestEndpointType type, nlohmann::ordered_json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + auto prepared = Signing::prepareBody(config, type, std::move(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) { + getLogger()->error("RestApi: error for {}: {}", toString(type), ec.message()); + return; + } + listener.onMessage(responseBody, type); + }); + + session->run(serialized); + } + + std::string signAndSendSync(RestEndpointType type, nlohmann::ordered_json body, + const std::optional& vaultAddress = std::nullopt, + const std::optional& expiresAfter = std::nullopt) + { + auto prepared = Signing::prepareBody(config, type, std::move(body), vaultAddress, expiresAfter); + std::string serialized = prepared.dump(); + getLogger()->debug("{}", serialized); + + 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(const ApiConfig& config) + : impl_(std::make_unique(config, defaultListener)) +{ + if (!config.skipBuildingSymbolMap) { + impl_->exchangeRequestBuilder.initializeMapping(config, this); + } +} + +RestApi::RestApi(const ApiConfig& config, RestApiListener& listener) + : impl_(std::make_unique(config, listener)) +{ + if (!config.skipBuildingSymbolMap) { + impl_->exchangeRequestBuilder.initializeMapping(config, this); + } +} + +RestApi::~RestApi() = default; + +std::string RestApi::spotMeta() +{ + return impl_->signAndSendSync(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); +} + +std::string RestApi::meta(const std::optional& dex) +{ + return impl_->signAndSendSync(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); +} + +std::string RestApi::perpDexs() +{ + return impl_->signAndSendSync(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); +} + +std::string RestApi::placeOrder(const std::vector& orders, + Grouping grouping, + const std::optional& builder) +{ + return impl_->signAndSendSync(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::spotMetaAsync() +{ + impl_->signAndSend(RestEndpointType::SpotMeta, InfoRequestBuilder::spotMeta()); +} + +void RestApi::metaAsync(const std::optional& dex) +{ + impl_->signAndSend(RestEndpointType::Meta, InfoRequestBuilder::meta(dex)); +} + +void RestApi::perpDexsAsync() +{ + impl_->signAndSend(RestEndpointType::PerpDexs, InfoRequestBuilder::perpDexs()); +} + +void RestApi::placeOrderAsync(const std::vector& orders, + Grouping grouping, + const std::optional& builder) +{ + impl_->signAndSend(RestEndpointType::PlaceOrder, impl_->exchangeRequestBuilder.placeOrder(orders, grouping, builder)); +} + +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 new file mode 100644 index 0000000..780a8e2 --- /dev/null +++ b/src/rest/RestApiMessageParser.cpp @@ -0,0 +1,347 @@ +#include "hyperliquid/rest/RestApiMessageParser.h" +#include +#include "../config/Logger.h" + +namespace hyperliquid +{ + struct RestApiMessageParser::Impl + { + RestEndpointListener& listener; + simdjson::ondemand::parser parser; + simdjson::padded_string padded; + + explicit Impl(RestEndpointListener& listener) : listener(listener) {} + + void parse(const std::string& message, RestEndpointType type) + { + 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; + case RestEndpointType::CancelOrder: + case RestEndpointType::CancelOrderByCloid: + listener.onCancelOrder(parseCancelOrder(message)); + break; + case RestEndpointType::ModifyOrder: + case RestEndpointType::BatchModifyOrder: + listener.onModifyOrder(parseModifyOrder(message)); + break; + default: + getLogger()->error("RestMessageParser: unhandled RestEndpointType: {}", toString(type)); + 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) + { + 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; + } + + 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) + { + getLogger()->error("RestMessageParser: parse error in perpDexs: {}\n raw: {}", err.what(), message); + } + + 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) + { + getLogger()->error("RestMessageParser: parse error in spotMeta: {}\n raw: {}", e.what(), message); + } + + return response; + } + + MetaResponse parseMeta(const std::string& message) + { + MetaResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + auto universe = doc["universe"].get_array().value(); + for (auto entry : universe) + { + auto obj = entry.get_object().value(); + AssetMeta asset; + asset.name = std::string(obj["name"].get_string().value()); + asset.szDecimals = static_cast(obj["szDecimals"].get_int64().value()); + asset.maxLeverage = static_cast(obj["maxLeverage"].get_int64().value()); + response.universe.push_back(std::move(asset)); + } + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("RestMessageParser: parse error in meta: {}\n raw: {}", e.what(), message); + } + + return response; + } + }; + + static RestEndpointListener defaultEndpointListener; + + RestApiMessageParser::RestApiMessageParser() + : impl_(std::make_unique(defaultEndpointListener)) {} + + RestApiMessageParser::RestApiMessageParser(RestEndpointListener& listener) + : impl_(std::make_unique(listener)) {} + + RestApiMessageParser::~RestApiMessageParser() = default; + RestApiMessageParser::RestApiMessageParser(RestApiMessageParser&&) noexcept = default; + RestApiMessageParser& RestApiMessageParser::operator=(RestApiMessageParser&&) noexcept = default; + + void RestApiMessageParser::parse(const std::string& message, RestEndpointType type) + { + 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); + } + + 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/rest/RestMessageParser.cpp b/src/rest/RestMessageParser.cpp deleted file mode 100644 index b302f7b..0000000 --- a/src/rest/RestMessageParser.cpp +++ /dev/null @@ -1,68 +0,0 @@ -#include "hyperliquid/rest/RestMessageParser.h" -#include -#include - -namespace hyperliquid -{ - struct RestMessageParser::Impl - { - InfoEndpointListener& listener; - simdjson::ondemand::parser parser; - simdjson::padded_string padded; - - explicit Impl(InfoEndpointListener& listener) : listener(listener) {} - - void parse(const std::string& message, InfoEndpointType type) - { - switch (type) - { - case InfoEndpointType::Meta: - listener.onMeta(parseMeta(message)); - break; - default: - std::cerr << "RestMessageParser: unhandled InfoEndpointType: " - << toString(type) << std::endl; - break; - } - } - - MetaResponse parseMeta(const std::string& message) - { - MetaResponse response; - padded = simdjson::padded_string(message.data(), message.size()); - auto doc = parser.iterate(padded); - - try - { - auto universe = doc["universe"].get_array().value(); - for (auto entry : universe) - { - auto obj = entry.get_object().value(); - AssetMeta asset; - asset.name = std::string(obj["name"].get_string().value()); - asset.szDecimals = static_cast(obj["szDecimals"].get_int64().value()); - asset.maxLeverage = static_cast(obj["maxLeverage"].get_int64().value()); - response.universe.push_back(std::move(asset)); - } - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "RestMessageParser: parse error in meta: " << e.what() << std::endl; - } - - return response; - } - }; - - RestMessageParser::RestMessageParser(InfoEndpointListener& listener) - : impl_(std::make_unique(listener)) {} - - RestMessageParser::~RestMessageParser() = default; - RestMessageParser::RestMessageParser(RestMessageParser&&) noexcept = default; - RestMessageParser& RestMessageParser::operator=(RestMessageParser&&) noexcept = default; - - void RestMessageParser::parse(const std::string& message, InfoEndpointType type) - { - impl_->parse(message, type); - } -} // namespace hyperliquid 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..762a6bf --- /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_; +}; + +} diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp new file mode 100644 index 0000000..3c2d688 --- /dev/null +++ b/src/signing/Signing.cpp @@ -0,0 +1,80 @@ +#include "Signing.h" +#include "SigningHelpers.h" + +#include +#include + +namespace hyperliquid { + +nlohmann::ordered_json Signing::prepareBody( + const ApiConfig& config, + RestEndpointType type, + nlohmann::ordered_json body, + const std::optional& vaultAddress, + const std::optional& expiresAfter) +{ + if (vaultAddress) body["vaultAddress"] = *vaultAddress; + if (expiresAfter) body["expiresAfter"] = *expiresAfter; + + if (isAuthenticated(type)) + { + if (!config.wallet.has_value()) + { + 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 body; +} + +Signature Signing::signUserSignedAction( + const Wallet& wallet, + const nlohmann::ordered_json& action, + const std::vector& payloadTypes, + const std::string& primaryType, + bool isMainnet) +{ + uint64_t chainId = 0x66eee; + + auto structHash = SigningHelpers::userSignedStructHash(primaryType, payloadTypes, action); + auto domSep = SigningHelpers::domainSeparatorHash("HyperliquidSignTransaction", "1", chainId); + auto finalHash = SigningHelpers::eip712Hash(domSep, structHash); + + return SigningHelpers::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 = SigningHelpers::actionHash(action, vaultAddress, nonce, expiresAfter); + + std::string source = isMainnet ? "a" : "b"; + auto structHash = SigningHelpers::agentStructHash(source, hash); + auto domSep = SigningHelpers::domainSeparatorHash(); + auto finalHash = SigningHelpers::eip712Hash(domSep, structHash); + + return SigningHelpers::ecdsaSign(wallet, finalHash); +} + +} diff --git a/src/signing/Signing.h b/src/signing/Signing.h new file mode 100644 index 0000000..c432709 --- /dev/null +++ b/src/signing/Signing.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include + +#include "hyperliquid/config/Config.h" +#include "hyperliquid/types/RequestTypes.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 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/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/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/WSMessageParser.cpp b/src/websocket/WSMessageParser.cpp deleted file mode 100644 index 02bd2d5..0000000 --- a/src/websocket/WSMessageParser.cpp +++ /dev/null @@ -1,304 +0,0 @@ -#include "hyperliquid/websocket/WSMessageParser.h" -#include -#include -#include - -namespace hyperliquid -{ - struct WSMessageParser::Impl - { - simdjson::ondemand::parser parser; - simdjson::padded_string padded; - - static double toDouble(std::string_view sv) - { - double val = std::numeric_limits::quiet_NaN(); - std::from_chars(sv.data(), sv.data() + sv.size(), val); - return val; - } - - void crack(const std::string& message, WSMessageHandler& listener) - { - padded = simdjson::padded_string(message.data(), message.size()); - auto doc = parser.iterate(padded); - - try - { - std::string_view channel; - auto channelVal = doc["channel"]; - auto channelType = channelVal.type().value(); - if (channelType == simdjson::ondemand::json_type::string) - { - channel = channelVal.get_string().value(); - } - else if (channelType == simdjson::ondemand::json_type::object) - { - auto obj = channelVal.get_object().value(); - auto typeStr = obj["type"].get_string(); - if (typeStr.error()) return; - channel = typeStr.value(); - } - else - { - return; - } - - if (channel == "l2Book") - { - auto data = doc["data"].get_object().value(); - crackL2Book(data, listener); - } - else if (channel == "bbo") - { - auto data = doc["data"].get_object().value(); - crackBbo(data, listener); - } - else if (channel == "trades") - { - auto data = doc["data"].get_array().value(); - crackTrades(data, listener); - } - else if (channel == "candle") - { - auto data = doc["data"].get_array().value(); - crackCandles(data, listener); - } - else if (channel == "allMids") - { - auto data = doc["data"].get_object().value(); - crackAllMids(data, listener); - } - else if (channel == "activeAssetCtx" || channel == "activeSpotAssetCtx") - { - auto data = doc["data"].get_object().value(); - crackActiveAssetCtx(data, listener); - } - else if (channel == "error") - { - std::cerr << "Error: " << message << std::endl; - } - else - { - std::cerr << "Unhandled message: " << message << std::endl; - } - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "parse error: " << e.what() << std::endl; - } - } - - void crackL2Book(simdjson::ondemand::object& data, WSMessageHandler& listener) - { - L2BookUpdate book; - book.coin = std::string(data["coin"].get_string().value()); - book.time = data["time"].get_uint64().value(); - - auto levels = data["levels"].get_array().value(); - size_t sideIdx = 0; - for (auto side : levels) - { - if (sideIdx > 1) - { - std::cerr << "unexpected l2Book side index: " << sideIdx << std::endl; - break; - } - Side s = (sideIdx == 0) ? Side::Bid : Side::Ask; - for (auto entry : side.get_array().value()) - { - try - { - auto obj = entry.get_object().value(); - PriceLevel level; - level.side = s; - level.px = std::string(obj["px"].get_string().value()); - level.sz = std::string(obj["sz"].get_string().value()); - level.n = static_cast(obj["n"].get_int64().value()); - listener.onL2BookLevel(book, level); - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "parse error in l2Book level: " << e.what() << std::endl; - } - } - sideIdx++; - } - } - - void crackBbo(simdjson::ondemand::object& data, WSMessageHandler& listener) - { - BboUpdate update; - update.coin = std::string(data["coin"].get_string().value()); - update.time = data["time"].get_uint64().value(); - update.hasBid = false; - update.hasAsk = false; - - auto bbo = data["bbo"].get_array().value(); - size_t idx = 0; - for (auto entry : bbo) - { - bool isNull = (entry.type().value() == simdjson::ondemand::json_type::null); - if (idx == 0) - { - update.hasBid = !isNull; - if (!isNull) - { - auto obj = entry.get_object().value(); - update.bid.px = std::string(obj["px"].get_string().value()); - update.bid.sz = std::string(obj["sz"].get_string().value()); - update.bid.n = static_cast(obj["n"].get_int64().value()); - } - } - else - { - update.hasAsk = !isNull; - if (!isNull) - { - auto obj = entry.get_object().value(); - update.ask.px = std::string(obj["px"].get_string().value()); - update.ask.sz = std::string(obj["sz"].get_string().value()); - update.ask.n = static_cast(obj["n"].get_int64().value()); - } - } - idx++; - } - - listener.onBbo(update); - } - - void crackTrades(simdjson::ondemand::array& data, WSMessageHandler& listener) - { - Trade trade; - for (auto entry : data) - { - try - { - auto obj = entry.get_object().value(); - trade.coin = std::string(obj["coin"].get_string().value()); - auto sideStr = obj["side"].get_string().value(); - trade.side = sideStr.size() > 0 ? sideStr[0] : '?'; - trade.px = std::string(obj["px"].get_string().value()); - trade.sz = std::string(obj["sz"].get_string().value()); - trade.hash = std::string(obj["hash"].get_string().value()); - trade.time = obj["time"].get_uint64().value(); - trade.tid = obj["tid"].get_uint64().value(); - - trade.buyer.clear(); - trade.seller.clear(); - simdjson::ondemand::array users; - if (!obj["users"].get_array().get(users)) - { - size_t ui = 0; - for (auto u : users) - { - if (ui == 0) trade.buyer = std::string(u.get_string().value()); - else if (ui == 1) trade.seller = std::string(u.get_string().value()); - ui++; - } - } - - listener.onTrade(trade); - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "parse error in trade: " << e.what() << std::endl; - } - } - } - - void crackCandles(simdjson::ondemand::array& data, WSMessageHandler& listener) - { - Candle candle; - for (auto entry : data) - { - try - { - auto obj = entry.get_object().value(); - candle.coin = std::string(obj["s"].get_string().value()); - candle.interval = std::string(obj["i"].get_string().value()); - candle.openTime = obj["t"].get_uint64().value(); - candle.closeTime = obj["T"].get_uint64().value(); - candle.open = obj["o"].get_double().value(); - candle.close = obj["c"].get_double().value(); - candle.high = obj["h"].get_double().value(); - candle.low = obj["l"].get_double().value(); - candle.volume = obj["v"].get_double().value(); - candle.numTrades = static_cast(obj["n"].get_int64().value()); - listener.onCandle(candle); - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "parse error in candle: " << e.what() << std::endl; - } - } - } - - void crackAllMids(simdjson::ondemand::object& data, WSMessageHandler& listener) - { - auto mids = data["mids"].get_object().value(); - AllMidsEntry entry; - for (auto field : mids) - { - try - { - entry.coin = std::string(field.unescaped_key().value()); - entry.mid = toDouble(field.value().get_string().value()); - listener.onAllMidsEntry(entry); - } - catch (const simdjson::simdjson_error& e) - { - std::cerr << "parse error in allMids entry: " << e.what() << std::endl; - } - } - } - - void crackActiveAssetCtx(simdjson::ondemand::object& data, WSMessageHandler& 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); - - 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(); - 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(); - listener.onSpotAssetCtx(spot); - } - } - }; - - WSMessageParser::WSMessageParser() : impl_(std::make_unique()) {} - - WSMessageParser::~WSMessageParser() = default; - WSMessageParser::WSMessageParser(WSMessageParser&&) noexcept = default; - WSMessageParser& WSMessageParser::operator=(WSMessageParser&&) noexcept = default; - - void WSMessageParser::crack(const std::string& message, WSMessageHandler& listener) - { - impl_->crack(message, listener); - } -} // namespace hyperliquid diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp new file mode 100644 index 0000000..1a9c663 --- /dev/null +++ b/src/websocket/WebsocketApi.cpp @@ -0,0 +1,256 @@ +#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 + { + internal::WebsocketRunner ws; + 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(ApiConfig& config, WebsocketApiListener& listener) + : ws(config, *this), listener(listener), config(config), postRequestCounter(0) + { + config.skipBuildingSymbolMap = true; + RestApi restApi(config); + exchangeRequestBuilder.initializeMapping(config, &restApi); + } + + ~Impl() + { + ws.stop(); + if (thread.joinable()) thread.join(); + } + + 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 == "pong") + { + ws.onPongReceived(); + return; + } + + 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); + } + + void onWsConnected() override + { + listener.onConnected(); + } + + void onWsDisconnected(bool hasError, const std::string& errMsg) override + { + 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(ApiConfig& config, WebsocketApiListener& listener) : impl_( + std::make_unique(config, listener)) + { + } + + 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 = { + {"method", "subscribe"}, + { + "subscription", { + {"type", toString(type)}, + } + } + }; + 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; + } + impl_->ws.send(subscribeMsg.dump()); + } + + void WebsocketApi::unsubscribe(const SubscriptionType type, const std::map& filters) + { + nlohmann::json unsubscribeMsg = { + {"method", "unsubscribe"}, + { + "subscription", { + {"type", toString(type)}, + } + } + }; + 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; + } + 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(); + } + + 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 new file mode 100644 index 0000000..c59cda9 --- /dev/null +++ b/src/websocket/WebsocketMessageParser.cpp @@ -0,0 +1,534 @@ +#include "hyperliquid/websocket/WebsocketMessageParser.h" +#include +#include +#include "../config/Logger.h" + +#include "../../include/hyperliquid/types/ResponseTypes.h" + +namespace hyperliquid +{ + struct WebsocketMessageParser::Impl + { + simdjson::ondemand::parser parser; + simdjson::padded_string padded; + + static double toDouble(std::string_view sv) + { + double val = std::numeric_limits::quiet_NaN(); + std::from_chars(sv.data(), sv.data() + sv.size(), val); + return val; + } + + void crack(const std::string& message, WebsocketMessageHandler& listener) + { + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + std::string_view channel; + auto channelVal = doc["channel"]; + auto channelType = channelVal.type().value(); + if (channelType == simdjson::ondemand::json_type::string) + { + channel = channelVal.get_string().value(); + } + else if (channelType == simdjson::ondemand::json_type::object) + { + auto obj = channelVal.get_object().value(); + auto typeStr = obj["type"].get_string(); + if (typeStr.error()) return; + channel = typeStr.value(); + } + else + { + return; + } + + if (channel == "l2Book") + { + auto data = doc["data"].get_object().value(); + crackL2Book(data, listener); + } + else if (channel == "bbo") + { + auto data = doc["data"].get_object().value(); + crackBbo(data, listener); + } + else if (channel == "trades") + { + auto data = doc["data"].get_array().value(); + crackTrades(data, listener); + } + else if (channel == "candle") + { + auto data = doc["data"].get_array().value(); + crackCandles(data, listener); + } + else if (channel == "allMids") + { + auto data = doc["data"].get_object().value(); + crackAllMids(data, listener); + } + else if (channel == "activeAssetCtx" || channel == "activeSpotAssetCtx") + { + 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(); + crackSubscriptionResponse(data, listener); + } + else if (channel == "error") + { + getLogger()->error("Websocket error: {}", message); + } + else + { + getLogger()->warn("Unhandled message: {}", message); + } + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error: {}", e.what()); + } + } + + void crackSubscriptionResponse(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + SubscriptionResponse response; + if (data["method"].get_string().value() == "subscribe") + { + response.method = SubscriptionMethod::Subscribe; + } else + { + response.method = SubscriptionMethod::Unsubscribe; + } + Subscription subscription; + subscription.type = stringToSubscriptionType(std::string(data["subscription"].get_object().value()["type"].get_string().value())); + response.subscription = subscription; + listener.onSubscriptionResponse(response); + } + + + void crackL2Book(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + L2BookUpdate book; + book.coin = std::string(data["coin"].get_string().value()); + book.time = data["time"].get_uint64().value(); + + auto levels = data["levels"].get_array().value(); + size_t sideIdx = 0; + for (auto side : levels) + { + if (sideIdx > 1) + { + getLogger()->error("unexpected l2Book side index: {}", sideIdx); + break; + } + Side s = (sideIdx == 0) ? Side::Bid : Side::Ask; + for (auto entry : side.get_array().value()) + { + try + { + auto obj = entry.get_object().value(); + PriceLevel level; + level.side = s; + level.px = std::string(obj["px"].get_string().value()); + level.sz = std::string(obj["sz"].get_string().value()); + level.n = static_cast(obj["n"].get_int64().value()); + listener.onL2BookLevel(book, level); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in l2Book level: {}", e.what()); + } + } + sideIdx++; + } + } + + void crackBbo(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + BboUpdate update; + update.coin = std::string(data["coin"].get_string().value()); + update.time = data["time"].get_uint64().value(); + update.hasBid = false; + update.hasAsk = false; + + auto bbo = data["bbo"].get_array().value(); + size_t idx = 0; + for (auto entry : bbo) + { + bool isNull = (entry.type().value() == simdjson::ondemand::json_type::null); + if (idx == 0) + { + update.hasBid = !isNull; + if (!isNull) + { + auto obj = entry.get_object().value(); + update.bid.px = std::string(obj["px"].get_string().value()); + update.bid.sz = std::string(obj["sz"].get_string().value()); + update.bid.n = static_cast(obj["n"].get_int64().value()); + } + } + else + { + update.hasAsk = !isNull; + if (!isNull) + { + auto obj = entry.get_object().value(); + update.ask.px = std::string(obj["px"].get_string().value()); + update.ask.sz = std::string(obj["sz"].get_string().value()); + update.ask.n = static_cast(obj["n"].get_int64().value()); + } + } + idx++; + } + + listener.onBbo(update); + } + + void crackTrades(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) + { + Trade trade; + for (auto entry : data) + { + try + { + auto obj = entry.get_object().value(); + trade.coin = std::string(obj["coin"].get_string().value()); + auto sideStr = obj["side"].get_string().value(); + trade.side = sideStr.size() > 0 ? sideStr[0] : '?'; + trade.px = std::string(obj["px"].get_string().value()); + trade.sz = std::string(obj["sz"].get_string().value()); + trade.hash = std::string(obj["hash"].get_string().value()); + trade.time = obj["time"].get_uint64().value(); + trade.tid = obj["tid"].get_uint64().value(); + + trade.buyer.clear(); + trade.seller.clear(); + simdjson::ondemand::array users; + if (!obj["users"].get_array().get(users)) + { + size_t ui = 0; + for (auto u : users) + { + if (ui == 0) trade.buyer = std::string(u.get_string().value()); + else if (ui == 1) trade.seller = std::string(u.get_string().value()); + ui++; + } + } + + listener.onTrade(trade); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in trade: {}", e.what()); + } + } + } + + void crackCandles(simdjson::ondemand::array& data, WebsocketMessageHandler& listener) + { + Candle candle; + for (auto entry : data) + { + try + { + auto obj = entry.get_object().value(); + candle.coin = std::string(obj["s"].get_string().value()); + candle.interval = std::string(obj["i"].get_string().value()); + candle.openTime = obj["t"].get_uint64().value(); + candle.closeTime = obj["T"].get_uint64().value(); + candle.open = obj["o"].get_double().value(); + candle.close = obj["c"].get_double().value(); + candle.high = obj["h"].get_double().value(); + candle.low = obj["l"].get_double().value(); + candle.volume = obj["v"].get_double().value(); + candle.numTrades = static_cast(obj["n"].get_int64().value()); + listener.onCandle(candle); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in candle: {}", e.what()); + } + } + } + + void crackAllMids(simdjson::ondemand::object& data, WebsocketMessageHandler& listener) + { + auto mids = data["mids"].get_object().value(); + AllMidsEntry entry; + for (auto field : mids) + { + try + { + entry.coin = std::string(field.unescaped_key().value()); + entry.mid = toDouble(field.value().get_string().value()); + listener.onAllMidsEntry(entry); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("parse error in allMids entry: {}", e.what()); + } + } + } + + // 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(); + + std::string_view fundingStr; + bool isPerp = !ctx["funding"].get_string().get(fundingStr); + + if (isPerp) + { + PerpAssetCtx perp; + perp.coin = coin; + 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 = 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, bool isSnapshot) + { + 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; + } + + fill.isSnapshot = isSnapshot; + 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) + { + 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, isSnapshot); + } + 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, false); + } + 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()) {} + + WebsocketMessageParser::~WebsocketMessageParser() = default; + WebsocketMessageParser::WebsocketMessageParser(WebsocketMessageParser&&) noexcept = default; + WebsocketMessageParser& WebsocketMessageParser::operator=(WebsocketMessageParser&&) noexcept = default; + + void WebsocketMessageParser::crack(const std::string& message, WebsocketMessageHandler& listener) + { + impl_->crack(message, listener); + } + + void WebsocketMessageParser::reset() + { + } +} // namespace hyperliquid diff --git a/src/websocket/WSRunner.cpp b/src/websocket/WebsocketRunner.cpp similarity index 70% rename from src/websocket/WSRunner.cpp rename to src/websocket/WebsocketRunner.cpp index cafca69..1ee3f30 100644 --- a/src/websocket/WSRunner.cpp +++ b/src/websocket/WebsocketRunner.cpp @@ -1,30 +1,37 @@ -#include "WSRunner.h" -#include +#include "WebsocketRunner.h" #include +#include "../config/Logger.h" namespace hyperliquid { namespace internal { -WSRunner::WSRunner(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); } -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::onPongReceived() { + lastPongTime_ = std::chrono::steady_clock::now(); + pendingPong_ = false; +} + +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_) { @@ -33,12 +40,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,16 +53,16 @@ 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; + getLogger()->error("resolve error: {}", ec.message()); scheduleReconnect(); return; } @@ -68,16 +75,16 @@ 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; + 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; } @@ -88,9 +95,9 @@ 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; + getLogger()->error("SSL handshake error: {}", ec.message()); scheduleReconnect(); return; } @@ -106,9 +113,9 @@ 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; + getLogger()->error("WebSocket handshake error: {}", ec.message()); scheduleReconnect(); return; } @@ -129,18 +136,18 @@ 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 - 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 +159,12 @@ void WSRunner::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(); @@ -173,7 +180,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,9 +193,9 @@ 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; + getLogger()->error("write error: {}", ec.message()); return; } @@ -196,20 +203,20 @@ 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; - std::cout << "Waiting to close websocket..." << std::endl; + getLogger()->info("Waiting to close websocket..."); beast::get_lowest_layer(*ws_).cancel(); } -void WSRunner::scheduleReconnect() { +void WebsocketRunner::scheduleReconnect() { if (stopping_) return; 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) { @@ -219,14 +226,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 +243,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,13 +254,13 @@ void WSRunner::startPingTimer() { }); } -void WSRunner::doPing() { +void WebsocketRunner::doPing() { if (stopping_ || !connected_) return; 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(); @@ -262,15 +269,11 @@ void WSRunner::doPing() { } pendingPong_ = true; - ws_->async_ping({}, [this](beast::error_code ec) { - if (ec) { - if (!stopping_) { - std::cerr << "Ping failed: " << ec.message() << std::endl; - 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/WSRunner.h b/src/websocket/WebsocketRunner.h similarity index 91% rename from src/websocket/WSRunner.h rename to src/websocket/WebsocketRunner.h index 44b98a0..d7d17f8 100644 --- a/src/websocket/WSRunner.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 { @@ -35,12 +34,13 @@ 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(ApiConfig& config, WSListener& listener); + ~WebsocketRunner(); void send(const std::string& message); + void onPongReceived(); net::io_context& getIoContext(); diff --git a/tests/signing_test.cpp b/tests/signing_test.cpp new file mode 100644 index 0000000..b057ddf --- /dev/null +++ b/tests/signing_test.cpp @@ -0,0 +1,303 @@ +// Signing tests ported from hyperliquid-python-sdk +#include + +#include "signing/Signing.h" +#include "signing/SigningHelpers.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)); +} + +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; +} + +// 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); + if (s.find('.') != std::string::npos) + { + size_t last = s.find_last_not_of('0'); + if (s[last] == '.') + s = s.substr(0, last); + else + s = s.substr(0, last + 1); + } + return s; +} + +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; + + auto orderWire = makeLimitOrderWire(4, true, 1670.1, 0.0147, "Ioc"); + auto action = orderWireToAction(orderWire); + auto hash = SigningHelpers::actionHash(action, std::nullopt, timestamp, std::nullopt); + + std::string connectionIdHex = SigningHelpers::toHexPadded(hash.data(), 32); + EXPECT_EQ(connectionIdHex, "0x0fcbeda5ae3c4950a548021552a4fea2226858c4453571bf3f24ba017eac2908"); +} + +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(SigningTest, L1ActionSigningOrderMatches) +{ + auto wallet = testWallet(); + auto orderWire = makeLimitOrderWire(1, true, 100, 100, "Gtc"); + 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(SigningTest, L1ActionSigningOrderWithCloidMatches) +{ + auto wallet = testWallet(); + 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); + 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(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(SigningTest, L1ActionSigningTpslOrderMatches) +{ + auto wallet = testWallet(); + + nlohmann::ordered_json orderWire; + orderWire["a"] = 1; + orderWire["b"] = true; + orderWire["p"] = floatToWire(100); + orderWire["s"] = floatToWire(100); + orderWire["r"] = false; + orderWire["t"] = {{"trigger", {{"isMarket", true}, {"triggerPx", floatToWire(103)}, {"tpsl", "sl"}}}}; + + 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(SigningTest, FloatToIntForHashing) +{ + EXPECT_EQ(floatToIntForHashing(0.00001231), 1231ULL); + EXPECT_EQ(floatToIntForHashing(1.033), 103300000ULL); +} + +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(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(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(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(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); +} diff --git a/vcpkg.json b/vcpkg.json new file mode 100644 index 0000000..8a70a44 --- /dev/null +++ b/vcpkg.json @@ -0,0 +1,14 @@ +{ + "name": "hyperliquid-sdk-cpp", + "version": "0.1.0", + "dependencies": [ + "openssl", + "boost-asio", + "boost-beast", + "boost-system", + "simdjson", + "nlohmann-json", + "spdlog", + "gtest" + ] +}