diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aadaf8b..375eef6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,77 @@ jobs: ctest --test-dir build --build-config Release --output-on-failure -R "determinism|equivalence|checksum" + # The end-to-end claim, checked offline on every platform. + # + # `tests/fixtures/kraken_btcusd_l2.cbcap` is a verbatim recording of Kraken's v2 book + # channel. Replaying it must rebuild the book and match all 301 checksums the + # exchange published, with no network access at all. This is what turns the + # match rate in the README from a claim into a regression test — and it is only + # possible because crypto market data can be redistributed, which is why public + # equities order book projects ship without runnable data. + replay: + name: replay-${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v5 + + - name: Install OpenSSL (macOS) + if: runner.os == 'macOS' + run: | + brew install openssl@3 + echo "OPENSSL_ROOT_DIR=$(brew --prefix openssl@3)" >> "$GITHUB_ENV" + + - name: Configure + run: > + cmake -S . -B build-tools + -DCMAKE_BUILD_TYPE=Release + -DCROSSBOOK_BUILD_TESTS=ON + -DCROSSBOOK_BUILD_TOOLS=ON + -DCROSSBOOK_WERROR=ON + + - name: Build + run: cmake --build build-tools --config Release --parallel + + # The library-level assertion: every checksum, the depth contract, and the + # exact state hash. + - name: Fixture replay suite + run: > + ctest --test-dir build-tools --build-config Release --output-on-failure + -R "fixture|trim" + + # The same claim through the actual binary, whose exit status is non-zero + # on any divergence. Proves the shipped tool works, not just the library. + - name: crossbook_verify --replay (Unix) + if: runner.os != 'Windows' + run: ./build-tools/tools/crossbook_verify --replay tests/fixtures/kraken_btcusd_l2.cbcap + + - name: crossbook_verify --replay (Windows) + if: runner.os == 'Windows' + run: .\build-tools\tools\Release\crossbook_verify.exe --replay tests\fixtures\kraken_btcusd_l2.cbcap + + # Connecting to a real venue, on demand only. + # + # Deliberately not on push: a green build must never depend on an exchange + # being reachable, and a test that fails because Kraken had a slow minute + # teaches people to ignore red. Run it by hand when the transport changes. + live: + name: live (manual) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - name: Configure + run: > + cmake -S . -B build-live -DCMAKE_BUILD_TYPE=Release -DCROSSBOOK_BUILD_TOOLS=ON + - name: Build + run: cmake --build build-live --parallel + - name: Verify against Kraken for one minute + run: ./build-live/tools/crossbook_verify --venue kraken --symbol BTC/USD --seconds 60 --quiet + sanitizers: name: sanitizers (asan+ubsan) runs-on: ubuntu-latest @@ -135,6 +206,10 @@ jobs: run: ./build-fuzz/fuzz/fuzz_sequence -max_total_time=45 -print_final_stats=1 - name: Fuzz JSON scanner and venue decoders run: ./build-fuzz/fuzz/fuzz_decode -max_total_time=90 -print_final_stats=1 + # The frame reader is the only parser here fed bytes that have not been + # framed by anything we control, so it gets the longest budget. + - name: Fuzz websocket frame reader + run: ./build-fuzz/fuzz/fuzz_ws_frame -max_total_time=90 -print_final_stats=1 - name: Upload crash artefacts if: failure() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index 0abc5ee..7861cf9 100644 --- a/.gitignore +++ b/.gitignore @@ -38,10 +38,13 @@ leak-* timeout-* oom-* -# Captures — feed recordings are large and regenerable. Small committed -# fixtures live in tests/fixtures/ and are added with `git add -f`. +# Captures — feed recordings are large and regenerable, so they are ignored by +# default. The exception is tests/fixtures/, which holds the small committed +# captures CI replays offline; those are the evidence behind the match rate in +# the README, so they are un-ignored explicitly rather than force-added by hand. captures/ *.cbcap +!tests/fixtures/*.cbcap # Benchmark output bench-results/ diff --git a/CMakeLists.txt b/CMakeLists.txt index b3335f3..420e94b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,8 @@ else() endif() option(CROSSBOOK_BUILD_TESTS "Build the test suite" ${CROSSBOOK_IS_TOP_LEVEL}) +option(CROSSBOOK_BUILD_TOOLS "Build the network tools (needs a TLS backend)" + ${CROSSBOOK_IS_TOP_LEVEL}) option(CROSSBOOK_BUILD_BENCH "Build the benchmarks" OFF) option(CROSSBOOK_BUILD_FUZZ "Build the fuzz targets (clang only)" OFF) option(CROSSBOOK_WERROR "Treat warnings as errors" ${CROSSBOOK_IS_TOP_LEVEL}) @@ -90,6 +92,39 @@ if(CROSSBOOK_BUILD_BENCH) add_subdirectory(bench) endif() +# --------------------------------------------------------------------------- +# Transport and tools +# +# Kept behind an option and in a separate target because this is the only part +# of the project with a platform dependency. Windows uses Schannel, which ships +# with the OS; everywhere else uses OpenSSL. Nothing here is reachable from the +# header-only library, so consuming `crossbook::crossbook` still costs nothing. +# +# When there is no TLS backend the tools are skipped with a message rather than +# built without TLS. Talking to an exchange in plaintext is not a degraded mode, +# it is a different and much worse program. +# --------------------------------------------------------------------------- +if(CROSSBOOK_BUILD_TOOLS) + if(WIN32) + set(CROSSBOOK_HAVE_TLS ON) + else() + find_package(OpenSSL) + if(OpenSSL_FOUND) + set(CROSSBOOK_HAVE_TLS ON) + else() + set(CROSSBOOK_HAVE_TLS OFF) + message(STATUS + "crossbook: OpenSSL not found - skipping the network tools. " + "Install libssl-dev (Debian/Ubuntu) or openssl (Homebrew) to build them.") + endif() + endif() + + if(CROSSBOOK_HAVE_TLS) + add_subdirectory(src/net) + add_subdirectory(tools) + endif() +endif() + if(CROSSBOOK_BUILD_FUZZ) add_subdirectory(fuzz) endif() diff --git a/README.md b/README.md index e824fe4..4305e32 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,83 @@ Divergences are never summarised away. Every mismatch is [recorded with a cause](include/crossbook/divergence.hpp), because a match rate without an enumerated remainder isn't evidence. +## The measurement + +`crossbook_verify` connects to Kraken, rebuilds the book, and recomputes the +exchange's CRC32 over local state on every update. No API key, no account. + +```bash +cmake --preset release && cmake --build build/release +./build/release/tools/crossbook_verify --venue kraken --symbol BTC/USD --seconds 180 +``` + +A three-minute run on BTC/USD, 2026-08-01: + +```text +frames 2939 + applied 2759 +checksums verified 2759 +checksum mismatches 0 +match rate 100.000000% (2759 of 2759) +state hash 7648057f6909c67a +``` + +The exit status is the point: any mismatch, any decode failure, any resync and it +exits non-zero. + +**And you can check this without taking my word for it.** A recorded minute of +that feed is committed at [`tests/fixtures/kraken_btcusd_l2.cbcap`](tests/fixtures/) — 72 KB +of verbatim Kraken bytes — and replays offline, deterministically, on every +platform: + +```bash +./build/release/tools/crossbook_verify --replay tests/fixtures/kraken_btcusd_l2.cbcap +# 301 of 301 checksums matched, state hash 080281c2dd87183f +``` + +CI runs exactly that on Linux, macOS and Windows on every push, and asserts the +state hash is bit-identical across all three. That is why the number above is a +regression test rather than an anecdote. In equities the equivalent data is +licensed and cannot be redistributed, which is why every public ITCH order book +repository ships without runnable data and asks to be believed. + +Recording your own is one command, and works for Binance too: + +```bash +./build/release/tools/crossbook_capture --venue kraken --symbol ETH/USD \ + --seconds 60 --out eth.cbcap +./build/release/tools/crossbook_verify --replay eth.cbcap +``` + +`crossbook_capture` deliberately does not decode anything. Recording and +interpreting are separate jobs, and keeping them separate is what makes a +capture evidence rather than output: change the book implementation and the +capture is still the bytes the exchange sent, so the new implementation can be +held to them. + +### Live verification found a real bug + +Worth stating plainly, because it is the reason the verifier exists. + +The first live run reported **98.66%** — 4 of 298 updates mismatched — and the +book held 20 bid levels for a subscription that asked for 10. + +The cause is a gap in the depth-limited contract that unit tests do not reach. +Kraken reports cancellations, so a reader that handles those looks correct. It +never reports that a level fell out of the top ten because a *better* level +arrived — from the venue's side there is nothing to say. Those orphaned levels +sit below the checksummed depth doing no harm, until enough removals near the +touch promote one back into view, and then the checksum fails on an update that +was itself perfectly fine. The divergence is minutes away from its cause. + +The fix is [`BasicL2Book::trim`](include/crossbook/book.hpp), and the reason it +is trustworthy is the same reason the bug was found: replaying the committed +capture with trimming disabled still fails, and +[a test asserts that it does](tests/test_fixture_replay.cpp). + +No amount of testing the book against itself would have surfaced this. The +exchange's checksum did, in sixty seconds. + ## Performance Measured, with the methodology stated, because a number without one is noise. @@ -246,14 +323,39 @@ for (std::string_view frame : frames_from_your_transport) { - [x] Feed handler with resnapshot recovery and staleness detection - [x] HDR histogram with coordinated-omission correction - [x] Open-loop replay harness measuring against the schedule -- [x] 139 test cases / 841 assertions, `-Werror`, ASan + UBSan, four fuzz targets -- [ ] Websocket transport — **not built.** Bring your own frames. +- [x] Websocket transport: RFC 6455 framing, TLS via Schannel and OpenSSL +- [x] Depth-limited book trimming — found by live verification, not by a test +- [x] Capture and byte-exact offline replay, with a recorded capture committed +- [x] 196 test cases, `-Werror`, ASan + UBSan, five fuzz targets +- [ ] Automatic Binance REST snapshot reconciliation in the tool (v0.3) - [ ] L3 / order-by-order books (v0.4) - [ ] Cross-venue consolidated book and `executable_size` (v0.4) -The library decodes, verifies, and recovers; it does not open sockets. Feed it -frames from whatever transport you like — that boundary keeps the correctness -core testable offline and free of a TLS dependency. +The library still decodes, verifies, and recovers without opening a socket: the +transport is a separate, optional target, and consuming `crossbook::crossbook` +pulls in no TLS stack. `-DCROSSBOOK_BUILD_TOOLS=OFF` drops it entirely. That +boundary is what keeps the correctness core testable offline — which is also how +the whole stack gets tested, since CI verifies a recorded capture rather than a +live venue. + +### Dependencies, and the deliberate lack of them + +The library has none: standard library only. The JSON reader, the RFC 6455 +codec, SHA-1 and base64 are written here rather than pulled in, and two of those +are load-bearing rather than stylistic. + +The JSON reader returns the **untouched wire token** for every value, because +Kraken's checksum is computed over the digits as the venue spelled them — a +parser that hands back a `double` has already destroyed the information needed +to verify the book. And SHA-1 is here so that `Sec-WebSocket-Accept` is actually +*verified* rather than assumed; that check is what proves the peer parsed the +upgrade request rather than merely answering 101, and it is the step most +hand-rolled clients skip. + +TLS is the one thing that cannot reasonably be written here, so each platform's +own is used: Schannel on Windows, which ships with the OS, and OpenSSL +elsewhere. `cmake --build` therefore produces a working client on a stock +Windows machine with nothing installed. ## What this is not diff --git a/fuzz/CMakeLists.txt b/fuzz/CMakeLists.txt index 7882b06..94a47f4 100644 --- a/fuzz/CMakeLists.txt +++ b/fuzz/CMakeLists.txt @@ -17,3 +17,4 @@ add_fuzz_target(fuzz_parse_fixed fuzz_parse_fixed.cpp) add_fuzz_target(fuzz_book fuzz_book.cpp) add_fuzz_target(fuzz_sequence fuzz_sequence.cpp) add_fuzz_target(fuzz_decode fuzz_decode.cpp) +add_fuzz_target(fuzz_ws_frame fuzz_ws_frame.cpp) diff --git a/fuzz/fuzz_ws_frame.cpp b/fuzz/fuzz_ws_frame.cpp new file mode 100644 index 0000000..1aa9af5 --- /dev/null +++ b/fuzz/fuzz_ws_frame.cpp @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Fuzz the websocket frame reader. +// +// This is the code that reads a 64-bit length off a socket and then indexes with +// it. Every other parser in this repository consumes bytes that have already +// been framed; this one consumes whatever the peer sends, before anything has +// been validated. If exactly one thing in crossbook deserves coverage-guided +// fuzzing, it is this. +// +// The properties asserted go beyond "does not crash", because ASan already +// catches that: +// +// 1. TERMINATION. The reader must never claim progress it did not make. Every +// call either returns an event, consumes bytes, or asks for more — so a +// loop over `next` on a fixed buffer must end. +// +// 2. BOUNDS. Every returned payload must lie inside memory the reader owns, +// and must respect the configured message ceiling. ASan enforces the first +// violently; the explicit check makes the intent legible. +// +// 3. STICKINESS OF FAILURE. A protocol error is terminal. A reader that +// reported an error and then resynchronised would be building a book from +// bytes nobody has a contract for. + +#include +#include +#include + +#include "fuzz_check.hpp" + +#include "crossbook/net/ws_frame.hpp" + +using namespace crossbook::net; + +extern "C" int LLVMFuzzerTestOneInput(const std::uint8_t* data, std::size_t size) { + if (size < 2) { + return 0; + } + + // A small ceiling relative to typical inputs, so the too-large path is + // reached often rather than only on inputs the fuzzer must work to build. + constexpr std::size_t kMaxMessage = 4096; + + // The first byte picks a chunk size, so the same bytes get delivered with + // different splits. Frames straddling a read boundary is where the + // bookkeeping bugs live, and a fuzzer that always appends the whole buffer + // at once would never explore that. + const std::size_t chunk = static_cast(data[0]) + 1; + const std::uint8_t* payload = data + 1; + const std::size_t payload_size = size - 1; + + FrameReader reader(kMaxMessage); + + std::size_t offset = 0; + bool terminal = false; + + while (offset < payload_size && !terminal) { + const std::size_t take = (chunk < payload_size - offset) ? chunk : (payload_size - offset); + reader.append(reinterpret_cast(payload + offset), take); + offset += take; + + for (int guard = 0;; ++guard) { + // The reader consumes at least one frame per event, and a frame is + // at least two bytes, so the number of events from a bounded buffer + // is bounded. A run away past that means `next` returned an event + // without consuming anything. + CB_CHECK(guard <= static_cast(payload_size) + 2); + + Event event; + const ReadStatus status = reader.next(event); + + if (status == ReadStatus::kNeedMore) { + break; + } + if (status == ReadStatus::kProtocolError || status == ReadStatus::kMessageTooLarge || + status == ReadStatus::kClose) { + terminal = true; + break; + } + + // A message or a control frame: its payload must be within the + // ceiling. Control frames are separately capped at 125 by the + // specification, and the reader must be enforcing that too. + CB_CHECK(event.payload.size() <= kMaxMessage); + if (is_control(event.opcode)) { + CB_CHECK(event.payload.size() <= kMaxControlPayload); + } + + // Touch every byte so ASan checks the whole span, not just the + // pointer. A view that outlived its buffer would be invisible + // otherwise. + volatile std::uint8_t sink = 0; + for (const char c : event.payload) { + sink = static_cast(sink ^ static_cast(c)); + } + (void)sink; + } + } + + // Once a failure is latched, the reader must stay failed no matter what is + // fed to it afterwards. This is the property that stops a corrupted stream + // from producing a plausible book: there is no resynchronisation point in a + // stream of length-prefixed frames, so recovery would mean inventing frames. + if (reader.failed()) { + reader.append(reinterpret_cast(payload), payload_size); + for (int i = 0; i < 4; ++i) { + Event event; + const ReadStatus status = reader.next(event); + CB_CHECK(status == ReadStatus::kProtocolError || + status == ReadStatus::kMessageTooLarge); + } + } + + return 0; +} diff --git a/include/crossbook/book.hpp b/include/crossbook/book.hpp index a64eaf2..bf290a1 100644 --- a/include/crossbook/book.hpp +++ b/include/crossbook/book.hpp @@ -25,6 +25,7 @@ #pragma once #include +#include #include #include #include @@ -452,6 +453,61 @@ class BasicL2Book { /// Best bid / best ask, if the side has any levels. [[nodiscard]] bool best(Side s, Level& out) const noexcept { return side(s).best(out); } + /// Drop levels beyond `max_levels` on one side, keeping those nearest the + /// touch. Returns how many were removed. `max_levels == 0` does nothing. + /// + /// DEPTH-LIMITED SUBSCRIPTIONS NEED THIS, AND THE NEED IS NOT OBVIOUS. + /// + /// A venue serving a top-N book tells you when a level is cancelled, but not + /// when a level falls out of the window because a better one arrived — from + /// its point of view there is nothing to report, the level simply is not in + /// the top N any more. A client that never trims therefore accumulates + /// levels the venue stopped tracking long ago. They sit there harmlessly, + /// below the depth the checksum covers, until enough removals near the touch + /// let one back into the top 10 — and then the checksum fails, on an update + /// that did nothing wrong, minutes after the actual divergence. + /// + /// Measured against Kraken BTC/USD at depth 10: without trimming, 4 of 298 + /// updates mismatched over one minute, and the book had grown to 20 bid + /// levels for a 10-level subscription. With it, every update matched. + /// + /// Allocation-free: doomed prices are batched through a stack buffer, and + /// the loop repeats if one pass could not name them all. + std::size_t trim(Side s, std::size_t max_levels) { + if (max_levels == 0) { + return 0; + } + + std::size_t removed = 0; + for (;;) { + // Collect before removing: mutating a side while iterating it is + // undefined for the map, and confusing for the array. + std::array doomed{}; + std::size_t seen = 0; + std::size_t count = 0; + side(s).for_each([&](const Level& lvl) { + if (seen++ < max_levels) { + return true; + } + doomed[count++] = lvl.price; + return count < doomed.size(); + }); + + if (count == 0) { + return removed; + } + for (std::size_t i = 0; i < count; ++i) { + side(s).apply(doomed[i], Qty{0}); + } + removed += count; + } + } + + /// Trim both sides. The common case, since a depth is per subscription. + std::size_t trim(std::size_t max_levels) { + return trim(Side::kBid, max_levels) + trim(Side::kAsk, max_levels); + } + /// Copy the top `n` levels of a side, in book order. Returns how many were /// available (which may be fewer than `n`). [[nodiscard]] std::size_t top(Side s, std::size_t n, std::vector& out) const { diff --git a/include/crossbook/capture.hpp b/include/crossbook/capture.hpp new file mode 100644 index 0000000..d423648 --- /dev/null +++ b/include/crossbook/capture.hpp @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Capture files: raw venue frames plus the instant each one arrived. +// +// WHY THIS FILE IS THE POINT OF THE TRANSPORT LAYER +// +// The claim this repository makes — that the book is verified against the +// exchange's own arithmetic on live data — is only interesting if someone else +// can check it. In equities they cannot: the equivalent data is licensed and +// cannot be redistributed, which is why every public ITCH order book project +// ships without runnable data and asks to be believed. +// +// Crypto venues have no such restriction, so a capture can simply be committed. +// A capture turns a live measurement into a reproducible one: the same bytes +// replayed on any machine must produce the same book, the same state hash, and +// the same checksum match rate. That makes the number in the README a test +// rather than a claim, and it is what CI replays offline on every push. +// +// THE FORMAT +// +// line 1 "CBCAP1 \n" +// then, repeating: +// " \n" +// "\n" +// +// Length-prefixed rather than one-JSON-object-per-line, for two reasons. The +// bytes are stored exactly as they came off the wire — no escaping, no +// re-encoding, nothing that could make the replayed book differ from the live +// one, which would defeat the entire purpose. And a frame containing a newline +// stays one record instead of silently becoming two. +// +// Timestamps are steady-clock nanoseconds. Only differences are meaningful; the +// epoch is arbitrary and deliberately not wall-clock, because a capture that +// straddles an NTP step should not contain a negative inter-arrival gap. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace crossbook { +namespace detail { + +/// fopen, without tripping MSVC's deprecation of it. +/// +/// A header that forces `_CRT_SECURE_NO_WARNINGS` on everyone who includes it +/// is worse than the four lines it saves — that macro would silence the warning +/// in the consumer's own code too. +[[nodiscard]] inline std::FILE* open_file(const char* path, const char* mode) noexcept { +#ifdef _MSC_VER + std::FILE* file = nullptr; + return (::fopen_s(&file, path, mode) == 0) ? file : nullptr; +#else + return std::fopen(path, mode); +#endif +} + +} // namespace detail + +/// One recorded frame. `payload` views into the buffer held by `Capture`. +struct CapturedFrame { + std::int64_t ts_recv{0}; + std::string_view payload; +}; + +/// Streaming writer. Frames are appended as they arrive, so a capture that is +/// interrupted is still a valid prefix rather than a lost file. +class CaptureWriter { +public: + CaptureWriter() = default; + + ~CaptureWriter() { close(); } + + CaptureWriter(const CaptureWriter&) = delete; + CaptureWriter& operator=(const CaptureWriter&) = delete; + + [[nodiscard]] bool open(const std::string& path, std::string_view venue, + std::string_view symbol, std::int64_t start_unix_ns) { + close(); + // Binary mode matters on Windows: text mode would translate '\n' into + // CRLF inside frame payloads and change the bytes being recorded. + file_ = detail::open_file(path.c_str(), "wb"); + if (file_ == nullptr) { + return false; + } + const int written = std::fprintf(file_, "CBCAP1 %.*s %.*s %lld\n", + static_cast(venue.size()), venue.data(), + static_cast(symbol.size()), symbol.data(), + static_cast(start_unix_ns)); + return written > 0; + } + + [[nodiscard]] bool write(std::int64_t ts_recv, std::string_view payload) { + if (file_ == nullptr) { + return false; + } + if (std::fprintf(file_, "%lld %zu\n", static_cast(ts_recv), + payload.size()) <= 0) { + return false; + } + if (!payload.empty() && + std::fwrite(payload.data(), 1, payload.size(), file_) != payload.size()) { + return false; + } + if (std::fputc('\n', file_) == EOF) { + return false; + } + ++frames_; + bytes_ += payload.size(); + return true; + } + + void close() { + if (file_ != nullptr) { + (void)std::fclose(file_); + file_ = nullptr; + } + } + + [[nodiscard]] std::uint64_t frames() const noexcept { return frames_; } + [[nodiscard]] std::uint64_t bytes() const noexcept { return bytes_; } + [[nodiscard]] bool is_open() const noexcept { return file_ != nullptr; } + +private: + std::FILE* file_{nullptr}; + std::uint64_t frames_{0}; + std::uint64_t bytes_{0}; +}; + +/// A loaded capture. Owns the file contents; the frame views point into it. +class Capture { +public: + [[nodiscard]] const std::string& venue() const noexcept { return venue_; } + [[nodiscard]] const std::string& symbol() const noexcept { return symbol_; } + [[nodiscard]] std::int64_t start_unix_ns() const noexcept { return start_unix_ns_; } + [[nodiscard]] const std::vector& frames() const noexcept { return frames_; } + [[nodiscard]] bool empty() const noexcept { return frames_.empty(); } + + /// Parse a capture held in memory. + /// + /// Returns false and leaves `error` set on anything it cannot navigate. A + /// truncated final record is tolerated — a capture cut short by Ctrl-C ends + /// mid-frame, and refusing to load it would make every interrupted run + /// worthless. + [[nodiscard]] bool parse(std::string contents, std::string& error) { + buffer_ = std::move(contents); + frames_.clear(); + + std::string_view text(buffer_); + const std::size_t first_eol = text.find('\n'); + if (first_eol == std::string_view::npos) { + error = "capture has no header line"; + return false; + } + + std::string_view header = text.substr(0, first_eol); + if (!header.starts_with("CBCAP1 ")) { + error = "capture header is not CBCAP1"; + return false; + } + header.remove_prefix(7); + + if (!take_field(header, venue_) || !take_field(header, symbol_)) { + error = "capture header is missing venue or symbol"; + return false; + } + std::string start_text; + (void)take_field(header, start_text); + start_unix_ns_ = parse_i64(start_text); + + std::size_t pos = first_eol + 1; + while (pos < text.size()) { + const std::size_t eol = text.find('\n', pos); + if (eol == std::string_view::npos) { + break; // Truncated record header; see the note above. + } + const std::string_view record = text.substr(pos, eol - pos); + const std::size_t space = record.find(' '); + if (space == std::string_view::npos) { + error = "malformed record header: " + std::string(record); + return false; + } + + const std::int64_t ts = parse_i64(record.substr(0, space)); + const std::int64_t len = parse_i64(record.substr(space + 1)); + if (len < 0) { + error = "negative frame length"; + return false; + } + + const std::size_t body = eol + 1; + const auto count = static_cast(len); + if (body + count > text.size()) { + break; // Truncated payload. + } + + frames_.push_back(CapturedFrame{ts, text.substr(body, count)}); + pos = body + count + 1; // Skip the record's trailing newline. + } + + error.clear(); + return true; + } + + /// Load and parse a capture from disk. + [[nodiscard]] bool load(const std::string& path, std::string& error) { + std::FILE* file = detail::open_file(path.c_str(), "rb"); + if (file == nullptr) { + error = "cannot open capture: " + path; + return false; + } + std::string contents; + char chunk[65536]; + for (;;) { + const std::size_t got = std::fread(chunk, 1, sizeof(chunk), file); + if (got == 0) { + break; + } + contents.append(chunk, got); + } + (void)std::fclose(file); + return parse(std::move(contents), error); + } + + /// Median inter-arrival gap, nanoseconds. Zero for a capture of fewer than + /// two frames. Median rather than mean because market data arrives in + /// bursts and a mean is dominated by the quiet stretches between them. + [[nodiscard]] std::int64_t median_gap_ns() const { + if (frames_.size() < 2) { + return 0; + } + std::vector gaps; + gaps.reserve(frames_.size() - 1); + for (std::size_t i = 1; i < frames_.size(); ++i) { + const std::int64_t delta = frames_[i].ts_recv - frames_[i - 1].ts_recv; + gaps.push_back(delta > 0 ? delta : 0); + } + const std::size_t mid = gaps.size() / 2; + std::nth_element(gaps.begin(), gaps.begin() + static_cast(mid), + gaps.end()); + return gaps[mid]; + } + +private: + /// Pull one space-delimited field off the front of `text`. + static bool take_field(std::string_view& text, std::string& out) { + if (text.empty()) { + return false; + } + const std::size_t space = text.find(' '); + if (space == std::string_view::npos) { + out.assign(text); + text = {}; + return !out.empty(); + } + out.assign(text.substr(0, space)); + text.remove_prefix(space + 1); + return !out.empty(); + } + + /// Decimal parse that returns -1 rather than throwing. Every caller checks + /// the result against a range, so a sentinel beats an exception here. + [[nodiscard]] static std::int64_t parse_i64(std::string_view text) noexcept { + if (text.empty()) { + return -1; + } + std::int64_t value = 0; + for (const char c : text) { + if (c < '0' || c > '9') { + return -1; + } + if (value > (9'223'372'036'854'775'807LL - (c - '0')) / 10) { + return -1; // Overflow: reject rather than wrap. + } + value = value * 10 + (c - '0'); + } + return value; + } + + std::string buffer_; + std::string venue_; + std::string symbol_; + std::vector frames_; + std::int64_t start_unix_ns_{0}; +}; + +} // namespace crossbook diff --git a/include/crossbook/feed.hpp b/include/crossbook/feed.hpp index cd24520..792cb9e 100644 --- a/include/crossbook/feed.hpp +++ b/include/crossbook/feed.hpp @@ -77,6 +77,10 @@ struct FeedStats { std::uint64_t snapshots_applied{0}; std::uint64_t checksums_verified{0}; std::uint64_t checksum_mismatches{0}; + /// Levels dropped for falling outside a depth-limited subscription. A + /// steady trickle is normal and expected; zero on a depth-limited feed + /// means the depth was never configured, which is worth being able to see. + std::uint64_t levels_trimmed{0}; }; /// Drives one instrument on one venue. @@ -86,16 +90,24 @@ struct FeedStats { template class Feed { public: - Feed(std::string venue, Decoder decoder, SequencePolicy policy) + /// `depth` is the number of levels the subscription covers, or 0 for a full + /// book. It is not cosmetic: a depth-limited venue never tells you that a + /// level fell out of the window, so a feed that does not trim accumulates + /// levels the venue stopped tracking and eventually fails its checksums. + /// See `BasicL2Book::trim` for the measurement. + Feed(std::string venue, Decoder decoder, SequencePolicy policy, std::size_t depth = 0) : venue_(std::move(venue)), decoder_(std::move(decoder)), book_(decoder_.spec()), - tracker_(policy) {} + tracker_(policy), + depth_(depth) {} [[nodiscard]] const BookT& book() const noexcept { return book_; } [[nodiscard]] const DivergenceLog& divergences() const noexcept { return log_; } [[nodiscard]] const SequenceTracker& sequence() const noexcept { return tracker_; } [[nodiscard]] const FeedStats& stats() const noexcept { return stats_; } + /// Levels per side this subscription covers; 0 for a full book. + [[nodiscard]] std::size_t depth() const noexcept { return depth_; } [[nodiscard]] Decoder& decoder() noexcept { return decoder_; } /// True when the book reflects the venue and may be read. @@ -185,6 +197,7 @@ class Feed { for (const LevelUpdate& lvl : msg.levels) { book_.apply(lvl.side, lvl.price, lvl.qty); } + stats_.levels_trimmed += book_.trim(depth_); book_.set_last_update(msg.ts); last_ts_ = msg.ts; @@ -230,6 +243,10 @@ class Feed { for (const LevelUpdate& lvl : msg.levels) { book_.apply(lvl.side, lvl.price, lvl.qty); } + // Trim BEFORE verifying. The checksum covers the top ten levels, so a + // stale level that has re-entered the top ten is exactly what the + // checksum is about to catch - and exactly what trimming prevents. + stats_.levels_trimmed += book_.trim(depth_); book_.set_last_update(msg.ts); last_ts_ = msg.ts; ++stats_.applied; @@ -270,6 +287,7 @@ class Feed { Decoder decoder_; BookT book_; SequenceTracker tracker_; + std::size_t depth_{0}; DivergenceLog log_; FeedStats stats_{}; Timestamp last_ts_{0}; diff --git a/include/crossbook/net/handshake.hpp b/include/crossbook/net/handshake.hpp new file mode 100644 index 0000000..f1d14a5 --- /dev/null +++ b/include/crossbook/net/handshake.hpp @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// The RFC 6455 opening handshake, as a pure function of bytes. +// +// The handshake is the only part of a websocket client with a cryptographic +// check in it, and it is routinely skipped: plenty of clients send the upgrade +// request and then just look for "101" in the response. That is not what the +// check is for. +// +// `Sec-WebSocket-Accept` is base64(SHA1(client_key + GUID)). Verifying it proves +// the peer actually parsed our request and speaks the protocol, rather than +// being an intermediary that will happily return 101 and then hand us bytes +// that are not frames. Getting that wrong turns every subsequent length field +// into garbage read with pointer arithmetic, which is the failure mode +// ws_frame.hpp is written defensively against — so it is worth not reaching. +// +// SHA-1 is here because the specification names it, not because it is a +// reasonable hash in 2026. It is used for exactly one thing — proving the peer +// echoed a nonce — and nothing about that use depends on collision resistance. +// It is 60 lines and it removes the last excuse for a dependency. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace crossbook::net { + +/// The magic value from RFC 6455 §1.3. Not a secret; its job is to make the +/// response impossible to produce by echoing the request. +inline constexpr std::string_view kWebSocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +namespace detail { + +[[nodiscard]] constexpr std::uint32_t rotl32(std::uint32_t v, int bits) noexcept { + return (v << bits) | (v >> (32 - bits)); +} + +} // namespace detail + +/// SHA-1 (FIPS 180-4). Sufficient for the handshake, and used for nothing else. +[[nodiscard]] inline std::array sha1(std::string_view data) noexcept { + std::uint32_t h[5] = {0x67452301U, 0xEFCDAB89U, 0x98BADCFEU, 0x10325476U, 0xC3D2E1F0U}; + + const std::uint64_t bit_len = static_cast(data.size()) * 8U; + + // Message + 0x80 + zero padding to 56 mod 64 + 8-byte big-endian length. + const std::size_t padded_len = ((data.size() + 8) / 64 + 1) * 64; + + auto byte_at = [&](std::size_t i) -> std::uint8_t { + if (i < data.size()) { + return static_cast(data[i]); + } + if (i == data.size()) { + return 0x80U; + } + if (i >= padded_len - 8) { + const std::size_t shift = (padded_len - 1 - i) * 8; + return static_cast((bit_len >> shift) & 0xFFU); + } + return 0U; + }; + + std::array w{}; + for (std::size_t chunk = 0; chunk < padded_len; chunk += 64) { + for (std::size_t i = 0; i < 16; ++i) { + w[i] = (static_cast(byte_at(chunk + i * 4)) << 24) | + (static_cast(byte_at(chunk + i * 4 + 1)) << 16) | + (static_cast(byte_at(chunk + i * 4 + 2)) << 8) | + static_cast(byte_at(chunk + i * 4 + 3)); + } + for (std::size_t i = 16; i < 80; ++i) { + w[i] = detail::rotl32(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1); + } + + std::uint32_t a = h[0]; + std::uint32_t b = h[1]; + std::uint32_t c = h[2]; + std::uint32_t d = h[3]; + std::uint32_t e = h[4]; + + for (std::size_t i = 0; i < 80; ++i) { + std::uint32_t f = 0; + std::uint32_t k = 0; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5A827999U; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ED9EBA1U; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8F1BBCDCU; + } else { + f = b ^ c ^ d; + k = 0xCA62C1D6U; + } + const std::uint32_t temp = detail::rotl32(a, 5) + f + e + k + w[i]; + e = d; + d = c; + c = detail::rotl32(b, 30); + b = a; + a = temp; + } + + h[0] += a; + h[1] += b; + h[2] += c; + h[3] += d; + h[4] += e; + } + + std::array out{}; + for (std::size_t i = 0; i < 5; ++i) { + out[i * 4] = static_cast((h[i] >> 24) & 0xFFU); + out[i * 4 + 1] = static_cast((h[i] >> 16) & 0xFFU); + out[i * 4 + 2] = static_cast((h[i] >> 8) & 0xFFU); + out[i * 4 + 3] = static_cast(h[i] & 0xFFU); + } + return out; +} + +/// Standard base64 with padding. +[[nodiscard]] inline std::string base64_encode(const std::uint8_t* data, std::size_t len) { + static constexpr char kAlphabet[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + std::string out; + out.reserve(((len + 2) / 3) * 4); + + std::size_t i = 0; + while (i + 3 <= len) { + const std::uint32_t block = (static_cast(data[i]) << 16) | + (static_cast(data[i + 1]) << 8) | + static_cast(data[i + 2]); + out.push_back(kAlphabet[(block >> 18) & 0x3FU]); + out.push_back(kAlphabet[(block >> 12) & 0x3FU]); + out.push_back(kAlphabet[(block >> 6) & 0x3FU]); + out.push_back(kAlphabet[block & 0x3FU]); + i += 3; + } + + if (i < len) { + const std::size_t remaining = len - i; + std::uint32_t block = static_cast(data[i]) << 16; + if (remaining == 2) { + block |= static_cast(data[i + 1]) << 8; + } + out.push_back(kAlphabet[(block >> 18) & 0x3FU]); + out.push_back(kAlphabet[(block >> 12) & 0x3FU]); + out.push_back(remaining == 2 ? kAlphabet[(block >> 6) & 0x3FU] : '='); + out.push_back('='); + } + return out; +} + +/// The `Sec-WebSocket-Accept` value a conforming server must return for `key`. +[[nodiscard]] inline std::string websocket_accept_for(std::string_view key) { + std::string combined; + combined.reserve(key.size() + kWebSocketGuid.size()); + combined.append(key); + combined.append(kWebSocketGuid); + const auto digest = sha1(combined); + return base64_encode(digest.data(), digest.size()); +} + +/// Build the opening handshake request. +/// +/// `key` must be the base64 of 16 random bytes (§4.1). Randomness is the +/// caller's job because this header has no business owning an RNG, and because +/// a test needs to pin the key to get a reproducible request. +[[nodiscard]] inline std::string make_handshake_request(std::string_view host, + std::uint16_t port, + std::string_view path, + std::string_view key, + bool secure) { + std::string req; + req.reserve(256); + req.append("GET ").append(path.empty() ? "/" : path).append(" HTTP/1.1\r\n"); + + // The port is omitted when it is the scheme default: some venues route on + // an exact Host match, and "host:443" is not the same string as "host". + req.append("Host: ").append(host); + const std::uint16_t default_port = secure ? 443 : 80; + if (port != default_port) { + req.append(":").append(std::to_string(port)); + } + req.append("\r\n"); + + req.append("Upgrade: websocket\r\n"); + req.append("Connection: Upgrade\r\n"); + req.append("Sec-WebSocket-Key: ").append(key).append("\r\n"); + req.append("Sec-WebSocket-Version: 13\r\n"); + req.append("User-Agent: crossbook/0.2\r\n"); + req.append("\r\n"); + return req; +} + +enum class HandshakeStatus : std::uint8_t { + kOk, + /// The response headers are not complete yet; read more and retry. + kIncomplete, + /// Anything other than 101. The status line is worth surfacing: a 429 or a + /// 403 from a venue is operational information, not a parse failure. + kNotSwitchingProtocols, + /// Upgrade / Connection headers missing or wrong. + kNotUpgraded, + /// Sec-WebSocket-Accept absent or did not match. See the file header for + /// why this is checked rather than assumed. + kBadAccept, +}; + +[[nodiscard]] constexpr std::string_view to_string(HandshakeStatus s) noexcept { + switch (s) { + case HandshakeStatus::kOk: + return "ok"; + case HandshakeStatus::kIncomplete: + return "incomplete"; + case HandshakeStatus::kNotSwitchingProtocols: + return "not_switching_protocols"; + case HandshakeStatus::kNotUpgraded: + return "not_upgraded"; + case HandshakeStatus::kBadAccept: + return "bad_accept"; + } + return "unknown"; +} + +namespace detail { + +[[nodiscard]] constexpr char lower(char c) noexcept { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; +} + +[[nodiscard]] constexpr bool iequals(std::string_view a, std::string_view b) noexcept { + if (a.size() != b.size()) { + return false; + } + for (std::size_t i = 0; i < a.size(); ++i) { + if (lower(a[i]) != lower(b[i])) { + return false; + } + } + return true; +} + +[[nodiscard]] constexpr bool icontains(std::string_view haystack, std::string_view needle) noexcept { + if (needle.empty() || haystack.size() < needle.size()) { + return needle.empty(); + } + for (std::size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + if (iequals(haystack.substr(i, needle.size()), needle)) { + return true; + } + } + return false; +} + +[[nodiscard]] constexpr std::string_view trim(std::string_view s) noexcept { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) { + s.remove_prefix(1); + } + while (!s.empty() && (s.back() == ' ' || s.back() == '\t' || s.back() == '\r')) { + s.remove_suffix(1); + } + return s; +} + +/// Look up a header, case-insensitively, in a raw header block. +[[nodiscard]] inline std::string_view find_header(std::string_view headers, + std::string_view name) noexcept { + std::size_t pos = 0; + while (pos < headers.size()) { + const std::size_t eol = headers.find("\r\n", pos); + const std::string_view line = + headers.substr(pos, (eol == std::string_view::npos ? headers.size() : eol) - pos); + const std::size_t colon = line.find(':'); + if (colon != std::string_view::npos && iequals(trim(line.substr(0, colon)), name)) { + return trim(line.substr(colon + 1)); + } + if (eol == std::string_view::npos) { + break; + } + pos = eol + 2; + } + return {}; +} + +} // namespace detail + +struct HandshakeResponse { + /// Bytes consumed by the response, so the caller knows where frames begin. + /// A server is allowed to start sending frames in the same TCP segment as + /// the 101, and discarding that tail loses the first message. + std::size_t header_bytes{0}; + int status_code{0}; +}; + +/// Validate a server's handshake response. +[[nodiscard]] inline HandshakeStatus parse_handshake_response(std::string_view response, + std::string_view expected_accept, + HandshakeResponse& out) { + const std::size_t end = response.find("\r\n\r\n"); + if (end == std::string_view::npos) { + return HandshakeStatus::kIncomplete; + } + out.header_bytes = end + 4; + + const std::size_t first_eol = response.find("\r\n"); + const std::string_view status_line = response.substr(0, first_eol); + + // "HTTP/1.1 101 Switching Protocols" + const std::size_t sp = status_line.find(' '); + if (sp == std::string_view::npos) { + return HandshakeStatus::kNotSwitchingProtocols; + } + int code = 0; + for (std::size_t i = sp + 1; i < status_line.size() && status_line[i] != ' '; ++i) { + const char c = status_line[i]; + if (c < '0' || c > '9') { + return HandshakeStatus::kNotSwitchingProtocols; + } + code = code * 10 + (c - '0'); + } + out.status_code = code; + if (code != 101) { + return HandshakeStatus::kNotSwitchingProtocols; + } + + const std::string_view headers = response.substr(first_eol + 2, end - first_eol - 2); + + if (!detail::iequals(detail::find_header(headers, "upgrade"), "websocket")) { + return HandshakeStatus::kNotUpgraded; + } + // Connection is a comma-separated token list; "keep-alive, Upgrade" is legal. + if (!detail::icontains(detail::find_header(headers, "connection"), "upgrade")) { + return HandshakeStatus::kNotUpgraded; + } + if (detail::find_header(headers, "sec-websocket-accept") != expected_accept) { + return HandshakeStatus::kBadAccept; + } + return HandshakeStatus::kOk; +} + +} // namespace crossbook::net diff --git a/include/crossbook/net/transport.hpp b/include/crossbook/net/transport.hpp new file mode 100644 index 0000000..80cdeb1 --- /dev/null +++ b/include/crossbook/net/transport.hpp @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Byte transport: a TCP socket, optionally wrapped in TLS. +// +// THIS IS THE ONLY PART OF CROSSBOOK THAT IS NOT HEADER-ONLY, AND THE ONLY PART +// THAT TOUCHES A PLATFORM API. That is deliberate. Everything above it — the +// framing, the decoders, the book, the verifier — is a function of bytes and +// stays testable without a network. The socket is quarantined here so that the +// interesting code does not inherit its untestability. +// +// TLS BACKENDS. `wss://` is mandatory at every venue, and there is no portable +// TLS in the standard library, so exactly one platform dependency is +// unavoidable. Rather than take a third-party one, each platform's own is used: +// +// Windows Schannel, which ships with the OS +// POSIX OpenSSL, which is present on every Linux and macOS CI image +// +// The result is that `cmake --build` produces a working client on a stock +// Windows machine with nothing installed, which is the difference between a +// reader trying the tool and a reader closing the tab. The library target +// itself remains header-only and dependency-free; this is a separate, optional +// target, and consuming crossbook as a library does not pull it in. + +#pragma once + +#include +#include +#include +#include + +namespace crossbook::net { + +enum class IoStatus : std::uint8_t { + kOk, + /// Nothing arrived within the timeout. Not an error: a quiet market is + /// indistinguishable from a quiet socket, and only the caller knows which + /// silence is acceptable. + kTimeout, + /// The peer closed cleanly. + kClosed, + /// Anything else. `last_error()` carries the platform's description. + kError, +}; + +[[nodiscard]] constexpr const char* to_string(IoStatus s) noexcept { + switch (s) { + case IoStatus::kOk: + return "ok"; + case IoStatus::kTimeout: + return "timeout"; + case IoStatus::kClosed: + return "closed"; + case IoStatus::kError: + return "error"; + } + return "unknown"; +} + +/// A bidirectional byte stream. +/// +/// Blocking with timeouts rather than non-blocking with an event loop: this +/// client follows one or two sockets, and a reactor would be more machinery +/// than the problem has. The interface does not preclude one later. +class Transport { +public: + virtual ~Transport() = default; + + Transport(const Transport&) = delete; + Transport& operator=(const Transport&) = delete; + + /// Resolve, connect, and (for TLS) complete the handshake. + /// `host` is also the name verified against the server certificate. + [[nodiscard]] virtual bool connect(const std::string& host, std::uint16_t port, + int timeout_ms) = 0; + + /// Read up to `len` bytes. `n_read` is set only when the status is kOk. + /// + /// A short read is normal and not an error — TLS records and TCP segments + /// have nothing to do with message boundaries, which is precisely why + /// FrameReader is incremental. + [[nodiscard]] virtual IoStatus read(char* buf, std::size_t len, std::size_t& n_read) = 0; + + /// Write all `len` bytes, or fail. Partial writes are retried internally: + /// a half-sent websocket frame desynchronises the stream permanently, so + /// there is no useful way for a caller to handle one. + [[nodiscard]] virtual IoStatus write(const char* buf, std::size_t len) = 0; + + /// Change the read timeout after connecting. + /// + /// The handshake and the steady state want very different numbers. An + /// opening handshake across a congested path can legitimately take tens of + /// seconds - measured against Kraken's edge, sometimes over twenty - while a + /// steady-state read wants to time out in about a second so the caller's + /// loop stays responsive to shutdown and to its own deadlines. Using one + /// value for both means choosing between a client that gives up on a slow + /// connect and a loop that hangs for ten seconds on every quiet market. + virtual void set_read_timeout(int timeout_ms) = 0; + + virtual void close() = 0; + + [[nodiscard]] virtual bool connected() const noexcept = 0; + + /// Human-readable description of the last failure, including the platform + /// error code. Empty when nothing has failed. + [[nodiscard]] virtual const std::string& last_error() const noexcept = 0; + +protected: + Transport() = default; +}; + +/// Create a transport. `secure` selects TLS. +/// +/// Returns null only if the build has no TLS backend for this platform, which +/// the CMake configuration makes an error rather than a silent downgrade — +/// falling back to plaintext against an exchange is not a graceful degradation. +[[nodiscard]] std::unique_ptr make_transport(bool secure); + +/// One-shot HTTPS GET, returning the response body. +/// +/// Binance has no snapshot on its websocket stream: the documented procedure is +/// to buffer the diff stream, fetch a REST depth snapshot, and reconcile the two +/// by sequence number. Without this the Binance book cannot be started at all, +/// so a minimal HTTP client is not scope creep — it is the other half of the +/// venue's contract. +/// +/// Deliberately minimal: no redirects, no chunked-encoding edge cases beyond the +/// common one, no connection reuse. It fetches one JSON document at startup. +[[nodiscard]] bool https_get(const std::string& host, const std::string& path, std::string& body, + std::string& error, int timeout_ms = 10'000); + +} // namespace crossbook::net diff --git a/include/crossbook/net/url.hpp b/include/crossbook/net/url.hpp new file mode 100644 index 0000000..1a66842 --- /dev/null +++ b/include/crossbook/net/url.hpp @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Just enough URL parsing to dial a websocket endpoint. +// +// Deliberately not a general URL parser. It accepts `ws://` and `wss://` with +// an optional port and path, and rejects everything else rather than guessing. +// A feed handler dials a handful of endpoints from configuration; the failure +// mode worth engineering against is a typo silently becoming a connection to +// the wrong host, not an inability to parse userinfo and fragments. + +#pragma once + +#include +#include +#include + +namespace crossbook::net { + +struct Url { + std::string host; + std::string path{"/"}; + std::uint16_t port{0}; + bool secure{false}; +}; + +enum class UrlError : std::uint8_t { + kOk, + /// Scheme was absent or was something other than ws / wss. + kBadScheme, + kEmptyHost, + /// Port was not a decimal number in [1, 65535]. + kBadPort, +}; + +[[nodiscard]] constexpr std::string_view to_string(UrlError e) noexcept { + switch (e) { + case UrlError::kOk: + return "ok"; + case UrlError::kBadScheme: + return "bad_scheme"; + case UrlError::kEmptyHost: + return "empty_host"; + case UrlError::kBadPort: + return "bad_port"; + } + return "unknown"; +} + +/// Decimal port, rejecting empty input, non-digits, and anything out of range. +/// Separate so the overflow check is stated once rather than inlined twice. +[[nodiscard]] inline bool parse_port(std::string_view text, std::uint16_t& out) noexcept { + if (text.empty() || text.size() > 5) { + return false; + } + std::uint32_t value = 0; + for (const char c : text) { + if (c < '0' || c > '9') { + return false; + } + value = value * 10U + static_cast(c - '0'); + } + if (value == 0 || value > 65535U) { + return false; + } + out = static_cast(value); + return true; +} + +/// Parse `ws://host[:port][/path]` or `wss://...`. +/// +/// The default port follows the scheme — 80 for ws, 443 for wss — which is the +/// one piece of implicit behaviour here, and it is the one every venue relies +/// on. +[[nodiscard]] inline UrlError parse_url(std::string_view text, Url& out) { + Url url; + + constexpr std::string_view kSecurePrefix = "wss://"; + constexpr std::string_view kPlainPrefix = "ws://"; + + if (text.starts_with(kSecurePrefix)) { + url.secure = true; + url.port = 443; + text.remove_prefix(kSecurePrefix.size()); + } else if (text.starts_with(kPlainPrefix)) { + url.secure = false; + url.port = 80; + text.remove_prefix(kPlainPrefix.size()); + } else { + return UrlError::kBadScheme; + } + + // Split authority from path at the first '/'. + std::string_view authority = text; + const std::size_t slash = text.find('/'); + if (slash != std::string_view::npos) { + authority = text.substr(0, slash); + url.path = std::string(text.substr(slash)); + } + + // Split host from port at the last ':', so an IPv6 literal in brackets is + // not mangled by the colons inside it. + std::string_view host = authority; + if (!authority.empty() && authority.front() == '[') { + const std::size_t close = authority.find(']'); + if (close == std::string_view::npos) { + return UrlError::kEmptyHost; + } + host = authority.substr(0, close + 1); + const std::string_view rest = authority.substr(close + 1); + if (!rest.empty()) { + if (rest.front() != ':') { + return UrlError::kBadPort; + } + if (!parse_port(rest.substr(1), url.port)) { + return UrlError::kBadPort; + } + } + } else { + const std::size_t colon = authority.rfind(':'); + if (colon != std::string_view::npos) { + host = authority.substr(0, colon); + if (!parse_port(authority.substr(colon + 1), url.port)) { + return UrlError::kBadPort; + } + } + } + + if (host.empty()) { + return UrlError::kEmptyHost; + } + url.host = std::string(host); + + out = std::move(url); + return UrlError::kOk; +} + +} // namespace crossbook::net diff --git a/include/crossbook/net/websocket.hpp b/include/crossbook/net/websocket.hpp new file mode 100644 index 0000000..746a175 --- /dev/null +++ b/include/crossbook/net/websocket.hpp @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// A websocket client: transport + handshake + frame reader, assembled. +// +// This class is thin on purpose. Everything difficult about websockets lives in +// `ws_frame.hpp` and `handshake.hpp`, both of which are pure and tested without +// a network. What is left here is sequencing — dial, upgrade, verify, loop — +// plus the two obligations a client cannot delegate: +// +// 1. EVERY CLIENT FRAME MUST BE MASKED with a fresh, unpredictable key +// (RFC 6455 §5.3). The key exists to stop a hostile page from steering a +// proxy into caching attacker-chosen bytes; a fixed or counting key +// defeats it entirely, so the key comes from std::random_device. +// +// 2. A PING MUST BE ANSWERED. Venues disconnect clients that do not, and the +// resulting "the feed just stops after 60 seconds" is a miserable thing to +// debug from the outside. `poll` answers them itself. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "crossbook/net/transport.hpp" +#include "crossbook/net/url.hpp" +#include "crossbook/net/ws_frame.hpp" + +namespace crossbook::net { + +/// Counters worth publishing next to a match rate: a feed that silently +/// reconnected twice during a measurement window did not measure what it claims. +struct WebSocketStats { + std::uint64_t messages{0}; + std::uint64_t bytes_received{0}; + std::uint64_t pings_received{0}; + std::uint64_t pongs_sent{0}; + std::uint64_t pongs_received{0}; + std::uint64_t frames_sent{0}; +}; + +class WebSocketClient { +public: + explicit WebSocketClient(std::size_t max_message_bytes = FrameReader::kDefaultMaxMessage); + ~WebSocketClient(); + + WebSocketClient(const WebSocketClient&) = delete; + WebSocketClient& operator=(const WebSocketClient&) = delete; + + /// Dial `url` (ws:// or wss://) and complete the opening handshake. + /// + /// The two timeouts are separate because they want opposite things. + /// `handshake_timeout_ms` has to tolerate a slow path: an opening handshake + /// through a busy edge can take tens of seconds, and measured against + /// Kraken it sometimes takes over twenty. `read_timeout_ms` takes over once + /// the connection is up and wants to be short, because it sets how often + /// `poll` returns during a quiet market and therefore how quickly the caller + /// notices a deadline or a Ctrl-C. Collapsing the two into one number forces + /// a choice between failing slow connects and a sluggish loop; the first + /// version of this class made that mistake and failed to connect during + /// exactly the episodes it most needed to ride out. + [[nodiscard]] bool connect(std::string_view url, int handshake_timeout_ms = 30'000, + int read_timeout_ms = 1'000); + + /// Next application message. + /// + /// - kMessage: `out.payload` is valid until the next call to `poll`. + /// - kNeedMore: nothing arrived within the read timeout. Not an error. + /// - kClose: the peer closed; `out.close_code` says why. + /// - kProtocolError / kMessageTooLarge: fatal, the connection is dropped. + /// + /// Ping and pong frames are handled internally and never surface here. + [[nodiscard]] ReadStatus poll(Event& out); + + [[nodiscard]] bool send_text(std::string_view payload); + [[nodiscard]] bool send_binary(std::string_view payload); + [[nodiscard]] bool send_ping(std::string_view payload = {}); + + /// Send a close frame and drop the connection. + void close(CloseCode code = CloseCode::kNormal, std::string_view reason = {}); + + [[nodiscard]] bool connected() const noexcept; + [[nodiscard]] const std::string& last_error() const noexcept { return error_; } + [[nodiscard]] const Url& url() const noexcept { return url_; } + [[nodiscard]] const WebSocketStats& stats() const noexcept { return stats_; } + +private: + [[nodiscard]] bool send_frame(Opcode opcode, std::string_view payload); + [[nodiscard]] std::uint32_t next_mask_key(); + /// Read the handshake response, keeping any frame bytes that arrived with it. + [[nodiscard]] bool complete_handshake(const std::string& expected_accept, int timeout_ms); + + std::unique_ptr transport_; + FrameReader reader_; + Url url_; + std::string error_; + std::vector send_buf_; + std::mt19937 rng_; + WebSocketStats stats_{}; + bool open_{false}; +}; + +} // namespace crossbook::net diff --git a/include/crossbook/net/ws_frame.hpp b/include/crossbook/net/ws_frame.hpp new file mode 100644 index 0000000..dd567f0 --- /dev/null +++ b/include/crossbook/net/ws_frame.hpp @@ -0,0 +1,546 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// RFC 6455 frame codec — the pure part of a websocket client. +// +// WHY THIS IS ITS OWN FILE, WITH NO I/O IN IT: +// +// Everything here is a function of bytes. No socket, no TLS, no clock, no +// platform header. That is what lets the framing layer — the part actually +// exposed to bytes an exchange controls — be unit tested exhaustively and +// fuzzed, while the parts that cannot be fuzzed (the handshake, the socket) stay +// as thin as possible around it. +// +// The threat model is not academic. A feed handler parses length-prefixed +// binary from a remote host on every message, and a 64-bit length field read +// without bounds discipline is the oldest remote-code-execution shape there is. +// So: every length is validated before it is trusted, the reassembly buffer has +// a hard ceiling, and `fuzz/fuzz_ws_frame.cpp` drives this state machine with +// coverage-guided garbage. +// +// STRICTNESS IS DELIBERATE. The RFC's "MUST" list is enforced rather than +// tolerated — reserved bits, non-minimal length encodings, fragmented control +// frames, masked server frames. A frame the specification forbids is either a +// broken venue or something wearing a venue's clothes, and quietly accepting it +// means the book is being built from bytes nobody has a contract for. + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace crossbook::net { + +/// RFC 6455 §5.2 opcodes. +enum class Opcode : std::uint8_t { + kContinuation = 0x0, + kText = 0x1, + kBinary = 0x2, + kClose = 0x8, + kPing = 0x9, + kPong = 0xA, +}; + +[[nodiscard]] constexpr std::string_view to_string(Opcode op) noexcept { + switch (op) { + case Opcode::kContinuation: + return "continuation"; + case Opcode::kText: + return "text"; + case Opcode::kBinary: + return "binary"; + case Opcode::kClose: + return "close"; + case Opcode::kPing: + return "ping"; + case Opcode::kPong: + return "pong"; + } + return "unknown"; +} + +/// Control frames have the high opcode bit set (§5.5). They may be interleaved +/// between the fragments of a data message, which is the detail a naive reader +/// gets wrong: a ping arriving mid-message must not terminate the message. +[[nodiscard]] constexpr bool is_control(Opcode op) noexcept { + return (static_cast(op) & 0x08U) != 0; +} + +[[nodiscard]] constexpr bool is_known_opcode(std::uint8_t raw) noexcept { + return raw == 0x0 || raw == 0x1 || raw == 0x2 || raw == 0x8 || raw == 0x9 || raw == 0xA; +} + +/// Control frame payloads are capped by the specification, not by us (§5.5). +inline constexpr std::size_t kMaxControlPayload = 125; + +/// Largest frame header: 2 fixed bytes + 8 length bytes + 4 mask bytes. +inline constexpr std::size_t kMaxHeaderSize = 14; + +struct FrameHeader { + Opcode opcode{Opcode::kContinuation}; + std::uint64_t payload_len{0}; + /// Big-endian as it appeared on the wire; `apply_mask` expects this form. + std::uint32_t mask_key{0}; + /// Bytes consumed by the header itself. + std::size_t header_size{0}; + bool fin{false}; + bool masked{false}; +}; + +enum class FrameStatus : std::uint8_t { + kOk, + /// Not enough bytes yet. Read more and call again with a longer buffer. + kIncomplete, + /// The bytes are not a legal frame. The connection must be closed; there is + /// no resynchronisation point in a stream of length-prefixed frames. + kProtocolError, +}; + +/// Parse a frame header from the front of `buf`. +/// +/// Never reads past `buf.size()`, and never trusts a length field before it has +/// been range-checked. `kIncomplete` is returned for any truncation, including a +/// buffer holding only part of the extended length. +[[nodiscard]] inline FrameStatus parse_frame_header(std::string_view buf, + FrameHeader& out) noexcept { + if (buf.size() < 2) { + return FrameStatus::kIncomplete; + } + + const auto b0 = static_cast(buf[0]); + const auto b1 = static_cast(buf[1]); + + // RSV1-3 must be zero: they only carry meaning under an extension, and we + // negotiate none. Set bits mean we are misreading the stream. + if ((b0 & 0x70U) != 0) { + return FrameStatus::kProtocolError; + } + + const auto raw_opcode = static_cast(b0 & 0x0FU); + if (!is_known_opcode(raw_opcode)) { + return FrameStatus::kProtocolError; + } + + FrameHeader header; + header.fin = (b0 & 0x80U) != 0; + header.opcode = static_cast(raw_opcode); + header.masked = (b1 & 0x80U) != 0; + + const auto short_len = static_cast(b1 & 0x7FU); + std::size_t pos = 2; + + if (short_len < 126) { + header.payload_len = short_len; + } else if (short_len == 126) { + if (buf.size() < pos + 2) { + return FrameStatus::kIncomplete; + } + header.payload_len = (static_cast(static_cast(buf[pos])) << 8) | + static_cast(static_cast(buf[pos + 1])); + pos += 2; + // §5.2: the minimal number of bytes MUST be used to encode the length. + // Accepting a padded encoding would let the same message arrive in two + // spellings, which is a parser-differential waiting to happen. + if (header.payload_len < 126) { + return FrameStatus::kProtocolError; + } + } else { + if (buf.size() < pos + 8) { + return FrameStatus::kIncomplete; + } + std::uint64_t len = 0; + for (std::size_t i = 0; i < 8; ++i) { + len = (len << 8) | static_cast(static_cast(buf[pos + i])); + } + pos += 8; + // §5.2: the most significant bit MUST be zero. + if ((len & 0x8000000000000000ULL) != 0) { + return FrameStatus::kProtocolError; + } + if (len < 65536) { + return FrameStatus::kProtocolError; // Non-minimal, as above. + } + header.payload_len = len; + } + + // §5.5: control frames must not be fragmented and must be short enough to + // fit in a single frame. Both are load-bearing — an unbounded "ping" is a + // memory exhaustion primitive. + if (is_control(header.opcode)) { + if (!header.fin || header.payload_len > kMaxControlPayload) { + return FrameStatus::kProtocolError; + } + } + + if (header.masked) { + if (buf.size() < pos + 4) { + return FrameStatus::kIncomplete; + } + std::memcpy(&header.mask_key, buf.data() + pos, 4); + pos += 4; + } + + header.header_size = pos; + out = header; + return FrameStatus::kOk; +} + +/// XOR a span with the frame's masking key (§5.3). +/// +/// `offset` is the payload-relative position of `data[0]`, so a payload split +/// across reads can be unmasked in pieces without buffering it whole. +inline void apply_mask(char* data, std::size_t len, std::uint32_t mask_key, + std::size_t offset = 0) noexcept { + if (mask_key == 0) { + return; // XOR with zero is identity; skip the loop entirely. + } + unsigned char key[4]; + std::memcpy(key, &mask_key, 4); + for (std::size_t i = 0; i < len; ++i) { + data[i] = static_cast(static_cast(data[i]) ^ key[(i + offset) & 3U]); + } +} + +/// Serialise a frame header into `buf`, which must have room for +/// `kMaxHeaderSize` bytes. Returns the number written. +/// +/// A client MUST mask every frame it sends (§5.3), so `mask_key` is required +/// rather than optional. Passing a predictable key is a protocol violation in +/// spirit if not in letter; the caller is expected to supply a random one, and +/// `WebSocketClient` does. +[[nodiscard]] inline std::size_t write_frame_header(char* buf, Opcode opcode, + std::uint64_t payload_len, + std::uint32_t mask_key, + bool fin = true) noexcept { + std::size_t pos = 0; + buf[pos++] = static_cast((fin ? 0x80U : 0x00U) | static_cast(opcode)); + + if (payload_len < 126) { + buf[pos++] = static_cast(0x80U | static_cast(payload_len)); + } else if (payload_len <= 0xFFFF) { + buf[pos++] = static_cast(0x80U | 126U); + buf[pos++] = static_cast((payload_len >> 8) & 0xFFU); + buf[pos++] = static_cast(payload_len & 0xFFU); + } else { + buf[pos++] = static_cast(0x80U | 127U); + for (int shift = 56; shift >= 0; shift -= 8) { + buf[pos++] = static_cast((payload_len >> shift) & 0xFFU); + } + } + + std::memcpy(buf + pos, &mask_key, 4); + pos += 4; + return pos; +} + +/// Close status codes worth naming (§7.4.1). +enum class CloseCode : std::uint16_t { + kNormal = 1000, + kGoingAway = 1001, + kProtocolError = 1002, + kUnsupportedData = 1003, + /// Reserved: never sent on the wire, used locally for "closed without one". + kNoStatus = 1005, + /// Reserved: connection dropped without a close frame. + kAbnormal = 1006, + kInvalidPayload = 1007, + kPolicyViolation = 1008, + kMessageTooBig = 1009, + kInternalError = 1011, +}; + +/// What `FrameReader::next` produced. +enum class ReadStatus : std::uint8_t { + /// Nothing complete yet. Read more bytes from the transport. + kNeedMore, + /// A complete data message (text or binary), reassembled across fragments. + kMessage, + kPing, + kPong, + kClose, + /// The peer violated RFC 6455. Close the connection; do not attempt to + /// resynchronise. + kProtocolError, + /// A message exceeded the configured ceiling. Distinguished from a protocol + /// error because it is our limit, not the peer's mistake, and it maps to + /// close code 1009 rather than 1002. + kMessageTooLarge, +}; + +/// One decoded event. `payload` is valid until the next call to `next` or +/// `append` on the same reader. +struct Event { + Opcode opcode{Opcode::kContinuation}; + std::string_view payload; + /// Only meaningful when the status is kClose. 1005 means the peer sent no + /// code, which the specification distinguishes from sending 1000. + std::uint16_t close_code{static_cast(CloseCode::kNoStatus)}; +}; + +/// Incremental frame reader: bytes in, messages out. +/// +/// Handles the three things that make this more than a length-prefix loop — +/// fragmentation, control frames interleaved between fragments, and frames that +/// straddle transport reads. +/// +/// ZERO-COPY FAST PATH: an unfragmented message that is already fully buffered +/// is returned as a view straight into the receive buffer, with no copy at all. +/// That is the overwhelmingly common case for an exchange feed, where a message +/// is a couple of hundred bytes and arrives whole. Fragmented messages fall back +/// to reassembly into a separate buffer, because there is nowhere contiguous to +/// point at. +class FrameReader { +public: + /// The default ceiling is generous for a market data feed — the largest + /// book snapshot from any venue covered here is well under a megabyte — and + /// finite, which is the property that matters. A reassembly buffer that + /// grows to whatever the peer asks for is a remote out-of-memory. + static constexpr std::size_t kDefaultMaxMessage = 8U * 1024U * 1024U; + + explicit FrameReader(std::size_t max_message_bytes = kDefaultMaxMessage) + : max_message_bytes_(max_message_bytes) { + buf_.reserve(64 * 1024); + } + + /// Hand raw transport bytes to the reader. + void append(const char* data, std::size_t len) { + compact(); + buf_.insert(buf_.end(), data, data + len); + } + + void append(std::string_view bytes) { append(bytes.data(), bytes.size()); } + + /// Space the caller can read transport bytes directly into, avoiding a copy + /// through an intermediate buffer. Follow with `commit`. + [[nodiscard]] char* writable_tail(std::size_t len) { + compact(); + const std::size_t old = buf_.size(); + buf_.resize(old + len); + return buf_.data() + old; + } + + /// Report how many of the bytes handed out by `writable_tail` were filled. + void commit(std::size_t written, std::size_t requested) noexcept { + buf_.resize(buf_.size() - (requested - written)); + } + + /// Pull the next event, if one is complete. + /// + /// A failure is LATCHED. Once this has reported a protocol error or an + /// oversized message, it reports the same thing forever, until `reset`. + /// There is no resynchronisation point in a stream of length-prefixed + /// frames: after a bad length the next byte read as an opcode is whatever + /// happened to be there. A reader that recovered would be inventing frames, + /// and inventing frames is how a plausible, wrong book gets built. + [[nodiscard]] ReadStatus next(Event& out) { + if (failure_ != ReadStatus::kNeedMore) { + return failure_; + } + + // The previous event may have pointed into buf_; only now is it safe to + // drop those bytes. + consume_pending(); + + for (;;) { + const std::string_view view(buf_.data() + read_pos_, buf_.size() - read_pos_); + + FrameHeader header; + const FrameStatus status = parse_frame_header(view, header); + if (status == FrameStatus::kIncomplete) { + return ReadStatus::kNeedMore; + } + if (status == FrameStatus::kProtocolError) { + return latch(ReadStatus::kProtocolError); + } + + // §5.1: a server MUST NOT mask. A masked frame from a server means + // we are not talking to the server we think we are, or we have lost + // frame alignment. Either way, stop. + if (header.masked) { + return latch(ReadStatus::kProtocolError); + } + + // Reject an oversized frame before waiting for its bytes to arrive: + // otherwise a declared 4 GiB payload makes us buffer until we die, + // and the ceiling protects nothing. + if (header.payload_len > max_message_bytes_ || + assembled_.size() + header.payload_len > max_message_bytes_) { + return latch(ReadStatus::kMessageTooLarge); + } + + const std::size_t frame_total = header.header_size + + static_cast(header.payload_len); + if (view.size() < frame_total) { + return ReadStatus::kNeedMore; + } + + const char* payload = view.data() + header.header_size; + const auto payload_len = static_cast(header.payload_len); + + if (is_control(header.opcode)) { + // Control frames are self-contained and never join the message + // under assembly, so a ping between two fragments leaves the + // partial message exactly as it was. + read_pos_ += frame_total; + return emit_control(header.opcode, payload, payload_len, out); + } + + // --- Data frame --- + + if (header.opcode == Opcode::kContinuation) { + if (!assembling_) { + return latch(ReadStatus::kProtocolError); // §5.4: nothing to continue. + } + } else { + if (assembling_) { + return latch(ReadStatus::kProtocolError); // §5.4: interleaved messages. + } + message_opcode_ = header.opcode; + } + + if (header.fin && !assembling_) { + // Whole message in one frame, already buffered: hand back a view + // into the receive buffer and defer the consume until the caller + // has had a chance to read it. + out.opcode = message_opcode_; + out.payload = std::string_view(payload, payload_len); + out.close_code = static_cast(CloseCode::kNoStatus); + pending_consume_ = frame_total; + return ReadStatus::kMessage; + } + + assembled_.insert(assembled_.end(), payload, payload + payload_len); + assembling_ = true; + read_pos_ += frame_total; + + if (header.fin) { + out.opcode = message_opcode_; + out.payload = std::string_view(assembled_.data(), assembled_.size()); + out.close_code = static_cast(CloseCode::kNoStatus); + assembling_ = false; + pending_clear_assembled_ = true; + return ReadStatus::kMessage; + } + // Not the final fragment: loop round for the next frame. + } + } + + /// Bytes buffered but not yet consumed. Diagnostic only. + [[nodiscard]] std::size_t buffered() const noexcept { return buf_.size() - read_pos_; } + + /// True while a fragmented message is partially assembled. + [[nodiscard]] bool assembling() const noexcept { return assembling_; } + + /// True once a terminal failure has been latched. + [[nodiscard]] bool failed() const noexcept { return failure_ != ReadStatus::kNeedMore; } + + void reset() noexcept { + buf_.clear(); + assembled_.clear(); + read_pos_ = 0; + pending_consume_ = 0; + pending_clear_assembled_ = false; + assembling_ = false; + failure_ = ReadStatus::kNeedMore; + } + +private: + /// kNeedMore is the "no failure" sentinel: it is the one status `next` can + /// return that says nothing about the stream's validity. + [[nodiscard]] ReadStatus latch(ReadStatus status) noexcept { + failure_ = status; + return status; + } + + [[nodiscard]] ReadStatus emit_control(Opcode opcode, const char* payload, std::size_t len, + Event& out) { + // Copied rather than viewed: control payloads are at most 125 bytes, and + // copying them means a ping cannot be invalidated by the data frame the + // caller processes next. + control_len_ = len; + if (len > 0) { + std::memcpy(control_.data(), payload, len); + } + out.opcode = opcode; + out.payload = std::string_view(control_.data(), control_len_); + out.close_code = static_cast(CloseCode::kNoStatus); + + if (opcode == Opcode::kPing) { + return ReadStatus::kPing; + } + if (opcode == Opcode::kPong) { + return ReadStatus::kPong; + } + + // §5.5.1: a close payload is either empty or at least a two-byte code. + // A single byte is malformed, not "a code we could not read". + if (len == 1) { + return ReadStatus::kProtocolError; + } + if (len >= 2) { + out.close_code = + static_cast((static_cast( + static_cast(payload[0])) + << 8) | + static_cast( + static_cast(payload[1]))); + out.payload = std::string_view(control_.data() + 2, control_len_ - 2); + } + return ReadStatus::kClose; + } + + /// Drop the bytes the previously returned event pointed at. + void consume_pending() noexcept { + if (pending_consume_ != 0) { + read_pos_ += pending_consume_; + pending_consume_ = 0; + } + if (pending_clear_assembled_) { + assembled_.clear(); + pending_clear_assembled_ = false; + } + } + + /// Reclaim consumed bytes from the front of the buffer. + /// + /// Amortised: the memmove only runs once the consumed prefix is worth + /// reclaiming, so steady-state reading is not quadratic in message count. + void compact() { + consume_pending(); + if (read_pos_ == 0) { + return; + } + if (read_pos_ == buf_.size()) { + buf_.clear(); + read_pos_ = 0; + return; + } + if (read_pos_ < kCompactThreshold) { + return; + } + const std::size_t remaining = buf_.size() - read_pos_; + std::memmove(buf_.data(), buf_.data() + read_pos_, remaining); + buf_.resize(remaining); + read_pos_ = 0; + } + + static constexpr std::size_t kCompactThreshold = 32 * 1024; + + std::size_t max_message_bytes_; + std::vector buf_; + std::vector assembled_; + std::array control_{}; + std::size_t control_len_{0}; + std::size_t read_pos_{0}; + std::size_t pending_consume_{0}; + ReadStatus failure_{ReadStatus::kNeedMore}; + Opcode message_opcode_{Opcode::kText}; + bool pending_clear_assembled_{false}; + bool assembling_{false}; +}; + +} // namespace crossbook::net diff --git a/src/net/CMakeLists.txt b/src/net/CMakeLists.txt new file mode 100644 index 0000000..a1d2d0d --- /dev/null +++ b/src/net/CMakeLists.txt @@ -0,0 +1,31 @@ +# The transport library. +# +# Separate from `crossbook` on purpose: the library proper is header-only and +# depends on nothing, and consuming it must not drag in a TLS stack. Only the +# tools link this. + +add_library(crossbook_net STATIC + tcp_socket.cpp + transport.cpp + websocket.cpp +) +add_library(crossbook::net ALIAS crossbook_net) + +target_link_libraries(crossbook_net + PUBLIC crossbook::crossbook + PRIVATE crossbook_warnings +) + +# The internal headers (tcp_socket.hpp, tls_backend.hpp) sit next to the sources +# and are deliberately not installed. +target_include_directories(crossbook_net PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +if(WIN32) + target_sources(crossbook_net PRIVATE tls_schannel.cpp) + # secur32 is Schannel/SSPI; crypt32 backs certificate validation; ws2_32 is + # the socket layer. All three ship with the OS. + target_link_libraries(crossbook_net PRIVATE ws2_32 secur32 crypt32) +else() + target_sources(crossbook_net PRIVATE tls_openssl.cpp) + target_link_libraries(crossbook_net PRIVATE OpenSSL::SSL OpenSSL::Crypto) +endif() diff --git a/src/net/tcp_socket.cpp b/src/net/tcp_socket.cpp new file mode 100644 index 0000000..35292ad --- /dev/null +++ b/src/net/tcp_socket.cpp @@ -0,0 +1,292 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti + +#include "tcp_socket.hpp" + +#include +#include +#include + +#ifdef _WIN32 +// clang-format off +#include +#include +#include // FormatMessageA / LocalFree; must follow winsock2.h. +// clang-format on +#else +#include +#include +#include +#include +#include +#include +#include + +// Linux suppresses SIGPIPE per-send with MSG_NOSIGNAL; macOS and the BSDs do it +// per-socket with SO_NOSIGPIPE and do not define the flag at all. Without one of +// the two, a venue closing the connection mid-write kills the process outright +// rather than returning an error the caller can act on. +#ifndef MSG_NOSIGNAL +#define CROSSBOOK_MSG_NOSIGNAL 0 +#else +#define CROSSBOOK_MSG_NOSIGNAL MSG_NOSIGNAL +#endif +#endif + +namespace crossbook::net::detail { +namespace { + +#ifdef _WIN32 +/// WSAStartup exactly once, without a static initialisation order problem. +/// +/// Function-local static initialisation is thread-safe since C++11, which is +/// what makes this correct in the presence of two feeds connecting at once. +struct WinsockInit { + WinsockInit() { + WSADATA data{}; + result = ::WSAStartup(MAKEWORD(2, 2), &data); + } + ~WinsockInit() { + if (result == 0) { + ::WSACleanup(); + } + } + WinsockInit(const WinsockInit&) = delete; + WinsockInit& operator=(const WinsockInit&) = delete; + int result{0}; +}; + +bool ensure_winsock() { + static WinsockInit init; + return init.result == 0; +} + +int last_socket_error() { return ::WSAGetLastError(); } +#else +int last_socket_error() { return errno; } +#endif + +} // namespace + +std::string socket_error_string(const char* context) { + const int code = last_socket_error(); + std::string out(context); + out.append(": "); + +#ifdef _WIN32 + char* message = nullptr; + const DWORD len = ::FormatMessageA( + FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, static_cast(code), 0, reinterpret_cast(&message), 0, nullptr); + if (len != 0 && message != nullptr) { + std::string text(message, len); + while (!text.empty() && (text.back() == '\n' || text.back() == '\r')) { + text.pop_back(); + } + out.append(text); + ::LocalFree(message); + } else { + out.append("unknown error"); + } +#else + out.append(std::strerror(code)); +#endif + + out.append(" (").append(std::to_string(code)).append(")"); + return out; +} + +bool TcpSocket::connect(const std::string& host, std::uint16_t port, int timeout_ms, + std::string& error) { +#ifdef _WIN32 + if (!ensure_winsock()) { + error = socket_error_string("WSAStartup"); + return false; + } +#endif + + close(); + + // Strip the brackets from an IPv6 literal: they belong to the URL syntax, + // not to the address. + std::string node = host; + if (node.size() >= 2 && node.front() == '[' && node.back() == ']') { + node = node.substr(1, node.size() - 2); + } + + ::addrinfo hints{}; + hints.ai_family = AF_UNSPEC; // v4 or v6, whichever resolves. + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + + ::addrinfo* results = nullptr; + const std::string service = std::to_string(port); + const int rc = ::getaddrinfo(node.c_str(), service.c_str(), &hints, &results); + if (rc != 0 || results == nullptr) { +#ifdef _WIN32 + error = socket_error_string("getaddrinfo"); +#else + error = std::string("getaddrinfo: ") + ::gai_strerror(rc); +#endif + return false; + } + + std::string last_failure; + for (::addrinfo* it = results; it != nullptr; it = it->ai_next) { + const SocketHandle fd = ::socket(it->ai_family, it->ai_socktype, it->ai_protocol); + if (fd == kInvalidSocket) { + last_failure = socket_error_string("socket"); + continue; + } + + if (::connect(fd, it->ai_addr, static_cast(it->ai_addrlen)) != 0) { + last_failure = socket_error_string("connect"); +#ifdef _WIN32 + ::closesocket(fd); +#else + ::close(fd); +#endif + continue; + } + + fd_ = fd; + break; + } + ::freeaddrinfo(results); + + if (fd_ == kInvalidSocket) { + error = last_failure.empty() ? "connect: no address succeeded" : last_failure; + return false; + } + + // Nagle batches small writes, which for a subscribe frame means waiting for + // an ACK before the exchange even sees it. The payloads here are tiny and + // latency-relevant; there is nothing to coalesce. + int one = 1; + (void)::setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&one), + static_cast(sizeof(one))); + +#if defined(SO_NOSIGPIPE) + // The macOS / BSD half of the SIGPIPE story; see the note by the include. + (void)::setsockopt(fd_, SOL_SOCKET, SO_NOSIGPIPE, &one, static_cast(sizeof(one))); +#endif + + set_read_timeout(timeout_ms); + + error.clear(); + return true; +} + +void TcpSocket::set_read_timeout(int timeout_ms) noexcept { + if (fd_ == kInvalidSocket) { + return; + } +#ifdef _WIN32 + // Windows takes the timeout as a DWORD of milliseconds. + auto ms = static_cast(timeout_ms); + (void)::setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&ms), + static_cast(sizeof(ms))); + (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&ms), + static_cast(sizeof(ms))); +#else + ::timeval tv{}; + tv.tv_sec = timeout_ms / 1000; + tv.tv_usec = static_cast((timeout_ms % 1000) * 1000); + (void)::setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, static_cast(sizeof(tv))); + (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, static_cast(sizeof(tv))); +#endif +} + +IoStatus TcpSocket::read(char* buf, std::size_t len, std::size_t& n_read, std::string& error) { + if (fd_ == kInvalidSocket) { + error = "read: socket not connected"; + return IoStatus::kError; + } + +#ifdef _WIN32 + const int got = ::recv(fd_, buf, static_cast(len), 0); +#else + const auto got = ::recv(fd_, buf, len, 0); +#endif + + if (got > 0) { + n_read = static_cast(got); + return IoStatus::kOk; + } + if (got == 0) { + return IoStatus::kClosed; // Orderly shutdown by the peer. + } + +#ifdef _WIN32 + const int code = ::WSAGetLastError(); + if (code == WSAETIMEDOUT || code == WSAEWOULDBLOCK) { + return IoStatus::kTimeout; + } +#else + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return IoStatus::kTimeout; + } + if (errno == EINTR) { + // A signal, not a failure. Report it as a timeout so the caller's loop + // simply comes round again. + return IoStatus::kTimeout; + } +#endif + + error = socket_error_string("recv"); + return IoStatus::kError; +} + +IoStatus TcpSocket::write(const char* buf, std::size_t len, std::string& error) { + if (fd_ == kInvalidSocket) { + error = "write: socket not connected"; + return IoStatus::kError; + } + + std::size_t sent = 0; + while (sent < len) { +#ifdef _WIN32 + const int n = ::send(fd_, buf + sent, static_cast(len - sent), 0); +#else + const auto n = ::send(fd_, buf + sent, len - sent, CROSSBOOK_MSG_NOSIGNAL); +#endif + if (n > 0) { + sent += static_cast(n); + continue; + } + if (n == 0) { + return IoStatus::kClosed; + } + +#ifdef _WIN32 + const int code = ::WSAGetLastError(); + if (code == WSAETIMEDOUT || code == WSAEWOULDBLOCK) { + return IoStatus::kTimeout; + } +#else + if (errno == EINTR) { + continue; + } + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return IoStatus::kTimeout; + } +#endif + error = socket_error_string("send"); + return IoStatus::kError; + } + return IoStatus::kOk; +} + +void TcpSocket::close() noexcept { + if (fd_ == kInvalidSocket) { + return; + } +#ifdef _WIN32 + ::closesocket(fd_); +#else + ::close(fd_); +#endif + fd_ = kInvalidSocket; +} + +} // namespace crossbook::net::detail diff --git a/src/net/tcp_socket.hpp b/src/net/tcp_socket.hpp new file mode 100644 index 0000000..20718ed --- /dev/null +++ b/src/net/tcp_socket.hpp @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Internal: a blocking TCP socket with timeouts, shared by the plaintext +// transport and by both TLS backends (each of which needs somewhere to put the +// bytes its handshake produces). +// +// Not installed and not part of the public interface. Nothing above the +// transport layer should know what a socket is. + +#pragma once + +#include +#include +#include + +#include "crossbook/net/transport.hpp" + +#ifdef _WIN32 +// clang-format off +#include +#include +// clang-format on +#else +#include +#endif + +namespace crossbook::net::detail { + +#ifdef _WIN32 +using SocketHandle = ::SOCKET; +inline constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +/// Winsock spells the address and option length as a signed int; POSIX uses an +/// unsigned socklen_t. Naming the difference is what keeps -Wsign-conversion +/// quiet without scattering casts that happen to be right on one platform. +using SockLen = int; +#else +using SocketHandle = int; +inline constexpr SocketHandle kInvalidSocket = -1; +using SockLen = ::socklen_t; +#endif + +/// Format a platform socket error as "message (code)". +[[nodiscard]] std::string socket_error_string(const char* context); + +/// Blocking TCP socket with send and receive timeouts. +class TcpSocket { +public: + TcpSocket() = default; + ~TcpSocket() { close(); } + + TcpSocket(const TcpSocket&) = delete; + TcpSocket& operator=(const TcpSocket&) = delete; + + /// Resolve `host` and connect to the first address that accepts. + /// + /// Every result from getaddrinfo is tried in turn, so a host that publishes + /// an AAAA record on a machine with no IPv6 route still connects rather than + /// failing on the first candidate. + [[nodiscard]] bool connect(const std::string& host, std::uint16_t port, int timeout_ms, + std::string& error); + + [[nodiscard]] IoStatus read(char* buf, std::size_t len, std::size_t& n_read, + std::string& error); + + /// Change the receive timeout on a connected socket. + void set_read_timeout(int timeout_ms) noexcept; + + /// Write all of `len`, looping over partial sends. + [[nodiscard]] IoStatus write(const char* buf, std::size_t len, std::string& error); + + void close() noexcept; + + [[nodiscard]] bool valid() const noexcept { return fd_ != kInvalidSocket; } + [[nodiscard]] SocketHandle handle() const noexcept { return fd_; } + +private: + SocketHandle fd_{kInvalidSocket}; +}; + +} // namespace crossbook::net::detail diff --git a/src/net/tls_backend.hpp b/src/net/tls_backend.hpp new file mode 100644 index 0000000..7bcb3bb --- /dev/null +++ b/src/net/tls_backend.hpp @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Internal: the seam between the transport factory and whichever TLS backend +// this platform was built with. Exactly one translation unit defines it. + +#pragma once + +#include + +#include "crossbook/net/transport.hpp" + +namespace crossbook::net::detail { + +/// Defined by tls_schannel.cpp on Windows and tls_openssl.cpp elsewhere. +[[nodiscard]] std::unique_ptr make_tls_transport(); + +} // namespace crossbook::net::detail diff --git a/src/net/tls_openssl.cpp b/src/net/tls_openssl.cpp new file mode 100644 index 0000000..8c8ee8a --- /dev/null +++ b/src/net/tls_openssl.cpp @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// TLS via OpenSSL — the POSIX backend. +// +// Shorter than its Schannel counterpart for one reason: OpenSSL will own the +// file descriptor and do its own reading, so there is no ciphertext buffer to +// manage and no SECBUFFER_EXTRA to get wrong. +// +// HOSTNAME VERIFICATION IS EXPLICIT. `SSL_CTX_set_verify` alone validates the +// chain but not the name, so a certificate legitimately issued for any host at +// all would pass. `SSL_set1_host` is what ties the connection to the host we +// meant to reach, and it is the line most often missing from hand-rolled +// OpenSSL clients. + +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "crossbook/net/transport.hpp" +#include "tcp_socket.hpp" +#include "tls_backend.hpp" + +namespace crossbook::net::detail { +namespace { + +[[nodiscard]] std::string openssl_error(const char* context) { + std::string out(context); + out.append(": "); + const unsigned long code = ::ERR_get_error(); + if (code == 0) { + out.append("no OpenSSL error queued"); + return out; + } + char buf[256]; + ::ERR_error_string_n(code, buf, sizeof(buf)); + out.append(buf); + return out; +} + +class OpenSslTransport final : public Transport { +public: + ~OpenSslTransport() override { close(); } + + [[nodiscard]] bool connect(const std::string& host, std::uint16_t port, + int timeout_ms) override { + close(); + + if (!socket_.connect(host, port, timeout_ms, error_)) { + return false; + } + + ctx_ = ::SSL_CTX_new(::TLS_client_method()); + if (ctx_ == nullptr) { + error_ = openssl_error("SSL_CTX_new"); + socket_.close(); + return false; + } + + // TLS 1.0 and 1.1 are dead; refusing them here means a downgrade cannot + // be negotiated on our side at all. + (void)::SSL_CTX_set_min_proto_version(ctx_, TLS1_2_VERSION); + ::SSL_CTX_set_verify(ctx_, SSL_VERIFY_PEER, nullptr); + if (::SSL_CTX_set_default_verify_paths(ctx_) != 1) { + error_ = openssl_error("SSL_CTX_set_default_verify_paths"); + close(); + return false; + } + + ssl_ = ::SSL_new(ctx_); + if (ssl_ == nullptr) { + error_ = openssl_error("SSL_new"); + close(); + return false; + } + + // Strip brackets from an IPv6 literal before using it as a name. + std::string name = host; + if (name.size() >= 2 && name.front() == '[' && name.back() == ']') { + name = name.substr(1, name.size() - 2); + } + + // SNI: without it a venue behind shared hosting serves the wrong + // certificate and the handshake fails for a reason that looks like ours. + if (::SSL_set_tlsext_host_name(ssl_, name.c_str()) != 1) { + error_ = openssl_error("SSL_set_tlsext_host_name"); + close(); + return false; + } + // Chain validation plus name validation; see the file header. + if (::SSL_set1_host(ssl_, name.c_str()) != 1) { + error_ = openssl_error("SSL_set1_host"); + close(); + return false; + } + + if (::SSL_set_fd(ssl_, static_cast(socket_.handle())) != 1) { + error_ = openssl_error("SSL_set_fd"); + close(); + return false; + } + + const int rc = ::SSL_connect(ssl_); + if (rc != 1) { + const long verify = ::SSL_get_verify_result(ssl_); + if (verify != X509_V_OK) { + error_ = std::string("TLS handshake: certificate rejected: ") + + ::X509_verify_cert_error_string(verify); + } else { + error_ = openssl_error("SSL_connect"); + } + close(); + return false; + } + + connected_ = true; + error_.clear(); + return true; + } + + [[nodiscard]] IoStatus read(char* buf, std::size_t len, std::size_t& n_read) override { + if (!connected_ || ssl_ == nullptr) { + error_ = "read: not connected"; + return IoStatus::kError; + } + if (len == 0) { + n_read = 0; + return IoStatus::kOk; + } + + ::ERR_clear_error(); + const int got = ::SSL_read(ssl_, buf, static_cast(std::min( + len, static_cast(INT32_MAX)))); + if (got > 0) { + n_read = static_cast(got); + return IoStatus::kOk; + } + return classify(got); + } + + [[nodiscard]] IoStatus write(const char* buf, std::size_t len) override { + if (!connected_ || ssl_ == nullptr) { + error_ = "write: not connected"; + return IoStatus::kError; + } + + std::size_t sent = 0; + while (sent < len) { + ::ERR_clear_error(); + const int n = ::SSL_write( + ssl_, buf + sent, + static_cast(std::min(len - sent, + static_cast(INT32_MAX)))); + if (n > 0) { + sent += static_cast(n); + continue; + } + return classify(n); + } + return IoStatus::kOk; + } + + void set_read_timeout(int timeout_ms) override { socket_.set_read_timeout(timeout_ms); } + + void close() override { + if (ssl_ != nullptr) { + // Best-effort close_notify. A venue that has already gone away makes + // this fail, which is not worth reporting. + (void)::SSL_shutdown(ssl_); + ::SSL_free(ssl_); + ssl_ = nullptr; + } + if (ctx_ != nullptr) { + ::SSL_CTX_free(ctx_); + ctx_ = nullptr; + } + socket_.close(); + connected_ = false; + } + + [[nodiscard]] bool connected() const noexcept override { return connected_; } + [[nodiscard]] const std::string& last_error() const noexcept override { return error_; } + +private: + /// Map a non-positive SSL_read / SSL_write return onto our status. + /// + /// The socket is blocking with a timeout, so an expired timeout surfaces as + /// WANT_READ / WANT_WRITE rather than as an error. Treating those as + /// failures would tear down the connection every time the market went quiet. + [[nodiscard]] IoStatus classify(int rc) { + const int err = ::SSL_get_error(ssl_, rc); + switch (err) { + case SSL_ERROR_WANT_READ: + case SSL_ERROR_WANT_WRITE: + return IoStatus::kTimeout; + case SSL_ERROR_ZERO_RETURN: + connected_ = false; + return IoStatus::kClosed; + case SSL_ERROR_SYSCALL: + if (rc == 0) { + connected_ = false; + return IoStatus::kClosed; // Clean EOF without close_notify. + } + connected_ = false; + error_ = socket_error_string("SSL_ERROR_SYSCALL"); + return IoStatus::kError; + default: + connected_ = false; + error_ = openssl_error("SSL"); + return IoStatus::kError; + } + } + + TcpSocket socket_; + ::SSL_CTX* ctx_{nullptr}; + ::SSL* ssl_{nullptr}; + std::string error_; + bool connected_{false}; +}; + +} // namespace + +std::unique_ptr make_tls_transport() { return std::make_unique(); } + +} // namespace crossbook::net::detail diff --git a/src/net/tls_schannel.cpp b/src/net/tls_schannel.cpp new file mode 100644 index 0000000..e33e8b7 --- /dev/null +++ b/src/net/tls_schannel.cpp @@ -0,0 +1,543 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// TLS via Schannel — the Windows backend. +// +// WHY SCHANNEL RATHER THAN OPENSSL ON WINDOWS: +// +// It ships with the operating system, so `cmake --build` produces a working +// client on a stock Windows machine with nothing installed and no vcpkg step. +// Certificate validation uses the system trust store, which is the store the +// machine's administrator actually maintains. +// +// The awkward part of Schannel is that it does not own the socket: it converts +// between ciphertext and plaintext and leaves the transport entirely to you. +// Every one of its calls can come back saying "that was not a whole record" +// (SEC_E_INCOMPLETE_MESSAGE) or "I consumed less than you gave me, the rest is +// the next record" (SECBUFFER_EXTRA). Both cases are the normal path, not the +// error path, and mishandling either produces a client that works on a fast +// local link and corrupts data the moment a record straddles two segments. +// That is the entire reason this file is longer than its OpenSSL counterpart. + +#include +#include +#include +#include +#include +#include +#include + +#define SECURITY_WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX + +// schannel.h hides the modern SCH_CREDENTIALS struct behind this macro and +// exposes only the deprecated SCHANNEL_CRED without it. SCHANNEL_CRED carries a +// protocol allow-list that this code has no business setting: pinning enabled +// protocols in application code is how a program ends up refusing TLS 1.3 years +// after the OS learned it. SCH_CREDENTIALS defers that to system policy. +#define SCHANNEL_USE_BLACKLISTS + +// clang-format off +#include +#include +#include +// subauth.h before schannel.h: with SCHANNEL_USE_BLACKLISTS defined, the +// CRYPTO_SETTINGS struct refers to PUNICODE_STRING, and nothing else in this +// include chain declares it. +#include +#include +#include +#include +// clang-format on + +#include "crossbook/net/transport.hpp" +#include "tcp_socket.hpp" +#include "tls_backend.hpp" + +namespace crossbook::net::detail { +namespace { + +/// Ciphertext read buffer. One TLS record is at most 16 KiB of plaintext plus +/// framing; 32 KiB holds any record plus a partial successor. +constexpr std::size_t kReadChunk = 32 * 1024; + +[[nodiscard]] std::string sec_error(const char* context, SECURITY_STATUS status) { + std::string out(context); + out.append(": SSPI status 0x"); + char hex[16]; + (void)std::snprintf(hex, sizeof(hex), "%08lX", static_cast(status)); + out.append(hex); + + // The common ones by name, because "0x80090308" tells an operator nothing + // and "certificate not trusted" tells them exactly what to go look at. + switch (status) { + case SEC_E_UNTRUSTED_ROOT: + out.append(" (certificate chain not trusted)"); + break; + case SEC_E_CERT_EXPIRED: + out.append(" (certificate expired)"); + break; + case SEC_E_WRONG_PRINCIPAL: + out.append(" (certificate name mismatch)"); + break; + case SEC_E_ILLEGAL_MESSAGE: + out.append(" (peer sent an illegal TLS message)"); + break; + case SEC_E_ALGORITHM_MISMATCH: + out.append(" (no shared cipher suite)"); + break; + default: + break; + } + return out; +} + +class SchannelTransport final : public Transport { +public: + ~SchannelTransport() override { close(); } + + [[nodiscard]] bool connect(const std::string& host, std::uint16_t port, + int timeout_ms) override { + close(); + + if (!socket_.connect(host, port, timeout_ms, error_)) { + return false; + } + + SCH_CREDENTIALS credentials{}; + credentials.dwVersion = SCH_CREDENTIALS_VERSION; + // AUTO_CRED_VALIDATION is the default, but stating it is the point: this + // client verifies the chain and the hostname, and a reviewer should be + // able to see that without knowing Schannel's defaults. + credentials.dwFlags = SCH_USE_STRONG_CRYPTO | SCH_CRED_AUTO_CRED_VALIDATION | + SCH_CRED_NO_DEFAULT_CREDS; + + TimeStamp expiry{}; + SECURITY_STATUS status = ::AcquireCredentialsHandleA( + nullptr, const_cast(UNISP_NAME_A), SECPKG_CRED_OUTBOUND, nullptr, &credentials, + nullptr, nullptr, &cred_, &expiry); + if (status != SEC_E_OK) { + error_ = sec_error("AcquireCredentialsHandle", status); + socket_.close(); + return false; + } + have_cred_ = true; + + // The name the certificate is validated against. Bracketed IPv6 literals + // are not valid SNI, but neither is any venue addressed that way. + target_name_ = host; + if (!handshake()) { + socket_.close(); + return false; + } + + status = ::QueryContextAttributes(&ctx_, SECPKG_ATTR_STREAM_SIZES, &sizes_); + if (status != SEC_E_OK) { + error_ = sec_error("QueryContextAttributes(STREAM_SIZES)", status); + socket_.close(); + return false; + } + + send_buf_.resize(static_cast(sizes_.cbHeader) + + static_cast(sizes_.cbMaximumMessage) + + static_cast(sizes_.cbTrailer)); + + connected_ = true; + error_.clear(); + return true; + } + + [[nodiscard]] IoStatus read(char* buf, std::size_t len, std::size_t& n_read) override { + if (!connected_) { + error_ = "read: not connected"; + return IoStatus::kError; + } + if (len == 0) { + n_read = 0; + return IoStatus::kOk; + } + + // Serve whatever a previous decrypt left over first: one TLS record can + // hold more plaintext than the caller asked for. + if (plain_pos_ < plain_.size()) { + const std::size_t take = (std::min)(len, plain_.size() - plain_pos_); + std::memcpy(buf, plain_.data() + plain_pos_, take); + plain_pos_ += take; + n_read = take; + return IoStatus::kOk; + } + + plain_.clear(); + plain_pos_ = 0; + + for (;;) { + // Try to decrypt what is already buffered before asking for more: + // the tail of the handshake often arrives with application data + // behind it in the same segment. + if (!enc_.empty()) { + const IoStatus status = decrypt_buffered(); + if (status == IoStatus::kOk) { + const std::size_t take = (std::min)(len, plain_.size()); + std::memcpy(buf, plain_.data(), take); + plain_pos_ = take; + n_read = take; + return IoStatus::kOk; + } + if (status != IoStatus::kTimeout) { + return status; // kClosed or kError; kTimeout means "need more". + } + } + + const std::size_t old_size = enc_.size(); + enc_.resize(old_size + kReadChunk); + std::size_t got = 0; + const IoStatus status = + socket_.read(enc_.data() + old_size, kReadChunk, got, error_); + enc_.resize(old_size + (status == IoStatus::kOk ? got : 0)); + + if (status == IoStatus::kOk) { + continue; + } + if (status == IoStatus::kClosed) { + connected_ = false; + } + if (status == IoStatus::kError) { + connected_ = false; + } + return status; + } + } + + [[nodiscard]] IoStatus write(const char* buf, std::size_t len) override { + if (!connected_) { + error_ = "write: not connected"; + return IoStatus::kError; + } + + std::size_t sent = 0; + while (sent < len) { + const std::size_t chunk = + (std::min)(len - sent, static_cast(sizes_.cbMaximumMessage)); + + std::memcpy(send_buf_.data() + sizes_.cbHeader, buf + sent, chunk); + + SecBuffer buffers[4]{}; + buffers[0].BufferType = SECBUFFER_STREAM_HEADER; + buffers[0].cbBuffer = sizes_.cbHeader; + buffers[0].pvBuffer = send_buf_.data(); + buffers[1].BufferType = SECBUFFER_DATA; + buffers[1].cbBuffer = static_cast(chunk); + buffers[1].pvBuffer = send_buf_.data() + sizes_.cbHeader; + buffers[2].BufferType = SECBUFFER_STREAM_TRAILER; + buffers[2].cbBuffer = sizes_.cbTrailer; + buffers[2].pvBuffer = send_buf_.data() + sizes_.cbHeader + chunk; + buffers[3].BufferType = SECBUFFER_EMPTY; + + SecBufferDesc desc{}; + desc.ulVersion = SECBUFFER_VERSION; + desc.cBuffers = 4; + desc.pBuffers = buffers; + + const SECURITY_STATUS status = ::EncryptMessage(&ctx_, 0, &desc, 0); + if (status != SEC_E_OK) { + error_ = sec_error("EncryptMessage", status); + connected_ = false; + return IoStatus::kError; + } + + // EncryptMessage rewrites the buffer lengths; the record is the sum + // of the three, which is not the same as the sum we passed in. + const std::size_t record = + static_cast(buffers[0].cbBuffer) + + static_cast(buffers[1].cbBuffer) + + static_cast(buffers[2].cbBuffer); + + const IoStatus io = socket_.write(send_buf_.data(), record, error_); + if (io != IoStatus::kOk) { + connected_ = false; + return io; + } + sent += chunk; + } + return IoStatus::kOk; + } + + void set_read_timeout(int timeout_ms) override { socket_.set_read_timeout(timeout_ms); } + + void close() override { + if (have_ctx_) { + (void)::DeleteSecurityContext(&ctx_); + have_ctx_ = false; + } + if (have_cred_) { + (void)::FreeCredentialsHandle(&cred_); + have_cred_ = false; + } + socket_.close(); + enc_.clear(); + plain_.clear(); + plain_pos_ = 0; + connected_ = false; + } + + [[nodiscard]] bool connected() const noexcept override { return connected_; } + [[nodiscard]] const std::string& last_error() const noexcept override { return error_; } + +private: + static constexpr unsigned long kIscFlags = + ISC_REQ_SEQUENCE_DETECT | ISC_REQ_REPLAY_DETECT | ISC_REQ_CONFIDENTIALITY | + ISC_RET_EXTENDED_ERROR | ISC_REQ_ALLOCATE_MEMORY | ISC_REQ_STREAM; + + /// Drive InitializeSecurityContext to completion. + /// + /// Also used to service SEC_I_RENEGOTIATE mid-stream, which is why it starts + /// from whatever is already in `enc_` rather than assuming an empty buffer. + [[nodiscard]] bool handshake() { + unsigned long out_flags = 0; + TimeStamp expiry{}; + + // --- First leg: produce ClientHello with no input. --- + SecBuffer out_buffer{}; + out_buffer.BufferType = SECBUFFER_TOKEN; + SecBufferDesc out_desc{}; + out_desc.ulVersion = SECBUFFER_VERSION; + out_desc.cBuffers = 1; + out_desc.pBuffers = &out_buffer; + + SECURITY_STATUS status = ::InitializeSecurityContextA( + &cred_, nullptr, target_name_.data(), kIscFlags, 0, 0, nullptr, 0, &ctx_, &out_desc, + &out_flags, &expiry); + if (status != SEC_I_CONTINUE_NEEDED) { + error_ = sec_error("InitializeSecurityContext(initial)", status); + return false; + } + have_ctx_ = true; + + if (out_buffer.cbBuffer != 0 && out_buffer.pvBuffer != nullptr) { + const IoStatus io = socket_.write(static_cast(out_buffer.pvBuffer), + out_buffer.cbBuffer, error_); + (void)::FreeContextBuffer(out_buffer.pvBuffer); + if (io != IoStatus::kOk) { + return false; + } + } + + return continue_handshake(); + } + + /// The read / InitializeSecurityContext loop, from the second leg onward. + [[nodiscard]] bool continue_handshake() { + unsigned long out_flags = 0; + TimeStamp expiry{}; + + // A conforming handshake is a handful of legs. The bound exists so that + // a peer which keeps returning "continue" without consuming anything + // cannot spin this loop forever. + constexpr int kMaxLegs = 64; + + for (int leg = 0;; ++leg) { + if (leg >= kMaxLegs) { + error_ = "TLS handshake: exceeded the leg limit without completing"; + return false; + } + // Only read when the last attempt said the record was short. On the + // first pass through, `enc_` is empty and this always reads. + if (enc_.empty() || need_more_) { + const std::size_t old_size = enc_.size(); + enc_.resize(old_size + kReadChunk); + std::size_t got = 0; + const IoStatus io = socket_.read(enc_.data() + old_size, kReadChunk, got, error_); + enc_.resize(old_size + (io == IoStatus::kOk ? got : 0)); + if (io == IoStatus::kClosed) { + error_ = "TLS handshake: peer closed the connection"; + return false; + } + if (io == IoStatus::kTimeout) { + error_ = "TLS handshake: timed out waiting for the server"; + return false; + } + if (io == IoStatus::kError) { + return false; + } + need_more_ = false; + } + + SecBuffer in_buffers[2]{}; + in_buffers[0].BufferType = SECBUFFER_TOKEN; + in_buffers[0].cbBuffer = static_cast(enc_.size()); + in_buffers[0].pvBuffer = enc_.data(); + in_buffers[1].BufferType = SECBUFFER_EMPTY; + + SecBufferDesc in_desc{}; + in_desc.ulVersion = SECBUFFER_VERSION; + in_desc.cBuffers = 2; + in_desc.pBuffers = in_buffers; + + SecBuffer out_buffer{}; + out_buffer.BufferType = SECBUFFER_TOKEN; + SecBufferDesc out_desc{}; + out_desc.ulVersion = SECBUFFER_VERSION; + out_desc.cBuffers = 1; + out_desc.pBuffers = &out_buffer; + + const SECURITY_STATUS status = ::InitializeSecurityContextA( + &cred_, &ctx_, target_name_.data(), kIscFlags, 0, 0, &in_desc, 0, nullptr, + &out_desc, &out_flags, &expiry); + + if (status == SEC_E_INCOMPLETE_MESSAGE) { + need_more_ = true; // Keep what we have and append to it. + continue; + } + + if (out_buffer.cbBuffer != 0 && out_buffer.pvBuffer != nullptr) { + const IoStatus io = socket_.write(static_cast(out_buffer.pvBuffer), + out_buffer.cbBuffer, error_); + (void)::FreeContextBuffer(out_buffer.pvBuffer); + if (io != IoStatus::kOk) { + return false; + } + } + + // Whatever Schannel did not consume is the beginning of the next + // record — possibly already application data. Dropping it here is + // the classic Schannel bug: the first message of the session simply + // vanishes, intermittently, depending on segmentation. + take_extra(in_buffers[1]); + + if (status == SEC_E_OK) { + return true; + } + if (status == SEC_I_CONTINUE_NEEDED) { + need_more_ = enc_.empty(); + continue; + } + error_ = sec_error("InitializeSecurityContext", status); + return false; + } + } + + /// Move a SECBUFFER_EXTRA span to the front of the ciphertext buffer. + /// + /// The two producers describe the span differently, which is a documented + /// asymmetry and an easy thing to get wrong in one of the two places: + /// InitializeSecurityContext reports only `cbBuffer`, counted back from the + /// end of the input, while DecryptMessage also fills in `pvBuffer`. + void take_extra(const SecBuffer& extra) { + if (extra.BufferType != SECBUFFER_EXTRA || extra.cbBuffer == 0) { + enc_.clear(); + return; + } + const std::size_t count = static_cast(extra.cbBuffer); + if (count > enc_.size()) { + enc_.clear(); // Cannot happen; not worth trusting that it cannot. + return; + } + const char* src = (extra.pvBuffer != nullptr) + ? static_cast(extra.pvBuffer) + : enc_.data() + (enc_.size() - count); + std::memmove(enc_.data(), src, count); + enc_.resize(count); + } + + /// Decrypt one record out of `enc_` into `plain_`. + /// + /// Returns kTimeout to mean "incomplete record, read more" — the caller's + /// loop treats it as such, and no other status can express it. + [[nodiscard]] IoStatus decrypt_buffered() { + SecBuffer buffers[4]{}; + buffers[0].BufferType = SECBUFFER_DATA; + buffers[0].cbBuffer = static_cast(enc_.size()); + buffers[0].pvBuffer = enc_.data(); + buffers[1].BufferType = SECBUFFER_EMPTY; + buffers[2].BufferType = SECBUFFER_EMPTY; + buffers[3].BufferType = SECBUFFER_EMPTY; + + SecBufferDesc desc{}; + desc.ulVersion = SECBUFFER_VERSION; + desc.cBuffers = 4; + desc.pBuffers = buffers; + + const SECURITY_STATUS status = ::DecryptMessage(&ctx_, &desc, 0, nullptr); + + if (status == SEC_E_INCOMPLETE_MESSAGE) { + return IoStatus::kTimeout; + } + if (status == SEC_I_CONTEXT_EXPIRED) { + connected_ = false; + return IoStatus::kClosed; // The peer sent close_notify. + } + if (status == SEC_I_RENEGOTIATE) { + // Rare on TLS 1.2 and absent on 1.3, but a server may ask. Resuming + // the handshake loop is the whole handling; the alternative is + // dropping the connection on a legal server request. + const SecBuffer* extra = find_buffer(buffers, SECBUFFER_EXTRA); + if (extra != nullptr && extra->cbBuffer != 0) { + take_extra(*extra); + } else { + enc_.clear(); + } + need_more_ = enc_.empty(); + if (!continue_handshake()) { + connected_ = false; + return IoStatus::kError; + } + return IoStatus::kTimeout; // Nothing decrypted yet; loop again. + } + if (status != SEC_E_OK) { + error_ = sec_error("DecryptMessage", status); + connected_ = false; + return IoStatus::kError; + } + + const SecBuffer* data = find_buffer(buffers, SECBUFFER_DATA); + if (data != nullptr && data->cbBuffer != 0) { + const char* p = static_cast(data->pvBuffer); + plain_.assign(p, p + data->cbBuffer); + } + + const SecBuffer* extra = find_buffer(buffers, SECBUFFER_EXTRA); + if (extra != nullptr && extra->cbBuffer != 0) { + // Copy before shrinking: the extra span points into `enc_` itself. + std::vector rest(static_cast(extra->pvBuffer), + static_cast(extra->pvBuffer) + extra->cbBuffer); + enc_.swap(rest); + } else { + enc_.clear(); + } + + return plain_.empty() ? IoStatus::kTimeout : IoStatus::kOk; + } + + /// First buffer of a given type, skipping the one we handed in as input. + [[nodiscard]] static const SecBuffer* find_buffer(const SecBuffer (&buffers)[4], + unsigned long type) noexcept { + for (std::size_t i = 1; i < 4; ++i) { + if (buffers[i].BufferType == type) { + return &buffers[i]; + } + } + return nullptr; + } + + TcpSocket socket_; + CredHandle cred_{}; + CtxtHandle ctx_{}; + SecPkgContext_StreamSizes sizes_{}; + std::string target_name_; + std::vector enc_; + std::vector plain_; + std::vector send_buf_; + std::string error_; + std::size_t plain_pos_{0}; + bool have_cred_{false}; + bool have_ctx_{false}; + bool need_more_{false}; + bool connected_{false}; +}; + +} // namespace + +std::unique_ptr make_tls_transport() { return std::make_unique(); } + +} // namespace crossbook::net::detail diff --git a/src/net/transport.cpp b/src/net/transport.cpp new file mode 100644 index 0000000..f4f28a4 --- /dev/null +++ b/src/net/transport.cpp @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// The plaintext transport, the factory, and the one-shot HTTPS GET used to +// fetch Binance's REST depth snapshot. + +#include "crossbook/net/transport.hpp" + +#include +#include +#include +#include + +#include "tcp_socket.hpp" +#include "tls_backend.hpp" + +namespace crossbook::net { +namespace { + +/// TCP with no TLS. Kept because a local test server is plaintext and because +/// having the plain path exercised keeps the TLS backends honest about what +/// they are actually adding. +class PlainTransport final : public Transport { +public: + [[nodiscard]] bool connect(const std::string& host, std::uint16_t port, + int timeout_ms) override { + if (!socket_.connect(host, port, timeout_ms, error_)) { + return false; + } + connected_ = true; + return true; + } + + [[nodiscard]] IoStatus read(char* buf, std::size_t len, std::size_t& n_read) override { + const IoStatus status = socket_.read(buf, len, n_read, error_); + if (status == IoStatus::kClosed || status == IoStatus::kError) { + connected_ = false; + } + return status; + } + + [[nodiscard]] IoStatus write(const char* buf, std::size_t len) override { + const IoStatus status = socket_.write(buf, len, error_); + if (status == IoStatus::kClosed || status == IoStatus::kError) { + connected_ = false; + } + return status; + } + + void set_read_timeout(int timeout_ms) override { socket_.set_read_timeout(timeout_ms); } + + void close() override { + socket_.close(); + connected_ = false; + } + + [[nodiscard]] bool connected() const noexcept override { return connected_; } + [[nodiscard]] const std::string& last_error() const noexcept override { return error_; } + +private: + detail::TcpSocket socket_; + std::string error_; + bool connected_{false}; +}; + +/// Case-insensitive header lookup over a raw HTTP header block. +[[nodiscard]] std::string_view http_header(std::string_view headers, std::string_view name) { + auto lower = [](char c) { + return (c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c; + }; + std::size_t pos = 0; + while (pos < headers.size()) { + const std::size_t eol = headers.find("\r\n", pos); + const std::string_view line = + headers.substr(pos, (eol == std::string_view::npos ? headers.size() : eol) - pos); + const std::size_t colon = line.find(':'); + if (colon != std::string_view::npos && colon == name.size()) { + bool match = true; + for (std::size_t i = 0; i < name.size(); ++i) { + if (lower(line[i]) != lower(name[i])) { + match = false; + break; + } + } + if (match) { + std::string_view value = line.substr(colon + 1); + while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) { + value.remove_prefix(1); + } + return value; + } + } + if (eol == std::string_view::npos) { + break; + } + pos = eol + 2; + } + return {}; +} + +/// Decode HTTP/1.1 chunked transfer coding. +/// +/// Binance answers depth requests with Content-Length in practice, but a proxy +/// or a CDN in front of it may re-chunk the response, and a body silently +/// truncated at the first chunk header decodes into a snapshot that looks +/// plausible and is missing most of the book. +[[nodiscard]] bool decode_chunked(std::string_view input, std::string& out) { + std::size_t pos = 0; + while (pos < input.size()) { + const std::size_t eol = input.find("\r\n", pos); + if (eol == std::string_view::npos) { + return false; + } + // The size line may carry chunk extensions after a ';'. + std::string_view size_text = input.substr(pos, eol - pos); + const std::size_t semi = size_text.find(';'); + if (semi != std::string_view::npos) { + size_text = size_text.substr(0, semi); + } + + std::size_t chunk_size = 0; + if (size_text.empty()) { + return false; + } + for (const char c : size_text) { + std::size_t digit = 0; + if (c >= '0' && c <= '9') { + digit = static_cast(c - '0'); + } else if (c >= 'a' && c <= 'f') { + digit = static_cast(c - 'a' + 10); + } else if (c >= 'A' && c <= 'F') { + digit = static_cast(c - 'A' + 10); + } else { + return false; + } + chunk_size = chunk_size * 16 + digit; + } + + pos = eol + 2; + if (chunk_size == 0) { + return true; // Terminal chunk; trailers ignored. + } + if (pos + chunk_size > input.size()) { + return false; + } + out.append(input.substr(pos, chunk_size)); + pos += chunk_size + 2; // Skip the chunk's trailing CRLF. + } + return false; +} + +} // namespace + +std::unique_ptr make_transport(bool secure) { + if (!secure) { + return std::make_unique(); + } + return detail::make_tls_transport(); +} + +bool https_get(const std::string& host, const std::string& path, std::string& body, + std::string& error, int timeout_ms) { + body.clear(); + error.clear(); + + auto transport = make_transport(true); + if (!transport) { + error = "no TLS backend in this build"; + return false; + } + if (!transport->connect(host, 443, timeout_ms)) { + error = transport->last_error(); + return false; + } + + std::string request; + request.append("GET ").append(path).append(" HTTP/1.1\r\n"); + request.append("Host: ").append(host).append("\r\n"); + request.append("User-Agent: crossbook/0.2\r\n"); + request.append("Accept: application/json\r\n"); + // No keep-alive: one request per connection means the end of the body is + // unambiguous even if the server omits Content-Length. + request.append("Connection: close\r\n\r\n"); + + if (transport->write(request.data(), request.size()) != IoStatus::kOk) { + error = transport->last_error().empty() ? "http write failed" : transport->last_error(); + return false; + } + + // A depth snapshot is a few hundred kilobytes; the ceiling is here so a + // misbehaving endpoint cannot make this loop until memory runs out. + constexpr std::size_t kMaxResponse = 64U * 1024U * 1024U; + + std::string response; + char chunk[16384]; + for (;;) { + std::size_t got = 0; + const IoStatus status = transport->read(chunk, sizeof(chunk), got); + if (status == IoStatus::kOk) { + response.append(chunk, got); + if (response.size() > kMaxResponse) { + error = "http response exceeded ceiling"; + return false; + } + continue; + } + if (status == IoStatus::kClosed) { + break; + } + if (status == IoStatus::kTimeout) { + error = "http read timed out"; + return false; + } + error = transport->last_error().empty() ? "http read failed" : transport->last_error(); + return false; + } + transport->close(); + + const std::size_t header_end = response.find("\r\n\r\n"); + if (header_end == std::string::npos) { + error = "http response had no header terminator"; + return false; + } + + const std::string_view head(response.data(), header_end); + const std::size_t status_sp = head.find(' '); + if (status_sp == std::string_view::npos) { + error = "http response had no status line"; + return false; + } + const std::string_view code = head.substr(status_sp + 1, 3); + if (code != "200") { + error = "http status " + std::string(code); + return false; + } + + const std::string_view raw_body(response.data() + header_end + 4, + response.size() - header_end - 4); + + if (http_header(head, "transfer-encoding").find("chunked") != std::string_view::npos) { + if (!decode_chunked(raw_body, body)) { + error = "malformed chunked body"; + return false; + } + return true; + } + + body.assign(raw_body); + return true; +} + +} // namespace crossbook::net diff --git a/src/net/websocket.cpp b/src/net/websocket.cpp new file mode 100644 index 0000000..077fd0f --- /dev/null +++ b/src/net/websocket.cpp @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti + +#include "crossbook/net/websocket.hpp" + +#include +#include +#include + +#include "crossbook/net/handshake.hpp" + +namespace crossbook::net { +namespace { + +/// Read buffer for the transport. Sized so a burst of small messages is picked +/// up in one syscall without the buffer itself dominating the working set. +constexpr std::size_t kReadChunk = 32 * 1024; + +/// Seed a PRNG from the platform entropy source. +/// +/// The masking key must be unpredictable to the server (§5.3), which rules out +/// a fixed seed — including the "deterministic for tests" fixed seed that would +/// otherwise be this repository's habit. Determinism is enforced at the book +/// layer, where it is a correctness property; here it would be a defect. +[[nodiscard]] std::mt19937 seeded_rng() { + std::random_device device; + std::array seed_data{}; + for (auto& value : seed_data) { + value = device(); + } + std::seed_seq seq(seed_data.begin(), seed_data.end()); + return std::mt19937(seq); +} + +} // namespace + +WebSocketClient::WebSocketClient(std::size_t max_message_bytes) + : reader_(max_message_bytes), rng_(seeded_rng()) { + send_buf_.reserve(4096); +} + +WebSocketClient::~WebSocketClient() { + if (open_) { + close(CloseCode::kGoingAway); + } +} + +bool WebSocketClient::connected() const noexcept { + return open_ && transport_ != nullptr && transport_->connected(); +} + +std::uint32_t WebSocketClient::next_mask_key() { + // mt19937 yields 32 bits per call, which is exactly a masking key. + return static_cast(rng_()); +} + +bool WebSocketClient::connect(std::string_view url_text, int handshake_timeout_ms, + int read_timeout_ms) { + error_.clear(); + reader_.reset(); + stats_ = WebSocketStats{}; + open_ = false; + + const UrlError url_status = parse_url(url_text, url_); + if (url_status != UrlError::kOk) { + error_ = std::string("bad url: ") + std::string(to_string(url_status)); + return false; + } + + transport_ = make_transport(url_.secure); + if (!transport_) { + error_ = "no TLS backend in this build"; + return false; + } + if (!transport_->connect(url_.host, url_.port, handshake_timeout_ms)) { + error_ = transport_->last_error(); + return false; + } + + // §4.1: a fresh 16-byte nonce, base64 encoded. + std::array nonce{}; + for (std::size_t i = 0; i < nonce.size(); i += 4) { + const std::uint32_t bits = next_mask_key(); + std::memcpy(nonce.data() + i, &bits, 4); + } + const std::string key = base64_encode(nonce.data(), nonce.size()); + const std::string expected_accept = websocket_accept_for(key); + + const std::string request = + make_handshake_request(url_.host, url_.port, url_.path, key, url_.secure); + if (transport_->write(request.data(), request.size()) != IoStatus::kOk) { + error_ = transport_->last_error().empty() ? "handshake write failed" + : transport_->last_error(); + transport_->close(); + return false; + } + + if (!complete_handshake(expected_accept, handshake_timeout_ms)) { + transport_->close(); + return false; + } + + // The generous handshake budget has done its job; switch to a short read + // timeout so the caller's poll loop stays responsive on a quiet market. + transport_->set_read_timeout(read_timeout_ms); + + open_ = true; + return true; +} + +bool WebSocketClient::complete_handshake(const std::string& expected_accept, int timeout_ms) { + (void)timeout_ms; // The transport already carries the read timeout. + + std::string response; + char chunk[kReadChunk]; + + // Bounded so a peer that sends headers forever cannot exhaust memory before + // it ever has to produce a valid status line. + constexpr std::size_t kMaxHandshakeBytes = 64 * 1024; + + for (;;) { + HandshakeResponse parsed; + const HandshakeStatus status = + parse_handshake_response(response, expected_accept, parsed); + + if (status == HandshakeStatus::kOk) { + // A server may pack frames into the same segment as the 101. Those + // bytes belong to the reader, and dropping them loses the first + // message of the session — intermittently, which is worse. + if (parsed.header_bytes < response.size()) { + reader_.append(response.data() + parsed.header_bytes, + response.size() - parsed.header_bytes); + } + return true; + } + if (status == HandshakeStatus::kNotSwitchingProtocols) { + error_ = "handshake rejected: HTTP " + std::to_string(parsed.status_code); + return false; + } + if (status != HandshakeStatus::kIncomplete) { + error_ = std::string("handshake failed: ") + std::string(to_string(status)); + return false; + } + + if (response.size() > kMaxHandshakeBytes) { + error_ = "handshake response exceeded ceiling"; + return false; + } + + std::size_t got = 0; + const IoStatus io = transport_->read(chunk, sizeof(chunk), got); + if (io == IoStatus::kOk) { + response.append(chunk, got); + continue; + } + if (io == IoStatus::kTimeout) { + error_ = "handshake timed out"; + return false; + } + if (io == IoStatus::kClosed) { + error_ = "peer closed during handshake"; + return false; + } + error_ = transport_->last_error().empty() ? "handshake read failed" + : transport_->last_error(); + return false; + } +} + +ReadStatus WebSocketClient::poll(Event& out) { + if (!connected()) { + error_ = "poll: not connected"; + return ReadStatus::kProtocolError; + } + + for (;;) { + const ReadStatus status = reader_.next(out); + + switch (status) { + case ReadStatus::kMessage: + ++stats_.messages; + return ReadStatus::kMessage; + + case ReadStatus::kPing: + // §5.5.2: the pong must carry the ping's payload verbatim. + ++stats_.pings_received; + if (!send_frame(Opcode::kPong, out.payload)) { + return ReadStatus::kProtocolError; + } + ++stats_.pongs_sent; + continue; + + case ReadStatus::kPong: + ++stats_.pongs_received; + continue; + + case ReadStatus::kClose: + // Echo the code back, then stop. A client that just drops the + // socket leaves the venue's side waiting on a half-open + // connection until its own timeout fires. + (void)send_frame(Opcode::kClose, out.payload); + open_ = false; + transport_->close(); + return ReadStatus::kClose; + + case ReadStatus::kProtocolError: + error_ = "peer violated RFC 6455"; + close(CloseCode::kProtocolError, "protocol error"); + return ReadStatus::kProtocolError; + + case ReadStatus::kMessageTooLarge: + error_ = "message exceeded the configured ceiling"; + close(CloseCode::kMessageTooBig, "message too big"); + return ReadStatus::kMessageTooLarge; + + case ReadStatus::kNeedMore: + break; // Fall through to the transport read below. + } + + char* tail = reader_.writable_tail(kReadChunk); + std::size_t got = 0; + const IoStatus io = transport_->read(tail, kReadChunk, got); + reader_.commit(io == IoStatus::kOk ? got : 0, kReadChunk); + + if (io == IoStatus::kOk) { + stats_.bytes_received += got; + continue; + } + if (io == IoStatus::kTimeout) { + return ReadStatus::kNeedMore; + } + if (io == IoStatus::kClosed) { + open_ = false; + out.close_code = static_cast(CloseCode::kAbnormal); + out.payload = {}; + return ReadStatus::kClose; + } + error_ = transport_->last_error().empty() ? "transport read failed" + : transport_->last_error(); + open_ = false; + return ReadStatus::kProtocolError; + } +} + +bool WebSocketClient::send_frame(Opcode opcode, std::string_view payload) { + if (transport_ == nullptr || !transport_->connected()) { + error_ = "send: not connected"; + return false; + } + + const std::uint32_t mask_key = next_mask_key(); + + // The header goes into a fixed array first, rather than straight into the + // send buffer. `write_frame_header` may write up to kMaxHeaderSize bytes, + // and an array of exactly that size states the bound in the type where a + // just-resized vector only implies it — GCC 13 rejects the implied version + // under -Wstringop-overflow, and it is not wrong to want the guarantee. + std::array header{}; + const std::size_t header_size = + write_frame_header(header.data(), opcode, payload.size(), mask_key); + + const std::size_t total = header_size + payload.size(); + send_buf_.resize(total); + std::memcpy(send_buf_.data(), header.data(), header_size); + + if (!payload.empty()) { + std::memcpy(send_buf_.data() + header_size, payload.data(), payload.size()); + apply_mask(send_buf_.data() + header_size, payload.size(), mask_key); + } + + if (transport_->write(send_buf_.data(), total) != IoStatus::kOk) { + error_ = transport_->last_error().empty() ? "frame write failed" + : transport_->last_error(); + return false; + } + ++stats_.frames_sent; + return true; +} + +bool WebSocketClient::send_text(std::string_view payload) { + return send_frame(Opcode::kText, payload); +} + +bool WebSocketClient::send_binary(std::string_view payload) { + return send_frame(Opcode::kBinary, payload); +} + +bool WebSocketClient::send_ping(std::string_view payload) { + if (payload.size() > kMaxControlPayload) { + error_ = "ping payload exceeds 125 bytes"; + return false; + } + return send_frame(Opcode::kPing, payload); +} + +void WebSocketClient::close(CloseCode code, std::string_view reason) { + if (transport_ != nullptr && transport_->connected() && open_) { + // §5.5.1: two-byte big-endian code, then an optional UTF-8 reason. + std::string payload; + payload.reserve(2 + reason.size()); + payload.push_back(static_cast((static_cast(code) >> 8) & 0xFFU)); + payload.push_back(static_cast(static_cast(code) & 0xFFU)); + payload.append(reason.substr(0, kMaxControlPayload - 2)); + (void)send_frame(Opcode::kClose, payload); + } + if (transport_ != nullptr) { + transport_->close(); + } + open_ = false; +} + +} // namespace crossbook::net diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5a2daba..b366846 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,12 @@ add_executable(crossbook_tests test_venues.cpp test_feed.cpp test_replay.cpp + test_ws_frame.cpp + test_url.cpp + test_handshake.cpp + test_capture.cpp + test_depth_trim.cpp + test_fixture_replay.cpp ) target_link_libraries(crossbook_tests PRIVATE @@ -31,6 +37,16 @@ target_link_libraries(crossbook_tests PRIVATE Catch2::Catch2WithMain ) +# Somewhere writable for the capture round-trip test. The build tree, so a test +# run leaves nothing behind in the source tree or in a shared temp directory. +target_compile_definitions(crossbook_tests PRIVATE + CROSSBOOK_TEST_TMP_DIR="${CMAKE_CURRENT_BINARY_DIR}" + # The committed capture the end-to-end replay test runs against. Baked in as + # an absolute path so the test does not depend on the working directory it + # happens to be launched from. + CROSSBOOK_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures" +) + list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) catch_discover_tests(crossbook_tests) diff --git a/tests/fixtures/kraken_btcusd_l2.cbcap b/tests/fixtures/kraken_btcusd_l2.cbcap new file mode 100644 index 0000000..84bfe19 --- /dev/null +++ b/tests/fixtures/kraken_btcusd_l2.cbcap @@ -0,0 +1,755 @@ +CBCAP1 kraken BTC/USD 1785552832693693000 +201603688723400 139 +{"channel":"status","type":"update","data":[{"version":"2.0.10","system":"online","api_version":"v2","connection_id":7204881492680171915}]} +201603845874000 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:53.184174Z","time_out":"2026-08-01T02:53:53.184216Z"} +201604073826100 848 +{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01876947},{"price":63003.3,"qty":0.00005100},{"price":63002.6,"qty":0.01602600},{"price":63000.2,"qty":0.00005100},{"price":63000.0,"qty":0.08325683},{"price":62997.0,"qty":0.09705100},{"price":62993.9,"qty":0.00005100},{"price":62991.8,"qty":0.00158700},{"price":62991.6,"qty":0.11000000},{"price":62988.9,"qty":0.10000000}],"asks":[{"price":63005.0,"qty":2.07265551},{"price":63005.1,"qty":0.55006860},{"price":63005.2,"qty":0.79358539},{"price":63005.8,"qty":0.01537966},{"price":63005.9,"qty":0.11835139},{"price":63006.0,"qty":0.04761452},{"price":63006.3,"qty":0.23809955},{"price":63006.5,"qty":0.79356896},{"price":63007.3,"qty":0.06000000},{"price":63008.4,"qty":0.00057100}],"checksum":2531248887,"timestamp":"2026-08-01T02:53:53.398143Z"}]} +201604243794700 23 +{"channel":"heartbeat"} +201604311454200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.08665551}],"checksum":2532652056,"timestamp":"2026-08-01T02:53:53.646219Z"}]} +201605244717700 23 +{"channel":"heartbeat"} +201605634741500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.9,"qty":0.00000000},{"price":63008.6,"qty":0.00039677}],"checksum":3629011692,"timestamp":"2026-08-01T02:53:54.966086Z"}]} +201605805105100 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.4,"qty":0.00000000},{"price":63009.2,"qty":0.28207838}],"checksum":1024595226,"timestamp":"2026-08-01T02:53:55.137815Z"}]} +201605885769300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.14864105}],"checksum":1198578833,"timestamp":"2026-08-01T02:53:55.223312Z"}]} +201605907216500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.13464105}],"checksum":2220311411,"timestamp":"2026-08-01T02:53:55.229516Z"}]} +201606248087800 23 +{"channel":"heartbeat"} +201606660192100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.07265551}],"checksum":1027140597,"timestamp":"2026-08-01T02:53:55.995316Z"}]} +201606664030600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.08365551}],"checksum":3353100845,"timestamp":"2026-08-01T02:53:55.999884Z"}]} +201606699045900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":1.59719228}],"checksum":1137225339,"timestamp":"2026-08-01T02:53:56.035270Z"}]} +201606700077000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.75950313}],"checksum":417164630,"timestamp":"2026-08-01T02:53:56.038299Z"}]} +201606846108200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.00000000},{"price":63009.3,"qty":0.79353387}],"checksum":3520428661,"timestamp":"2026-08-01T02:53:56.182977Z"}]} +201606849194700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.2,"qty":0.28274124}],"checksum":2292810938,"timestamp":"2026-08-01T02:53:56.185650Z"}]} +201606907376800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.14941515}],"checksum":260883061,"timestamp":"2026-08-01T02:53:56.239993Z"}]} +201607007370300 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.345070Z","time_out":"2026-08-01T02:53:56.345138Z"} +201607007454100 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.345169Z","time_out":"2026-08-01T02:53:56.345206Z"} +201607009205600 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.348069Z","time_out":"2026-08-01T02:53:56.348087Z"} +201607009220000 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.348110Z","time_out":"2026-08-01T02:53:56.348120Z"} +201607064056100 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.400288Z","time_out":"2026-08-01T02:53:56.400341Z"} +201607064075900 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:53:56.400365Z","time_out":"2026-08-01T02:53:56.400380Z"} +201607077299100 847 +{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01876947},{"price":63003.3,"qty":0.00005100},{"price":63002.6,"qty":0.01602600},{"price":63000.2,"qty":0.00005100},{"price":63000.0,"qty":0.08325683},{"price":62997.0,"qty":0.09705100},{"price":62993.9,"qty":0.00005100},{"price":62991.8,"qty":0.00158700},{"price":62991.6,"qty":0.11000000},{"price":62988.9,"qty":0.10000000}],"asks":[{"price":63005.0,"qty":2.14941515},{"price":63005.1,"qty":0.55006860},{"price":63005.2,"qty":0.79358539},{"price":63005.8,"qty":0.01537966},{"price":63006.0,"qty":0.04761452},{"price":63006.3,"qty":0.23809955},{"price":63006.5,"qty":0.79356896},{"price":63007.3,"qty":0.06000000},{"price":63008.6,"qty":0.00039677},{"price":63009.2,"qty":0.28274124}],"checksum":260883061,"timestamp":"2026-08-01T02:53:56.333369Z"}]} +201607250090500 23 +{"channel":"heartbeat"} +201607433567000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.13841515}],"checksum":289505324,"timestamp":"2026-08-01T02:53:56.768904Z"}]} +201608259876200 23 +{"channel":"heartbeat"} +201609245917300 23 +{"channel":"heartbeat"} +201609530169100 321 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":2.00825935},{"price":63005.0,"qty":1.99103002},{"price":63005.0,"qty":1.98023002},{"price":63005.0,"qty":1.94923002},{"price":63005.0,"qty":1.56873940}],"checksum":345451028,"timestamp":"2026-08-01T02:53:58.864981Z"}]} +201609540323000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.76873940}],"checksum":1155370259,"timestamp":"2026-08-01T02:53:58.878162Z"}]} +201609856071500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.4,"qty":0.00005100}],"checksum":3433440525,"timestamp":"2026-08-01T02:53:59.194104Z"}]} +201610246827000 23 +{"channel":"heartbeat"} +201610539676800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.77413940}],"checksum":416465274,"timestamp":"2026-08-01T02:53:59.877632Z"}]} +201610542228600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.79164563}],"checksum":1930811526,"timestamp":"2026-08-01T02:53:59.879157Z"}]} +201610542268800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82264563}],"checksum":3595722396,"timestamp":"2026-08-01T02:53:59.880024Z"}]} +201610914571600 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.4,"qty":0.00000000},{"price":63009.2,"qty":0.28274124}],"checksum":2486653344,"timestamp":"2026-08-01T02:54:00.248314Z"}]} +201611248201500 23 +{"channel":"heartbeat"} +201612006221900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":1.58751728}],"checksum":1320185132,"timestamp":"2026-08-01T02:54:01.341944Z"}]} +201612018041200 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.00222061}],"checksum":719933159,"timestamp":"2026-08-01T02:54:01.346006Z"}]} +201612018118500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.75905326}],"checksum":3903675794,"timestamp":"2026-08-01T02:54:01.346140Z"}]} +201612023764500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.75683265},{"price":63008.9,"qty":0.00222061}],"checksum":1409115271,"timestamp":"2026-08-01T02:54:01.361600Z"}]} +201612261878500 23 +{"channel":"heartbeat"} +201612359250800 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.9,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":698939138,"timestamp":"2026-08-01T02:54:01.695421Z"}]} +201612522242800 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:01.858278Z","time_out":"2026-08-01T02:54:01.858360Z"} +201612522261700 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:01.858392Z","time_out":"2026-08-01T02:54:01.858411Z"} +201612577765300 847 +{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01876947},{"price":63003.3,"qty":0.00005100},{"price":63002.6,"qty":0.01602600},{"price":63000.2,"qty":0.00005100},{"price":63000.0,"qty":0.08325683},{"price":62997.0,"qty":0.09705100},{"price":62993.9,"qty":0.00005100},{"price":62991.8,"qty":0.00158700},{"price":62991.6,"qty":0.11000000},{"price":62988.9,"qty":0.10000000}],"asks":[{"price":63005.0,"qty":1.82264563},{"price":63005.1,"qty":0.55006860},{"price":63005.2,"qty":0.79358539},{"price":63005.8,"qty":0.01537966},{"price":63006.0,"qty":0.04761452},{"price":63006.3,"qty":0.23809955},{"price":63006.5,"qty":0.79356896},{"price":63007.3,"qty":0.06000000},{"price":63008.6,"qty":0.00039677},{"price":63009.2,"qty":0.28229589}],"checksum":698939138,"timestamp":"2026-08-01T02:54:01.878547Z"}]} +201613247631400 23 +{"channel":"heartbeat"} +201614251972400 23 +{"channel":"heartbeat"} +201614760660600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83364563}],"checksum":3877632203,"timestamp":"2026-08-01T02:54:04.095805Z"}]} +201615245797100 23 +{"channel":"heartbeat"} +201615535086500 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82264563}],"checksum":698939138,"timestamp":"2026-08-01T02:54:04.865969Z"}]} +201616144146200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83564563}],"checksum":4249132846,"timestamp":"2026-08-01T02:54:05.476651Z"}]} +201616252092700 23 +{"channel":"heartbeat"} +201617252332200 23 +{"channel":"heartbeat"} +201617940370600 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82264563}],"checksum":698939138,"timestamp":"2026-08-01T02:54:07.274467Z"}]} +201618250711000 23 +{"channel":"heartbeat"} +201618635064000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83664563}],"checksum":500281340,"timestamp":"2026-08-01T02:54:07.972733Z"}]} +201619251201000 23 +{"channel":"heartbeat"} +201620249080700 23 +{"channel":"heartbeat"} +201621248474000 23 +{"channel":"heartbeat"} +201621402329800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":1.59719228}],"checksum":3834081175,"timestamp":"2026-08-01T02:54:10.739020Z"}]} +201621404351100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.75972064}],"checksum":3477015596,"timestamp":"2026-08-01T02:54:10.742008Z"}]} +201621405522200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.76194125}],"checksum":2103554156,"timestamp":"2026-08-01T02:54:10.743024Z"}]} +201621409062400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.75972064},{"price":63008.9,"qty":0.00222061}],"checksum":1698778649,"timestamp":"2026-08-01T02:54:10.746690Z"}]} +201621557225800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.9,"qty":1.59941289}],"checksum":1597011515,"timestamp":"2026-08-01T02:54:10.892399Z"}]} +201621570702000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.8,"qty":0.75972064}],"checksum":329086662,"timestamp":"2026-08-01T02:54:10.895749Z"}]} +201621570755400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.5,"qty":0.00222061}],"checksum":2347406755,"timestamp":"2026-08-01T02:54:10.896139Z"}]} +201621710145600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.6,"qty":0.75945456}],"checksum":4090867410,"timestamp":"2026-08-01T02:54:11.046351Z"}]} +201621818888600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83625276}],"checksum":1206212079,"timestamp":"2026-08-01T02:54:11.150706Z"}]} +201622147012300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.6,"qty":0.00039677}],"checksum":360982297,"timestamp":"2026-08-01T02:54:11.484229Z"}]} +201622212095500 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.5,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":647275776,"timestamp":"2026-08-01T02:54:11.548887Z"}]} +201622257172300 23 +{"channel":"heartbeat"} +201622341065100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":2427351873,"timestamp":"2026-08-01T02:54:11.677289Z"}]} +201622376424900 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:11.713489Z","time_out":"2026-08-01T02:54:11.713552Z"} +201622376445400 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:11.713592Z","time_out":"2026-08-01T02:54:11.713607Z"} +201622498820600 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:11.835545Z","time_out":"2026-08-01T02:54:11.835590Z"} +201622501515100 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:11.840139Z","time_out":"2026-08-01T02:54:11.840161Z"} +201622578878700 848 +{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01876947},{"price":63003.3,"qty":0.00005100},{"price":63002.6,"qty":0.01602600},{"price":63000.2,"qty":0.00005100},{"price":63000.0,"qty":0.08325683},{"price":62997.0,"qty":0.09705100},{"price":62993.9,"qty":0.00005100},{"price":62992.1,"qty":0.05120000},{"price":62991.8,"qty":0.00158700},{"price":62991.6,"qty":0.11000000}],"asks":[{"price":63005.0,"qty":1.83625276},{"price":63005.1,"qty":0.55006860},{"price":63005.2,"qty":0.79358539},{"price":63005.8,"qty":0.01537966},{"price":63006.0,"qty":0.04761452},{"price":63006.3,"qty":0.23809955},{"price":63006.5,"qty":0.79356896},{"price":63007.3,"qty":0.06000000},{"price":63008.6,"qty":0.00039677},{"price":63009.2,"qty":0.28229589}],"checksum":2427351873,"timestamp":"2026-08-01T02:54:11.887849Z"}]} +201623253752100 23 +{"channel":"heartbeat"} +201623507806200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82225276}],"checksum":2268033320,"timestamp":"2026-08-01T02:54:12.842887Z"}]} +201624259106400 23 +{"channel":"heartbeat"} +201624447112200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83225276}],"checksum":1853055833,"timestamp":"2026-08-01T02:54:13.783882Z"}]} +201624614527800 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.00000000},{"price":62988.9,"qty":0.10000000}],"asks":[],"checksum":889643078,"timestamp":"2026-08-01T02:54:13.952506Z"}]} +201625251738600 23 +{"channel":"heartbeat"} +201626100606500 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82225276}],"checksum":317586942,"timestamp":"2026-08-01T02:54:15.435253Z"}]} +201626255797000 23 +{"channel":"heartbeat"} +201627016291600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":2268033320,"timestamp":"2026-08-01T02:54:16.353057Z"}]} +201627249434000 23 +{"channel":"heartbeat"} +201628250516500 23 +{"channel":"heartbeat"} +201628458259000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.4,"qty":0.00057100}],"checksum":1823831923,"timestamp":"2026-08-01T02:54:17.790439Z"}]} +201629249881300 23 +{"channel":"heartbeat"} +201630250219500 23 +{"channel":"heartbeat"} +201631064714900 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01718794},{"price":63004.9,"qty":0.01661321}],"asks":[],"checksum":3104198434,"timestamp":"2026-08-01T02:54:20.399506Z"}]} +201631249610300 23 +{"channel":"heartbeat"} +201632259699800 23 +{"channel":"heartbeat"} +201632608528000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83425276}],"checksum":1185905513,"timestamp":"2026-08-01T02:54:21.944156Z"}]} +201633270721300 23 +{"channel":"heartbeat"} +201633401434200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82225276}],"checksum":3104198434,"timestamp":"2026-08-01T02:54:22.737428Z"}]} +201634250668300 23 +{"channel":"heartbeat"} +201635252530100 23 +{"channel":"heartbeat"} +201636257073600 23 +{"channel":"heartbeat"} +201636500866100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83225276}],"checksum":2521752545,"timestamp":"2026-08-01T02:54:25.838947Z"}]} +201637256575400 23 +{"channel":"heartbeat"} +201637424088800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82225276}],"checksum":3104198434,"timestamp":"2026-08-01T02:54:26.761399Z"}]} +201637842415300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82298144}],"checksum":915051891,"timestamp":"2026-08-01T02:54:27.179286Z"}]} +201638233173800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83398144}],"checksum":1041399180,"timestamp":"2026-08-01T02:54:27.569033Z"}]} +201638255810900 23 +{"channel":"heartbeat"} +201638771753100 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.4,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":1993390488,"timestamp":"2026-08-01T02:54:28.108746Z"}]} +201639253274800 23 +{"channel":"heartbeat"} +201639910269600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82298144}],"checksum":2688282607,"timestamp":"2026-08-01T02:54:29.245200Z"}]} +201640252186500 23 +{"channel":"heartbeat"} +201640674737500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83498144}],"checksum":3369362826,"timestamp":"2026-08-01T02:54:30.012117Z"}]} +201640868488900 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.00000000},{"price":62988.0,"qty":0.04762812}],"asks":[],"checksum":2528703015,"timestamp":"2026-08-01T02:54:30.203446Z"}]} +201641026192300 178 +{"method":"unsubscribe","result":{"channel":"book","depth":10,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:30.363513Z","time_out":"2026-08-01T02:54:30.363587Z"} +201641026273700 192 +{"method":"subscribe","result":{"channel":"book","depth":10,"snapshot":true,"symbol":"BTC/USD"},"success":true,"time_in":"2026-08-01T02:54:30.363620Z","time_out":"2026-08-01T02:54:30.363642Z"} +201641104175900 848 +{"channel":"book","type":"snapshot","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01661321},{"price":63003.3,"qty":0.00005100},{"price":63002.6,"qty":0.01602600},{"price":63000.2,"qty":0.00005100},{"price":63000.0,"qty":0.08325683},{"price":62997.0,"qty":0.09705100},{"price":62993.9,"qty":0.00005100},{"price":62991.8,"qty":0.00158700},{"price":62991.6,"qty":0.11000000},{"price":62988.0,"qty":0.04762812}],"asks":[{"price":63005.0,"qty":1.83498144},{"price":63005.1,"qty":0.55006860},{"price":63005.2,"qty":0.79358539},{"price":63005.8,"qty":0.01537966},{"price":63006.0,"qty":0.04761452},{"price":63006.3,"qty":0.23809955},{"price":63006.5,"qty":0.79356896},{"price":63007.3,"qty":0.06000000},{"price":63008.6,"qty":0.00039677},{"price":63009.2,"qty":0.28229589}],"checksum":2528703015,"timestamp":"2026-08-01T02:54:30.381376Z"}]} +201641255899000 23 +{"channel":"heartbeat"} +201641455847000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82298144}],"checksum":4267145282,"timestamp":"2026-08-01T02:54:30.793859Z"}]} +201641624105100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.80547521}],"checksum":194517830,"timestamp":"2026-08-01T02:54:30.958228Z"}]} +201641685211500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.4,"qty":0.00005100}],"checksum":1820162136,"timestamp":"2026-08-01T02:54:31.021934Z"}]} +201642260822800 23 +{"channel":"heartbeat"} +201642853831700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82367769}],"checksum":1644358878,"timestamp":"2026-08-01T02:54:32.188848Z"}]} +201643257729900 23 +{"channel":"heartbeat"} +201643359253700 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.4,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":1991223987,"timestamp":"2026-08-01T02:54:32.695892Z"}]} +201644227540600 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":683883806,"timestamp":"2026-08-01T02:54:33.563628Z"}]} +201644254550800 23 +{"channel":"heartbeat"} +201644699739500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83367769}],"checksum":3248435055,"timestamp":"2026-08-01T02:54:34.035518Z"}]} +201644824846900 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.00000000},{"price":62988.0,"qty":0.04762812}],"asks":[],"checksum":2683549890,"timestamp":"2026-08-01T02:54:34.161956Z"}]} +201645253006000 23 +{"channel":"heartbeat"} +201645493240300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82367769}],"checksum":1991223987,"timestamp":"2026-08-01T02:54:34.830872Z"}]} +201645541699700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.82292204}],"checksum":2541148319,"timestamp":"2026-08-01T02:54:34.874851Z"}]} +201645567779500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":3373944626,"timestamp":"2026-08-01T02:54:34.904175Z"}]} +201646257143300 23 +{"channel":"heartbeat"} +201646294233400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.83392204}],"checksum":535894341,"timestamp":"2026-08-01T02:54:35.631271Z"}]} +201647254671600 23 +{"channel":"heartbeat"} +201647517036300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.34316739}],"checksum":3002726964,"timestamp":"2026-08-01T02:54:36.852422Z"}]} +201647895863800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.33216739}],"checksum":2118765478,"timestamp":"2026-08-01T02:54:37.231592Z"}]} +201648258138900 23 +{"channel":"heartbeat"} +201648374345100 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.00000000},{"price":62988.0,"qty":0.04762812}],"asks":[],"checksum":539319307,"timestamp":"2026-08-01T02:54:37.710825Z"}]} +201648718547700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.34416739}],"checksum":1385279883,"timestamp":"2026-08-01T02:54:38.056294Z"}]} +201649257511400 23 +{"channel":"heartbeat"} +201649452349600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.94157637}],"checksum":1231223527,"timestamp":"2026-08-01T02:54:38.786093Z"}]} +201649534877900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92957637}],"checksum":1978841971,"timestamp":"2026-08-01T02:54:38.868031Z"}]} +201649575754800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92927926}],"checksum":3001702467,"timestamp":"2026-08-01T02:54:38.909877Z"}]} +201650256773100 23 +{"channel":"heartbeat"} +201650311349800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.94127926}],"checksum":2390366679,"timestamp":"2026-08-01T02:54:39.644627Z"}]} +201651264220200 23 +{"channel":"heartbeat"} +201651924594300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92927926}],"checksum":3001702467,"timestamp":"2026-08-01T02:54:41.261100Z"}]} +201652256139600 23 +{"channel":"heartbeat"} +201652846960000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.2,"qty":0.72362788}],"checksum":590713688,"timestamp":"2026-08-01T02:54:42.183771Z"}]} +201652921894100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":0.00222061}],"checksum":2127937945,"timestamp":"2026-08-01T02:54:42.258839Z"}]} +201653255665400 23 +{"channel":"heartbeat"} +201653548727500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":3001702467,"timestamp":"2026-08-01T02:54:42.886077Z"}]} +201654255352000 23 +{"channel":"heartbeat"} +201654506080300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92790608}],"checksum":3505752551,"timestamp":"2026-08-01T02:54:43.842519Z"}]} +201655261259000 23 +{"channel":"heartbeat"} +201655576869500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":0.00222061}],"checksum":1699822117,"timestamp":"2026-08-01T02:54:44.912004Z"}]} +201656256133400 23 +{"channel":"heartbeat"} +201656365846200 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":993897864,"timestamp":"2026-08-01T02:54:45.702908Z"}]} +201656389113800 179 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.69594396}],"checksum":5221294,"timestamp":"2026-08-01T02:54:45.714801Z"}]} +201656498589900 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92790608}],"checksum":993897864,"timestamp":"2026-08-01T02:54:45.834511Z"}]} +201657254965100 23 +{"channel":"heartbeat"} +201658075982600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.92788312}],"checksum":1875415800,"timestamp":"2026-08-01T02:54:47.412968Z"}]} +201658255823300 23 +{"channel":"heartbeat"} +201658326819000 179 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.93888312}],"checksum":4550478,"timestamp":"2026-08-01T02:54:47.656999Z"}]} +201658452667300 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.3,"qty":0.00000000},{"price":63009.2,"qty":0.28229589}],"checksum":3437630513,"timestamp":"2026-08-01T02:54:47.789057Z"}]} +201658452712400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.78017305}],"checksum":381453949,"timestamp":"2026-08-01T02:54:47.789086Z"}]} +201658522175900 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.1,"qty":0.00000000},{"price":63009.3,"qty":0.79353387}],"checksum":3847250326,"timestamp":"2026-08-01T02:54:47.856817Z"}]} +201658644207600 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.77977625},{"price":63005.0,"qty":1.77874605}],"checksum":3548299426,"timestamp":"2026-08-01T02:54:47.972644Z"}]} +201658743376700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.77914285}],"checksum":1486872909,"timestamp":"2026-08-01T02:54:48.080051Z"}]} +201658794715700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.77953965}],"checksum":3086741841,"timestamp":"2026-08-01T02:54:48.131496Z"}]} +201658853824800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.77993645}],"checksum":3244200783,"timestamp":"2026-08-01T02:54:48.185780Z"}]} +201658881919400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.0,"qty":0.00000000},{"price":63009.5,"qty":0.00005100}],"checksum":2519650159,"timestamp":"2026-08-01T02:54:48.218499Z"}]} +201658886826800 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.2,"qty":0.00000000},{"price":63009.6,"qty":0.88861000}],"checksum":458053182,"timestamp":"2026-08-01T02:54:48.218941Z"}]} +201658886861900 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.1,"qty":0.00000000},{"price":63009.7,"qty":0.00056500}],"checksum":454041965,"timestamp":"2026-08-01T02:54:48.219232Z"}]} +201658886883000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.75741678}],"checksum":1749746459,"timestamp":"2026-08-01T02:54:48.219294Z"}]} +201658886899600 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63002.6,"qty":0.00000000},{"price":62988.0,"qty":0.04762812}],"asks":[],"checksum":3777798240,"timestamp":"2026-08-01T02:54:48.219336Z"}]} +201658893374400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":1.16000780}],"checksum":475899707,"timestamp":"2026-08-01T02:54:48.219511Z"}]} +201658893408000 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.8,"qty":0.00000000},{"price":63010.0,"qty":0.00072868}],"checksum":1943263744,"timestamp":"2026-08-01T02:54:48.219696Z"}]} +201658893431200 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.03166451}],"asks":[],"checksum":606114522,"timestamp":"2026-08-01T02:54:48.219883Z"}]} +201658893449400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.6,"qty":0.00000000},{"price":63010.4,"qty":0.07670054}],"checksum":1837447706,"timestamp":"2026-08-01T02:54:48.219987Z"}]} +201658893469700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.65235880}],"checksum":3990175972,"timestamp":"2026-08-01T02:54:48.220495Z"}]} +201658907168400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.65077162}],"checksum":958138846,"timestamp":"2026-08-01T02:54:48.223894Z"}]} +201658907227500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.41880950}],"checksum":2193806376,"timestamp":"2026-08-01T02:54:48.224600Z"}]} +201658907245300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.35304986}],"checksum":4137671165,"timestamp":"2026-08-01T02:54:48.225573Z"}]} +201658907262300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.30184986}],"checksum":592257692,"timestamp":"2026-08-01T02:54:48.229735Z"}]} +201658907278200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.3,"qty":0.59740898}],"asks":[],"checksum":2869459521,"timestamp":"2026-08-01T02:54:48.232231Z"}]} +201658918722400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.27578044}],"asks":[],"checksum":428975280,"timestamp":"2026-08-01T02:54:48.237630Z"}]} +201658918761200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.1,"qty":0.00158967}],"checksum":3202837901,"timestamp":"2026-08-01T02:54:48.237942Z"}]} +201658918777600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.28364738}],"checksum":1283455386,"timestamp":"2026-08-01T02:54:48.238268Z"}]} +201658918792700 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.6,"qty":0.00000000},{"price":63010.4,"qty":0.07670054}],"checksum":663018415,"timestamp":"2026-08-01T02:54:48.239223Z"}]} +201658918810500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.1,"qty":0.00000000},{"price":63011.4,"qty":1.58751729}],"checksum":3645550100,"timestamp":"2026-08-01T02:54:48.239618Z"}]} +201658918828000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.28404418}],"checksum":751431816,"timestamp":"2026-08-01T02:54:48.240366Z"}]} +201658918842500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62991.7,"qty":0.03283500}],"asks":[],"checksum":3869980277,"timestamp":"2026-08-01T02:54:48.242774Z"}]} +201658918857400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.08404418}],"checksum":2726266193,"timestamp":"2026-08-01T02:54:48.243725Z"}]} +201658947120800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.4,"qty":0.17832658}],"asks":[],"checksum":3315697734,"timestamp":"2026-08-01T02:54:48.246276Z"}]} +201658947162200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.5,"qty":0.00000000},{"price":63012.7,"qty":0.00005100}],"checksum":3900870852,"timestamp":"2026-08-01T02:54:48.246603Z"}]} +201658947183400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.4,"qty":0.00000000},{"price":63013.5,"qty":0.00039674}],"checksum":3385746136,"timestamp":"2026-08-01T02:54:48.247136Z"}]} +201658947203100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.30748044}],"asks":[],"checksum":1743847766,"timestamp":"2026-08-01T02:54:48.248147Z"}]} +201658947219400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.3,"qty":0.00000000},{"price":63014.5,"qty":0.09861692}],"checksum":2802183574,"timestamp":"2026-08-01T02:54:48.251772Z"}]} +201658947238400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.5,"qty":0.53978245}],"checksum":2376267654,"timestamp":"2026-08-01T02:54:48.253434Z"}]} +201658947254200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.33848044}],"asks":[],"checksum":3895503347,"timestamp":"2026-08-01T02:54:48.254269Z"}]} +201658947323200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.2,"qty":0.00000000},{"price":63015.8,"qty":0.00005100}],"checksum":1416214282,"timestamp":"2026-08-01T02:54:48.254415Z"}]} +201658947342800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.05304418}],"checksum":135093734,"timestamp":"2026-08-01T02:54:48.258950Z"}]} +201658947359000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.25304418}],"checksum":1736466372,"timestamp":"2026-08-01T02:54:48.259036Z"}]} +201658959911900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24764418}],"checksum":2042621148,"timestamp":"2026-08-01T02:54:48.263402Z"}]} +201658959950300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.3,"qty":0.85355998}],"checksum":1425285237,"timestamp":"2026-08-01T02:54:48.264802Z"}]} +201658959968400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.7,"qty":0.79354268}],"checksum":1100915802,"timestamp":"2026-08-01T02:54:48.265165Z"}]} +201658959985500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.8,"qty":0.79365290}],"asks":[],"checksum":2989418461,"timestamp":"2026-08-01T02:54:48.265317Z"}]} +201658960002500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.0,"qty":0.79367500}],"asks":[],"checksum":3816788770,"timestamp":"2026-08-01T02:54:48.265851Z"}]} +201658960019100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62995.6,"qty":0.79370517}],"asks":[],"checksum":2117881262,"timestamp":"2026-08-01T02:54:48.265989Z"}]} +201658960035300 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63013.5,"qty":0.00000000},{"price":63016.3,"qty":0.14511965}],"checksum":297334617,"timestamp":"2026-08-01T02:54:48.267020Z"}]} +201658960055200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.3,"qty":0.00000000},{"price":62993.9,"qty":0.00005100}],"asks":[],"checksum":2518097390,"timestamp":"2026-08-01T02:54:48.267351Z"}]} +201658960075400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.8,"qty":0.00000000},{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":1173221895,"timestamp":"2026-08-01T02:54:48.273987Z"}]} +201658960095300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.04764418}],"checksum":2507384171,"timestamp":"2026-08-01T02:54:48.274735Z"}]} +201658960111800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.5,"qty":0.05120000}],"asks":[],"checksum":2459039106,"timestamp":"2026-08-01T02:54:48.275396Z"}]} +201658990532300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.4,"qty":0.39244887}],"asks":[],"checksum":1215752892,"timestamp":"2026-08-01T02:54:48.279780Z"}]} +201658990585600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.4,"qty":0.17832658}],"asks":[],"checksum":2459039106,"timestamp":"2026-08-01T02:54:48.279814Z"}]} +201658990610600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24764418}],"checksum":1108321006,"timestamp":"2026-08-01T02:54:48.281144Z"}]} +201658990634300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.3,"qty":0.06000000}],"checksum":3661624138,"timestamp":"2026-08-01T02:54:48.282760Z"}]} +201658990657000 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.0,"qty":0.00000000},{"price":62992.1,"qty":0.05120000}],"asks":[],"checksum":2240896611,"timestamp":"2026-08-01T02:54:48.283316Z"}]} +201658990739600 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62995.6,"qty":0.00000000},{"price":62991.8,"qty":0.00158700}],"asks":[],"checksum":1343845841,"timestamp":"2026-08-01T02:54:48.283325Z"}]} +201658990761600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63013.0,"qty":0.23196212}],"checksum":4075447549,"timestamp":"2026-08-01T02:54:48.284033Z"}]} +201658990779000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63001.6,"qty":0.59740898}],"asks":[],"checksum":3823654712,"timestamp":"2026-08-01T02:54:48.287117Z"}]} +201658990796400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.4,"qty":0.00000000},{"price":62991.8,"qty":0.00158700}],"asks":[],"checksum":3038938864,"timestamp":"2026-08-01T02:54:48.287557Z"}]} +201658990817700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.23664418}],"checksum":3737007536,"timestamp":"2026-08-01T02:54:48.287829Z"}]} +201658990834700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.09436451}],"asks":[],"checksum":1101389247,"timestamp":"2026-08-01T02:54:48.290712Z"}]} +201658990852400 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63013.0,"qty":0.00000000},{"price":63016.3,"qty":0.14511965},{"price":63014.4,"qty":0.23196212}],"checksum":2511376668,"timestamp":"2026-08-01T02:54:48.291030Z"}]} +201658990877200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.20490067}],"checksum":3388759719,"timestamp":"2026-08-01T02:54:48.291762Z"}]} +201658990894700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21030067}],"checksum":1484553225,"timestamp":"2026-08-01T02:54:48.293936Z"}]} +201658990911800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.06266451}],"asks":[],"checksum":3804168079,"timestamp":"2026-08-01T02:54:48.293985Z"}]} +201658990929300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21069747}],"checksum":2693643230,"timestamp":"2026-08-01T02:54:48.296178Z"}]} +201658990946600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.03166451}],"asks":[],"checksum":3065878370,"timestamp":"2026-08-01T02:54:48.296764Z"}]} +201658990964100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.9,"qty":0.79353970}],"checksum":231500427,"timestamp":"2026-08-01T02:54:48.297539Z"}]} +201658990981400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24169747}],"checksum":1528880504,"timestamp":"2026-08-01T02:54:48.300565Z"}]} +201658990998300 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63001.6,"qty":0.00000000},{"price":62991.7,"qty":0.73947512}],"asks":[],"checksum":1725267744,"timestamp":"2026-08-01T02:54:48.300716Z"}]} +201658991019700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.06266451}],"asks":[],"checksum":1894077340,"timestamp":"2026-08-01T02:54:48.301231Z"}]} +201658991037200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24194769}],"checksum":3502595556,"timestamp":"2026-08-01T02:54:48.303011Z"}]} +201659011928000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.03166451}],"asks":[],"checksum":3337974104,"timestamp":"2026-08-01T02:54:48.306610Z"}]} +201659011969200 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.1,"qty":0.02896972}],"asks":[],"checksum":216521960,"timestamp":"2026-08-01T02:54:48.308800Z"}]} +201659011986400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":1402211549,"timestamp":"2026-08-01T02:54:48.309292Z"}]} +201659012055200 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.4,"qty":0.00000000},{"price":63015.8,"qty":0.00005100},{"price":63013.0,"qty":0.23196212}],"checksum":3239209711,"timestamp":"2026-08-01T02:54:48.313007Z"}]} +201659012077300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63010.4,"qty":0.03174078}],"checksum":427331718,"timestamp":"2026-08-01T02:54:48.316004Z"}]} +201659012092400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63003.1,"qty":0.59740898}],"asks":[],"checksum":2260645535,"timestamp":"2026-08-01T02:54:48.318260Z"}]} +201659012108400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62994.0,"qty":0.04762359}],"asks":[],"checksum":2749230350,"timestamp":"2026-08-01T02:54:48.320360Z"}]} +201659012124300 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63013.0,"qty":0.00000000},{"price":63015.8,"qty":0.00005100},{"price":63014.4,"qty":0.23196212}],"checksum":3934056340,"timestamp":"2026-08-01T02:54:48.320377Z"}]} +201659012145600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62994.1,"qty":0.02951407}],"asks":[],"checksum":1126380722,"timestamp":"2026-08-01T02:54:48.324343Z"}]} +201659012167800 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.9,"qty":0.00000000},{"price":63015.4,"qty":0.88861100}],"checksum":2364325906,"timestamp":"2026-08-01T02:54:48.324813Z"}]} +201659012186400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.71863269}],"checksum":2242271850,"timestamp":"2026-08-01T02:54:48.324904Z"}]} +201659012201300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.5,"qty":0.09861692}],"checksum":2910713130,"timestamp":"2026-08-01T02:54:48.326236Z"}]} +201659012216300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.74963269}],"checksum":415739609,"timestamp":"2026-08-01T02:54:48.327345Z"}]} +201659012231400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.5,"qty":0.00000000},{"price":63015.8,"qty":0.00005100}],"checksum":1621432620,"timestamp":"2026-08-01T02:54:48.327627Z"}]} +201659012249600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63015.3,"qty":0.81997216}],"checksum":2260090464,"timestamp":"2026-08-01T02:54:48.328740Z"}]} +201659016237100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24194769}],"checksum":2415698968,"timestamp":"2026-08-01T02:54:48.341816Z"}]} +201659016268800 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.4,"qty":0.00000000},{"price":63015.8,"qty":0.00005100},{"price":63012.5,"qty":0.23196212}],"checksum":2004890606,"timestamp":"2026-08-01T02:54:48.342191Z"}]} +201659016293600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":1400973843,"timestamp":"2026-08-01T02:54:48.343913Z"}]} +201659016310500 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.15372248}],"asks":[],"checksum":314487858,"timestamp":"2026-08-01T02:54:48.344326Z"}]} +201659016327700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21069747}],"checksum":2911474182,"timestamp":"2026-08-01T02:54:48.344644Z"}]} +201659016344500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.18472248}],"asks":[],"checksum":3418493364,"timestamp":"2026-08-01T02:54:48.347475Z"}]} +201659024452900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.01069747}],"checksum":3134198906,"timestamp":"2026-08-01T02:54:48.350972Z"}]} +201659024601400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.20345192}],"asks":[],"checksum":1484028204,"timestamp":"2026-08-01T02:54:48.352092Z"}]} +201659024619400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.23515192}],"asks":[],"checksum":851786695,"timestamp":"2026-08-01T02:54:48.354484Z"}]} +201659024636200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.00529747}],"checksum":1744287419,"timestamp":"2026-08-01T02:54:48.354652Z"}]} +201659024653000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.00554769}],"checksum":2046095574,"timestamp":"2026-08-01T02:54:48.355861Z"}]} +201659024670300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.11309395}],"asks":[],"checksum":4273816769,"timestamp":"2026-08-01T02:54:48.356002Z"}]} +201659024686300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.20554769}],"checksum":1396805191,"timestamp":"2026-08-01T02:54:48.358279Z"}]} +201659030614800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":2074717226,"timestamp":"2026-08-01T02:54:48.361831Z"}]} +201659030642400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63015.3,"qty":1.16609074}],"checksum":1890200200,"timestamp":"2026-08-01T02:54:48.361894Z"}]} +201659030658800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.08209395}],"asks":[],"checksum":458432815,"timestamp":"2026-08-01T02:54:48.362083Z"}]} +201659030674400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63015.3,"qty":0.34611858}],"checksum":3122860510,"timestamp":"2026-08-01T02:54:48.362115Z"}]} +201659030689100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.05039395}],"asks":[],"checksum":264556044,"timestamp":"2026-08-01T02:54:48.362167Z"}]} +201659030704000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.08139395}],"asks":[],"checksum":3300443608,"timestamp":"2026-08-01T02:54:48.363595Z"}]} +201659030719100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.24194769}],"checksum":3770276901,"timestamp":"2026-08-01T02:54:48.364523Z"}]} +201659030733800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.05039395}],"asks":[],"checksum":734386161,"timestamp":"2026-08-01T02:54:48.364869Z"}]} +201659030748400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":264556044,"timestamp":"2026-08-01T02:54:48.365929Z"}]} +201659044464000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.4,"qty":0.79358284}],"checksum":118034796,"timestamp":"2026-08-01T02:54:48.380489Z"}]} +201659044504300 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.4,"qty":0.79365794}],"asks":[],"checksum":810344239,"timestamp":"2026-08-01T02:54:48.380788Z"}]} +201659051556900 251 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.5,"qty":0.00000000},{"price":63015.4,"qty":0.88861100},{"price":63010.8,"qty":0.23196212}],"checksum":251223905,"timestamp":"2026-08-01T02:54:48.382160Z"}]} +201659051596600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.2,"qty":0.01574458}],"checksum":1702603134,"timestamp":"2026-08-01T02:54:48.383911Z"}]} +201659051636500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.01094769}],"checksum":3338399290,"timestamp":"2026-08-01T02:54:48.384204Z"}]} +201659052666400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.08209395}],"asks":[],"checksum":2928917021,"timestamp":"2026-08-01T02:54:48.386176Z"}]} +201659052693400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.00554769}],"checksum":2093731339,"timestamp":"2026-08-01T02:54:48.386201Z"}]} +201659052711100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.11309395}],"asks":[],"checksum":1453197701,"timestamp":"2026-08-01T02:54:48.386808Z"}]} +201659052728400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.20554769}],"checksum":4217553667,"timestamp":"2026-08-01T02:54:48.388350Z"}]} +201659059500300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.6,"qty":0.01100000}],"checksum":1614030582,"timestamp":"2026-08-01T02:54:48.389212Z"}]} +201659059531500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.08209395}],"asks":[],"checksum":3584969633,"timestamp":"2026-08-01T02:54:48.390283Z"}]} +201659059550200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.05039395}],"asks":[],"checksum":3183878022,"timestamp":"2026-08-01T02:54:48.390605Z"}]} +201659059566900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":1465707710,"timestamp":"2026-08-01T02:54:48.391068Z"}]} +201659062678100 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63010.8,"qty":0.00000000},{"price":63014.2,"qty":0.01574458},{"price":63011.6,"qty":0.23196212}],"checksum":3066989639,"timestamp":"2026-08-01T02:54:48.396076Z"}]} +201659062715200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.17245192}],"asks":[],"checksum":4254354192,"timestamp":"2026-08-01T02:54:48.398198Z"}]} +201659062733600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21069747}],"checksum":1118006564,"timestamp":"2026-08-01T02:54:48.398425Z"}]} +201659070648000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.20345192}],"asks":[],"checksum":2274213046,"timestamp":"2026-08-01T02:54:48.399702Z"}]} +201659070686800 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.6,"qty":0.00000000},{"price":63014.2,"qty":0.01574458},{"price":63013.0,"qty":0.23196212}],"checksum":4203099370,"timestamp":"2026-08-01T02:54:48.402366Z"}]} +201659070710800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21094769}],"checksum":1169232606,"timestamp":"2026-08-01T02:54:48.403214Z"}]} +201659070727200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.08139395}],"asks":[],"checksum":1001196302,"timestamp":"2026-08-01T02:54:48.403300Z"}]} +201659074723900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.05039395}],"asks":[],"checksum":3134742984,"timestamp":"2026-08-01T02:54:48.405876Z"}]} +201659080244500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.21055089}],"checksum":3047690015,"timestamp":"2026-08-01T02:54:48.412105Z"}]} +201659082793700 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.4,"qty":0.00000000},{"price":62994.0,"qty":0.04762359}],"asks":[],"checksum":2263480638,"timestamp":"2026-08-01T02:54:48.416279Z"}]} +201659082825200 252 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63013.0,"qty":0.00000000},{"price":63014.2,"qty":0.01574458},{"price":63010.3,"qty":0.23196212}],"checksum":3041158240,"timestamp":"2026-08-01T02:54:48.419047Z"}]} +201659105981200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.71823589}],"checksum":1729632135,"timestamp":"2026-08-01T02:54:48.441603Z"}]} +201659111818900 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.74923589}],"checksum":3533851764,"timestamp":"2026-08-01T02:54:48.443709Z"}]} +201659124285500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63010.3,"qty":0.00000000},{"price":63014.2,"qty":0.01574458}],"checksum":1069917170,"timestamp":"2026-08-01T02:54:48.458688Z"}]} +201659144912700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.74883909}],"checksum":3061409825,"timestamp":"2026-08-01T02:54:48.472709Z"}]} +201659154735400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.4,"qty":0.00000000},{"price":63015.3,"qty":0.34611858}],"checksum":4244753481,"timestamp":"2026-08-01T02:54:48.479164Z"}]} +201659154775100 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.3,"qty":0.00000000},{"price":63015.4,"qty":0.88861100}],"checksum":2067383908,"timestamp":"2026-08-01T02:54:48.479549Z"}]} +201659155013400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.6,"qty":0.06000000}],"checksum":3530294140,"timestamp":"2026-08-01T02:54:48.480392Z"}]} +201659155041100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.2,"qty":0.79357338}],"checksum":408064782,"timestamp":"2026-08-01T02:54:48.480628Z"}]} +201659155057200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.6,"qty":0.79366750}],"asks":[],"checksum":3547981443,"timestamp":"2026-08-01T02:54:48.480948Z"}]} +201659155073000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.7,"qty":0.25219295}],"asks":[],"checksum":688175582,"timestamp":"2026-08-01T02:54:48.482199Z"}]} +201659164876500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.6,"qty":0.25257225}],"checksum":2785853945,"timestamp":"2026-08-01T02:54:48.495175Z"}]} +201659164916600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.2,"qty":0.01574458}],"checksum":2202169519,"timestamp":"2026-08-01T02:54:48.498842Z"}]} +201659164937000 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63014.2,"qty":0.00000000},{"price":63014.6,"qty":0.06000000}],"checksum":1916759355,"timestamp":"2026-08-01T02:54:48.499449Z"}]} +201659191099900 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63003.1,"qty":0.00000000},{"price":62996.5,"qty":0.06219499}],"asks":[],"checksum":2930526977,"timestamp":"2026-08-01T02:54:48.526680Z"}]} +201659191146000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.3,"qty":0.59740898}],"checksum":2504294081,"timestamp":"2026-08-01T02:54:48.527109Z"}]} +201659193612600 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.6,"qty":0.01100000}],"checksum":664067554,"timestamp":"2026-08-01T02:54:48.528501Z"}]} +201659196454700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.2,"qty":0.42318233}],"checksum":4275537367,"timestamp":"2026-08-01T02:54:48.529857Z"}]} +201659196486400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.74844229}],"checksum":1915009234,"timestamp":"2026-08-01T02:54:48.531807Z"}]} +201659259829200 23 +{"channel":"heartbeat"} +201659278237200 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.0,"qty":0.74804549}],"checksum":164114751,"timestamp":"2026-08-01T02:54:48.589097Z"}]} +201659278300500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.3,"qty":0.00000000},{"price":63012.7,"qty":0.79354268}],"checksum":1062470122,"timestamp":"2026-08-01T02:54:48.592851Z"}]} +201659278320800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.7,"qty":0.84960193}],"asks":[],"checksum":2051982185,"timestamp":"2026-08-01T02:54:48.593136Z"}]} +201659278336500 216 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63011.2,"qty":0.00000000},{"price":63014.6,"qty":0.06000000}],"checksum":951458292,"timestamp":"2026-08-01T02:54:48.594198Z"}]} +201659278354500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.6,"qty":0.24787249}],"checksum":2810009384,"timestamp":"2026-08-01T02:54:48.596262Z"}]} +201659278369400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.6,"qty":0.00000000},{"price":62994.1,"qty":0.02951407}],"asks":[],"checksum":3635455413,"timestamp":"2026-08-01T02:54:48.599905Z"}]} +201659278388100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.8,"qty":0.79355276}],"checksum":764638453,"timestamp":"2026-08-01T02:54:48.601317Z"}]} +201659278403000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.3,"qty":0.79365839}],"asks":[],"checksum":3982731161,"timestamp":"2026-08-01T02:54:48.604324Z"}]} +201659278418200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.7,"qty":0.59740898}],"asks":[],"checksum":4102473163,"timestamp":"2026-08-01T02:54:48.605653Z"}]} +201659278433000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.7,"qty":0.79369186}],"asks":[],"checksum":3680179701,"timestamp":"2026-08-01T02:54:48.606073Z"}]} +201659284692300 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.1,"qty":0.28074823}],"asks":[],"checksum":3640568384,"timestamp":"2026-08-01T02:54:48.609082Z"}]} +201659284726400 180 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.6,"qty":0.47983461}],"checksum":15375418,"timestamp":"2026-08-01T02:54:48.617375Z"}]} +201659297364700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63010.0,"qty":0.01647326}],"checksum":1993717295,"timestamp":"2026-08-01T02:54:48.630082Z"}]} +201659297399200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63012.2,"qty":0.00000000},{"price":63014.6,"qty":0.06000000}],"checksum":3582836098,"timestamp":"2026-08-01T02:54:48.630151Z"}]} +201659357793300 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63003.3,"qty":0.00000000},{"price":62996.5,"qty":0.06219499}],"asks":[],"checksum":1576724548,"timestamp":"2026-08-01T02:54:48.685734Z"}]} +201659357843200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.4,"qty":0.00005100}],"checksum":4100362982,"timestamp":"2026-08-01T02:54:48.686300Z"}]} +201659361709100 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62999.3,"qty":0.00000000},{"price":62994.1,"qty":0.26267262}],"asks":[],"checksum":1899149859,"timestamp":"2026-08-01T02:54:48.699573Z"}]} +201659364503000 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.2,"qty":0.00000000},{"price":63014.6,"qty":0.06000000}],"checksum":2286835443,"timestamp":"2026-08-01T02:54:48.700067Z"}]} +201659365347000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.5,"qty":0.79358163}],"checksum":661274405,"timestamp":"2026-08-01T02:54:48.701426Z"}]} +201659365368700 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.6,"qty":0.79366815}],"asks":[],"checksum":778979495,"timestamp":"2026-08-01T02:54:48.702179Z"}]} +201659388389800 215 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.7,"qty":0.00000000},{"price":63014.6,"qty":0.06000000}],"checksum":31557168,"timestamp":"2026-08-01T02:54:48.724536Z"}]} +201659563608600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63010.0,"qty":0.00072868}],"checksum":2178170945,"timestamp":"2026-08-01T02:54:48.900279Z"}]} +201659563651600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.7,"qty":0.01574458}],"checksum":3433029200,"timestamp":"2026-08-01T02:54:48.900525Z"}]} +201659570619700 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.7,"qty":0.00000000},{"price":62994.1,"qty":0.26267262}],"asks":[],"checksum":2011090291,"timestamp":"2026-08-01T02:54:48.907750Z"}]} +201659571569100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.7,"qty":0.59740898}],"checksum":3004260782,"timestamp":"2026-08-01T02:54:48.908110Z"}]} +201659697674200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63000.2,"qty":0.00163829}],"asks":[],"checksum":2919678597,"timestamp":"2026-08-01T02:54:49.035359Z"}]} +201659780708400 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.6,"qty":0.00000000},{"price":62994.0,"qty":0.04762359}],"asks":[],"checksum":3269061519,"timestamp":"2026-08-01T02:54:49.117273Z"}]} +201659781340300 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62996.7,"qty":0.00000000},{"price":62993.9,"qty":0.00005100}],"asks":[],"checksum":3215290055,"timestamp":"2026-08-01T02:54:49.117652Z"}]} +201659782634400 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.1,"qty":0.02896972}],"asks":[],"checksum":2441810807,"timestamp":"2026-08-01T02:54:49.120224Z"}]} +201659782664100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.7,"qty":0.79367853}],"asks":[],"checksum":541760740,"timestamp":"2026-08-01T02:54:49.120237Z"}]} +201659782681700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62995.6,"qty":0.79370536}],"asks":[],"checksum":3662237032,"timestamp":"2026-08-01T02:54:49.120268Z"}]} +201659785217200 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.1,"qty":0.00000000},{"price":62994.0,"qty":0.04762359}],"asks":[],"checksum":1500312231,"timestamp":"2026-08-01T02:54:49.121908Z"}]} +201659785250700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.8,"qty":0.28444797}],"asks":[],"checksum":1691415095,"timestamp":"2026-08-01T02:54:49.122044Z"}]} +201659831981000 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.7,"qty":0.00000000},{"price":62994.0,"qty":0.04762359}],"asks":[],"checksum":1767709429,"timestamp":"2026-08-01T02:54:49.169293Z"}]} +201659850241200 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.03166451}],"asks":[],"checksum":3660764626,"timestamp":"2026-08-01T02:54:49.186210Z"}]} +201659905299100 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62994.7,"qty":0.72000000}],"asks":[],"checksum":1270410963,"timestamp":"2026-08-01T02:54:49.242217Z"}]} +201659923556800 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.0,"qty":0.00158708}],"checksum":875275250,"timestamp":"2026-08-01T02:54:49.259647Z"}]} +201659925954500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.8,"qty":1.07812577}],"asks":[],"checksum":1821201612,"timestamp":"2026-08-01T02:54:49.264042Z"}]} +201659935845000 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63003.3,"qty":0.00005100}],"asks":[],"checksum":4249901719,"timestamp":"2026-08-01T02:54:49.273917Z"}]} +201659972161800 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63008.7,"qty":0.00000000},{"price":63012.6,"qty":0.47983461}],"checksum":3675470500,"timestamp":"2026-08-01T02:54:49.306620Z"}]} +201659972206800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63004.9,"qty":0.01661321}],"asks":[],"checksum":3831443536,"timestamp":"2026-08-01T02:54:49.306754Z"}]} +201659972223500 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.1,"qty":0.01574458}],"checksum":3988424158,"timestamp":"2026-08-01T02:54:49.306952Z"}]} +201659975255100 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":63003.6,"qty":0.01505130}],"asks":[],"checksum":970924028,"timestamp":"2026-08-01T02:54:49.308227Z"}]} +201660008866500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.8,"qty":0.00000000},{"price":63012.6,"qty":0.47983461}],"checksum":4259346833,"timestamp":"2026-08-01T02:54:49.344982Z"}]} +201660008919800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.7,"qty":0.79356707}],"checksum":3045367171,"timestamp":"2026-08-01T02:54:49.346729Z"}]} +201660238180800 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.4,"qty":0.65680545}],"checksum":3571477087,"timestamp":"2026-08-01T02:54:49.574756Z"}]} +201660259271700 23 +{"channel":"heartbeat"} +201660271570700 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63009.9,"qty":0.00158706}],"checksum":2727411365,"timestamp":"2026-08-01T02:54:49.589210Z"}]} +201661161350700 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.8,"qty":0.28444797}],"asks":[],"checksum":623763228,"timestamp":"2026-08-01T02:54:50.498577Z"}]} +201661162096100 180 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62998.5,"qty":0.84486940}],"asks":[],"checksum":82783027,"timestamp":"2026-08-01T02:54:50.500109Z"}]} +201661260546400 23 +{"channel":"heartbeat"} +201661516463400 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[{"price":62997.7,"qty":0.59740898}],"asks":[],"checksum":457353090,"timestamp":"2026-08-01T02:54:50.851640Z"}]} +201662258728900 23 +{"channel":"heartbeat"} +201663261165100 23 +{"channel":"heartbeat"} +201663271887800 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63007.7,"qty":0.00000000},{"price":63010.0,"qty":0.00072868}],"checksum":2609558147,"timestamp":"2026-08-01T02:54:52.601118Z"}]} +201663271926500 217 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.4,"qty":0.00000000},{"price":63010.4,"qty":0.03174078}],"checksum":3172479514,"timestamp":"2026-08-01T02:54:52.603667Z"}]} +201663271943000 181 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63005.4,"qty":0.47847887}],"checksum":376238870,"timestamp":"2026-08-01T02:54:52.604639Z"}]} +201663927798600 182 +{"channel":"book","type":"update","data":[{"symbol":"BTC/USD","bids":[],"asks":[{"price":63006.3,"qty":0.23809104}],"checksum":3793782525,"timestamp":"2026-08-01T02:54:53.266327Z"}]} diff --git a/tests/test_capture.cpp b/tests/test_capture.cpp new file mode 100644 index 0000000..75f0a53 --- /dev/null +++ b/tests/test_capture.cpp @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Capture files. +// +// The format exists so a live measurement can be re-run by someone else, which +// only works if the bytes survive the round trip exactly. Every test here is +// ultimately asking the same question: are these the same bytes the venue sent? + +#include + +#include + +#include "crossbook/capture.hpp" + +using namespace crossbook; + +TEST_CASE("Frames round-trip through a capture unchanged") { + Capture capture; + std::string error; + + const std::string contents = + "CBCAP1 kraken BTC/USD 1700000000000000000\n" + "1000 5\n" + "hello\n" + "2500 3\n" + "abc\n"; + + REQUIRE(capture.parse(contents, error)); + CHECK(capture.venue() == "kraken"); + CHECK(capture.symbol() == "BTC/USD"); + CHECK(capture.start_unix_ns() == 1'700'000'000'000'000'000LL); + + REQUIRE(capture.frames().size() == 2); + CHECK(capture.frames()[0].ts_recv == 1000); + CHECK(capture.frames()[0].payload == "hello"); + CHECK(capture.frames()[1].ts_recv == 2500); + CHECK(capture.frames()[1].payload == "abc"); +} + +TEST_CASE("A payload containing a newline stays one frame") { + // The reason the format is length-prefixed rather than line-delimited. A + // venue is entitled to put a newline inside a JSON string, and a + // line-oriented reader would turn one frame into two and corrupt the book + // replayed from it. + Capture capture; + std::string error; + + const std::string payload = "{\"a\":\"line1\nline2\"}"; + const std::string contents = "CBCAP1 test SYM 0\n1 " + std::to_string(payload.size()) + "\n" + + payload + "\n"; + + REQUIRE(capture.parse(contents, error)); + REQUIRE(capture.frames().size() == 1); + CHECK(capture.frames()[0].payload == payload); +} + +TEST_CASE("An empty frame is preserved") { + Capture capture; + std::string error; + REQUIRE(capture.parse("CBCAP1 test SYM 0\n1 0\n\n", error)); + REQUIRE(capture.frames().size() == 1); + CHECK(capture.frames()[0].payload.empty()); +} + +TEST_CASE("A capture cut short mid-frame loads as its valid prefix") { + // Ctrl-C during recording leaves exactly this. Refusing to load it would + // throw away every interrupted run, which is most of them. + Capture capture; + std::string error; + + const std::string contents = + "CBCAP1 kraken BTC/USD 0\n" + "1 5\n" + "hello\n" + "2 100\n" + "trunc"; + + REQUIRE(capture.parse(contents, error)); + REQUIRE(capture.frames().size() == 1); + CHECK(capture.frames()[0].payload == "hello"); +} + +TEST_CASE("A file that is not a capture is refused") { + Capture capture; + std::string error; + + CHECK_FALSE(capture.parse("", error)); + CHECK_FALSE(capture.parse("not a capture\n1 2\nab\n", error)); + CHECK_FALSE(capture.parse("CBCAP1\n", error)); + CHECK_FALSE(error.empty()); +} + +TEST_CASE("A malformed record header is an error, not a silent stop") { + Capture capture; + std::string error; + CHECK_FALSE(capture.parse("CBCAP1 v s 0\nnotanumber\nxx\n", error)); +} + +TEST_CASE("The writer produces something the reader accepts") { + const std::string path = + (std::string(CROSSBOOK_TEST_TMP_DIR) + "/crossbook_capture_roundtrip.cbcap"); + + { + CaptureWriter writer; + REQUIRE(writer.open(path, "kraken", "BTC/USD", 1234)); + REQUIRE(writer.write(10, R"({"channel":"book","type":"snapshot"})")); + REQUIRE(writer.write(20, R"({"channel":"book","type":"update"})")); + REQUIRE(writer.write(30, "")); + CHECK(writer.frames() == 3); + } + + Capture capture; + std::string error; + REQUIRE(capture.load(path, error)); + CHECK(capture.venue() == "kraken"); + CHECK(capture.symbol() == "BTC/USD"); + CHECK(capture.start_unix_ns() == 1234); + REQUIRE(capture.frames().size() == 3); + CHECK(capture.frames()[0].payload == R"({"channel":"book","type":"snapshot"})"); + CHECK(capture.frames()[1].payload == R"({"channel":"book","type":"update"})"); + CHECK(capture.frames()[2].payload.empty()); + CHECK(capture.frames()[2].ts_recv == 30); +} + +TEST_CASE("The median gap ignores the bursts a mean would follow") { + Capture capture; + std::string error; + + // Four fast arrivals and one long pause. The mean gap is dominated by the + // pause; the median describes what the feed actually does most of the time. + REQUIRE(capture.parse( + "CBCAP1 v s 0\n" + "0 1\na\n" + "100 1\nb\n" + "200 1\nc\n" + "300 1\nd\n" + "1000300 1\ne\n", + error)); + + CHECK(capture.frames().size() == 5); + CHECK(capture.median_gap_ns() == 100); +} + +TEST_CASE("A capture of one frame has no gap to report") { + Capture capture; + std::string error; + REQUIRE(capture.parse("CBCAP1 v s 0\n5 1\na\n", error)); + CHECK(capture.median_gap_ns() == 0); +} diff --git a/tests/test_depth_trim.cpp b/tests/test_depth_trim.cpp new file mode 100644 index 0000000..442a66a --- /dev/null +++ b/tests/test_depth_trim.cpp @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Depth trimming. +// +// This exists because of a bug that live verification found and no unit test +// would have. Subscribed to Kraken BTC/USD at depth 10, the book grew to 20 bid +// levels over a minute and 4 of 298 checksums failed. +// +// The cause is a gap in the depth-limited contract that is easy to miss: the +// venue reports cancellations, so a naive reader looks correct, but it never +// reports that a level fell out of the window because a better one arrived — +// from its side there is nothing to say. Those orphaned levels sit below the +// checksummed depth doing no harm until removals near the touch promote one back +// into the top ten, and then the checksum fails on an update that was itself +// perfectly fine. +// +// The tests below pin the behaviour; `test_fixture_replay.cpp` pins the +// end-to-end consequence against the recorded capture. + +#include +#include + +#include + +#include "crossbook/book.hpp" + +using namespace crossbook; + +namespace { + +template +std::vector levels(const BookT& book, Side side) { + std::vector out; + book.side(side).for_each([&](const Level& level) { + out.push_back(level); + return true; + }); + return out; +} + +} // namespace + +TEMPLATE_TEST_CASE("Trimming keeps the levels nearest the touch", "[trim]", MapBook, ArrayBook) { + TestType book(InstrumentSpec{"BTC/USD", 1, 8}); + + // Bids descend from the touch, asks ascend. + for (int i = 0; i < 20; ++i) { + book.apply(Side::kBid, Price{630000 - i}, Qty{100 + i}); + book.apply(Side::kAsk, Price{630010 + i}, Qty{200 + i}); + } + REQUIRE(book.bids().size() == 20); + REQUIRE(book.asks().size() == 20); + + CHECK(book.trim(10) == 20); // Ten dropped from each side. + REQUIRE(book.bids().size() == 10); + REQUIRE(book.asks().size() == 10); + + const auto bids = levels(book, Side::kBid); + CHECK(bids.front().price == Price{630000}); // Best bid survives. + CHECK(bids.back().price == Price{630000 - 9}); + const auto asks = levels(book, Side::kAsk); + CHECK(asks.front().price == Price{630010}); + CHECK(asks.back().price == Price{630010 + 9}); +} + +TEMPLATE_TEST_CASE("Trimming a book already within depth changes nothing", "[trim]", MapBook, + ArrayBook) { + TestType book(InstrumentSpec{"BTC/USD", 1, 8}); + for (int i = 0; i < 5; ++i) { + book.apply(Side::kBid, Price{630000 - i}, Qty{1}); + } + const std::uint64_t before = book.state_hash(); + CHECK(book.trim(10) == 0); + CHECK(book.state_hash() == before); +} + +TEMPLATE_TEST_CASE("A depth of zero means a full book and never trims", "[trim]", MapBook, + ArrayBook) { + TestType book(InstrumentSpec{"BTC/USD", 1, 8}); + for (int i = 0; i < 50; ++i) { + book.apply(Side::kBid, Price{630000 - i}, Qty{1}); + } + CHECK(book.trim(0) == 0); + CHECK(book.bids().size() == 50); +} + +TEMPLATE_TEST_CASE("Trimming more levels than one batch can name still converges", "[trim]", + MapBook, ArrayBook) { + // The removal buffer is a fixed 64 entries, so a book this far over depth + // needs several passes. Getting the loop wrong leaves a book that is quietly + // still too deep, which is the exact failure being fixed. + TestType book(InstrumentSpec{"BTC/USD", 1, 8}); + for (int i = 0; i < 500; ++i) { + book.apply(Side::kBid, Price{630000 - i}, Qty{1}); + } + CHECK(book.trim(Side::kBid, 10) == 490); + CHECK(book.bids().size() == 10); + CHECK(levels(book, Side::kBid).front().price == Price{630000}); +} + +TEST_CASE("Both book implementations trim identically", "[trim]") { + // The differential oracle again: the array book is the one that could be + // wrong, and trimming touches its window and its overflow map at once. + MapBook reference(InstrumentSpec{"BTC/USD", 1, 8}); + ArrayBook subject(InstrumentSpec{"BTC/USD", 1, 8}); + + // Prices deliberately spread far enough to push some levels out of the + // array's window and into its overflow map. + for (int i = 0; i < 200; ++i) { + const std::int64_t price = 630000 - (i * 977 % 40000); + reference.apply(Side::kBid, Price{price}, Qty{1 + i}); + subject.apply(Side::kBid, Price{price}, Qty{1 + i}); + } + REQUIRE(reference.state_hash() == subject.state_hash()); + + CHECK(reference.trim(Side::kBid, 10) == subject.trim(Side::kBid, 10)); + CHECK(reference.bids().size() == subject.bids().size()); + CHECK(reference.state_hash() == subject.state_hash()); +} + +TEST_CASE("Trimming removes exactly the levels a top-N view would not show", "[trim]") { + // The scenario from the live failure, in miniature: a book at depth, then a + // new best arrives. The venue sends no removal for the level pushed out, so + // without trimming the book carries N+1 levels and the checksum input is + // wrong the moment the extra one is promoted back into view. + ArrayBook book(InstrumentSpec{"BTC/USD", 1, 8}); + for (int i = 0; i < 10; ++i) { + book.apply(Side::kAsk, Price{630100 + i}, Qty{5}); + } + REQUIRE(book.asks().size() == 10); + + book.apply(Side::kAsk, Price{630099}, Qty{7}); // A new best ask. + CHECK(book.asks().size() == 11); + + CHECK(book.trim(Side::kAsk, 10) == 1); + const auto asks = levels(book, Side::kAsk); + REQUIRE(asks.size() == 10); + CHECK(asks.front().price == Price{630099}); + CHECK(asks.back().price == Price{630108}); // 630109 is gone, as the venue sees it. +} diff --git a/tests/test_fixture_replay.cpp b/tests/test_fixture_replay.cpp new file mode 100644 index 0000000..2cf248e --- /dev/null +++ b/tests/test_fixture_replay.cpp @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// The end-to-end check: a recorded minute of Kraken, replayed offline. +// +// WHY THIS TEST IS THE MOST IMPORTANT ONE IN THE SUITE +// +// Every other test here checks a component against a rule I wrote down. This one +// checks the whole stack against the exchange's own arithmetic, on bytes the +// exchange actually sent, and it does it without a network so it runs on every +// push on every platform. +// +// `tests/fixtures/kraken_btcusd_l2.cbcap` is a verbatim recording of the Kraken v2 +// book channel: no re-encoding, no normalisation, the same bytes crossbook_verify +// received live. Replaying it must reproduce, exactly: +// +// - every one of the 301 checksums Kraken published, matched +// - a book holding exactly the subscribed depth +// - one specific state hash, identical on Windows, Linux and macOS +// +// That last one is the determinism claim. There is no floating point anywhere in +// the book, so there is nothing left that could legitimately differ between +// platforms — if this hash moves, something is wrong, and the test says so +// rather than leaving it to be noticed in production. +// +// The capture is 72 KB and committed. That is the whole point: in equities the +// equivalent data cannot be redistributed, so public order book projects ship +// without runnable data and ask to be believed. Crypto venues impose no such +// restriction, so the claim can simply be checked. + +#include + +#include + +#include "crossbook/capture.hpp" +#include "crossbook/feed.hpp" +#include "crossbook/venues/kraken.hpp" + +using namespace crossbook; + +namespace { + +constexpr const char* kFixture = CROSSBOOK_FIXTURE_DIR "/kraken_btcusd_l2.cbcap"; + +/// Scales and depth as Kraken spelled them in this capture. Hard-coded here +/// rather than inferred, so the test pins the values instead of agreeing with +/// whatever the inference happens to produce. +constexpr Scale kPriceScale = 1; +constexpr Scale kQtyScale = 8; +constexpr std::size_t kDepth = 10; + +} // namespace + +TEST_CASE("The recorded Kraken capture replays with every checksum matching", "[fixture]") { + Capture capture; + std::string error; + REQUIRE(capture.load(kFixture, error)); + INFO("capture load error: " << error); + REQUIRE(capture.venue() == "kraken"); + REQUIRE(capture.symbol() == "BTC/USD"); + REQUIRE(capture.frames().size() > 300); + + Feed feed( + "kraken", venues::KrakenBookDecoder(InstrumentSpec{"BTC/USD", kPriceScale, kQtyScale}), + SequencePolicy::kStrictIncrement, kDepth); + + for (const CapturedFrame& frame : capture.frames()) { + (void)feed.handle(frame.payload); + } + + const FeedStats& stats = feed.stats(); + + // The headline claim, as an assertion rather than a README sentence. + CHECK(stats.checksum_mismatches == 0); + CHECK(stats.rejected == 0); + CHECK(stats.resyncs_requested == 0); + CHECK(feed.synced()); + + // Verifying nothing would satisfy every check above, so require that real + // verification happened. + CHECK(stats.checksums_verified == 301); + CHECK(feed.match_rate() == 1.0); + CHECK(feed.divergences().entries().empty()); + + // The depth contract: a top-10 subscription holds ten levels per side, no + // matter how many distinct prices passed through the window. + CHECK(feed.book().bids().size() == kDepth); + CHECK(feed.book().asks().size() == kDepth); + CHECK(stats.levels_trimmed > 0); +} + +TEST_CASE("Replaying the capture is bit-identical across platforms", "[fixture][determinism]") { + Capture capture; + std::string error; + REQUIRE(capture.load(kFixture, error)); + + Feed feed( + "kraken", venues::KrakenBookDecoder(InstrumentSpec{"BTC/USD", kPriceScale, kQtyScale}), + SequencePolicy::kStrictIncrement, kDepth); + for (const CapturedFrame& frame : capture.frames()) { + (void)feed.handle(frame.payload); + } + + // FNV-1a over the mantissas of every level, in book order. No floating + // point is involved anywhere upstream of this, so a difference here is a + // defect and never a rounding difference. + CHECK(feed.book().state_hash() == 0x080281c2dd87183fULL); +} + +TEST_CASE("Without depth trimming the same capture fails, which is why trimming exists", + "[fixture][trim]") { + // A negative control. If a future change made trimming unnecessary — or made + // it a no-op — this test would start passing for the wrong reason, and the + // check above would no longer be evidence of anything. + Capture capture; + std::string error; + REQUIRE(capture.load(kFixture, error)); + + Feed feed( + "kraken", venues::KrakenBookDecoder(InstrumentSpec{"BTC/USD", kPriceScale, kQtyScale}), + SequencePolicy::kStrictIncrement, /*depth=*/0); + for (const CapturedFrame& frame : capture.frames()) { + (void)feed.handle(frame.payload); + } + + CHECK(feed.stats().checksum_mismatches > 0); + CHECK(feed.book().bids().size() > kDepth); +} diff --git a/tests/test_handshake.cpp b/tests/test_handshake.cpp new file mode 100644 index 0000000..536a514 --- /dev/null +++ b/tests/test_handshake.cpp @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// The opening handshake, checked against published vectors. +// +// SHA-1 and base64 are implemented here rather than depended on, so they are +// tested against the specifications' own answers rather than against +// themselves. A hand-rolled hash that is only checked by round-tripping through +// itself is not checked at all. + +#include + +#include + +#include "crossbook/net/handshake.hpp" + +using namespace crossbook::net; + +namespace { + +std::string hex(const std::array& digest) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.reserve(40); + for (const std::uint8_t byte : digest) { + out.push_back(kHex[(byte >> 4) & 0x0FU]); + out.push_back(kHex[byte & 0x0FU]); + } + return out; +} + +std::string b64(std::string_view text) { + return base64_encode(reinterpret_cast(text.data()), text.size()); +} + +} // namespace + +TEST_CASE("SHA-1 matches the FIPS 180-4 vectors") { + CHECK(hex(sha1("")) == "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + CHECK(hex(sha1("abc")) == "a9993e364706816aba3e25717850c26c9cd0d89d"); + CHECK(hex(sha1("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq")) == + "84983e441c3bd26ebaae4aa1f95129e5e54670f1"); + + // A million 'a' characters: the vector that catches a broken length field, + // since the bit count no longer fits in the low word. + CHECK(hex(sha1(std::string(1'000'000, 'a'))) == "34aa973cd4c4daa4f61eeb2bdbad27316534016f"); +} + +TEST_CASE("base64 matches the RFC 4648 vectors") { + CHECK(b64("") == ""); + CHECK(b64("f") == "Zg=="); + CHECK(b64("fo") == "Zm8="); + CHECK(b64("foo") == "Zm9v"); + CHECK(b64("foob") == "Zm9vYg=="); + CHECK(b64("fooba") == "Zm9vYmE="); + CHECK(b64("foobar") == "Zm9vYmFy"); +} + +TEST_CASE("The accept value matches RFC 6455's own example") { + // Section 1.3, verbatim. This single vector is what proves the GUID, the + // hash, and the encoding are all being combined the way a server will. + CHECK(websocket_accept_for("dGhlIHNhbXBsZSBub25jZQ==") == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="); +} + +TEST_CASE("The request carries the headers a server requires") { + const std::string request = + make_handshake_request("ws.kraken.com", 443, "/v2", "dGhlIHNhbXBsZSBub25jZQ==", true); + + CHECK(request.starts_with("GET /v2 HTTP/1.1\r\n")); + CHECK(request.find("Host: ws.kraken.com\r\n") != std::string::npos); + CHECK(request.find("Upgrade: websocket\r\n") != std::string::npos); + CHECK(request.find("Connection: Upgrade\r\n") != std::string::npos); + CHECK(request.find("Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n") != std::string::npos); + CHECK(request.find("Sec-WebSocket-Version: 13\r\n") != std::string::npos); + CHECK(request.ends_with("\r\n\r\n")); +} + +TEST_CASE("The Host header omits the port only when it is the scheme default") { + CHECK(make_handshake_request("example.com", 443, "/", "k", true).find("Host: example.com\r\n") != + std::string::npos); + CHECK(make_handshake_request("example.com", 9443, "/", "k", true) + .find("Host: example.com:9443\r\n") != std::string::npos); + CHECK(make_handshake_request("example.com", 80, "/", "k", false).find("Host: example.com\r\n") != + std::string::npos); +} + +TEST_CASE("A conforming response is accepted") { + const std::string accept = websocket_accept_for("dGhlIHNhbXBsZSBub25jZQ=="); + const std::string response = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + + accept + "\r\n\r\n"; + + HandshakeResponse parsed; + REQUIRE(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kOk); + CHECK(parsed.status_code == 101); + CHECK(parsed.header_bytes == response.size()); +} + +TEST_CASE("Frame bytes packed behind the 101 are reported, not lost") { + // A server is entitled to put the first frames in the same segment as the + // handshake response. header_bytes is what tells the caller where they + // start; getting it wrong drops the first message intermittently. + const std::string accept = websocket_accept_for("abc"); + const std::string headers = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: " + + accept + "\r\n\r\n"; + const std::string response = headers + "\x81\x02hi"; + + HandshakeResponse parsed; + REQUIRE(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kOk); + CHECK(parsed.header_bytes == headers.size()); + CHECK(response.size() - parsed.header_bytes == 4); +} + +TEST_CASE("Header matching is case-insensitive and tolerates token lists") { + const std::string accept = websocket_accept_for("abc"); + const std::string response = + "HTTP/1.1 101 Switching Protocols\r\n" + "upgrade: WebSocket\r\n" + "connection: keep-alive, Upgrade\r\n" + "sec-websocket-accept: " + + accept + "\r\n\r\n"; + + HandshakeResponse parsed; + CHECK(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kOk); +} + +TEST_CASE("Responses that are not a valid upgrade are rejected") { + const std::string accept = websocket_accept_for("abc"); + HandshakeResponse parsed; + + SECTION("incomplete headers ask for more bytes") { + CHECK(parse_handshake_response("HTTP/1.1 101 Switching Protocols\r\nUpgrade: web", accept, + parsed) == HandshakeStatus::kIncomplete); + } + SECTION("a non-101 status is surfaced with its code") { + CHECK(parse_handshake_response("HTTP/1.1 429 Too Many Requests\r\n\r\n", accept, parsed) == + HandshakeStatus::kNotSwitchingProtocols); + CHECK(parsed.status_code == 429); + } + SECTION("a missing Upgrade header is rejected") { + const std::string response = + "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + + accept + "\r\n\r\n"; + CHECK(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kNotUpgraded); + } + SECTION("a wrong accept value is rejected") { + const std::string response = + "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + "Sec-WebSocket-Accept: c3VyZWx5IG5vdA==\r\n\r\n"; + CHECK(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kBadAccept); + } + SECTION("a missing accept header is rejected") { + const std::string response = + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n"; + CHECK(parse_handshake_response(response, accept, parsed) == HandshakeStatus::kBadAccept); + } +} diff --git a/tests/test_url.cpp b/tests/test_url.cpp new file mode 100644 index 0000000..b0f85a2 --- /dev/null +++ b/tests/test_url.cpp @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti + +#include + +#include "crossbook/net/url.hpp" + +using namespace crossbook::net; + +TEST_CASE("Websocket URLs parse with the scheme's default port") { + Url url; + REQUIRE(parse_url("wss://ws.kraken.com/v2", url) == UrlError::kOk); + CHECK(url.secure); + CHECK(url.host == "ws.kraken.com"); + CHECK(url.port == 443); + CHECK(url.path == "/v2"); + + REQUIRE(parse_url("ws://localhost/feed", url) == UrlError::kOk); + CHECK_FALSE(url.secure); + CHECK(url.port == 80); +} + +TEST_CASE("An explicit port overrides the default") { + Url url; + REQUIRE(parse_url("wss://stream.binance.com:9443/ws/btcusdt@depth@100ms", url) == + UrlError::kOk); + CHECK(url.host == "stream.binance.com"); + CHECK(url.port == 9443); + CHECK(url.path == "/ws/btcusdt@depth@100ms"); +} + +TEST_CASE("A missing path becomes the root") { + Url url; + REQUIRE(parse_url("wss://example.com", url) == UrlError::kOk); + CHECK(url.path == "/"); +} + +TEST_CASE("IPv6 literals are not split on their own colons") { + Url url; + REQUIRE(parse_url("ws://[2001:db8::1]:8080/x", url) == UrlError::kOk); + CHECK(url.host == "[2001:db8::1]"); + CHECK(url.port == 8080); + CHECK(url.path == "/x"); + + REQUIRE(parse_url("wss://[2001:db8::1]/", url) == UrlError::kOk); + CHECK(url.host == "[2001:db8::1]"); + CHECK(url.port == 443); +} + +TEST_CASE("Anything that is not ws or wss is refused") { + Url url; + // https:// in particular: a plausible typo that must not silently become a + // connection attempt against a port nobody meant. + CHECK(parse_url("https://example.com", url) == UrlError::kBadScheme); + CHECK(parse_url("example.com", url) == UrlError::kBadScheme); + CHECK(parse_url("", url) == UrlError::kBadScheme); +} + +TEST_CASE("Malformed ports are refused rather than defaulted") { + Url url; + CHECK(parse_url("wss://example.com:0/", url) == UrlError::kBadPort); + CHECK(parse_url("wss://example.com:99999/", url) == UrlError::kBadPort); + CHECK(parse_url("wss://example.com:abc/", url) == UrlError::kBadPort); + CHECK(parse_url("wss://example.com:/", url) == UrlError::kBadPort); +} + +TEST_CASE("An empty host is refused") { + Url url; + CHECK(parse_url("wss:///path", url) == UrlError::kEmptyHost); +} diff --git a/tests/test_ws_frame.cpp b/tests/test_ws_frame.cpp new file mode 100644 index 0000000..d6a5438 --- /dev/null +++ b/tests/test_ws_frame.cpp @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// RFC 6455 framing. +// +// The bar here is higher than "it decodes a message", because this is the layer +// that reads a length off a socket and then trusts it. Every test that asserts a +// rejection is asserting a bound that is not being exceeded. + +#include + +#include +#include +#include +#include + +#include "crossbook/net/ws_frame.hpp" + +using namespace crossbook::net; + +namespace { + +/// Build a server-to-client frame: FIN set, unmasked, minimal length encoding. +std::string server_frame(Opcode opcode, std::string_view payload, bool fin = true) { + std::string out; + out.push_back(static_cast((fin ? 0x80U : 0x00U) | static_cast(opcode))); + + if (payload.size() < 126) { + out.push_back(static_cast(payload.size())); + } else if (payload.size() <= 0xFFFF) { + out.push_back(static_cast(126)); + out.push_back(static_cast((payload.size() >> 8) & 0xFFU)); + out.push_back(static_cast(payload.size() & 0xFFU)); + } else { + out.push_back(static_cast(127)); + for (int shift = 56; shift >= 0; shift -= 8) { + out.push_back(static_cast((payload.size() >> shift) & 0xFFU)); + } + } + out.append(payload); + return out; +} + +} // namespace + +TEST_CASE("A short unmasked frame header parses") { + const std::string frame = server_frame(Opcode::kText, "hello"); + FrameHeader header; + REQUIRE(parse_frame_header(frame, header) == FrameStatus::kOk); + CHECK(header.fin); + CHECK_FALSE(header.masked); + CHECK(header.opcode == Opcode::kText); + CHECK(header.payload_len == 5); + CHECK(header.header_size == 2); +} + +TEST_CASE("Every truncation of a header reports incomplete, never a parse") { + // 70 KiB forces the 64-bit length path, so the check covers all three + // header shapes as the buffer grows one byte at a time. + const std::string payload(70000, 'x'); + const std::string frame = server_frame(Opcode::kBinary, payload); + + for (std::size_t prefix = 0; prefix < 10; ++prefix) { + FrameHeader header; + const FrameStatus status = parse_frame_header(std::string_view(frame).substr(0, prefix), header); + INFO("prefix length " << prefix); + CHECK(status == FrameStatus::kIncomplete); + } + + FrameHeader header; + REQUIRE(parse_frame_header(frame, header) == FrameStatus::kOk); + CHECK(header.payload_len == 70000); + CHECK(header.header_size == 10); +} + +TEST_CASE("Reserved bits are rejected") { + std::string frame = server_frame(Opcode::kText, "hi"); + frame[0] = static_cast(static_cast(frame[0]) | 0x40U); // RSV1 + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); +} + +TEST_CASE("Unknown opcodes are rejected rather than ignored") { + std::string frame = server_frame(Opcode::kText, "hi"); + frame[0] = static_cast(0x80U | 0x03U); // Reserved data opcode. + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); +} + +TEST_CASE("Non-minimal length encodings are rejected") { + SECTION("16-bit form used for a length that fits in 7 bits") { + // A payload of 5 spelled with the 126 escape. + const std::string frame = std::string("\x81\x7e\x00\x05hello", 9); + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); + } + SECTION("64-bit form used for a length that fits in 16 bits") { + std::string frame("\x81\x7f", 2); + for (int shift = 56; shift >= 0; shift -= 8) { + frame.push_back(static_cast((200ULL >> shift) & 0xFFU)); + } + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); + } + SECTION("64-bit length with the top bit set is rejected") { + std::string frame("\x81\x7f", 2); + frame.push_back(static_cast(0x80)); + for (int i = 0; i < 7; ++i) { + frame.push_back('\0'); + } + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); + } +} + +TEST_CASE("Control frames must be short and unfragmented") { + SECTION("a fragmented ping is a protocol error") { + const std::string frame = server_frame(Opcode::kPing, "x", /*fin=*/false); + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); + } + SECTION("a ping over 125 bytes is a protocol error") { + const std::string frame = server_frame(Opcode::kPing, std::string(126, 'x')); + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kProtocolError); + } + SECTION("exactly 125 bytes is allowed") { + const std::string frame = server_frame(Opcode::kPing, std::string(125, 'x')); + FrameHeader header; + CHECK(parse_frame_header(frame, header) == FrameStatus::kOk); + } +} + +TEST_CASE("Masking is its own inverse, and offsets keep a split payload aligned") { + const std::string original = "the quick brown fox jumps over the lazy dog"; + const std::uint32_t key = 0xDEADBEEF; + + std::string buffer = original; + apply_mask(buffer.data(), buffer.size(), key); + CHECK(buffer != original); + apply_mask(buffer.data(), buffer.size(), key); + CHECK(buffer == original); + + // Unmasking in two pieces must match unmasking in one, or a payload split + // across two reads decodes to garbage from the split point onward. + std::string whole = original; + apply_mask(whole.data(), whole.size(), key); + + std::string split = whole; + constexpr std::size_t kCut = 7; // Deliberately not a multiple of 4. + apply_mask(split.data(), kCut, key, 0); + apply_mask(split.data() + kCut, split.size() - kCut, key, kCut); + CHECK(split == original); +} + +TEST_CASE("A written header round-trips through the parser") { + for (const std::uint64_t length : {std::uint64_t{0}, std::uint64_t{125}, std::uint64_t{126}, + std::uint64_t{65535}, std::uint64_t{65536}, + std::uint64_t{1'000'000}}) { + char buffer[kMaxHeaderSize]; + const std::size_t written = + write_frame_header(buffer, Opcode::kText, length, 0x11223344U); + + FrameHeader header; + REQUIRE(parse_frame_header(std::string_view(buffer, written), header) == FrameStatus::kOk); + INFO("payload length " << length); + CHECK(header.payload_len == length); + CHECK(header.masked); // A client MUST mask; the writer always does. + CHECK(header.mask_key == 0x11223344U); + CHECK(header.header_size == written); + } +} + +TEST_CASE("The reader returns a whole message") { + FrameReader reader; + const std::string frame = server_frame(Opcode::kText, R"({"channel":"book"})"); + reader.append(frame); + + Event event; + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.opcode == Opcode::kText); + CHECK(event.payload == R"({"channel":"book"})"); + CHECK(reader.next(event) == ReadStatus::kNeedMore); +} + +TEST_CASE("A message split across transport reads is reassembled") { + // Byte at a time is the pathological case, and it is the one that shakes out + // off-by-ones in the buffer bookkeeping. A real socket will split a frame at + // an arbitrary point, so "arbitrary" is tested as "every point". + const std::string payload(500, 'a'); + const std::string frame = server_frame(Opcode::kText, payload); + + FrameReader reader; + Event event; + for (std::size_t i = 0; i + 1 < frame.size(); ++i) { + reader.append(frame.data() + i, 1); + REQUIRE(reader.next(event) == ReadStatus::kNeedMore); + } + reader.append(frame.data() + frame.size() - 1, 1); + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.payload == payload); +} + +TEST_CASE("Fragmented messages are joined") { + FrameReader reader; + reader.append(server_frame(Opcode::kText, "part one ", /*fin=*/false)); + reader.append(server_frame(Opcode::kContinuation, "part two ", /*fin=*/false)); + reader.append(server_frame(Opcode::kContinuation, "part three")); + + Event event; + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.opcode == Opcode::kText); + CHECK(event.payload == "part one part two part three"); +} + +TEST_CASE("A ping between fragments does not corrupt the message") { + // This is the case a reader that treats every frame as a message gets wrong, + // and it is not hypothetical: venues ping on a timer regardless of what they + // are in the middle of sending. + FrameReader reader; + reader.append(server_frame(Opcode::kText, "before ", /*fin=*/false)); + reader.append(server_frame(Opcode::kPing, "keepalive")); + reader.append(server_frame(Opcode::kContinuation, "after")); + + Event event; + REQUIRE(reader.next(event) == ReadStatus::kPing); + CHECK(event.payload == "keepalive"); + CHECK(reader.assembling()); + + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.payload == "before after"); +} + +TEST_CASE("Close frames carry a code when they have one") { + SECTION("with a code and reason") { + FrameReader reader; + reader.append(server_frame(Opcode::kClose, std::string("\x03\xe8", 2) + "bye")); + Event event; + REQUIRE(reader.next(event) == ReadStatus::kClose); + CHECK(event.close_code == 1000); + CHECK(event.payload == "bye"); + } + SECTION("empty close reports 1005, which is not the same as 1000") { + FrameReader reader; + reader.append(server_frame(Opcode::kClose, "")); + Event event; + REQUIRE(reader.next(event) == ReadStatus::kClose); + CHECK(event.close_code == static_cast(CloseCode::kNoStatus)); + } + SECTION("a one-byte close payload is malformed") { + FrameReader reader; + reader.append(server_frame(Opcode::kClose, "\x03")); + Event event; + CHECK(reader.next(event) == ReadStatus::kProtocolError); + } +} + +TEST_CASE("Protocol violations from the server are rejected") { + SECTION("a continuation with nothing to continue") { + FrameReader reader; + reader.append(server_frame(Opcode::kContinuation, "orphan")); + Event event; + CHECK(reader.next(event) == ReadStatus::kProtocolError); + } + SECTION("a new data frame while a message is still assembling") { + FrameReader reader; + reader.append(server_frame(Opcode::kText, "first", /*fin=*/false)); + reader.append(server_frame(Opcode::kText, "second")); + Event event; + CHECK(reader.next(event) == ReadStatus::kProtocolError); + } + SECTION("a masked frame from the server") { + // Servers must not mask. A masked frame means we have lost alignment. + std::string frame = server_frame(Opcode::kText, "hi"); + frame[1] = static_cast(static_cast(frame[1]) | 0x80U); + frame.insert(2, 4, '\0'); // Make room for the mask key. + FrameReader reader; + reader.append(frame); + Event event; + CHECK(reader.next(event) == ReadStatus::kProtocolError); + } +} + +TEST_CASE("An oversized message is refused before its bytes are buffered") { + // The declared length is what is rejected, not the accumulated bytes: a + // ceiling that only trips after the payload has been read is not a ceiling. + FrameReader reader(1024); + + std::string header("\x82\x7f", 2); + for (int shift = 56; shift >= 0; shift -= 8) { + header.push_back(static_cast((4'000'000'000ULL >> shift) & 0xFFU)); + } + reader.append(header); + + Event event; + CHECK(reader.next(event) == ReadStatus::kMessageTooLarge); + CHECK(reader.buffered() == header.size()); // Nothing was consumed. +} + +TEST_CASE("A fragmented message is bounded by the same ceiling as a single frame") { + FrameReader reader(300); + reader.append(server_frame(Opcode::kBinary, std::string(200, 'x'), /*fin=*/false)); + + Event event; + REQUIRE(reader.next(event) == ReadStatus::kNeedMore); + + reader.append(server_frame(Opcode::kContinuation, std::string(200, 'y'))); + CHECK(reader.next(event) == ReadStatus::kMessageTooLarge); +} + +TEST_CASE("Many messages in one buffer are drained in order") { + FrameReader reader; + std::string stream; + for (int i = 0; i < 100; ++i) { + stream += server_frame(Opcode::kText, "msg" + std::to_string(i)); + } + reader.append(stream); + + Event event; + for (int i = 0; i < 100; ++i) { + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.payload == "msg" + std::to_string(i)); + } + CHECK(reader.next(event) == ReadStatus::kNeedMore); + CHECK(reader.buffered() == 0); +} + +TEST_CASE("A protocol error is terminal, not a hiccup to recover from") { + // After a bad length, the next byte read as an opcode is whatever happened + // to be there. Recovering would mean inventing frames, and a book built + // from invented frames looks entirely plausible. + FrameReader reader; + reader.append(server_frame(Opcode::kContinuation, "orphan")); + + Event event; + REQUIRE(reader.next(event) == ReadStatus::kProtocolError); + CHECK(reader.failed()); + + reader.append(server_frame(Opcode::kText, "perfectly valid")); + CHECK(reader.next(event) == ReadStatus::kProtocolError); + CHECK(reader.next(event) == ReadStatus::kProtocolError); + + reader.reset(); + CHECK_FALSE(reader.failed()); + reader.append(server_frame(Opcode::kText, "fresh start")); + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.payload == "fresh start"); +} + +TEST_CASE("An empty payload is a message, not a non-event") { + FrameReader reader; + reader.append(server_frame(Opcode::kText, "")); + Event event; + REQUIRE(reader.next(event) == ReadStatus::kMessage); + CHECK(event.payload.empty()); +} diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt new file mode 100644 index 0000000..968707c --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,8 @@ +foreach(tool crossbook_capture crossbook_verify) + add_executable(${tool} ${tool}.cpp) + target_link_libraries(${tool} PRIVATE + crossbook::crossbook + crossbook::net + crossbook_warnings + ) +endforeach() diff --git a/tools/crossbook_capture.cpp b/tools/crossbook_capture.cpp new file mode 100644 index 0000000..5522853 --- /dev/null +++ b/tools/crossbook_capture.cpp @@ -0,0 +1,326 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// crossbook_capture — connect to a venue, record what it sends. +// +// This is the tool that makes the rest of the repository checkable by someone +// who is not me. It dials a public crypto websocket with no API key, records +// every frame verbatim alongside the instant it arrived, and writes a capture +// file that replays deterministically. +// +// It deliberately does not decode, does not build a book, and does not verify +// anything. Recording and interpreting are separate jobs, and keeping them +// separate means a capture is evidence rather than output: if the book +// implementation changes, the capture is still the same bytes the exchange +// sent, and the new implementation can be held to it. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "crossbook/capture.hpp" +#include "crossbook/net/websocket.hpp" +#include "tool_common.hpp" + +namespace { + +std::atomic g_stop{false}; + +extern "C" void on_signal(int) { g_stop.store(true, std::memory_order_relaxed); } + +[[nodiscard]] std::int64_t steady_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +[[nodiscard]] std::int64_t unix_ns() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +struct Venue { + std::string url; + std::string subscribe; ///< Empty when the venue selects the stream by path. + std::string name; +}; + +/// Lower-case a symbol and drop separators: "BTC/USD" becomes "btcusd", which is +/// the shape Binance stream names take. +[[nodiscard]] std::string binance_stream_symbol(std::string_view symbol) { + std::string out; + out.reserve(symbol.size()); + for (const char c : symbol) { + if (c == '/' || c == '-' || c == '_') { + continue; + } + out.push_back((c >= 'A' && c <= 'Z') ? static_cast(c - 'A' + 'a') : c); + } + return out; +} + +[[nodiscard]] bool build_venue(std::string_view name, std::string_view symbol, int depth, + Venue& out) { + if (name == "kraken") { + out.name = "kraken"; + out.url = "wss://ws.kraken.com/v2"; + // depth must be one of Kraken's supported book depths; the checksum + // covers the top 10 regardless, so 10 is the useful default. + out.subscribe = std::string(R"({"method":"subscribe","params":{"channel":"book",)") + + R"("symbol":[")" + std::string(symbol) + R"("],"depth":)" + + std::to_string(depth) + "}}"; + return true; + } + if (name == "binance") { + out.name = "binance"; + // 100ms diff-depth: the fastest cadence the public spot stream offers. + out.url = "wss://stream.binance.com:9443/ws/" + binance_stream_symbol(symbol) + + "@depth@100ms"; + return true; + } + if (name == "binance-futures") { + out.name = "binance-futures"; + out.url = "wss://fstream.binance.com/ws/" + binance_stream_symbol(symbol) + "@depth@100ms"; + return true; + } + return false; +} + +void print_usage() { + std::printf( + "crossbook_capture - record a venue's raw websocket feed\n" + "\n" + "Usage:\n" + " crossbook_capture [options]\n" + "\n" + "Options:\n" + " --venue kraken | binance | binance-futures (default: kraken)\n" + " --symbol instrument, in the venue's own spelling (default: BTC/USD)\n" + " --depth book depth to request, Kraken only (default: 10)\n" + " --seconds stop after n seconds; 0 runs until Ctrl-C (default: 30)\n" + " --out write a capture file (default: none, count only)\n" + " --url dial this URL instead of a venue preset\n" + " --subscribe send this after connecting; use with --url\n" + " --quiet suppress the periodic progress line\n" + " --help this text\n" + "\n" + "Examples:\n" + " crossbook_capture --venue kraken --symbol BTC/USD --seconds 60 --out btc.cbcap\n" + " crossbook_capture --venue binance --symbol BTCUSDT --seconds 10\n" + "\n" + "Both venues are public: no API key, no account, nothing to configure.\n"); +} + +/// Percentile over a sorted vector, using the nearest-rank convention so the +/// answer is always a value that was actually observed. +[[nodiscard]] std::int64_t percentile(const std::vector& sorted, double p) { + if (sorted.empty()) { + return 0; + } + auto index = static_cast((p / 100.0) * static_cast(sorted.size())); + if (index >= sorted.size()) { + index = sorted.size() - 1; + } + return sorted[index]; +} + +} // namespace + +int main(int argc, char** argv) { + std::string venue_name = "kraken"; + std::string symbol = "BTC/USD"; + std::string out_path; + std::string custom_url; + std::string custom_subscribe; + int depth = 10; + int seconds = 30; + bool quiet = false; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg(argv[i]); + auto value = [&](const char* what) -> const char* { + if (i + 1 >= argc) { + std::fprintf(stderr, "error: %s needs a value\n", what); + std::exit(2); + } + return argv[++i]; + }; + + if (arg == "--help" || arg == "-h") { + print_usage(); + return 0; + } else if (arg == "--venue") { + venue_name = value("--venue"); + } else if (arg == "--symbol") { + symbol = value("--symbol"); + } else if (arg == "--out") { + out_path = value("--out"); + } else if (arg == "--url") { + custom_url = value("--url"); + } else if (arg == "--subscribe") { + custom_subscribe = value("--subscribe"); + } else if (arg == "--depth") { + depth = std::atoi(value("--depth")); + } else if (arg == "--seconds") { + seconds = std::atoi(value("--seconds")); + } else if (arg == "--quiet") { + quiet = true; + } else { + std::fprintf(stderr, "error: unknown option %.*s\n", static_cast(arg.size()), + arg.data()); + print_usage(); + return 2; + } + } + + Venue venue; + if (!custom_url.empty()) { + venue.name = "custom"; + venue.url = custom_url; + venue.subscribe = custom_subscribe; + } else if (!build_venue(venue_name, symbol, depth, venue)) { + std::fprintf(stderr, "error: unknown venue '%s'\n", venue_name.c_str()); + return 2; + } + + (void)std::signal(SIGINT, on_signal); +#ifdef SIGTERM + (void)std::signal(SIGTERM, on_signal); +#endif + + crossbook::net::WebSocketClient client; + std::printf("connecting to %s\n", venue.url.c_str()); + + if (!crossbook::tools::connect_with_backoff(client, venue.url)) { + std::fprintf(stderr, "error: %s\n", client.last_error().c_str()); + return 1; + } + std::printf("connected: %s%s\n", client.url().host.c_str(), client.url().path.c_str()); + + if (!venue.subscribe.empty()) { + if (!client.send_text(venue.subscribe)) { + std::fprintf(stderr, "error: subscribe failed: %s\n", client.last_error().c_str()); + return 1; + } + std::printf("subscribed: %s\n", venue.subscribe.c_str()); + } + + crossbook::CaptureWriter writer; + if (!out_path.empty()) { + if (!writer.open(out_path, venue.name, symbol, unix_ns())) { + std::fprintf(stderr, "error: cannot write %s\n", out_path.c_str()); + return 1; + } + std::printf("recording to %s\n", out_path.c_str()); + } + + const std::int64_t start = steady_ns(); + const std::int64_t deadline = + seconds > 0 ? start + static_cast(seconds) * 1'000'000'000LL : 0; + + std::vector gaps; + gaps.reserve(1 << 16); + + std::uint64_t frames = 0; + std::uint64_t bytes = 0; + std::int64_t previous = 0; + std::int64_t next_report = start + 1'000'000'000LL; + int exit_code = 0; + + for (;;) { + if (g_stop.load(std::memory_order_relaxed)) { + std::printf("\ninterrupted\n"); + break; + } + if (deadline != 0 && steady_ns() >= deadline) { + break; + } + + crossbook::net::Event event; + const crossbook::net::ReadStatus status = client.poll(event); + + if (status == crossbook::net::ReadStatus::kMessage) { + const std::int64_t now = steady_ns(); + ++frames; + bytes += event.payload.size(); + if (previous != 0) { + gaps.push_back(now - previous); + } + previous = now; + + if (writer.is_open() && !writer.write(now, event.payload)) { + std::fprintf(stderr, "error: capture write failed\n"); + exit_code = 1; + break; + } + } else if (status == crossbook::net::ReadStatus::kNeedMore) { + // Read timeout: a quiet market, not a problem. Loop round so the + // deadline and the signal flag still get checked. + continue; + } else if (status == crossbook::net::ReadStatus::kClose) { + std::printf("\npeer closed (code %u)\n", static_cast(event.close_code)); + break; + } else { + std::fprintf(stderr, "\nerror: %s\n", client.last_error().c_str()); + exit_code = 1; + break; + } + + const std::int64_t now = steady_ns(); + if (!quiet && now >= next_report) { + const double elapsed = static_cast(now - start) / 1e9; + std::printf("\r%6.1fs %8llu frames %7.1f/s %8.2f MiB", elapsed, + static_cast(frames), + static_cast(frames) / (elapsed > 0 ? elapsed : 1), + static_cast(bytes) / (1024.0 * 1024.0)); + (void)std::fflush(stdout); + next_report = now + 1'000'000'000LL; + } + } + + client.close(); + writer.close(); + + const double elapsed = static_cast(steady_ns() - start) / 1e9; + std::sort(gaps.begin(), gaps.end()); + + std::printf("\n\n"); + std::printf("venue %s %s\n", venue.name.c_str(), symbol.c_str()); + std::printf("elapsed %.2f s\n", elapsed); + std::printf("messages %llu (%.1f/s)\n", static_cast(frames), + static_cast(frames) / (elapsed > 0 ? elapsed : 1)); + std::printf("bytes %.2f MiB\n", static_cast(bytes) / (1024.0 * 1024.0)); + std::printf("pings answered %llu\n", + static_cast(client.stats().pongs_sent)); + + if (!gaps.empty()) { + // Inter-arrival, not latency: this is how often the venue speaks, and it + // says nothing about how fast anything here is. The distinction matters + // enough to spell out in the output rather than leave to the reader. + std::printf("inter-arrival gaps p50 %.2f ms p99 %.2f ms max %.2f ms\n", + static_cast(percentile(gaps, 50)) / 1e6, + static_cast(percentile(gaps, 99)) / 1e6, + static_cast(gaps.back()) / 1e6); + } + if (writer.frames() > 0) { + std::printf("capture %s (%llu frames, %.2f MiB)\n", out_path.c_str(), + static_cast(writer.frames()), + static_cast(writer.bytes()) / (1024.0 * 1024.0)); + } + + if (frames == 0 && exit_code == 0) { + // Connecting and then receiving nothing is a failure, not a quiet + // success. Exiting zero here would let a broken subscription pass CI. + std::fprintf(stderr, "error: connected but received no messages\n"); + exit_code = 1; + } + return exit_code; +} diff --git a/tools/crossbook_verify.cpp b/tools/crossbook_verify.cpp new file mode 100644 index 0000000..9cc25bb --- /dev/null +++ b/tools/crossbook_verify.cpp @@ -0,0 +1,646 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// crossbook_verify — rebuild a book from a live venue and check it against the +// venue's own arithmetic, continuously, with a number at the end. +// +// This is the claim in the README made executable. It connects with no API key, +// reconstructs the book from the feed, recomputes Kraken's CRC32 over local +// state on every single update, and reports the match rate together with an +// enumerated list of every disagreement. A match rate without that list is +// marketing; the list is what makes the number checkable. +// +// The same binary replays a capture file offline and must produce the same +// answer, byte for byte, on any platform. That is what CI runs, and it is why +// the number in the README is a regression test rather than an anecdote. +// +// EXIT STATUS IS THE POINT. Any checksum mismatch, sequence gap, or decode +// failure exits non-zero. A verifier that reports problems and then exits +// successfully is a verifier nobody will wire into anything. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "crossbook/capture.hpp" +#include "crossbook/feed.hpp" +#include "crossbook/json.hpp" +#include "crossbook/net/transport.hpp" +#include "crossbook/net/websocket.hpp" +#include "crossbook/replay.hpp" +#include "crossbook/venues/binance.hpp" +#include "crossbook/venues/kraken.hpp" +#include "tool_common.hpp" + +namespace { + +std::atomic g_stop{false}; + +extern "C" void on_signal(int) { g_stop.store(true, std::memory_order_relaxed); } + +[[nodiscard]] std::int64_t steady_ns() { + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()) + .count(); +} + +[[nodiscard]] std::int64_t unix_ns() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +struct Options { + std::string venue{"kraken"}; + std::string symbol{"BTC/USD"}; + std::string replay_path; + std::string capture_path; + int seconds{30}; + int depth{10}; + int price_scale{-1}; ///< -1 means infer from the venue's own spelling. + int qty_scale{-1}; + double speed{0.0}; ///< >0 enables open-loop paced replay at this multiple. + bool quiet{false}; +}; + +// --------------------------------------------------------------------------- +// Scale inference +// --------------------------------------------------------------------------- + +/// Decimal places in a numeric token, as the venue spelled it. +[[nodiscard]] int decimals_in(std::string_view token) { + const std::size_t dot = token.find('.'); + if (dot == std::string_view::npos) { + return 0; + } + return static_cast(token.size() - dot - 1); +} + +/// Infer an instrument's price and quantity scales from a snapshot frame. +/// +/// Not a shortcut around configuration — it is reading the venue's own answer. +/// The checksum identity in fixed.hpp rests on the precondition that values are +/// spelled canonically at the instrument's scale, trailing zeros included, and +/// Kraken's documented example ("0.00100000") shows that it does. Given that, +/// the widest decimal count in a snapshot *is* the scale. +/// +/// Getting this wrong produces a book that is numerically correct and fails +/// every checksum, so inferring it beats a hard-coded table that silently rots +/// when a venue changes precision. +[[nodiscard]] bool infer_scales(std::string_view frame, std::string_view price_key, + std::string_view qty_key, bool levels_are_arrays, + int& price_scale, int& qty_scale, int* depth_out = nullptr) { + using namespace crossbook; + + int price_max = -1; + int qty_max = -1; + int side_levels = 0; + int max_side_levels = 0; + + auto scan_level = [&](const JsonValue& level) { + ++side_levels; + if (levels_are_arrays) { + // Binance: ["price","qty"]. + int seen = 0; + (void)json::for_each(level.raw, [&](const JsonValue& field) { + const std::string_view token = json::number_token(field); + if (seen == 0) { + price_max = (std::max)(price_max, decimals_in(token)); + } else if (seen == 1) { + qty_max = (std::max)(qty_max, decimals_in(token)); + } + ++seen; + return seen < 2; + }); + } else { + // Kraken: {"price":...,"qty":...}. + price_max = (std::max)(price_max, + decimals_in(json::number_token(json::find(level.raw, price_key)))); + qty_max = + (std::max)(qty_max, decimals_in(json::number_token(json::find(level.raw, qty_key)))); + } + return true; + }; + + auto scan_side = [&](std::string_view container, std::string_view key) { + const JsonValue array = json::find(container, key); + if (array && array.type == JsonType::kArray) { + side_levels = 0; + (void)json::for_each(array.raw, scan_level); + max_side_levels = (std::max)(max_side_levels, side_levels); + } + }; + + // Kraken wraps the levels in data[0]; Binance puts them at the top level. + std::string_view container = frame; + const JsonValue data = json::find(frame, "data"); + if (data && data.type == JsonType::kArray) { + (void)json::for_each(data.raw, [&](const JsonValue& entry) { + container = entry.raw; + return false; // First entry only. + }); + } + + if (levels_are_arrays) { + scan_side(container, "bids"); + scan_side(container, "asks"); + scan_side(container, "b"); + scan_side(container, "a"); + } else { + scan_side(container, "bids"); + scan_side(container, "asks"); + } + + if (price_max < 0 || qty_max < 0) { + return false; + } + price_scale = price_max; + qty_scale = qty_max; + if (depth_out != nullptr) { + // A snapshot of a depth-N subscription carries exactly N levels per + // side, so the capture describes its own depth and a replay needs no + // extra argument to reproduce the live run. + *depth_out = max_side_levels; + } + return true; +} + +// --------------------------------------------------------------------------- +// Reporting +// --------------------------------------------------------------------------- + +template +void print_report(const FeedT& feed, const Options& options, double elapsed_s) { + using namespace crossbook; + + const auto& stats = feed.stats(); + const auto& log = feed.divergences(); + + std::printf("\n"); + std::printf("venue %s %s\n", options.venue.c_str(), options.symbol.c_str()); + std::printf("scales price 10^-%d qty 10^-%d\n", options.price_scale, + options.qty_scale); + std::printf("elapsed %.2f s\n", elapsed_s); + std::printf("frames %llu\n", static_cast(stats.frames)); + std::printf(" applied %llu\n", static_cast(stats.applied)); + std::printf(" ignored %llu\n", static_cast(stats.ignored)); + std::printf(" rejected %llu\n", static_cast(stats.rejected)); + std::printf("snapshots %llu\n", + static_cast(stats.snapshots_applied)); + std::printf("resyncs requested %llu\n", + static_cast(stats.resyncs_requested)); + // Surfaced because zero trims on a depth-limited feed means the depth was + // never configured, and that is exactly the misconfiguration that produces + // a book which looks right for minutes and then fails its checksums. + std::printf("depth / trimmed %d levels / %llu dropped\n", options.depth, + static_cast(stats.levels_trimmed)); + + std::printf("\n"); + std::printf("checksums verified %llu\n", + static_cast(stats.checksums_verified)); + std::printf("checksum mismatches %llu\n", + static_cast(stats.checksum_mismatches)); + + if (stats.checksums_verified == 0) { + // Zero verified is not a perfect score. Saying so plainly matters more + // than the match rate, because 100% of nothing reads identically to + // 100% of everything on a dashboard. + std::printf("match rate n/a - NOTHING WAS VERIFIED\n"); + } else { + std::printf("match rate %.6f%% (%llu of %llu)\n", log.match_rate() * 100.0, + static_cast(log.verified()), + static_cast(log.verified() + log.total_recorded())); + } + + if (!log.entries().empty()) { + std::printf("\ndivergences (%llu recorded, %llu dropped past the log's capacity)\n", + static_cast(log.total_recorded()), + static_cast(log.dropped())); + // Every one, up to a readable limit. A summarised remainder is exactly + // the thing this tool refuses to produce. + std::size_t shown = 0; + for (const Divergence& d : log.entries()) { + if (shown++ >= 20) { + std::printf(" ... and %zu more\n", log.entries().size() - 20); + break; + } + std::printf(" [%s] seq=%llu expected=%llu actual=%llu\n", + std::string(to_string(d.kind)).c_str(), + static_cast(d.sequence), + static_cast(d.expected), + static_cast(d.actual)); + if (!d.detail.empty()) { + std::printf(" %.200s\n", d.detail.c_str()); + } + } + } + + Level bid{}; + Level ask{}; + const bool have_bid = feed.book().best(Side::kBid, bid); + const bool have_ask = feed.book().best(Side::kAsk, ask); + std::printf("\n"); + std::printf("synced %s\n", feed.synced() ? "yes" : "NO"); + if (have_bid && have_ask) { + std::printf("top of book %s x %s / %s x %s\n", + format_fixed(bid.price.ticks, static_cast(options.price_scale)).c_str(), + format_fixed(bid.qty.units, static_cast(options.qty_scale)).c_str(), + format_fixed(ask.price.ticks, static_cast(options.price_scale)).c_str(), + format_fixed(ask.qty.units, static_cast(options.qty_scale)).c_str()); + } + std::printf("levels %zu bid / %zu ask\n", feed.book().bids().size(), + feed.book().asks().size()); + // The determinism gate: replaying the same capture must reproduce this + // exact value on every platform and every build. + std::printf("state hash %016llx\n", + static_cast(feed.book().state_hash())); +} + +/// Non-zero when anything at all disagreed. See the file header. +template +[[nodiscard]] int verdict(const FeedT& feed) { + const auto& stats = feed.stats(); + if (stats.checksums_verified == 0 && stats.applied == 0) { + std::fprintf(stderr, "\nFAIL: nothing was applied\n"); + return 1; + } + if (stats.checksum_mismatches != 0 || stats.rejected != 0 || stats.resyncs_requested != 0) { + std::fprintf(stderr, "\nFAIL: the book disagreed with the venue\n"); + return 1; + } + if (stats.checksums_verified == 0) { + std::fprintf(stderr, "\nFAIL: no checksum was ever verified\n"); + return 1; + } + std::printf("\nPASS: every checked update matched the exchange\n"); + return 0; +} + +// --------------------------------------------------------------------------- +// Live +// --------------------------------------------------------------------------- + +[[nodiscard]] std::string kraken_subscribe(const std::string& symbol, int depth, bool subscribe) { + return std::string(R"({"method":")") + (subscribe ? "subscribe" : "unsubscribe") + + R"(","params":{"channel":"book","symbol":[")" + symbol + R"("],"depth":)" + + std::to_string(depth) + "}}"; +} + +int run_kraken_live(Options& options) { + using namespace crossbook; + + net::WebSocketClient client; + const std::string url = "wss://ws.kraken.com/v2"; + std::printf("connecting to %s\n", url.c_str()); + if (!tools::connect_with_backoff(client, url)) { + std::fprintf(stderr, "error: %s\n", client.last_error().c_str()); + return 1; + } + if (!client.send_text(kraken_subscribe(options.symbol, options.depth, true))) { + std::fprintf(stderr, "error: subscribe failed: %s\n", client.last_error().c_str()); + return 1; + } + std::printf("subscribed to book %s depth %d\n", options.symbol.c_str(), options.depth); + + CaptureWriter writer; + if (!options.capture_path.empty() && + !writer.open(options.capture_path, "kraken", options.symbol, unix_ns())) { + std::fprintf(stderr, "error: cannot write %s\n", options.capture_path.c_str()); + return 1; + } + + // The feed is constructed only once the scales are known, because the + // decoder needs them and a wrong scale fails every checksum. + std::unique_ptr> feed; + + const std::int64_t start = steady_ns(); + const std::int64_t deadline = + options.seconds > 0 ? start + static_cast(options.seconds) * 1'000'000'000LL + : 0; + std::int64_t next_report = start + 1'000'000'000LL; + + for (;;) { + if (g_stop.load(std::memory_order_relaxed)) { + std::printf("\ninterrupted\n"); + break; + } + if (deadline != 0 && steady_ns() >= deadline) { + break; + } + + net::Event event; + const net::ReadStatus status = client.poll(event); + if (status == net::ReadStatus::kNeedMore) { + continue; + } + if (status == net::ReadStatus::kClose) { + std::printf("\npeer closed (code %u)\n", static_cast(event.close_code)); + break; + } + if (status != net::ReadStatus::kMessage) { + std::fprintf(stderr, "\nerror: %s\n", client.last_error().c_str()); + return 1; + } + + if (writer.is_open() && !writer.write(steady_ns(), event.payload)) { + std::fprintf(stderr, "error: capture write failed\n"); + return 1; + } + + if (!feed) { + // Wait for the book snapshot, which is the only frame carrying + // enough levels to read the instrument's precision off the wire. + // + // Matched structurally rather than by searching for "snapshot" in + // the text: the subscribe acknowledgement says `"snapshot":true` + // and carries no levels at all, so a substring test picks the wrong + // frame and then fails on it. + if (json::string_body(json::find(event.payload, "channel")) != "book" || + json::string_body(json::find(event.payload, "type")) != "snapshot") { + continue; + } + int price_scale = options.price_scale; + int qty_scale = options.qty_scale; + if (price_scale < 0 || qty_scale < 0) { + int inferred_price = 0; + int inferred_qty = 0; + if (!infer_scales(event.payload, "price", "qty", false, inferred_price, + inferred_qty)) { + continue; // Not a frame with levels after all; keep waiting. + } + if (price_scale < 0) { + price_scale = inferred_price; + } + if (qty_scale < 0) { + qty_scale = inferred_qty; + } + std::printf("inferred scales: price 10^-%d, qty 10^-%d\n", price_scale, qty_scale); + } + options.price_scale = price_scale; + options.qty_scale = qty_scale; + + feed = std::make_unique>( + "kraken", + venues::KrakenBookDecoder(InstrumentSpec{options.symbol, + static_cast(price_scale), + static_cast(qty_scale)}), + SequencePolicy::kStrictIncrement, static_cast(options.depth)); + } + + const FeedStatus fed = feed->handle(event.payload); + if (fed == FeedStatus::kNeedsSnapshot) { + // The book is known to be wrong. Kraken has no resnapshot request, + // so the recovery is to drop the subscription and take a new one. + std::printf("\nresyncing after a divergence\n"); + (void)client.send_text(kraken_subscribe(options.symbol, options.depth, false)); + (void)client.send_text(kraken_subscribe(options.symbol, options.depth, true)); + } + + const std::int64_t now = steady_ns(); + if (!options.quiet && now >= next_report) { + const double elapsed = static_cast(now - start) / 1e9; + std::printf("\r%6.1fs %7llu verified %llu mismatched %.4f%% ", elapsed, + static_cast(feed->stats().checksums_verified), + static_cast(feed->stats().checksum_mismatches), + feed->match_rate() * 100.0); + (void)std::fflush(stdout); + next_report = now + 1'000'000'000LL; + } + } + + client.close(); + writer.close(); + + if (!feed) { + std::fprintf(stderr, "\nerror: no snapshot arrived; nothing was verified\n"); + return 1; + } + + print_report(*feed, options, static_cast(steady_ns() - start) / 1e9); + if (writer.frames() > 0) { + std::printf("capture %s (%llu frames)\n", options.capture_path.c_str(), + static_cast(writer.frames())); + } + return verdict(*feed); +} + +// --------------------------------------------------------------------------- +// Replay +// --------------------------------------------------------------------------- + +template +int run_replay_with(FeedT& feed, const crossbook::Capture& capture, Options& options) { + using namespace crossbook; + + const std::int64_t start = steady_ns(); + + if (options.speed > 0.0) { + // Open-loop: paced at the recorded inter-arrival times, so the latency + // figures include queueing delay rather than measuring service time. + std::vector events; + events.reserve(capture.frames().size()); + for (const CapturedFrame& frame : capture.frames()) { + events.push_back(ReplayEvent{frame.ts_recv, frame.payload}); + } + + ReplayOptions replay_options; + replay_options.speed = options.speed; + const ReplayResult result = replay_open_loop( + events, [&](const ReplayEvent& event) { (void)feed.handle(event.frame); }, + replay_options); + + std::printf("\nopen-loop replay at %.1fx\n", options.speed); + std::printf(" events %llu\n", + static_cast(result.events)); + std::printf(" kept pace %s\n", result.kept_pace() ? "yes" : "NO"); + std::printf(" latency p50/p99 %llu ns / %llu ns\n", + static_cast(result.latency.p50), + static_cast(result.latency.p99)); + std::printf(" latency p99.9/max %llu ns / %llu ns\n", + static_cast(result.latency.p999), + static_cast(result.latency.max)); + } else { + for (const CapturedFrame& frame : capture.frames()) { + (void)feed.handle(frame.payload); + } + } + + print_report(feed, options, static_cast(steady_ns() - start) / 1e9); + return verdict(feed); +} + +int run_replay(Options& options) { + using namespace crossbook; + + Capture capture; + std::string error; + if (!capture.load(options.replay_path, error)) { + std::fprintf(stderr, "error: %s\n", error.c_str()); + return 1; + } + if (capture.empty()) { + std::fprintf(stderr, "error: capture contains no frames\n"); + return 1; + } + + options.venue = capture.venue(); + options.symbol = capture.symbol(); + std::printf("replaying %s: %s %s, %zu frames, median gap %.2f ms\n", + options.replay_path.c_str(), capture.venue().c_str(), capture.symbol().c_str(), + capture.frames().size(), static_cast(capture.median_gap_ns()) / 1e6); + + const bool binance = capture.venue().starts_with("binance"); + + // Read the scales off the capture's own snapshot, so a capture is + // self-describing and a replay needs no arguments beyond the file. + int price_scale = options.price_scale; + int qty_scale = options.qty_scale; + int inferred_depth = 0; + { + bool found = false; + for (const CapturedFrame& frame : capture.frames()) { + int inferred_price = 0; + int inferred_qty = 0; + int depth = 0; + if (infer_scales(frame.payload, "price", "qty", binance, inferred_price, inferred_qty, + &depth)) { + if (price_scale < 0) { + price_scale = inferred_price; + } + if (qty_scale < 0) { + qty_scale = inferred_qty; + } + inferred_depth = depth; + found = true; + break; + } + } + if (!found) { + std::fprintf(stderr, "error: no frame in the capture carries price levels\n"); + return 1; + } + std::printf("inferred scales: price 10^-%d, qty 10^-%d, depth %d\n", price_scale, + qty_scale, inferred_depth); + } + options.price_scale = price_scale; + options.qty_scale = qty_scale; + + const InstrumentSpec spec{options.symbol, static_cast(price_scale), + static_cast(qty_scale)}; + + if (binance) { + const venues::BinanceMarket market = capture.venue() == "binance-futures" + ? venues::BinanceMarket::kFutures + : venues::BinanceMarket::kSpot; + venues::BinanceDepthDecoder decoder(spec, market); + const SequencePolicy policy = decoder.policy(); + // Binance depth streams are diffs over a full book, not a top-N view, + // so there is nothing to trim. + Feed feed(capture.venue(), std::move(decoder), policy); + return run_replay_with(feed, capture, options); + } + + Feed feed("kraken", venues::KrakenBookDecoder(spec), + SequencePolicy::kStrictIncrement, + static_cast(inferred_depth)); + options.depth = inferred_depth; + return run_replay_with(feed, capture, options); +} + +void print_usage() { + std::printf( + "crossbook_verify - rebuild a book live and check it against the venue's own checksum\n" + "\n" + "Usage:\n" + " crossbook_verify [--venue kraken] [--symbol BTC/USD] [--seconds 30] [--capture f]\n" + " crossbook_verify --replay [--speed 1.0]\n" + "\n" + "Options:\n" + " --venue kraken (the only venue publishing a checksum)\n" + " --symbol instrument, in the venue's own spelling (default: BTC/USD)\n" + " --depth book depth to subscribe to (default: 10)\n" + " --seconds run for n seconds; 0 runs until Ctrl-C (default: 30)\n" + " --capture also record the raw feed, for later replay\n" + " --replay verify a recorded capture instead of connecting\n" + " --speed replay open-loop at x times the recorded rate, and\n" + " report latency percentiles measured against the schedule\n" + " --price-scale override the inferred price precision\n" + " --qty-scale override the inferred quantity precision\n" + " --quiet suppress the progress line\n" + "\n" + "Exits non-zero if any checksum mismatched, any frame failed to decode, or\n" + "nothing was verified at all.\n"); +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + + for (int i = 1; i < argc; ++i) { + const std::string_view arg(argv[i]); + auto value = [&](const char* what) -> const char* { + if (i + 1 >= argc) { + std::fprintf(stderr, "error: %s needs a value\n", what); + std::exit(2); + } + return argv[++i]; + }; + + if (arg == "--help" || arg == "-h") { + print_usage(); + return 0; + } else if (arg == "--venue") { + options.venue = value("--venue"); + } else if (arg == "--symbol") { + options.symbol = value("--symbol"); + } else if (arg == "--replay") { + options.replay_path = value("--replay"); + } else if (arg == "--capture") { + options.capture_path = value("--capture"); + } else if (arg == "--seconds") { + options.seconds = std::atoi(value("--seconds")); + } else if (arg == "--depth") { + options.depth = std::atoi(value("--depth")); + } else if (arg == "--price-scale") { + options.price_scale = std::atoi(value("--price-scale")); + } else if (arg == "--qty-scale") { + options.qty_scale = std::atoi(value("--qty-scale")); + } else if (arg == "--speed") { + options.speed = std::atof(value("--speed")); + } else if (arg == "--quiet") { + options.quiet = true; + } else { + std::fprintf(stderr, "error: unknown option %.*s\n", static_cast(arg.size()), + arg.data()); + print_usage(); + return 2; + } + } + + (void)std::signal(SIGINT, on_signal); +#ifdef SIGTERM + (void)std::signal(SIGTERM, on_signal); +#endif + + if (!options.replay_path.empty()) { + return run_replay(options); + } + if (options.venue != "kraken") { + // Binance publishes no checksum, so there is nothing to verify against + // live beyond sequence continuity. Saying so is better than printing a + // match rate over zero checks. + std::fprintf(stderr, + "error: only kraken publishes a checksum to verify against live.\n" + " use crossbook_capture for other venues, then replay the capture.\n"); + return 2; + } + return run_kraken_live(options); +} diff --git a/tools/tool_common.hpp b/tools/tool_common.hpp new file mode 100644 index 0000000..819e789 --- /dev/null +++ b/tools/tool_common.hpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Josh Dardashti +// +// Shared plumbing for the command-line tools. +// +// Deliberately not part of the library: this is operational policy — how long +// to wait, how many times to try — and policy belongs to the program making the +// decision, not to the protocol implementation underneath it. + +#pragma once + +#include +#include +#include +#include +#include + +#include "crossbook/net/websocket.hpp" + +namespace crossbook::tools { + +/// Connect, retrying with exponential backoff. +/// +/// Not defensive padding. Venues throttle repeated connections from one address +/// — reconnecting in a tight loop after a disconnect is the behaviour that earns +/// the throttle in the first place — and a stall during the opening handshake is +/// what that throttling looks like from the client side. Backing off is the +/// documented way to behave, and a verifier that gives up on the first refused +/// connection cannot be pointed at a long run. +[[nodiscard]] inline bool connect_with_backoff(net::WebSocketClient& client, + const std::string& url, int attempts = 4) { + int delay_ms = 1000; + for (int attempt = 1; attempt <= attempts; ++attempt) { + if (client.connect(url)) { + return true; + } + if (attempt == attempts) { + break; + } + std::fprintf(stderr, "connect attempt %d/%d failed (%s); retrying in %.1fs\n", attempt, + attempts, client.last_error().c_str(), delay_ms / 1000.0); + std::this_thread::sleep_for(std::chrono::milliseconds(delay_ms)); + delay_ms *= 2; + } + return false; +} + +} // namespace crossbook::tools