From 8666268f4a21475a6efc13001360cab4930262e5 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Wed, 10 Jun 2026 08:22:55 -0400 Subject: [PATCH] Migrate to Zig 0.16: std.Io transports, minimum version 0.16.0 (closes #55) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zig 0.16 moved networking, HTTP, sleep, and clocks behind the new std.Io interface, which broke the entire transport layer at compile time (hidden until now by lazy analysis, since unit tests never touch network code paths). New src/runtime.zig (eth.runtime) keeps every public signature unchanged by constructing a default Io internally (std.Io.Threaded.global_single_threaded — synchronous on the calling thread, matching the old blocking behavior), and provides milliTimestamp()/sleepMs() replacements for the removed std APIs. - http_transport, flashbots, sse_transport: http.Client now takes io - ws_transport: std.Io.net Stream/IpAddress/HostName, Io reader/writer framing (readVec to preserve recv-like short reads), TLS client rebuilt against the new init options (bundle, entropy, realtime_now) - ws_client, retry_provider, wallet, sse: sleeps and clocks via runtime - mnemonic: entropy via Io random (latent break, now pinned by a test) - keccak: comptime branch quota raised for 0.16's stdlib permutation - bench/examples: timers, args, stdout migrated; three pre-existing example bugs fixed (wrong file name in build.zig, missing try, unhandled optional) - CI matrix 0.15.2 -> 0.16.0 (master unchanged); README/CONTRIBUTING prerequisites and build.zig.zon minimum_zig_version updated Breaking: minimum supported Zig is now 0.16.0. Verified on 0.16.0 and 0.17.0-dev.813: build, unit tests, fmt, and 23/23 Anvil integration tests (HTTP, WS subscriptions, LogWatcher). --- .github/workflows/ci.yml | 6 +- CHANGELOG.md | 4 + CONTRIBUTING.md | 2 +- README.md | 4 +- bench/bench.zig | 28 +++++- bench/keccak_bench_cli.zig | 4 +- bench/keccak_compare.zig | 26 +++++- bench/u256_bench.zig | 26 +++++- build.zig.zon | 2 +- examples/01_derive_address.zig | 2 +- examples/02_check_balance.zig | 4 +- examples/03_sign_message.zig | 2 +- examples/04_send_transaction.zig | 6 +- examples/05_read_erc20.zig | 4 +- examples/06_hd_wallet.zig | 4 +- examples/07_selectors.zig | 2 +- examples/build.zig | 2 +- examples/build.zig.zon | 2 +- src/flashbots.zig | 3 +- src/http_transport.zig | 3 +- src/keccak.zig | 5 +- src/mnemonic.zig | 9 +- src/retry_provider.zig | 15 +++- src/root.zig | 2 + src/runtime.zig | 47 +++++++++++ src/sse_transport.zig | 7 +- src/wallet.zig | 3 +- src/ws_client.zig | 19 +++-- src/ws_transport.zig | 141 ++++++++++++++++++++----------- tests/integration_tests.zig | 8 +- 30 files changed, 291 insertions(+), 101 deletions(-) create mode 100644 src/runtime.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1537ccb..31558cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, macos-latest] - zig-version: ["0.15.2", "master"] + zig-version: ["0.16.0", "master"] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -30,7 +30,7 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v2 with: - version: "0.15.2" + version: "0.16.0" - name: Check formatting run: zig fmt --check src/ tests/ @@ -44,6 +44,6 @@ jobs: - uses: actions/checkout@v4 - uses: mlugg/setup-zig@v2 with: - version: "0.15.2" + version: "0.16.0" - name: Build library run: zig build diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ecdaf5..2bbec28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- Minimum supported Zig version is now 0.16.0. Transports (HTTP, WebSocket, SSE), retry backoff, and receipt polling were migrated to the new `std.Io` interface; the library constructs a default blocking `std.Io` internally, so public signatures are unchanged. This is a breaking change for Zig 0.15 users +- New `eth.runtime` module exposes the library's default `std.Io` (`defaultIo()`) plus `milliTimestamp()` and `sleepMs()` helpers replacing the removed `std.time.milliTimestamp` and `std.Thread.sleep` + ### Added - `log_watcher`: block-scoped log watching (#36). `LogWatcher.pollOnce()` drives per-block `eth_getLogs` from a `newHeads` subscription, back-fills blocks missed across reconnects, and re-fetches reorged ranges on parent-hash mismatch; `watchLogs` offers a callback loop - `provider.parseBlockHeaderObject`: parse a `BlockHeader` from a bare JSON object diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 657fd14..ea8ab93 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in contributing to eth.zig! This document covers everyt ## Prerequisites -- [Zig >= 0.15.2](https://ziglang.org/download/) +- [Zig >= 0.16.0](https://ziglang.org/download/) - Git ## Getting Started diff --git a/README.md b/README.md index 3b14d14..729ff40 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/strobelabs/eth.zig/actions/workflows/ci.yml/badge.svg)](https://github.com/strobelabs/eth.zig/actions/workflows/ci.yml) [![Docs](https://img.shields.io/badge/docs-ethzig.org-blue)](https://ethzig.org) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Zig](https://img.shields.io/badge/Zig-%E2%89%A5%200.15.2-orange)](https://ziglang.org/) +[![Zig](https://img.shields.io/badge/Zig-%E2%89%A5%200.16.0-orange)](https://ziglang.org/) **The fastest Ethereum library.** Beats Rust's alloy.rs on 23 out of 26 benchmarks. @@ -335,7 +335,7 @@ cd examples && zig build && ./zig-out/bin/01_derive_address ## Requirements -- Zig >= 0.15.2 +- Zig >= 0.16.0 ## Running Tests diff --git a/bench/bench.zig b/bench/bench.zig index ed74532..7829db4 100644 --- a/bench/bench.zig +++ b/bench/bench.zig @@ -52,7 +52,29 @@ var precomputed_pubkey: [65]u8 = undefined; const WARMUP_NS: u64 = 500_000_000; // 0.5s warmup const BENCH_NS: u64 = 2_000_000_000; // 2s measurement -const Timer = std.time.Timer; +/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. +const Timer = struct { + start_ns: i96, + + fn now() i96 { + const io = std.Io.Threaded.global_single_threaded.io(); + return std.Io.Clock.now(.awake, io).nanoseconds; + } + + fn start() error{}!Timer { + return .{ .start_ns = now() }; + } + + fn reset(self: *Timer) void { + self.start_ns = now(); + } + + fn read(self: *Timer) u64 { + const elapsed = now() - self.start_ns; + if (elapsed < 0) return 0; + return @intCast(elapsed); + } +}; const BenchResult = struct { ns_per_op: u64, @@ -453,7 +475,7 @@ fn benchEip712Hash() void { // ============================================================================ pub fn main() !void { - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -471,7 +493,7 @@ pub fn main() !void { precomputed_pubkey = eth.secp256k1.derivePublicKey(TEST_PRIVKEY) catch unreachable; var out_buf: [8192]u8 = undefined; - var w = std.fs.File.stdout().writer(&out_buf); + var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &out_buf); const stdout = &w.interface; try stdout.print("\n{s:<34} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "iters" }); diff --git a/bench/keccak_bench_cli.zig b/bench/keccak_bench_cli.zig index 43c17c8..cfbdaf2 100644 --- a/bench/keccak_bench_cli.zig +++ b/bench/keccak_bench_cli.zig @@ -1,8 +1,8 @@ const std = @import("std"); const eth = @import("eth"); -pub fn main() !void { - var args = std.process.args(); +pub fn main(init: std.process.Init.Minimal) !void { + var args = std.process.Args.Iterator.init(init.args); _ = args.next(); // skip program name const size_str = args.next() orelse "32"; diff --git a/bench/keccak_compare.zig b/bench/keccak_compare.zig index 8ea85d5..244013e 100644 --- a/bench/keccak_compare.zig +++ b/bench/keccak_compare.zig @@ -13,7 +13,29 @@ const DATA_4K: [4096]u8 = @splat(0xAB); const WARMUP_NS: u64 = 500_000_000; const BENCH_NS: u64 = 2_000_000_000; -const Timer = std.time.Timer; +/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. +const Timer = struct { + start_ns: i96, + + fn now() i96 { + const io = std.Io.Threaded.global_single_threaded.io(); + return std.Io.Clock.now(.awake, io).nanoseconds; + } + + fn start() error{}!Timer { + return .{ .start_ns = now() }; + } + + fn reset(self: *Timer) void { + self.start_ns = now(); + } + + fn read(self: *Timer) u64 { + const elapsed = now() - self.start_ns; + if (elapsed < 0) return 0; + return @intCast(elapsed); + } +}; const BenchResult = struct { ns_per_op: u64, iters: u64 }; @@ -111,7 +133,7 @@ fn benchStdlibKeccak4k() void { pub fn main() !void { var out_buf: [8192]u8 = undefined; - var w = std.fs.File.stdout().writer(&out_buf); + var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &out_buf); const stdout = &w.interface; try stdout.print("\n{s:<30} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "iters" }); diff --git a/bench/u256_bench.zig b/bench/u256_bench.zig index b87c6e3..f4de0c2 100644 --- a/bench/u256_bench.zig +++ b/bench/u256_bench.zig @@ -25,7 +25,29 @@ const FULL_C: u256 = 0x00000001_00000000_00000000_00000000_00000000_00000000_000 const WARMUP_NS: u64 = 500_000_000; // 0.5s warmup const BENCH_NS: u64 = 2_000_000_000; // 2s measurement -const Timer = std.time.Timer; +/// Minimal replacement for std.time.Timer, which was removed in Zig 0.16. +const Timer = struct { + start_ns: i96, + + fn now() i96 { + const io = std.Io.Threaded.global_single_threaded.io(); + return std.Io.Clock.now(.awake, io).nanoseconds; + } + + fn start() error{}!Timer { + return .{ .start_ns = now() }; + } + + fn reset(self: *Timer) void { + self.start_ns = now(); + } + + fn read(self: *Timer) u64 { + const elapsed = now() - self.start_ns; + if (elapsed < 0) return 0; + return @intCast(elapsed); + } +}; const BenchResult = struct { ns_per_op: u64, @@ -232,7 +254,7 @@ fn runAndJson(comptime name: []const u8, comptime func: fn () void, stdout: anyt pub fn main() !void { var buf: [8192]u8 = undefined; - var w = std.fs.File.stdout().writer(&buf); + var w = std.Io.File.stdout().writerStreaming(std.Io.Threaded.global_single_threaded.io(), &buf); const stdout = &w.interface; try stdout.print("\n{s:<32} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "iters" }); diff --git a/build.zig.zon b/build.zig.zon index f85fbf7..ad7eb27 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -2,7 +2,7 @@ .name = .eth_zig, .version = "0.4.0", .fingerprint = 0xd0f21900fa26f179, - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .dependencies = .{}, .paths = .{ "build.zig", diff --git a/examples/01_derive_address.zig b/examples/01_derive_address.zig index 71016dd..fe7445c 100644 --- a/examples/01_derive_address.zig +++ b/examples/01_derive_address.zig @@ -7,7 +7,7 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; // Hardhat/Anvil account #0 private key (DO NOT use in production) diff --git a/examples/02_check_balance.zig b/examples/02_check_balance.zig index fc151d4..92f2741 100644 --- a/examples/02_check_balance.zig +++ b/examples/02_check_balance.zig @@ -8,10 +8,10 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/examples/03_sign_message.zig b/examples/03_sign_message.zig index b42dbdf..341b29f 100644 --- a/examples/03_sign_message.zig +++ b/examples/03_sign_message.zig @@ -7,7 +7,7 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; // Hardhat/Anvil account #0 private key diff --git a/examples/04_send_transaction.zig b/examples/04_send_transaction.zig index 69f3bd1..74b73b7 100644 --- a/examples/04_send_transaction.zig +++ b/examples/04_send_transaction.zig @@ -8,10 +8,10 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); @@ -38,7 +38,7 @@ pub fn main() !void { // Send 0.1 ETH to account #1 const recipient = try eth.primitives.addressFromHex("0x70997970C51812dc3A010C7d01b50e0d17dc79C8"); const recipient_checksum = eth.primitives.addressToChecksum(&recipient); - const value = eth.units.parseEther(0.1); + const value = eth.units.parseEther(0.1) orelse return error.InvalidAmount; try stdout.print("Sending 0.1 ETH\n", .{}); try stdout.print(" From: {s}\n", .{sender_checksum}); diff --git a/examples/05_read_erc20.zig b/examples/05_read_erc20.zig index 5dede00..4391305 100644 --- a/examples/05_read_erc20.zig +++ b/examples/05_read_erc20.zig @@ -8,10 +8,10 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; - var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + var gpa: std.heap.DebugAllocator(.{}) = .init; defer _ = gpa.deinit(); const allocator = gpa.allocator(); diff --git a/examples/06_hd_wallet.zig b/examples/06_hd_wallet.zig index 5823822..bcab72f 100644 --- a/examples/06_hd_wallet.zig +++ b/examples/06_hd_wallet.zig @@ -8,7 +8,7 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; // Standard test mnemonic (DO NOT use in production) @@ -27,7 +27,7 @@ pub fn main() !void { // Derive first 5 accounts for (0..5) |i| { const key = try eth.hd_wallet.deriveEthAccount(seed, @intCast(i)); - const addr = key.toAddress(); + const addr = try key.toAddress(); const checksum = eth.primitives.addressToChecksum(&addr); try stdout.print(" [{d}] {s}\n", .{ i, checksum }); } diff --git a/examples/07_selectors.zig b/examples/07_selectors.zig index 68bdeb2..4e3b2da 100644 --- a/examples/07_selectors.zig +++ b/examples/07_selectors.zig @@ -9,7 +9,7 @@ const eth = @import("eth"); pub fn main() !void { var buf: [4096]u8 = undefined; - var stdout_impl = std.fs.File.stdout().writer(&buf); + var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf); const stdout = &stdout_impl.interface; // Comptime: evaluated at compile time, zero runtime cost diff --git a/examples/build.zig b/examples/build.zig index 726e12e..811065c 100644 --- a/examples/build.zig +++ b/examples/build.zig @@ -17,7 +17,7 @@ pub fn build(b: *std.Build) void { .{ "04_send_transaction", "04_send_transaction.zig" }, .{ "05_read_erc20", "05_read_erc20.zig" }, .{ "06_hd_wallet", "06_hd_wallet.zig" }, - .{ "07_comptime_selectors", "07_comptime_selectors.zig" }, + .{ "07_selectors", "07_selectors.zig" }, }; inline for (examples) |example| { diff --git a/examples/build.zig.zon b/examples/build.zig.zon index 9e82e0e..85d213b 100644 --- a/examples/build.zig.zon +++ b/examples/build.zig.zon @@ -2,7 +2,7 @@ .name = .eth_zig_examples, .version = "0.1.0", .fingerprint = 0xd73bf5facd267e8e, - .minimum_zig_version = "0.15.2", + .minimum_zig_version = "0.16.0", .dependencies = .{ .eth = .{ .path = "..", diff --git a/src/flashbots.zig b/src/flashbots.zig index f43c184..748ea3b 100644 --- a/src/flashbots.zig +++ b/src/flashbots.zig @@ -7,6 +7,7 @@ const primitives = @import("primitives.zig"); const uint256_mod = @import("uint256.zig"); const json_rpc = @import("json_rpc.zig"); const HttpTransport = @import("http_transport.zig").HttpTransport; +const runtime = @import("runtime.zig"); // ============================================================================ // Types @@ -210,7 +211,7 @@ pub const Relay = struct { .allocator = allocator, .url = url, .auth_signer = signer_mod.Signer.init(auth_key), - .client = .{ .allocator = allocator }, + .client = .{ .allocator = allocator, .io = runtime.defaultIo() }, .next_id = 1, }; } diff --git a/src/http_transport.zig b/src/http_transport.zig index 954f6f1..ee04ba9 100644 --- a/src/http_transport.zig +++ b/src/http_transport.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const runtime = @import("runtime.zig"); /// HTTP JSON-RPC transport layer. /// @@ -13,7 +14,7 @@ pub const HttpTransport = struct { return .{ .url = url, .allocator = allocator, - .client = .{ .allocator = allocator }, + .client = .{ .allocator = allocator, .io = runtime.defaultIo() }, }; } diff --git a/src/keccak.zig b/src/keccak.zig index 172a29e..3532059 100644 --- a/src/keccak.zig +++ b/src/keccak.zig @@ -16,7 +16,10 @@ pub const Keccak256 = StdlibKeccak256; /// Works at both comptime and runtime. pub fn hash(data: []const u8) Hash { if (@inComptime()) { - @setEvalBranchQuota(10000); + // Zig 0.16's stdlib Keccak permutation exceeds the default 1000-branch + // comptime quota even for a single short input (e.g. comptime function + // selectors), so raise it for the comptime path only. + @setEvalBranchQuota(100000); var result: Hash = undefined; StdlibKeccak256.hash(data, &result, .{}); return result; diff --git a/src/mnemonic.zig b/src/mnemonic.zig index e359906..3c030d3 100644 --- a/src/mnemonic.zig +++ b/src/mnemonic.zig @@ -1,5 +1,6 @@ const std = @import("std"); const keccak = @import("keccak.zig"); +const runtime = @import("runtime.zig"); /// BIP-39 mnemonic phrase generation and seed derivation. /// @@ -44,7 +45,7 @@ pub const ValidEntropySize = enum(u8) { /// Generate a random mnemonic phrase. pub fn generate(comptime entropy_size: ValidEntropySize) [entropy_size.wordCount()][]const u8 { var entropy: [@intFromEnum(entropy_size)]u8 = undefined; - std.crypto.random.bytes(&entropy); + runtime.defaultIo().random(&entropy); return entropyToMnemonic(entropy_size, &entropy); } @@ -292,6 +293,12 @@ test "validate rejects bad checksum" { try std.testing.expectError(error.InvalidChecksum, validate(&words)); } +test "generate produces a valid mnemonic" { + const words = generate(.@"128"); + try std.testing.expectEqual(@as(usize, 12), words.len); + try validate(&words); +} + test "entropyToMnemonic known vector" { // All-zero entropy should produce "abandon" x 11 + "about" const entropy = @as([16]u8, @splat(0)); diff --git a/src/retry_provider.zig b/src/retry_provider.zig index aa006b9..f9702b4 100644 --- a/src/retry_provider.zig +++ b/src/retry_provider.zig @@ -1,5 +1,6 @@ const std = @import("std"); const provider_mod = @import("provider.zig"); +const runtime = @import("runtime.zig"); const block_mod = @import("block.zig"); const receipt_mod = @import("receipt.zig"); const json_rpc = @import("json_rpc.zig"); @@ -45,7 +46,9 @@ pub const RetryingProvider = struct { /// Initialise a RetryingProvider wrapping the given Provider. /// Seeds the PRNG from a cryptographically random value. pub fn init(inner: *Provider, opts: RetryOpts) RetryingProvider { - const seed = std.crypto.random.int(u64); + var seed_bytes: [8]u8 = undefined; + runtime.defaultIo().random(&seed_bytes); + const seed = std.mem.readInt(u64, &seed_bytes, .little); return .{ .inner = inner, .opts = opts, @@ -219,7 +222,7 @@ const RetryState = struct { const jitter_ms: u64 = @intFromFloat(@as(f64, @floatFromInt(self.backoff_ms)) * self.provider.opts.jitter); const extra = if (jitter_ms > 0) self.provider.prng.random().int(u64) % jitter_ms else 0; - std.time.sleep((self.backoff_ms + extra) * std.time.ns_per_ms); + runtime.sleepMs(self.backoff_ms + extra); const next_backoff: u64 = @intFromFloat( @as(f64, @floatFromInt(self.backoff_ms)) * self.provider.opts.backoff_multiplier, @@ -252,6 +255,14 @@ fn isRetryable(err: anyerror, opts: RetryOpts) bool { // Tests // ============================================================================ +test "RetryingProvider.init seeds the jitter PRNG" { + var fake_inner: Provider = undefined; + var rp = RetryingProvider.init(&fake_inner, .{}); + try std.testing.expectEqual(@as(u32, 3), rp.opts.max_attempts); + // The PRNG must be usable. + _ = rp.prng.random().int(u64); +} + test "RetryOpts - defaults" { const opts = RetryOpts{}; try std.testing.expectEqual(@as(u32, 3), opts.max_attempts); diff --git a/src/root.zig b/src/root.zig index 8ef28f6..73205ea 100644 --- a/src/root.zig +++ b/src/root.zig @@ -74,6 +74,7 @@ pub const chains = @import("chains/chain.zig"); // -- Utilities -- pub const units = @import("utils/units.zig"); pub const constants = @import("utils/constants.zig"); +pub const runtime = @import("runtime.zig"); // Re-export common types for convenience pub const Address = primitives.Address; @@ -143,6 +144,7 @@ test { _ = @import("ens/reverse.zig"); // Utils _ = @import("utils/units.zig"); + _ = @import("runtime.zig"); // DEX Math _ = @import("dex/v2.zig"); _ = @import("dex/v3.zig"); diff --git a/src/runtime.zig b/src/runtime.zig new file mode 100644 index 0000000..76a3ebc --- /dev/null +++ b/src/runtime.zig @@ -0,0 +1,47 @@ +const std = @import("std"); + +/// Default `std.Io` runtime used by the library. +/// +/// Zig 0.16 moved networking, HTTP, sleeping, and clocks behind the `std.Io` +/// interface. To keep the public eth.zig API unchanged (no `Io` parameter on +/// transports), the library constructs a default blocking implementation +/// internally. All operations the library performs through this `Io` are +/// synchronous and run on the calling thread, matching the pre-0.16 behavior +/// of the blocking std.net / std.http APIs. +/// +/// Callers that need a custom event loop should drive the library from their +/// own threads; the transports remain blocking by design. +pub fn defaultIo() std.Io { + // `global_single_threaded` performs every operation synchronously on the + // calling thread and never allocates, spawns threads, or requires deinit. + return std.Io.Threaded.global_single_threaded.io(); +} + +/// Wall-clock time in milliseconds since the Unix epoch. +/// +/// Replacement for `std.time.milliTimestamp()`, which was removed in +/// Zig 0.16. Deadline-style APIs in this library (for example +/// `WsTransport.readMessageDeadline`) use this time base. +pub fn milliTimestamp() i64 { + return std.Io.Clock.now(.real, defaultIo()).toMilliseconds(); +} + +/// Blocking sleep for `ms` milliseconds. +/// +/// Replacement for `std.Thread.sleep(ms * std.time.ns_per_ms)`, which was +/// removed in Zig 0.16. +pub fn sleepMs(ms: u64) void { + const capped_ms: i64 = @intCast(@min(ms, std.math.maxInt(i64))); + defaultIo().sleep(.fromMilliseconds(capped_ms), .awake) catch {}; +} + +test "milliTimestamp returns a plausible value" { + const ms = milliTimestamp(); + // After 2020-01-01 and before 2100-01-01. + try std.testing.expect(ms > 1_577_836_800_000); + try std.testing.expect(ms < 4_102_444_800_000); +} + +test "sleepMs zero returns immediately" { + sleepMs(0); +} diff --git a/src/sse_transport.zig b/src/sse_transport.zig index d7d05a8..30a822a 100644 --- a/src/sse_transport.zig +++ b/src/sse_transport.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const runtime = @import("runtime.zig"); // ============================================================================ // Types @@ -196,7 +197,7 @@ pub fn subscribe( parser: *SseParser, callback: *const fn (event: SseEvent) void, ) !void { - var client = std.http.Client{ .allocator = allocator }; + var client = std.http.Client{ .allocator = allocator, .io = runtime.defaultIo() }; defer client.deinit(); const uri = try std.Uri.parse(url); @@ -297,9 +298,7 @@ pub fn subscribeWithReconnect( // Use server-specified retry delay if available, otherwise exponential backoff. const delay = parser.retry_ms orelse backoff_ms; if (opts.on_reconnect) |cb| cb(delay); - // Cap before converting to nanoseconds to prevent u64 overflow. - const max_sleep_ms: u64 = std.math.maxInt(u64) / std.time.ns_per_ms; - std.Thread.sleep(@min(delay, max_sleep_ms) * std.time.ns_per_ms); + runtime.sleepMs(delay); // Only advance exponential backoff when server hasn't specified retry. if (parser.retry_ms == null) { diff --git a/src/wallet.zig b/src/wallet.zig index 0e50122..29d6a8f 100644 --- a/src/wallet.zig +++ b/src/wallet.zig @@ -1,5 +1,6 @@ const std = @import("std"); const signer_mod = @import("signer.zig"); +const runtime = @import("runtime.zig"); const provider_mod = @import("provider.zig"); const http_transport_mod = @import("http_transport.zig"); const transaction_mod = @import("transaction.zig"); @@ -139,7 +140,7 @@ pub const Wallet = struct { if (try self.provider.getTransactionReceipt(tx_hash)) |receipt| { return receipt; } - std.Thread.sleep(1_000_000_000); // 1 second + runtime.sleepMs(1_000); // 1 second } return null; } diff --git a/src/ws_client.zig b/src/ws_client.zig index 170845c..27df28b 100644 --- a/src/ws_client.zig +++ b/src/ws_client.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const runtime = @import("runtime.zig"); const ws_transport = @import("ws_transport.zig"); const subscription = @import("subscription.zig"); const json_rpc = @import("json_rpc.zig"); @@ -104,7 +105,7 @@ pub const WsClient = struct { next_id: u64, opts: Opts, state: State, - /// `std.time.milliTimestamp()` of the last received frame. + /// `runtime.milliTimestamp()` of the last received frame. last_activity_ms: i64, /// Sentinel for an in-flight ping. pending_pong: bool, @@ -135,10 +136,10 @@ pub const WsClient = struct { .next_id = 1, .opts = opts, .state = .connected, - .last_activity_ms = std.time.milliTimestamp(), + .last_activity_ms = runtime.milliTimestamp(), .pending_pong = false, .ping_sent_ms = 0, - .rng = std.Random.DefaultPrng.init(@bitCast(std.time.milliTimestamp())), + .rng = std.Random.DefaultPrng.init(@bitCast(runtime.milliTimestamp())), }; return self; } @@ -378,7 +379,7 @@ pub const WsClient = struct { const t = &self.transport.?; const frames_before = t.frames_received; - const now = std.time.milliTimestamp(); + const now = runtime.milliTimestamp(); // Pong-timeout check. if (self.pending_pong and now - self.ping_sent_ms >= @as(i64, @intCast(self.opts.pong_timeout_ms))) { @@ -390,7 +391,7 @@ pub const WsClient = struct { const elapsed_idle = now - self.last_activity_ms; if (elapsed_idle >= @as(i64, @intCast(self.opts.ping_interval_ms))) { var nonce: [8]u8 = undefined; - std.crypto.random.bytes(&nonce); + runtime.defaultIo().random(&nonce); t.sendPing(&nonce) catch return error.WriteError; self.ping_sent_ms = now; self.pending_pong = true; @@ -407,7 +408,7 @@ pub const WsClient = struct { const maybe_frame = t.readMessageDeadline(deadline_ms) catch return error.ReadError; if (maybe_frame) |frame| { - self.last_activity_ms = std.time.milliTimestamp(); + self.last_activity_ms = runtime.milliTimestamp(); self.pending_pong = false; return frame; } @@ -415,7 +416,7 @@ pub const WsClient = struct { // we were waiting for a data frame, we are still alive. Otherwise // loop, which will either send a ping or trip pong-timeout. if (t.frames_received != frames_before) { - self.last_activity_ms = std.time.milliTimestamp(); + self.last_activity_ms = runtime.milliTimestamp(); self.pending_pong = false; } } @@ -472,7 +473,7 @@ pub const WsClient = struct { const delay = applyJitter(base, self.opts.jitter_pct, self.rng.random()); if (self.opts.on_reconnect) |cb| cb(attempt, delay); - std.Thread.sleep(delay * std.time.ns_per_ms); + runtime.sleepMs(delay); const maybe_t = WsTransport.connect(self.allocator, self.url); if (maybe_t) |t_val| { @@ -480,7 +481,7 @@ pub const WsClient = struct { if (self.resubscribeAll(&t)) |_| { self.transport = t; self.state = .connected; - self.last_activity_ms = std.time.milliTimestamp(); + self.last_activity_ms = runtime.milliTimestamp(); return; } else |_| { t.close(); diff --git a/src/ws_transport.zig b/src/ws_transport.zig index 326e5e7..7d86310 100644 --- a/src/ws_transport.zig +++ b/src/ws_transport.zig @@ -1,9 +1,10 @@ const std = @import("std"); const json_rpc = @import("json_rpc.zig"); +const runtime = @import("runtime.zig"); /// Minimal WebSocket transport for JSON-RPC over ws:// and wss:// URLs. /// -/// Implements RFC 6455 framing on top of std.net.Stream with optional TLS. +/// Implements RFC 6455 framing on top of std.Io.net.Stream with optional TLS. /// This is a synchronous (blocking) implementation suitable for use with /// Ethereum JSON-RPC subscriptions and requests. @@ -298,7 +299,8 @@ pub fn validateHandshakeResponse(response: []const u8, expected_accept: []const /// defer allocator.free(response); pub const WsTransport = struct { allocator: std.mem.Allocator, - stream: std.net.Stream, + io: std.Io, + stream: std.Io.net.Stream, next_id: u64, // Read buffer for incoming WebSocket frame data @@ -324,15 +326,24 @@ pub const WsTransport = struct { /// stable addresses for the lifetime of the connection. pub const TlsState = struct { tls_client: std.crypto.tls.Client, - stream_reader: std.net.Stream.Reader, - stream_writer: std.net.Stream.Writer, + stream_reader: std.Io.net.Stream.Reader, + stream_writer: std.Io.net.Stream.Writer, + + // CA store used to verify the server certificate during the + // handshake. The TLS client rescans the system store into this + // bundle (guarded by the lock) inside `init`. + ca_lock: std.Io.RwLock, + ca_bundle: std.crypto.Certificate.Bundle, // Buffers that the TLS client and stream reader/writer reference. // These are stored here so they live as long as the TLS client. - tls_read_buf: [16384]u8, - socket_write_buf: [16384]u8, - socket_read_buf: [16384]u8, - stream_write_buf: [16384]u8, + // The socket-side read buffer must hold at least one full TLS + // ciphertext record (`min_buffer_len`); the others are sized the + // same for headroom. + tls_read_buf: [std.crypto.tls.Client.min_buffer_len]u8, + socket_write_buf: [std.crypto.tls.Client.min_buffer_len]u8, + socket_read_buf: [std.crypto.tls.Client.min_buffer_len]u8, + stream_write_buf: [std.crypto.tls.Client.min_buffer_len]u8, }; pub const TransportError = error{ @@ -354,21 +365,33 @@ pub const WsTransport = struct { /// and performs the WebSocket upgrade handshake. pub fn connect(allocator: std.mem.Allocator, url: []const u8) TransportError!WsTransport { const parsed = parseUrl(url) catch return error.ConnectionFailed; - - // Open TCP connection - const stream = std.net.tcpConnectToHost(allocator, parsed.host, parsed.port) catch - return error.ConnectionFailed; - errdefer stream.close(); + const io = runtime.defaultIo(); + + // Open TCP connection. Try the host as an IP literal first, then + // fall back to DNS resolution. + const stream = blk: { + if (std.Io.net.IpAddress.parse(parsed.host, parsed.port)) |addr| { + break :blk addr.connect(io, .{ .mode = .stream }) catch + return error.ConnectionFailed; + } else |_| { + const host_name = std.Io.net.HostName.init(parsed.host) catch + return error.ConnectionFailed; + break :blk host_name.connect(io, parsed.port, .{ .mode = .stream }) catch + return error.ConnectionFailed; + } + }; + errdefer stream.close(io); var transport = WsTransport{ .allocator = allocator, + .io = io, .stream = stream, .next_id = 1, .is_tls = parsed.is_tls, }; if (parsed.is_tls) { - transport.tls_state = initTls(allocator, stream, parsed.host) catch + transport.tls_state = initTls(allocator, io, stream, parsed.host) catch return error.TlsInitFailed; } @@ -380,30 +403,42 @@ pub const WsTransport = struct { } /// Initialize TLS state on the heap. - fn initTls(allocator: std.mem.Allocator, stream: std.net.Stream, host: []const u8) !*TlsState { + fn initTls(allocator: std.mem.Allocator, io: std.Io, stream: std.Io.net.Stream, host: []const u8) !*TlsState { const state = try allocator.create(TlsState); errdefer allocator.destroy(state); // Initialize the stream reader/writer with buffers stored in the state. - state.stream_reader = stream.reader(&state.socket_read_buf); - state.stream_writer = stream.writer(&state.stream_write_buf); + state.stream_reader = stream.reader(io, &state.socket_read_buf); + state.stream_writer = stream.writer(io, &state.stream_write_buf); + + // System CA certificates for verification. The TLS client rescans + // the system store into `ca_bundle` during init; the bundle is only + // needed for the handshake, so it is freed once init completes. + state.ca_lock = .init; + state.ca_bundle = .empty; + defer { + state.ca_bundle.deinit(allocator); + state.ca_bundle = .empty; + } - // Load system CA certificates for TLS verification. - // The CA bundle is only used during the TLS handshake in init() - // and is not retained by the TLS client afterwards, so we free - // it once init completes. - var ca_bundle: std.crypto.Certificate.Bundle = .{}; - ca_bundle.rescan(allocator) catch return error.TlsInitFailed; - defer ca_bundle.deinit(allocator); + var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined; + io.random(&entropy); state.tls_client = std.crypto.tls.Client.init( - state.stream_reader.interface(), + &state.stream_reader.interface, &state.stream_writer.interface, .{ .host = .{ .explicit = host }, - .ca = .{ .bundle = ca_bundle }, + .ca = .{ .bundle = .{ + .gpa = allocator, + .io = io, + .lock = &state.ca_lock, + .bundle = &state.ca_bundle, + } }, .read_buffer = &state.tls_read_buf, .write_buffer = &state.socket_write_buf, + .entropy = &entropy, + .realtime_now = std.Io.Clock.now(.real, io), }, ) catch return error.TlsInitFailed; @@ -414,7 +449,7 @@ pub const WsTransport = struct { pub fn close(self: *WsTransport) void { // Try to send a close frame (best effort) self.sendFrame(&.{}, .close) catch {}; - self.stream.close(); + self.stream.close(self.io); // Free heap-allocated TLS state if (self.tls_state) |state| { @@ -480,7 +515,7 @@ pub const WsTransport = struct { /// Read the next WebSocket message with a deadline. /// - /// `deadline_ms` is an absolute timestamp from `std.time.milliTimestamp()`. + /// `deadline_ms` is an absolute timestamp from `eth.runtime.milliTimestamp()`. /// Returns the payload (caller owns memory) on success, or null if the /// deadline expires before a complete data frame is available. /// @@ -499,7 +534,7 @@ pub const WsTransport = struct { // readable from the caller's perspective. if (self.read_end > self.read_pos) return true; var fds = [_]std.posix.pollfd{.{ - .fd = self.stream.handle, + .fd = self.stream.socket.handle, .events = std.posix.POLL.IN, .revents = 0, }}; @@ -519,7 +554,7 @@ pub const WsTransport = struct { fn sendFrame(self: *WsTransport, payload: []const u8, opcode: Opcode) !void { // Generate random mask key (required for client-to-server frames) var mask_key: [4]u8 = undefined; - std.crypto.random.bytes(&mask_key); + self.io.random(&mask_key); const frame = encodeFrame(self.allocator, opcode, payload, mask_key) catch return error.OutOfMemory; @@ -536,7 +571,7 @@ pub const WsTransport = struct { } /// Read a complete WebSocket frame, returning null on deadline. - /// `deadline_ms` is absolute (`std.time.milliTimestamp()` units). + /// `deadline_ms` is absolute (`eth.runtime.milliTimestamp()` units). fn readFrameDeadline(self: *WsTransport, deadline_ms: i64) !?[]u8 { return self.readFrameImpl(deadline_ms); } @@ -619,7 +654,7 @@ pub const WsTransport = struct { fn performHandshake(self: *WsTransport, host: []const u8, port: u16, path: []const u8) !void { // Generate random key var random_bytes: [16]u8 = undefined; - std.crypto.random.bytes(&random_bytes); + self.io.random(&random_bytes); const ws_key = generateWebSocketKey(random_bytes); // Build and send handshake request @@ -686,7 +721,7 @@ pub const WsTransport = struct { } if (deadline_ms) |dl| { - const now = std.time.milliTimestamp(); + const now = runtime.milliTimestamp(); if (now >= dl) return false; const wait_i64 = dl - now; const wait_ms: i32 = if (wait_i64 > std.math.maxInt(i32)) @@ -710,24 +745,38 @@ pub const WsTransport = struct { tls.tls_client.writer.writeAll(data) catch return error.WriteError; tls.tls_client.writer.flush() catch return error.WriteError; } else { - // Plain TCP: write directly via POSIX syscall - var sent: usize = 0; - while (sent < data.len) { - const n = std.posix.write(self.stream.handle, data[sent..]) catch return error.WriteError; - if (n == 0) return error.WriteError; - sent += n; - } + // Plain TCP: write through an unbuffered transient stream writer. + var stream_writer = self.stream.writer(self.io, &.{}); + stream_writer.interface.writeAll(data) catch return error.WriteError; + stream_writer.interface.flush() catch return error.WriteError; } } /// Read some bytes from the underlying transport (plain TCP or TLS). + /// + /// Performs a single underlying read and returns however many bytes are + /// available (like a raw `recv`), rather than blocking until `buf` is + /// full. Returns 0 on end of stream. fn readSome(self: *WsTransport, buf: []u8) !usize { + var data: [1][]u8 = .{buf}; if (self.tls_state) |tls| { - // Read through TLS: use the TLS client's reader - return tls.tls_client.reader.readSliceShort(buf) catch return error.ReadError; + // Read through TLS: use the TLS client's reader. A successful + // read of 0 bytes just means a record with no application data + // was processed, so try again. + while (true) { + const n = tls.tls_client.reader.readVec(&data) catch |err| switch (err) { + error.EndOfStream => return 0, + else => return error.ReadError, + }; + if (n != 0) return n; + } } else { - // Plain TCP: read directly via POSIX syscall - return std.posix.read(self.stream.handle, buf) catch return error.ReadError; + // Plain TCP: read through an unbuffered transient stream reader. + var stream_reader = self.stream.reader(self.io, &.{}); + return stream_reader.interface.readVec(&data) catch |err| switch (err) { + error.EndOfStream => 0, + else => error.ReadError, + }; } } }; @@ -776,9 +825,7 @@ pub fn connectWithReconnect( } else |_| {} if (opts.on_reconnect) |cb| cb(backoff_ms); - // Cap before converting to nanoseconds to prevent u64 overflow. - const max_sleep_ms: u64 = std.math.maxInt(u64) / std.time.ns_per_ms; - std.Thread.sleep(@min(backoff_ms, max_sleep_ms) * std.time.ns_per_ms); + runtime.sleepMs(backoff_ms); // Guard against overflow before clamping. backoff_ms = if (backoff_ms > opts.max_backoff_ms / 2) diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index 93ae71d..1c4af94 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -27,10 +27,10 @@ const ANVIL_CHAIN_ID: u64 = 31337; /// Check if Anvil is reachable by opening a TCP connection to 127.0.0.1:8545. /// This avoids going through the HTTP client which can crash on connection refused. fn isAnvilAvailable() bool { - const addr = std.net.Address.parseIp4(ANVIL_HOST, ANVIL_PORT) catch return false; - const stream = std.posix.socket(addr.any.family, std.posix.SOCK.STREAM, 0) catch return false; - defer std.posix.close(stream); - std.posix.connect(stream, &addr.any, addr.getOsSockLen()) catch return false; + const io = eth.runtime.defaultIo(); + const addr = std.Io.net.IpAddress.parse(ANVIL_HOST, ANVIL_PORT) catch return false; + const stream = addr.connect(io, .{ .mode = .stream }) catch return false; + stream.close(io); return true; }