diff --git a/CMakeLists.txt b/CMakeLists.txt index ad35ffb..d0ec630 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -195,6 +195,7 @@ if(HYPERLIQUID_BUILD_TESTS) hyperliquid_add_test(transfers_test) hyperliquid_add_test(misc_info_test) hyperliquid_add_test(user_dex_abstraction_test) + hyperliquid_add_test(user_portfolio_margin_test) hyperliquid_add_test(websocket_api_exchange_test) hyperliquid_add_test(websocket_api_dispatch_test) hyperliquid_add_test(rest_abstraction_test) diff --git a/README.md b/README.md index 9a76cba..2a07e94 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | General | `borrowLendReserveState` | ✅ | `RestApi::borrowLendReserveState` | | General | `allBorrowLendReserveStates` | ✅ | `RestApi::allBorrowLendReserveStates` | | General | `approvedBuilders` | ✅ | `RestApi::approvedBuilders` | -| General | `exchangeStatus` | ⬜ | | +| General | `exchangeStatus` | ✅ | `RestApi::exchangeStatus` | | General | `extraAgents` | ⬜ | | | General | `gossipPriorityAuctionStatus` | ⬜ | | | General | `gossipRootIps` | ⬜ | | @@ -161,7 +161,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | Spot / Outcomes | `outcomeDeployerLimits` | ⬜ | | | Spot / Outcomes | `outcomeTemplates` | ⬜ | | -58 of 78 documented info endpoints implemented. One (`tokenDetails`) has a `RestEndpointType` enum value reserved but no request builder or method yet. +59 of 78 documented info endpoints implemented. One (`tokenDetails`) has a `RestEndpointType` enum value reserved but no request builder or method yet. ### Exchange actions (`/exchange`) @@ -231,12 +231,12 @@ Legend: ✅ implemented — ⬜ not yet implemented. | `subAccountTransfer` | ✅ | `RestApi::subAccountTransfer` | | `topUpIsolatedOnlyMargin` | ⬜ | | | `userOutcome` | ⬜ | | -| `userPortfolioMargin` | ⬜ | | +| `userPortfolioMargin` | ✅ | `RestApi::userPortfolioMargin` | | `validatorL1Stream` | ⬜ | | | `vaultDistribute` | ⬜ | | | `vaultModify` | ⬜ | | -36 of 68 documented exchange actions implemented on REST (`RestApi`). `WebsocketApi` covers a smaller subset — `placeOrder`, `cancelOrder`, `cancelOrderByCloid`, `scheduleCancel`, `modifyOrder`, `batchModifyOrder` — plus posting `meta`/`spotMeta`/`outcomeMeta`/`perpDexs` info reads over the socket; the newer transfer/staking/TWAP actions are REST-only so far. +39 of 68 documented exchange actions implemented on REST (`RestApi`). `WebsocketApi` covers a smaller subset — `placeOrder`, `cancelOrder`, `cancelOrderByCloid`, `scheduleCancel`, `modifyOrder`, `batchModifyOrder` — plus posting `meta`/`spotMeta`/`outcomeMeta`/`perpDexs` info reads over the socket; the newer transfer/staking/TWAP actions are REST-only so far. `perpDeploy` is a large multi-variant action (16 sub-actions sharing `"type": "perpDeploy"`); only `registerAsset2` (deploying a new HIP-3 perp asset, optionally creating a new dex) is implemented. The other 15 variants (`registerAsset`, `setOracle`, `setFundingMultipliers`, `setFundingInterestRates`, `haltTrading`, `setMarginTableIds`, `insertMarginTable`, `setFeeRecipient`, `setOpenInterestCaps`, `setSubDeployers`, `setMarginModes`, `setFeeScale`, `setGrowthModes`, `setPerpAnnotation`, `disableDex`) are post-deployment admin/config actions for an already-deployed dex and are not yet implemented. diff --git a/examples/rest_info.cpp b/examples/rest_info.cpp index c8f2a74..d0e4a5f 100644 --- a/examples/rest_info.cpp +++ b/examples/rest_info.cpp @@ -38,6 +38,15 @@ int main() spdlog::info(" user={} isolatedAsset={} marginAvailable=[{}, {}]", p.user, p.isolatedAsset, p.marginAvailable[0], p.marginAvailable[1]); + // Cheap, no-wallet operational status check. Confirmed live against both testnet and + // mainnet: {"specialStatuses":null,"time":} - specialStatuses is null absent any active + // special status. + spdlog::info("=== exchangeStatus ==="); + auto status = api.exchangeStatus(); + spdlog::info("time={} specialStatuses={}", status.time, status.specialStatuses.size()); + for (const auto& s : status.specialStatuses) + spdlog::info(" {}", s); + uint64_t now = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); diff --git a/examples/rest_misc_actions.cpp b/examples/rest_misc_actions.cpp index 403a356..2e23080 100644 --- a/examples/rest_misc_actions.cpp +++ b/examples/rest_misc_actions.cpp @@ -59,5 +59,16 @@ int main() evmReq.data = "0x"; logSimpleResponse("sendToEvmWithData", api.sendToEvmWithData(evmReq)); + // Enables/disables portfolio margin mode for the calling account. EIP-712 user-signed action + // (see CONTRIBUTING.md) - request shape (user, enabled) cross-checked against + // nktkas/hyperliquid's userPortfolioMargin.ts, not independently confirmed against a live + // response in this environment (no funded testnet wallet available here); the generic + // {status,response} shape it's parsed with is already exercised by other exchange actions + // above (e.g. sendToEvmWithData). + hyperliquid::UserPortfolioMarginRequest portfolioMarginReq; + portfolioMarginReq.user = wallet.accountAddress; + portfolioMarginReq.enabled = true; + logSimpleResponse("userPortfolioMargin", api.userPortfolioMargin(portfolioMarginReq)); + return 0; } diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index 5244fda..09dcba2 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -120,6 +120,7 @@ class RestApi { LiquidatableResponse liquidatable(); UserDexAbstractionResponse userDexAbstractionState(const std::string& user); UserAbstractionResponse userAbstraction(const std::string& user); + ExchangeStatusResponse exchangeStatus(); PlaceOrderResponse placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder = std::nullopt, @@ -178,6 +179,7 @@ class RestApi { DelegatorRewardsResponse delegatorRewards(const std::string& user); SimpleResponse sendToEvmWithData(const SendToEvmWithDataRequest& request); SimpleResponse userDexAbstraction(const UserDexAbstractionRequest& request); + SimpleResponse userPortfolioMargin(const UserPortfolioMarginRequest& request); SimpleResponse agentSendAsset(const AgentSendAssetRequest& request, const std::optional& vaultAddress = std::nullopt); SimpleResponse reserveRequestWeight(const ReserveRequestWeightRequest& request, @@ -265,6 +267,7 @@ class RestApi { void liquidatableAsync(); void userDexAbstractionStateAsync(const std::string& user); void userAbstractionAsync(const std::string& user); + void exchangeStatusAsync(); void placeOrderAsync(const std::vector& orders, Grouping grouping, const std::optional& builder = std::nullopt, @@ -317,6 +320,7 @@ class RestApi { void delegatorRewardsAsync(const std::string& user); void sendToEvmWithDataAsync(const SendToEvmWithDataRequest& request); void userDexAbstractionAsync(const UserDexAbstractionRequest& request); + void userPortfolioMarginAsync(const UserPortfolioMarginRequest& request); void agentSendAssetAsync(const AgentSendAssetRequest& request, const std::optional& vaultAddress = std::nullopt); void reserveRequestWeightAsync(const ReserveRequestWeightRequest& request, diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h index d53aec7..c0a702a 100644 --- a/include/hyperliquid/rest/RestApiMessageParser.h +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -97,6 +97,7 @@ namespace hyperliquid DelegatorRewardsResponse parseDelegatorRewards(const std::string& message); UserDexAbstractionResponse parseUserDexAbstractionState(const std::string& message); UserAbstractionResponse parseUserAbstraction(const std::string& message); + ExchangeStatusResponse parseExchangeStatus(const std::string& message); private: struct Impl; diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h index 1493343..6f09540 100644 --- a/include/hyperliquid/rest/RestEndpointListener.h +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -81,6 +81,7 @@ class RestEndpointListener { virtual void onLiquidatable(const LiquidatableResponse&, std::optional = std::nullopt) {} virtual void onUserDexAbstractionState(const UserDexAbstractionResponse&, std::optional = std::nullopt) {} virtual void onUserAbstraction(const UserAbstractionResponse&, std::optional = std::nullopt) {} + virtual void onExchangeStatus(const ExchangeStatusResponse&, std::optional = std::nullopt) {} }; } diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 7c9b6ff..8381373 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -170,6 +170,7 @@ namespace hyperliquid UserFees, MaxBuilderFee, ApprovedBuilders, + ExchangeStatus, VaultDetails, UserVaultEquities, Portfolio, @@ -249,6 +250,7 @@ namespace hyperliquid TokenDelegate, SendToEvmWithData, UserDexAbstraction, + UserPortfolioMargin, AgentSendAsset, ReserveRequestWeight, Noop, @@ -281,6 +283,7 @@ namespace hyperliquid case RestEndpointType::UserFees: return "userFees"; case RestEndpointType::MaxBuilderFee: return "maxBuilderFee"; case RestEndpointType::ApprovedBuilders: return "approvedBuilders"; + case RestEndpointType::ExchangeStatus: return "exchangeStatus"; case RestEndpointType::VaultDetails: return "vaultDetails"; case RestEndpointType::UserVaultEquities: return "userVaultEquities"; case RestEndpointType::Portfolio: return "portfolio"; @@ -357,6 +360,7 @@ namespace hyperliquid case RestEndpointType::TokenDelegate: return "tokenDelegate"; case RestEndpointType::SendToEvmWithData: return "sendToEvmWithData"; case RestEndpointType::UserDexAbstraction: return "userDexAbstraction"; + case RestEndpointType::UserPortfolioMargin: return "userPortfolioMargin"; case RestEndpointType::AgentSendAsset: return "agentSendAsset"; case RestEndpointType::ReserveRequestWeight: return "reserveRequestWeight"; case RestEndpointType::Noop: return "noop"; @@ -391,6 +395,7 @@ namespace hyperliquid case RestEndpointType::UserFees: return false; case RestEndpointType::MaxBuilderFee: return false; case RestEndpointType::ApprovedBuilders: return false; + case RestEndpointType::ExchangeStatus: return false; case RestEndpointType::VaultDetails: return false; case RestEndpointType::UserVaultEquities: return false; case RestEndpointType::Portfolio: return false; @@ -467,6 +472,7 @@ namespace hyperliquid case RestEndpointType::TokenDelegate: return true; case RestEndpointType::SendToEvmWithData: return true; case RestEndpointType::UserDexAbstraction: return true; + case RestEndpointType::UserPortfolioMargin: return true; case RestEndpointType::AgentSendAsset: return true; case RestEndpointType::ReserveRequestWeight: return true; case RestEndpointType::Noop: return true; @@ -480,8 +486,8 @@ namespace hyperliquid } // usdClassTransfer/sendAsset/usdSend/spotSend/withdraw3/approveBuilderFee/userSetAbstraction, - // the staking actions (cDeposit/cWithdraw/tokenDelegate), sendToEvmWithData, and - // userDexAbstraction are EIP-712 user-signed actions (see + // the staking actions (cDeposit/cWithdraw/tokenDelegate), sendToEvmWithData, + // userDexAbstraction, and userPortfolioMargin are EIP-712 user-signed actions (see // Signing::prepareUserSignedActionBody), not L1 actions. All other authenticated actions here // (including agentSendAsset/reserveRequestWeight/noop) are L1 actions signed with the // agent/master key directly. @@ -501,6 +507,7 @@ namespace hyperliquid case RestEndpointType::TokenDelegate: case RestEndpointType::SendToEvmWithData: case RestEndpointType::UserDexAbstraction: + case RestEndpointType::UserPortfolioMargin: return true; default: return false; @@ -912,6 +919,12 @@ namespace hyperliquid bool enabled; }; + struct UserPortfolioMarginRequest + { + std::string user; + bool enabled; + }; + struct AgentSendAssetRequest { std::string destination; diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 8c248f5..43cf27b 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -804,6 +804,13 @@ namespace hyperliquid std::vector coins; }; + struct ExchangeStatusResponse + { + // Empty when the wire value is null (the common case - no active special statuses). + std::vector specialStatuses; + uint64_t time = 0; + }; + struct PredictedFundingVenue { std::string venue; diff --git a/include/hyperliquid/websocket/WebsocketApi.h b/include/hyperliquid/websocket/WebsocketApi.h index b585591..3a0d2b5 100644 --- a/include/hyperliquid/websocket/WebsocketApi.h +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -146,6 +146,7 @@ namespace hyperliquid void userDexAbstractionState(const std::string& user, std::optional correlationId = std::nullopt); void userAbstraction(const std::string& user, std::optional correlationId = std::nullopt); + void exchangeStatus(std::optional correlationId = std::nullopt); void delegations(const std::string& user, std::optional correlationId = std::nullopt); void delegatorSummary(const std::string& user, std::optional correlationId = std::nullopt); @@ -264,6 +265,8 @@ namespace hyperliquid void userDexAbstraction(const UserDexAbstractionRequest& request, std::optional correlationId = std::nullopt); + void userPortfolioMargin(const UserPortfolioMarginRequest& request, + std::optional correlationId = std::nullopt); void start(); void stop(); diff --git a/include/hyperliquid/websocket/WebsocketApiListener.h b/include/hyperliquid/websocket/WebsocketApiListener.h index ba932cb..e093dff 100644 --- a/include/hyperliquid/websocket/WebsocketApiListener.h +++ b/include/hyperliquid/websocket/WebsocketApiListener.h @@ -76,6 +76,7 @@ class WebsocketApiListener { virtual void onPostResponse(const DelegatorSummaryResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const DelegatorHistoryResponse&, std::optional = std::nullopt) {} virtual void onPostResponse(const DelegatorRewardsResponse&, std::optional = std::nullopt) {} + virtual void onPostResponse(const ExchangeStatusResponse&, std::optional = std::nullopt) {} // Every exchange action shares this response shape, so RestEndpointType disambiguates. virtual void onPostResponse(RestEndpointType, const SimpleResponse&, diff --git a/src/messages/ExchangeRequestBuilder.cpp b/src/messages/ExchangeRequestBuilder.cpp index fa10bfc..e262d61 100644 --- a/src/messages/ExchangeRequestBuilder.cpp +++ b/src/messages/ExchangeRequestBuilder.cpp @@ -736,6 +736,18 @@ namespace hyperliquid return body; } + nlohmann::ordered_json ExchangeRequestBuilder::userPortfolioMargin(const UserPortfolioMarginRequest& request) const + { + nlohmann::ordered_json action; + action["type"] = "userPortfolioMargin"; + action["user"] = request.user; + action["enabled"] = request.enabled; + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + nlohmann::ordered_json ExchangeRequestBuilder::agentSendAsset(const AgentSendAssetRequest& request) const { nlohmann::ordered_json action; diff --git a/src/messages/ExchangeRequestBuilder.h b/src/messages/ExchangeRequestBuilder.h index ec10b70..6cb5a1a 100644 --- a/src/messages/ExchangeRequestBuilder.h +++ b/src/messages/ExchangeRequestBuilder.h @@ -89,6 +89,8 @@ class ExchangeRequestBuilder { nlohmann::ordered_json userDexAbstraction(const UserDexAbstractionRequest& request) const; + nlohmann::ordered_json userPortfolioMargin(const UserPortfolioMarginRequest& request) const; + nlohmann::ordered_json agentSendAsset(const AgentSendAssetRequest& request) const; nlohmann::ordered_json reserveRequestWeight(const ReserveRequestWeightRequest& request) const; diff --git a/src/messages/InfoRequestBuilder.cpp b/src/messages/InfoRequestBuilder.cpp index 573280d..2803829 100644 --- a/src/messages/InfoRequestBuilder.cpp +++ b/src/messages/InfoRequestBuilder.cpp @@ -515,4 +515,11 @@ nlohmann::ordered_json InfoRequestBuilder::userAbstraction(const std::string& us return body; } +nlohmann::ordered_json InfoRequestBuilder::exchangeStatus() +{ + nlohmann::ordered_json body; + body["type"] = toString(RestEndpointType::ExchangeStatus); + return body; +} + } diff --git a/src/messages/InfoRequestBuilder.h b/src/messages/InfoRequestBuilder.h index a08fd57..262a3c4 100644 --- a/src/messages/InfoRequestBuilder.h +++ b/src/messages/InfoRequestBuilder.h @@ -102,6 +102,8 @@ class InfoRequestBuilder { static nlohmann::ordered_json userDexAbstractionState(const std::string& user); static nlohmann::ordered_json userAbstraction(const std::string& user); + + static nlohmann::ordered_json exchangeStatus(); }; } // namespace hyperliquid diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 8e2967a..85fa072 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -510,6 +510,12 @@ UserAbstractionResponse RestApi::userAbstraction(const std::string& user) impl_->signAndSendSync(RestEndpointType::UserAbstraction, InfoRequestBuilder::userAbstraction(user))); } +ExchangeStatusResponse RestApi::exchangeStatus() +{ + return RestApiMessageParser().parseExchangeStatus( + impl_->signAndSendSync(RestEndpointType::ExchangeStatus, InfoRequestBuilder::exchangeStatus())); +} + PlaceOrderResponse RestApi::placeOrder(const std::vector& orders, Grouping grouping, const std::optional& builder, @@ -800,6 +806,13 @@ SimpleResponse RestApi::userDexAbstraction(const UserDexAbstractionRequest& requ impl_->exchangeRequestBuilder.userDexAbstraction(request))); } +SimpleResponse RestApi::userPortfolioMargin(const UserPortfolioMarginRequest& request) +{ + return RestApiMessageParser().parseSimpleResponse( + impl_->signAndSendSync(RestEndpointType::UserPortfolioMargin, + impl_->exchangeRequestBuilder.userPortfolioMargin(request))); +} + SimpleResponse RestApi::agentSendAsset(const AgentSendAssetRequest& request, const std::optional& vaultAddress) { @@ -1124,6 +1137,11 @@ void RestApi::userAbstractionAsync(const std::string& user) impl_->signAndSend(RestEndpointType::UserAbstraction, InfoRequestBuilder::userAbstraction(user)); } +void RestApi::exchangeStatusAsync() +{ + impl_->signAndSend(RestEndpointType::ExchangeStatus, InfoRequestBuilder::exchangeStatus()); +} + void RestApi::placeOrderAsync(const std::vector& orders, Grouping grouping, const std::optional& builder, @@ -1375,6 +1393,12 @@ void RestApi::userDexAbstractionAsync(const UserDexAbstractionRequest& request) impl_->exchangeRequestBuilder.userDexAbstraction(request)); } +void RestApi::userPortfolioMarginAsync(const UserPortfolioMarginRequest& request) +{ + impl_->signAndSend(RestEndpointType::UserPortfolioMargin, + impl_->exchangeRequestBuilder.userPortfolioMargin(request)); +} + void RestApi::agentSendAssetAsync(const AgentSendAssetRequest& request, const std::optional& vaultAddress) { diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index a373d44..ecf0155 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -213,6 +213,7 @@ namespace hyperliquid case RestEndpointType::TokenDelegate: case RestEndpointType::SendToEvmWithData: case RestEndpointType::UserDexAbstraction: + case RestEndpointType::UserPortfolioMargin: case RestEndpointType::AgentSendAsset: case RestEndpointType::ReserveRequestWeight: case RestEndpointType::Noop: @@ -245,6 +246,9 @@ namespace hyperliquid case RestEndpointType::UserAbstraction: listener.onUserAbstraction(parseUserAbstraction(message), correlationId); break; + case RestEndpointType::ExchangeStatus: + listener.onExchangeStatus(parseExchangeStatus(message), correlationId); + break; default: getLogger()->error("RestMessageParser: unhandled RestEndpointType: {}", toString(type)); break; @@ -2864,6 +2868,38 @@ namespace hyperliquid return response; } + ExchangeStatusResponse parseExchangeStatus(const std::string& message) + { + ExchangeStatusResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + validateStructure(message); + + auto obj = doc.get_object().value(); + + // Confirmed live against both testnet and mainnet: {"specialStatuses":null,"time":} - + // specialStatuses is null in the common case, so only read it as an array when present. + simdjson::ondemand::value specialStatusesVal; + if (obj["specialStatuses"].get(specialStatusesVal) == simdjson::SUCCESS + && !specialStatusesVal.is_null()) + { + for (auto entry : specialStatusesVal.get_array()) + response.specialStatuses.push_back(std::string(entry.get_string().value())); + } + + response.time = obj["time"].get_uint64().value(); + } + catch (const simdjson::simdjson_error& e) + { + getLogger()->error("RestMessageParser: parse error in exchangeStatus: {}\n raw: {}", e.what(), message); + } + + return response; + } + ApprovedBuildersResponse parseApprovedBuilders(const std::string& message) { ApprovedBuildersResponse response; @@ -3642,4 +3678,9 @@ namespace hyperliquid { return impl_->parseUserAbstraction(message); } + + ExchangeStatusResponse RestApiMessageParser::parseExchangeStatus(const std::string& message) + { + return impl_->parseExchangeStatus(message); + } } // namespace hyperliquid diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp index f3a1b78..9693903 100644 --- a/src/signing/Signing.cpp +++ b/src/signing/Signing.cpp @@ -301,6 +301,13 @@ nlohmann::ordered_json Signing::prepareUserSignedActionBody( {"enabled", "bool"}, {"nonce", "uint64"}}; timeField = "nonce"; break; + case RestEndpointType::UserPortfolioMargin: + primaryType = "HyperliquidTransaction:UserPortfolioMargin"; + payloadTypes = { + {"hyperliquidChain", "string"}, {"user", "address"}, + {"enabled", "bool"}, {"nonce", "uint64"}}; + timeField = "nonce"; + break; default: throw std::invalid_argument("Not a user-signed action: " + toString(type)); } diff --git a/src/websocket/PostResponseDispatch.cpp b/src/websocket/PostResponseDispatch.cpp index 5592dc7..98ad5f4 100644 --- a/src/websocket/PostResponseDispatch.cpp +++ b/src/websocket/PostResponseDispatch.cpp @@ -188,6 +188,9 @@ namespace hyperliquid::internal case RestEndpointType::DelegatorRewards: listener.onPostResponse(parser.parseDelegatorRewards(dataJson), correlationId); break; + case RestEndpointType::ExchangeStatus: + listener.onPostResponse(parser.parseExchangeStatus(dataJson), correlationId); + break; default: getLogger()->error("PostResponseDispatch: unhandled info RestEndpointType: {}", toString(type)); break; diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index 6371412..f085efe 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -448,6 +448,12 @@ namespace hyperliquid std::nullopt, std::nullopt, correlationId); } + void WebsocketApi::exchangeStatus(std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::ExchangeStatus, InfoRequestBuilder::exchangeStatus(), + std::nullopt, std::nullopt, correlationId); + } + void WebsocketApi::frontendOpenOrders(const std::string& user, const std::optional& dex, std::optional correlationId) { @@ -901,6 +907,14 @@ namespace hyperliquid std::nullopt, std::nullopt, correlationId); } + void WebsocketApi::userPortfolioMargin(const UserPortfolioMarginRequest& request, + std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::UserPortfolioMargin, + impl_->exchangeRequestBuilder.userPortfolioMargin(request), + std::nullopt, std::nullopt, correlationId); + } + void WebsocketApi::agentSendAsset(const AgentSendAssetRequest& request, std::optional correlationId, const std::optional& vaultAddress) diff --git a/tests/misc_info_test.cpp b/tests/misc_info_test.cpp index 7d242ee..27921da 100644 --- a/tests/misc_info_test.cpp +++ b/tests/misc_info_test.cpp @@ -248,3 +248,51 @@ TEST(RestApiMessageParserMiscTest, ParseAllPerpMetasMalformedInputDoesNotCrash) EXPECT_TRUE(response.dexMetas.empty()); } + +TEST(InfoRequestBuilderMiscTest, ExchangeStatus) +{ + auto body = InfoRequestBuilder::exchangeStatus(); + EXPECT_EQ(body["type"], "exchangeStatus"); +} + +// Real payload captured live from both api.hyperliquid-testnet.xyz/info and +// api.hyperliquid.xyz/info with body {"type":"exchangeStatus"} - specialStatuses is null in the +// common case (no active special statuses), time is a raw (non-string-encoded) millisecond +// timestamp. +TEST(RestApiMessageParserMiscTest, ParseExchangeStatusNoSpecialStatuses) +{ + std::string message = R"({"specialStatuses":null,"time":1788859210259})"; + + RestApiMessageParser parser; + auto response = parser.parseExchangeStatus(message); + + EXPECT_TRUE(response.specialStatuses.empty()); + EXPECT_EQ(response.time, 1788859210259ULL); +} + +TEST(RestApiMessageParserMiscTest, ParseExchangeStatusWithSpecialStatuses) +{ + // specialStatuses' element shape isn't documented anywhere we could find (it was null in + // every live capture) - assuming an array of strings by analogy with every other "list of + // status/message" field in this API (e.g. ApprovedBuildersResponse). Flagged as inferred, not + // confirmed, per CONTRIBUTING.md. + std::string message = R"({"specialStatuses":["scheduled maintenance"],"time":1699564800000})"; + + RestApiMessageParser parser; + auto response = parser.parseExchangeStatus(message); + + ASSERT_EQ(response.specialStatuses.size(), 1u); + EXPECT_EQ(response.specialStatuses[0], "scheduled maintenance"); + EXPECT_EQ(response.time, 1699564800000ULL); +} + +TEST(RestApiMessageParserMiscTest, ParseExchangeStatusMalformedInputDoesNotCrash) +{ + std::string message = R"({"specialStatuses":null,"time":)"; + + RestApiMessageParser parser; + auto response = parser.parseExchangeStatus(message); + + EXPECT_TRUE(response.specialStatuses.empty()); + EXPECT_EQ(response.time, 0u); +} diff --git a/tests/user_portfolio_margin_test.cpp b/tests/user_portfolio_margin_test.cpp new file mode 100644 index 0000000..b400eb4 --- /dev/null +++ b/tests/user_portfolio_margin_test.cpp @@ -0,0 +1,157 @@ +#include + +#include "hyperliquid/config/Config.h" +#include "hyperliquid/rest/RestApiMessageParser.h" +#include "messages/ExchangeRequestBuilder.h" +#include "signing/Signing.h" + +using namespace hyperliquid; + +// Request shape (action.type/user/enabled fields, EIP-712 user-signed action) cross-checked +// against nktkas/hyperliquid's src/api/exchange/_methods/userPortfolioMargin.ts, which mirrors +// userDexAbstraction's field set exactly (user, enabled) - synthetic wallet/address below, never +// sent to a live server. +static const std::string kDummyPrivateKey = + "0123456789012345678901234567890123456789012345678901234567890123"; +static const std::string kUser = "0xcb3f0bd249a89e45e86a44bcfc7113e4ffe84cd1"; + +static Wallet dummyWallet() +{ + return Wallet{"", kDummyPrivateKey}; +} + +static ApiConfig testnetConfig(const Wallet& wallet) +{ + ApiConfig config; + config.env = Environment::Testnet; + config.wallet = wallet; + config.skipBuildingSymbolMap = true; + return config; +} + +TEST(UserPortfolioMarginBuilder, EnabledBodyShape) +{ + ExchangeRequestBuilder builder; + + UserPortfolioMarginRequest req; + req.user = kUser; + req.enabled = true; + + auto action = builder.userPortfolioMargin(req)["action"]; + EXPECT_EQ(action["type"], "userPortfolioMargin"); + EXPECT_EQ(action["user"], kUser); + EXPECT_EQ(action["enabled"], true); +} + +TEST(UserPortfolioMarginBuilder, DisabledBodyShape) +{ + ExchangeRequestBuilder builder; + + UserPortfolioMarginRequest req; + req.user = kUser; + req.enabled = false; + + auto action = builder.userPortfolioMargin(req)["action"]; + EXPECT_EQ(action["type"], "userPortfolioMargin"); + EXPECT_EQ(action["enabled"], false); +} + +TEST(PrepareUserSignedActionBody, UserPortfolioMarginRoundTrip) +{ + ExchangeRequestBuilder builder; + UserPortfolioMarginRequest req; + req.user = kUser; + req.enabled = true; + + auto action = builder.userPortfolioMargin(req)["action"]; + auto wallet = dummyWallet(); + auto config = testnetConfig(wallet); + + auto body = Signing::prepareUserSignedActionBody(config, RestEndpointType::UserPortfolioMargin, action); + + ASSERT_TRUE(body.contains("action")); + ASSERT_TRUE(body.contains("nonce")); + ASSERT_TRUE(body.contains("signature")); + + const auto& signedAction = body["action"]; + EXPECT_EQ(signedAction["hyperliquidChain"], "Testnet"); + EXPECT_EQ(signedAction["signatureChainId"], "0x66eee"); + EXPECT_FALSE(signedAction.contains("time")); + ASSERT_TRUE(signedAction.contains("nonce")); + EXPECT_EQ(signedAction["nonce"].get(), body["nonce"].get()); + + auto expectedSig = Signing::signUserSignedAction( + wallet, signedAction, + { + {"hyperliquidChain", "string"}, + {"user", "address"}, + {"enabled", "bool"}, + {"nonce", "uint64"}, + }, + "HyperliquidTransaction:UserPortfolioMargin"); + + EXPECT_EQ(body["signature"]["r"], expectedSig.r); + EXPECT_EQ(body["signature"]["s"], expectedSig.s); + EXPECT_EQ(body["signature"]["v"], expectedSig.v); +} + +TEST(PrepareUserSignedActionBody, UserPortfolioMarginUsesMainnetChainLabel) +{ + ExchangeRequestBuilder builder; + UserPortfolioMarginRequest req; + req.user = kUser; + req.enabled = false; + + auto action = builder.userPortfolioMargin(req)["action"]; + auto wallet = dummyWallet(); + ApiConfig config; + config.env = Environment::Mainnet; + config.wallet = wallet; + config.skipBuildingSymbolMap = true; + + auto body = Signing::prepareUserSignedActionBody(config, RestEndpointType::UserPortfolioMargin, action); + EXPECT_EQ(body["action"]["hyperliquidChain"], "Mainnet"); +} + +TEST(PrepareUserSignedActionBody, UserPortfolioMarginMissingWalletThrows) +{ + ApiConfig config; + config.env = Environment::Testnet; + config.skipBuildingSymbolMap = true; + + ExchangeRequestBuilder builder; + UserPortfolioMarginRequest req; + req.user = kUser; + req.enabled = true; + auto action = builder.userPortfolioMargin(req)["action"]; + + EXPECT_THROW( + Signing::prepareUserSignedActionBody(config, RestEndpointType::UserPortfolioMargin, action), + std::invalid_argument); +} + +// Both fixtures below are real testnet captures (POST /exchange, userPortfolioMargin), not +// inferred from the TS SDK: enabling failed with a real business-rule error on this test wallet +// (insufficient account value/volume), and disabling (already-off, so a no-op) succeeded with +// the generic {status:"ok", response:{type:"default"}} shape shared by every other simple +// exchange action. + +TEST(UserPortfolioMarginResponseParsing, SuccessResponse) +{ + static const std::string kOk = R"({"status":"ok","response":{"type":"default"}})"; + RestApiMessageParser parser; + auto resp = parser.parseSimpleResponse(kOk); + EXPECT_EQ(resp.status, "ok"); + EXPECT_FALSE(resp.error.has_value()); +} + +TEST(UserPortfolioMarginResponseParsing, ErrorResponse) +{ + static const std::string kErr = + R"({"status":"err","response":"Portfolio margin requires account value of $10000 or total volume of $5000000."})"; + RestApiMessageParser parser; + auto resp = parser.parseSimpleResponse(kErr); + EXPECT_EQ(resp.status, "err"); + ASSERT_TRUE(resp.error.has_value()); + EXPECT_EQ(*resp.error, "Portfolio margin requires account value of $10000 or total volume of $5000000."); +}