From 3032a0548d79b1eadc520ed5d4fc40bea94fef00 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:27:31 +0200 Subject: [PATCH] fix(clob): resolve missing final settlement hashes --- docs/order-flow.md | 10 ++-- include/polymarket/clob_client.hpp | 10 ++-- src/clob_order_settlement.cpp | 23 ++++++--- tests/test_stream_settlement.cpp | 79 +++++++++++++++++++++++++++++- 4 files changed, 108 insertions(+), 14 deletions(-) diff --git a/docs/order-flow.md b/docs/order-flow.md index 843336c..3a8059f 100644 --- a/docs/order-flow.md +++ b/docs/order-flow.md @@ -115,6 +115,8 @@ if (settlement) - An order without fills returns at once with the order's own hashes. - `transaction_hashes` holds the unique hashes of fills that did not fail. `trades` holds every fill's final `Trade`, both in `trade_ids` order. +- A confirmed fill without a transaction hash stays pending until its final + hash is available. Earlier MINED or order-response hashes are not reused. - The wait blocks the calling thread. It polls with a monotonic deadline, never sleeps past it, and polls once more at the deadline. A 429 whose Retry-After would end past the deadline is returned as `RateLimit` instead @@ -138,9 +140,11 @@ stream.on_stream_recovered([&] { tracker.mark_recovered(); }); auto settlement = client.wait_for_order_fill_settlement(placed.value(), tracker); ``` -- The wait returns as soon as the stream reports every fill CONFIRMED or - FAILED, with the same `OrderSettlement` as the polling form. -- Fills are looked up through REST only after `mark_recovered()`, every +- The wait returns as soon as the stream reports every fill CONFIRMED with + its final hash or FAILED, with the same `OrderSettlement` as the polling form. +- A confirmation without a final hash triggers an immediate REST lookup. + If the hash is still missing, normal reconciliation continues until the deadline. +- Fills are also looked up through REST after `mark_recovered()`, every `reconcile_interval` (5 seconds by default) as a safety net, and once at the deadline. - The tracker keeps the latest status of its most recent `capacity` trades diff --git a/include/polymarket/clob_client.hpp b/include/polymarket/clob_client.hpp index 6e58ceb..56f9de7 100644 --- a/include/polymarket/clob_client.hpp +++ b/include/polymarket/clob_client.hpp @@ -191,8 +191,9 @@ namespace polymarket Result> get_trade_result(const std::string &trade_id); // Blocks until every fill in `order.trade_ids` is CONFIRMED or FAILED, - // polling get_trade_result. Covers only the fills matched when the - // order was posted, not later fills of a remainder resting on the book. + // polling get_trade_result. Confirmed fills must have a final hash. + // Covers only the fills matched when the order was posted, not later + // fills of a remainder resting on the book. // Fails with Timeout while fills are still settling (the order itself // is unaffected), TransactionFailed when every fill failed, or the // error of the first trade lookup that fails. @@ -203,8 +204,9 @@ namespace polymarket // Same result, driven by fills a UserStream reports through `tracker`: // returns as soon as the stream shows every fill settled. Trades are - // looked up through REST only after the stream recovers from a gap, - // every `reconcile_interval` as a safety net, and once at the deadline. + // looked up through REST when a confirmation lacks its final hash, + // after the stream recovers from a gap, every `reconcile_interval` as + // a safety net, and once at the deadline. Result wait_for_order_fill_settlement( const OrderResponse &order, const TradeStatusTracker &tracker, std::chrono::milliseconds timeout = std::chrono::seconds(30), diff --git a/src/clob_order_settlement.cpp b/src/clob_order_settlement.cpp index 449cb8c..3097043 100644 --- a/src/clob_order_settlement.cpp +++ b/src/clob_order_settlement.cpp @@ -16,9 +16,10 @@ namespace polymarket { constexpr const char *trades_endpoint = "/data/trades"; - bool is_settled(const Trade &trade) + bool has_settlement_details(const Trade &trade) { - return detail::is_settled_trade_status(trade.status); + const auto status = detail::normalized_trade_status(trade.status); + return status == "FAILED" || (status == "CONFIRMED" && !trade.transaction_hash.empty()); } bool is_failed(const Trade &trade) @@ -166,7 +167,7 @@ namespace polymarket if (settled[index]) continue; auto trade = lookup_trade(trade_ids[index], deadline); if (!trade) return Result::failure(trade.error()); - if (trade.value() && is_settled(*trade.value())) + if (trade.value() && has_settlement_details(*trade.value())) settled[index] = std::move(trade.value()); else pending.push_back(trade_ids[index]); @@ -195,24 +196,34 @@ namespace polymarket if (trade_ids.empty()) return Result::success(unfilled_settlement(order)); std::vector> settled(trade_ids.size()); + std::vector hash_lookup_requested(trade_ids.size(), false); const auto deadline = std::chrono::steady_clock::now() + timeout; auto next_reconcile = std::chrono::steady_clock::now() + reconcile_interval; auto reconciled_recoveries = tracker.recoveries(); while (true) { const auto seen_version = tracker.version(); + bool needs_hash_lookup = false; for (std::size_t index = 0; index < trade_ids.size(); ++index) { if (settled[index]) continue; auto trade = tracker.latest(trade_ids[index]); - if (trade && is_settled(*trade)) settled[index] = std::move(trade); + if (trade && has_settlement_details(*trade)) + settled[index] = std::move(trade); + else if (trade && detail::is_settled_trade_status(trade->status) && + !hash_lookup_requested[index]) + { + hash_lookup_requested[index] = true; + needs_hash_lookup = true; + } } auto pending = pending_ids(trade_ids, settled); if (pending.empty()) break; const auto now = std::chrono::steady_clock::now(); const auto recoveries = tracker.recoveries(); - if (now >= next_reconcile || now >= deadline || recoveries != reconciled_recoveries) + if (needs_hash_lookup || now >= next_reconcile || now >= deadline || + recoveries != reconciled_recoveries) { reconciled_recoveries = recoveries; for (std::size_t index = 0; index < trade_ids.size(); ++index) @@ -220,7 +231,7 @@ namespace polymarket if (settled[index]) continue; auto trade = lookup_trade(trade_ids[index], deadline); if (!trade) return Result::failure(trade.error()); - if (trade.value() && is_settled(*trade.value())) + if (trade.value() && has_settlement_details(*trade.value())) settled[index] = std::move(trade.value()); } pending = pending_ids(trade_ids, settled); diff --git a/tests/test_stream_settlement.cpp b/tests/test_stream_settlement.cpp index 3f4d6bc..8994d4c 100644 --- a/tests/test_stream_settlement.cpp +++ b/tests/test_stream_settlement.cpp @@ -1,4 +1,5 @@ #include "../src/clob_client_test_fixture.hpp" +#include "../src/user_stream_protocol.hpp" #include "check_support.hpp" #include "polymarket/trade_status_tracker.hpp" @@ -8,6 +9,7 @@ #include #include #include +#include #include using namespace polymarket; @@ -17,7 +19,7 @@ using namespace std::chrono_literals; namespace { std::string trade_page(const std::string &id, const std::string &status, - const std::string &hash = "") + const nlohmann::json &hash = "") { nlohmann::json trade = { {"id", id}, @@ -96,6 +98,78 @@ namespace "a late earlier-stage event must not replace a final status"); } + void test_sparse_stream_confirmation_resolves_final_hash() + { + for (const auto &hash_fields : + {nlohmann::json::object(), nlohmann::json{{"transaction_hash", nullptr}}, + nlohmann::json{{"transaction_hash", ""}}}) + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + TradeStatusTracker tracker; + tracker.record(trade_event("t1", "MINED", "0xold")); + auto payload = + nlohmann::json::parse(trade_page("t1", "TRADE_STATUS_CONFIRMED")).at("data").at(0); + payload["event_type"] = "trade"; + payload.erase("transaction_hash"); + payload.update(hash_fields); + const auto events = detail::parse_user_events(payload.dump()); + tracker.record(std::get(events.at(0))); + server.enqueue(trade_page("t1", "CONFIRMED", "0xfinal")); + auto order = matched_order({"t1"}); + order.transaction_hashes = {"0xold"}; + const auto started = std::chrono::steady_clock::now(); + const auto settled = client.wait_for_order_fill_settlement(order, tracker, 5s, 10s); + check(settled && + settled.value().transaction_hashes == std::vector{"0xfinal"} && + settled.value().trades.at(0).transaction_hash == "0xfinal", + "sparse confirmations must resolve the final hash instead of reusing MINED"); + check(server.requests().size() == 1 && std::chrono::steady_clock::now() - started < 2s, + "a sparse confirmation must reconcile immediately, before the safety interval"); + } + } + + void test_rest_confirmation_without_hash_keeps_waiting() + { + for (const bool use_stream : {false, true}) + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + TradeStatusTracker tracker; + tracker.record(trade_event("t1", "CONFIRMED")); + server.enqueue(trade_page("t1", "CONFIRMED", nullptr)); + server.enqueue(trade_page("t1", "CONFIRMED", "0xfinal")); + const auto order = matched_order({"t1"}); + const auto settled = + use_stream ? client.wait_for_order_fill_settlement(order, tracker, 5s, 20ms) + : client.wait_for_order_fill_settlement(order, 5s, 20ms); + check(settled && + settled.value().transaction_hashes == std::vector{"0xfinal"}, + "a confirmed REST trade without a hash must not complete either wait"); + check(server.requests().size() == 2, "only pending hash details must be reconciled"); + } + } + + void test_missing_hash_timeout_and_lookup_error() + { + clob_test::LocalServer server; + auto client = clob_test::authenticated_client(server.url()); + TradeStatusTracker tracker; + tracker.record(trade_event("t1", "CONFIRMED")); + server.enqueue(trade_page("t1", "CONFIRMED", nullptr)); + const auto order = matched_order({"t1"}); + const auto timeout = client.wait_for_order_fill_settlement(order, tracker, 0ms, 10s); + check(!timeout && timeout.error().code == SdkErrorCode::Timeout && + timeout.error().retryable, + "missing final hashes must time out rather than return empty success"); + server.enqueue(R"({"error":"unavailable"})", 500); + const auto failed = client.wait_for_order_fill_settlement(order, tracker, 5s, 10s); + check(!failed && failed.error().http_status == 500 && + failed.error().endpoint == "/data/trades", + "hash reconciliation must preserve the original REST error"); + check(server.requests().size() == 2, "each incomplete confirmation must be checked once"); + } + void test_recovery_reconciles_through_rest() { clob_test::LocalServer server; @@ -192,6 +266,9 @@ int main() { test_stream_confirmation_needs_no_rest(); test_final_status_is_sticky(); + test_sparse_stream_confirmation_resolves_final_hash(); + test_rest_confirmation_without_hash_keeps_waiting(); + test_missing_hash_timeout_and_lookup_error(); test_recovery_reconciles_through_rest(); test_safety_net_reconciles_without_events(); test_timeout_keeps_final_check();