Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/order-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 6 additions & 4 deletions include/polymarket/clob_client.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,9 @@ namespace polymarket
Result<std::optional<Trade>> 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.
Expand All @@ -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<OrderSettlement> wait_for_order_fill_settlement(
const OrderResponse &order, const TradeStatusTracker &tracker,
std::chrono::milliseconds timeout = std::chrono::seconds(30),
Expand Down
23 changes: 17 additions & 6 deletions src/clob_order_settlement.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -166,7 +167,7 @@ namespace polymarket
if (settled[index]) continue;
auto trade = lookup_trade(trade_ids[index], deadline);
if (!trade) return Result<OrderSettlement>::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]);
Expand Down Expand Up @@ -195,32 +196,42 @@ namespace polymarket
if (trade_ids.empty()) return Result<OrderSettlement>::success(unfilled_settlement(order));

std::vector<std::optional<Trade>> settled(trade_ids.size());
std::vector<bool> 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)
{
if (settled[index]) continue;
auto trade = lookup_trade(trade_ids[index], deadline);
if (!trade) return Result<OrderSettlement>::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);
Expand Down
79 changes: 78 additions & 1 deletion tests/test_stream_settlement.cpp
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -8,6 +9,7 @@
#include <stdexcept>
#include <string>
#include <thread>
#include <variant>
#include <vector>

using namespace polymarket;
Expand All @@ -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},
Expand Down Expand Up @@ -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<UserTradeEvent>(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<std::string>{"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<std::string>{"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;
Expand Down Expand Up @@ -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();
Expand Down
Loading