From a56796e5f9cb2e0198ba6d2f205420317b7e45cc Mon Sep 17 00:00:00 2001 From: Josh <135767837+jdardash@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:44:24 -0700 Subject: [PATCH 1/4] feat(net): TLS websocket transport and live capture Lands the transport the README listed as in progress: crossbook now connects to a venue itself rather than only reconstructing books from events it is handed. Everything difficult is pure and testable without a network - RFC 6455 framing, the handshake, URL parsing, the capture format - and only the socket and TLS are platform code, quarantined in a separate optional target. The header-only library keeps its zero dependencies; consuming crossbook does not pull any of this in. - ws_frame.hpp: RFC 6455 codec. Rejects reserved bits, non-minimal lengths, fragmented and oversized control frames, and masked server frames. Failures latch: there is no resynchronisation point in a length-prefixed stream. - handshake.hpp: SHA-1 and base64 from scratch so Sec-WebSocket-Accept is verified rather than assumed. Checked against the RFC 6455 and FIPS 180-4 vectors. - transport: Schannel on Windows (ships with the OS, so the tools build on a stock machine), OpenSSL elsewhere with explicit hostname verification. - capture.hpp: length-prefixed capture format, so a live measurement can be replayed byte-identically by someone else. - crossbook_capture: connects to Kraken or Binance with no API key. 52 new test cases and a fuzz target for the frame reader, which is the one parser here exposed to unframed bytes off a socket. --- CMakeLists.txt | 35 ++ fuzz/CMakeLists.txt | 1 + fuzz/fuzz_ws_frame.cpp | 117 ++++++ include/crossbook/capture.hpp | 293 +++++++++++++++ include/crossbook/net/handshake.hpp | 350 ++++++++++++++++++ include/crossbook/net/transport.hpp | 121 ++++++ include/crossbook/net/url.hpp | 138 +++++++ include/crossbook/net/websocket.hpp | 99 +++++ include/crossbook/net/ws_frame.hpp | 546 ++++++++++++++++++++++++++++ src/net/CMakeLists.txt | 31 ++ src/net/tcp_socket.cpp | 285 +++++++++++++++ src/net/tcp_socket.hpp | 71 ++++ src/net/tls_backend.hpp | 18 + src/net/tls_openssl.cpp | 229 ++++++++++++ src/net/tls_schannel.cpp | 541 +++++++++++++++++++++++++++ src/net/transport.cpp | 250 +++++++++++++ src/net/websocket.cpp | 301 +++++++++++++++ tests/CMakeLists.txt | 10 + tests/test_capture.cpp | 150 ++++++++ tests/test_handshake.cpp | 166 +++++++++ tests/test_url.cpp | 70 ++++ tests/test_ws_frame.cpp | 355 ++++++++++++++++++ tools/CMakeLists.txt | 7 + tools/crossbook_capture.cpp | 328 +++++++++++++++++ 24 files changed, 4512 insertions(+) create mode 100644 fuzz/fuzz_ws_frame.cpp create mode 100644 include/crossbook/capture.hpp create mode 100644 include/crossbook/net/handshake.hpp create mode 100644 include/crossbook/net/transport.hpp create mode 100644 include/crossbook/net/url.hpp create mode 100644 include/crossbook/net/websocket.hpp create mode 100644 include/crossbook/net/ws_frame.hpp create mode 100644 src/net/CMakeLists.txt create mode 100644 src/net/tcp_socket.cpp create mode 100644 src/net/tcp_socket.hpp create mode 100644 src/net/tls_backend.hpp create mode 100644 src/net/tls_openssl.cpp create mode 100644 src/net/tls_schannel.cpp create mode 100644 src/net/transport.cpp create mode 100644 src/net/websocket.cpp create mode 100644 tests/test_capture.cpp create mode 100644 tests/test_handshake.cpp create mode 100644 tests/test_url.cpp create mode 100644 tests/test_ws_frame.cpp create mode 100644 tools/CMakeLists.txt create mode 100644 tools/crossbook_capture.cpp 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/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/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/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..3fd29e5 --- /dev/null +++ b/include/crossbook/net/transport.hpp @@ -0,0 +1,121 @@ +// 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; + + 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..5cd6e5e --- /dev/null +++ b/include/crossbook/net/websocket.hpp @@ -0,0 +1,99 @@ +// 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. + /// + /// `timeout_ms` bounds both the connect and each subsequent read; it is not + /// a deadline for the whole session. + [[nodiscard]] bool connect(std::string_view url, int timeout_ms = 10'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..48f4c15 --- /dev/null +++ b/src/net/tcp_socket.cpp @@ -0,0 +1,285 @@ +// 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, sizeof(one)); +#endif + +#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, sizeof(tv)); + (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); +#endif + + error.clear(); + return true; +} + +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..53371bf --- /dev/null +++ b/src/net/tcp_socket.hpp @@ -0,0 +1,71 @@ +// 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 +#endif + +namespace crossbook::net::detail { + +#ifdef _WIN32 +using SocketHandle = ::SOCKET; +inline constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +inline constexpr SocketHandle kInvalidSocket = -1; +#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); + + /// 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..d065572 --- /dev/null +++ b/src/net/tls_openssl.cpp @@ -0,0 +1,229 @@ +// 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 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..92c51a6 --- /dev/null +++ b/src/net/tls_schannel.cpp @@ -0,0 +1,541 @@ +// 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 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..02a849e --- /dev/null +++ b/src/net/transport.cpp @@ -0,0 +1,250 @@ +// 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 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..54a4e10 --- /dev/null +++ b/src/net/websocket.cpp @@ -0,0 +1,301 @@ +// 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 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, 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, timeout_ms)) { + transport_->close(); + return false; + } + + 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(); + + send_buf_.clear(); + send_buf_.resize(kMaxHeaderSize + payload.size()); + + const std::size_t header_size = + write_frame_header(send_buf_.data(), opcode, payload.size(), mask_key); + + 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); + } + + const std::size_t total = header_size + payload.size(); + 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..82ff308 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,10 @@ 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 ) target_link_libraries(crossbook_tests PRIVATE @@ -31,6 +35,12 @@ 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}" +) + list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) catch_discover_tests(crossbook_tests) 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_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..1ad251f --- /dev/null +++ b/tests/test_ws_frame.cpp @@ -0,0 +1,355 @@ +// 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 "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..dbae500 --- /dev/null +++ b/tools/CMakeLists.txt @@ -0,0 +1,7 @@ +add_executable(crossbook_capture crossbook_capture.cpp) + +target_link_libraries(crossbook_capture PRIVATE + crossbook::crossbook + crossbook::net + crossbook_warnings +) diff --git a/tools/crossbook_capture.cpp b/tools/crossbook_capture.cpp new file mode 100644 index 0000000..c4d9796 --- /dev/null +++ b/tools/crossbook_capture.cpp @@ -0,0 +1,328 @@ +// 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" + +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()); + + // A generous connect timeout that then becomes the per-read timeout, which + // is what makes the poll loop return often enough to notice Ctrl-C and the + // run deadline even when the market is silent. + if (!client.connect(venue.url, 5'000)) { + 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; +} From 5dece2bded48e978a466155d9e8d593731767c01 Mon Sep 17 00:00:00 2001 From: Josh <135767837+jdardash@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:14:47 -0700 Subject: [PATCH 2/4] fix(book): trim depth-limited books, or their checksums eventually fail Found by pointing crossbook_verify at Kraken rather than by a test. 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 depth-limited contract has a gap that is easy to miss. 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, minutes after the actual divergence. - BasicL2Book::trim keeps the levels nearest the touch and drops the rest. Allocation-free: removals batch through a stack buffer and the loop repeats if one pass cannot name them all. - Feed takes a depth and trims BEFORE verifying, since the stale level the checksum is about to catch is exactly the one trimming removes. - FeedStats::levels_trimmed, so a depth that was never configured is visible rather than silent. Verified on both book implementations, including prices spread wide enough to push levels into the array book's overflow map. --- include/crossbook/book.hpp | 56 +++++++++++++++ include/crossbook/feed.hpp | 22 +++++- tests/test_depth_trim.cpp | 141 +++++++++++++++++++++++++++++++++++++ 3 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 tests/test_depth_trim.cpp 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/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/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. +} From a03542bdaae5633b2a515e4f2a252d6f0e3121e3 Mon Sep 17 00:00:00 2001 From: Josh <135767837+jdardash@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:15:02 -0700 Subject: [PATCH 3/4] feat(tools): crossbook_verify, and a committed capture CI replays Makes the README's central claim executable and checkable by someone who is not me. crossbook_verify connects to Kraken with no API key, rebuilds the book, recomputes the exchange's CRC32 over local state on every update, and reports the match rate with every divergence enumerated. Exit status is non-zero on any mismatch, decode failure, or resync - a verifier that reports problems and then exits successfully is one nobody will wire into anything. Measured over three minutes on BTC/USD: 2759 of 2759 checksums matched. The part that matters more than the number: tests/fixtures/kraken_btcusd_l2.cbcap is 72 KB of verbatim Kraken bytes, committed, and replays to 301 of 301 matches and one fixed state hash on every platform. CI runs it offline on Linux, macOS and Windows, so the number is a regression test rather than an anecdote. This is only possible because crypto market data can be redistributed; the equities equivalent cannot, which is why public ITCH projects ship without runnable data. Also here: - Instrument scales and subscription depth are read off the venue's own spelling in the snapshot, so a capture is self-describing and a replay needs no arguments beyond the file. A wrong scale fails every checksum, and a hard-coded table rots silently when a venue changes precision. - Split handshake and read timeouts. One value cannot serve both: an opening handshake through a busy edge takes tens of seconds - measured against Kraken, sometimes over twenty, and curl agrees - while a steady-state read wants about a second so the poll loop stays responsive. The single-timeout version failed to connect during exactly the episodes it needed to ride out. - Connect retry with exponential backoff, because reconnecting in a tight loop is what earns a throttle in the first place. - The live CI job is manual-only. A green build must never depend on an exchange being reachable. --- .github/workflows/ci.yml | 75 +++ .gitignore | 7 +- README.md | 112 +++- include/crossbook/net/transport.hpp | 11 + include/crossbook/net/websocket.hpp | 15 +- src/net/tcp_socket.cpp | 13 +- src/net/tcp_socket.hpp | 3 + src/net/tls_openssl.cpp | 2 + src/net/tls_schannel.cpp | 2 + src/net/transport.cpp | 2 + src/net/websocket.cpp | 11 +- tests/CMakeLists.txt | 6 + tests/fixtures/kraken_btcusd_l2.cbcap | 755 ++++++++++++++++++++++++++ tests/test_fixture_replay.cpp | 128 +++++ tools/CMakeLists.txt | 15 +- tools/crossbook_capture.cpp | 6 +- tools/crossbook_verify.cpp | 645 ++++++++++++++++++++++ tools/tool_common.hpp | 48 ++ 18 files changed, 1829 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/kraken_btcusd_l2.cbcap create mode 100644 tests/test_fixture_replay.cpp create mode 100644 tools/crossbook_verify.cpp create mode 100644 tools/tool_common.hpp 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/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/include/crossbook/net/transport.hpp b/include/crossbook/net/transport.hpp index 3fd29e5..80cdeb1 100644 --- a/include/crossbook/net/transport.hpp +++ b/include/crossbook/net/transport.hpp @@ -86,6 +86,17 @@ class Transport { /// 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; diff --git a/include/crossbook/net/websocket.hpp b/include/crossbook/net/websocket.hpp index 5cd6e5e..746a175 100644 --- a/include/crossbook/net/websocket.hpp +++ b/include/crossbook/net/websocket.hpp @@ -54,9 +54,18 @@ class WebSocketClient { /// Dial `url` (ws:// or wss://) and complete the opening handshake. /// - /// `timeout_ms` bounds both the connect and each subsequent read; it is not - /// a deadline for the whole session. - [[nodiscard]] bool connect(std::string_view url, int timeout_ms = 10'000); + /// 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. /// diff --git a/src/net/tcp_socket.cpp b/src/net/tcp_socket.cpp index 48f4c15..53eb68b 100644 --- a/src/net/tcp_socket.cpp +++ b/src/net/tcp_socket.cpp @@ -171,6 +171,16 @@ bool TcpSocket::connect(const std::string& host, std::uint16_t port, int timeout (void)::setsockopt(fd_, SOL_SOCKET, SO_NOSIGPIPE, &one, 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); @@ -185,9 +195,6 @@ bool TcpSocket::connect(const std::string& host, std::uint16_t port, int timeout (void)::setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); #endif - - error.clear(); - return true; } IoStatus TcpSocket::read(char* buf, std::size_t len, std::size_t& n_read, std::string& error) { diff --git a/src/net/tcp_socket.hpp b/src/net/tcp_socket.hpp index 53371bf..6d3f35a 100644 --- a/src/net/tcp_socket.hpp +++ b/src/net/tcp_socket.hpp @@ -56,6 +56,9 @@ class TcpSocket { [[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); diff --git a/src/net/tls_openssl.cpp b/src/net/tls_openssl.cpp index d065572..8c8ee8a 100644 --- a/src/net/tls_openssl.cpp +++ b/src/net/tls_openssl.cpp @@ -166,6 +166,8 @@ class OpenSslTransport final : public Transport { 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 diff --git a/src/net/tls_schannel.cpp b/src/net/tls_schannel.cpp index 92c51a6..e33e8b7 100644 --- a/src/net/tls_schannel.cpp +++ b/src/net/tls_schannel.cpp @@ -263,6 +263,8 @@ class SchannelTransport final : public Transport { 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_); diff --git a/src/net/transport.cpp b/src/net/transport.cpp index 02a849e..f4f28a4 100644 --- a/src/net/transport.cpp +++ b/src/net/transport.cpp @@ -47,6 +47,8 @@ class PlainTransport final : public Transport { return status; } + void set_read_timeout(int timeout_ms) override { socket_.set_read_timeout(timeout_ms); } + void close() override { socket_.close(); connected_ = false; diff --git a/src/net/websocket.cpp b/src/net/websocket.cpp index 54a4e10..6633120 100644 --- a/src/net/websocket.cpp +++ b/src/net/websocket.cpp @@ -54,7 +54,8 @@ std::uint32_t WebSocketClient::next_mask_key() { return static_cast(rng_()); } -bool WebSocketClient::connect(std::string_view url_text, int timeout_ms) { +bool WebSocketClient::connect(std::string_view url_text, int handshake_timeout_ms, + int read_timeout_ms) { error_.clear(); reader_.reset(); stats_ = WebSocketStats{}; @@ -71,7 +72,7 @@ bool WebSocketClient::connect(std::string_view url_text, int timeout_ms) { error_ = "no TLS backend in this build"; return false; } - if (!transport_->connect(url_.host, url_.port, timeout_ms)) { + if (!transport_->connect(url_.host, url_.port, handshake_timeout_ms)) { error_ = transport_->last_error(); return false; } @@ -94,11 +95,15 @@ bool WebSocketClient::connect(std::string_view url_text, int timeout_ms) { return false; } - if (!complete_handshake(expected_accept, timeout_ms)) { + 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; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 82ff308..b366846 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -28,6 +28,8 @@ add_executable(crossbook_tests test_url.cpp test_handshake.cpp test_capture.cpp + test_depth_trim.cpp + test_fixture_replay.cpp ) target_link_libraries(crossbook_tests PRIVATE @@ -39,6 +41,10 @@ target_link_libraries(crossbook_tests PRIVATE # 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) 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_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/tools/CMakeLists.txt b/tools/CMakeLists.txt index dbae500..968707c 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -1,7 +1,8 @@ -add_executable(crossbook_capture crossbook_capture.cpp) - -target_link_libraries(crossbook_capture PRIVATE - crossbook::crossbook - crossbook::net - crossbook_warnings -) +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 index c4d9796..5522853 100644 --- a/tools/crossbook_capture.cpp +++ b/tools/crossbook_capture.cpp @@ -27,6 +27,7 @@ #include "crossbook/capture.hpp" #include "crossbook/net/websocket.hpp" +#include "tool_common.hpp" namespace { @@ -198,10 +199,7 @@ int main(int argc, char** argv) { crossbook::net::WebSocketClient client; std::printf("connecting to %s\n", venue.url.c_str()); - // A generous connect timeout that then becomes the per-read timeout, which - // is what makes the poll loop return often enough to notice Ctrl-C and the - // run deadline even when the market is silent. - if (!client.connect(venue.url, 5'000)) { + if (!crossbook::tools::connect_with_backoff(client, venue.url)) { std::fprintf(stderr, "error: %s\n", client.last_error().c_str()); return 1; } diff --git a/tools/crossbook_verify.cpp b/tools/crossbook_verify.cpp new file mode 100644 index 0000000..43a9777 --- /dev/null +++ b/tools/crossbook_verify.cpp @@ -0,0 +1,645 @@ +// 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 "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 From 4c79da777a20f1a824b301c933b99bbfc63be526 Mon Sep 17 00:00:00 2001 From: Josh <135767837+jdardash@users.noreply.github.com> Date: Fri, 31 Jul 2026 20:22:55 -0700 Subject: [PATCH 4/4] fix(net): build clean on GCC and Clang Three POSIX-only failures, none of which MSVC can see. - socklen_t is unsigned on POSIX and a signed int on Winsock, so every setsockopt and connect length was a sign conversion under -Werror. Named the difference as a `SockLen` alias rather than sprinkling casts that happen to be right on one platform. - GCC 13 rejected `clear()` then `resize()` on the send buffer under -Wstringop-overflow, unable to prove the frame header had room. The header now goes into a fixed std::array whose bound is in the type. Better code for the same reason the warning fired. - Two includes MSVC supplies transitively and libstdc++ does not. --- src/net/tcp_socket.cpp | 14 +++++++------- src/net/tcp_socket.hpp | 7 +++++++ src/net/websocket.cpp | 16 +++++++++++----- tests/test_ws_frame.cpp | 1 + tools/crossbook_verify.cpp | 1 + 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/net/tcp_socket.cpp b/src/net/tcp_socket.cpp index 53eb68b..35292ad 100644 --- a/src/net/tcp_socket.cpp +++ b/src/net/tcp_socket.cpp @@ -139,7 +139,7 @@ bool TcpSocket::connect(const std::string& host, std::uint16_t port, int timeout continue; } - if (::connect(fd, it->ai_addr, static_cast(it->ai_addrlen)) != 0) { + if (::connect(fd, it->ai_addr, static_cast(it->ai_addrlen)) != 0) { last_failure = socket_error_string("connect"); #ifdef _WIN32 ::closesocket(fd); @@ -164,11 +164,11 @@ bool TcpSocket::connect(const std::string& host, std::uint16_t port, int timeout // latency-relevant; there is nothing to coalesce. int one = 1; (void)::setsockopt(fd_, IPPROTO_TCP, TCP_NODELAY, reinterpret_cast(&one), - static_cast(sizeof(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, sizeof(one)); + (void)::setsockopt(fd_, SOL_SOCKET, SO_NOSIGPIPE, &one, static_cast(sizeof(one))); #endif set_read_timeout(timeout_ms); @@ -185,15 +185,15 @@ void TcpSocket::set_read_timeout(int timeout_ms) noexcept { // 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))); + static_cast(sizeof(ms))); (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&ms), - static_cast(sizeof(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, sizeof(tv)); - (void)::setsockopt(fd_, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)); + (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 } diff --git a/src/net/tcp_socket.hpp b/src/net/tcp_socket.hpp index 6d3f35a..20718ed 100644 --- a/src/net/tcp_socket.hpp +++ b/src/net/tcp_socket.hpp @@ -21,6 +21,8 @@ #include #include // clang-format on +#else +#include #endif namespace crossbook::net::detail { @@ -28,9 +30,14 @@ 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)". diff --git a/src/net/websocket.cpp b/src/net/websocket.cpp index 6633120..077fd0f 100644 --- a/src/net/websocket.cpp +++ b/src/net/websocket.cpp @@ -250,18 +250,24 @@ bool WebSocketClient::send_frame(Opcode opcode, std::string_view payload) { const std::uint32_t mask_key = next_mask_key(); - send_buf_.clear(); - send_buf_.resize(kMaxHeaderSize + payload.size()); - + // 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(send_buf_.data(), opcode, payload.size(), mask_key); + 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); } - const std::size_t total = header_size + payload.size(); if (transport_->write(send_buf_.data(), total) != IoStatus::kOk) { error_ = transport_->last_error().empty() ? "frame write failed" : transport_->last_error(); diff --git a/tests/test_ws_frame.cpp b/tests/test_ws_frame.cpp index 1ad251f..d6a5438 100644 --- a/tests/test_ws_frame.cpp +++ b/tests/test_ws_frame.cpp @@ -9,6 +9,7 @@ #include +#include #include #include #include diff --git a/tools/crossbook_verify.cpp b/tools/crossbook_verify.cpp index 43a9777..9cc25bb 100644 --- a/tools/crossbook_verify.cpp +++ b/tools/crossbook_verify.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include