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; }