From 197955772558a189aa25c8f5fb7fbab357b0b962 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:46:04 +0200 Subject: [PATCH 1/3] feat(network): route WebSockets through proxies and add geoblock check IXWebSocket cannot use proxies, so market, user, and JSON-RPC WebSockets connected directly even when REST used a proxy. A routed WebSocketClient now connects through a loopback relay that forwards each connection over a libcurl CONNECT_ONLY session, so WebSockets get the same HTTP, HTTPS, and SOCKS proxy support and TLS verification as REST. - Add NetworkRoute and set_default_network_route() to route every SDK transport, including the ones PositionClient, OrderbookManager, UserStream, and the JSON-RPC clients create internally. - Add interface binding (HttpClientOptions/WebSocketOptions interface_name) for VPN tunnel interfaces. - Routes fail closed: an unreachable or rejecting proxy fails the connection instead of falling back to a direct route. - Drop the dead CURLOPT_PROXYTYPE branch: a socks5:// scheme already selects local DNS, contrary to its comment. Docs recommend socks5h://. - Add check_geoblock() and ClobClient::get_geoblock_status(), which query Polymarket's eligibility endpoint through the configured route. HttpClientOptions and WebSocketOptions gained trailing fields; consumers must rebuild against the new headers. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 3 + cmake/PolymarketTests.cmake | 4 + docs/networking.md | 129 +++++++++ include/polymarket/clob_client.hpp | 7 + include/polymarket/geoblock.hpp | 28 ++ include/polymarket/http_client.hpp | 9 +- include/polymarket/network.hpp | 39 +++ include/polymarket/websocket_client.hpp | 5 + src/geoblock.cpp | 46 ++++ src/http_client.cpp | 49 +--- src/network_route.cpp | 62 +++++ src/network_route.hpp | 18 ++ src/websocket_client_state.hpp | 8 + src/websocket_client_teardown.cpp | 7 + src/websocket_client_transport.cpp | 40 ++- src/websocket_tunnel.cpp | 341 ++++++++++++++++++++++++ src/websocket_tunnel.hpp | 61 +++++ tests/http_client_transport_fixture.hpp | 14 +- tests/network_proxy_fixture.hpp | 277 +++++++++++++++++++ tests/test_network_routing.cpp | 290 ++++++++++++++++++++ 20 files changed, 1396 insertions(+), 41 deletions(-) create mode 100644 docs/networking.md create mode 100644 include/polymarket/geoblock.hpp create mode 100644 include/polymarket/network.hpp create mode 100644 src/geoblock.cpp create mode 100644 src/network_route.cpp create mode 100644 src/network_route.hpp create mode 100644 src/websocket_tunnel.cpp create mode 100644 src/websocket_tunnel.hpp create mode 100644 tests/network_proxy_fixture.hpp create mode 100644 tests/test_network_routing.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index defc9c3..1cb944c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -113,6 +113,7 @@ set(POLYMARKET_CLIENT_SOURCES src/evm_reorg_reconciliation.cpp src/evm_utils.cpp src/gamma_market_fetcher.cpp + src/geoblock.cpp src/http_client.cpp src/http_client_connection.cpp src/http_global.cpp @@ -121,6 +122,7 @@ set(POLYMARKET_CLIENT_SOURCES src/json_rpc_transactions.cpp src/json_rpc_ws_dispatch.cpp src/json_rpc_ws_runtime.cpp + src/network_route.cpp src/websocket_client_callbacks.cpp src/websocket_client.cpp src/websocket_client_resilience.cpp @@ -128,6 +130,7 @@ set(POLYMARKET_CLIENT_SOURCES src/websocket_client_transport.cpp src/websocket_resilience.cpp src/websocket_market_data.cpp + src/websocket_tunnel.cpp src/websocket_user_data.cpp src/market_fetcher.cpp src/market_discovery.cpp diff --git a/cmake/PolymarketTests.cmake b/cmake/PolymarketTests.cmake index 33ddc71..1302a22 100644 --- a/cmake/PolymarketTests.cmake +++ b/cmake/PolymarketTests.cmake @@ -155,6 +155,10 @@ if(POLYMARKET_CLIENT_BUILD_TESTS) target_link_libraries(test_websocket_resilience PRIVATE polymarket::client) add_test(NAME test_websocket_resilience COMMAND test_websocket_resilience) + add_executable(test_network_routing tests/test_network_routing.cpp) + target_link_libraries(test_network_routing PRIVATE polymarket::client) + add_test(NAME test_network_routing COMMAND test_network_routing) + add_executable(test_oracle_watcher tests/test_oracle_watcher.cpp) target_link_libraries(test_oracle_watcher PRIVATE polymarket::client) add_test(NAME test_oracle_watcher COMMAND test_oracle_watcher) diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..a754134 --- /dev/null +++ b/docs/networking.md @@ -0,0 +1,129 @@ +# Networking: proxies, VPN interfaces, and geoblock checks + +The SDK can send every connection it makes (REST, market and user WebSockets, +Polygon JSON-RPC, the relayer, and Gamma) through a proxy, or bind it to a specific +network interface such as a VPN tunnel. + +## Route all SDK traffic + +Set a process-wide route once at startup, before creating clients: + +```cpp +#include + +polymarket::set_default_network_route({ + .proxy_url = "socks5h://user:pass@127.0.0.1:1080", +}); +``` + +The default route applies to every `HttpClient` and `WebSocketClient` whose own +options leave the field empty. That includes the transports that `PositionClient`, +`OrderbookManager`, `UserStream`, `MarketFetcher`, and the JSON-RPC clients +create internally. + +`HttpClient` reads the route when it applies its options (construction, +`configure`, `set_proxy`). `WebSocketClient` reads it on every `connect()`. + +## Route a single client + +```cpp +polymarket::HttpClientOptions http; +http.proxy_url = "http://user:pass@proxy.internal:3128"; +polymarket::ClobClient client("https://clob.polymarket.com", 137, http); + +polymarket::WebSocketOptions ws_options; +ws_options.proxy_url = "socks5h://127.0.0.1:1080"; +polymarket::WebSocketClient ws; +ws.configure(ws_options); +``` + +`ClobClient::set_proxy()` still works and applies to its CLOB and Data API +transports. + +## Supported proxies + +Proxy URLs use libcurl syntax, with optional `user:password@` credentials: + +| Scheme | Proxy type | DNS resolved by | +| ------------ | ------------------------ | --------------- | +| `http://` | HTTP proxy with CONNECT | proxy | +| `https://` | HTTPS proxy with CONNECT | proxy | +| `socks5h://` | SOCKS5 | proxy | +| `socks5://` | SOCKS5 | local resolver | +| `socks4a://` | SOCKS4a | proxy | +| `socks4://` | SOCKS4 | local resolver | +| no scheme | HTTP proxy (`host:port`) | proxy | + +Prefer `socks5h://` over `socks5://` so host names are not resolved locally. + +TLS stays end to end. The SDK tunnels through the proxy and verifies the +Polymarket certificate; the proxy only sees encrypted traffic. + +## Bind to a VPN interface + +`interface_name` binds outgoing sockets to an interface, local IP address, or +host name. Use it to send SDK traffic over a VPN tunnel interface without making +that tunnel the machine's default route: + +```cpp +polymarket::set_default_network_route({.interface_name = "wg0"}); // Linux WireGuard +// macOS tunnels are usually utunN; libcurl also accepts "if!name" and "host!name". +``` + +A system-wide VPN needs no SDK configuration: every connection already follows +the operating system's routing table. + +## Failure behavior + +A configured route fails closed. If the proxy is unreachable, rejects the +tunnel, or the interface does not exist, requests and WebSocket connections fail. +They never fall back to a direct connection. WebSocket error callbacks name the +routed failure, for example +`WebSocket tunnel could not reach ws-subscriptions-clob.polymarket.com:443: ...`. + +With no route configured, REST requests follow libcurl's standard proxy +environment variables (`HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`). WebSockets use +only an explicit route, so set `set_default_network_route` when both must +share one path. + +## How WebSockets are routed + +IXWebSocket, the WebSocket library, cannot use proxies or bind interfaces. When +a route is set, `WebSocketClient` starts a small relay that listens on +`127.0.0.1`. IXWebSocket connects to it in plain `ws://` mode. Each connection is +forwarded over a libcurl `CONNECT_ONLY` connection, which performs the proxy +handshake and the TLS session with the real host. The relay sets the original +`Host` and `Origin` headers, so the server sees the same handshake as a direct +connection. + +Automatic reconnects go back through the relay, which opens a fresh routed +connection each time. Without a route, WebSockets connect directly as before +and none of this code runs. + +The relay adds one loopback hop and a few microseconds per frame. That is small +next to the proxy hop itself. + +## Check order-placement eligibility + +Polymarket rejects orders from restricted jurisdictions and asks integrations to +check eligibility before trading. `check_geoblock` queries +[`GET https://polymarket.com/api/geoblock`](https://docs.polymarket.com/api-reference/geoblock) +through a given route: + +```cpp +auto status = client.get_geoblock_status(); // uses the client's route +if (!status) { + std::cerr << status.error().message << "\n"; +} else if (status.value().blocked) { + std::cerr << "Order placement unavailable from " << status.value().country << "\n"; +} +``` + +A response without a boolean `blocked` field is a parse error, never "allowed". + +Proxies and VPNs change your network path, not where you are. Polymarket's +[Terms of Use](https://polymarket.com/tos) prohibit using a VPN, proxy, or similar +tool to misrepresent your location or get around its geographic restrictions. +You are responsible for complying with the terms and the laws that apply to you. Legitimate uses of these routing options include +corporate egress proxies, routing through hosting close to Polymarket's +infrastructure, and isolating trading traffic on dedicated interfaces. diff --git a/include/polymarket/clob_client.hpp b/include/polymarket/clob_client.hpp index 9e85049..a48a426 100644 --- a/include/polymarket/clob_client.hpp +++ b/include/polymarket/clob_client.hpp @@ -1,6 +1,7 @@ #pragma once #include "polymarket/clob_types.hpp" +#include "polymarket/geoblock.hpp" #include "polymarket/types.hpp" #include "polymarket/http_client.hpp" #include "polymarket/order_signer.hpp" @@ -205,6 +206,12 @@ namespace polymarket // Set proxy for HTTP requests (e.g., "http://user:pass@proxy.example.com:8080") void set_proxy(const std::string &proxy_url) { http_.set_proxy(proxy_url); data_http_.set_proxy(proxy_url); } + // Order-placement eligibility for this client's route (proxy/interface included) + Result get_geoblock_status() const + { + return check_geoblock(http_.options()); + } + // Set custom user agent void set_user_agent(const std::string &user_agent) { http_.set_user_agent(user_agent); data_http_.set_user_agent(user_agent); } diff --git a/include/polymarket/geoblock.hpp b/include/polymarket/geoblock.hpp new file mode 100644 index 0000000..5b978e0 --- /dev/null +++ b/include/polymarket/geoblock.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "polymarket/http_client.hpp" +#include "polymarket/sdk_error.hpp" + +#include + +namespace polymarket +{ + // Order-placement eligibility Polymarket reports for the caller's public IP. + // See https://docs.polymarket.com/api-reference/geoblock + struct GeoblockStatus + { + bool blocked{true}; + std::string ip; + std::string country; // ISO 3166-1 alpha-2 + std::string region; + }; + + inline constexpr const char *geoblock_base_url = "https://polymarket.com"; + + // Queries GET /api/geoblock with the given transport options, so the + // answer reflects the route (proxy or interface) those options select. + // Orders from blocked regions are rejected; check before trading and + // surface the result to users instead of retrying rejected orders. + Result check_geoblock(const HttpClientOptions &options = {}, + const std::string &base_url = geoblock_base_url); +} // namespace polymarket diff --git a/include/polymarket/http_client.hpp b/include/polymarket/http_client.hpp index 21d76b6..d7225d1 100644 --- a/include/polymarket/http_client.hpp +++ b/include/polymarket/http_client.hpp @@ -37,8 +37,12 @@ namespace polymarket long tcp_keepintvl_seconds = 20; bool tcp_nodelay = true; bool allow_connection_reuse = true; + // Empty fields fall back to the default route in . + // An empty proxy without a default route uses libcurl's proxy + // environment variables (HTTPS_PROXY, ALL_PROXY, NO_PROXY). std::string proxy_url; std::string user_agent; + std::string interface_name; // e.g. "wg0"; see NetworkRoute }; struct RequestMetrics @@ -74,7 +78,9 @@ namespace polymarket void set_timeout_ms(long timeout_ms); void set_base_url(const std::string &base_url); void add_header(const std::string &header); - void set_proxy(const std::string &proxy_url); // e.g., "http://user:pass@proxy.example.com:8080" + // e.g. "http://user:pass@proxy.example.com:8080" or "socks5h://127.0.0.1:1080". + // An empty URL falls back to the default network route. + void set_proxy(const std::string &proxy_url); void set_user_agent(const std::string &user_agent); void set_dns_cache_timeout(long seconds); // DNS cache TTL (default: 60s) void set_keepalive_interval(long seconds); // TCP keepalive probe interval @@ -141,6 +147,7 @@ namespace polymarket void init(); void cleanup(); void apply_options(); + void apply_route(); void heartbeat_once(); HttpResponse perform(const std::string &method, const std::string &path, const std::string &url); diff --git a/include/polymarket/network.hpp b/include/polymarket/network.hpp new file mode 100644 index 0000000..9550367 --- /dev/null +++ b/include/polymarket/network.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include + +namespace polymarket +{ + // Outbound route for SDK connections. + // + // proxy_url uses libcurl syntax with optional user:password@ credentials: + // http://, https://, socks4://, socks4a://, socks5:// (local DNS) or + // socks5h:// (DNS resolved by the proxy). A URL without a scheme is an + // HTTP proxy. + // + // interface_name binds outgoing sockets to a network interface, local IP + // address, or host name, for example a VPN tunnel interface such as "wg0" + // or "utun4". libcurl also accepts its "if!name" and "host!name" forms. + struct NetworkRoute + { + std::string proxy_url; + std::string interface_name; + + bool empty() const { return proxy_url.empty() && interface_name.empty(); } + }; + + // Process-wide fallback for HttpClient and WebSocketClient instances whose + // own options leave proxy_url or interface_name empty. It also covers SDK + // components that create their transports internally, such as + // PositionClient, OrderbookManager, UserStream, MarketFetcher, and the + // JSON-RPC clients. + // + // HttpClient reads the route when it applies options (construction, + // configure, set_proxy). WebSocketClient reads it on every connect(). + // Set it once at startup, before creating clients. + // + // A configured route fails closed: if the proxy or interface is + // unavailable, connections fail instead of falling back to a direct route. + void set_default_network_route(const NetworkRoute &route); + NetworkRoute default_network_route(); +} // namespace polymarket diff --git a/include/polymarket/websocket_client.hpp b/include/polymarket/websocket_client.hpp index c00e5aa..2f9ff7d 100644 --- a/include/polymarket/websocket_client.hpp +++ b/include/polymarket/websocket_client.hpp @@ -39,6 +39,11 @@ namespace polymarket uint32_t max_backoff_ms{10000}; int ping_interval_ms{10000}; std::size_t message_queue_limit{1024}; + // Route for this connection; empty fields fall back to the default + // route in . A set route sends the connection + // through libcurl, so it supports the same proxy schemes as HTTP. + std::string proxy_url; + std::string interface_name; }; struct WebSocketStats diff --git a/src/geoblock.cpp b/src/geoblock.cpp new file mode 100644 index 0000000..c2bffaf --- /dev/null +++ b/src/geoblock.cpp @@ -0,0 +1,46 @@ +#include "polymarket/geoblock.hpp" + +#include + +namespace polymarket +{ + namespace + { + constexpr const char *geoblock_path = "/api/geoblock"; + + std::string optional_string(const nlohmann::json &body, const char *key) + { + const auto field = body.find(key); + return field != body.end() && field->is_string() ? field->get() : ""; + } + } // namespace + + Result check_geoblock(const HttpClientOptions &options, + const std::string &base_url) + { + HttpClient http(options); + http.set_base_url(base_url); + const auto response = http.get(geoblock_path); + if (!response.ok()) + return Result::failure(make_sdk_error(response, geoblock_path)); + + try + { + const auto body = nlohmann::json::parse(response.body); + const auto blocked = body.find("blocked"); + // Fail closed: an answer without a boolean verdict is not "allowed". + if (blocked == body.end() || !blocked->is_boolean()) + return Result::failure( + make_parse_error("geoblock response requires a boolean blocked field", + geoblock_path, response.body)); + return Result::success( + {blocked->get(), optional_string(body, "ip"), + optional_string(body, "country"), optional_string(body, "region")}); + } + catch (const nlohmann::json::exception &error) + { + return Result::failure( + make_parse_error(error.what(), geoblock_path, response.body)); + } + } +} // namespace polymarket diff --git a/src/http_client.cpp b/src/http_client.cpp index 4e91914..dd0dab4 100644 --- a/src/http_client.cpp +++ b/src/http_client.cpp @@ -1,5 +1,6 @@ #include "polymarket/http_client.hpp" #include "http_global.hpp" +#include "network_route.hpp" #include #include @@ -196,43 +197,16 @@ namespace polymarket { std::lock_guard lock(curl_mutex_); options_.proxy_url = proxy_url; - proxy_url_ = proxy_url; - if (curl_) - { - if (proxy_url_.empty()) - { - curl_easy_setopt(curl_, CURLOPT_PROXY, nullptr); - curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(curl_, CURLOPT_PROXY_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl_, CURLOPT_PROXY_SSL_VERIFYHOST, 2L); - return; - } - - curl_easy_setopt(curl_, CURLOPT_PROXY, proxy_url_.c_str()); - - // Detect proxy type from URL scheme - if (proxy_url_.find("socks5://") == 0 || proxy_url_.find("socks5h://") == 0) - { - // SOCKS5 proxy - use socks5h for DNS resolution through proxy - curl_easy_setopt(curl_, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME); - } - else if (proxy_url_.find("socks4://") == 0) - { - curl_easy_setopt(curl_, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS4); - } - else - { - // HTTP/HTTPS proxy - curl_easy_setopt(curl_, CURLOPT_HTTPPROXYTUNNEL, 1L); // Use CONNECT for HTTPS - curl_easy_setopt(curl_, CURLOPT_PROXYTYPE, CURLPROXY_HTTP); - } + apply_route(); + } - curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl_, CURLOPT_SSL_VERIFYHOST, 2L); - curl_easy_setopt(curl_, CURLOPT_PROXY_SSL_VERIFYPEER, 1L); - curl_easy_setopt(curl_, CURLOPT_PROXY_SSL_VERIFYHOST, 2L); - } + void HttpClient::apply_route() + { + if (!curl_) return; + const auto route = + detail::resolve_network_route(options_.proxy_url, options_.interface_name); + proxy_url_ = route.proxy_url; + detail::apply_network_route(curl_, route); } void HttpClient::set_user_agent(const std::string &user_agent) @@ -264,7 +238,6 @@ namespace polymarket { std::lock_guard lock(curl_mutex_); options_ = options; - proxy_url_ = options.proxy_url; apply_options(); } @@ -283,7 +256,7 @@ namespace polymarket curl_easy_setopt(curl_, CURLOPT_FORBID_REUSE, options_.allow_connection_reuse ? 0L : 1L); curl_easy_setopt(curl_, CURLOPT_FRESH_CONNECT, options_.allow_connection_reuse ? 0L : 1L); curl_easy_setopt(curl_, CURLOPT_USERAGENT, options_.user_agent.empty() ? nullptr : options_.user_agent.c_str()); - set_proxy(options_.proxy_url); + apply_route(); } } // namespace polymarket diff --git a/src/network_route.cpp b/src/network_route.cpp new file mode 100644 index 0000000..2ae76fe --- /dev/null +++ b/src/network_route.cpp @@ -0,0 +1,62 @@ +#include "network_route.hpp" + +#include + +namespace polymarket +{ + namespace + { + struct DefaultRouteState + { + std::mutex mutex; + NetworkRoute route; + }; + + DefaultRouteState &default_route_state() + { + static DefaultRouteState state; + return state; + } + } // namespace + + void set_default_network_route(const NetworkRoute &route) + { + auto &state = default_route_state(); + std::lock_guard lock(state.mutex); + state.route = route; + } + + NetworkRoute default_network_route() + { + auto &state = default_route_state(); + std::lock_guard lock(state.mutex); + return state.route; + } + + namespace detail + { + NetworkRoute resolve_network_route(const std::string &proxy_url, + const std::string &interface_name) + { + auto route = default_network_route(); + if (!proxy_url.empty()) route.proxy_url = proxy_url; + if (!interface_name.empty()) route.interface_name = interface_name; + return route; + } + + void apply_network_route(CURL *curl, const NetworkRoute &route) + { + const bool proxied = !route.proxy_url.empty(); + curl_easy_setopt(curl, CURLOPT_PROXY, proxied ? route.proxy_url.c_str() : nullptr); + // A scheme in the proxy URL (socks5h://, https://, ...) overrides + // this type; scheme-less proxies are HTTP proxies. + curl_easy_setopt(curl, CURLOPT_PROXYTYPE, static_cast(CURLPROXY_HTTP)); + // Tunnel through HTTP proxies with CONNECT so TLS stays end to end. + curl_easy_setopt(curl, CURLOPT_HTTPPROXYTUNNEL, proxied ? 1L : 0L); + curl_easy_setopt(curl, CURLOPT_PROXY_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_PROXY_SSL_VERIFYHOST, 2L); + curl_easy_setopt(curl, CURLOPT_INTERFACE, + route.interface_name.empty() ? nullptr : route.interface_name.c_str()); + } + } // namespace detail +} // namespace polymarket diff --git a/src/network_route.hpp b/src/network_route.hpp new file mode 100644 index 0000000..632ade8 --- /dev/null +++ b/src/network_route.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "polymarket/network.hpp" + +#include + +#include + +namespace polymarket::detail +{ + // Fills each empty field from the process-wide default route. + NetworkRoute resolve_network_route(const std::string &proxy_url, + const std::string &interface_name); + + // Applies a route to an easy handle. The handle copies both strings. + // An empty proxy restores libcurl's proxy environment handling. + void apply_network_route(CURL *curl, const NetworkRoute &route); +} // namespace polymarket::detail diff --git a/src/websocket_client_state.hpp b/src/websocket_client_state.hpp index 10c3722..17af311 100644 --- a/src/websocket_client_state.hpp +++ b/src/websocket_client_state.hpp @@ -15,6 +15,7 @@ namespace polymarket::detail { class BoundedMessageQueue; + class WebSocketTunnel; struct WebSocketCallbacks { @@ -77,6 +78,8 @@ namespace polymarket::detail static void validate_options(const WebSocketOptions &options); void require_inactive_locked() const; void apply_options_locked(); + void prepare_route_locked(); + std::string tunnel_error() const; void install_transport_callback(); void handle_transport_message(const ix::WebSocketMessagePtr &message); void handle_open(); @@ -143,6 +146,11 @@ namespace polymarket::detail ix::WebSocket ws_; std::unique_ptr message_queue_; WebSocketOptions options_; + std::string url_; + + // Set while a routed connection is active; read by IX callbacks. + mutable std::mutex tunnel_mutex_; + std::shared_ptr tunnel_; mutable std::mutex lifecycle_mutex_; mutable std::mutex callback_update_mutex_; diff --git a/src/websocket_client_teardown.cpp b/src/websocket_client_teardown.cpp index 87ebd73..ba31aab 100644 --- a/src/websocket_client_teardown.cpp +++ b/src/websocket_client_teardown.cpp @@ -1,5 +1,6 @@ #include "websocket_client_state.hpp" #include "websocket_resilience.hpp" +#include "websocket_tunnel.hpp" namespace polymarket::detail { @@ -64,6 +65,12 @@ namespace polymarket::detail ws_.stop(); stop_message_worker(); ws_.setOnMessageCallback(nullptr); + // ws_.stop() joined the IX thread, so no callback still reads the tunnel. + std::shared_ptr tunnel; + { + std::lock_guard lock(tunnel_mutex_); + tunnel = std::move(tunnel_); + } } void WebSocketClientState::publish_disconnected() diff --git a/src/websocket_client_transport.cpp b/src/websocket_client_transport.cpp index b604711..9220df4 100644 --- a/src/websocket_client_transport.cpp +++ b/src/websocket_client_transport.cpp @@ -1,6 +1,8 @@ +#include "network_route.hpp" #include "websocket_callback_context.hpp" #include "websocket_client_state.hpp" #include "websocket_resilience.hpp" +#include "websocket_tunnel.hpp" namespace polymarket::detail { @@ -36,9 +38,40 @@ namespace polymarket::detail { std::lock_guard lock(lifecycle_mutex_); require_inactive_locked(); + url_ = url; ws_.setUrl(url); } + void WebSocketClientState::prepare_route_locked() + { + auto route = resolve_network_route(options_.proxy_url, options_.interface_name); + std::shared_ptr tunnel; + if (!route.empty()) + { + tunnel = std::make_shared(url_, std::move(route)); + ws_.setUrl(tunnel->local_url()); + ws_.setExtraHeaders( + {{"Host", tunnel->host_header()}, {"Origin", tunnel->origin_header()}}); + } + else + { + ws_.setUrl(url_); + ws_.setExtraHeaders({}); + } + std::lock_guard lock(tunnel_mutex_); + tunnel_ = std::move(tunnel); + } + + std::string WebSocketClientState::tunnel_error() const + { + std::shared_ptr tunnel; + { + std::lock_guard lock(tunnel_mutex_); + tunnel = tunnel_; + } + return tunnel ? tunnel->last_error() : std::string(); + } + bool WebSocketClientState::connect() { if (is_internal_callback(this) && transport_stop_pending_.load()) return false; @@ -72,6 +105,7 @@ namespace polymarket::detail try { + prepare_route_locked(); start_transport_worker(); install_transport_callback(); if (options_.reconnect_enabled) ws_.enableAutomaticReconnection(); @@ -195,7 +229,11 @@ namespace polymarket::detail } state_cv_.notify_all(); - invoke_error_callback(message->errorInfo.reason); + // IXWebSocket only sees the loopback leg; name the routed failure. + const auto route_error = tunnel_error(); + invoke_error_callback(route_error.empty() + ? message->errorInfo.reason + : message->errorInfo.reason + " (" + route_error + ")"); } bool WebSocketClientState::wait_until_connected(std::chrono::milliseconds timeout) diff --git a/src/websocket_tunnel.cpp b/src/websocket_tunnel.cpp new file mode 100644 index 0000000..6dd1199 --- /dev/null +++ b/src/websocket_tunnel.cpp @@ -0,0 +1,341 @@ +#include "websocket_tunnel.hpp" + +#include "http_global.hpp" +#include "network_route.hpp" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + namespace + { + constexpr long upstream_connect_timeout_ms = 10000; + constexpr std::size_t relay_chunk_bytes = 16 * 1024; + constexpr std::size_t max_pending_bytes = 1024 * 1024; +#ifdef MSG_NOSIGNAL + constexpr int send_flags = MSG_NOSIGNAL; +#else + constexpr int send_flags = 0; +#endif + + void prepare_socket(int fd, bool nonblocking) + { + ::fcntl(fd, F_SETFD, FD_CLOEXEC); + if (nonblocking) ::fcntl(fd, F_SETFL, ::fcntl(fd, F_GETFL, 0) | O_NONBLOCK); +#ifdef SO_NOSIGPIPE + int enabled = 1; + ::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)); +#endif + } + + std::string system_error(const char *operation) + { + return std::string(operation) + ": " + std::strerror(errno); + } + + // Unsent bytes for one direction. Compacts lazily so partial writes + // stay O(1) without letting the consumed prefix grow unbounded. + class PendingBytes + { + public: + bool empty() const { return offset_ == bytes_.size(); } + std::size_t size() const { return bytes_.size() - offset_; } + bool has_room() const { return size() < max_pending_bytes; } + const char *data() const { return bytes_.data() + offset_; } + + void append(const char *data, std::size_t count) + { + if (offset_ > relay_chunk_bytes && offset_ * 2 > bytes_.size()) + { + bytes_.erase(0, offset_); + offset_ = 0; + } + bytes_.append(data, count); + } + + void consume(std::size_t count) + { + offset_ += count; + if (offset_ != bytes_.size()) return; + bytes_.clear(); + offset_ = 0; + } + + private: + std::string bytes_; + std::size_t offset_{0}; + }; + + using CurlHandle = std::unique_ptr; + } // namespace + + WebSocketTunnel::WebSocketTunnel(const std::string &remote_url, NetworkRoute route) + : route_(std::move(route)) + { + std::string protocol; + std::string host; + std::string path; + std::string query; + int port = 0; + if (!ix::UrlParser::parse(remote_url, protocol, host, path, query, port) || + (protocol != "ws" && protocol != "wss") || host.empty()) + { + throw std::invalid_argument("WebSocket URL must use ws:// or wss://: " + remote_url); + } + + const auto authority = (host.find(':') != std::string::npos ? "[" + host + "]" : host) + + ":" + std::to_string(port); + upstream_url_ = (protocol == "wss" ? "https://" : "http://") + authority + "/"; + host_header_ = authority; + origin_header_ = protocol + "://" + authority; + + acquire_http_global(); + global_acquired_ = true; + try + { + int wake[2] = {-1, -1}; + if (::pipe(wake) != 0) throw std::runtime_error(system_error("pipe")); + wake_read_fd_ = wake[0]; + wake_write_fd_ = wake[1]; + prepare_socket(wake_read_fd_, true); + prepare_socket(wake_write_fd_, true); + + listen_fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (listen_fd_ < 0) throw std::runtime_error(system_error("socket")); + prepare_socket(listen_fd_, true); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + socklen_t length = sizeof(address); + if (::bind(listen_fd_, reinterpret_cast(&address), sizeof(address)) != 0 || + ::listen(listen_fd_, 4) != 0 || + ::getsockname(listen_fd_, reinterpret_cast(&address), &length) != 0) + { + throw std::runtime_error(system_error("WebSocket tunnel listener")); + } + local_url_ = "ws://127.0.0.1:" + std::to_string(ntohs(address.sin_port)) + path; + worker_ = std::thread([this] { run(); }); + } + catch (...) + { + for (const int fd : {listen_fd_, wake_read_fd_, wake_write_fd_}) + if (fd >= 0) ::close(fd); + release_http_global(); + throw; + } + } + + WebSocketTunnel::~WebSocketTunnel() + { + stopping_.store(true); + const char wake = 1; + (void)::write(wake_write_fd_, &wake, 1); + if (worker_.joinable()) worker_.join(); + for (const int fd : {listen_fd_, wake_read_fd_, wake_write_fd_}) + ::close(fd); + if (global_acquired_) release_http_global(); + } + + std::string WebSocketTunnel::last_error() const + { + std::lock_guard lock(error_mutex_); + return last_error_; + } + + void WebSocketTunnel::set_error(std::string message) + { + std::lock_guard lock(error_mutex_); + last_error_ = std::move(message); + } + + void WebSocketTunnel::run() + { + while (!stopping_.load()) + { + pollfd fds[2] = {{listen_fd_, POLLIN, 0}, {wake_read_fd_, POLLIN, 0}}; + if (::poll(fds, 2, -1) < 0) + { + if (errno == EINTR) continue; + set_error(system_error("poll")); + return; + } + if (fds[1].revents != 0) return; + const int client_fd = ::accept(listen_fd_, nullptr, nullptr); + if (client_fd < 0) continue; + prepare_socket(client_fd, true); + int enabled = 1; + ::setsockopt(client_fd, IPPROTO_TCP, TCP_NODELAY, &enabled, sizeof(enabled)); + serve(client_fd); + ::close(client_fd); + } + } + + void WebSocketTunnel::serve(int client_fd) + { + CurlHandle upstream(open_upstream(), curl_easy_cleanup); + if (!upstream) return; + curl_socket_t upstream_fd = CURL_SOCKET_BAD; + if (curl_easy_getinfo(upstream.get(), CURLINFO_ACTIVESOCKET, &upstream_fd) != CURLE_OK || + upstream_fd == CURL_SOCKET_BAD) + { + set_error("WebSocket tunnel could not read the upstream socket"); + return; + } + set_error(""); + relay(client_fd, upstream.get(), upstream_fd); + } + + int WebSocketTunnel::abort_on_stop(void *tunnel, curl_off_t, curl_off_t, curl_off_t, curl_off_t) + { + return static_cast(tunnel)->stopping_.load() ? 1 : 0; + } + + CURL *WebSocketTunnel::open_upstream() + { + CurlHandle curl(curl_easy_init(), curl_easy_cleanup); + if (!curl) + { + set_error("WebSocket tunnel could not create a libcurl handle"); + return nullptr; + } + char error_buffer[CURL_ERROR_SIZE] = {}; + CURL *handle = curl.get(); + curl_easy_setopt(handle, CURLOPT_URL, upstream_url_.c_str()); + curl_easy_setopt(handle, CURLOPT_CONNECT_ONLY, 1L); + // ALPN must offer only HTTP/1.1; the WebSocket upgrade is an HTTP/1.1 request. + curl_easy_setopt(handle, CURLOPT_HTTP_VERSION, static_cast(CURL_HTTP_VERSION_1_1)); + curl_easy_setopt(handle, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(handle, CURLOPT_CONNECTTIMEOUT_MS, upstream_connect_timeout_ms); + curl_easy_setopt(handle, CURLOPT_TCP_NODELAY, 1L); + curl_easy_setopt(handle, CURLOPT_TCP_KEEPALIVE, 1L); + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(handle, CURLOPT_SSL_VERIFYHOST, 2L); + curl_easy_setopt(handle, CURLOPT_ERRORBUFFER, error_buffer); + curl_easy_setopt(handle, CURLOPT_NOPROGRESS, 0L); + curl_easy_setopt(handle, CURLOPT_XFERINFOFUNCTION, abort_on_stop); + curl_easy_setopt(handle, CURLOPT_XFERINFODATA, this); + apply_network_route(handle, route_); + // Also tunnel ws:// through proxies taken from the environment. + curl_easy_setopt(handle, CURLOPT_HTTPPROXYTUNNEL, 1L); + + const auto code = curl_easy_perform(handle); + curl_easy_setopt(handle, CURLOPT_ERRORBUFFER, nullptr); + if (code != CURLE_OK) + { + set_error(std::string("WebSocket tunnel could not reach ") + host_header_ + ": " + + (error_buffer[0] != '\0' ? error_buffer : curl_easy_strerror(code))); + return nullptr; + } + return curl.release(); + } + + void WebSocketTunnel::relay(int client_fd, CURL *upstream, curl_socket_t upstream_fd) + { + PendingBytes to_upstream; + PendingBytes to_client; + bool client_eof = false; + bool upstream_eof = false; + char chunk[relay_chunk_bytes]; + + while (!stopping_.load()) + { + // Read both sides before polling: libcurl can hold decrypted bytes + // that no longer make the socket readable. + while (!client_eof && to_upstream.has_room()) + { + const auto received = ::recv(client_fd, chunk, sizeof(chunk), 0); + if (received > 0) + to_upstream.append(chunk, static_cast(received)); + else if (received == 0) + client_eof = true; + else if (errno == EINTR) + continue; + else if (errno == EAGAIN || errno == EWOULDBLOCK) + break; + else + return; + } + while (!upstream_eof && to_client.has_room()) + { + std::size_t received = 0; + const auto code = curl_easy_recv(upstream, chunk, sizeof(chunk), &received); + if (code == CURLE_AGAIN) break; + if (code != CURLE_OK) + { + set_error(std::string("WebSocket tunnel read failed: ") + + curl_easy_strerror(code)); + upstream_eof = true; + } + else if (received == 0) + upstream_eof = true; + else + to_client.append(chunk, received); + } + + while (!client_eof && !to_client.empty()) + { + const auto sent = ::send(client_fd, to_client.data(), to_client.size(), send_flags); + if (sent > 0) + to_client.consume(static_cast(sent)); + else if (sent < 0 && errno == EINTR) + continue; + else if (sent < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) + break; + else + return; + } + while (!upstream_eof && !to_upstream.empty()) + { + std::size_t sent = 0; + const auto code = + curl_easy_send(upstream, to_upstream.data(), to_upstream.size(), &sent); + if (code == CURLE_AGAIN) break; + if (code != CURLE_OK) + { + set_error(std::string("WebSocket tunnel write failed: ") + + curl_easy_strerror(code)); + return; + } + to_upstream.consume(sent); + } + + // Close both legs once either side has closed and its last bytes + // were delivered to the other side. + if ((upstream_eof && to_client.empty()) || (client_eof && to_upstream.empty()) || + (upstream_eof && client_eof)) + return; + + const short client_events = + static_cast((!client_eof && to_upstream.has_room() ? POLLIN : 0) | + (!client_eof && !to_client.empty() ? POLLOUT : 0)); + const short upstream_events = + static_cast((!upstream_eof && to_client.has_room() ? POLLIN : 0) | + (!upstream_eof && !to_upstream.empty() ? POLLOUT : 0)); + pollfd fds[3] = { + {client_events != 0 ? client_fd : -1, client_events, 0}, + {upstream_events != 0 ? static_cast(upstream_fd) : -1, upstream_events, 0}, + {wake_read_fd_, POLLIN, 0}}; + if (::poll(fds, 3, -1) < 0 && errno != EINTR) + { + set_error(system_error("poll")); + return; + } + if (fds[2].revents != 0) return; + } + } +} // namespace polymarket::detail diff --git a/src/websocket_tunnel.hpp b/src/websocket_tunnel.hpp new file mode 100644 index 0000000..d11286e --- /dev/null +++ b/src/websocket_tunnel.hpp @@ -0,0 +1,61 @@ +#pragma once + +#include "polymarket/network.hpp" + +#include + +#include +#include +#include +#include +#include + +namespace polymarket::detail +{ + // IXWebSocket cannot use proxies or bind interfaces. When a route is set, + // WebSocketClient points IXWebSocket at this loopback relay instead. The + // relay forwards each accepted connection over a libcurl CONNECT_ONLY + // connection, which performs the proxy handshake and the TLS session with + // the real host, so WebSockets get the same route as REST requests. + // + // IXWebSocket speaks plain ws:// over loopback; the remote leg keeps the + // original scheme and certificate verification. One connection is relayed + // at a time, matching one IXWebSocket per WebSocketClient. + class WebSocketTunnel + { + public: + WebSocketTunnel(const std::string &remote_url, NetworkRoute route); + ~WebSocketTunnel(); + + WebSocketTunnel(const WebSocketTunnel &) = delete; + WebSocketTunnel &operator=(const WebSocketTunnel &) = delete; + + const std::string &local_url() const { return local_url_; } + // Host and Origin values IXWebSocket would send to the remote directly. + const std::string &host_header() const { return host_header_; } + const std::string &origin_header() const { return origin_header_; } + std::string last_error() const; + + private: + void run(); + void serve(int client_fd); + CURL *open_upstream(); + void relay(int client_fd, CURL *upstream, curl_socket_t upstream_fd); + void set_error(std::string message); + static int abort_on_stop(void *tunnel, curl_off_t, curl_off_t, curl_off_t, curl_off_t); + + NetworkRoute route_; + std::string upstream_url_; + std::string local_url_; + std::string host_header_; + std::string origin_header_; + int listen_fd_{-1}; + int wake_read_fd_{-1}; + int wake_write_fd_{-1}; + bool global_acquired_{false}; + std::atomic stopping_{false}; + mutable std::mutex error_mutex_; + std::string last_error_; + std::thread worker_; + }; +} // namespace polymarket::detail diff --git a/tests/http_client_transport_fixture.hpp b/tests/http_client_transport_fixture.hpp index f881df2..9fb973f 100644 --- a/tests/http_client_transport_fixture.hpp +++ b/tests/http_client_transport_fixture.hpp @@ -12,6 +12,7 @@ #include #include #include +#include #include namespace @@ -95,6 +96,12 @@ namespace truncate_next_response_.store(true); } + void set_response_body(std::string body) + { + std::lock_guard lock(requests_mutex_); + response_body_ = std::move(body); + } + private: int fd_{-1}; int port_{0}; @@ -107,6 +114,7 @@ namespace bool heartbeat_started_{false}; bool heartbeat_released_{true}; std::atomic truncate_next_response_{false}; + std::string response_body_{R"({"ok":true})"}; static std::size_t content_length(const std::string &request) { @@ -185,7 +193,11 @@ namespace } } } - std::string body = R"({"ok":true})"; + std::string body; + { + std::lock_guard lock(requests_mutex_); + body = response_body_; + } std::string response; if (parsed.path == "/redirect") { diff --git a/tests/network_proxy_fixture.hpp b/tests/network_proxy_fixture.hpp new file mode 100644 index 0000000..06bad8a --- /dev/null +++ b/tests/network_proxy_fixture.hpp @@ -0,0 +1,277 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace network_test +{ + // Loopback proxy speaking HTTP CONNECT and SOCKS5 (no auth). It records + // each tunnel target and the first bytes the client sent through it, and + // only dials 127.0.0.1 so tests never leave the machine. + class LocalProxy + { + public: + enum class Mode : std::uint8_t + { + Forward, + Reject, + Stall + }; + + explicit LocalProxy(Mode mode = Mode::Forward) : mode_(mode) + { + fd_ = ::socket(AF_INET, SOCK_STREAM, 0); + if (fd_ < 0) throw std::runtime_error("proxy socket failed"); + int yes = 1; + ::setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + socklen_t length = sizeof(address); + if (::bind(fd_, reinterpret_cast(&address), sizeof(address)) != 0 || + ::listen(fd_, 8) != 0 || + ::getsockname(fd_, reinterpret_cast(&address), &length) != 0) + throw std::runtime_error("proxy listen failed"); + port_ = ntohs(address.sin_port); + acceptor_ = std::thread([this] { accept_loop(); }); + } + + ~LocalProxy() + { + running_.store(false); + ::shutdown(fd_, SHUT_RDWR); + ::close(fd_); + if (acceptor_.joinable()) acceptor_.join(); + std::vector sessions; + { + std::lock_guard lock(mutex_); + sessions.swap(sessions_); + } + for (auto &session : sessions) + if (session.joinable()) session.join(); + } + + int port() const { return port_; } + std::string http_url() const { return "http://127.0.0.1:" + std::to_string(port_); } + std::string socks5h_url() const { return "socks5h://127.0.0.1:" + std::to_string(port_); } + + std::vector targets() const + { + std::lock_guard lock(mutex_); + return targets_; + } + + std::vector first_payloads() const + { + std::lock_guard lock(mutex_); + return first_payloads_; + } + + bool wait_for_targets(std::size_t count, std::chrono::milliseconds timeout) + { + std::unique_lock lock(mutex_); + return cv_.wait_for(lock, timeout, [&] { return targets_.size() >= count; }); + } + + private: + static bool read_exact(int fd, void *output, std::size_t size) + { + auto *bytes = static_cast(output); + while (size > 0) + { + const auto received = ::recv(fd, bytes, size, 0); + if (received <= 0) return false; + bytes += received; + size -= static_cast(received); + } + return true; + } + + static bool send_all(int fd, const void *input, std::size_t size) + { + const auto *bytes = static_cast(input); + while (size > 0) + { + const auto sent = ::send(fd, bytes, size, 0); + if (sent <= 0) return false; + bytes += sent; + size -= static_cast(sent); + } + return true; + } + + static int dial_loopback(int port) + { + const int fd = ::socket(AF_INET, SOCK_STREAM, 0); + sockaddr_in address{}; + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = htons(static_cast(port)); + if (fd >= 0 && + ::connect(fd, reinterpret_cast(&address), sizeof(address)) == 0) + return fd; + if (fd >= 0) ::close(fd); + return -1; + } + + void accept_loop() + { + while (running_.load()) + { + const int client = ::accept(fd_, nullptr, nullptr); + if (client < 0) continue; + std::lock_guard lock(mutex_); + sessions_.emplace_back( + [this, client] + { + session(client); + ::close(client); + }); + } + } + + void record_target(const std::string &target) + { + { + std::lock_guard lock(mutex_); + targets_.push_back(target); + } + cv_.notify_all(); + } + + // Returns the target port, or -1 after rejecting the client. + int handshake(int client) + { + unsigned char first = 0; + if (!read_exact(client, &first, 1)) return -1; + if (first == 0x05) return socks5_handshake(client); + + std::string request(1, static_cast(first)); + char byte = 0; + while (request.find("\r\n\r\n") == std::string::npos && read_exact(client, &byte, 1)) + request.push_back(byte); + const auto start = request.find(' ') + 1; + const auto target = request.substr(start, request.find(' ', start) - start); + record_target(target); + if (mode_ == Mode::Stall) return stall(client); + if (mode_ == Mode::Reject) + { + const std::string denied = "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\n\r\n"; + send_all(client, denied.data(), denied.size()); + return -1; + } + const std::string established = "HTTP/1.1 200 Connection established\r\n\r\n"; + if (!send_all(client, established.data(), established.size())) return -1; + return std::stoi(target.substr(target.rfind(':') + 1)); + } + + int socks5_handshake(int client) + { + unsigned char method_count = 0; + unsigned char methods[255]; + if (!read_exact(client, &method_count, 1) || !read_exact(client, methods, method_count)) + return -1; + const unsigned char no_auth[2] = {0x05, 0x00}; + unsigned char header[4]; + if (!send_all(client, no_auth, 2) || !read_exact(client, header, 4)) return -1; + + std::string host; + if (header[3] == 0x03) + { + unsigned char length = 0; + if (!read_exact(client, &length, 1)) return -1; + host.resize(length); + if (!read_exact(client, host.data(), length)) return -1; + } + else if (header[3] == 0x01) + { + unsigned char ip[4]; + if (!read_exact(client, ip, 4)) return -1; + host = std::to_string(ip[0]) + "." + std::to_string(ip[1]) + "." + + std::to_string(ip[2]) + "." + std::to_string(ip[3]); + } + else + return -1; + unsigned char port_bytes[2]; + if (!read_exact(client, port_bytes, 2)) return -1; + const int port = (port_bytes[0] << 8) | port_bytes[1]; + record_target(host + ":" + std::to_string(port)); + if (mode_ == Mode::Stall) return stall(client); + const unsigned char reply_code = mode_ == Mode::Reject ? 0x02 : 0x00; + const unsigned char reply[10] = {0x05, reply_code, 0x00, 0x01, 127, 0, 0, 1, 0, 0}; + if (!send_all(client, reply, sizeof(reply)) || mode_ == Mode::Reject) return -1; + return port; + } + + int stall(int client) + { + char byte = 0; + while (running_.load()) + { + pollfd fd{client, POLLIN, 0}; + if (::poll(&fd, 1, 20) > 0 && ::recv(client, &byte, 1, 0) <= 0) break; + } + return -1; + } + + void session(int client) + { + const int port = handshake(client); + if (port < 0) return; + const int upstream = dial_loopback(port); + if (upstream < 0) return; + + bool first_chunk = true; + char buffer[16 * 1024]; + while (running_.load()) + { + pollfd fds[2] = {{client, POLLIN, 0}, {upstream, POLLIN, 0}}; + if (::poll(fds, 2, 20) <= 0) continue; + const auto pump = [&](int from, int to, bool record) + { + const auto received = ::recv(from, buffer, sizeof(buffer), 0); + if (received <= 0) return false; + if (record) + { + std::lock_guard lock(mutex_); + first_payloads_.emplace_back(buffer, static_cast(received)); + } + return send_all(to, buffer, static_cast(received)); + }; + if ((fds[0].revents & (POLLIN | POLLHUP | POLLERR)) != 0) + { + if (!pump(client, upstream, first_chunk)) break; + first_chunk = false; + } + if ((fds[1].revents & (POLLIN | POLLHUP | POLLERR)) != 0 && + !pump(upstream, client, false)) + break; + } + ::close(upstream); + } + + Mode mode_; + int fd_{-1}; + int port_{0}; + std::atomic running_{true}; + std::thread acceptor_; + mutable std::mutex mutex_; + std::condition_variable cv_; + std::vector sessions_; + std::vector targets_; + std::vector first_payloads_; + }; +} // namespace network_test diff --git a/tests/test_network_routing.cpp b/tests/test_network_routing.cpp new file mode 100644 index 0000000..011c05a --- /dev/null +++ b/tests/test_network_routing.cpp @@ -0,0 +1,290 @@ +#include "check_support.hpp" +#include "http_client_transport_fixture.hpp" +#include "network_proxy_fixture.hpp" +#include "websocket_test_server.hpp" + +#include "polymarket/geoblock.hpp" +#include "polymarket/http_client.hpp" +#include "polymarket/network.hpp" +#include "polymarket/websocket_client.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace std::chrono_literals; +using network_test::LocalProxy; + +namespace +{ + // The HTTP fixture defines its own expect(); keep the shared counter here. + void expect(bool condition, const std::string &message) + { + check_support::check(condition, message); + } + + // Collects messages and errors delivered on the client's worker threads. + struct WsObserver + { + std::mutex mutex; + std::condition_variable cv; + std::vector messages; + std::vector errors; + + void attach(polymarket::WebSocketClient &client) + { + client.on_message( + [this](const std::string &message) + { + std::lock_guard lock(mutex); + messages.push_back(message); + cv.notify_all(); + }); + client.on_error( + [this](const std::string &error) + { + std::lock_guard lock(mutex); + errors.push_back(error); + cv.notify_all(); + }); + } + + bool wait_for_message(const std::string &expected, std::chrono::milliseconds timeout) + { + std::unique_lock lock(mutex); + return cv.wait_for(lock, timeout, + [&] + { + return std::find(messages.begin(), messages.end(), expected) != + messages.end(); + }); + } + + bool wait_for_error_containing(const std::string &text, std::chrono::milliseconds timeout) + { + std::unique_lock lock(mutex); + return cv.wait_for(lock, timeout, + [&] + { + for (const auto &error : errors) + if (error.find(text) != std::string::npos) return true; + return false; + }); + } + }; + + polymarket::WebSocketOptions routed_options(const std::string &proxy_url) + { + polymarket::WebSocketOptions options; + options.min_backoff_ms = 20; + options.max_backoff_ms = 50; + options.proxy_url = proxy_url; + return options; + } + + std::string server_authority(const websocket_test::LocalWebSocketServer &server) + { + return server.url().substr(std::string("ws://").size()); + } + + void websocket_through_http_proxy() + { + LocalProxy proxy; + websocket_test::LocalWebSocketServer server; + WsObserver observer; + { + polymarket::WebSocketClient client; + client.set_url(server.url() + "/ws/market?feed=1"); + client.configure(routed_options(proxy.http_url())); + observer.attach(client); + client.connect(); + expect(client.wait_until_connected(5s), "WS over HTTP proxy did not connect"); + expect(proxy.wait_for_targets(1, 2s), "HTTP proxy saw no CONNECT"); + const auto targets = proxy.targets(); + expect(!targets.empty() && targets.front() == server_authority(server), + "CONNECT target is not the WebSocket server"); + + const auto payloads = proxy.first_payloads(); + expect(!payloads.empty() && + payloads.front().find("GET /ws/market?feed=1 HTTP/1.1") == 0 && + payloads.front().find("Host: " + server_authority(server) + "\r\n") != + std::string::npos, + "upgrade request lost its path or remote Host header"); + + expect(client.send("ping-through-proxy"), "send through proxy failed"); + expect(server.wait_for_message_count("ping-through-proxy", 1, 2s), + "server did not receive the proxied message"); + expect(server.send_to_clients("book-update"), "server send failed"); + expect(observer.wait_for_message("book-update", 2s), + "client did not receive the proxied message"); + + // IXWebSocket reconnects to the loopback relay, which must dial + // the proxy again rather than fall back to a direct connection. + server.close_clients(); + expect(proxy.wait_for_targets(2, 5s), "reconnect did not go through the proxy"); + expect(server.wait_for_connections(2, 5s), "client did not reconnect"); + client.disconnect(); + } + expect(server.wait_for_no_clients(5s), "proxied client left a server connection open"); + } + + void websocket_through_socks5h() + { + LocalProxy proxy; + websocket_test::LocalWebSocketServer server; + polymarket::WebSocketClient client; + // A host name reaches the SOCKS5 proxy unresolved with socks5h://. + const auto port = server.url().substr(server.url().rfind(':') + 1); + client.set_url("ws://localhost:" + port); + client.configure(routed_options(proxy.socks5h_url())); + client.connect(); + expect(client.wait_until_connected(5s), "WS over SOCKS5 did not connect"); + const auto targets = proxy.targets(); + expect(!targets.empty() && targets.front() == "localhost:" + port, + "socks5h did not pass the host name to the proxy"); + client.disconnect(); + expect(server.wait_for_no_clients(5s), "SOCKS5 client left a server connection open"); + } + + void default_route_covers_http_and_websocket() + { + LocalProxy proxy; + LocalHttpServer http_server; + websocket_test::LocalWebSocketServer ws_server; + polymarket::set_default_network_route({proxy.http_url(), ""}); + + polymarket::HttpClient http; + http.set_base_url("http://127.0.0.1:" + std::to_string(http_server.port())); + expect(http.get("/routed").ok(), "HTTP request through default route failed"); + + polymarket::WebSocketClient ws; + ws.set_url(ws_server.url()); + ws.connect(); + expect(ws.wait_until_connected(5s), "WS through default route did not connect"); + ws.disconnect(); + + const auto targets = proxy.targets(); + expect(targets.size() == 2 && + targets[0] == "127.0.0.1:" + std::to_string(http_server.port()) && + targets[1] == server_authority(ws_server), + "default route did not send HTTP and WS through the proxy"); + polymarket::set_default_network_route({}); + expect(ws_server.wait_for_no_clients(5s), "default-route client left a connection open"); + } + + void routes_fail_closed() + { + websocket_test::LocalWebSocketServer server; + LocalHttpServer http_server; + LocalProxy rejecting(LocalProxy::Mode::Reject); + const std::string dead_proxy = "http://127.0.0.1:1"; + + for (const auto &proxy_url : {dead_proxy, rejecting.http_url()}) + { + WsObserver observer; + polymarket::WebSocketClient client; + client.set_url(server.url()); + auto options = routed_options(proxy_url); + options.reconnect_enabled = false; + client.configure(options); + observer.attach(client); + client.connect(); + expect(!client.wait_until_connected(3s), "WS connected despite a failed proxy"); + expect(observer.wait_for_error_containing("WebSocket tunnel could not reach", 3s), + "WS error does not name the routed failure"); + client.disconnect(); + + polymarket::HttpClientOptions http_options; + http_options.proxy_url = proxy_url; + polymarket::HttpClient http(http_options); + http.set_base_url("http://127.0.0.1:" + std::to_string(http_server.port())); + expect(!http.get("/never").ok(), "HTTP succeeded despite a failed proxy"); + } + expect(!server.wait_for_connections(1, 200ms), "a failed route reached the WS server"); + expect(http_server.requests().empty(), "a failed route reached the HTTP server"); + + polymarket::WebSocketClient unbound; + unbound.set_url(server.url()); + auto options = routed_options(""); + options.interface_name = "polymarket-no-such-if0"; + options.reconnect_enabled = false; + unbound.configure(options); + unbound.connect(); + expect(!unbound.wait_until_connected(3s), "WS connected through a missing interface"); + unbound.disconnect(); + } + + void interface_binding_routes_websocket() + { + websocket_test::LocalWebSocketServer server; + polymarket::WebSocketClient client; + client.set_url(server.url()); + auto options = routed_options(""); + options.interface_name = "127.0.0.1"; + client.configure(options); + client.connect(); + expect(client.wait_until_connected(5s), "WS bound to 127.0.0.1 did not connect"); + client.disconnect(); + expect(server.wait_for_no_clients(5s), "bound client left a server connection open"); + } + + void disconnect_interrupts_stalled_proxy() + { + LocalProxy stalled(LocalProxy::Mode::Stall); + websocket_test::LocalWebSocketServer server; + polymarket::WebSocketClient client; + client.set_url(server.url()); + client.configure(routed_options(stalled.http_url())); + client.connect(); + expect(stalled.wait_for_targets(1, 3s), "stalled proxy saw no CONNECT"); + const auto started = std::chrono::steady_clock::now(); + client.disconnect(); + expect(std::chrono::steady_clock::now() - started < 3s, + "disconnect waited for the stalled proxy connect timeout"); + } + + void geoblock_check_uses_route() + { + LocalHttpServer server; + LocalProxy proxy; + polymarket::HttpClientOptions options; + options.proxy_url = proxy.http_url(); + const auto base_url = "http://127.0.0.1:" + std::to_string(server.port()); + + server.set_response_body( + R"({"blocked":false,"ip":"203.0.113.7","country":"DE","region":"BE"})"); + const auto allowed = polymarket::check_geoblock(options, base_url); + expect(allowed.ok() && !allowed.value().blocked && allowed.value().ip == "203.0.113.7" && + allowed.value().country == "DE" && allowed.value().region == "BE", + "geoblock status was not parsed"); + expect(proxy.targets().size() == 1, "geoblock check bypassed the configured proxy"); + const auto requests = server.requests(); + expect(!requests.empty() && requests.back().path == "/api/geoblock", + "geoblock check used the wrong path"); + + server.set_response_body(R"({"blocked":true,"country":"US"})"); + const auto blocked = polymarket::check_geoblock(options, base_url); + expect(blocked.ok() && blocked.value().blocked && blocked.value().country == "US", + "blocked geoblock status was not reported"); + + server.set_response_body(R"({"ok":true})"); + const auto malformed = polymarket::check_geoblock(options, base_url); + expect(!malformed.ok() && malformed.error().code == polymarket::SdkErrorCode::Parse, + "geoblock response without a verdict must fail closed"); + } +} // namespace + +int main() +{ + websocket_through_http_proxy(); + websocket_through_socks5h(); + default_route_covers_http_and_websocket(); + routes_fail_closed(); + interface_binding_routes_websocket(); + disconnect_interrupts_stalled_proxy(); + geoblock_check_uses_route(); + return check_support::finish("test_network_routing"); +} From ef37a603990dbf36459998508aadc930d30e5deb Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:46:15 +0200 Subject: [PATCH 2/3] perf: cut market-data parsing and order signing time by about 40% Market data: - Parse each frame once without deep-copying the JSON DOM. - Read price and size strings by reference instead of copying them before conversion; strtod keeps std::stod's grammar and range errors. - Emit snapshots already in book order and detect duplicate prices with a sort instead of a hash set per side. - Gate the arbitrage check on prices before copying market identifiers. Order path: - Encode decimal uint256 values without per-digit allocations. - Hex-encode with a lookup table instead of stringstream. - Compute the constant POLY_1271 type hashes and suffix once. - Cache neg-risk metadata for the client's lifetime. A token's neg-risk flag is fixed at market creation, so the 5-minute TTL only added a round trip to the order path. Benchmarks now use real-length IDs and price_change traffic. Best of 7, Release, Apple M4 Max: 100-level book 53.7 to 28.7 us, price_change 5.8 to 3.4 us, V2 signing 27.1 to 15.6 us. New tests cover malformed numbers and changes, unsorted duplicate prices, 77-digit and uint256-boundary token IDs against their hex forms. Co-Authored-By: Claude Opus 5.5 --- benchmarks/benchmark_v2_signing.cpp | 4 +- benchmarks/benchmark_ws_parse.cpp | 78 +++++++++++++++++------ docs/benchmarks.md | 32 +++++++++- src/clob_market_metadata.cpp | 8 ++- src/order_signer.cpp | 30 +++++---- src/order_signer_v2.cpp | 59 ++++++++--------- src/orderbook_runtime_stream.cpp | 21 +++--- src/orderbook_stream.cpp | 14 +++- src/orderbook_stream.hpp | 3 + src/websocket_market_data.cpp | 99 +++++++++++++++++++++-------- tests/test_order_signer_v2.cpp | 29 +++++++++ tests/test_orderbook_stream.cpp | 34 ++++++++++ 12 files changed, 302 insertions(+), 109 deletions(-) diff --git a/benchmarks/benchmark_v2_signing.cpp b/benchmarks/benchmark_v2_signing.cpp index 1758f2d..c75e4c2 100644 --- a/benchmarks/benchmark_v2_signing.cpp +++ b/benchmarks/benchmark_v2_signing.cpp @@ -16,7 +16,9 @@ namespace order.maker = maker; order.signer = maker; order.taker = "0x0000000000000000000000000000000000000000"; - order.token_id = "1234567890"; + // Real CLOB token IDs are 77-digit uint256 values. + order.token_id = + "83782113303236477866335970881283179052006378592241188539196785830237452024272"; order.maker_amount = "1000000"; order.taker_amount = "2000000"; order.side = OrderSide::BUY; diff --git a/benchmarks/benchmark_ws_parse.cpp b/benchmarks/benchmark_ws_parse.cpp index ae64bec..9040cde 100644 --- a/benchmarks/benchmark_ws_parse.cpp +++ b/benchmarks/benchmark_ws_parse.cpp @@ -2,31 +2,73 @@ #include #include +#include -int main(int argc, char **argv) +namespace { - const int iterations = argc > 1 ? std::stoi(argv[1]) : 100000; - const std::string message = - R"([{"event_type":"book","market":"condition-1","asset_id":"token-1","timestamp":"1782753357257","bids":[{"price":"0.41","size":"100"}],"asks":[{"price":"0.42","size":"80"}]}])"; + // Real CLOB identifiers are 66-77 characters, beyond small-string storage. + const std::string condition_id = + "0x13c6cc0af4836015fcfdab862cadc62b2117df300b69ba173416fcfe9110649b"; + const std::string token_id = + "83782113303236477866335970881283179052006378592241188539196785830237452024272"; - auto start = std::chrono::steady_clock::now(); - std::size_t levels = 0; - for (int i = 0; i < iterations; ++i) + std::string book_message(int levels_per_side) { - const auto events = polymarket::detail::parse_market_book_events(message); - polymarket::Orderbook book; - for (const auto &event : events) + std::string bids; + std::string asks; + for (int level = 0; level < levels_per_side; ++level) { - polymarket::detail::apply_market_book_event(book, event, 1); - levels += book.bids.size() + book.asks.size(); + const auto separator = level == 0 ? "" : ","; + bids += separator + std::string(R"({"price":"0.)") + std::to_string(4800 - level * 10) + + R"(","size":")" + std::to_string(100 + level) + R"(.25"})"; + asks += separator + std::string(R"({"price":"0.)") + std::to_string(5200 + level * 10) + + R"(","size":")" + std::to_string(90 + level) + R"(.5"})"; } + return R"([{"event_type":"book","market":")" + condition_id + R"(","asset_id":")" + + token_id + R"(","timestamp":"1782753357257","hash":"0xabc","bids":[)" + bids + + R"(],"asks":[)" + asks + "]}]"; } - auto end = std::chrono::steady_clock::now(); - const auto elapsed_us = std::chrono::duration_cast(end - start).count(); - std::cout << "benchmark=ws_parse iterations=" << iterations - << " total_us=" << elapsed_us - << " avg_us=" << static_cast(elapsed_us) / iterations - << " levels=" << levels << "\n"; + std::string price_change_message() + { + return R"({"event_type":"price_change","market":")" + condition_id + + R"(","timestamp":"1782753357257","price_changes":[{"asset_id":")" + token_id + + R"(","price":"0.4790","size":"250.5","side":"BUY","hash":"0xdef","best_bid":"0.48","best_ask":"0.52"},{"asset_id":")" + + token_id + + R"(","price":"0.5210","size":"0","side":"SELL","hash":"0x123","best_bid":"0.48","best_ask":"0.52"}]})"; + } + + // Parses and applies one message per iteration to a persistent book, the + // steady-state work OrderbookManager does per frame. + void run(const std::string &name, const std::string &message, int iterations, + polymarket::Orderbook &book) + { + std::size_t levels = 0; + const auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < iterations; ++i) + { + for (const auto &event : polymarket::detail::parse_market_book_events(message)) + { + polymarket::detail::apply_market_book_event(book, event, 1); + levels += book.bids.size() + book.asks.size(); + } + } + const auto elapsed = std::chrono::steady_clock::now() - start; + const auto elapsed_ns = + std::chrono::duration_cast(elapsed).count(); + std::cout << "benchmark=ws_parse workload=" << name << " iterations=" << iterations + << " bytes=" << message.size() + << " avg_ns=" << static_cast(elapsed_ns) / iterations + << " levels=" << levels << "\n"; + } +} // namespace + +int main(int argc, char **argv) +{ + const int iterations = argc > 1 ? std::stoi(argv[1]) : 100000; + polymarket::Orderbook book; + run("book_100_levels", book_message(50), iterations / 10 + 1, book); + run("price_change_2", price_change_message(), iterations, book); + run("book_2_levels", book_message(1), iterations, book); return 0; } diff --git a/docs/benchmarks.md b/docs/benchmarks.md index d2b1103..24116c9 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -25,11 +25,39 @@ Run individual benchmarks: Targets: -- `benchmark_v2_signing`: signs a fixed V2 order with deterministic salts. +- `benchmark_v2_signing`: signs a fixed V2 order with a real-length 77-digit + token ID and deterministic salts. - `benchmark_order_payload`: serializes a fixed V2 order payload. - `benchmark_http_fixture`: compares cold-client and warm-client GET requests against a local loopback HTTP fixture. -- `benchmark_ws_parse`: parses representative market WebSocket JSON messages. +- `benchmark_ws_parse`: parses and applies market WebSocket messages to a + persistent book, the per-frame work `OrderbookManager` does. It reports one + line per workload: a 100-level book snapshot, a 2-change `price_change`, and a + 2-level book. Identifiers have real lengths (66-77 characters), so string + copies are not hidden by small-string storage. + +## Reference numbers + +Best of 7 runs, Release, Apple M4 Max, Apple clang 21: + +| Workload | Before | After | Change | +| -------------------------------- | ------- | ------- | ------ | +| `ws_parse` 100-level book | 53.7 µs | 28.7 µs | -47% | +| `ws_parse` 2-change price_change | 5.8 µs | 3.4 µs | -42% | +| `ws_parse` 2-level book | 4.1 µs | 2.4 µs | -42% | +| `v2_signing` | 27.1 µs | 15.6 µs | -42% | + +"Before" is `main` at `a15fbab`, measured with the same benchmark inputs. The gains come from +parsing each frame once without copying the JSON DOM, reading numbers without +string copies, emitting snapshots already in book order, encoding uint256 values +without per-digit allocations, and hex-encoding with a lookup table. + +To compare branches, run each benchmark several times and keep the best result +for each line, for example: + +```bash +for i in 1 2 3 4 5 6 7; do ./build/benchmark_ws_parse 200000; done | sort +``` Interpretation: diff --git a/src/clob_market_metadata.cpp b/src/clob_market_metadata.cpp index 73ef4f5..0601ea5 100644 --- a/src/clob_market_metadata.cpp +++ b/src/clob_market_metadata.cpp @@ -12,6 +12,10 @@ namespace polymarket namespace { + // Tick sizes change as prices approach 0 or 1, so they expire. A + // token's neg-risk flag is fixed at market creation; caching it for + // the client's lifetime keeps an extra round trip off the order path. + // clear_market_metadata_cache() still evicts both. constexpr auto METADATA_CACHE_TTL = std::chrono::minutes(5); class MetadataFetchCompletion @@ -129,8 +133,8 @@ namespace polymarket std::lock_guard lock(metadata_cache_mutex_); return neg_risk_cache_ .insert_or_assign(token_id, - MetadataCacheEntry{info, - std::chrono::steady_clock::now() + METADATA_CACHE_TTL}) + MetadataCacheEntry{ + info, std::chrono::steady_clock::time_point::max()}) .first->second.value; } catch (...) diff --git a/src/order_signer.cpp b/src/order_signer.cpp index cbc8a3b..a925770 100644 --- a/src/order_signer.cpp +++ b/src/order_signer.cpp @@ -15,26 +15,30 @@ namespace polymarket { - std::string to_hex(const std::vector &data) + namespace { - std::stringstream ss; - ss << "0x"; - for (auto b : data) + std::string prefixed_hex(const uint8_t *data, std::size_t size) { - ss << std::hex << std::setfill('0') << std::setw(2) << static_cast(b); + static constexpr char digits[] = "0123456789abcdef"; + std::string hex(2 + 2 * size, '0'); + hex[1] = 'x'; + for (std::size_t index = 0; index < size; ++index) + { + hex[2 + 2 * index] = digits[data[index] >> 4]; + hex[3 + 2 * index] = digits[data[index] & 0x0f]; + } + return hex; } - return ss.str(); + } // namespace + + std::string to_hex(const std::vector &data) + { + return prefixed_hex(data.data(), data.size()); } std::string to_hex(const std::array &data) { - std::stringstream ss; - ss << "0x"; - for (auto b : data) - { - ss << std::hex << std::setfill('0') << std::setw(2) << static_cast(b); - } - return ss.str(); + return prefixed_hex(data.data(), data.size()); } std::vector from_hex(const std::string &hex) diff --git a/src/order_signer_v2.cpp b/src/order_signer_v2.cpp index ec4f2c3..8f265b0 100644 --- a/src/order_signer_v2.cpp +++ b/src/order_signer_v2.cpp @@ -34,31 +34,23 @@ namespace polymarket return result; } - std::string num = value; - if (!std::all_of(num.begin(), num.end(), [](char digit) - { return digit >= '0' && digit <= '9'; })) + if (!std::all_of(value.begin(), value.end(), + [](char digit) { return digit >= '0' && digit <= '9'; })) { throw std::invalid_argument("uint256 value must be an unsigned decimal integer"); } - std::vector bytes; - while (!num.empty() && num != "0") + // Multiply-accumulate into the big-endian word: no allocation per digit. + for (const char digit : value) { - int remainder = 0; - std::string quotient; - for (char c : num) + unsigned carry = static_cast(digit - '0'); + for (auto byte = result.rbegin(); byte != result.rend(); ++byte) { - int digit = remainder * 10 + (c - '0'); - if (!quotient.empty() || digit / 256 > 0) - quotient += static_cast('0' + digit / 256); - remainder = digit % 256; + const unsigned next = static_cast(*byte) * 10 + carry; + *byte = static_cast(next & 0xff); + carry = next >> 8; } - bytes.push_back(static_cast(remainder)); - num = quotient.empty() ? "0" : quotient; + if (carry != 0) throw std::invalid_argument("uint256 value exceeds 32 bytes"); } - if (bytes.size() > result.size()) - throw std::invalid_argument("uint256 value exceeds 32 bytes"); - for (size_t i = 0; i < bytes.size(); i++) - result[31 - i] = bytes[i]; return result; } @@ -117,6 +109,7 @@ namespace polymarket std::array OrderSigner::hash_order_v2(const OrderData &order, const std::string &salt) { std::vector encoded; + encoded.reserve(32 * 12); static const auto type_hash = keccak256(V2_ORDER_TYPE); encoded.insert(encoded.end(), type_hash.begin(), type_hash.end()); @@ -211,14 +204,22 @@ namespace polymarket const std::array &order_hash, const std::string &verifying_contract) { - auto solady_type_hash = keccak256( - "TypedDataSign(Order contents,string name,string version,uint256 chainId," - "address verifyingContract,bytes32 salt)" + - V2_ORDER_TYPE); - auto name_hash = keccak256("DepositWallet"); - auto version_hash = keccak256("1"); + static const auto solady_type_hash = + keccak256("TypedDataSign(Order contents,string name,string version,uint256 chainId," + "address verifyingContract,bytes32 salt)" + + V2_ORDER_TYPE); + static const auto name_hash = keccak256("DepositWallet"); + static const auto version_hash = keccak256("1"); + // ERC-7739 suffix: the hex-encoded type string and its uint16 length. + static const auto type_suffix = [] + { + std::ostringstream len_hex; + len_hex << std::hex << std::setfill('0') << std::setw(4) << V2_ORDER_TYPE.size(); + return string_to_hex_no_prefix(V2_ORDER_TYPE) + len_hex.str(); + }(); std::vector encoded; + encoded.reserve(32 * 7); encoded.insert(encoded.end(), solady_type_hash.begin(), solady_type_hash.end()); encoded.insert(encoded.end(), order_hash.begin(), order_hash.end()); encoded.insert(encoded.end(), name_hash.begin(), name_hash.end()); @@ -234,13 +235,7 @@ namespace polymarket auto solady_struct_hash = keccak256(encoded); auto inner_signature = sign_hash(encode_eip712(domain_hash, solady_struct_hash)); - std::ostringstream len_hex; - len_hex << std::hex << std::setfill('0') << std::setw(4) << V2_ORDER_TYPE.size(); - - return "0x" + strip_0x(inner_signature) + - strip_0x(to_hex(domain_hash)) + - strip_0x(to_hex(order_hash)) + - string_to_hex_no_prefix(V2_ORDER_TYPE) + - len_hex.str(); + return "0x" + strip_0x(inner_signature) + strip_0x(to_hex(domain_hash)) + + strip_0x(to_hex(order_hash)) + type_suffix; } } diff --git a/src/orderbook_runtime_stream.cpp b/src/orderbook_runtime_stream.cpp index a4535b9..7b668cd 100644 --- a/src/orderbook_runtime_stream.cpp +++ b/src/orderbook_runtime_stream.cpp @@ -133,19 +133,18 @@ namespace polymarket::detail std::shared_lock lock(markets_mutex_); const auto found = markets_.find(condition_id); if (found == markets_.end()) return; + // Most updates do not cross the trigger; gate on prices before + // copying the market's identifier strings. + snapshot_market_prices(*found->second, market); + if (market.best_ask_yes <= 0.0 || market.best_ask_no <= 0.0 || + !has_fresh_arb_depth(market, now_ns(), config_.max_book_age_ms * 1'000'000ULL, + market.minimum_order_size) || + market.combined() >= config_.trigger_combined) + return; + snapshot_market_identity(*found->second, market); live = found->second; - market = snapshot_market(*live); } - if (market.best_ask_yes <= 0.0 || market.best_ask_no <= 0.0) - return; - const double required_shares = market.minimum_order_size; - if (!has_fresh_arb_depth(market, now_ns(), - config_.max_book_age_ms * 1'000'000ULL, - required_shares) || - market.combined() >= config_.trigger_combined || - !stream_is_current(expected_generation, - expected_websocket_generation)) - return; + if (!stream_is_current(expected_generation, expected_websocket_generation)) return; arb_opportunities_++; const auto callbacks = callbacks_snapshot(); diff --git a/src/orderbook_stream.cpp b/src/orderbook_stream.cpp index e173e4c..af4d28f 100644 --- a/src/orderbook_stream.cpp +++ b/src/orderbook_stream.cpp @@ -7,18 +7,29 @@ namespace polymarket::detail MarketState snapshot_market(const LiveMarketState &live) { MarketState state; + snapshot_market_prices(live, state); + snapshot_market_identity(live, state); + return state; + } + + void snapshot_market_identity(const LiveMarketState &live, MarketState &state) + { state.slug = live.slug; state.title = live.title; state.symbol = live.symbol; state.condition_id = live.condition_id; state.token_yes = live.token_yes; state.token_no = live.token_no; + state.minimum_tick_size = live.minimum_tick_size; + } + + void snapshot_market_prices(const LiveMarketState &live, MarketState &state) + { state.best_ask_yes = live.best_ask_yes.load(); state.best_ask_no = live.best_ask_no.load(); state.best_ask_yes_size = live.best_ask_yes_size.load(); state.best_ask_no_size = live.best_ask_no_size.load(); state.minimum_order_size = live.minimum_order_size; - state.minimum_tick_size = live.minimum_tick_size; state.end_time_ms = live.end_time_ms; state.neg_risk = live.neg_risk; state.fees_enabled = live.fees_enabled; @@ -28,7 +39,6 @@ namespace polymarket::detail state.last_update_yes_ns = live.last_update_yes_ns.load(); state.last_update_no_ns = live.last_update_no_ns.load(); state.update_count = live.update_count.load(); - return state; } void reset_market_leg(LiveMarketState &market, const std::string &token_id) diff --git a/src/orderbook_stream.hpp b/src/orderbook_stream.hpp index 2e363b4..802c6e5 100644 --- a/src/orderbook_stream.hpp +++ b/src/orderbook_stream.hpp @@ -20,6 +20,9 @@ namespace polymarket::detail }; MarketState snapshot_market(const LiveMarketState &market); + // Numeric fields only; enough to evaluate the arbitrage trigger. + void snapshot_market_prices(const LiveMarketState &market, MarketState &state); + void snapshot_market_identity(const LiveMarketState &market, MarketState &state); void reset_market_leg(LiveMarketState &market, const std::string &token_id); std::optional apply_stream_book_event( std::unordered_map &books, diff --git a/src/websocket_market_data.cpp b/src/websocket_market_data.cpp index 989782c..ae638f2 100644 --- a/src/websocket_market_data.cpp +++ b/src/websocket_market_data.cpp @@ -4,9 +4,10 @@ #include #include +#include #include +#include #include -#include using json = nlohmann::json; @@ -19,10 +20,20 @@ namespace polymarket::detail double number = 0.0; if (value.is_string()) { - const auto text = value.get(); - std::size_t parsed = 0; - number = std::stod(text, &parsed); - if (parsed != text.size()) + // Same grammar as std::stod without copying the string. + const auto &text = value.get_ref(); + char *end = nullptr; + errno = 0; + number = std::strtod(text.c_str(), &end); + if (end == text.c_str()) + { + throw std::invalid_argument("orderbook number is not numeric"); + } + if (errno == ERANGE) + { + throw std::out_of_range("orderbook number is out of range"); + } + if (end != text.c_str() + text.size()) { throw std::invalid_argument("orderbook number has trailing data"); } @@ -67,10 +78,11 @@ namespace polymarket::detail "book snapshot requires bids and asks arrays"); } - const auto parse_side = [](const json &levels, - std::vector &output) + // Emits each side in book order (bids descending, asks + // ascending) so applying the snapshot needs no second sort. + const auto parse_side = + [](const json &levels, std::vector &output, bool bids) { - std::unordered_set prices; output.reserve(levels.size()); for (const auto &level : levels) { @@ -79,17 +91,21 @@ namespace polymarket::detail throw std::invalid_argument( "orderbook level must be an object"); } - const auto parsed = parse_level(level); - if (!prices.insert(parsed.price).second) - { - throw std::invalid_argument( - "book snapshot contains a duplicate price"); - } - output.push_back(parsed); + output.push_back(parse_level(level)); + } + const auto ordered = [bids](const PriceLevel &left, const PriceLevel &right) + { return bids ? left.price > right.price : left.price < right.price; }; + if (!std::is_sorted(output.begin(), output.end(), ordered)) + std::sort(output.begin(), output.end(), ordered); + if (std::adjacent_find(output.begin(), output.end(), + [](const PriceLevel &left, const PriceLevel &right) + { return left.price == right.price; }) != output.end()) + { + throw std::invalid_argument("book snapshot contains a duplicate price"); } }; - parse_side(message["bids"], event.bids); - parse_side(message["asks"], event.asks); + parse_side(message["bids"], event.bids, true); + parse_side(message["asks"], event.asks, false); } bool supported_tick_size(const std::string &tick_size) @@ -134,10 +150,8 @@ namespace polymarket::detail std::vector parse_market_book_events(const std::string &message) { const auto parsed = json::parse(message); - const auto messages = parsed.is_array() ? parsed : json::array({parsed}); std::vector events; - - for (const auto &item : messages) + const auto handle_item = [&events](const json &item) { const auto event_type = item.value("event_type", ""); if (event_type == "book") @@ -163,7 +177,18 @@ namespace polymarket::detail { for (const auto &change : item["price_changes"]) { - const auto asset_id = change.value("asset_id", ""); + // Reference the parsed strings; like value(), get_ref + // throws on non-object changes and non-string fields. + if (!change.is_object()) + { + throw std::invalid_argument("price change must be an object"); + } + const auto asset_field = change.find("asset_id"); + if (asset_field == change.end()) + { + continue; + } + const auto &asset_id = asset_field->get_ref(); if (asset_id.empty()) { continue; @@ -175,15 +200,28 @@ namespace polymarket::detail events.push_back({asset_id}); event = std::prev(events.end()); } - const auto side = change.value("side", ""); - if (side != "BUY" && side != "SELL") + const auto side_field = change.find("side"); + const bool buy = side_field != change.end() && + side_field->get_ref() == "BUY"; + if (!buy && (side_field == change.end() || + side_field->get_ref() != "SELL")) { throw std::invalid_argument( "price change side must be BUY or SELL"); } - event->changes.push_back({side == "BUY", parse_level(change)}); + event->changes.push_back({buy, parse_level(change)}); } } + }; + + if (parsed.is_array()) + { + for (const auto &item : parsed) + handle_item(item); + } + else + { + handle_item(parsed); } return events; } @@ -199,10 +237,15 @@ namespace polymarket::detail for (const auto &level : event.asks) validate_level(level); book.bids = event.bids; book.asks = event.asks; - std::sort(book.bids.begin(), book.bids.end(), [](const auto &left, const auto &right) - { return left.price > right.price; }); - std::sort(book.asks.begin(), book.asks.end(), [](const auto &left, const auto &right) - { return left.price < right.price; }); + const auto descending = [](const auto &left, const auto &right) + { return left.price > right.price; }; + const auto ascending = [](const auto &left, const auto &right) + { return left.price < right.price; }; + // Parsed snapshots arrive in book order; sort others. + if (!std::is_sorted(book.bids.begin(), book.bids.end(), descending)) + std::sort(book.bids.begin(), book.bids.end(), descending); + if (!std::is_sorted(book.asks.begin(), book.asks.end(), ascending)) + std::sort(book.asks.begin(), book.asks.end(), ascending); } else { diff --git a/tests/test_order_signer_v2.cpp b/tests/test_order_signer_v2.cpp index b110856..12905f5 100644 --- a/tests/test_order_signer_v2.cpp +++ b/tests/test_order_signer_v2.cpp @@ -180,6 +180,35 @@ int main() return 1; } + // Real token IDs are 77-digit decimals; they must hash like their hex form. + auto decimal_token = base_order(SignatureType::EOA); + decimal_token.token_id = + "83782113303236477866335970881283179052006378592241188539196785830237452024272"; + auto hex_token = base_order(SignatureType::EOA); + hex_token.token_id = "0xb93afee3d1ce24bd0791e32827a3c09a5737b5923490dbcf702000488486b9d0"; + auto max_token = base_order(SignatureType::EOA); + max_token.token_id = + "115792089237316195423570985008687907853269984665640564039457584007913129639935"; + auto max_hex_token = base_order(SignatureType::EOA); + max_hex_token.token_id = "0x" + std::string(64, 'f'); + auto overflow_token = base_order(SignatureType::EOA); + overflow_token.token_id = + "115792089237316195423570985008687907853269984665640564039457584007913129639936"; + if (!expect_equal( + "77-digit decimal token id", + signer.sign_order_with_salt(decimal_token, kExchangeV2, "123456789").signature, + signer.sign_order_with_salt(hex_token, kExchangeV2, "123456789").signature) || + !expect_equal( + "uint256 max token id", + signer.sign_order_with_salt(max_token, kExchangeV2, "123456789").signature, + signer.sign_order_with_salt(max_hex_token, kExchangeV2, "123456789").signature) || + !expect_invalid_argument( + "uint256 max plus one token id", + [&] { signer.sign_order_with_salt(overflow_token, kExchangeV2, "123456789"); })) + { + return 1; + } + auto short_metadata = base_order(SignatureType::EOA); short_metadata.metadata = "0x01"; auto oversized_builder = base_order(SignatureType::EOA); diff --git a/tests/test_orderbook_stream.cpp b/tests/test_orderbook_stream.cpp index 42749eb..e4dd5d7 100644 --- a/tests/test_orderbook_stream.cpp +++ b/tests/test_orderbook_stream.cpp @@ -81,6 +81,40 @@ int main() } } + for ( + const auto *invalid_market_data : + {R"({"event_type":"book","asset_id":"yes","bids":[{"price":"0.4","size":"1"},{"price":"0.3","size":"2"},{"price":"0.4","size":"3"}],"asks":[]})", + R"({"event_type":"book","asset_id":"yes","bids":[{"price":"","size":"1"}],"asks":[]})", + R"({"event_type":"book","asset_id":"yes","bids":[{"price":"0.4","size":"1e-400"}],"asks":[]})", + R"({"event_type":"book","asset_id":"yes","bids":[{"price":"0.4x","size":"1"}],"asks":[]})", + R"({"event_type":"price_change","price_changes":["yes"]})", + R"({"event_type":"price_change","price_changes":[{"asset_id":7,"price":"0.4","size":"1","side":"BUY"}]})", + R"({"event_type":"price_change","price_changes":[{"asset_id":"yes","price":"0.4","size":"1","side":"HOLD"}]})"}) + { + bool rejected = false; + try + { + (void)detail::parse_market_book_events(invalid_market_data); + } + catch (const std::exception &) + { + rejected = true; + } + if (!check(rejected, "malformed numbers, changes, and unsorted duplicates are rejected")) + { + std::cerr << " input: " << invalid_market_data << '\n'; + return 1; + } + } + + // Snapshots arrive in book order regardless of wire order. + if (!check(snapshots[0].asks.size() == 2 && close_to(snapshots[0].asks[0].price, 0.45) && + close_to(snapshots[0].asks[1].price, 0.46), + "parsed snapshot asks ascend")) + { + return 1; + } + Orderbook yes; detail::apply_market_book_event(yes, snapshots[0], 1000); if (!check(close_to(yes.best_ask(), 0.45), "snapshot best ask") || From 2060757dd259f75fc1c4de9cb35904257dda7d54 Mon Sep 17 00:00:00 2001 From: SebastianBoehler <27767932+SebastianBoehler@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:46:15 +0200 Subject: [PATCH 3/3] docs: refresh README and contributor and agent onboarding - Rewrite the README with a centered header, badges, a compiling quick start, feature and example tables, performance numbers, and a docs index. Move migration notes and Polygon indexing details into docs/migration.md and docs/polygon-indexing.md. - Add a repository map and networking and benchmark references to AGENTS.md, and CLAUDE.md that imports it for Claude Code. - Add SECURITY.md, CODEOWNERS, and a network route field in the bug report template. - Turn build.sh into a one-command configure, build, and offline test run that works with the bash 3.2 shipped on macOS. Co-Authored-By: Claude Opus 5.5 --- .github/CODEOWNERS | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 1 + AGENTS.md | 21 + CLAUDE.md | 1 + CONTRIBUTING.md | 5 +- README.md | 565 +++++++++++---------------- SECURITY.md | 32 ++ build.sh | 59 ++- docs/migration.md | 40 ++ docs/polygon-indexing.md | 71 ++++ 10 files changed, 437 insertions(+), 360 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 CLAUDE.md create mode 100644 SECURITY.md create mode 100644 docs/migration.md create mode 100644 docs/polygon-indexing.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..a4c6e85 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default reviewer for all changes. +* @SebastianBoehler diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 566993a..249bec8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -27,6 +27,7 @@ What should have happened? - Compiler: - CMake version: - Install method: FetchContent / release archive / source build +- Network route: direct / proxy (scheme only, no credentials) / bound interface ## Notes diff --git a/AGENTS.md b/AGENTS.md index ef46109..708c7d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,12 +15,33 @@ Concurrent work is common. Continue around unrelated changes without resetting, removing, or reformatting them. When asked to commit all work, review every tracked and untracked change and create logical commit groups. +## Repository map + +| Area | Public headers (`include/polymarket/`) | Implementation (`src/`) | +| --------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| HTTP transport | `http_client.hpp`, `network.hpp` | `http_client*.cpp`, `http_global.cpp`, `network_route.cpp` | +| WebSocket transport | `websocket_client.hpp` | `websocket_client*.cpp`, `websocket_resilience.cpp`, `websocket_tunnel.cpp` | +| CLOB REST and trading | `clob_client.hpp`, `clob_types.hpp`, `geoblock.hpp` | `clob_*.cpp`, `order_execution.cpp`, `order_amounts.cpp`, `geoblock.cpp` | +| Order signing | `order_signer.hpp`, `decimal_math.hpp` | `order_signer*.cpp`, `decimal_math.cpp` | +| Market data streams | `orderbook.hpp`, `types.hpp` | `orderbook*.cpp`, `websocket_market_data.cpp`, `arb_*.cpp` | +| User stream | `user_stream.hpp` | `user_stream*.cpp`, `websocket_user_data.cpp` | +| On-chain positions | `position_client.hpp`, `trading_approvals.hpp`, `evm_*.hpp` | `position_*.cpp`, `approval_calls.cpp`, `safe_relayer.cpp`, `evm_*.cpp` | +| Polygon indexing | `json_rpc_client.hpp`, `evm_event_indexer.hpp`, `oracle_watcher.hpp` | `json_rpc_*.cpp`, `evm_*indexer*.cpp`, `oracle_*.cpp`, `polymarket_events.cpp` | + +Tests live in `tests/` (fixtures in `*_fixture.hpp` and `*_test_support.hpp`); +some contract tests live in `src/*_contract_tests.cpp`. Benchmarks live in +`benchmarks/`. `./build.sh` configures, builds, and runs the offline suite. + ## Load these references when relevant - Protocol, signing, transactions, numeric parsing, or stream lifecycle changes: read [docs/protocol-development.md](docs/protocol-development.md). - Position operations or wallet support: also read [docs/position-operations.md](docs/position-operations.md). +- Proxies, interface binding, or WebSocket transport changes: read + [docs/networking.md](docs/networking.md). Routed connections must fail closed. +- Hot-path performance changes: run the matching target in `benchmarks/` before + and after, as described in [docs/benchmarks.md](docs/benchmarks.md). - Versions, tags, release packages, or publication: read [docs/releasing.md](docs/releasing.md) and `.github/workflows/release.yml`. - Test registration and example targets: inspect `cmake/PolymarketTests.cmake` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 715a682..632c8b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,10 @@ of commits. ## Validation -Configure an isolated build directory for the task: +`./build.sh` configures a Release build with examples and tests, builds it, and +runs the offline suite. Set `BUILD_DIR`, `BUILD_TYPE`, `BENCHMARKS=ON`, or +`CMAKE_ARGS` to adjust it. To run the steps yourself, configure an isolated +build directory for the task: ```bash cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \ diff --git a/README.md b/README.md index 03189af..f5e25fc 100644 --- a/README.md +++ b/README.md @@ -1,436 +1,323 @@ -# Polymarket C++ Client +
+ +# polymarket-cpp-client + +**A low-latency C++20 SDK for Polymarket.**
+CLOB REST and WebSocket streaming, EIP-712 order signing, on-chain positions, and Polygon indexing. [![build](https://github.com/SebastianBoehler/polymarket-cpp-client/actions/workflows/build.yml/badge.svg)](https://github.com/SebastianBoehler/polymarket-cpp-client/actions/workflows/build.yml) [![release](https://img.shields.io/github/v/release/SebastianBoehler/polymarket-cpp-client)](https://github.com/SebastianBoehler/polymarket-cpp-client/releases) +[![license](https://img.shields.io/github/license/SebastianBoehler/polymarket-cpp-client)](LICENSE) +[![C++20](https://img.shields.io/badge/C%2B%2B-20-00599C?logo=cplusplus&logoColor=white)](https://en.cppreference.com/w/cpp/20) +[![CMake](https://img.shields.io/badge/CMake-3.22%2B-064F8C?logo=cmake&logoColor=white)](https://cmake.org) +[![platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20macOS-lightgrey)](#requirements) +[![stars](https://img.shields.io/github/stars/SebastianBoehler/polymarket-cpp-client?style=flat&logo=github)](https://github.com/SebastianBoehler/polymarket-cpp-client/stargazers) +[![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md) + +[Quick start](#quick-start) · +[Features](#features) · +[Installation](#installation) · +[Examples](#examples) · +[Performance](#performance) · +[Docs](#documentation) · +[Contributing](#contributing) + +
+ +--- + +## Quick start + +```cpp +#include + +#include +#include + +int main() +{ + using namespace polymarket; + const char *private_key = std::getenv("PRIVATE_KEY"); + if (!private_key) return 1; + + // Derive L2 API credentials from the signer once, then trade with both. + const auto creds = ClobClient("https://clob.polymarket.com", 137, private_key) + .create_or_derive_api_key(); + ClobClient client("https://clob.polymarket.com", 137, private_key, creds); + + // Polymarket rejects orders from restricted regions; check before trading. + const auto geoblock = client.get_geoblock_status(); + if (!geoblock || geoblock.value().blocked) return 1; -Reusable C++20 client for Polymarket: REST, WebSocket streaming, and order signing (EIP-712) with examples and tests. + client.warm_connection(); // open TCP + TLS before the first order + + CreateOrderParams order; + order.token_id = ""; + order.price = 0.42; + order.size = 10; + order.side = OrderSide::BUY; + + const auto response = client.create_and_post_order(order); // GTC by default + std::cout << (response.success ? response.order_id : response.error_msg) << "\n"; +} +``` + +Tick size and neg-risk metadata are resolved and cached for you. See +[Examples](#examples) for streaming, user events, and on-chain positions. ## Features -- **REST**: market discovery, orderbook/price queries, auth key management, and trading endpoints. -- **Transport controls**: configurable libcurl timeouts, keepalive, connection reuse, proxy/user-agent, request metrics, and cumulative stats. -- **WebSocket**: orderbook streaming via IXWebSocket with reconnect, subscription replay, typed callbacks, and backpressure counters. -- **User stream**: authenticated CLOB user channel (`UserStream`) with typed order/trade events, per-market or all-market subscriptions, plus gap callbacks (invalidate state) and recovery callbacks (reconcile via REST once the subscription is restored). -- **Signing**: CLOB V2 EIP-712 order signing (secp256k1, keccak). -- **Decimal math**: shared scaled-integer conversion for trading amounts. -- **Structured errors**: opt-in `Result` APIs with typed SDK error classification. -- **EVM JSON-RPC**: Polygon HTTP catch-up and WebSocket subscriptions for logs, heads, and pending transaction hashes, plus transaction calls (nonce, gas, `eth_call`, raw send, receipts). -- **On-chain positions**: `PositionClient` splits, merges and redeems CTF and Protocol V2 positions, and checks and sets the one-time trading approvals, from an EOA or, gaslessly through the Polymarket relayer, from a Gnosis Safe. Built on an ABI encoder, EIP-155 transaction signing, and a `PolymarketContracts` address registry. -- **Resolution Events**: Decoders for UMA adapter and Conditional Tokens resolution/redemption logs. -- **Proxy Support**: HTTP/HTTPS proxy with authentication for geo-restricted access. -- **Neg-Risk Markets**: Automatic exchange selection for neg_risk markets. -- **Examples**: REST (`rest_example`), signing (`sign_example`), WebSocket (`ws_example`), on-chain positions (`position_example`), trading approvals (`approvals_example`), onchain watchers. -- **Benchmarks and tests**: local benchmark targets plus `ctest` coverage. +| Area | What you get | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Trading** | CLOB V2 EIP-712 order signing (EOA, proxy, Safe, `POLY_1271` deposit wallets), limit and market orders, batch posting, cancels, tick-size rounding | +| **Market data** | REST books, prices, midpoints, markets, trades; WebSocket orderbook streaming with reconnect, subscription replay, gap detection, backpressure | +| **User stream** | Authenticated `UserStream` with typed order/trade events, gap callbacks, and REST reconciliation hooks | +| **On-chain positions** | `PositionClient` split/merge/redeem for CTF and Protocol V2, trading approvals, gasless Safe operations through the Polymarket relayer | +| **Polygon indexing** | JSON-RPC HTTP catch-up + WebSocket subscriptions, persistent `EvmEventIndexer`, UMA and Conditional Tokens event decoders | +| **Networking** | HTTP/HTTPS/SOCKS proxies and VPN interface binding for REST **and** WebSockets, one process-wide route, fail-closed, geoblock eligibility check | +| **Low latency** | Warm keep-alive connections, heartbeat, `TCP_NODELAY`, DNS caching, metadata caches, per-request metrics | +| **Errors** | Opt-in `Result` APIs with typed `SdkError` (transport, API, auth, rate limit, parse, signing) and request IDs | +| **Neg-risk markets** | Automatic exchange and collateral-adapter selection | ## Requirements -- CMake 3.22+ -- C++20 compiler +- CMake 3.22+ and a C++20 compiler - libcurl, OpenSSL, zlib -- Prebuilt release targets: macOS 12+ arm64 and Linux x86-64 - -## Contributing +- Linux or macOS (prebuilt releases: Linux x86-64, macOS 12+ arm64) -See [CONTRIBUTING.md](CONTRIBUTING.md) for coding rules, commit names, and checks. -Coding agents start with [AGENTS.md](AGENTS.md). +Other dependencies (nlohmann/json, IXWebSocket, secp256k1, keccak) are fetched +and pinned by hash at configure time. ## Installation -### Option 1: CMake FetchContent (Recommended) - -Add to your `CMakeLists.txt`: +### CMake FetchContent (recommended) ```cmake include(FetchContent) - -# Fetch specific version FetchContent_Declare( polymarket_client GIT_REPOSITORY https://github.com/SebastianBoehler/polymarket-cpp-client.git - GIT_TAG v3.0.0 # or any release tag + GIT_TAG v3.0.0 # or any release tag ) FetchContent_MakeAvailable(polymarket_client) -# Link to your target target_link_libraries(your_target PRIVATE polymarket::client) ``` -### Option 2: Pre-built Releases +### Prebuilt releases -Download pre-built binaries from [Releases](https://github.com/SebastianBoehler/polymarket-cpp-client/releases): +Download an archive from [Releases](https://github.com/SebastianBoehler/polymarket-cpp-client/releases) +and keep it as a dedicated prefix, since it contains its pinned static +dependencies and headers: ```bash -# macOS +# macOS arm64 (use polymarket-cpp-client-linux-x64.tar.gz on Linux) curl -LO https://github.com/SebastianBoehler/polymarket-cpp-client/releases/download/v3.0.0/polymarket-cpp-client-macos-arm64.tar.gz mkdir -p polymarket-cpp-client-3.0.0 tar -xzf polymarket-cpp-client-macos-arm64.tar.gz -C polymarket-cpp-client-3.0.0 - -# Linux -curl -LO https://github.com/SebastianBoehler/polymarket-cpp-client/releases/download/v3.0.0/polymarket-cpp-client-linux-x64.tar.gz -mkdir -p polymarket-cpp-client-3.0.0 -tar -xzf polymarket-cpp-client-linux-x64.tar.gz -C polymarket-cpp-client-3.0.0 ``` -Keep this as a dedicated prefix because the archive contains its pinned static -dependencies and headers. Then point CMake at it: - ```cmake -# Configure with: # cmake -S . -B build -DCMAKE_PREFIX_PATH=/absolute/path/to/polymarket-cpp-client-3.0.0 find_package(polymarket_client REQUIRED) target_link_libraries(your_target PRIVATE polymarket::client) ``` -### Option 3: Build from Source +### From source ```bash +./build.sh # configure, build examples and tests, run offline tests +# or step by step: cmake -S . -B build -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=ON -DPOLYMARKET_CLIENT_BUILD_TESTS=ON -cmake --build build --parallel 2 -# offline tests +cmake --build build --parallel ctest --test-dir build --output-on-failure -LE live -# optional benchmarks -cmake -S . -B build -DPOLYMARKET_CLIENT_BUILD_BENCHMARKS=ON -# install (into system or a prefix you configure) cmake --install build --prefix ``` -## Version Info - -See the [v3.0.0 release notes](docs/releases/v3.0.0.md). Rebuild consumers with -the matching headers and libraries when upgrading; public type layouts changed. - -### v3 migration - -Public headers now live under `polymarket/`. Update includes such as -`#include ` to `#include `. -The CMake target remains `polymarket::client`. Rebuild consumers with the v3 -headers and libraries. Flat compatibility headers are not installed. Use a clean install prefix; -installing over v2 does not remove its old flat headers. - -### v2 migration - -Version 2 is not binary-compatible with v1: several public concrete types grew -to support safe concurrency, stream recovery, metadata caching, and resumable -indexing. Recompile consumers against the v2 headers and archive. `OrderSigner` -is now explicitly non-copyable (moving remains supported), and balance/allowance -methods accept an optional conditional-token ID. `BalanceAllowance::allowances` -is now a per-spender map matching the V2 response. `RewardsInfo` and -`EarningsInfo` now expose V2 reward configs, market metadata, addresses, and -rates; the invented v1 `reward_epoch` and `epoch` fields were removed. -`Position` now includes the Data API's total/realized PnL, icon, event slug, -and opposite-outcome fields. Market-list methods now return `ClobMarketPage` -so callers retain `next_cursor`; read markets from its `data` member. -`create_market_order` now returns `PreparedOrder`, which keeps the FAK/FOK -execution policy beside the signature, and posting a raw `SignedOrder` requires -an explicit `OrderType`; its no-type overload defaults to FAK, matching the -combined create-and-post helper. Unsupported order-type enum values throw -instead of silently becoming GTC. `OrderResponse` now retains asynchronous -`trade_ids`, and `OpenOrder` retains owner/maker identities, associated trades, -and outcome. `Trade` now matches the V2 response, including nested -maker orders, owner/maker identities, bucket index, trader side, and optional -error information. The no-op `Config::max_combined` and `Config::size_usdc` -members were removed. `get_fee_rate` now requires a token ID; -`get_rewards_markets()` is replaced by `get_rewards_markets_current()` or the -condition-ID overload; and `Notification` now carries numeric `type`, `owner`, -and structured `payload` fields. Authentication now requires both signer and -API credentials, and an empty order tick size resolves market metadata. - -Check library version at runtime: +Upgrading from v1 or v2? See the [migration guide](docs/migration.md). Check the +version at runtime with `polymarket::version_string` from ``. -```cpp -#include -#include +## Usage -int main() { - std::cout << "polymarket-cpp-client v" << polymarket::version_string << "\n"; - // Or access individual components: - // polymarket::version_major, version_minor, version_patch -} -``` +### Stream orderbooks -## Examples - -- `rest_example`: fetch markets from CLOB REST -- `sign_example`: sign a dummy order (requires `PRIVATE_KEY`) -- `ws_example`: connect to Polymarket WS and subscribe to orderbook agg -- `user_stream_example`: stream your own order and trade events (requires `PRIVATE_KEY`; optional condition IDs as arguments) -- `position_example`: split, merge or redeem a position from an EOA or Safe; dry run unless `--execute` (requires `PRIVATE_KEY`, `POLYGON_RPC_ENDPOINT`) -- `approvals_example`: list a wallet's missing trading approvals and grant them; dry run unless `--execute` (requires `PRIVATE_KEY`, `POLYGON_RPC_ENDPOINT`) -- `uma_oracle_watch`: stream UMA adapter lifecycle events over Polygon JSON-RPC -- `condition_resolution_watch`: stream Conditional Tokens resolution/redemption events -- `evm_event_indexer_example`: persistent HTTP catch-up + live WS indexer with a cursor file -- `feed_latency_benchmark`: compare local receive timing across Polymarket market WS and Polygon RPC WS -- `polymarket_arb`: discover one crypto/neg-risk market and analyze hypothetical complementary YES/NO FOK batches +```cpp +#include -Build them with `POLYMARKET_CLIENT_BUILD_EXAMPLES=ON` and run from `build/`. -Local benchmark targets are documented in [docs/benchmarks.md](docs/benchmarks.md). +polymarket::WebSocketClient ws; +ws.set_url("wss://ws-subscriptions-clob.polymarket.com/ws/market"); -The arbitrage example defaults to a BTC 15-minute market and dry-run mode: +polymarket::WebSocketOptions options; +options.message_queue_limit = 4096; +options.max_reconnect_attempts = 5; +ws.configure(options); -```bash -./build/polymarket_arb --15m --symbol btc --fetch-only -./build/polymarket_arb --neg-risk --max 5 --dry-run +// Sent on connect and replayed after every reconnect. +ws.track_subscription(R"({"assets_ids":["",""],"type":"market"})"); +ws.on_typed_message([](const polymarket::TypedWebSocketMessage &msg) { + if (msg.event_type == "book" || msg.event_type == "price_change") { + // msg.asset_id changed + } +}); +ws.connect(); ``` -`polymarket_arb` is analysis-only. It rejects `--live` before authentication or -network access because the CLOB batch endpoint processes orders independently; -two complementary FOK orders are not an atomic trade and can leave directional -exposure. `order_test --live` remains available for an explicitly requested -single-order smoke test, not paired arbitrage execution. - -## CLOB V2 Status - -Polymarket production CLOB uses V2 at `https://clob.polymarket.com`. The -authenticated order path signs V2 EIP-712 orders, posts V2 order payloads, uses -the V2 exchange contracts, and supports the V2 `POLY_1271` deposit-wallet -signature wrapper. See [docs/clob-v2-migration.md](docs/clob-v2-migration.md) -for implementation notes. +`OrderbookManager` builds on this: it keeps sorted books per token, sends real +subscribe/unsubscribe operations, and restores the token set after reconnect. +`WebSocketStats` counts reconnects, dropped messages, parse errors, and bytes. -## On-Chain Position Operations - -`PositionClient` turns pUSD into YES+NO shares (split), YES+NO pairs back into -pUSD (merge), and resolved positions into pUSD (redeem): +### Keep connections warm ```cpp -#include "polymarket/position_client.hpp" +polymarket::HttpClientOptions http; +http.timeout_ms = 2500; +http.connect_timeout_ms = 1000; +http.dns_cache_timeout_seconds = 120; -polymarket::PositionClientConfig config; -config.private_key = std::getenv("PRIVATE_KEY"); -config.rpc_url = std::getenv("POLYGON_RPC_ENDPOINT"); -config.wallet_type = polymarket::SignatureType::POLY_GNOSIS_SAFE; // or EOA -config.relayer_api_key = std::getenv("RELAYER_API_KEY"); +polymarket::ClobClient client("https://clob.polymarket.com", 137, private_key, creds, + polymarket::SignatureType::EOA, "", http); +client.warm_connection(); +client.start_heartbeat(25); -polymarket::PositionClient client(config); -client.merge_positions(condition_id, "max").wait(); +auto response = client.create_and_post_order(params); +std::cout << "avg latency: " << client.get_connection_stats().avg_latency_ms << " ms\n"; ``` -A new wallet needs the trading approvals once; `client.setup_trading_approvals()` -grants whichever of them `client.get_trading_approvals_state()` reports missing. -See [docs/position-operations.md](docs/position-operations.md) for wallet -types, batching and atomicity, errors, and the approval set. - -## Structured Errors +Order helpers round prices, sizes, and maker/taker amounts to the market's tick +size. Leave `tick_size` empty to resolve it from the client's metadata cache. -Existing convenience methods still return `std::optional`, vectors, booleans, -or `OrderResponse`. New opt-in `Result` methods expose structured failures -for callers that need to inspect transport errors, API responses, auth failures, -rate limits, parse errors, signing errors, and invalid arguments. +### Route traffic through a proxy or VPN interface ```cpp -auto result = client.get_open_orders_result(); -if (!result) { - const auto& error = result.error(); - std::cerr << polymarket::sdk_error_code_to_string(error.code) - << " " << error.http_status - << " " << error.message << "\n"; -} -``` - -`SdkError` includes endpoint, HTTP status, response body excerpt, retryability, -and request id when the server returns a recognizable request-id header. - -## Polygon JSON-RPC Watchers +#include -The client includes provider-neutral EVM JSON-RPC helpers for users who want to build their own low-latency indexer instead of depending on a third-party Polymarket data feed. - -```bash -# Live UMA adapter events -POLYMARKET_POLYGON_RPC_WS=wss://your-polygon-rpc \ -POLYMARKET_UMA_CTF_ADAPTER=0x... \ -./build/uma_oracle_watch - -# Catch up historical CTF logs first, then stream live logs -./build/condition_resolution_watch \ - --rpc-http https://your-polygon-rpc \ - --rpc-ws wss://your-polygon-rpc \ - --ctf 0x... \ - --from-block 0x39f0000 \ - --to-block latest +// Every SDK connection, REST and WebSocket, now uses this route. +polymarket::set_default_network_route({.proxy_url = "socks5h://127.0.0.1:1080"}); +// or bind to a VPN tunnel interface instead: +polymarket::set_default_network_route({.interface_name = "wg0"}); ``` -Optional flags: - -- `--pending`: also subscribe to `newPendingTransactions` if the RPC provider or node exposes mempool data. -- `--heads`: also stream new block headers. -- `--duration-seconds `: stop automatically after `n` seconds. -- `--timeout-ms `: override the HTTP JSON-RPC timeout used during catch-up. +Routes fail closed: if the proxy or interface is unavailable, connections fail +instead of going direct. Per-client overrides live in `HttpClientOptions` and +`WebSocketOptions`. See [docs/networking.md](docs/networking.md). -Use current contract addresses from the official [Polymarket contracts docs](https://docs.polymarket.com/resources/contracts) and [resolution docs](https://docs.polymarket.com/concepts/resolution). The examples intentionally require addresses via args or env vars because Polymarket has versioned resolution contracts and docs may list both active and legacy adapters. +> [!IMPORTANT] +> Proxies and VPNs change your network path, not your location. Polymarket's +> [Terms of Use](https://polymarket.com/tos) prohibit using them to get around +> geographic restrictions. Use `get_geoblock_status()` to check eligibility. -For persistent indexing, combine `eth_getLogs` catch-up (`--from-block`) with live `eth_subscribe` logs and store the last processed block in your own application. A normal hosted WebSocket RPC is enough for confirmed logs. Complete pre-confirmation mempool visibility is not guaranteed by public RPC; for that you typically need a provider that exposes pending transactions at scale or your own Polygon node with txpool/mempool access. +### Structured errors -The reusable `EvmEventIndexer` handles the catch-up/live handoff for any `EvmLogFilter`: - -```bash -./build/evm_event_indexer_example \ - --rpc-http https://your-polygon-rpc \ - --rpc-ws wss://your-polygon-rpc \ - --ctf 0x... \ - --cursor-file ./polymarket-cursors.json \ - --start-block 0x51b0000 \ - --live \ - --duration-seconds 60 -``` - -If no cursor exists and `--start-block` is omitted, the example starts at the current latest block to avoid accidental full-history backfills. Pass `--start-block` explicitly when you want historical replay. - -Indexer delivery is at-least-once across process restarts. The cursor file -persists position, not recent log bodies, so synthetic orphan retractions are -available only for logs retained by the running process. Use confirmations and -an idempotent downstream store; rebuild from source logs if a restart spans a -reorg inside your unconfirmed window. - -`OracleResolutionDashboard` retains the newest 1,024 canonical events per key -for rollback and timeline reconstruction while its aggregate counters cover all -accepted events. A removal at or before that compacted boundary is ignored; use -persistent source logs and rebuild the dashboard when deeper reorg recovery is -required. - -## Tests - -`test_utils` exercises basic utility helpers. `test_evm_events` covers EVM topic hashing, log filter serialization, and UMA/CTF event decoding. `test_evm_event_indexer` covers block range planning and file-backed cursors. Transport, order execution, typed error, signing, and WebSocket resilience tests are included when `POLYMARKET_CLIENT_BUILD_TESTS=ON`. `test_evm_abi`, `test_evm_abi_shapes`, `test_evm_transaction`, `test_json_rpc_transactions`, `test_polymarket_contracts`, `test_position_calls`, `test_position_client`, `test_safe_relayer`, `test_safe_relayer_flows`, `test_approval_calls`, `test_trading_approvals` and `test_transaction_waiters` check ABI encoding, transaction signing, Safe signing and relayer payloads against vectors from the official Python SDK, and run position flows against local fake RPC, Gamma and relayer servers. Run via `ctest --test-dir build`. - -`test_oracle_watcher` validates in-memory normalization against synthetic UMA/CTF fixtures. -`test_oracle_watcher_historical` is labeled `live` and only performs its -historical RPC smoke check when `POLYMARKET_RUN_LIVE_SMOKE=1` is set. Run it -explicitly with `POLYMARKET_RUN_LIVE_SMOKE=1 ctest --test-dir build -L live`. -It uses these defaults unless overridden: -- `POLYMARKET_POLYGON_RPC_HTTP=https://polygon-bor.publicnode.com` -- `POLYMARKET_UMA_CTF_ADAPTER=0x6a9d222616c90fca5754cd1333cfd9b7fb6a4f74` -- `POLYMARKET_CONDITIONAL_TOKENS=0x4d97dcd97ec945f40cf65f87097ace5ea0476045` - -Set these env vars explicitly if you need another RPC or contract set. - -## Key components - -- `include/` headers for client API -- `src/http_client.cpp`: libcurl HTTP client -- `src/sdk_error.cpp`: typed SDK error helpers -- `src/websocket_client.cpp`: IXWebSocket wrapper -- `src/websocket_client_resilience.cpp`: reconnect, queue, and subscription helpers -- `src/json_rpc_client.cpp`, `src/json_rpc_transactions.cpp`: EVM HTTP/WS JSON-RPC helpers and transaction calls -- `src/evm_abi.cpp`, `src/evm_transaction.cpp`: ABI encoding and EIP-155 transaction signing -- `src/polymarket_contracts.cpp`: Polygon mainnet contract registry -- `src/position_client.cpp`, `src/position_calls.cpp`, `src/position_gamma.cpp`: split/merge/redeem, calldata and Gamma market resolution -- `src/position_senders.cpp`, `src/transaction_handle.cpp`: EOA/Safe sending and waiting for outcomes -- `src/trading_approvals.cpp`, `src/approval_calls.cpp`: trading approval state and setup, ERC-20/ERC-1155 approvals and transfers -- `src/safe_relayer.cpp`: Gnosis Safe signing and relayer submission -- `src/evm_event_indexer.cpp`: persistent log catch-up and live indexing -- `src/evm_utils.cpp`: ABI/log utilities -- `src/polymarket_events.cpp`: UMA/CTF event decoders -- `src/order_signer.cpp`: EIP-712 signing (secp256k1, keccak) -- `src/clob_client.cpp`: REST endpoints and parsing -- `src/clob_order_execution.cpp`: trading/order execution endpoints -- `src/orderbook.cpp`: WS orderbook management - -## Proxy Configuration - -Configure HTTP proxy for geo-restricted access: +Convenience methods return `std::optional`, vectors, booleans, or +`OrderResponse`. Opt-in `*_result` methods return `Result` with a typed error: ```cpp -#include "polymarket/clob_client.hpp" - -polymarket::ClobClient client("https://clob.polymarket.com", 137); - -// Set proxy (supports authentication) -client.set_proxy("http://user:pass@proxy.example.com:8080"); - -// Optional: set custom user agent -client.set_user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) ..."); +auto result = client.get_open_orders_result(); +if (!result) { + const auto &error = result.error(); + std::cerr << polymarket::sdk_error_code_to_string(error.code) << " " + << error.http_status << " " << error.message << "\n"; +} ``` -## Low-Latency Trading (Keep TCP/TLS Hot) +`SdkError` includes the endpoint, HTTP status, response excerpt, retryability, +and the request ID when the server returns one. -Keep TCP/TLS connections warm by configuring transport options once, pre-warming -the connection, and optionally running a heartbeat: +### On-chain positions ```cpp -#include "polymarket/clob_client.hpp" - -polymarket::HttpClientOptions http_options; -http_options.timeout_ms = 2500; -http_options.connect_timeout_ms = 1000; -http_options.dns_cache_timeout_seconds = 120; -http_options.tcp_nodelay = true; -http_options.tcp_keepalive = true; - -polymarket::ClobClient client("https://clob.polymarket.com", 137, - private_key, creds); -client.configure_transport(http_options); -client.warm_connection(); -client.start_heartbeat(25); +#include -auto response = client.create_and_post_order(params); -auto stats = client.get_connection_stats(); -std::cout << "Avg latency: " << stats.avg_latency_ms << "ms\n"; +polymarket::PositionClientConfig config; +config.private_key = std::getenv("PRIVATE_KEY"); +config.rpc_url = std::getenv("POLYGON_RPC_ENDPOINT"); +config.wallet_type = polymarket::SignatureType::POLY_GNOSIS_SAFE; // or EOA +config.relayer_api_key = std::getenv("RELAYER_API_KEY"); -client.stop_heartbeat(); +polymarket::PositionClient positions(config); +positions.setup_trading_approvals(); // once per wallet; grants what is missing +positions.merge_positions(condition_id, "max").wait(); ``` -## Order Precision - -Limit and market order helpers round prices, share sizes, and maker/taker -amounts with Polymarket's tick-size precision rules. Leave `tick_size` empty to -resolve it from the client's market-metadata cache, or provide the exact cached -minimum tick size explicitly. +See [docs/position-operations.md](docs/position-operations.md) for wallet types, +batching and atomicity, errors, and the approval set. -## WebSocket Resilience +### Neg-risk markets -`WebSocketClient` supports additive production-safety options for market-data -consumers: automatic reconnect backoff, ping interval, bounded message queue, -subscription replay, typed message callbacks, and counters for reconnects, -dropped messages, parse errors, last message time, messages, and bytes. +`create_order()` detects neg-risk markets and signs against the matching +exchange; `PositionClient` picks the neg-risk collateral adapter the same way. -```cpp -polymarket::WebSocketClient ws; -ws.set_url("wss://ws-subscriptions-clob.polymarket.com/ws/market"); -polymarket::WebSocketOptions options; -options.message_queue_limit = 4096; -options.min_backoff_ms = 250; -options.max_backoff_ms = 5000; -options.max_reconnect_attempts = 5; -ws.configure(options); +- Standard exchange: `0xE111180000d2663C0091e4f400237545B87B996B` +- Neg-risk exchange: `0xe2222d279d744050d28e00520010520000310F59` -const std::string subscription = R"({ - "assets_ids":["yes-token-id","no-token-id"], - "type":"market", - "custom_feature_enabled":true -})"; -ws.track_subscription(subscription); // Sent on connect and replayed on reconnect. - -ws.on_message([](const std::string& raw) { - // Raw callback remains available. -}); -ws.on_typed_message([](const polymarket::TypedWebSocketMessage& msg) { - if (msg.event_type == "book" || msg.event_type == "price_change") { - // Current CLOB market-channel event for msg.asset_id. - } -}); -ws.connect(); -``` - -Track a subscription before connecting when using `WebSocketClient` directly. -`OrderbookManager` tracks the current token set, sends real dynamic -subscribe/unsubscribe operations, and restores the set after reconnect. +## Examples -## Neg-Risk Markets +Build with `-DPOLYMARKET_CLIENT_BUILD_EXAMPLES=ON` and run from `build/`. + +| Example | What it does | +| ---------------------------- | ------------------------------------------------------------------------------------ | +| `rest_example` | Public markets and books; balances and open orders with credentials | +| `sign_example` | Signs a dummy order (`PRIVATE_KEY`) | +| `ws_example` | Streams market-channel orderbook messages | +| `user_stream_example` | Streams your own order and trade events (`PRIVATE_KEY`) | +| `position_example` | Split, merge, or redeem from an EOA or Safe; dry run unless `--execute` | +| `approvals_example` | Lists and grants missing trading approvals; dry run unless `--execute` | +| `uma_oracle_watch` | Streams UMA adapter lifecycle events over Polygon JSON-RPC | +| `condition_resolution_watch` | Streams Conditional Tokens resolution and redemption events | +| `evm_event_indexer_example` | Persistent HTTP catch-up + live WebSocket indexer with a cursor file | +| `feed_latency_benchmark` | Compares receive timing across the Polymarket market WS and a Polygon RPC WS | +| `polymarket_arb` | Analysis-only scan of complementary YES/NO books (`--15m --symbol btc --fetch-only`) | + +`polymarket_arb` rejects `--live`: the CLOB batch endpoint processes orders +independently, so two complementary FOK orders are not an atomic trade. + +## Performance + +Hot paths are measured by small benchmark targets +(`-DPOLYMARKET_CLIENT_BUILD_BENCHMARKS=ON`). Best of 7 runs, Release build, +Apple M4 Max, real-length 77-digit token IDs: + +| Workload | Time per operation | +| ----------------------------------------------- | ------------------ | +| Parse + apply a 2-change `price_change` message | 3.4 µs | +| Parse + apply a 100-level book snapshot | 28.7 µs | +| Sign a CLOB V2 order (EIP-712, secp256k1) | 15.6 µs | + +Methodology and how to compare branches: [docs/benchmarks.md](docs/benchmarks.md). + +## Documentation + +| Guide | Topic | +| ---------------------------------------------------- | ------------------------------------------------------ | +| [Networking](docs/networking.md) | Proxies, VPN interfaces, WebSocket routing, geoblock | +| [Position operations](docs/position-operations.md) | Split, merge, redeem, approvals, Safe relayer | +| [Polygon indexing](docs/polygon-indexing.md) | JSON-RPC watchers, persistent indexer, reorg handling | +| [CLOB V2 migration](docs/clob-v2-migration.md) | V2 order signing and exchange contracts | +| [Benchmarks](docs/benchmarks.md) | Benchmark targets and methodology | +| [Migration guide](docs/migration.md) | Upgrading between major versions | +| [Protocol development](docs/protocol-development.md) | Rules for protocol, signing, and stream lifecycle work | +| [Releasing](docs/releasing.md) | Release process for maintainers | -The client automatically detects neg_risk markets and uses the appropriate exchange address for order signing: +## Contributing -- **Standard markets**: `0xE111180000d2663C0091e4f400237545B87B996B` -- **Neg-risk markets**: `0xe2222d279d744050d28e00520010520000310F59` +Contributions are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) for +coding rules, commit format, and the checks CI runs. Coding agents (Claude Code, +Codex, Cursor, Copilot) start with [AGENTS.md](AGENTS.md). -This is handled automatically in `create_order()` - no manual intervention needed. -`PositionClient` likewise picks the neg-risk collateral adapter for these markets. +```bash +./build.sh # build everything and run the offline test suite +``` -## GitHub Actions +Bugs and feature requests go to [Issues](https://github.com/SebastianBoehler/polymarket-cpp-client/issues); +security reports follow [SECURITY.md](SECURITY.md). -- **build.yml**: Debug and Release builds on Linux and macOS, with examples, tests, benchmark targets, and `ctest`. -- **release.yml**: Automated releases when you push a version tag +## Disclaimer -Push a version tag to trigger the release workflow, which builds macOS/Linux -artifacts and creates the GitHub release. +This is an independent open-source project, not affiliated with or endorsed by +Polymarket. Trading involves risk of loss; test with small sizes and read the +code paths you depend on. You are responsible for complying with Polymarket's +terms and the laws of your jurisdiction. ## License -MIT +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dfa63b9 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,32 @@ +# Security policy + +This SDK handles private keys, API secrets, and order signatures. Please report +security issues privately so they can be fixed before details are public. + +## Reporting a vulnerability + +Use GitHub's private reporting: +[Report a vulnerability](https://github.com/SebastianBoehler/polymarket-cpp-client/security/advisories/new). +Do not open a public issue or pull request for a suspected vulnerability. + +Include the affected version or commit, the component (for example signing, L2 +authentication headers, transport, or position operations), reproduction steps, +and the impact you expect. Never include real private keys, API secrets, or +funded wallet details; use throwaway values. + +You can expect an acknowledgement within a few days. Fixes ship in the next +release, and the advisory credits the reporter unless you prefer otherwise. + +## Scope + +In scope: key and secret handling, signature construction, authentication +headers, TLS and proxy verification, request routing, and anything that could +leak credentials or produce an order or transaction the caller did not request. + +Out of scope: vulnerabilities in Polymarket's services or contracts (report +those to Polymarket) and issues that require an already compromised machine. + +## Supported versions + +Security fixes target the latest release. Upgrade to the newest version before +reporting. diff --git a/build.sh b/build.sh index fd5b44e..407759d 100755 --- a/build.sh +++ b/build.sh @@ -1,27 +1,46 @@ -#!/bin/bash -set -e +#!/usr/bin/env bash +# Configure, build, and run the offline test suite in one step. +# +# ./build.sh Release build in ./build with examples and tests +# BUILD_TYPE=Debug ./build.sh +# BUILD_DIR=build-asan CMAKE_ARGS="-DCMAKE_CXX_FLAGS=-fsanitize=address" ./build.sh +# BENCHMARKS=ON ./build.sh also build the benchmark targets +# SKIP_TESTS=1 ./build.sh build only +set -euo pipefail cd "$(dirname "$0")" -echo "=== Building Polymarket Arbitrage Bot (C++) ===" -echo "" +build_dir="${BUILD_DIR:-build}" +build_type="${BUILD_TYPE:-Release}" +jobs="${JOBS:-$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)}" -# Create build directory -mkdir -p build -cd build +extra_args=() +if [[ "$(uname)" == "Darwin" ]]; then + # Gives clang-tidy an explicit SDK in the compile database. + sdk_path="$(xcrun --sdk macosx --show-sdk-path 2>/dev/null || true)" + if [[ -n "${sdk_path}" ]]; then + extra_args+=("-DCMAKE_OSX_SYSROOT=${sdk_path}") + fi +fi +if [[ -n "${CMAKE_ARGS:-}" ]]; then + read -r -a user_args <<<"${CMAKE_ARGS}" + extra_args+=("${user_args[@]}") +fi -# Configure with CMake -echo "[1/3] Configuring with CMake..." -cmake .. -DCMAKE_BUILD_TYPE=Release +echo "==> Configuring ${build_dir} (${build_type})" +cmake -S . -B "${build_dir}" \ + -DCMAKE_BUILD_TYPE="${build_type}" \ + -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=ON \ + -DPOLYMARKET_CLIENT_BUILD_TESTS=ON \ + -DPOLYMARKET_CLIENT_BUILD_BENCHMARKS="${BENCHMARKS:-OFF}" \ + ${extra_args[@]+"${extra_args[@]}"} -# Build -echo "" -echo "[2/3] Building..." -make -j$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4) +echo "==> Building with ${jobs} jobs" +cmake --build "${build_dir}" --parallel "${jobs}" -echo "" -echo "[3/3] Build complete!" -echo "" -echo "Binary location: $(pwd)/polymarket_arb" -echo "" -echo "Run with: ./build/polymarket_arb --help" +if [[ -z "${SKIP_TESTS:-}" ]]; then + echo "==> Running offline tests" + ctest --test-dir "${build_dir}" --output-on-failure --parallel "${jobs}" -LE live +fi + +echo "==> Done. Binaries are in ${build_dir}/" diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..35a1888 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,40 @@ +# Migration guide + +Rebuild consumers against matching headers and libraries whenever you upgrade. +Release notes for each version live in [`docs/releases/`](releases/). + +## v3 + +Public headers now live under `polymarket/`. Update includes such as +`#include ` to `#include `. +The CMake target remains `polymarket::client`. Rebuild consumers with the v3 +headers and libraries. Flat compatibility headers are not installed. Use a clean install prefix; +installing over v2 does not remove its old flat headers. + +## v2 + +Version 2 is not binary-compatible with v1: several public concrete types grew +to support safe concurrency, stream recovery, metadata caching, and resumable +indexing. Recompile consumers against the v2 headers and archive. `OrderSigner` +is now explicitly non-copyable (moving remains supported), and balance/allowance +methods accept an optional conditional-token ID. `BalanceAllowance::allowances` +is now a per-spender map matching the V2 response. `RewardsInfo` and +`EarningsInfo` now expose V2 reward configs, market metadata, addresses, and +rates; the invented v1 `reward_epoch` and `epoch` fields were removed. +`Position` now includes the Data API's total/realized PnL, icon, event slug, +and opposite-outcome fields. Market-list methods now return `ClobMarketPage` +so callers retain `next_cursor`; read markets from its `data` member. +`create_market_order` now returns `PreparedOrder`, which keeps the FAK/FOK +execution policy beside the signature, and posting a raw `SignedOrder` requires +an explicit `OrderType`; its no-type overload defaults to FAK, matching the +combined create-and-post helper. Unsupported order-type enum values throw +instead of silently becoming GTC. `OrderResponse` now retains asynchronous +`trade_ids`, and `OpenOrder` retains owner/maker identities, associated trades, +and outcome. `Trade` now matches the V2 response, including nested +maker orders, owner/maker identities, bucket index, trader side, and optional +error information. The no-op `Config::max_combined` and `Config::size_usdc` +members were removed. `get_fee_rate` now requires a token ID; +`get_rewards_markets()` is replaced by `get_rewards_markets_current()` or the +condition-ID overload; and `Notification` now carries numeric `type`, `owner`, +and structured `payload` fields. Authentication now requires both signer and +API credentials, and an empty order tick size resolves market metadata. diff --git a/docs/polygon-indexing.md b/docs/polygon-indexing.md new file mode 100644 index 0000000..2b3253f --- /dev/null +++ b/docs/polygon-indexing.md @@ -0,0 +1,71 @@ +# Polygon JSON-RPC watchers and indexing + +The client includes provider-neutral EVM JSON-RPC helpers for users who want to build their own low-latency indexer instead of depending on a third-party Polymarket data feed. + +```bash +# Live UMA adapter events +POLYMARKET_POLYGON_RPC_WS=wss://your-polygon-rpc \ +POLYMARKET_UMA_CTF_ADAPTER=0x... \ +./build/uma_oracle_watch + +# Catch up historical CTF logs first, then stream live logs +./build/condition_resolution_watch \ + --rpc-http https://your-polygon-rpc \ + --rpc-ws wss://your-polygon-rpc \ + --ctf 0x... \ + --from-block 0x39f0000 \ + --to-block latest +``` + +Optional flags: + +- `--pending`: also subscribe to `newPendingTransactions` if the RPC provider or node exposes mempool data. +- `--heads`: also stream new block headers. +- `--duration-seconds `: stop automatically after `n` seconds. +- `--timeout-ms `: override the HTTP JSON-RPC timeout used during catch-up. + +Use current contract addresses from the official [Polymarket contracts docs](https://docs.polymarket.com/resources/contracts) and [resolution docs](https://docs.polymarket.com/concepts/resolution). The examples intentionally require addresses via args or env vars because Polymarket has versioned resolution contracts and docs may list both active and legacy adapters. + +For persistent indexing, combine `eth_getLogs` catch-up (`--from-block`) with live `eth_subscribe` logs and store the last processed block in your own application. A normal hosted WebSocket RPC is enough for confirmed logs. Complete pre-confirmation mempool visibility is not guaranteed by public RPC; for that you typically need a provider that exposes pending transactions at scale or your own Polygon node with txpool/mempool access. + +## Persistent indexer + +The reusable `EvmEventIndexer` handles the catch-up/live handoff for any `EvmLogFilter`: + +```bash +./build/evm_event_indexer_example \ + --rpc-http https://your-polygon-rpc \ + --rpc-ws wss://your-polygon-rpc \ + --ctf 0x... \ + --cursor-file ./polymarket-cursors.json \ + --start-block 0x51b0000 \ + --live \ + --duration-seconds 60 +``` + +If no cursor exists and `--start-block` is omitted, the example starts at the current latest block to avoid accidental full-history backfills. Pass `--start-block` explicitly when you want historical replay. + +Indexer delivery is at-least-once across process restarts. The cursor file +persists position, not recent log bodies, so synthetic orphan retractions are +available only for logs retained by the running process. Use confirmations and +an idempotent downstream store; rebuild from source logs if a restart spans a +reorg inside your unconfirmed window. + +`OracleResolutionDashboard` retains the newest 1,024 canonical events per key +for rollback and timeline reconstruction while its aggregate counters cover all +accepted events. A removal at or before that compacted boundary is ignored; use +persistent source logs and rebuild the dashboard when deeper reorg recovery is +required. + +## Live smoke test + +`test_oracle_watcher_historical` is labeled `live` and only performs its +historical RPC smoke check when `POLYMARKET_RUN_LIVE_SMOKE=1` is set. Run it +explicitly with `POLYMARKET_RUN_LIVE_SMOKE=1 ctest --test-dir build -L live`. +It uses these defaults unless overridden: + +- `POLYMARKET_POLYGON_RPC_HTTP=https://polygon-bor.publicnode.com` +- `POLYMARKET_UMA_CTF_ADAPTER=0x6a9d222616c90fca5754cd1333cfd9b7fb6a4f74` +- `POLYMARKET_CONDITIONAL_TOKENS=0x4d97dcd97ec945f40cf65f87097ace5ea0476045` + +Set these env vars explicitly if you need another RPC or contract set.