From 3c58b35068af83b535884add8971d206e62959a6 Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:49:07 +0300 Subject: [PATCH 1/3] fix(intrade): restore legacy login across numbered domains Classify Intrade login pages by their response contract, support the current site-form and modern SPA flows, and preserve cookies across manual redirect responses. Add regressions for numbered legacy origins, current modern login, and the full authenticated redirect chain. --- .../IntradeBarPlatform/ApiResponses.hpp | 29 +- .../IntradeBarPlatform/AuthManager.hpp | 15 +- .../HttpClientComponent.hpp | 8 + .../IntradeBarPlatform/RequestManager.hpp | 824 +++++++++++++++--- .../IntradeBarPlatform/http_parsers.hpp | 302 ++++++- tests/intrade_bar_api/WORKFLOWS.md | 45 +- .../intrade_bar_api_response_test.cpp | 267 +++++- 7 files changed, 1326 insertions(+), 164 deletions(-) diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp index 9bd7b63a..7a821781 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/ApiResponses.hpp @@ -17,13 +17,40 @@ namespace optionx::platforms::intrade_bar { bool available = false; }; - /// \brief Hidden login challenge and cookies parsed from the main page. + /// \brief Login protocol detected from the broker page contents. + enum class LoginPageFlow { + LEGACY_FORM, ///< Retired hidden-input login form. + MODERN_NUXT, ///< Nuxt landing page with the current SPA login API. + SITE_FORM ///< Server-rendered site form with challenge proof-of-work. + }; + + /// \brief Challenge and cookies prepared for the detected login protocol. struct MainPageChallenge { + LoginPageFlow flow = LoginPageFlow::LEGACY_FORM; + std::string origin; std::string req_id; std::string req_value; std::string cookies; + std::string site_api_path; + std::string site_csrf; + std::string site_pow; + std::string site_nonce; + std::uint32_t site_pow_bits = 0; }; + /// \brief Converts a login protocol to a stable diagnostic string. + inline const char* login_page_flow_to_string(LoginPageFlow flow) noexcept { + switch (flow) { + case LoginPageFlow::LEGACY_FORM: + return "legacy_form"; + case LoginPageFlow::MODERN_NUXT: + return "modern_nuxt"; + case LoginPageFlow::SITE_FORM: + return "site_form"; + } + return "unknown"; + } + /// \brief User credentials returned by the login page flow. struct LoginCredentials { std::string user_id; diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp index 9f10f570..28a75a46 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp @@ -914,7 +914,9 @@ namespace optionx::platforms::intrade_bar { return; } if (!success) { - LOGIT_WARN("Intrade Bar auth: auth endpoint rejected fresh credentials."); + LOGIT_WARN( + "Intrade Bar auth: authentication flow failed. reason=", + reason); result_callback(false, reason); return; } @@ -965,9 +967,7 @@ namespace optionx::platforms::intrade_bar { // Step 1: Handle main page response auto handle_main_page_response = [this, handle_login_response, generation]( bool success, - const std::string& req_id, - const std::string& req_value, - const std::string& cookies, + const MainPageChallenge& challenge, const std::string& reason) { if (!is_auth_generation_current(generation, "main-page-challenge")) { return; @@ -979,8 +979,11 @@ namespace optionx::platforms::intrade_bar { } // Proceed to login - LOGIT_INFO("Intrade Bar auth: main page challenge succeeded, sending login."); - m_request_manager.request_login(req_id, req_value, cookies, m_auth_data, + LOGIT_INFO( + "Intrade Bar auth: login page recognized as ", + login_page_flow_to_string(challenge.flow), + ", sending login."); + m_request_manager.request_login(challenge, m_auth_data, [handle_login_response]( bool success, const std::string& user_id, diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/HttpClientComponent.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/HttpClientComponent.hpp index b6a7c6f1..dd8e23d8 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/HttpClientComponent.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/HttpClientComponent.hpp @@ -15,6 +15,7 @@ namespace optionx::platforms::intrade_bar { TRADE_RESULT, ///< The limit for trade result requests. BALANCE, ///< The limit for balance requests. ACCOUNT_INFO, ///< The limit for account information requests. + AUTHENTICATION, ///< The limit for challenge-based authentication requests. ACCOUNT_SETTINGS, ///< The limit for account type or currency change requests. TICK_DATA, ///< The limit for tick data requests. FX_BAR_HISTORY, ///< The limit for Intrade FX bar-history requests. @@ -31,6 +32,9 @@ namespace optionx::platforms::intrade_bar { /// \brief Minimum interval between trade-result polling requests. inline constexpr uint32_t TRADE_RESULT_REQUEST_INTERVAL_MS = 2000; + /// \brief Minimum broker-required interval between site-auth requests. + inline constexpr uint32_t SITE_AUTH_REQUEST_INTERVAL_MS = 2100; + /// \class HttpClientComponent /// \brief Handles HTTP requests and manages rate limits for the Intrade Bar platform. class HttpClientComponent final : public components::BaseHttpClientComponent { @@ -49,6 +53,10 @@ namespace optionx::platforms::intrade_bar { TRADE_RESULT_REQUEST_INTERVAL_MS); set_rate_limit_rps(RateLimitType::BALANCE, 1); set_rate_limit_rpm(RateLimitType::ACCOUNT_INFO, 6); + set_rate_limit( + RateLimitType::AUTHENTICATION, + 1, + SITE_AUTH_REQUEST_INTERVAL_MS); set_rate_limit_rpm(RateLimitType::ACCOUNT_SETTINGS, 12); set_rate_limit_rps(RateLimitType::TICK_DATA, 1); set_rate_limit_rps( diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp index 2d0cc71b..54041471 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp @@ -132,6 +132,14 @@ namespace optionx::platforms::intrade_bar { /// \brief Requests the main page to extract authentication parameters. /// \param auth_data Shared pointer to authentication data. /// \param callback Callback function to receive authentication parameters. + void request_main_page( + std::shared_ptr auth_data, + std::function callback); + + /// \brief Compatibility overload exposing only legacy challenge fields. void request_main_page( std::shared_ptr auth_data, std::function callback); - /// \brief Sends login credentials for authentication. - /// \param req_id The request ID obtained from the main page. - /// \param req_value The request value obtained from the main page. - /// \param cookies Cookies extracted from the main page. + /// \brief Sends credentials using the login contract detected on the page. + /// \param challenge Login contract and challenge data obtained from the page. /// \param auth_data Shared pointer to authentication data. /// \param result_callback Callback function to receive login result. + void request_login( + const MainPageChallenge& challenge, + std::shared_ptr auth_data, + std::function result_callback); + + /// \brief Compatibility overload for callers using legacy challenge fields. void request_login( const std::string& req_id, const std::string& req_value, @@ -176,6 +193,12 @@ namespace optionx::platforms::intrade_bar { const std::string& reason)> result_callback); /// \brief Typed variant of request_login. + void request_login_result( + const MainPageChallenge& challenge, + std::shared_ptr auth_data, + std::function result_callback); + + /// \brief Compatibility overload for legacy typed login callers. void request_login_result( const std::string& req_id, const std::string& req_value, @@ -344,15 +367,46 @@ namespace optionx::platforms::intrade_bar { return m_client.get_rate_limit(rate_limit_id); } - /// \brief Requests and parses the legacy login endpoint. - /// \param auth_data Shared pointer to authentication data. - /// \param result_callback Callback receiving the legacy challenge. - void request_legacy_login_page( + /// \brief Requests and classifies a broker login page by its content. + /// \param path Same-origin page path to request. + /// \param result_callback Callback receiving the detected login contract. + void request_login_page( + const std::string& path, + std::function result_callback); + + /// \brief Requests and solves a site-form proof-of-work challenge. + void request_site_login_challenge( + const SiteLoginConfig& config, + const std::string& origin, + const std::string& cookies, + std::function result_callback); + + /// \brief Submits credentials through the site-form API. + void request_site_login( + const MainPageChallenge& challenge, std::shared_ptr auth_data, + int early_retry_count, + bool challenge_refreshed, std::function result_callback); + + /// \brief Follows a login redirect chain while validating every origin. + void follow_login_redirect( + const std::string& original_host, + const std::string& redirect_url, + const std::string& cookies, + const std::string& user_id, + std::function result_callback); @@ -452,9 +506,7 @@ namespace optionx::platforms::intrade_bar { std::shared_ptr auth_data, std::function result_callback) { LOGIT_TRACE0(); @@ -463,68 +515,48 @@ namespace optionx::platforms::intrade_bar { auth_data->password.empty()) { LOGIT_ERROR_IF(auth_data->email.empty(), "Email is missing."); LOGIT_ERROR_IF(auth_data->password.empty(), "Password is missing."); - result_callback(false, std::string(), std::string(), std::string(), "Email or password is missing."); + result_callback(false, MainPageChallenge{}, "Email or password is missing."); return; } - // Prefer the explicit legacy endpoint. This preserves the compatibility - // path from #171 and avoids probing the public landing page when the - // broker still exposes the old challenge form. If that endpoint is no - // longer available, fall back to the current SPA landing page. - request_legacy_login_page( - auth_data, - [this, result_callback]( - bool success, - const std::string& req_id, - const std::string& req_value, - const std::string& cookies, - const std::string& reason) { - if (success) { - result_callback(true, req_id, req_value, cookies, reason); - return; - } - - auto future = get_http_client().get( - "/", - kurlyk::QueryParams(), - { - {"Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8"}, - {"Content-Type", "application/x-www-form-urlencoded"}, - {"Upgrade-Insecure-Requests", "1"} - }, - get_rate_limit(RateLimitType::ACCOUNT_INFO) - ); + const std::string current_host = m_api_host.empty() + ? auth_data->host + : m_api_host; + if (is_modern_intrade_origin(current_host)) { + request_login_page("/", std::move(result_callback)); + return; + } - auto callback = [result_callback](kurlyk::HttpResponsePtr response) { - if (!validate_response(response, [&result_callback](const std::string& error_text) { - result_callback(false, std::string(), std::string(), std::string(), error_text); - })) { + request_login_page( + "/login", + [this, result_callback]( + bool success, + const MainPageChallenge& challenge, + const std::string& reason) { + if (success) { + result_callback(true, challenge, reason); return; } - if (!is_modern_intrade_landing_page(response->content)) { - result_callback( - false, - std::string(), - std::string(), - std::string(), - "Neither legacy /login nor modern landing page was recognized."); - return; - } - - result_callback( - true, - std::string(), - std::string(), - merge_set_cookies(std::string(), response->headers), - std::string()); - }; - - add_http_request_task(std::move(future), std::move(callback)); - }); + request_login_page( + "/", + [result_callback, first_reason = reason]( + bool fallback_success, + const MainPageChallenge& fallback_challenge, + const std::string& fallback_reason) { + if (fallback_success) { + result_callback(true, fallback_challenge, std::string()); + return; + } + result_callback( + false, + MainPageChallenge{}, + fallback_reason.empty() ? first_reason : fallback_reason); + }); + }); } - inline void RequestManager::request_legacy_login_page( + inline void RequestManager::request_main_page( std::shared_ptr auth_data, std::function result_callback) { - static_cast(auth_data); + request_main_page( + std::move(auth_data), + [result_callback = std::move(result_callback)]( + bool success, + const MainPageChallenge& challenge, + const std::string& reason) { + if (!success) { + result_callback(false, {}, {}, {}, reason); + return; + } + result_callback( + true, + challenge.req_id, + challenge.req_value, + challenge.cookies, + {}); + }); + } + inline void RequestManager::request_login_page( + const std::string& path, + std::function result_callback) { + const std::string page_origin = m_api_host; auto future = get_http_client().get( - "/login", + path, kurlyk::QueryParams(), { {"Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, @@ -545,42 +601,98 @@ namespace optionx::platforms::intrade_bar { get_rate_limit(RateLimitType::ACCOUNT_INFO) ); - auto callback = [result_callback = std::move(result_callback)]( + auto callback = [this, page_origin, result_callback = std::move(result_callback)]( kurlyk::HttpResponsePtr response) mutable { if (!validate_response(response, [&result_callback](const std::string& error_text) { - result_callback(false, std::string(), std::string(), std::string(), error_text); + result_callback(false, MainPageChallenge{}, error_text); })) { return; } + const std::string page_cookies = merge_set_cookies( + std::string(), + response->headers); + if (is_site_login_page(response->content)) { + try { + const auto config = parse_site_login_config(response->content); + if (!config) { + result_callback( + false, + MainPageChallenge{}, + "Intrade site login configuration was not found."); + return; + } + request_site_login_challenge( + *config, + page_origin, + page_cookies, + [this, result_callback = std::move(result_callback)]( + MainPageChallengeResult result) mutable { + if (!result.success) { + result_callback(false, MainPageChallenge{}, result.error_message); + return; + } + result_callback(true, result.value, std::string()); + }); + } catch (const std::exception& ex) { + LOGIT_ERROR( + "Failed to parse Intrade site login page. status=", + response->status_code, + ", body_bytes=", + response->content.size(), + ", reason=", + ex.what()); + result_callback(false, MainPageChallenge{}, ex.what()); + } + return; + } + + if (is_modern_intrade_landing_page(response->content)) { + MainPageChallenge challenge; + challenge.flow = LoginPageFlow::MODERN_NUXT; + challenge.origin = page_origin; + challenge.cookies = page_cookies; + result_callback(true, challenge, std::string()); + return; + } + + if (response->content.find("name=\"g-rec-res-l\"") == std::string::npos) { + LOGIT_WARN( + "Unrecognized Intrade login page. status=", + response->status_code, + ", body_bytes=", + response->content.size()); + result_callback( + false, + MainPageChallenge{}, + "No supported Intrade login contract was recognized."); + return; + } + const auto parsed_data = parse_main_page_response( response->content, response->headers); if (!parsed_data) { -# ifdef OPTIONX_LOG_UNIQUE_FILE_INDEX - const int log_index = OPTIONX_LOG_UNIQUE_FILE_INDEX; - LOGIT_STREAM_ERROR_TO(log_index) << response->content; - LOGIT_PRINT_ERROR( - "Failed to process legacy login response; content log was written to file: ", - LOGIT_GET_LAST_FILE_NAME(log_index) - ); -# else LOGIT_ERROR( - "Failed to process legacy login response.", - (response ? response->status_code : -1) - ); -# endif + "Failed to process legacy login response. status=", + response->status_code, + ", body_bytes=", + response->content.size()); result_callback( false, - std::string(), - std::string(), - std::string(), + MainPageChallenge{}, "Failed to process legacy login response."); return; } - const auto& [req_id, req_value, cookies] = *parsed_data; - result_callback(true, req_id, req_value, cookies, std::string()); + const auto& [req_id, req_value, parsed_cookies] = *parsed_data; + MainPageChallenge challenge; + challenge.flow = LoginPageFlow::LEGACY_FORM; + challenge.origin = page_origin; + challenge.req_id = req_id; + challenge.req_value = req_value; + challenge.cookies = parsed_cookies; + result_callback(true, challenge, std::string()); }; add_http_request_task(std::move(future), std::move(callback)); @@ -662,45 +774,435 @@ namespace optionx::platforms::intrade_bar { add_http_request_task(std::move(future), std::move(response_callback)); } - inline void RequestManager::request_login( - const std::string& req_id, - const std::string& req_value, + inline void RequestManager::request_site_login_challenge( + const SiteLoginConfig& config, + const std::string& origin, const std::string& cookies, + std::function result_callback) { + const std::string referer = origin.empty() ? std::string() : origin + "/login"; + if (!origin.empty()) { + auto& client = get_http_client(); + client.set_host(origin); + client.set_origin(origin); + client.set_referer(referer); + } + const kurlyk::QueryParams query = { + {"csrf", config.csrf}, + {"action", "challenge"}, + {"for", "login"} + }; + auto future = get_http_client().post( + config.api_path, + kurlyk::QueryParams(), + { + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"}, + {"X-Requested-With", "XMLHttpRequest"}, + {"Cookie", cookies} + }, + kurlyk::utils::to_query_string(query), + get_rate_limit(RateLimitType::AUTHENTICATION)); + + auto callback = [this, config, origin, cookies, result_callback = std::move(result_callback)]( + kurlyk::HttpResponsePtr response) mutable { + if (!validate_response(response, [&result_callback](const std::string& error_text) { + result_callback(MainPageChallengeResult::fail(error_text)); + })) { + return; + } + + try { + const auto parsed = parse_site_login_challenge_response( + response->content, + config.bits); + const auto nonce = solve_site_login_pow(parsed.challenge, parsed.bits); + if (!nonce) { + result_callback(MainPageChallengeResult::fail( + "Failed to solve Intrade site login proof-of-work.")); + return; + } + + MainPageChallenge challenge; + challenge.flow = LoginPageFlow::SITE_FORM; + challenge.origin = origin; + challenge.cookies = merge_set_cookies(cookies, response->headers); + challenge.site_api_path = config.api_path; + challenge.site_csrf = config.csrf; + challenge.site_pow = parsed.challenge; + challenge.site_nonce = *nonce; + challenge.site_pow_bits = parsed.bits; + result_callback(MainPageChallengeResult::ok(std::move(challenge))); + } catch (const std::exception& ex) { + LOGIT_ERROR( + "Failed to process Intrade site login challenge. status=", + response->status_code, + ", body_bytes=", + response->content.size(), + ", reason=", + ex.what()); + result_callback(MainPageChallengeResult::fail(ex.what(), response->status_code)); + } + }; + + add_http_request_task(std::move(future), std::move(callback)); + } + + inline void RequestManager::follow_login_redirect( + const std::string& original_host, + const std::string& redirect_url, + const std::string& cookies, + const std::string& user_id, + std::function result_callback) { + const auto target = resolve_login_redirect_target(redirect_url); + if (!target) { + result_callback(false, {}, {}, {}, "Malformed login redirect URL."); + return; + } + + const auto origin_from_host = [](const std::string& host) { + if (const auto origin = parse_login_redirect_origin(host)) return *origin; + std::string origin = host; + while (!origin.empty() && origin.back() == '/') origin.pop_back(); + return origin; + }; + const std::string initial_origin = target->origin + ? *target->origin + : origin_from_host(original_host); + if (target->origin && + classify_intrade_login_origin(initial_origin) == IntradeLoginOrigin::UNKNOWN) { + result_callback(false, {}, {}, {}, "Rejected login redirect origin."); + return; + } + + using RedirectHandler = std::function; + auto handler = std::make_shared(); + const std::weak_ptr weak_handler = handler; + *handler = [this, original_host, user_id, result_callback, weak_handler]( + const std::string& origin, + const std::string& path, + const std::string& request_cookies, + const int redirect_count) { + const auto restore = [this, original_host]() { + if (original_host.empty()) return; + auto& client = get_http_client(); + client.set_host(original_host); + client.set_origin(original_host); + client.set_referer(original_host + "/"); + m_api_host = original_host; + }; + const auto fail = [restore, result_callback](const std::string& reason) { + restore(); + result_callback(false, {}, {}, {}, reason); + }; + + const auto strong_handler = weak_handler.lock(); + if (!strong_handler) { + fail("Login redirect handler expired."); + return; + } + if (redirect_count > 5) { + fail("Too many login redirects."); + return; + } + + auto& client = get_http_client(); + client.set_host(origin); + client.set_origin(origin); + client.set_referer(origin + "/login"); + client.set_follow_location(false); + client.set_valid_statuses({200, 301, 302, 303, 307, 308}); + auto future = client.get( + path, + kurlyk::QueryParams(), + { + {"Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"}, + {"Upgrade-Insecure-Requests", "1"}, + {"Cookie", request_cookies} + }, + get_rate_limit(RateLimitType::ACCOUNT_INFO)); + client.set_follow_location(true); + client.set_valid_statuses({200}); + + auto callback = [ + this, + user_id, + result_callback, + strong_handler, + origin, + request_cookies, + redirect_count, + restore, + fail](kurlyk::HttpResponsePtr response) { + const auto analysis = analyze_login_redirect_response( + response, + origin, + request_cookies); + LOGIT_INFO( + "Intrade Bar login redirect response: origin=", + origin, + ", status=", + (response ? response->status_code : -1), + ", set_cookie_headers=", + (response ? response->headers.count("set-cookie") : 0), + ", cookie_names=", + describe_cookie_names(analysis.cookies)); + if (analysis.action == LoginRedirectResponseAction::FAILURE || + analysis.action == LoginRedirectResponseAction::UNSUPPORTED_MODERN) { + fail(analysis.reason); + return; + } + if (analysis.action == LoginRedirectResponseAction::FOLLOW) { + (*strong_handler)( + analysis.next_origin, + analysis.next_target->path, + analysis.cookies, + redirect_count + 1); + return; + } + + restore(); + std::string resolved_user_id = user_id; + std::string resolved_user_hash; + if (const auto parsed = parse_cookies(analysis.cookies)) { + resolved_user_id = std::get<0>(*parsed); + resolved_user_hash = std::get<1>(*parsed); + } else if (!has_cookie(analysis.cookies, "PHPSESSID")) { + const bool returned_public_landing = + response && is_modern_intrade_landing_page(response->content); + result_callback( + false, + {}, + {}, + {}, + std::string(returned_public_landing + ? "Login redirect returned a public landing page instead of an authenticated session; " + : "Failed to parse login cookies; ") + + "origin=" + origin + + ", status=" + std::to_string(response ? response->status_code : -1) + + ", body_bytes=" + std::to_string(response ? response->content.size() : 0) + + ", cookie_names=" + + describe_cookie_names(analysis.cookies)); + return; + } + result_callback( + true, + resolved_user_id, + resolved_user_hash, + analysis.cookies, + {}); + }; + add_http_request_task(std::move(future), std::move(callback)); + }; + + (*handler)(initial_origin, target->path, cookies, 0); + } + + inline void RequestManager::request_site_login( + const MainPageChallenge& challenge, std::shared_ptr auth_data, + const int early_retry_count, + const bool challenge_refreshed, std::function result_callback) { - LOGIT_DEBUG(req_id, req_value, utils::redact_secret_value(cookies)); - - // The broker's current public host is a Nuxt shell. Its login endpoint - // returns an opaque /auth/t=... redirect. Follow that chain manually: - // legacy terminal origins remain supported, while a successfully - // established session on the modern SPA is reported as unsupported. - if (req_id.empty() && req_value.empty()) { - const std::string legacy_host = - m_api_host.empty() ? auth_data->host : m_api_host; + const std::string origin = challenge.origin.empty() + ? m_api_host + : challenge.origin; + const std::string referer = origin.empty() ? std::string() : origin + "/login"; + if (!origin.empty()) { + auto& client = get_http_client(); + client.set_host(origin); + client.set_origin(origin); + client.set_referer(referer); + } + const kurlyk::QueryParams query = { + {"csrf", challenge.site_csrf}, + {"action", "login"}, + {"pow", challenge.site_pow}, + {"nonce", challenge.site_nonce}, + {"email", auth_data->email}, + {"password", auth_data->password}, + {"website", ""} + }; + auto future = get_http_client().post( + challenge.site_api_path, + kurlyk::QueryParams(), + { + {"Accept", "application/json"}, + {"Content-Type", "application/x-www-form-urlencoded"}, + {"X-Requested-With", "XMLHttpRequest"}, + {"Cookie", challenge.cookies} + }, + kurlyk::utils::to_query_string(query), + get_rate_limit(RateLimitType::AUTHENTICATION)); + + auto callback = [this, challenge, auth_data, early_retry_count, + challenge_refreshed, result_callback = std::move(result_callback)]( + kurlyk::HttpResponsePtr response) mutable { + if (!validate_response(response, [&result_callback](const std::string& error_text) { + result_callback(false, {}, {}, {}, error_text); + })) { + return; + } + + const auto parsed = parse_site_login_response(response->content); + if (!parsed) { + result_callback(false, {}, {}, {}, "Failed to parse Intrade site login response."); + return; + } + if (parsed->error == "pow_early" && early_retry_count < 3) { + request_site_login( + challenge, + auth_data, + early_retry_count + 1, + challenge_refreshed, + std::move(result_callback)); + return; + } + if ((parsed->error == "pow_expired" || + parsed->error == "pow_bad" || + parsed->error == "pow_work") && !challenge_refreshed) { + SiteLoginConfig config{ + challenge.site_api_path, + challenge.site_csrf, + challenge.site_pow_bits}; + request_site_login_challenge( + config, + challenge.origin, + challenge.cookies, + [this, auth_data, result_callback = std::move(result_callback)]( + MainPageChallengeResult refreshed) mutable { + if (!refreshed.success) { + result_callback(false, {}, {}, {}, refreshed.error_message); + return; + } + request_site_login( + refreshed.value, + auth_data, + 0, + true, + std::move(result_callback)); + }); + return; + } + if (!parsed->ok) { + result_callback( + false, + {}, + {}, + {}, + parsed->error.empty() + ? "Intrade site login rejected credentials." + : parsed->error); + return; + } + if (parsed->step == "2fa" || parsed->step == "code") { + result_callback( + false, + {}, + {}, + {}, + "Intrade site login requires interactive two-factor authentication."); + return; + } + if (parsed->redirect_url.empty()) { + result_callback(false, {}, {}, {}, "Intrade site login returned no redirect."); + return; + } + + const std::string original_host = challenge.origin.empty() + ? (m_api_host.empty() ? auth_data->host : m_api_host) + : challenge.origin; + const std::string response_cookies = merge_set_cookies( + challenge.cookies, + response->headers); + follow_login_redirect( + original_host, + parsed->redirect_url, + response_cookies, + {}, + std::move(result_callback)); + }; + add_http_request_task(std::move(future), std::move(callback)); + } + + inline void RequestManager::request_login( + const MainPageChallenge& challenge, + std::shared_ptr auth_data, + std::function result_callback) { + LOGIT_DEBUG( + login_page_flow_to_string(challenge.flow), + utils::redact_secret_value(challenge.cookies)); + + // The broker's current public host is a Nuxt shell. Its browser login + // uses the same-origin multipart API below. A successful session on + // the modern SPA is intentionally reported as unsupported; the + // legacy redirect path remains available for older compatible origins. + if (challenge.flow == LoginPageFlow::SITE_FORM) { + request_site_login( + challenge, + std::move(auth_data), + 0, + false, + std::move(result_callback)); + return; + } + + if (challenge.flow == LoginPageFlow::MODERN_NUXT) { + const std::string legacy_host = challenge.origin.empty() + ? (m_api_host.empty() ? auth_data->host : m_api_host) + : challenge.origin; + const std::string cookies = challenge.cookies; auto& client = get_http_client(); client.set_host(legacy_host); client.set_origin(legacy_host); client.set_referer(legacy_host + "/"); - const nlohmann::json login_payload = { - {"email", auth_data->email}, - {"password", auth_data->password} - }; + static constexpr char modern_login_boundary[] = + "----optionx-intrade-modern-login"; + const auto password_hash = md5_hex_digest(auth_data->password); + if (password_hash.empty()) { + result_callback( + false, + std::string(), + std::string(), + std::string(), + "Failed to hash password for modern Intrade login."); + return; + } + const auto login_body = make_multipart_form_body( + modern_login_boundary, + { + {"email", auth_data->email}, + {"password", password_hash} + }); auto future = client.post( - "/route/ts_login_user", + "/ajax/index/sign-in", kurlyk::QueryParams(), { {"Accept", "application/json, text/plain, */*"}, - {"Content-Type", "application/json"}, + {"Content-Type", std::string("multipart/form-data; boundary=") + modern_login_boundary}, {"Origin", legacy_host}, {"Referer", legacy_host + "/"}, {"Cookie", cookies} }, - login_payload.dump(), + login_body, get_rate_limit(RateLimitType::ACCOUNT_INFO) ); @@ -758,6 +1260,26 @@ namespace optionx::platforms::intrade_bar { return; } + if (is_modern_intrade_origin(legacy_host)) { + restore_legacy_host(); + if (!has_cookie(merged_cookies, "PHPSESSID")) { + result_callback( + false, + std::string(), + std::string(), + std::string(), + "Modern login succeeded without an authenticated session cookie."); + return; + } + result_callback( + false, + std::string(), + std::string(), + std::string(), + unsupported_modern_intrade_account_reason()); + return; + } + if (parsed->redirect_url.empty()) { restore_legacy_host(); result_callback( @@ -864,6 +1386,7 @@ namespace optionx::platforms::intrade_bar { client.set_origin(origin); client.set_referer(origin + "/"); client.set_follow_location(false); + client.set_valid_statuses({200, 301, 302, 303, 307, 308}); auto redirect_future = client.get( path, kurlyk::QueryParams(), @@ -875,6 +1398,7 @@ namespace optionx::platforms::intrade_bar { get_rate_limit(RateLimitType::ACCOUNT_INFO) ); client.set_follow_location(true); + client.set_valid_statuses({200}); auto redirect_callback = [ modern_user_id, @@ -954,13 +1478,15 @@ namespace optionx::platforms::intrade_bar { return; } + const std::string cookies = challenge.cookies; + // Prepare query parameters kurlyk::QueryParams query = { {"email", auth_data->email}, {"password", auth_data->password}, }; - if (!req_id.empty() && !req_value.empty()) { - query.emplace(req_id, req_value); + if (!challenge.req_id.empty() && !challenge.req_value.empty()) { + query.emplace(challenge.req_id, challenge.req_value); } // Prepare HTTP GET request @@ -972,14 +1498,14 @@ namespace optionx::platforms::intrade_bar { {"Content-Type", "application/x-www-form-urlencoded"}, {"Upgrade-Insecure-Requests", "1"}, {"Connection", "keep-alive"}, - {"Cookie", cookies} + {"Cookie", challenge.cookies} }, kurlyk::utils::to_query_string(query), get_rate_limit(RateLimitType::ACCOUNT_INFO) ); // Define callback for handling HTTP response - auto callback = [this, cookies, auth_data, result_callback]( + auto callback = [this, challenge, auth_data, result_callback]( kurlyk::HttpResponsePtr response) { if (!validate_response(response, [&result_callback](const std::string& error_text){ result_callback(false, std::string(), std::string(), std::string(), error_text); @@ -1039,14 +1565,19 @@ namespace optionx::platforms::intrade_bar { auto login_result = parse_login(response->content); if (login_result) { const auto [user_id, user_hash] = *login_result; - result_callback(true, user_id, user_hash, cookies, std::string()); + result_callback( + true, + user_id, + user_hash, + challenge.cookies, + std::string()); return; } } if (redirect_url) { const std::string login_response_cookies = merge_set_cookies( - cookies, + challenge.cookies, response->headers); auto redirect_future = get_http_client().get( redirect_path, @@ -1098,13 +1629,11 @@ namespace optionx::platforms::intrade_bar { return; } -# ifdef OPTIONX_LOG_UNIQUE_FILE_INDEX - const int log_index = OPTIONX_LOG_UNIQUE_FILE_INDEX; - LOGIT_STREAM_ERROR_TO(log_index) << response->content; - LOGIT_PRINT_ERROR("Failed to parse login; content log was written to file: ", LOGIT_GET_LAST_FILE_NAME(log_index)); -# else - LOGIT_PRINT_ERROR("Failed to parse login."); -# endif + LOGIT_ERROR( + "Failed to parse legacy login response. status=", + response->status_code, + ", body_bytes=", + response->content.size()); result_callback(false, std::string(), std::string(), std::string(), "Failed to parse login."); }; @@ -1112,6 +1641,30 @@ namespace optionx::platforms::intrade_bar { add_http_request_task(std::move(future), std::move(callback)); } + inline void RequestManager::request_login( + const std::string& req_id, + const std::string& req_value, + const std::string& cookies, + std::shared_ptr auth_data, + std::function result_callback) { + MainPageChallenge challenge; + challenge.flow = (req_id.empty() && req_value.empty()) + ? LoginPageFlow::MODERN_NUXT + : LoginPageFlow::LEGACY_FORM; + challenge.req_id = req_id; + challenge.req_value = req_value; + challenge.cookies = cookies; + request_login( + challenge, + std::move(auth_data), + std::move(result_callback)); + } + inline void RequestManager::request_auth( const std::string& user_id, const std::string& user_hash, @@ -2251,16 +2804,14 @@ namespace optionx::platforms::intrade_bar { std::move(auth_data), [callback = std::move(callback)]( bool success, - const std::string& req_id, - const std::string& req_value, - const std::string& cookies, + const MainPageChallenge& challenge, const std::string& reason) { if (!callback) return; if (!success) { callback(MainPageChallengeResult::fail(reason)); return; } - callback(MainPageChallengeResult::ok(MainPageChallenge{req_id, req_value, cookies})); + callback(MainPageChallengeResult::ok(challenge)); }); } @@ -2280,15 +2831,11 @@ namespace optionx::platforms::intrade_bar { } inline void RequestManager::request_login_result( - const std::string& req_id, - const std::string& req_value, - const std::string& cookies, + const MainPageChallenge& challenge, std::shared_ptr auth_data, std::function result_callback) { request_login( - req_id, - req_value, - cookies, + challenge, std::move(auth_data), [result_callback = std::move(result_callback)]( bool success, @@ -2306,6 +2853,25 @@ namespace optionx::platforms::intrade_bar { }); } + inline void RequestManager::request_login_result( + const std::string& req_id, + const std::string& req_value, + const std::string& cookies, + std::shared_ptr auth_data, + std::function result_callback) { + MainPageChallenge challenge; + challenge.flow = (req_id.empty() && req_value.empty()) + ? LoginPageFlow::MODERN_NUXT + : LoginPageFlow::LEGACY_FORM; + challenge.req_id = req_id; + challenge.req_value = req_value; + challenge.cookies = cookies; + request_login_result( + challenge, + std::move(auth_data), + std::move(result_callback)); + } + inline void RequestManager::request_auth_result( const std::string& user_id, const std::string& user_hash, diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp index ba7ff193..055614a2 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp @@ -15,12 +15,14 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include "ApiResponses.hpp" @@ -37,6 +39,225 @@ namespace optionx::platforms::intrade_bar { std::string error; }; + /// \brief Configuration embedded in the broker site login page. + struct SiteLoginConfig { + std::string api_path; + std::string csrf; + std::uint32_t bits = 17; + }; + + /// \brief Proof-of-work challenge returned by the site API. + struct SiteLoginChallengeResponse { + std::string challenge; + std::uint32_t bits = 17; + }; + + /// \brief Parsed response from a site login submission. + struct SiteLoginResponse { + bool ok = false; + std::string redirect_url; + std::string step; + std::string challenge; + std::string error; + }; + + inline constexpr std::uint32_t MAX_SITE_LOGIN_POW_BITS = 20; + + /// \brief Validates a same-origin API path from site configuration. + inline bool is_safe_site_login_api_path(const std::string& path) { + if (path.empty() || path.front() != '/' || + (path.size() > 1 && path[1] == '/') || + path.find("://") != std::string::npos || + path.find_first_of("\\?#\r\n") != std::string::npos) { + return false; + } + return true; + } + + /// \brief Detects a server-rendered site login form by its HTML contract. + inline bool is_site_login_page(const std::string& content) { + std::size_t search_pos = 0; + bool has_login_form = false; + bool has_site_config = false; + while ((search_pos = content.find('<', search_pos)) != std::string::npos) { + const auto tag_end = content.find('>', search_pos); + if (tag_end == std::string::npos) break; + const auto opening_tag = content.substr(search_pos, tag_end - search_pos + 1); + if (opening_tag.compare(0, 5, "("login")) { + has_login_form = true; + } + if (opening_tag.compare(0, 7, "("site-config")) { + has_site_config = true; + } + if (has_login_form && has_site_config) return true; + search_pos = tag_end + 1; + } + return false; + } + + /// \brief Parses the broker site's embedded login configuration. + /// \throws std::runtime_error When the detected configuration is malformed. + inline std::optional parse_site_login_config( + const std::string& content) { + std::size_t search_pos = 0; + while ((search_pos = content.find("', search_pos); + if (tag_end == std::string::npos) break; + const auto opening_tag = content.substr(search_pos, tag_end - search_pos + 1); + if (utils::extract_html_attr(opening_tag, "id") != + std::optional("site-config")) { + search_pos = tag_end + 1; + continue; + } + + const auto content_start = tag_end + 1; + const auto content_end = content.find("", content_start); + if (content_end == std::string::npos) { + throw std::runtime_error("Intrade site login config script is incomplete."); + } + + try { + const auto payload = nlohmann::json::parse( + utils::trim_copy(content.substr(content_start, content_end - content_start))); + if (!payload.is_object()) { + throw std::runtime_error("Intrade site login config is not an object."); + } + + SiteLoginConfig config; + config.api_path = payload.value("api", std::string()); + config.csrf = payload.value("csrf", std::string()); + if (payload.contains("bits")) { + if (!payload.at("bits").is_number_unsigned() && + !payload.at("bits").is_number_integer()) { + throw std::runtime_error("Intrade site login PoW bits are invalid."); + } + const auto bits = payload.at("bits").get(); + if (bits <= 0 || bits > MAX_SITE_LOGIN_POW_BITS) { + throw std::runtime_error("Intrade site login PoW bits are out of range."); + } + config.bits = static_cast(bits); + } + if (!is_safe_site_login_api_path(config.api_path) || config.csrf.empty()) { + throw std::runtime_error( + "Intrade site login config is missing safe API or CSRF data."); + } + return config; + } catch (const nlohmann::json::exception& ex) { + throw std::runtime_error( + std::string("Failed to parse Intrade site login config: ") + ex.what()); + } + } + return std::nullopt; + } + + /// \brief Parses a site API challenge response. + /// \throws std::runtime_error When the response is not a usable challenge. + inline SiteLoginChallengeResponse parse_site_login_challenge_response( + const std::string& content, + std::uint32_t fallback_bits) { + try { + const auto payload = nlohmann::json::parse(content); + if (!payload.is_object() || !payload.value("ok", false) || + !payload.contains("challenge") || + !payload.at("challenge").is_string()) { + throw std::runtime_error("Intrade site login challenge was rejected."); + } + + SiteLoginChallengeResponse result; + result.challenge = payload.at("challenge").get(); + result.bits = fallback_bits; + if (payload.contains("bits")) { + if (!payload.at("bits").is_number_unsigned() && + !payload.at("bits").is_number_integer()) { + throw std::runtime_error("Intrade site login challenge bits are invalid."); + } + const auto bits = payload.at("bits").get(); + if (bits <= 0 || bits > MAX_SITE_LOGIN_POW_BITS) { + throw std::runtime_error("Intrade site login challenge bits are out of range."); + } + result.bits = static_cast(bits); + } + if (result.challenge.empty() || result.challenge.size() > 256 || + result.bits == 0 || result.bits > MAX_SITE_LOGIN_POW_BITS || + !std::all_of(result.challenge.begin(), result.challenge.end(), [](unsigned char ch) { + return ch >= 0x21 && ch <= 0x7e; + })) { + throw std::runtime_error("Intrade site login challenge is invalid."); + } + return result; + } catch (const nlohmann::json::exception& ex) { + throw std::runtime_error( + std::string("Failed to parse Intrade site login challenge: ") + ex.what()); + } + } + + /// \brief Solves the browser-compatible SHA-256 proof-of-work challenge. + /// \return Decimal nonce, or empty when the bounded search fails. + inline std::optional solve_site_login_pow( + const std::string& challenge, + std::uint32_t bits) { + if (challenge.empty() || bits == 0 || bits > MAX_SITE_LOGIN_POW_BITS) { + return std::nullopt; + } + + const std::uint32_t limit = + (std::numeric_limits::max)() >> bits; + const std::uint64_t max_attempts = std::uint64_t{1} << (bits + 4U); + std::unique_ptr context( + EVP_MD_CTX_new(), + &EVP_MD_CTX_free); + if (!context) return std::nullopt; + + const std::string prefix = challenge + ':'; + for (std::uint64_t nonce = 0; nonce < max_attempts; ++nonce) { + const std::string candidate = prefix + std::to_string(nonce); + unsigned char digest[EVP_MAX_MD_SIZE] = {}; + unsigned int digest_size = 0; + if (EVP_DigestInit_ex(context.get(), EVP_sha256(), nullptr) != 1 || + EVP_DigestUpdate(context.get(), candidate.data(), candidate.size()) != 1 || + EVP_DigestFinal_ex(context.get(), digest, &digest_size) != 1 || + digest_size < 4) { + return std::nullopt; + } + + const std::uint32_t first_word = + (static_cast(digest[0]) << 24U) | + (static_cast(digest[1]) << 16U) | + (static_cast(digest[2]) << 8U) | + static_cast(digest[3]); + if (first_word <= limit) return std::to_string(nonce); + } + return std::nullopt; + } + + /// \brief Parses the broker site login JSON response. + inline std::optional parse_site_login_response( + const std::string& content) { + try { + const auto payload = nlohmann::json::parse(content); + if (!payload.is_object()) return std::nullopt; + + SiteLoginResponse result; + result.ok = payload.value("ok", false); + for (const auto& field : { + std::pair("redirect", &result.redirect_url), + {"step", &result.step}, + {"challenge", &result.challenge}, + {"error", &result.error}}) { + if (payload.contains(field.first) && payload.at(field.first).is_string()) { + *field.second = payload.at(field.first).get(); + } + } + return result; + } catch (const nlohmann::json::exception&) { + return std::nullopt; + } + } + /// \brief API endpoint and CSRF token exposed by the current trading terminal. struct IntradeTerminalConfig { std::string api_path; @@ -140,6 +361,53 @@ namespace optionx::platforms::intrade_bar { } } + /// \brief Returns a lowercase MD5 digest for the current SPA login contract. + /// \param value Plain-text value to hash. + /// \return Lowercase hexadecimal digest, or an empty string on an OpenSSL failure. + inline std::string md5_hex_digest(const std::string& value) { + std::unique_ptr context( + EVP_MD_CTX_new(), + &EVP_MD_CTX_free); + if (!context || + EVP_DigestInit_ex(context.get(), EVP_md5(), nullptr) != 1 || + EVP_DigestUpdate(context.get(), value.data(), value.size()) != 1) { + return {}; + } + + unsigned char digest[EVP_MAX_MD_SIZE] = {}; + unsigned int digest_size = 0; + if (EVP_DigestFinal_ex(context.get(), digest, &digest_size) != 1) { + return {}; + } + + static constexpr char hex[] = "0123456789abcdef"; + std::string result; + result.reserve(static_cast(digest_size) * 2U); + for (unsigned int i = 0; i < digest_size; ++i) { + result.push_back(hex[digest[i] >> 4U]); + result.push_back(hex[digest[i] & 0x0fU]); + } + return result; + } + + /// \brief Encodes simple text fields as multipart/form-data. + /// \param boundary Multipart boundary selected by the caller. + /// \param fields Text form fields to encode. + /// \return Multipart request body. + inline std::string make_multipart_form_body( + const std::string& boundary, + std::initializer_list> fields) { + std::string body; + for (const auto& [name, value] : fields) { + body += "--" + boundary + "\r\n"; + body += "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"; + body += value; + body += "\r\n"; + } + body += "--" + boundary + "--\r\n"; + return body; + } + /// \brief Checks whether a named cookie is present in a Cookie header value. /// \param cookies Cookie header value. /// \param name Cookie name to find. @@ -252,14 +520,25 @@ namespace optionx::platforms::intrade_bar { return origin; } + /// \brief Recognizes a numbered legacy Intrade origin. + /// \param origin The `scheme://authority` origin to inspect. + /// \return True for HTTPS `intrade.bar` origins with any numeric index. + inline bool is_numbered_intrade_origin(const std::string& origin) { + static const std::regex numbered_origin_regex( + R"(^https://intrade[0-9]+\.bar$)", + std::regex::icase); + return std::regex_match(origin, numbered_origin_regex); + } + /// \brief Validates the trusted origin family used by legacy Intrade login. /// \param origin The `scheme://authority` origin to validate. - /// \return True only for HTTPS `intrade.bar` or `intrade.bar` origins. + /// \return True only for HTTPS `intrade.bar` or any numbered `intrade.bar` origin. inline bool is_allowed_intrade_redirect_origin(const std::string& origin) { - static const std::regex allowed_origin_regex( - R"(^https://intrade(?:[0-9]+)?\.bar$)", + static const std::regex primary_origin_regex( + R"(^https://intrade\.bar$)", std::regex::icase); - return std::regex_match(origin, allowed_origin_regex); + return std::regex_match(origin, primary_origin_regex) || + is_numbered_intrade_origin(origin); } /// \brief Identifies the platform behind an Intrade login origin. @@ -1580,6 +1859,21 @@ namespace optionx::platforms::intrade_bar { return cookie_map; } + /// \brief Returns cookie names without exposing cookie values. + inline std::string describe_cookie_names(const std::string& cookies) { + std::vector names; + for (const auto& item : parse_cookie_values(cookies)) { + names.push_back(item.first); + } + std::sort(names.begin(), names.end()); + std::ostringstream result; + for (std::size_t index = 0; index < names.size(); ++index) { + if (index != 0) result << ','; + result << names[index]; + } + return result.str(); + } + /// \brief Returns one cookie value when present. /// \param cookies The input Cookie header value. /// \param name Cookie name to find. diff --git a/tests/intrade_bar_api/WORKFLOWS.md b/tests/intrade_bar_api/WORKFLOWS.md index 23e119a9..9d50e77c 100644 --- a/tests/intrade_bar_api/WORKFLOWS.md +++ b/tests/intrade_bar_api/WORKFLOWS.md @@ -26,30 +26,37 @@ Used by `AuthManager` for `AuthMethod::EMAIL_PASSWORD`. `PHPSESSID` session. 3. If saved cookies work: `request_profile` -> optional settings switches -> `request_balance`. -4. If no valid session exists, request `/login` for the legacy challenge. If - that endpoint is unavailable or does not contain the retired hidden-input - form, probe the root page and recognize the current Nuxt SPA shell. -5. For the current SPA, `request_login` posts JSON `email/password` to - `/route/ts_login_user`, follows the returned opaque `/auth/t=...` redirect - chain while checking each `Location`, and stores the complete returned - cookie jar. A successful chain ending on a legacy `intrade*.bar` origin is - supported. A successful chain ending on `trading.intrade.bar` with a - `PHPSESSID` is reported explicitly as an unsupported modern account; the - API does not claim that platform's account as connected. Transport errors, - malformed responses, and unsuccessful login responses keep their ordinary - error paths. Legacy `user_id/user_hash` cookies remain supported; modern - sessions may contain only `PHPSESSID`, with the JSON `userID` retained as - the account identity hint. Legacy deployments continue through - `request_login` -> `request_auth`. -6. Store the returned session in `ServiceSessionDB` as a versioned envelope +4. If no valid session exists, request `/login` and classify the response by + its page contract, not by a hard-coded domain number: + - the server-rendered `data-site-form="login"` plus `site-config` contract + is used by the primary and numbered `intrade.bar` deployments; + - the retired hidden-input form remains supported; + - the current Nuxt SPA shell is the fallback when neither form is present. + For a site-form page, the selected page origin is retained for the + challenge and credential POST, even if another domain is selected while + callbacks are in flight. +5. For the site-form deployment, `request_login` obtains a broker challenge, + solves its bounded SHA-256 proof-of-work, and posts the credentials to the + same-origin site API. A successful redirect is then followed with the + same origin/cookie checks as the other login flows. +6. For the current SPA, `request_login` posts multipart `email` and + `MD5(password)` fields to `/ajax/index/sign-in`. A successful response + establishing a `PHPSESSID` on `trading.intrade.bar` is reported explicitly + as an unsupported modern account; the API does not claim that platform's + account as connected. Older compatible SPA responses that return an opaque + redirect are still followed with origin and cookie checks. Transport + errors, malformed responses, and unsuccessful login responses keep their + ordinary error paths. Legacy `user_id/user_hash` cookies remain supported, + and legacy deployments continue through `request_login` -> `request_auth`. +7. Store the returned session in `ServiceSessionDB` as a versioned envelope containing both the complete cookie jar and the JSON `userID`. Older plain-cookie records remain readable for backward compatibility. -7. Continue with: +8. Continue with: `request_profile` -> optional `request_switch_account_type` -> optional `request_switch_currency` -> `request_balance`. -8. On successful balance fetch, mark the account connected and publish account +9. On successful balance fetch, mark the account connected and publish account updates. -9. `ActiveTradesSyncManager` reacts to the connected account update and calls +10. `ActiveTradesSyncManager` reacts to the connected account update and calls `request_active_trades_snapshot` to synchronize broker-side active trades with the trade queue counter. diff --git a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp index 8d1eb849..94cb4c08 100644 --- a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp +++ b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp @@ -344,7 +344,7 @@ struct ModernLoginServer { headers); }; - server.resource["^/route/ts_login_user$"]["POST"] = [this]( + server.resource["^/ajax/index/sign-in$"]["POST"] = [this]( std::shared_ptr response, std::shared_ptr request) { ++login_requests; @@ -434,6 +434,120 @@ struct ModernLoginServer { std::string terminal_state_body; }; +struct SiteLoginServer { + ~SiteLoginServer() { + stop(); + } + + bool start() { + server.config.address = "127.0.0.1"; + server.config.port = 0; + + server.resource["^/health$"]["GET"] = []( + std::shared_ptr response, + std::shared_ptr) { + response->write(SimpleWeb::StatusCode::success_ok, "ok"); + }; + + server.resource["^/login$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++login_page_requests; + response->write( + SimpleWeb::StatusCode::success_ok, + R"HTML(
)HTML"); + }; + + server.resource["^/site/api\\.php$"]["POST"] = [this]( + std::shared_ptr response, + std::shared_ptr request) { + const auto body = request->content.string(); + if (body.find("action=challenge") != std::string::npos) { + ++challenge_requests; + challenge_body = body; + response->write( + SimpleWeb::StatusCode::success_ok, + R"JSON({"ok":true,"challenge":"fixture-challenge","bits":8})JSON"); + return; + } + + ++login_requests; + login_body = body; + response->write( + SimpleWeb::StatusCode::success_ok, + R"JSON({"ok":true,"redirect":"/auth/opaque-site-login"})JSON"); + }; + + server.resource["^/auth/opaque-site-login$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++auth_redirect_requests; + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Location", "/session"); + headers.emplace("Set-Cookie", "user_id=866188; Path=/; HttpOnly"); + headers.emplace("Set-Cookie", "user_hash=fake_user_hash; Path=/; HttpOnly"); + response->write(SimpleWeb::StatusCode::redirection_found, headers); + }; + + server.resource["^/session$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++session_requests; + response->write(SimpleWeb::StatusCode::success_ok, "authenticated"); + }; + + thread = std::thread([this]() { + server.start(); + }); + if (!wait_until_ready()) { + stop(); + return false; + } + return true; + } + + void stop() { + server.stop(); + if (thread.joinable()) thread.join(); + } + + std::string host() const { + return "http://127.0.0.1:" + std::to_string(server.bound_port()); + } + + bool wait_until_ready() const { + for (int i = 0; i < 100; ++i) { + if (server.bound_port() == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + try { + kurlyk::HttpClient client(host()); + client.set_timeout(1); + client.set_connect_timeout(1); + auto future = client.get("/health", {}, {}); + if (future.wait_for(std::chrono::seconds(1)) == std::future_status::ready) { + auto response = future.get(); + if (response && response->status_code == 200) return true; + } + } catch (...) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return false; + } + + BoundPortHttpServer server; + std::thread thread; + std::atomic login_page_requests{0}; + std::atomic challenge_requests{0}; + std::atomic login_requests{0}; + std::atomic auth_redirect_requests{0}; + std::atomic session_requests{0}; + std::string challenge_body; + std::string login_body; +}; + struct LocalTradeHistoryServer { explicit LocalTradeHistoryServer( long csv_status_code, @@ -946,6 +1060,11 @@ TEST(IntradeBarLogin, ParsesModernTradingLoginResponse) { EXPECT_TRUE(is_modern_intrade_origin("https://trading.intrade.bar")); EXPECT_FALSE(is_modern_intrade_origin("https://intrade35.bar")); + EXPECT_FALSE(is_numbered_intrade_origin("https://intrade.bar")); + EXPECT_TRUE(is_numbered_intrade_origin("https://intrade35.bar")); + EXPECT_TRUE(is_numbered_intrade_origin("https://intrade1000.bar")); + EXPECT_FALSE(is_numbered_intrade_origin("https://intrade35.bar.evil")); + EXPECT_TRUE(is_allowed_intrade_redirect_origin("HTTPS://INTRADE.BAR")); EXPECT_EQ( classify_intrade_login_origin("https://intrade35.bar"), IntradeLoginOrigin::LEGACY); @@ -979,6 +1098,40 @@ TEST(IntradeBarLogin, ParsesModernTradingLoginResponse) { ASSERT_TRUE(failure); EXPECT_FALSE(failure->success); EXPECT_EQ(failure->error, "Invalid credentials"); + EXPECT_EQ( + md5_hex_digest("fake_password"), + "8e30b89c1efc7e8b03e23a44409498f5"); +} + +TEST(IntradeBarLogin, ParsesSiteLoginContractByContent) { + const std::string page = + R"HTML(
)HTML"; + + EXPECT_TRUE(is_site_login_page(page)); + const auto config = parse_site_login_config(page); + ASSERT_TRUE(config); + EXPECT_EQ(config->api_path, "/site/api.php"); + EXPECT_EQ(config->csrf, "fixture-csrf"); + EXPECT_EQ(config->bits, 8u); + + const auto challenge = parse_site_login_challenge_response( + R"JSON({"ok":true,"challenge":"fixture-challenge","bits":8})JSON", + 17); + EXPECT_EQ(challenge.challenge, "fixture-challenge"); + EXPECT_EQ(challenge.bits, 8u); + EXPECT_EQ(solve_site_login_pow(challenge.challenge, challenge.bits), "227"); + + const auto login = parse_site_login_response( + R"JSON({"ok":true,"redirect":"/session"})JSON"); + ASSERT_TRUE(login); + EXPECT_TRUE(login->ok); + EXPECT_EQ(login->redirect_url, "/session"); + + EXPECT_FALSE(is_site_login_page("
")); + EXPECT_THROW( + parse_site_login_config( + R"HTML(
)HTML"), + std::runtime_error); } TEST(IntradeBarLogin, AnalyzesLegacyModernAndUnknownRedirectChains) { @@ -1139,10 +1292,11 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { EXPECT_TRUE(has_cookie(cookies, "user_id")); EXPECT_TRUE(has_cookie(cookies, "user_hash")); EXPECT_TRUE(has_cookie(cookies, "landing")); - EXPECT_NE(server.request_content_type.find("application/json"), std::string::npos); - const auto request_json = nlohmann::json::parse(server.request_body); - EXPECT_EQ(request_json.at("email"), "user@example.test"); - EXPECT_EQ(request_json.at("password"), "fake_password"); + EXPECT_NE(server.request_content_type.find("multipart/form-data"), std::string::npos); + EXPECT_NE(server.request_body.find("name=\"email\""), std::string::npos); + EXPECT_NE(server.request_body.find("user@example.test"), std::string::npos); + EXPECT_NE(server.request_body.find("name=\"password\""), std::string::npos); + EXPECT_NE(server.request_body.find(md5_hex_digest("fake_password")), std::string::npos); EXPECT_EQ(server.login_requests.load(), 1); EXPECT_EQ(server.auth_redirect_requests.load(), 1); @@ -1189,6 +1343,109 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { platform.shutdown(); } +TEST(IntradeBarLogin, UsesSiteFormWithoutHostnameSpecialCase) { + SiteLoginServer server; + ASSERT_TRUE(server.start()); + + TestPlatform platform; + HttpClientComponent http_client(platform); + RequestManager request_manager(platform, http_client); + + auto auth_data = std::make_shared(); + auth_data->host = server.host(); + auth_data->email = "user@example.test"; + auth_data->password = "fake_password"; + + events::AuthDataEvent auth_event(auth_data); + request_manager.on_event(&auth_event); + http_client.get_http_client().set_retry_attempts(0, 0); + + bool page_callback_received = false; + bool page_success = false; + MainPageChallenge challenge; + std::string page_reason; + request_manager.request_main_page( + auth_data, + [&](bool success, + const MainPageChallenge& parsed_challenge, + const std::string& reason) { + page_callback_received = true; + page_success = success; + challenge = parsed_challenge; + page_reason = reason; + }); + + for (int i = 0; i < 1000 && !page_callback_received; ++i) { + http_client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(page_callback_received) << page_reason; + ASSERT_TRUE(page_success) << page_reason; + EXPECT_EQ(challenge.flow, LoginPageFlow::SITE_FORM); + EXPECT_EQ(challenge.origin, server.host()); + EXPECT_EQ(challenge.site_api_path, "/site/api.php"); + EXPECT_EQ(challenge.site_csrf, "fixture-csrf"); + EXPECT_EQ(challenge.site_pow, "fixture-challenge"); + EXPECT_EQ(challenge.site_nonce, "227"); + EXPECT_TRUE(has_cookie(challenge.cookies, "PHPSESSID") == false); + + // A later auth event may select another broker domain while this challenge + // is still being handed to the login stage. The page origin must win. + auto changed_auth_data = std::make_shared(); + changed_auth_data->host = "http://127.0.0.1:1"; + events::AuthDataEvent changed_auth_event(changed_auth_data); + request_manager.on_event(&changed_auth_event); + + bool callback_received = false; + bool success = false; + std::string user_id; + std::string user_hash; + std::string cookies; + std::string reason; + request_manager.request_login( + challenge, + auth_data, + [&](bool login_success, + const std::string& login_user_id, + const std::string& login_user_hash, + const std::string& login_cookies, + const std::string& login_reason) { + callback_received = true; + success = login_success; + user_id = login_user_id; + user_hash = login_user_hash; + cookies = login_cookies; + reason = login_reason; + }); + + for (int i = 0; i < 1000 && !callback_received; ++i) { + http_client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(callback_received) << reason; + EXPECT_TRUE(success) << reason; + EXPECT_EQ(user_id, "866188"); + EXPECT_EQ(user_hash, "fake_user_hash"); + EXPECT_TRUE(has_cookie(cookies, "user_id")); + EXPECT_TRUE(has_cookie(cookies, "user_hash")); + EXPECT_EQ(server.login_page_requests.load(), 1); + EXPECT_EQ(server.challenge_requests.load(), 1); + EXPECT_EQ(server.login_requests.load(), 1); + EXPECT_EQ(server.auth_redirect_requests.load(), 1); + EXPECT_EQ(server.session_requests.load(), 1); + EXPECT_NE(server.challenge_body.find("action=challenge"), std::string::npos); + EXPECT_NE(server.challenge_body.find("for=login"), std::string::npos); + EXPECT_NE(server.login_body.find("action=login"), std::string::npos); + EXPECT_NE(server.login_body.find("pow=fixture-challenge"), std::string::npos); + EXPECT_NE(server.login_body.find("nonce=227"), std::string::npos); + EXPECT_NE(server.login_body.find("email=user%40example.test"), std::string::npos); + EXPECT_NE(server.login_body.find("password=fake_password"), std::string::npos); + + platform.shutdown(); +} + TEST(IntradeBarLogin, CachedModernSessionRoundTripRetainsUserId) { auto& session_db = optionx::storage::ServiceSessionDB::get_instance(); const std::string platform = to_str(PlatformType::INTRADE_BAR); From af7ea2ff57a1da10ee1e767e492ba6f381ab4439 Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:21:29 +0300 Subject: [PATCH 2/3] fix(intrade): bound site login proof-of-work lifecycle --- AGENTS.md | 10 + guides/implementation-notes.md | 27 ++ .../platforms/IntradeBarPlatform.hpp | 72 ++++-- .../IntradeBarPlatform/AuthManager.hpp | 2 + .../IntradeBarPlatform/RequestManager.hpp | 232 +++++++++++++++--- .../IntradeBarPlatform/http_parsers.hpp | 34 ++- .../optionx_cpp/utils/tasks/TaskManager.hpp | 46 ++-- .../intrade_bar_api_response_test.cpp | 139 ++++++++++- tests/utils_task_manager_test.cpp | 41 ++++ 9 files changed, 503 insertions(+), 100 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f8e2a001..2dda3061 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,6 +124,16 @@ supported public entry point or an internal leaf: manager или provider. - В async/HTTP/WebSocket коде сохраняй lifecycle: `run()` -> periodic `process()` -> `shutdown()`/drain/cancel. +- Platform-owned HTTP requests and `RequestManager` state are owner-loop + confined: create/configure requests from the platform `TaskManager` loop. + External threads must use `BaseTradingPlatform::post_task()` or an + equivalent enqueue API; do not add direct cross-thread `RequestManager` or + `HttpClient` calls without an explicit synchronization and lifecycle contract. +- CPU-heavy work such as site-login proof-of-work must have both a bounded + attempt budget and a `std::chrono::steady_clock` deadline. Admit at most the + explicitly supported number of concurrent operations, reject excess work + before queueing, and release admission state on success, failure, cancel, + timeout, scheduler rejection, and shutdown. - В recursive async/HTTP callbacks проверяй ownership graph. `lambda` или `std::function` не должна через `std::shared_ptr` владеть объектом, который содержит эту же callback-функцию: это создаёт постоянный reference cycle. diff --git a/guides/implementation-notes.md b/guides/implementation-notes.md index d367af98..822b4815 100644 --- a/guides/implementation-notes.md +++ b/guides/implementation-notes.md @@ -148,6 +148,33 @@ overloads. `run()` запускает worker thread, `process()` выполня - Rate limit ids хранятся в `m_rate_limits`; используй enum class `RateLimitType` внутри конкретной платформы. +## Platform Owner-Loop HTTP Contract + +Platform-owned HTTP state is confined to the platform owner loop. + +- `BaseTradingPlatform::run()` schedules the periodic owner-loop task. +- The owner loop processes the `EventBus`, HTTP futures, and manager callbacks + in one serialized `TaskManager` execution context. +- `RequestManager` and `HttpClient` configuration, request creation, and + follow-up requests must run on that owner loop. +- Concrete platform facade methods that initiate broker HTTP work must enqueue + before delegating to a manager. IntradeBar trade-result, trade-history, and + bar-history facade methods follow this rule; local read-only snapshots may + remain synchronous when their storage is independently synchronized. +- External threads must enqueue work with `BaseTradingPlatform::post_task()` or + use a facade API that performs the enqueue. They must not call + `RequestManager` or mutate `HttpClient` configuration directly. +- HTTP callbacks may create follow-up requests because they are delivered by + the owner loop. CPU-heavy helper work must use a dedicated bounded worker and + return its result through `post_task()`. Such work needs both a bounded + attempt count and a monotonic deadline; admission must be explicitly bounded + (currently one site-login PoW operation), with the slot released on every + terminal path including cancellation, timeout, scheduler rejection, and + shutdown. +- Shutdown stops the platform loop before component destruction. Dedicated + workers must be cancelable and joined, and their completion callbacks must + check shutdown and operation-generation state before touching platform data. + ## Broker HTTP Adapter Pattern Platform `RequestManager` classes should keep broker-specific HTTP and parser diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp index a1c5048f..1f901089 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp @@ -102,32 +102,52 @@ namespace optionx::platforms { /// \param query Broker-side trade identity and retry settings. /// \param result Partially filled result object to update with broker data. /// \param callback Callback receiving the updated result. + /// \details The request is queued onto the platform owner loop before + /// the trade manager touches account or HTTP state. /// \return True if the check was accepted for processing; false otherwise. bool fetch_trade_result( TradeResultQuery query, std::unique_ptr result, trade_result_check_callback_t callback) override { - return m_trade_manager.fetch_trade_result( - std::move(query), - std::move(result), - std::move(callback)); + if (!result || !callback) return false; + auto result_holder = + std::make_shared>(std::move(result)); + return post_task( + [this, + query = std::move(query), + result_holder, + callback = std::move(callback)]() mutable { + auto owned_result = std::move(*result_holder); + if (!owned_result) return; + m_trade_manager.fetch_trade_result( + std::move(query), + std::move(owned_result), + std::move(callback)); + }); } /// \brief Requests closed Intrade Bar trade history. /// \param request Trade history range and timestamp field. /// \param callback Callback receiving closed trade history or an error. + /// \details The request is queued onto the platform owner loop. /// \return True if the history request was accepted for processing; false otherwise. bool fetch_trade_history( const TradeHistoryRequest& request, trade_history_callback_t callback) override { - return m_trade_manager.fetch_trade_history(request, std::move(callback)); + if (!callback || !request.has_valid_range()) return false; + return post_task( + [this, request, callback = std::move(callback)]() mutable { + m_trade_manager.fetch_trade_history( + request, + std::move(callback)); + }); } /// \brief Requests all available closed Intrade Bar trade history. /// \param callback Callback receiving closed trade history or an error. /// \return True if the history request was accepted for processing; false otherwise. bool fetch_trade_history(trade_history_callback_t callback) override { - return m_trade_manager.fetch_trade_history( + return fetch_trade_history( TradeHistoryRequest::all(), std::move(callback)); } @@ -135,30 +155,34 @@ namespace optionx::platforms { /// \brief Requests historical Intrade Bar candle data. /// \param request Symbol, timeframe, range, and preferred price source. /// \param callback Callback receiving parsed bars or a failure reason. + /// \details The request is queued onto the platform owner loop before + /// the broker HTTP client is configured or invoked. /// \return True if the history request was accepted for processing; false otherwise. bool fetch_bar_history( const BarHistoryRequest& request, market_data::BaseMarketDataProvider::bar_history_callback_t callback) override { if (!callback) return false; - m_request_manager.request_bar_history_result( - request, - [callback = std::move(callback), request](intrade_bar::BarHistoryApiResult result) { - if (result) { - const auto range_complete = - intrade_bar::bar_sequence_covers_dense_range( - result.value, - request); - callback(BarHistoryResult::ok( - std::move(result.value), - result.status_code, - range_complete)); - return; - } - callback(BarHistoryResult::fail( - std::move(result.error_message), - result.status_code)); + return post_task( + [this, request, callback = std::move(callback)]() mutable { + m_request_manager.request_bar_history_result( + request, + [callback = std::move(callback), request](intrade_bar::BarHistoryApiResult result) mutable { + if (result) { + const auto range_complete = + intrade_bar::bar_sequence_covers_dense_range( + result.value, + request); + callback(BarHistoryResult::ok( + std::move(result.value), + result.status_code, + range_complete)); + return; + } + callback(BarHistoryResult::fail( + std::move(result.error_message), + result.status_code)); + }); }); - return true; } /// \brief Returns observed Intrade polling ticks from the in-memory archive. diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp index 28a75a46..b155f7bf 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/AuthManager.hpp @@ -330,6 +330,7 @@ namespace optionx::platforms::intrade_bar { connection_callback_t callback) { cancel_pending_connect_callback(reason); const std::uint64_t generation = ++m_auth_generation; + m_request_manager.invalidate_auth_operations(); if (callback) { m_pending_connect_generation = generation; m_pending_connect_callback = std::move(callback); @@ -344,6 +345,7 @@ namespace optionx::platforms::intrade_bar { inline void AuthManager::invalidate_auth_generation(const char* reason) { const std::uint64_t generation = ++m_auth_generation; + m_request_manager.invalidate_auth_operations(); cancel_pending_connect_callback(reason); LOGIT_DEBUG( "Intrade Bar auth: generation invalidated. reason=", diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp index 54041471..c34249e9 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp @@ -6,6 +6,9 @@ /// \brief Handles HTTP requests for user authentication, trade execution, balance updates, and market data retrieval. #include +#include +#include +#include #include #include "ApiResponses.hpp" @@ -29,8 +32,10 @@ namespace optionx::platforms::intrade_bar { BaseTradingPlatform& platform, HttpClientComponent& client) : BaseComponent(platform.event_bus()), + m_platform(platform), m_client(client), - m_binance_client("https://api.binance.com") { + m_binance_client("https://api.binance.com"), + m_pow_lifecycle(std::make_shared()) { subscribe(); m_api_headers = { @@ -55,8 +60,31 @@ namespace optionx::platforms::intrade_bar { platform.register_component(this); } - /// \brief Default destructor. - virtual ~RequestManager() = default; + /// \brief Stops proof-of-work workers before the manager is destroyed. + ~RequestManager() override { + shutdown(); + } + + /// \brief Cancels background proof-of-work and stale login completions. + void shutdown() override { + if (m_pow_lifecycle) { + m_pow_lifecycle->alive.store(false, std::memory_order_release); + invalidate_auth_operations(); + } + m_pow_task_manager.shutdown(); + } + + /// \brief Invalidates proof-of-work and login completions from an older auth flow. + /// + /// This is called by AuthManager whenever its public auth generation is + /// restarted, replaced, or invalidated. It deliberately does not stop + /// the worker: the worker observes the generation fence and exits + /// cooperatively, while shutdown() joins it during final teardown. + void invalidate_auth_operations() noexcept { + if (m_pow_lifecycle) { + m_pow_lifecycle->generation.fetch_add(1, std::memory_order_acq_rel); + } + } /// \brief Processes incoming events and dispatches them to the appropriate handlers. /// \param event The received event. @@ -341,8 +369,33 @@ namespace optionx::platforms::intrade_bar { std::function result_callback); private: + struct PowLifecycleState { + std::atomic generation{0}; + std::atomic alive{true}; + std::atomic pow_active{false}; + }; + + struct PowOperation { + explicit PowOperation(std::shared_ptr lifecycle_state) + : lifecycle(std::move(lifecycle_state)) { + } + + ~PowOperation() { + if (lifecycle) { + lifecycle->pow_active.store(false, std::memory_order_release); + } + } + + std::atomic cancelled{false}; + std::atomic completed{false}; + std::shared_ptr lifecycle; + }; + + BaseTradingPlatform& m_platform; ///< Owner loop used to deliver async completions. HttpClientComponent& m_client; ///< Reference to the HTTP client component. kurlyk::HttpClient m_binance_client; ///< Separate backend for BTCUSDT history. + utils::TaskManager m_pow_task_manager; ///< Dedicated worker for site-login proof-of-work. + std::shared_ptr m_pow_lifecycle; ///< Lifetime and auth-generation fence. kurlyk::Headers m_api_headers; ///< Default API headers. std::string m_user_id; ///< User ID for authentication. std::string m_user_hash; ///< User authentication hash. @@ -423,6 +476,7 @@ namespace optionx::platforms::intrade_bar { /// \param event The received authentication data event. void handle_event(const events::AuthDataEvent& event) { if (auto msg = std::dynamic_pointer_cast(event.auth_data)) { + m_pow_lifecycle->generation.fetch_add(1, std::memory_order_acq_rel); LOGIT_TRACE0(); const std::string referer(msg->host + "/"); auto& client = get_http_client(); @@ -574,6 +628,15 @@ namespace optionx::platforms::intrade_bar { result_callback(false, {}, {}, {}, reason); return; } + if (challenge.flow != LoginPageFlow::LEGACY_FORM) { + result_callback( + false, + {}, + {}, + {}, + "Detected login flow requires the typed RequestManager API."); + return; + } result_callback( true, challenge.req_id, @@ -803,10 +866,13 @@ namespace optionx::platforms::intrade_bar { kurlyk::utils::to_query_string(query), get_rate_limit(RateLimitType::AUTHENTICATION)); - auto callback = [this, config, origin, cookies, result_callback = std::move(result_callback)]( + const auto result_callback_holder = + std::make_shared>( + std::move(result_callback)); + auto callback = [this, config, origin, cookies, result_callback_holder]( kurlyk::HttpResponsePtr response) mutable { - if (!validate_response(response, [&result_callback](const std::string& error_text) { - result_callback(MainPageChallengeResult::fail(error_text)); + if (!validate_response(response, [result_callback_holder](const std::string& error_text) { + (*result_callback_holder)(MainPageChallengeResult::fail(error_text)); })) { return; } @@ -815,23 +881,131 @@ namespace optionx::platforms::intrade_bar { const auto parsed = parse_site_login_challenge_response( response->content, config.bits); - const auto nonce = solve_site_login_pow(parsed.challenge, parsed.bits); - if (!nonce) { - result_callback(MainPageChallengeResult::fail( - "Failed to solve Intrade site login proof-of-work.")); + const auto lifecycle = m_pow_lifecycle; + const auto generation = lifecycle->generation.load(std::memory_order_acquire); + const auto owner_loop = &m_platform; + const auto challenge_cookies = merge_set_cookies(cookies, response->headers); + + if (!lifecycle->alive.load(std::memory_order_acquire)) { + (*result_callback_holder)(MainPageChallengeResult::fail( + "Intrade site login proof-of-work worker is unavailable.")); return; } - MainPageChallenge challenge; - challenge.flow = LoginPageFlow::SITE_FORM; - challenge.origin = origin; - challenge.cookies = merge_set_cookies(cookies, response->headers); - challenge.site_api_path = config.api_path; - challenge.site_csrf = config.csrf; - challenge.site_pow = parsed.challenge; - challenge.site_nonce = *nonce; - challenge.site_pow_bits = parsed.bits; - result_callback(MainPageChallengeResult::ok(std::move(challenge))); + bool expected_pow_active = false; + if (!lifecycle->pow_active.compare_exchange_strong( + expected_pow_active, + true, + std::memory_order_acq_rel, + std::memory_order_acquire)) { + (*result_callback_holder)(MainPageChallengeResult::fail( + "Intrade site login proof-of-work is already running.")); + return; + } + + const auto operation = std::make_shared(lifecycle); + + const auto post_completion = [owner_loop, + lifecycle, + operation, + generation, + config, + origin, + challenge_cookies, + parsed_challenge = parsed.challenge, + parsed_bits = parsed.bits, + result_callback_holder]( + std::optional nonce, + std::string failure_reason) { + auto deliver = [lifecycle, + operation, + generation, + config, + origin, + challenge_cookies, + parsed_challenge, + parsed_bits, + nonce = std::move(nonce), + failure_reason = std::move(failure_reason), + result_callback_holder]() mutable { + if (operation->completed.exchange(true, std::memory_order_acq_rel)) { + return; + } + if (operation->cancelled.load(std::memory_order_acquire) || + !lifecycle->alive.load(std::memory_order_acquire) || + lifecycle->generation.load(std::memory_order_acquire) != generation) { + return; + } + if (!nonce) { + (*result_callback_holder)(MainPageChallengeResult::fail( + failure_reason.empty() + ? "Failed to solve Intrade site login proof-of-work." + : failure_reason)); + return; + } + + MainPageChallenge challenge; + challenge.flow = LoginPageFlow::SITE_FORM; + challenge.origin = origin; + challenge.cookies = challenge_cookies; + challenge.site_api_path = config.api_path; + challenge.site_csrf = config.csrf; + challenge.site_pow = parsed_challenge; + challenge.site_nonce = *nonce; + challenge.site_pow_bits = parsed_bits; + (*result_callback_holder)(MainPageChallengeResult::ok(std::move(challenge))); + }; + + if (!owner_loop->post_task(std::move(deliver))) { + operation->cancelled.store(true, std::memory_order_release); + } + }; + + m_pow_task_manager.run(); + const bool scheduled = m_pow_task_manager.add_single_task( + "intrade-site-login-pow", + [lifecycle, + operation, + generation, + parsed_challenge = parsed.challenge, + parsed_bits = parsed.bits, + post_completion]( + std::shared_ptr task) mutable { + const auto cancelled = [ + lifecycle, + operation, + generation, + task]() { + return task->is_shutdown() || + operation->cancelled.load(std::memory_order_acquire) || + !lifecycle->alive.load(std::memory_order_acquire) || + lifecycle->generation.load(std::memory_order_acquire) != generation; + }; + if (cancelled()) return; + + try { + const auto nonce = solve_site_login_pow( + parsed_challenge, + parsed_bits, + std::uint64_t{1} << (parsed_bits + 4U), + cancelled, + std::chrono::steady_clock::now() + SITE_LOGIN_POW_TIME_BUDGET); + if (cancelled()) return; + post_completion(std::move(nonce), {}); + } catch (const std::exception& ex) { + post_completion( + std::nullopt, + std::string("Intrade site login proof-of-work failed: ") + ex.what()); + } catch (...) { + post_completion( + std::nullopt, + "Intrade site login proof-of-work failed with an unknown exception."); + } + }); + if (!scheduled) { + (*result_callback_holder)(MainPageChallengeResult::fail( + "Intrade site login proof-of-work worker is unavailable.")); + } } catch (const std::exception& ex) { LOGIT_ERROR( "Failed to process Intrade site login challenge. status=", @@ -840,7 +1014,9 @@ namespace optionx::platforms::intrade_bar { response->content.size(), ", reason=", ex.what()); - result_callback(MainPageChallengeResult::fail(ex.what(), response->status_code)); + (*result_callback_holder)(MainPageChallengeResult::fail( + ex.what(), + response->status_code)); } }; @@ -2007,17 +2183,11 @@ namespace optionx::platforms::intrade_bar { auto [currency, account] = parse_profile_response(response->content); if (currency == CurrencyType::UNKNOWN || account == AccountType::UNKNOWN) { - //LOGIT_PRINT_ERROR("Response validation failed: expected 'ok', but received '", response->content, "'."); -# ifdef OPTIONX_LOG_UNIQUE_FILE_INDEX - const int log_index = OPTIONX_LOG_UNIQUE_FILE_INDEX; - LOGIT_STREAM_ERROR_TO(log_index) << response->content; LOGIT_PRINT_ERROR( - "Response validation failed: currency=",currency,";account=",account,"; Content log was written to file: ", - LOGIT_GET_LAST_FILE_NAME(log_index) - ); -# else - LOGIT_PRINT_ERROR("Response validation failed: currency=", currency, ";account=", account); -# endif + "Response validation failed: status=", response->status_code, + ";body_bytes=", response->content.size(), + ";currency=", currency, + ";account=", account); profile_callback(false, currency, account); return; } diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp index 055614a2..204f23e0 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp @@ -8,7 +8,9 @@ #include #include +#include #include +#include #include #include #include @@ -62,6 +64,7 @@ namespace optionx::platforms::intrade_bar { }; inline constexpr std::uint32_t MAX_SITE_LOGIN_POW_BITS = 20; + inline constexpr std::chrono::milliseconds SITE_LOGIN_POW_TIME_BUDGET{2500}; /// \brief Validates a same-origin API path from site configuration. inline bool is_safe_site_login_api_path(const std::string& path) { @@ -199,14 +202,18 @@ namespace optionx::platforms::intrade_bar { /// \return Decimal nonce, or empty when the bounded search fails. inline std::optional solve_site_login_pow( const std::string& challenge, - std::uint32_t bits) { + std::uint32_t bits, + std::uint64_t max_attempts, + const std::function& should_cancel = {}, + const std::chrono::steady_clock::time_point deadline = + std::chrono::steady_clock::time_point::max()) { if (challenge.empty() || bits == 0 || bits > MAX_SITE_LOGIN_POW_BITS) { return std::nullopt; } const std::uint32_t limit = (std::numeric_limits::max)() >> bits; - const std::uint64_t max_attempts = std::uint64_t{1} << (bits + 4U); + if (max_attempts == 0) return std::nullopt; std::unique_ptr context( EVP_MD_CTX_new(), &EVP_MD_CTX_free); @@ -214,6 +221,11 @@ namespace optionx::platforms::intrade_bar { const std::string prefix = challenge + ':'; for (std::uint64_t nonce = 0; nonce < max_attempts; ++nonce) { + if ((should_cancel && should_cancel()) || + std::chrono::steady_clock::now() >= deadline) { + return std::nullopt; + } + const std::string candidate = prefix + std::to_string(nonce); unsigned char digest[EVP_MAX_MD_SIZE] = {}; unsigned int digest_size = 0; @@ -234,6 +246,24 @@ namespace optionx::platforms::intrade_bar { return std::nullopt; } + /// \brief Solves a site login proof-of-work with the standard bounded budget. + /// \param challenge Printable broker challenge. + /// \param bits Required leading-zero difficulty. + /// \return Decimal nonce, or empty when the bounded search fails. + inline std::optional solve_site_login_pow( + const std::string& challenge, + std::uint32_t bits) { + if (bits == 0 || bits > MAX_SITE_LOGIN_POW_BITS) { + return std::nullopt; + } + return solve_site_login_pow( + challenge, + bits, + std::uint64_t{1} << (bits + 4U), + {}, + std::chrono::steady_clock::now() + SITE_LOGIN_POW_TIME_BUDGET); + } + /// \brief Parses the broker site login JSON response. inline std::optional parse_site_login_response( const std::string& content) { diff --git a/include/optionx_cpp/utils/tasks/TaskManager.hpp b/include/optionx_cpp/utils/tasks/TaskManager.hpp index 97dcdd2b..77dba916 100644 --- a/include/optionx_cpp/utils/tasks/TaskManager.hpp +++ b/include/optionx_cpp/utils/tasks/TaskManager.hpp @@ -35,18 +35,14 @@ namespace optionx::utils { /// \brief Adds a single execution task. /// \param callback The callback function to execute. bool add_single_task(Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(TaskType::SINGLE, std::move(callback))); - return true; + return add_task(std::make_shared(TaskType::SINGLE, std::move(callback))); } /// \brief Adds a delayed task. /// \param delay_ms Delay in milliseconds before execution. /// \param callback The callback function to execute. bool add_delayed_task(int64_t delay_ms, Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(TaskType::DELAYED_SINGLE, std::move(callback), delay_ms)); - return true; + return add_task(std::make_shared(TaskType::DELAYED_SINGLE, std::move(callback), delay_ms)); } /// \brief Adds a periodic task. @@ -54,8 +50,7 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_periodic_task(int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(TaskType::PERIODIC, std::move(callback), 0, period_ms)); - return true; + return add_task(std::make_shared(TaskType::PERIODIC, std::move(callback), 0, period_ms)); } /// \brief Adds a delayed periodic task. @@ -64,17 +59,14 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_delayed_periodic_task(int64_t delay_ms, int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(TaskType::DELAYED_PERIODIC, std::move(callback), delay_ms, period_ms)); - return true; + return add_task(std::make_shared(TaskType::DELAYED_PERIODIC, std::move(callback), delay_ms, period_ms)); } /// \brief Adds a task to execute at a specific timestamp. /// \param timestamp_ms Timestamp in milliseconds for execution. /// \param callback The callback function to execute. bool add_on_date_task(int64_t timestamp_ms, Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(TaskType::ON_DATE, std::move(callback), 0, 0, timestamp_ms)); - return true; + return add_task(std::make_shared(TaskType::ON_DATE, std::move(callback), 0, 0, timestamp_ms)); } /// \brief Adds a periodic task starting at a specific timestamp. @@ -83,8 +75,7 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_periodic_on_date_task(int64_t timestamp_ms, int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(TaskType::PERIODIC_ON_DATE, std::move(callback), 0, period_ms, timestamp_ms)); - return true; + return add_task(std::make_shared(TaskType::PERIODIC_ON_DATE, std::move(callback), 0, period_ms, timestamp_ms)); } // --- @@ -93,9 +84,7 @@ namespace optionx::utils { /// \param name /// \param callback The callback function to execute. bool add_single_task(std::string name, Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(std::move(name), TaskType::SINGLE, std::move(callback))); - return true; + return add_task(std::make_shared(std::move(name), TaskType::SINGLE, std::move(callback))); } /// \brief Adds a delayed task. @@ -103,9 +92,7 @@ namespace optionx::utils { /// \param delay_ms Delay in milliseconds before execution. /// \param callback The callback function to execute. bool add_delayed_task(std::string name, int64_t delay_ms, Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(std::move(name), TaskType::DELAYED_SINGLE, std::move(callback), delay_ms)); - return true; + return add_task(std::make_shared(std::move(name), TaskType::DELAYED_SINGLE, std::move(callback), delay_ms)); } /// \brief Adds a periodic task. @@ -114,8 +101,7 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_periodic_task(std::string name, int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(std::move(name), TaskType::PERIODIC, std::move(callback), 0, period_ms)); - return true; + return add_task(std::make_shared(std::move(name), TaskType::PERIODIC, std::move(callback), 0, period_ms)); } /// \brief Adds a delayed periodic task. @@ -125,8 +111,7 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_delayed_periodic_task(std::string name, int64_t delay_ms, int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(std::move(name), TaskType::DELAYED_PERIODIC, std::move(callback), delay_ms, period_ms)); - return true; + return add_task(std::make_shared(std::move(name), TaskType::DELAYED_PERIODIC, std::move(callback), delay_ms, period_ms)); } /// \brief Adds a task to execute at a specific timestamp. @@ -134,9 +119,7 @@ namespace optionx::utils { /// \param timestamp_ms Timestamp in milliseconds for execution. /// \param callback The callback function to execute. bool add_on_date_task(std::string name, int64_t timestamp_ms, Task::Callback callback) { - if (m_shutdown) return false; - add_task(std::make_shared(std::move(name), TaskType::ON_DATE, std::move(callback), 0, 0, timestamp_ms)); - return true; + return add_task(std::make_shared(std::move(name), TaskType::ON_DATE, std::move(callback), 0, 0, timestamp_ms)); } /// \brief Adds a periodic task starting at a specific timestamp. @@ -146,8 +129,7 @@ namespace optionx::utils { /// \param callback The callback function to execute. bool add_periodic_on_date_task(std::string name, int64_t timestamp_ms, int64_t period_ms, Task::Callback callback) { if (!can_add_periodic_task(period_ms)) return false; - add_task(std::make_shared(std::move(name), TaskType::PERIODIC_ON_DATE, std::move(callback), 0, period_ms, timestamp_ms)); - return true; + return add_task(std::make_shared(std::move(name), TaskType::PERIODIC_ON_DATE, std::move(callback), 0, period_ms, timestamp_ms)); } /// \brief Processes and executes ready tasks. @@ -264,11 +246,13 @@ namespace optionx::utils { /// \brief Adds a new task to the manager. /// \param task A unique pointer to the task. - void add_task(TaskPtr task) { + bool add_task(TaskPtr task) { std::unique_lock lock(m_mutex); + if (m_shutdown) return false; m_pending_tasks.push_back(std::move(task)); lock.unlock(); m_cv.notify_one(); + return true; } /// \brief Validates state and period before adding a periodic task. diff --git a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp index 94cb4c08..6d8df8b3 100644 --- a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp +++ b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp @@ -467,7 +467,8 @@ struct SiteLoginServer { challenge_body = body; response->write( SimpleWeb::StatusCode::success_ok, - R"JSON({"ok":true,"challenge":"fixture-challenge","bits":8})JSON"); + std::string(R"JSON({"ok":true,"challenge":"fixture-challenge","bits":)JSON") + + std::to_string(challenge_bits.load()) + "}"); return; } @@ -541,6 +542,7 @@ struct SiteLoginServer { std::thread thread; std::atomic login_page_requests{0}; std::atomic challenge_requests{0}; + std::atomic challenge_bits{8}; std::atomic login_requests{0}; std::atomic auth_redirect_requests{0}; std::atomic session_requests{0}; @@ -1134,6 +1136,30 @@ TEST(IntradeBarLogin, ParsesSiteLoginContractByContent) { std::runtime_error); } +TEST(IntradeBarLogin, BoundsAndCancelsSiteLoginPow) { + EXPECT_FALSE(solve_site_login_pow("fixture-challenge", 8, 1)); + + std::atomic cancellation_checks{0}; + const auto cancelled = solve_site_login_pow( + "fixture-challenge", + 8, + 1000, + [&cancellation_checks]() { + return ++cancellation_checks > 1; + }); + EXPECT_FALSE(cancelled); + EXPECT_GT(cancellation_checks.load(), 1); +} + +TEST(IntradeBarLogin, EnforcesMonotonicSiteLoginPowDeadline) { + EXPECT_FALSE(solve_site_login_pow( + "fixture-challenge", + 8, + std::numeric_limits::max(), + {}, + std::chrono::steady_clock::now())); +} + TEST(IntradeBarLogin, AnalyzesLegacyModernAndUnknownRedirectChains) { const auto legacy_redirect = std::make_unique(); legacy_redirect->status_code = 302; @@ -1234,19 +1260,17 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { bool main_callback_received = false; bool main_success = false; + MainPageChallenge main_challenge; std::string main_reason; request_manager.request_main_page( auth_data, [&](bool success, - const std::string& req_id, - const std::string& req_value, - const std::string&, + const MainPageChallenge& challenge, const std::string& reason) { main_callback_received = true; main_success = success; + main_challenge = challenge; main_reason = reason; - EXPECT_TRUE(req_id.empty()); - EXPECT_TRUE(req_value.empty()); }); for (int i = 0; i < 500 && !main_callback_received; ++i) { @@ -1255,6 +1279,7 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { } ASSERT_TRUE(main_callback_received); ASSERT_TRUE(main_success) << main_reason; + EXPECT_EQ(main_challenge.flow, LoginPageFlow::MODERN_NUXT); bool callback_received = false; bool success = false; @@ -1263,9 +1288,7 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { std::string cookies; std::string reason; request_manager.request_login( - "", - "", - "landing=1", + main_challenge, auth_data, [&](bool login_success, const std::string& login_user_id, @@ -1282,6 +1305,7 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { for (int i = 0; i < 500 && !callback_received; ++i) { http_client.process(); + platform.process(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -1308,9 +1332,7 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { cookies.clear(); reason.clear(); request_manager.request_login( - "", - "", - "landing=1", + main_challenge, auth_data, [&](bool login_success, const std::string& login_user_id, @@ -1327,6 +1349,7 @@ TEST(IntradeBarLogin, UsesModernTradingSpaLoginContract) { for (int i = 0; i < 500 && !callback_received; ++i) { http_client.process(); + platform.process(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -1377,6 +1400,7 @@ TEST(IntradeBarLogin, UsesSiteFormWithoutHostnameSpecialCase) { for (int i = 0; i < 1000 && !page_callback_received; ++i) { http_client.process(); + platform.process(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -1421,6 +1445,7 @@ TEST(IntradeBarLogin, UsesSiteFormWithoutHostnameSpecialCase) { for (int i = 0; i < 1000 && !callback_received; ++i) { http_client.process(); + platform.process(); std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -1446,6 +1471,94 @@ TEST(IntradeBarLogin, UsesSiteFormWithoutHostnameSpecialCase) { platform.shutdown(); } +TEST(IntradeBarLogin, LegacyMainPageOverloadRejectsSiteForm) { + SiteLoginServer server; + ASSERT_TRUE(server.start()); + + TestPlatform platform; + HttpClientComponent http_client(platform); + RequestManager request_manager(platform, http_client); + + auto auth_data = std::make_shared(); + auth_data->host = server.host(); + auth_data->email = "user@example.test"; + auth_data->password = "fake_password"; + + events::AuthDataEvent auth_event(auth_data); + request_manager.on_event(&auth_event); + http_client.get_http_client().set_retry_attempts(0, 0); + + bool callback_received = false; + bool success = true; + std::string reason; + request_manager.request_main_page( + auth_data, + [&](bool callback_success, + const std::string&, + const std::string&, + const std::string&, + const std::string& callback_reason) { + callback_received = true; + success = callback_success; + reason = callback_reason; + }); + + for (int i = 0; i < 1000 && !callback_received; ++i) { + http_client.process(); + platform.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + ASSERT_TRUE(callback_received) << reason; + EXPECT_FALSE(success); + EXPECT_EQ(reason, "Detected login flow requires the typed RequestManager API."); + EXPECT_EQ(server.challenge_requests.load(), 1); + EXPECT_EQ(server.login_requests.load(), 0); + platform.shutdown(); +} + +TEST(IntradeBarLogin, ShutsDownWhileSiteLoginPowIsRunning) { + SiteLoginServer server; + server.challenge_bits.store(20); + ASSERT_TRUE(server.start()); + + TestPlatform platform; + HttpClientComponent http_client(platform); + RequestManager request_manager(platform, http_client); + + auto auth_data = std::make_shared(); + auth_data->host = server.host(); + auth_data->email = "user@example.test"; + auth_data->password = "fake_password"; + + events::AuthDataEvent auth_event(auth_data); + request_manager.on_event(&auth_event); + http_client.get_http_client().set_retry_attempts(0, 0); + + std::atomic callback_received{false}; + request_manager.request_main_page( + auth_data, + [&](bool, + const MainPageChallenge&, + const std::string&) { + callback_received.store(true); + }); + + for (int i = 0; i < 1000 && server.challenge_requests.load() == 0; ++i) { + http_client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + ASSERT_EQ(server.challenge_requests.load(), 1); + + // Deliver the challenge response, then cancel the worker before it can + // complete a high-difficulty search or post a stale owner-loop callback. + http_client.process(); + platform.shutdown(); + + EXPECT_FALSE(callback_received.load()); + EXPECT_EQ(server.login_requests.load(), 0); +} + TEST(IntradeBarLogin, CachedModernSessionRoundTripRetainsUserId) { auto& session_db = optionx::storage::ServiceSessionDB::get_instance(); const std::string platform = to_str(PlatformType::INTRADE_BAR); @@ -3976,6 +4089,7 @@ TEST(IntradeBarApiResponses, PlatformBarHistoryResultPreservesFailureReason) { ++callback_count; })); + platform.process(); platform.shutdown(); ASSERT_EQ(callback_count, 1); @@ -3997,6 +4111,7 @@ TEST(IntradeBarApiResponses, PlatformBarHistoryRejectsNegativeTimeframe) { ++callback_count; })); + platform.process(); platform.shutdown(); ASSERT_EQ(callback_count, 1); diff --git a/tests/utils_task_manager_test.cpp b/tests/utils_task_manager_test.cpp index 66132a7c..cd7cc418 100644 --- a/tests/utils_task_manager_test.cpp +++ b/tests/utils_task_manager_test.cpp @@ -1,5 +1,10 @@ #include +#include +#include +#include +#include + #include TEST(LogRedactionTest, RedactsNonEmptySecrets) { @@ -38,6 +43,42 @@ TEST(TaskManagerTest, RejectsInvalidPeriodicPeriods) { EXPECT_FALSE(manager.add_periodic_on_date_task("bad-periodic-on-date", 1, 0, callback)); } +TEST(TaskManagerTest, RejectsQueueInsertionAfterShutdownBegins) { + optionx::utils::TaskManager manager; + std::atomic task_started{false}; + std::atomic release_task{false}; + + ASSERT_TRUE(manager.add_single_task( + [&task_started, &release_task](std::shared_ptr) { + task_started.store(true, std::memory_order_release); + while (!release_task.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + })); + manager.run(); + + for (int i = 0; i < 1000 && !task_started.load(std::memory_order_acquire); ++i) { + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + ASSERT_TRUE(task_started.load(std::memory_order_acquire)); + + auto shutdown_future = std::async(std::launch::async, [&manager]() { + manager.shutdown(); + }); + + bool rejected = false; + for (int i = 0; i < 10000 && !rejected; ++i) { + rejected = !manager.add_single_task( + [](std::shared_ptr) {}); + if (!rejected) std::this_thread::yield(); + } + + release_task.store(true, std::memory_order_release); + ASSERT_EQ(shutdown_future.wait_for(std::chrono::seconds(2)), std::future_status::ready); + shutdown_future.get(); + EXPECT_TRUE(rejected); +} + TEST(TaskTest, KeepsPreviousPeriodWhenSetPeriodIsInvalid) { int calls = 0; auto task = std::make_shared( From bd0de2e3add15dddae1bb3f5a89bf23942904046 Mon Sep 17 00:00:00 2001 From: LimiNode <20008174+LimiNode@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:22:10 +0300 Subject: [PATCH 3/3] fix(intrade): complete queued requests and release pow slots Complete accepted Intrade trade-result, trade-history, and bar-history requests exactly once when manager admission fails or platform shutdown cancels the owner-loop task. Release site-login proof-of-work admission when CPU work ends instead of retaining it through queued delivery, and document the owner-loop callback contract with lifecycle regressions. --- guides/implementation-notes.md | 12 +- .../platforms/IntradeBarPlatform.hpp | 121 ++++++++++++++++-- .../IntradeBarPlatform/RequestManager.hpp | 55 ++++++-- .../platforms/common/BaseTradingPlatform.hpp | 23 +++- tests/intrade_auth_lifecycle_test.cpp | 106 +++++++++++++++ tests/lifecycle_event_safety_test.cpp | 20 +++ 6 files changed, 308 insertions(+), 29 deletions(-) diff --git a/guides/implementation-notes.md b/guides/implementation-notes.md index 822b4815..290da4b9 100644 --- a/guides/implementation-notes.md +++ b/guides/implementation-notes.md @@ -161,6 +161,12 @@ Platform-owned HTTP state is confined to the platform owner loop. before delegating to a manager. IntradeBar trade-result, trade-history, and bar-history facade methods follow this rule; local read-only snapshots may remain synchronous when their storage is independently synchronized. +- A `true` result from a facade enqueue means that the owner-loop task was + accepted, not that the broker request has completed. Once accepted, a typed + operation callback must complete exactly once on success, manager rejection, + validation failure, or pre-execution platform-shutdown cancellation. Use the + cancellation-aware `BaseTradingPlatform::post_task()` overload for this + contract; do not silently drop a callback when a queued task is drained. - External threads must enqueue work with `BaseTradingPlatform::post_task()` or use a facade API that performs the enqueue. They must not call `RequestManager` or mutate `HttpClient` configuration directly. @@ -168,9 +174,11 @@ Platform-owned HTTP state is confined to the platform owner loop. the owner loop. CPU-heavy helper work must use a dedicated bounded worker and return its result through `post_task()`. Such work needs both a bounded attempt count and a monotonic deadline; admission must be explicitly bounded - (currently one site-login PoW operation), with the slot released on every + (currently one site-login PoW CPU calculation), with the slot released when + the calculation ends and on every terminal path including cancellation, timeout, scheduler rejection, and - shutdown. + shutdown. A queued owner-loop continuation does not hold the CPU admission + slot. - Shutdown stops the platform loop before component destruction. Dedicated workers must be cancelable and joined, and their completion callbacks must check shutdown and operation-generation state before touching platform data. diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp index 1f901089..6eea4642 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform.hpp @@ -110,19 +110,69 @@ namespace optionx::platforms { std::unique_ptr result, trade_result_check_callback_t callback) override { if (!result || !callback) return false; + auto result_holder = std::make_shared>(std::move(result)); + auto fallback_result = result_holder->get()->clone_shared(); + auto completed = std::make_shared>(false); + auto callback_holder = + std::make_shared(std::move(callback)); + auto complete = std::make_shared< + std::function)>>(); + *complete = [completed, callback_holder, fallback_result]( + std::unique_ptr completed_result) mutable { + if (completed->exchange(true, std::memory_order_acq_rel)) return; + if (!completed_result) { + completed_result = fallback_result->clone_unique(); + completed_result->trade_state = completed_result->live_state = TradeState::CHECK_ERROR; + completed_result->error_code = TradeErrorCode::PARSING_ERROR; + completed_result->error_desc = + "Intrade Bar trade result callback returned no result."; + } + auto terminal_callback = std::move(*callback_holder); + if (terminal_callback) { + terminal_callback(std::move(completed_result)); + } + }; + auto fail = [complete, fallback_result]( + TradeErrorCode error_code, + std::string reason) mutable { + auto failed_result = fallback_result->clone_unique(); + failed_result->trade_state = failed_result->live_state = TradeState::CHECK_ERROR; + failed_result->error_code = error_code; + failed_result->error_desc = std::move(reason); + (*complete)(std::move(failed_result)); + }; + return post_task( [this, query = std::move(query), result_holder, - callback = std::move(callback)]() mutable { + complete, + fail]() mutable { auto owned_result = std::move(*result_holder); - if (!owned_result) return; - m_trade_manager.fetch_trade_result( + if (!owned_result) { + fail( + TradeErrorCode::INVALID_REQUEST, + "Intrade Bar trade result request lost its result object."); + return; + } + const bool accepted = m_trade_manager.fetch_trade_result( std::move(query), std::move(owned_result), - std::move(callback)); + [complete](std::unique_ptr fetched_result) mutable { + (*complete)(std::move(fetched_result)); + }); + if (!accepted) { + fail( + TradeErrorCode::INVALID_REQUEST, + "Intrade Bar trade result request was rejected by TradeManager."); + } + }, + [fail]() mutable { + fail( + TradeErrorCode::CLIENT_FORCED_CLOSE, + "Intrade Bar trade result request was cancelled during platform shutdown."); }); } @@ -135,11 +185,35 @@ namespace optionx::platforms { const TradeHistoryRequest& request, trade_history_callback_t callback) override { if (!callback || !request.has_valid_range()) return false; + + auto completed = std::make_shared>(false); + auto callback_holder = + std::make_shared(std::move(callback)); + auto complete = std::make_shared< + std::function>(); + *complete = [completed, callback_holder]( + TradeHistoryResult result) mutable { + if (completed->exchange(true, std::memory_order_acq_rel)) return; + auto terminal_callback = std::move(*callback_holder); + if (terminal_callback) terminal_callback(std::move(result)); + }; + auto fail = [complete](std::string reason) mutable { + (*complete)(TradeHistoryResult::fail(std::move(reason))); + }; + return post_task( - [this, request, callback = std::move(callback)]() mutable { - m_trade_manager.fetch_trade_history( + [this, request, complete, fail]() mutable { + const bool accepted = m_trade_manager.fetch_trade_history( request, - std::move(callback)); + [complete](TradeHistoryResult result) mutable { + (*complete)(std::move(result)); + }); + if (!accepted) { + fail("Intrade Bar trade history request was rejected by TradeManager."); + } + }, + [fail]() mutable { + fail("Intrade Bar trade history request was cancelled during platform shutdown."); }); } @@ -162,26 +236,49 @@ namespace optionx::platforms { const BarHistoryRequest& request, market_data::BaseMarketDataProvider::bar_history_callback_t callback) override { if (!callback) return false; + + auto completed = std::make_shared>(false); + auto callback_holder = + std::make_shared( + std::move(callback)); + auto complete = std::make_shared< + std::function>(); + *complete = [completed, callback_holder]( + BarHistoryResult result) mutable { + if (completed->exchange(true, std::memory_order_acq_rel)) return; + auto terminal_callback = std::move(*callback_holder); + if (terminal_callback) terminal_callback(std::move(result)); + }; + auto fail = [complete](std::string reason) mutable { + (*complete)(BarHistoryResult::fail(std::move(reason))); + }; + return post_task( - [this, request, callback = std::move(callback)]() mutable { - m_request_manager.request_bar_history_result( + [this, request, complete, fail]() mutable { + const bool accepted = m_request_manager.request_bar_history_result( request, - [callback = std::move(callback), request](intrade_bar::BarHistoryApiResult result) mutable { + [complete, request](intrade_bar::BarHistoryApiResult result) mutable { if (result) { const auto range_complete = intrade_bar::bar_sequence_covers_dense_range( result.value, request); - callback(BarHistoryResult::ok( + (*complete)(BarHistoryResult::ok( std::move(result.value), result.status_code, range_complete)); return; } - callback(BarHistoryResult::fail( + (*complete)(BarHistoryResult::fail( std::move(result.error_message), result.status_code)); }); + if (!accepted) { + fail("Intrade Bar bar-history request was rejected by RequestManager."); + } + }, + [fail]() mutable { + fail("Intrade Bar bar-history request was cancelled during platform shutdown."); }); } diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp index c34249e9..ba01a23f 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp @@ -309,7 +309,9 @@ namespace optionx::platforms::intrade_bar { /// \brief Requests historical bars from the broker market-data backend. /// \param request Symbol, timeframe, range, and preferred price source. /// \param callback Callback receiving a typed bar history result. - void request_bar_history_result( + /// \return True when the request was admitted or completed synchronously; + /// false when the request callback itself was invalid. + bool request_bar_history_result( const BarHistoryRequest& request, std::function callback); @@ -381,13 +383,26 @@ namespace optionx::platforms::intrade_bar { } ~PowOperation() { + release_pow_slot(); + } + + /// \brief Releases the CPU admission slot exactly once. + /// + /// `pow_active` describes the proof-of-work calculation itself, + /// not the lifetime of the owner-loop continuation. The fallback + /// destructor release protects scheduler rejection and early exits, + /// while the worker calls this explicitly before posting delivery. + void release_pow_slot() noexcept { if (lifecycle) { - lifecycle->pow_active.store(false, std::memory_order_release); + if (!pow_slot_released.exchange(true, std::memory_order_acq_rel)) { + lifecycle->pow_active.store(false, std::memory_order_release); + } } } std::atomic cancelled{false}; std::atomic completed{false}; + std::atomic pow_slot_released{false}; std::shared_ptr lifecycle; }; @@ -903,7 +918,15 @@ namespace optionx::platforms::intrade_bar { return; } - const auto operation = std::make_shared(lifecycle); + std::shared_ptr operation; + try { + operation = std::make_shared(lifecycle); + } catch (...) { + // The admission flag is set before the operation object + // exists, so also release it if allocation itself fails. + lifecycle->pow_active.store(false, std::memory_order_release); + throw; + } const auto post_completion = [owner_loop, lifecycle, @@ -990,13 +1013,18 @@ namespace optionx::platforms::intrade_bar { std::uint64_t{1} << (parsed_bits + 4U), cancelled, std::chrono::steady_clock::now() + SITE_LOGIN_POW_TIME_BUDGET); + // The continuation may wait in the owner-loop queue; + // that wait must not hold the CPU admission slot. + operation->release_pow_slot(); if (cancelled()) return; post_completion(std::move(nonce), {}); } catch (const std::exception& ex) { + operation->release_pow_slot(); post_completion( std::nullopt, std::string("Intrade site login proof-of-work failed: ") + ex.what()); } catch (...) { + operation->release_pow_slot(); post_completion( std::nullopt, "Intrade site login proof-of-work failed with an unknown exception."); @@ -3112,22 +3140,22 @@ namespace optionx::platforms::intrade_bar { }); } - inline void RequestManager::request_bar_history_result( + inline bool RequestManager::request_bar_history_result( const BarHistoryRequest& request, std::function callback) { - if (!callback) return; + if (!callback) return false; if (request.symbol.empty()) { callback(BarHistoryApiResult::fail("Bar history symbol is required.")); - return; + return true; } if (request.timeframe <= 0) { callback(BarHistoryApiResult::fail("Bar history timeframe is invalid.")); - return; + return true; } if (request.from_ts <= 0 || request.to_ts < request.from_ts) { callback(BarHistoryApiResult::fail("Bar history request range is invalid.")); - return; + return true; } BarHistoryRequest effective_request = request; @@ -3154,7 +3182,7 @@ namespace optionx::platforms::intrade_bar { const auto source = use_binance ? BarPriceSource::LAST : effective_request.price_source; callback(BarHistoryApiResult::ok( make_empty_sequence(effective_request, source))); - return; + return true; } std::string binance_interval; @@ -3165,14 +3193,14 @@ namespace optionx::platforms::intrade_bar { effective_request.price_source == BarPriceSource::UNKNOWN) { callback(BarHistoryApiResult::fail( "BTCUSDT bar history is available only as LAST trade-price bars.")); - return; + return true; } binance_interval = binance_interval_from_timeframe(effective_request.timeframe); if (binance_interval.empty()) { callback(BarHistoryApiResult::fail( "Unsupported Binance kline timeframe for BTCUSDT history.")); - return; + return true; } max_bars_per_request = BINANCE_KLINES_MAX_BARS_PER_REQUEST; } else { @@ -3180,12 +3208,12 @@ namespace optionx::platforms::intrade_bar { effective_request.price_source == BarPriceSource::LAST) { callback(BarHistoryApiResult::fail( "Intrade FX bar history supports only BID, ASK, or MID price sources.")); - return; + return true; } if (effective_request.timeframe % time_shield::SEC_PER_MIN != 0) { callback(BarHistoryApiResult::fail( "Intrade FX bar history supports minute-based timeframes only.")); - return; + return true; } } @@ -3302,6 +3330,7 @@ namespace optionx::platforms::intrade_bar { }; (*request_next)(); + return true; } inline void RequestManager::request_trade_check_result( diff --git a/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp b/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp index 98b6b944..911ebe25 100644 --- a/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp +++ b/include/optionx_cpp/platforms/common/BaseTradingPlatform.hpp @@ -200,6 +200,21 @@ namespace optionx::platforms { /// \param callback Work that must execute in the platform owner loop. /// \return True when the task was accepted for owner-loop processing. bool post_task(std::function callback) { + return post_task(std::move(callback), {}); + } + + /// \brief Queues owner-loop work with a callback for shutdown cancellation. + /// \details The cancellation callback is invoked when an already accepted + /// task reaches the TaskManager shutdown drain before its work + /// callback executes. It is not invoked when admission itself + /// fails. This lets typed public operations complete their + /// callback contract without changing the general scheduler. + /// \param callback Work that must execute in the platform owner loop. + /// \param cancellation_callback Terminal callback for pre-execution shutdown cancellation. + /// \return True when the task was accepted for owner-loop processing. + bool post_task( + std::function callback, + std::function cancellation_callback) { if (!callback) return false; std::lock_guard lock(m_lifecycle_mutex); @@ -210,9 +225,13 @@ namespace optionx::platforms { return m_task_manager.add_single_task( "external", - [callback = std::move(callback)]( + [callback = std::move(callback), + cancellation_callback = std::move(cancellation_callback)]( std::shared_ptr task) mutable { - if (task->is_shutdown()) return; + if (task->is_shutdown()) { + if (cancellation_callback) cancellation_callback(); + return; + } callback(); }); } diff --git a/tests/intrade_auth_lifecycle_test.cpp b/tests/intrade_auth_lifecycle_test.cpp index 3b7aeab8..885d1b0c 100644 --- a/tests/intrade_auth_lifecycle_test.cpp +++ b/tests/intrade_auth_lifecycle_test.cpp @@ -1,5 +1,6 @@ #include +#include #include #include #include @@ -168,6 +169,111 @@ TEST(IntradeBarAuthLifecycle, InvalidAuthDataReportsValidationFailure) { platform.shutdown(); } +TEST(IntradeBarPublicRequests, OwnerLoopAndForeignThreadCallsHaveOneTerminalCallback) { + optionx::platforms::IntradeBarPlatform platform; + platform.run(false); + + std::atomic callback_count{0}; + std::atomic foreign_accepted{false}; + optionx::TradeHistoryResult owner_result; + optionx::TradeHistoryResult foreign_result; + + ASSERT_TRUE(platform.fetch_trade_history( + optionx::TradeHistoryRequest::all(), + [&callback_count, &owner_result](optionx::TradeHistoryResult result) { + owner_result = std::move(result); + callback_count.fetch_add(1, std::memory_order_release); + })); + platform.process(); + + std::thread caller([&]() { + foreign_accepted.store(platform.fetch_trade_history( + optionx::TradeHistoryRequest::all(), + [&callback_count, &foreign_result](optionx::TradeHistoryResult result) { + foreign_result = std::move(result); + callback_count.fetch_add(1, std::memory_order_release); + }), + std::memory_order_release); + }); + caller.join(); + + EXPECT_TRUE(foreign_accepted.load(std::memory_order_acquire)); + EXPECT_EQ(callback_count.load(std::memory_order_acquire), 1); + platform.process(); + + EXPECT_EQ(callback_count.load(std::memory_order_acquire), 2); + EXPECT_FALSE(owner_result.success); + EXPECT_FALSE(foreign_result.success); + platform.shutdown(); +} + +TEST(IntradeBarPublicRequests, AcceptedRequestsCompleteWhenShutdownCancelsOwnerTask) { + optionx::platforms::IntradeBarPlatform platform; + platform.run(false); + + int history_callbacks = 0; + int result_callbacks = 0; + int bar_callbacks = 0; + optionx::TradeHistoryResult history_result; + std::unique_ptr trade_result; + optionx::BarHistoryResult bar_result; + + optionx::TradeHistoryRequest history_request = + optionx::TradeHistoryRequest::all(); + ASSERT_TRUE(platform.fetch_trade_history( + history_request, + [&history_callbacks, &history_result](optionx::TradeHistoryResult result) { + ++history_callbacks; + history_result = std::move(result); + })); + + auto result = std::make_unique(); + result->trade_id = 77; + optionx::TradeResultQuery query; + query.trade_id = result->trade_id; + ASSERT_TRUE(platform.fetch_trade_result( + query, + std::move(result), + [&result_callbacks, &trade_result](std::unique_ptr completed) { + ++result_callbacks; + trade_result = std::move(completed); + })); + + optionx::BarHistoryRequest bar_request; + bar_request.symbol = "EURUSD"; + bar_request.timeframe = 60; + bar_request.from_ts = 1; + bar_request.to_ts = 60; + ASSERT_TRUE(platform.fetch_bar_history( + bar_request, + [&bar_callbacks, &bar_result](optionx::BarHistoryResult result) { + ++bar_callbacks; + bar_result = std::move(result); + })); + + platform.shutdown(); + + EXPECT_EQ(history_callbacks, 1); + EXPECT_FALSE(history_result.success); + EXPECT_NE( + history_result.error_desc.find("cancelled during platform shutdown"), + std::string::npos); + + EXPECT_EQ(result_callbacks, 1); + ASSERT_TRUE(trade_result); + EXPECT_EQ(trade_result->trade_id, 77u); + EXPECT_EQ(trade_result->trade_state, optionx::TradeState::CHECK_ERROR); + EXPECT_EQ( + trade_result->error_code, + optionx::TradeErrorCode::CLIENT_FORCED_CLOSE); + + EXPECT_EQ(bar_callbacks, 1); + EXPECT_FALSE(bar_result.success); + EXPECT_NE( + bar_result.error_desc.find("cancelled during platform shutdown"), + std::string::npos); +} + int main(int argc, char** argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/tests/lifecycle_event_safety_test.cpp b/tests/lifecycle_event_safety_test.cpp index 472e49f5..a0ba3f4e 100644 --- a/tests/lifecycle_event_safety_test.cpp +++ b/tests/lifecycle_event_safety_test.cpp @@ -157,6 +157,26 @@ TEST(BaseTradingPlatformLifecycle, PostsForeignThreadWorkToTheOwnerLoop) { EXPECT_FALSE(platform.post_task([]() {})); } +TEST(BaseTradingPlatformLifecycle, AcceptedPostedWorkCompletesCancellationOnShutdown) { + TestPlatform platform; + platform.run(false); + std::atomic executed{false}; + std::atomic cancelled{false}; + + ASSERT_TRUE(platform.post_task( + [&executed]() { + executed.store(true, std::memory_order_release); + }, + [&cancelled]() { + cancelled.store(true, std::memory_order_release); + })); + + platform.shutdown(); + + EXPECT_FALSE(executed.load(std::memory_order_acquire)); + EXPECT_TRUE(cancelled.load(std::memory_order_acquire)); +} + TEST(BaseTradingPlatformLifecycle, ConcurrentRunAndShutdownDoNotRunAfterStop) { for (int attempt = 0; attempt < 64; ++attempt) { TestPlatform platform;