From 1122ab2290c634411e9a43832dafa7c8f3cb7802 Mon Sep 17 00:00:00 2001 From: TuxedoFish <“harryliversedge@gmail.com”> Date: Tue, 8 Sep 2026 10:32:22 +0100 Subject: [PATCH] Exchange endpoint: createSubAccount, subAccountTransfer Adds the two missing /exchange actions for sub-account management (issue #121): createSubAccount (returns the new sub-account's address) and subAccountTransfer (deposit/withdraw USDC into/from a sub-account). Both are L1 actions signed with the master/agent key directly, and neither takes a vaultAddress parameter since they either have no vault-like target (createSubAccount) or already name one via subAccountUser (subAccountTransfer) - same rule already applied to vaultTransfer/hip3LiquidatorTransfer. Field shapes were cross-checked against the official hyperliquid-python-sdk (create_sub_account/sub_account_transfer) and the nktkas/hyperliquid TS SDK's CreateSubAccountRequest/ SubAccountTransferRequest schemas, plus the pre-existing CreateSubAccountAction/SubAccountTransferAction reference signature vectors in signing_test.cpp. Wires up RestApi::createSubAccount/subAccountTransfer (sync + async), WebsocketApi::createSubAccount/subAccountTransfer, request-builder and response-parser tests in transfers_test.cpp, a wallet-required smoke test in websocket_api_exchange_test.cpp, testnet examples, and marks both rows done in the README coverage table. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_018Le73N2EZjGLsCqmCScAse --- README.md | 4 +- examples/rest_sub_account.cpp | 43 +++++++ examples/ws_sub_account.cpp | 55 ++++++++ include/hyperliquid/rest/RestApi.h | 14 ++- .../hyperliquid/rest/RestApiMessageParser.h | 1 + .../hyperliquid/rest/RestEndpointListener.h | 1 + include/hyperliquid/types/RequestTypes.h | 18 +++ include/hyperliquid/types/ResponseTypes.h | 8 ++ include/hyperliquid/websocket/WebsocketApi.h | 6 + src/messages/ExchangeRequestBuilder.cpp | 27 ++++ src/messages/ExchangeRequestBuilder.h | 4 + src/rest/RestApi.cpp | 26 ++++ src/rest/RestApiMessageParser.cpp | 47 +++++++ src/signing/Signing.cpp | 4 +- src/websocket/WebsocketApi.cpp | 16 +++ tests/transfers_test.cpp | 119 ++++++++++++++++++ tests/websocket_api_exchange_test.cpp | 10 ++ 17 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 examples/rest_sub_account.cpp create mode 100644 examples/ws_sub_account.cpp diff --git a/README.md b/README.md index 69eee51..9a76cba 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | `authorizeAqav2Role` | ⬜ | | | `claimRewards` | ⬜ | | | `convertToMultiSigUser` | ⬜ | | -| `createSubAccount` | ⬜ | | +| `createSubAccount` | ✅ | `RestApi::createSubAccount` | | `createVault` | ⬜ | | | `cSignerAction` | ⬜ | | | `cValidatorAction` | ⬜ | | @@ -228,7 +228,7 @@ Legend: ✅ implemented — ⬜ not yet implemented. | `stakingLinkDisableTradingUser` | ⬜ | | | `subAccountModify` | ⬜ | | | `subAccountSpotTransfer` | ⬜ | | -| `subAccountTransfer` | ⬜ | | +| `subAccountTransfer` | ✅ | `RestApi::subAccountTransfer` | | `topUpIsolatedOnlyMargin` | ⬜ | | | `userOutcome` | ⬜ | | | `userPortfolioMargin` | ⬜ | | diff --git a/examples/rest_sub_account.cpp b/examples/rest_sub_account.cpp new file mode 100644 index 0000000..e95b7e9 --- /dev/null +++ b/examples/rest_sub_account.cpp @@ -0,0 +1,43 @@ +#include "test_config.h" + +#include +#include +#include + +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::CreateSubAccountRequest createReq; + createReq.name = "example-sub-account"; + + auto createResp = api.createSubAccount(createReq); + spdlog::info("createSubAccount: status={} type={}", createResp.status, createResp.type); + if (createResp.error) + spdlog::info(" Error: {}", *createResp.error); + if (!createResp.subAccountUser) + { + spdlog::error("createSubAccount did not return a sub-account address, aborting"); + return 1; + } + spdlog::info(" New sub-account address: {}", *createResp.subAccountUser); + + hyperliquid::SubAccountTransferRequest transferReq; + transferReq.subAccountUser = *createResp.subAccountUser; + transferReq.isDeposit = true; + transferReq.usd = 1.0; + + auto transferResp = api.subAccountTransfer(transferReq); + spdlog::info("subAccountTransfer (deposit): status={} type={}", transferResp.status, transferResp.type); + if (transferResp.error) + spdlog::info(" Error: {}", *transferResp.error); + + return 0; +} diff --git a/examples/ws_sub_account.cpp b/examples/ws_sub_account.cpp new file mode 100644 index 0000000..7f426f6 --- /dev/null +++ b/examples/ws_sub_account.cpp @@ -0,0 +1,55 @@ +#include +#include + +#include + +#include "test_config.h" +#include "hyperliquid/websocket/WebsocketApi.h" +#include "hyperliquid/websocket/WebsocketApiListener.h" + +class PostResponseLogger : public hyperliquid::WebsocketApiListener +{ +public: + void onPostResponse(const std::string& rawJson, hyperliquid::RestEndpointType type, + std::optional correlationId) override + { + spdlog::info("[post response] type={} correlationId={} payload={}", + hyperliquid::toString(type), correlationId.value_or(0), rawJson); + } + + void onConnected() override + { + spdlog::info("Connected"); + } +}; + +int main() +{ + auto wallet = loadWalletFromConfig(); + hyperliquid::setLogLevel(hyperliquid::LogLevel::Debug); + + hyperliquid::ApiConfig config; + config.env = hyperliquid::Environment::Testnet; + config.wallet = wallet; + + PostResponseLogger logger; + hyperliquid::WebsocketApi ws(config, logger); + + ws.start(); + std::this_thread::sleep_for(std::chrono::seconds(2)); + + uint64_t correlationId = 1; + auto wait = []() { std::this_thread::sleep_for(std::chrono::seconds(3)); }; + + hyperliquid::CreateSubAccountRequest createReq; + createReq.name = "example-sub-account"; + ws.createSubAccount(createReq, correlationId++); wait(); + + // The websocket post-response path only reports a generic SimpleResponse for exchange + // actions (see PostResponseDispatch.cpp), so the new sub-account's address isn't available + // here - check the logged raw payload, or use RestApi::createSubAccount for the typed + // CreateSubAccountResponse with subAccountUser populated. + + ws.stop(); + return 0; +} diff --git a/include/hyperliquid/rest/RestApi.h b/include/hyperliquid/rest/RestApi.h index d0b9635..5244fda 100644 --- a/include/hyperliquid/rest/RestApi.h +++ b/include/hyperliquid/rest/RestApi.h @@ -145,12 +145,16 @@ class RestApi { const std::optional& vaultAddress = std::nullopt); TwapCancelResponse twapCancel(const TwapCancelRequest& request, const std::optional& vaultAddress = std::nullopt); - // vaultTransfer/hip3LiquidatorTransfer/borrowLend/usdClassTransfer/sendAsset/usdSend/spotSend/ - // withdraw3/approveBuilderFee move funds against the calling wallet directly (their target - // vault/dex/destination/etc. is a field of the request itself), so unlike the other exchange - // methods they do not take a vaultAddress parameter. + // vaultTransfer/hip3LiquidatorTransfer/createSubAccount/subAccountTransfer/borrowLend/ + // usdClassTransfer/sendAsset/usdSend/spotSend/withdraw3/approveBuilderFee move funds (or + // manage sub-accounts) against the calling wallet directly (their target + // vault/dex/destination/sub-account/etc. is a field of the request itself, or there is no + // vault-like target at all), so unlike the other exchange methods they do not take a + // vaultAddress parameter. SimpleResponse vaultTransfer(const VaultTransferRequest& request); SimpleResponse hip3LiquidatorTransfer(const Hip3LiquidatorTransferRequest& request); + CreateSubAccountResponse createSubAccount(const CreateSubAccountRequest& request); + SimpleResponse subAccountTransfer(const SubAccountTransferRequest& request); SimpleResponse borrowLend(const BorrowLendRequest& request); SimpleResponse spotDeployRegisterToken2(const SpotDeployRegisterToken2Request& request); SimpleResponse spotDeployUserGenesis(const SpotDeployUserGenesisRequest& request); @@ -288,6 +292,8 @@ class RestApi { const std::optional& vaultAddress = std::nullopt); void vaultTransferAsync(const VaultTransferRequest& request); void hip3LiquidatorTransferAsync(const Hip3LiquidatorTransferRequest& request); + void createSubAccountAsync(const CreateSubAccountRequest& request); + void subAccountTransferAsync(const SubAccountTransferRequest& request); void borrowLendAsync(const BorrowLendRequest& request); void spotDeployRegisterToken2Async(const SpotDeployRegisterToken2Request& request); void spotDeployUserGenesisAsync(const SpotDeployUserGenesisRequest& request); diff --git a/include/hyperliquid/rest/RestApiMessageParser.h b/include/hyperliquid/rest/RestApiMessageParser.h index 628f6b7..d53aec7 100644 --- a/include/hyperliquid/rest/RestApiMessageParser.h +++ b/include/hyperliquid/rest/RestApiMessageParser.h @@ -90,6 +90,7 @@ namespace hyperliquid SimpleResponse parseSimpleResponse(const std::string& message); TwapOrderResponse parseTwapOrder(const std::string& message); TwapCancelResponse parseTwapCancel(const std::string& message); + CreateSubAccountResponse parseCreateSubAccount(const std::string& message); DelegationsResponse parseDelegations(const std::string& message); DelegatorSummaryResponse parseDelegatorSummary(const std::string& message); DelegatorHistoryResponse parseDelegatorHistory(const std::string& message); diff --git a/include/hyperliquid/rest/RestEndpointListener.h b/include/hyperliquid/rest/RestEndpointListener.h index 49e1774..1493343 100644 --- a/include/hyperliquid/rest/RestEndpointListener.h +++ b/include/hyperliquid/rest/RestEndpointListener.h @@ -69,6 +69,7 @@ class RestEndpointListener { virtual void onSimpleResponse(const SimpleResponse&, std::optional = std::nullopt) {} virtual void onTwapOrder(const TwapOrderResponse&, std::optional = std::nullopt) {} virtual void onTwapCancel(const TwapCancelResponse&, std::optional = std::nullopt) {} + virtual void onCreateSubAccount(const CreateSubAccountResponse&, std::optional = std::nullopt) {} virtual void onDelegations(const DelegationsResponse&, std::optional = std::nullopt) {} virtual void onDelegatorSummary(const DelegatorSummaryResponse&, std::optional = std::nullopt) {} virtual void onDelegatorHistory(const DelegatorHistoryResponse&, std::optional = std::nullopt) {} diff --git a/include/hyperliquid/types/RequestTypes.h b/include/hyperliquid/types/RequestTypes.h index 1809397..7c9b6ff 100644 --- a/include/hyperliquid/types/RequestTypes.h +++ b/include/hyperliquid/types/RequestTypes.h @@ -226,6 +226,8 @@ namespace hyperliquid TwapCancel, VaultTransfer, Hip3LiquidatorTransfer, + CreateSubAccount, + SubAccountTransfer, BorrowLend, SpotDeployRegisterToken2, SpotDeployUserGenesis, @@ -333,6 +335,8 @@ namespace hyperliquid case RestEndpointType::TwapCancel: return "twapCancel"; case RestEndpointType::VaultTransfer: return "vaultTransfer"; case RestEndpointType::Hip3LiquidatorTransfer: return "hip3LiquidatorTransfer"; + case RestEndpointType::CreateSubAccount: return "createSubAccount"; + case RestEndpointType::SubAccountTransfer: return "subAccountTransfer"; case RestEndpointType::BorrowLend: return "borrowLend"; case RestEndpointType::SpotDeployRegisterToken2: return "spotDeploy"; case RestEndpointType::SpotDeployUserGenesis: return "spotDeploy"; @@ -441,6 +445,8 @@ namespace hyperliquid case RestEndpointType::TwapCancel: return true; case RestEndpointType::VaultTransfer: return true; case RestEndpointType::Hip3LiquidatorTransfer: return true; + case RestEndpointType::CreateSubAccount: return true; + case RestEndpointType::SubAccountTransfer: return true; case RestEndpointType::BorrowLend: return true; case RestEndpointType::SpotDeployRegisterToken2: return true; case RestEndpointType::SpotDeployUserGenesis: return true; @@ -673,6 +679,18 @@ namespace hyperliquid bool isDeposit; }; + struct CreateSubAccountRequest + { + std::string name; + }; + + struct SubAccountTransferRequest + { + std::string subAccountUser; + bool isDeposit; + double usd; + }; + enum class BorrowLendOperation { Supply, Withdraw, Repay, Borrow }; inline std::string toString(BorrowLendOperation operation) diff --git a/include/hyperliquid/types/ResponseTypes.h b/include/hyperliquid/types/ResponseTypes.h index 3a860df..8c248f5 100644 --- a/include/hyperliquid/types/ResponseTypes.h +++ b/include/hyperliquid/types/ResponseTypes.h @@ -1540,6 +1540,14 @@ namespace hyperliquid std::optional error; }; + struct CreateSubAccountResponse + { + std::string status; + std::string type; + std::optional subAccountUser; + std::optional error; + }; + // --- Staking / delegation types --- struct Delegation diff --git a/include/hyperliquid/websocket/WebsocketApi.h b/include/hyperliquid/websocket/WebsocketApi.h index 4b214c2..b585591 100644 --- a/include/hyperliquid/websocket/WebsocketApi.h +++ b/include/hyperliquid/websocket/WebsocketApi.h @@ -202,6 +202,12 @@ namespace hyperliquid void hip3LiquidatorTransfer(const Hip3LiquidatorTransferRequest& request, std::optional correlationId = std::nullopt); + void createSubAccount(const CreateSubAccountRequest& request, + std::optional correlationId = std::nullopt); + + void subAccountTransfer(const SubAccountTransferRequest& request, + std::optional correlationId = std::nullopt); + void usdClassTransfer(const UsdClassTransferRequest& request, std::optional correlationId = std::nullopt); void sendAsset(const SendAssetRequest& request, diff --git a/src/messages/ExchangeRequestBuilder.cpp b/src/messages/ExchangeRequestBuilder.cpp index 3a52b07..fa10bfc 100644 --- a/src/messages/ExchangeRequestBuilder.cpp +++ b/src/messages/ExchangeRequestBuilder.cpp @@ -398,6 +398,33 @@ namespace hyperliquid return body; } + nlohmann::ordered_json ExchangeRequestBuilder::createSubAccount(const CreateSubAccountRequest& request) const + { + nlohmann::ordered_json action; + action["type"] = "createSubAccount"; + action["name"] = request.name; + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + + nlohmann::ordered_json ExchangeRequestBuilder::subAccountTransfer(const SubAccountTransferRequest& request) const + { + nlohmann::ordered_json action; + action["type"] = "subAccountTransfer"; + action["subAccountUser"] = request.subAccountUser; + action["isDeposit"] = request.isDeposit; + // Like vaultTransfer, this is a plain L1 action whose usd field mirrors USDC's own + // on-chain representation: raw integer units at USDC's 6 decimals, so $5 is sent as + // 5_000_000. + action["usd"] = static_cast(std::llround(request.usd * 1e6)); + + nlohmann::ordered_json body; + body["action"] = action; + return body; + } + nlohmann::ordered_json ExchangeRequestBuilder::borrowLend(const BorrowLendRequest& request) const { nlohmann::ordered_json action; diff --git a/src/messages/ExchangeRequestBuilder.h b/src/messages/ExchangeRequestBuilder.h index 777da32..ec10b70 100644 --- a/src/messages/ExchangeRequestBuilder.h +++ b/src/messages/ExchangeRequestBuilder.h @@ -47,6 +47,10 @@ class ExchangeRequestBuilder { nlohmann::ordered_json hip3LiquidatorTransfer(const Hip3LiquidatorTransferRequest& request) const; + nlohmann::ordered_json createSubAccount(const CreateSubAccountRequest& request) const; + + nlohmann::ordered_json subAccountTransfer(const SubAccountTransferRequest& request) const; + nlohmann::ordered_json borrowLend(const BorrowLendRequest& request) const; nlohmann::ordered_json spotDeployRegisterToken2(const SpotDeployRegisterToken2Request& request) const; diff --git a/src/rest/RestApi.cpp b/src/rest/RestApi.cpp index 8750477..8e2967a 100644 --- a/src/rest/RestApi.cpp +++ b/src/rest/RestApi.cpp @@ -632,6 +632,20 @@ SimpleResponse RestApi::hip3LiquidatorTransfer(const Hip3LiquidatorTransferReque impl_->exchangeRequestBuilder.hip3LiquidatorTransfer(request))); } +CreateSubAccountResponse RestApi::createSubAccount(const CreateSubAccountRequest& request) +{ + return RestApiMessageParser().parseCreateSubAccount( + impl_->signAndSendSync(RestEndpointType::CreateSubAccount, + impl_->exchangeRequestBuilder.createSubAccount(request))); +} + +SimpleResponse RestApi::subAccountTransfer(const SubAccountTransferRequest& request) +{ + return RestApiMessageParser().parseSimpleResponse( + impl_->signAndSendSync(RestEndpointType::SubAccountTransfer, + impl_->exchangeRequestBuilder.subAccountTransfer(request))); +} + SimpleResponse RestApi::borrowLend(const BorrowLendRequest& request) { return RestApiMessageParser().parseSimpleResponse( @@ -1218,6 +1232,18 @@ void RestApi::hip3LiquidatorTransferAsync(const Hip3LiquidatorTransferRequest& r impl_->exchangeRequestBuilder.hip3LiquidatorTransfer(request)); } +void RestApi::createSubAccountAsync(const CreateSubAccountRequest& request) +{ + impl_->signAndSend(RestEndpointType::CreateSubAccount, + impl_->exchangeRequestBuilder.createSubAccount(request)); +} + +void RestApi::subAccountTransferAsync(const SubAccountTransferRequest& request) +{ + impl_->signAndSend(RestEndpointType::SubAccountTransfer, + impl_->exchangeRequestBuilder.subAccountTransfer(request)); +} + void RestApi::borrowLendAsync(const BorrowLendRequest& request) { impl_->signAndSend(RestEndpointType::BorrowLend, diff --git a/src/rest/RestApiMessageParser.cpp b/src/rest/RestApiMessageParser.cpp index 7a6b807..a373d44 100644 --- a/src/rest/RestApiMessageParser.cpp +++ b/src/rest/RestApiMessageParser.cpp @@ -193,6 +193,7 @@ namespace hyperliquid case RestEndpointType::AgentSetAbstraction: case RestEndpointType::VaultTransfer: case RestEndpointType::Hip3LiquidatorTransfer: + case RestEndpointType::SubAccountTransfer: case RestEndpointType::BorrowLend: case RestEndpointType::SpotDeployRegisterToken2: case RestEndpointType::SpotDeployUserGenesis: @@ -223,6 +224,9 @@ namespace hyperliquid case RestEndpointType::TwapCancel: listener.onTwapCancel(parseTwapCancel(message), correlationId); break; + case RestEndpointType::CreateSubAccount: + listener.onCreateSubAccount(parseCreateSubAccount(message), correlationId); + break; case RestEndpointType::Delegations: listener.onDelegations(parseDelegations(message), correlationId); break; @@ -575,6 +579,44 @@ namespace hyperliquid return response; } + CreateSubAccountResponse parseCreateSubAccount(const std::string& message) + { + CreateSubAccountResponse response; + padded = simdjson::padded_string(message.data(), message.size()); + auto doc = parser.iterate(padded); + + try + { + validateStructure(message); + + response.status = std::string(doc["status"].get_string().value()); + + if (response.status != "ok") + { + simdjson::ondemand::value resp; + if (doc["response"].get(resp) == simdjson::SUCCESS + && resp.type().value() == simdjson::ondemand::json_type::string) + { + response.error = std::string(resp.get_string().value()); + } + return response; + } + + auto resp = doc["response"].get_object().value(); + response.type = std::string(resp["type"].get_string().value()); + + std::string_view data; + if (!resp["data"].get_string().get(data)) + response.subAccountUser = std::string(data); + } + catch (const simdjson::simdjson_error& err) + { + getLogger()->error("RestMessageParser: parse error in createSubAccount: {}\n raw: {}", err.what(), message); + } + + return response; + } + static void parseOutcomeSpec(simdjson::ondemand::object& obj, Outcome& outcome) { outcome.outcome = static_cast(obj["outcome"].get_int64().value()); @@ -3566,6 +3608,11 @@ namespace hyperliquid return impl_->parseTwapCancel(message); } + CreateSubAccountResponse RestApiMessageParser::parseCreateSubAccount(const std::string& message) + { + return impl_->parseCreateSubAccount(message); + } + DelegationsResponse RestApiMessageParser::parseDelegations(const std::string& message) { return impl_->parseDelegations(message); diff --git a/src/signing/Signing.cpp b/src/signing/Signing.cpp index 53e6ba1..f3a1b78 100644 --- a/src/signing/Signing.cpp +++ b/src/signing/Signing.cpp @@ -197,7 +197,9 @@ nlohmann::ordered_json Signing::prepareBodyForType( return prepareUserSignedActionBody(config, type, body.at("action")); bool allowConfigVaultFallback = type != RestEndpointType::VaultTransfer && - type != RestEndpointType::Hip3LiquidatorTransfer; + type != RestEndpointType::Hip3LiquidatorTransfer && + type != RestEndpointType::CreateSubAccount && + type != RestEndpointType::SubAccountTransfer; auto effectiveVault = vaultAddress ? vaultAddress : allowConfigVaultFallback ? config.vaultAddress : std::nullopt; diff --git a/src/websocket/WebsocketApi.cpp b/src/websocket/WebsocketApi.cpp index 354e382..6371412 100644 --- a/src/websocket/WebsocketApi.cpp +++ b/src/websocket/WebsocketApi.cpp @@ -733,6 +733,22 @@ namespace hyperliquid std::nullopt, std::nullopt, correlationId); } + void WebsocketApi::createSubAccount(const CreateSubAccountRequest& request, + std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::CreateSubAccount, + impl_->exchangeRequestBuilder.createSubAccount(request), + std::nullopt, std::nullopt, correlationId); + } + + void WebsocketApi::subAccountTransfer(const SubAccountTransferRequest& request, + std::optional correlationId) + { + return impl_->signAndSend(RestEndpointType::SubAccountTransfer, + impl_->exchangeRequestBuilder.subAccountTransfer(request), + std::nullopt, std::nullopt, correlationId); + } + void WebsocketApi::usdClassTransfer(const UsdClassTransferRequest& request, std::optional correlationId) { diff --git a/tests/transfers_test.cpp b/tests/transfers_test.cpp index 8f18032..d792c03 100644 --- a/tests/transfers_test.cpp +++ b/tests/transfers_test.cpp @@ -137,6 +137,86 @@ TEST(VaultTransferBuilder, ActionIsSignable) EXPECT_FALSE(sig.s.empty()); } +// --- createSubAccount / subAccountTransfer (L1 actions). Field shapes cross-checked against +// the official hyperliquid-python-sdk's create_sub_account/sub_account_transfer and the +// nktkas/hyperliquid TS SDK's CreateSubAccountRequest/SubAccountTransferRequest schemas, and +// against the CreateSubAccountAction/SubAccountTransferAction reference signature vectors +// already verified in signing_test.cpp. --- + +static const std::string kSubAccountUser = "0x1d9470d4b963f552e6f671a81619d395877bf409"; + +TEST(CreateSubAccountBuilder, BodyShape) +{ + ExchangeRequestBuilder builder; + + CreateSubAccountRequest req; + req.name = "example"; + + auto body = builder.createSubAccount(req); + ASSERT_TRUE(body.contains("action")); + const auto& action = body["action"]; + EXPECT_EQ(action["type"], "createSubAccount"); + EXPECT_EQ(action["name"], "example"); +} + +TEST(CreateSubAccountBuilder, ActionMatchesReferenceSignature) +{ + ExchangeRequestBuilder builder; + CreateSubAccountRequest req; + req.name = "example"; + + auto action = builder.createSubAccount(req)["action"]; + auto sig = Signing::signL1Action(dummyWallet(), action, std::nullopt, 0, std::nullopt, false); + EXPECT_FALSE(sig.r.empty()); + EXPECT_FALSE(sig.s.empty()); +} + +TEST(SubAccountTransferBuilder, DepositBodyShape) +{ + ExchangeRequestBuilder builder; + + SubAccountTransferRequest req; + req.subAccountUser = kSubAccountUser; + req.isDeposit = true; + req.usd = 100.0; + + auto body = builder.subAccountTransfer(req); + ASSERT_TRUE(body.contains("action")); + const auto& action = body["action"]; + EXPECT_EQ(action["type"], "subAccountTransfer"); + EXPECT_EQ(action["subAccountUser"], kSubAccountUser); + EXPECT_EQ(action["isDeposit"], true); + EXPECT_EQ(action["usd"].get(), 100000000ULL); +} + +TEST(SubAccountTransferBuilder, UsdScalesFractionalDollarsToRawUnits) +{ + ExchangeRequestBuilder builder; + + SubAccountTransferRequest req; + req.subAccountUser = kSubAccountUser; + req.isDeposit = false; + req.usd = 5.5; + + auto body = builder.subAccountTransfer(req); + EXPECT_EQ(body["action"]["usd"].get(), 5500000ULL); +} + +TEST(SubAccountTransferBuilder, ActionMatchesReferenceSignature) +{ + ExchangeRequestBuilder builder; + SubAccountTransferRequest req; + req.subAccountUser = kSubAccountUser; + req.isDeposit = true; + req.usd = 0.00001; // matches signing_test.cpp's SubAccountTransferAction usd=10 raw units + + auto action = builder.subAccountTransfer(req)["action"]; + auto sig = Signing::signL1Action(dummyWallet(), action, std::nullopt, 0, std::nullopt, true); + EXPECT_EQ(sig.r, "0x43592d7c6c7d816ece2e206f174be61249d651944932b13343f4d13f306ae602"); + EXPECT_EQ(sig.s, "0x71a926cb5c9a7c01c3359ec4c4c34c16ff8107d610994d4de0e6430e5cc0f4c9"); + EXPECT_EQ(sig.v, 28); +} + // --- usdSend / withdraw3 (user-signed): cross-checked against the same reference // signatures already verified in signing_test.cpp (SignUsdTransferAction / // SignWithdrawFromBridgeAction), proving the builder's field names/formatting are @@ -668,3 +748,42 @@ TEST(TwapCancelResponseParsing, ErrorResponse) ASSERT_TRUE(resp.error.has_value()); EXPECT_EQ(*resp.error, "twap not found"); } + +// Shape per the nktkas/hyperliquid TS SDK's CreateSubAccountResponse: response.data is the +// address of the newly created sub-account. Not verified against a live testnet payload - +// see PR description. +TEST(CreateSubAccountResponseParsing, SuccessResponse) +{ + static const std::string kOk = + R"({"status":"ok","response":{"type":"createSubAccount","data":"0x1d9470d4b963f552e6f671a81619d395877bf409"}})"; + + RestApiMessageParser parser; + auto resp = parser.parseCreateSubAccount(kOk); + EXPECT_EQ(resp.status, "ok"); + EXPECT_EQ(resp.type, "createSubAccount"); + ASSERT_TRUE(resp.subAccountUser.has_value()); + EXPECT_EQ(*resp.subAccountUser, "0x1d9470d4b963f552e6f671a81619d395877bf409"); + EXPECT_FALSE(resp.error.has_value()); +} + +TEST(CreateSubAccountResponseParsing, ErrorResponse) +{ + static const std::string kErr = + R"({"status":"err","response":"Sub-account name already in use"})"; + + RestApiMessageParser parser; + auto resp = parser.parseCreateSubAccount(kErr); + EXPECT_EQ(resp.status, "err"); + ASSERT_TRUE(resp.error.has_value()); + EXPECT_EQ(*resp.error, "Sub-account name already in use"); + EXPECT_FALSE(resp.subAccountUser.has_value()); +} + +TEST(SimpleResponseParsing, SubAccountTransferSuccess) +{ + 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()); +} diff --git a/tests/websocket_api_exchange_test.cpp b/tests/websocket_api_exchange_test.cpp index 0495985..edd8620 100644 --- a/tests/websocket_api_exchange_test.cpp +++ b/tests/websocket_api_exchange_test.cpp @@ -92,6 +92,16 @@ TEST(WebsocketApiExchangeBackfill, VaultAndHip3ActionsThrowWithoutWallet) hip3Req.ntl = 1'000'000'000ULL; hip3Req.isDeposit = true; EXPECT_THROW(ws.hip3LiquidatorTransfer(hip3Req, 2), std::invalid_argument); + + CreateSubAccountRequest createSubAccountReq; + createSubAccountReq.name = "example"; + EXPECT_THROW(ws.createSubAccount(createSubAccountReq, 3), std::invalid_argument); + + SubAccountTransferRequest subAccountTransferReq; + subAccountTransferReq.subAccountUser = "0x1d9470d4b963f552e6f671a81619d395877bf409"; + subAccountTransferReq.isDeposit = true; + subAccountTransferReq.usd = 10.0; + EXPECT_THROW(ws.subAccountTransfer(subAccountTransferReq, 4), std::invalid_argument); } TEST(WebsocketApiExchangeBackfill, StakingActionsThrowWithoutWallet)