From 0f21a3f4fc319ead74cc63aba74e2df157b91b12 Mon Sep 17 00:00:00 2001 From: yluoc Date: Sat, 10 Oct 2026 18:33:44 -0400 Subject: [PATCH 1/6] ci: add memory and undefined-behavior checks to the test job --- .github/workflows/build.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1f22024..5a91bfd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,3 +78,39 @@ jobs: run: | ./build/rest_example POLYMARKET_RUN_LIVE_SMOKE=1 ctest --test-dir build --output-on-failure --build-config ${{ matrix.build_type }} -L live + + sanitizers: + name: ubuntu-22.04 AddressSanitizer + UBSan + runs-on: ubuntu-22.04 + env: + SANITIZER_FLAGS: -fsanitize=address,undefined -fno-sanitize-recover=all -fno-omit-frame-pointer + ASAN_OPTIONS: detect_leaks=1:strict_string_checks=1:detect_stack_use_after_return=1 + UBSAN_OPTIONS: print_stacktrace=1 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y libcurl4-openssl-dev libssl-dev + + - name: Configure + run: | + cmake -S . -B build \ + -DCMAKE_BUILD_TYPE=Debug \ + -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=OFF \ + -DPOLYMARKET_CLIENT_BUILD_TESTS=ON \ + "-DCMAKE_C_FLAGS=$SANITIZER_FLAGS" \ + "-DCMAKE_CXX_FLAGS=$SANITIZER_FLAGS" \ + "-DCMAKE_EXE_LINKER_FLAGS=-fsanitize=address,undefined" \ + "-DCMAKE_SHARED_LINKER_FLAGS=-fsanitize=address,undefined" + + - name: Build + run: cmake --build build --parallel 2 + + - name: Test + run: ctest --test-dir build --output-on-failure -LE live -E '^test_package_consumer$' From 2651bc7063d357745c4fa1a08763e183b7030bf8 Mon Sep 17 00:00:00 2001 From: yluoc Date: Sat, 10 Oct 2026 18:50:29 -0400 Subject: [PATCH 2/6] test(fuzz): add libFuzzer targets for REST and WebSocket parsers --- CMakeLists.txt | 2 + CONTRIBUTING.md | 16 ++++ cmake/PolymarketFuzz.cmake | 38 ++++++++++ tests/fuzz/corpus/rest_responses/cancellation | 1 + tests/fuzz/corpus/rest_responses/market_page | 1 + tests/fuzz/corpus/rest_responses/open_order | 1 + .../corpus/rest_responses/open_order_page | 1 + .../fuzz/corpus/rest_responses/order_rejected | 1 + .../fuzz/corpus/rest_responses/order_response | 1 + tests/fuzz/corpus/rest_responses/orderbook | 1 + tests/fuzz/corpus/rest_responses/trade_page | 1 + .../fuzz/corpus/websocket_messages/book_batch | 1 + .../corpus/websocket_messages/book_snapshot | 1 + .../corpus/websocket_messages/price_change | 1 + .../websocket_messages/tick_size_change | 1 + .../fuzz/corpus/websocket_messages/user_order | 1 + .../websocket_messages/user_order_envelope | 1 + .../fuzz/corpus/websocket_messages/user_trade | 1 + tests/fuzz/fuzz_replay_main.cpp | 63 ++++++++++++++++ tests/fuzz/fuzz_rest_responses.cpp | 73 ++++++++++++++++++ tests/fuzz/fuzz_websocket_messages.cpp | 74 +++++++++++++++++++ 21 files changed, 281 insertions(+) create mode 100644 cmake/PolymarketFuzz.cmake create mode 100644 tests/fuzz/corpus/rest_responses/cancellation create mode 100644 tests/fuzz/corpus/rest_responses/market_page create mode 100644 tests/fuzz/corpus/rest_responses/open_order create mode 100644 tests/fuzz/corpus/rest_responses/open_order_page create mode 100644 tests/fuzz/corpus/rest_responses/order_rejected create mode 100644 tests/fuzz/corpus/rest_responses/order_response create mode 100644 tests/fuzz/corpus/rest_responses/orderbook create mode 100644 tests/fuzz/corpus/rest_responses/trade_page create mode 100644 tests/fuzz/corpus/websocket_messages/book_batch create mode 100644 tests/fuzz/corpus/websocket_messages/book_snapshot create mode 100644 tests/fuzz/corpus/websocket_messages/price_change create mode 100644 tests/fuzz/corpus/websocket_messages/tick_size_change create mode 100644 tests/fuzz/corpus/websocket_messages/user_order create mode 100644 tests/fuzz/corpus/websocket_messages/user_order_envelope create mode 100644 tests/fuzz/corpus/websocket_messages/user_trade create mode 100644 tests/fuzz/fuzz_replay_main.cpp create mode 100644 tests/fuzz/fuzz_rest_responses.cpp create mode 100644 tests/fuzz/fuzz_websocket_messages.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 7e16621..ceb3843 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,6 +26,7 @@ endif() option(POLYMARKET_CLIENT_BUILD_EXAMPLES "Build example/test executables" ${POLYMARKET_CLIENT_TOP_LEVEL_DEFAULT}) option(POLYMARKET_CLIENT_BUILD_TESTS "Build test executables" ${POLYMARKET_CLIENT_TOP_LEVEL_DEFAULT}) option(POLYMARKET_CLIENT_BUILD_BENCHMARKS "Build local benchmark executables" OFF) +option(POLYMARKET_CLIENT_BUILD_FUZZERS "Build libFuzzer targets (requires Clang)" OFF) unset(POLYMARKET_CLIENT_TOP_LEVEL_DEFAULT) include(CMakePackageConfigHelpers) @@ -219,6 +220,7 @@ target_link_libraries(polymarket_client include(cmake/PolymarketExamples.cmake) include(cmake/PolymarketTests.cmake) include(cmake/PolymarketBenchmarks.cmake) +include(cmake/PolymarketFuzz.cmake) # Install library, headers, and dependency targets into a single export set install(TARGETS diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 632c8b5..f604a2e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,6 +83,22 @@ shared plugin, and checks subproject configuration. Match the full CI matrix before releasing: Linux and macOS, Debug and Release. The workflow files define the authoritative build options and live-test policy. +Parsers for REST responses and WebSocket messages have libFuzzer targets in +`tests/fuzz/`. They need Clang and a separate build directory: + +```bash +cmake -S . -B build-fuzz -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo -DPOLYMARKET_CLIENT_BUILD_FUZZERS=ON \ + -DPOLYMARKET_CLIENT_BUILD_TESTS=OFF -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=OFF +cmake --build build-fuzz --parallel 2 --target fuzz_websocket_messages fuzz_rest_responses +ctest --test-dir build-fuzz -L fuzz +./build-fuzz/fuzz_websocket_messages -max_total_time=60 tests/fuzz/corpus/websocket_messages +``` + +The `ctest` step replays the checked-in samples. Fuzzing writes new inputs into +the corpus directory, so pass a scratch copy unless an input is worth keeping. +Rerun a crash by passing its `crash-*` file to the fuzzer. + A successful fake-server test proves the local request and response contract. It does not prove a transaction succeeded on mainnet. Report the distinction. diff --git a/cmake/PolymarketFuzz.cmake b/cmake/PolymarketFuzz.cmake new file mode 100644 index 0000000..3f73048 --- /dev/null +++ b/cmake/PolymarketFuzz.cmake @@ -0,0 +1,38 @@ +if(POLYMARKET_CLIENT_BUILD_FUZZERS) + if(NOT CMAKE_CXX_COMPILER_ID MATCHES "Clang") + message(FATAL_ERROR "POLYMARKET_CLIENT_BUILD_FUZZERS requires Clang with libFuzzer") + endif() + + set(POLYMARKET_FUZZ_SANITIZERS + -fsanitize=address,undefined + -fno-sanitize-recover=undefined + -fno-omit-frame-pointer) + target_compile_options(polymarket_client PRIVATE + -fsanitize=fuzzer-no-link ${POLYMARKET_FUZZ_SANITIZERS}) + target_link_options(polymarket_client INTERFACE + $) + enable_testing() +elseif(NOT POLYMARKET_CLIENT_BUILD_TESTS) + return() +endif() + +foreach(fuzz_target IN ITEMS websocket_messages rest_responses) + if(POLYMARKET_CLIENT_BUILD_FUZZERS) + add_executable(fuzz_${fuzz_target} tests/fuzz/fuzz_${fuzz_target}.cpp) + target_compile_options(fuzz_${fuzz_target} PRIVATE + -fsanitize=fuzzer ${POLYMARKET_FUZZ_SANITIZERS}) + target_link_options(fuzz_${fuzz_target} PRIVATE -fsanitize=fuzzer) + set(fuzz_replay_args -runs=0) + else() + add_executable(fuzz_${fuzz_target} + tests/fuzz/fuzz_${fuzz_target}.cpp + tests/fuzz/fuzz_replay_main.cpp) + set(fuzz_replay_args) + endif() + target_include_directories(fuzz_${fuzz_target} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_link_libraries(fuzz_${fuzz_target} PRIVATE polymarket::client) + add_test(NAME fuzz_${fuzz_target}_corpus + COMMAND fuzz_${fuzz_target} ${fuzz_replay_args} + ${CMAKE_CURRENT_SOURCE_DIR}/tests/fuzz/corpus/${fuzz_target}) + set_tests_properties(fuzz_${fuzz_target}_corpus PROPERTIES LABELS fuzz) +endforeach() diff --git a/tests/fuzz/corpus/rest_responses/cancellation b/tests/fuzz/corpus/rest_responses/cancellation new file mode 100644 index 0000000..75a0ed1 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/cancellation @@ -0,0 +1 @@ +{"canceled":["order-1"],"not_canceled":{"order-2":"still live"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/market_page b/tests/fuzz/corpus/rest_responses/market_page new file mode 100644 index 0000000..0131a5c --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/market_page @@ -0,0 +1 @@ +{"next_cursor":"LTE=","data":[{"condition_id":"current","neg_risk":true,"active":true,"closed":false,"end_date_iso":"2099-01-01T00:00:00Z","tokens":[{"token_id":"yes","outcome":"Yes"},{"token_id":"no","outcome":"No"}]}]} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/open_order b/tests/fuzz/corpus/rest_responses/open_order new file mode 100644 index 0000000..cdd1906 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/open_order @@ -0,0 +1 @@ +{"id":"order-1","status":"LIVE","owner":"owner-1","maker_address":"0xmaker","market":"mkt","asset_id":"asset","side":"BUY","original_size":"1","size_matched":"0.5","price":"0.5","associate_trades":["trade-1"],"outcome":"Yes","created_at":1,"expiration":"0","order_type":"GTC"} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/open_order_page b/tests/fuzz/corpus/rest_responses/open_order_page new file mode 100644 index 0000000..df43856 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/open_order_page @@ -0,0 +1 @@ +{"next_cursor":"LTE=","data":[{"id":"order-1","status":"LIVE","owner":"owner-1","maker_address":"0xmaker","market":"mkt","asset_id":"asset","side":"BUY","original_size":"1","size_matched":"0.5","price":"0.5","associate_trades":["trade-1"],"outcome":"Yes","created_at":1,"expiration":"0","order_type":"GTC"}]} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/order_rejected b/tests/fuzz/corpus/rest_responses/order_rejected new file mode 100644 index 0000000..4303062 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/order_rejected @@ -0,0 +1 @@ +{"success":false,"orderID":"","status":"","errorMsg":"not enough balance"} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/order_response b/tests/fuzz/corpus/rest_responses/order_response new file mode 100644 index 0000000..9cdca7a --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/order_response @@ -0,0 +1 @@ +{"success":true,"orderID":"order-1","status":"live","makingAmount":"1","takingAmount":"2","transactionsHashes":["0x1"],"tradeIDs":["trade-1"]} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/orderbook b/tests/fuzz/corpus/rest_responses/orderbook new file mode 100644 index 0000000..55ca11e --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/orderbook @@ -0,0 +1 @@ +{"asset_id":"yes","market":"cond-1","bids":[{"price":"0.40","size":"9"},{"price":"0.39","size":"3"}],"asks":[{"price":"0.45","size":"100"}],"tick_size":"0.01","min_order_size":"5","neg_risk":false,"hash":"0xabc","timestamp":"1782753357257"} \ No newline at end of file diff --git a/tests/fuzz/corpus/rest_responses/trade_page b/tests/fuzz/corpus/rest_responses/trade_page new file mode 100644 index 0000000..6832533 --- /dev/null +++ b/tests/fuzz/corpus/rest_responses/trade_page @@ -0,0 +1 @@ +{"next_cursor":"LTE=","data":[{"id":"trade-1","taker_order_id":"0xtaker","market":"cond-1","asset_id":"yes","side":"SELL","size":"5","fee_rate_bps":"0","price":"0.42","status":"MATCHED","match_time":"1782753357","last_update":"1782753358","outcome":"Yes","bucket_index":0,"owner":"owner-key","maker_address":"0xmaker","transaction_hash":"0xhash","trader_side":"TAKER","maker_orders":[{"order_id":"0xorder","owner":"owner-key","maker_address":"0xmaker","matched_amount":"5","price":"0.42","fee_rate_bps":"0","asset_id":"yes","outcome":"Yes","side":"BUY"}]}]} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/book_batch b/tests/fuzz/corpus/websocket_messages/book_batch new file mode 100644 index 0000000..e1a6fc2 --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/book_batch @@ -0,0 +1 @@ +[{"event_type":"book","asset_id":"token-2","bids":[],"asks":[]}] \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/book_snapshot b/tests/fuzz/corpus/websocket_messages/book_snapshot new file mode 100644 index 0000000..5681a2a --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/book_snapshot @@ -0,0 +1 @@ +{"event_type":"book","asset_id":"yes","bids":[{"price":"0.4","size":"1"},{"price":"0.3","size":"2"}],"asks":[{"price":"0.45","size":"100"}]} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/price_change b/tests/fuzz/corpus/websocket_messages/price_change new file mode 100644 index 0000000..8391321 --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/price_change @@ -0,0 +1 @@ +{"event_type":"price_change","market":"condition-1","price_changes":[{"asset_id":"yes","price":"0.45","size":"0","side":"SELL","hash":"a"},{"asset_id":"yes","price":"0.44","size":"12","side":"SELL","hash":"b"},{"asset_id":"yes","price":"0.40","size":"9","side":"BUY","hash":"c"}],"timestamp":"1782753358257"} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/tick_size_change b/tests/fuzz/corpus/websocket_messages/tick_size_change new file mode 100644 index 0000000..7cc6edc --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/tick_size_change @@ -0,0 +1 @@ +{"event_type":"tick_size_change","asset_id":"yes","old_tick_size":"0.01","new_tick_size":"0.001","timestamp":"1782753359257"} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/user_order b/tests/fuzz/corpus/websocket_messages/user_order new file mode 100644 index 0000000..9f2503c --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/user_order @@ -0,0 +1 @@ +{"event_type":"order","id":"0xorder","owner":"owner-key","market":"cond-1","asset_id":"yes","side":"buy","original_size":"10","size_matched":"0","price":"0.42","type":"PLACEMENT","status":"LIVE","order_type":"GTC","associate_trades":null,"outcome":"Yes","created_at":"1782753357","expiration":"0","timestamp":"1782753357257"} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/user_order_envelope b/tests/fuzz/corpus/websocket_messages/user_order_envelope new file mode 100644 index 0000000..65a578e --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/user_order_envelope @@ -0,0 +1 @@ +{"topic":"user","type":"order","payload":{"id":"o","owner":"k","market":"m","asset_id":"t","side":"SELL","original_size":"1","size_matched":"1","price":"0.5","type":"CANCELLATION"}} \ No newline at end of file diff --git a/tests/fuzz/corpus/websocket_messages/user_trade b/tests/fuzz/corpus/websocket_messages/user_trade new file mode 100644 index 0000000..84d25ba --- /dev/null +++ b/tests/fuzz/corpus/websocket_messages/user_trade @@ -0,0 +1 @@ +[{"event_type":"trade","id":"trade-1","taker_order_id":"0xtaker","market":"cond-1","asset_id":"yes","side":"SELL","size":"5","price":"0.42","status":"MATCHED","owner":"owner-key","fee_rate_bps":"0","matchtime":"1782753357","last_update":"1782753358","timestamp":1782753357257,"trader_side":"maker","bucket_index":2,"maker_orders":[{"order_id":"0xorder","owner":"owner-key","matched_amount":"5","price":"0.42","token_id":"yes","side":"buy","outcome":"Yes"}]}] \ No newline at end of file diff --git a/tests/fuzz/fuzz_replay_main.cpp b/tests/fuzz/fuzz_replay_main.cpp new file mode 100644 index 0000000..d014787 --- /dev/null +++ b/tests/fuzz/fuzz_replay_main.cpp @@ -0,0 +1,63 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +// Replays saved fuzz inputs through a fuzz target without libFuzzer, so the +// corpus runs with any compiler. Arguments are input files or directories. +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size); + +namespace +{ + bool replay_file(const std::filesystem::path &path) + { + std::ifstream input(path, std::ios::binary); + if (!input) + { + std::cerr << "cannot read " << path << '\n'; + return false; + } + const std::string bytes((std::istreambuf_iterator(input)), + std::istreambuf_iterator()); + (void)LLVMFuzzerTestOneInput(reinterpret_cast(bytes.data()), + bytes.size()); + return true; + } +} // namespace + +int main(int argc, char **argv) +{ + std::size_t replayed = 0; + for (int index = 1; index < argc; ++index) + { + const std::filesystem::path path(argv[index]); + std::vector files; + if (std::filesystem::is_directory(path)) + { + for (const auto &entry : std::filesystem::directory_iterator(path)) + { + if (entry.is_regular_file()) files.push_back(entry.path()); + } + } + else + { + files.push_back(path); + } + for (const auto &file : files) + { + if (!replay_file(file)) return 1; + ++replayed; + } + } + if (replayed == 0) + { + std::cerr << "no fuzz inputs replayed\n"; + return 1; + } + std::cout << "replayed " << replayed << " fuzz inputs\n"; + return 0; +} diff --git a/tests/fuzz/fuzz_rest_responses.cpp b/tests/fuzz/fuzz_rest_responses.cpp new file mode 100644 index 0000000..86344fb --- /dev/null +++ b/tests/fuzz/fuzz_rest_responses.cpp @@ -0,0 +1,73 @@ +#include "clob_client_internal.hpp" +#include "polymarket/market_fetcher.hpp" +#include "rest_orderbook_parsing.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Feeds one raw REST response body to every CLOB response parser. Rejected +// input must raise std::exception; an accepted order book must hold valid +// levels with no repeated price on either side. +namespace +{ + using polymarket::PriceLevel; + + bool valid_side(const std::vector &levels) + { + std::vector prices; + prices.reserve(levels.size()); + for (const auto &level : levels) + { + if (!std::isfinite(level.price) || !std::isfinite(level.size) || level.price <= 0.0 || + level.price >= 1.0 || level.size < 0.0) + { + return false; + } + prices.push_back(level.price); + } + std::sort(prices.begin(), prices.end()); + return std::adjacent_find(prices.begin(), prices.end()) == prices.end(); + } + + template void parse_or_reject(Parse parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + } + } +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) +{ + namespace detail = polymarket::detail; + const std::string body(reinterpret_cast(data), size); + + parse_or_reject( + [&] + { + const auto book = detail::parse_rest_orderbook_json(body); + if (book.asset_id.empty() || !valid_side(book.bids) || !valid_side(book.asks)) + { + std::abort(); + } + }); + parse_or_reject([&] { (void)detail::parse_open_order_json(body); }); + parse_or_reject([&] { (void)detail::parse_open_orders_json(body); }); + parse_or_reject([&] { (void)detail::parse_open_order_page_json(body); }); + parse_or_reject([&] { (void)detail::parse_order_response_json_strict(body); }); + parse_or_reject([&] { (void)detail::parse_cancellation_response_json(body); }); + parse_or_reject([&] { (void)detail::parse_trade_page_json(body); }); + parse_or_reject([&] { (void)detail::parse_clob_markets_json(body); }); + parse_or_reject([&] { (void)detail::parse_clob_market_page_json(body); }); + return 0; +} diff --git a/tests/fuzz/fuzz_websocket_messages.cpp b/tests/fuzz/fuzz_websocket_messages.cpp new file mode 100644 index 0000000..5bf0555 --- /dev/null +++ b/tests/fuzz/fuzz_websocket_messages.cpp @@ -0,0 +1,74 @@ +#include "user_stream_protocol.hpp" +#include "websocket_resilience.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Feeds one raw WebSocket frame to every market and user stream parser. +// Rejected input must raise std::exception; accepted book updates must leave +// each book with valid, ordered levels. +namespace +{ + using polymarket::Orderbook; + using polymarket::PriceLevel; + + bool valid_level(const PriceLevel &level) + { + return std::isfinite(level.price) && std::isfinite(level.size) && level.price > 0.0 && + level.price < 1.0 && level.size >= 0.0; + } + + void require_valid_book(const Orderbook &book) + { + const auto descending = [](const PriceLevel &left, const PriceLevel &right) + { return left.price > right.price; }; + const auto ascending = [](const PriceLevel &left, const PriceLevel &right) + { return left.price < right.price; }; + if (!std::all_of(book.bids.begin(), book.bids.end(), valid_level) || + !std::all_of(book.asks.begin(), book.asks.end(), valid_level) || + !std::is_sorted(book.bids.begin(), book.bids.end(), descending) || + !std::is_sorted(book.asks.begin(), book.asks.end(), ascending)) + { + std::abort(); + } + } + + template void parse_or_reject(Parse parse) + { + try + { + parse(); + } + catch (const std::exception &) + { + } + } +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t *data, std::size_t size) +{ + namespace detail = polymarket::detail; + const std::string message(reinterpret_cast(data), size); + + parse_or_reject([&] { (void)detail::parse_typed_message(message); }); + parse_or_reject([&] { (void)detail::parse_market_tick_size_change(message); }); + parse_or_reject([&] { (void)detail::parse_user_events(message); }); + parse_or_reject( + [&] + { + std::map books; + for (const auto &event : detail::parse_market_book_events(message)) + { + auto &book = books[event.asset_id]; + parse_or_reject([&] { detail::apply_market_book_event(book, event, 1); }); + require_valid_book(book); + } + }); + return 0; +} From 8ec0e2f8bbf586412847438f6a17b2aec42db9f2 Mon Sep 17 00:00:00 2001 From: yluoc Date: Sat, 10 Oct 2026 18:58:27 -0400 Subject: [PATCH 3/6] ci(fuzz): run fuzzers on pull requests and nightly --- .github/workflows/fuzz.yml | 84 ++++++++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 4 +- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/fuzz.yml diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 0000000..355d4fa --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,84 @@ +name: fuzz + +on: + pull_request: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + inputs: + seconds: + description: Seconds to run each fuzzer + default: "600" + +concurrency: + group: fuzz-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + fuzz: + name: libFuzzer ${{ matrix.target }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + target: [websocket_messages, rest_responses] + env: + FUZZ_TARGET: fuzz_${{ matrix.target }} + FUZZ_CORPUS: tests/fuzz/corpus/${{ matrix.target }} + FUZZ_SECONDS: ${{ inputs.seconds || (github.event_name == 'schedule' && '900') || '60' }} + UBSAN_OPTIONS: print_stacktrace=1 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install -y clang-18 libclang-rt-18-dev libcurl4-openssl-dev libssl-dev + + - name: Configure + run: | + cmake -S . -B build \ + -DCMAKE_C_COMPILER=clang-18 \ + -DCMAKE_CXX_COMPILER=clang++-18 \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=OFF \ + -DPOLYMARKET_CLIENT_BUILD_TESTS=OFF \ + -DPOLYMARKET_CLIENT_BUILD_FUZZERS=ON + + - name: Build + run: cmake --build build --parallel 2 --target "$FUZZ_TARGET" + + - name: Replay samples + run: ctest --test-dir build --output-on-failure -R "^${FUZZ_TARGET}_corpus$" + + - name: Fuzz + run: | + if [[ ! "$FUZZ_SECONDS" =~ ^[0-9]+$ ]]; then + echo "seconds must be a whole number" >&2 + exit 1 + fi + mkdir -p fuzz-run/corpus fuzz-run/artifacts + cp "$FUZZ_CORPUS"/* fuzz-run/corpus/ + "./build/$FUZZ_TARGET" \ + -max_total_time="$FUZZ_SECONDS" \ + -max_len=4096 \ + -timeout=10 \ + -rss_limit_mb=2048 \ + -print_final_stats=1 \ + -artifact_prefix=fuzz-run/artifacts/ \ + fuzz-run/corpus + + - name: Upload failing inputs + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ env.FUZZ_TARGET }}-failures + path: fuzz-run/artifacts/ + if-no-files-found: ignore diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f604a2e..7be53dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,7 +97,9 @@ ctest --test-dir build-fuzz -L fuzz The `ctest` step replays the checked-in samples. Fuzzing writes new inputs into the corpus directory, so pass a scratch copy unless an input is worth keeping. -Rerun a crash by passing its `crash-*` file to the fuzzer. +Rerun a crash by passing its `crash-*` file to the fuzzer. The `fuzz` workflow +runs each fuzzer for one minute on pull requests and 15 minutes nightly, and +uploads failing inputs as a workflow artifact. A successful fake-server test proves the local request and response contract. It does not prove a transaction succeeded on mainnet. Report the distinction. From b74bdf22839bf759a47ee7ea3c0bab880b79d0a3 Mon Sep 17 00:00:00 2001 From: yluoc Date: Sat, 10 Oct 2026 19:29:00 -0400 Subject: [PATCH 4/6] fix(evm): free encoded fields when transaction encoding fails on gcc 11 --- src/evm_transaction.cpp | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/evm_transaction.cpp b/src/evm_transaction.cpp index 65f04a8..8c36024 100644 --- a/src/evm_transaction.cpp +++ b/src/evm_transaction.cpp @@ -41,12 +41,15 @@ namespace polymarket if (to.size() != 20) throw std::invalid_argument("transaction to must be a 20-byte address"); - return {encode_uint_field(tx.nonce, "nonce"), - encode_uint_field(tx.gas_price, "gas_price"), - encode_uint_field(tx.gas_limit, "gas_limit"), - detail::rlp_encode_bytes(to), - encode_uint_field(tx.value, "value"), - detail::rlp_encode_bytes(parse_hex_field(tx.data, "data"))}; + std::vector> fields; + fields.reserve(9); + fields.push_back(encode_uint_field(tx.nonce, "nonce")); + fields.push_back(encode_uint_field(tx.gas_price, "gas_price")); + fields.push_back(encode_uint_field(tx.gas_limit, "gas_limit")); + fields.push_back(detail::rlp_encode_bytes(to)); + fields.push_back(encode_uint_field(tx.value, "value")); + fields.push_back(detail::rlp_encode_bytes(parse_hex_field(tx.data, "data"))); + return fields; } } // namespace From fa19611a01d46a3a4d5e840da8612ee45d1c848b Mon Sep 17 00:00:00 2001 From: yluoc Date: Sun, 11 Oct 2026 11:41:17 -0400 Subject: [PATCH 5/6] fix(fuzz): link sanitizers into the shared library and cover it in macOS CI --- .github/workflows/fuzz.yml | 31 +++++++++++++++++++++++++++++++ cmake/PolymarketFuzz.cmake | 2 +- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 355d4fa..f2802b9 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -82,3 +82,34 @@ jobs: name: ${{ env.FUZZ_TARGET }}-failures path: fuzz-run/artifacts/ if-no-files-found: ignore + + shared-library: + name: macos-15 shared library with fuzzers + runs-on: macos-15 + env: + UBSAN_OPTIONS: print_stacktrace=1 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure + run: | + llvm_prefix="$(brew --prefix llvm@18)" + cmake -S . -B build \ + -DCMAKE_C_COMPILER="$llvm_prefix/bin/clang" \ + -DCMAKE_CXX_COMPILER="$llvm_prefix/bin/clang++" \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 \ + -DBUILD_SHARED_LIBS=ON \ + -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=OFF \ + -DPOLYMARKET_CLIENT_BUILD_TESTS=OFF \ + -DPOLYMARKET_CLIENT_BUILD_FUZZERS=ON + + - name: Build + run: cmake --build build --parallel 2 + + - name: Replay samples + run: ctest --test-dir build --output-on-failure -L fuzz diff --git a/cmake/PolymarketFuzz.cmake b/cmake/PolymarketFuzz.cmake index 3f73048..cc830e8 100644 --- a/cmake/PolymarketFuzz.cmake +++ b/cmake/PolymarketFuzz.cmake @@ -9,7 +9,7 @@ if(POLYMARKET_CLIENT_BUILD_FUZZERS) -fno-omit-frame-pointer) target_compile_options(polymarket_client PRIVATE -fsanitize=fuzzer-no-link ${POLYMARKET_FUZZ_SANITIZERS}) - target_link_options(polymarket_client INTERFACE + target_link_options(polymarket_client PUBLIC $) enable_testing() elseif(NOT POLYMARKET_CLIENT_BUILD_TESTS) From 0df85f20cf6adf6fc7ba5667295e1d0738c6159a Mon Sep 17 00:00:00 2001 From: yluoc Date: Sun, 11 Oct 2026 13:12:38 -0400 Subject: [PATCH 6/6] ci(fuzz): pin the macOS SDK sysroot for the Homebrew LLVM shared build --- .github/workflows/fuzz.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index f2802b9..3f8f0f8 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -103,6 +103,7 @@ jobs: -DCMAKE_CXX_COMPILER="$llvm_prefix/bin/clang++" \ -DCMAKE_BUILD_TYPE=RelWithDebInfo \ -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 \ + -DCMAKE_OSX_SYSROOT="$(xcrun --show-sdk-path)" \ -DBUILD_SHARED_LIBS=ON \ -DPOLYMARKET_CLIENT_BUILD_EXAMPLES=OFF \ -DPOLYMARKET_CLIENT_BUILD_TESTS=OFF \