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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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$'
116 changes: 116 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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

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 \
-DCMAKE_OSX_SYSROOT="$(xcrun --show-sdk-path)" \
-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
2 changes: 2 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,24 @@ 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. 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.

Expand Down
38 changes: 38 additions & 0 deletions cmake/PolymarketFuzz.cmake
Original file line number Diff line number Diff line change
@@ -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 PUBLIC
$<BUILD_INTERFACE:-fsanitize=address,undefined>)
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()
15 changes: 9 additions & 6 deletions src/evm_transaction.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<std::vector<uint8_t>> 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

Expand Down
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/cancellation
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"canceled":["order-1"],"not_canceled":{"order-2":"still live"}}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/market_page
Original file line number Diff line number Diff line change
@@ -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"}]}]}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/open_order
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/open_order_page
Original file line number Diff line number Diff line change
@@ -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"}]}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/order_rejected
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"success":false,"orderID":"","status":"","errorMsg":"not enough balance"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/order_response
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"success":true,"orderID":"order-1","status":"live","makingAmount":"1","takingAmount":"2","transactionsHashes":["0x1"],"tradeIDs":["trade-1"]}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/orderbook
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/rest_responses/trade_page
Original file line number Diff line number Diff line change
@@ -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"}]}]}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/book_batch
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[{"event_type":"book","asset_id":"token-2","bids":[],"asks":[]}]
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/book_snapshot
Original file line number Diff line number Diff line change
@@ -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"}]}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/price_change
Original file line number Diff line number Diff line 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"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/tick_size_change
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"event_type":"tick_size_change","asset_id":"yes","old_tick_size":"0.01","new_tick_size":"0.001","timestamp":"1782753359257"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/user_order
Original file line number Diff line number Diff line change
@@ -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"}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/user_order_envelope
Original file line number Diff line number Diff line change
@@ -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"}}
1 change: 1 addition & 0 deletions tests/fuzz/corpus/websocket_messages/user_trade
Original file line number Diff line number Diff line change
@@ -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"}]}]
63 changes: 63 additions & 0 deletions tests/fuzz/fuzz_replay_main.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <string>
#include <vector>

// 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<char>(input)),
std::istreambuf_iterator<char>());
(void)LLVMFuzzerTestOneInput(reinterpret_cast<const std::uint8_t *>(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<std::filesystem::path> 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;
}
Loading
Loading