diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml new file mode 100644 index 0000000..35b901c --- /dev/null +++ b/.github/workflows/bench.yml @@ -0,0 +1,38 @@ +name: Bench + +# Benchmarks run nightly (and on demand), off the PR-blocking path: the harness +# is noisy on shared runners, so we capture numbers for trend/regression review +# rather than gate merges on them. Each run uploads the BENCH_JSON lines so a +# committed baseline + diff can be layered on later. +on: + schedule: + - cron: "0 7 * * *" # daily at 07:00 UTC + workflow_dispatch: + +# Least privilege: read the repo, upload artifacts via the Actions runtime token. +permissions: + contents: read + +jobs: + bench: + name: Benchmarks (ReleaseFast) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: "0.16.0" + - name: Run benchmarks + run: zig build bench | tee bench-results.txt + - name: Extract machine-readable results + run: grep '^BENCH_JSON' bench-results.txt > bench-results.jsonl || true + - name: Upload benchmark results + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: bench-results + path: | + bench-results.txt + bench-results.jsonl diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4099c21..8164181 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: pull_request: branches: [main] +# Least privilege: these jobs only read the repo (and upload artifacts via the +# Actions runtime token, which needs no extra GITHUB_TOKEN scope). +permissions: + contents: read + jobs: test: name: Test / Zig ${{ matrix.zig-version }} / ${{ matrix.os }} @@ -16,8 +21,10 @@ jobs: zig-version: ["0.16.0"] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: mlugg/setup-zig@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: version: ${{ matrix.zig-version }} - name: Run unit tests @@ -27,8 +34,10 @@ jobs: name: Format check runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: mlugg/setup-zig@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: version: "0.16.0" - name: Check formatting @@ -41,9 +50,47 @@ jobs: os: [ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: mlugg/setup-zig@v2 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 with: version: "0.16.0" - name: Build library run: zig build + + coverage: + name: Coverage (kcov) + runs-on: ubuntu-latest + # Informational: surfaces which branches the unit tests exercise. Not a + # required check -- a coverage dip should not block a merge on its own. + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + with: + version: "0.16.0" + - name: Install kcov + # kcov was dropped from Ubuntu's repos (gone in 24.04 noble) and ships + # no prebuilt binaries, so build a pinned release from source. + env: + KCOV_VERSION: v43 + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake g++ binutils-dev libcurl4-openssl-dev libdw-dev \ + libiberty-dev zlib1g-dev libssl-dev + git clone --depth 1 --branch "$KCOV_VERSION" https://github.com/SimonKagstrom/kcov.git /tmp/kcov + cmake -S /tmp/kcov -B /tmp/kcov/build -DCMAKE_BUILD_TYPE=Release + cmake --build /tmp/kcov/build --parallel "$(nproc)" + sudo cmake --install /tmp/kcov/build + - name: Build unit-test binary + run: zig build install-test + - name: Run kcov + run: kcov --include-pattern=src/ --exclude-pattern=src/crypto/ kcov-out ./zig-out/bin/test + - name: Upload coverage report + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: coverage-report + path: kcov-out diff --git a/.gitignore b/.gitignore index 0be3926..d2ac462 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ zig-pkg/ *.o *.s +# Coverage output (make coverage / kcov) +kcov-out/ + # Docs site build artifacts docs/node_modules/ docs/.next/ diff --git a/Makefile b/Makefile index 69228a4..7d799e2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ ZIG ?= zig +KCOV ?= kcov -.PHONY: build test fmt lint ci integration-test bench bench-u256 bench-keccak clean +.PHONY: build test fmt fmt-fix lint ci integration-test bench bench-u256 bench-keccak coverage docs clean ## Build the library (default) build: @@ -18,6 +19,18 @@ fmt: fmt-fix: $(ZIG) fmt src/ tests/ +## Measure unit-test code coverage with kcov (Linux; requires kcov installed). +## Excludes vendored C/crypto so the report reflects the Zig SDK surface. +coverage: + $(ZIG) build install-test + rm -rf kcov-out + $(KCOV) --include-pattern=src/ --exclude-pattern=src/crypto/ kcov-out ./zig-out/bin/test + @echo "Coverage report: kcov-out/index.html" + +## Generate API documentation into zig-out/docs +docs: + $(ZIG) build docs + ## Run fmt + test — everything CI checks locally (no Anvil required) lint: fmt test diff --git a/bench/bench.zig b/bench/bench.zig index 7829db4..a79d277 100644 --- a/bench/bench.zig +++ b/bench/bench.zig @@ -78,6 +78,9 @@ const Timer = struct { const BenchResult = struct { ns_per_op: u64, + /// Sample standard deviation of the per-batch ns/op measurements. Turns the + /// "within measurement noise" claims into data callers can actually check. + stddev_ns: u64, iters: u64, }; @@ -100,23 +103,53 @@ fn runBench(comptime func: fn () void) BenchResult { batch *= 2; } - // Measure: collect samples over BENCH_NS + // Measure: collect one ns/op sample per ~100ms batch over BENCH_NS. With a + // 2s window and ~100ms batches this is ~20 samples -- enough for a stddev. + var samples: [4096]f64 = undefined; + var n_samples: usize = 0; var total_iters: u64 = 0; timer.reset(); - - while (timer.read() < BENCH_NS) { + var elapsed: u64 = 0; + while (elapsed < BENCH_NS) { + const t0 = timer.read(); for (0..batch) |_| func(); + const t1 = timer.read(); + elapsed = t1; + if (n_samples < samples.len) { + const batch_ns: f64 = @floatFromInt(t1 - t0); + samples[n_samples] = batch_ns / @as(f64, @floatFromInt(batch)); + n_samples += 1; + } total_iters += batch; } const total_ns = timer.read(); const ns_per_op = if (total_iters > 0) total_ns / total_iters else 0; - return .{ .ns_per_op = ns_per_op, .iters = total_iters }; + + // Sample standard deviation (Bessel-corrected) of the per-batch ns/op. + var stddev_ns: u64 = 0; + if (n_samples > 1) { + var mean: f64 = 0; + for (samples[0..n_samples]) |s| mean += s; + mean /= @floatFromInt(n_samples); + var acc: f64 = 0; + for (samples[0..n_samples]) |s| { + const d = s - mean; + acc += d * d; + } + const variance = acc / @as(f64, @floatFromInt(n_samples - 1)); + stddev_ns = @intFromFloat(@round(@sqrt(variance))); + } + + return .{ .ns_per_op = ns_per_op, .stddev_ns = stddev_ns, .iters = total_iters }; } fn runAndPrint(comptime name: []const u8, comptime func: fn () void, stdout: anytype) !void { const result = runBench(func); - try stdout.print("{s:<34} {d:>9} ns {d:>14}\n", .{ name, result.ns_per_op, result.iters }); + try stdout.print("{s:<34} {d:>9} ns {d:>9} ns {d:>14}\n", .{ name, result.ns_per_op, result.stddev_ns, result.iters }); + // Machine-readable line for compare.sh and regression tooling. Filtered out + // of human views with `grep -v '^BENCH_JSON'`. + try stdout.print("BENCH_JSON|{{\"name\":\"{s}\",\"ns_per_op\":{d},\"stddev_ns\":{d},\"iters\":{d}}}\n", .{ name, result.ns_per_op, result.stddev_ns, result.iters }); } // ============================================================================ @@ -496,8 +529,8 @@ pub fn main() !void { 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" }); - try stdout.print("{s}\n", .{"" ++ @as([64]u8, @splat('-'))}); + try stdout.print("\n{s:<34} {s:>12} {s:>12} {s:>14}\n", .{ "Benchmark", "ns/op", "stddev", "iters" }); + try stdout.print("{s}\n", .{"" ++ @as([72]u8, @splat('-'))}); // Keccak256 try runAndPrint("keccak256_empty", benchKeccakEmpty, stdout); diff --git a/bench/compare.sh b/bench/compare.sh index 821b7c5..33b4540 100755 --- a/bench/compare.sh +++ b/bench/compare.sh @@ -19,7 +19,9 @@ echo "" # -- Step 1: Run eth-zig benchmarks -- echo "[1/3] Running eth-zig benchmarks (ReleaseFast)..." ZIG_OUTPUT=$(cd "$ROOT_DIR" && zig build bench 2>&1) -echo "$ZIG_OUTPUT" +# `|| true`: grep exits 1 if every line were filtered, which would abort the +# script under `set -o pipefail` even though the bench itself succeeded. +echo "$ZIG_OUTPUT" | grep -v "^BENCH_JSON" || true echo "" # -- Step 2: Run alloy.rs benchmarks -- diff --git a/build.zig b/build.zig index 6da354d..ae81d41 100644 --- a/build.zig +++ b/build.zig @@ -39,6 +39,35 @@ pub fn build(b: *std.Build) void { const test_step = b.step("test", "Run unit tests"); test_step.dependOn(&run_unit_tests.step); + // Install the unit-test binary so coverage tooling (kcov) can run it + // out-of-band: `zig build install-test` writes it to zig-out/bin/test. + const install_unit_tests = b.addInstallArtifact(unit_tests, .{}); + const install_test_step = b.step("install-test", "Install the unit-test binary (for coverage tooling)"); + install_test_step.dependOn(&install_unit_tests.step); + + // Formatting check as a first-class build step (mirrors the CI fmt job and + // `make fmt`, so `zig build fmt-check` is self-describing without Make). + const fmt_check = b.addFmt(.{ + .paths = &.{ "src", "tests", "bench", "build.zig" }, + .check = true, + }); + const fmt_step = b.step("fmt-check", "Check source formatting (zig fmt --check)"); + fmt_step.dependOn(&fmt_check.step); + + // API documentation: `zig build docs` emits the HTML docs generated from the + // public surface (src/root.zig) into zig-out/docs. + const docs_obj = b.addObject(.{ + .name = "eth", + .root_module = eth_module, + }); + const install_docs = b.addInstallDirectory(.{ + .source_dir = docs_obj.getEmittedDocs(), + .install_dir = .prefix, + .install_subdir = "docs", + }); + const docs_step = b.step("docs", "Generate API documentation into zig-out/docs"); + docs_step.dependOn(&install_docs.step); + // Integration tests (requires Anvil) const integration_tests = b.addTest(.{ .root_module = b.createModule(.{ diff --git a/src/abi_json.zig b/src/abi_json.zig index 91776e0..78d74b0 100644 --- a/src/abi_json.zig +++ b/src/abi_json.zig @@ -11,25 +11,34 @@ pub const ContractAbi = struct { functions: []const abi_types.Function, events: []const abi_types.Event, errors: []const abi_types.AbiError, - allocator: std.mem.Allocator, + /// Owns every allocation backing the parsed ABI. A single arena means there + /// is no hand-written recursive free, and a failure partway through parsing + /// cannot leak already-built entries: the `errdefer` in `fromJson` (and + /// `deinit` on success) frees the whole tree in one shot. + arena: *std.heap.ArenaAllocator, /// Parse a Solidity JSON ABI string into a ContractAbi. /// Caller must call deinit() to free all memory. - pub fn fromJson(allocator: std.mem.Allocator, json: []const u8) !ContractAbi { - const parsed = std.json.parseFromSlice(std.json.Value, allocator, json, .{}) catch { - return error.InvalidJson; + pub fn fromJson(gpa: std.mem.Allocator, json: []const u8) !ContractAbi { + const arena = try gpa.create(std.heap.ArenaAllocator); + errdefer gpa.destroy(arena); + arena.* = std.heap.ArenaAllocator.init(gpa); + errdefer arena.deinit(); + const allocator = arena.allocator(); + + // Parse "leaky" into the arena: the JSON DOM lives in the same arena as + // the parsed ABI, so it is reclaimed by deinit() with everything else. + // Surface allocator exhaustion as itself; only genuine parse failures + // collapse to InvalidJson. + const root = std.json.parseFromSliceLeaky(std.json.Value, allocator, json, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidJson, }; - defer parsed.deinit(); - - const root = parsed.value; if (root != .array) return error.InvalidAbiFormat; var functions: std.ArrayList(abi_types.Function) = .empty; - defer functions.deinit(allocator); var events: std.ArrayList(abi_types.Event) = .empty; - defer events.deinit(allocator); var errors: std.ArrayList(abi_types.AbiError) = .empty; - defer errors.deinit(allocator); for (root.array.items) |item| { if (item != .object) continue; @@ -38,14 +47,11 @@ pub const ContractAbi = struct { const type_str = jsonGetString(obj, "type") orelse continue; if (std.mem.eql(u8, type_str, "function")) { - const func = try parseFunction(allocator, obj); - try functions.append(allocator, func); + try functions.append(allocator, try parseFunction(allocator, obj)); } else if (std.mem.eql(u8, type_str, "event")) { - const evt = try parseEvent(allocator, obj); - try events.append(allocator, evt); + try events.append(allocator, try parseEvent(allocator, obj)); } else if (std.mem.eql(u8, type_str, "error")) { - const err = try parseError(allocator, obj); - try errors.append(allocator, err); + try errors.append(allocator, try parseError(allocator, obj)); } // Skip constructor, fallback, receive -- not needed for call encoding } @@ -54,30 +60,15 @@ pub const ContractAbi = struct { .functions = try functions.toOwnedSlice(allocator), .events = try events.toOwnedSlice(allocator), .errors = try errors.toOwnedSlice(allocator), - .allocator = allocator, + .arena = arena, }; } /// Free all memory allocated by fromJson. pub fn deinit(self: *ContractAbi) void { - for (self.functions) |func| { - freeParams(self.allocator, func.inputs); - freeParams(self.allocator, func.outputs); - self.allocator.free(func.name); - } - self.allocator.free(self.functions); - - for (self.events) |evt| { - freeParams(self.allocator, evt.inputs); - self.allocator.free(evt.name); - } - self.allocator.free(self.events); - - for (self.errors) |err| { - freeParams(self.allocator, err.inputs); - self.allocator.free(err.name); - } - self.allocator.free(self.errors); + const gpa = self.arena.child_allocator; + self.arena.deinit(); + gpa.destroy(self.arena); } /// Find a function by name. Returns null if not found. @@ -105,64 +96,34 @@ pub const ContractAbi = struct { } }; -fn freeParams(allocator: std.mem.Allocator, params: []const AbiParam) void { - for (params) |param| { - if (param.name.len > 0) allocator.free(param.name); - if (param.components.len > 0) { - freeParams(allocator, param.components); - } - } - allocator.free(params); -} +// All parse helpers below allocate into the caller's arena. There is no +// per-helper cleanup on error: a failure unwinds to ContractAbi.fromJson, whose +// errdefer tears down the whole arena, so partially-built entries cannot leak. fn parseFunction(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !abi_types.Function { const name_str = jsonGetString(obj, "name") orelse return error.MissingName; - const name = try allocator.dupe(u8, name_str); - errdefer allocator.free(name); - - const inputs = try parseInputs(allocator, obj, "inputs"); - errdefer freeParams(allocator, inputs); - - const outputs = try parseInputs(allocator, obj, "outputs"); - errdefer freeParams(allocator, outputs); - - const mutability = parseMutability(obj); - return .{ - .name = name, - .inputs = inputs, - .outputs = outputs, - .state_mutability = mutability, + .name = try allocator.dupe(u8, name_str), + .inputs = try parseInputs(allocator, obj, "inputs"), + .outputs = try parseInputs(allocator, obj, "outputs"), + .state_mutability = parseMutability(obj), }; } fn parseEvent(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !abi_types.Event { const name_str = jsonGetString(obj, "name") orelse return error.MissingName; - const name = try allocator.dupe(u8, name_str); - errdefer allocator.free(name); - - const inputs = try parseInputs(allocator, obj, "inputs"); - errdefer freeParams(allocator, inputs); - - const anonymous = jsonGetBool(obj, "anonymous") orelse false; - return .{ - .name = name, - .inputs = inputs, - .anonymous = anonymous, + .name = try allocator.dupe(u8, name_str), + .inputs = try parseInputs(allocator, obj, "inputs"), + .anonymous = jsonGetBool(obj, "anonymous") orelse false, }; } fn parseError(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !abi_types.AbiError { const name_str = jsonGetString(obj, "name") orelse return error.MissingName; - const name = try allocator.dupe(u8, name_str); - errdefer allocator.free(name); - - const inputs = try parseInputs(allocator, obj, "inputs"); - return .{ - .name = name, - .inputs = inputs, + .name = try allocator.dupe(u8, name_str), + .inputs = try parseInputs(allocator, obj, "inputs"), }; } @@ -172,19 +133,16 @@ fn parseError(allocator: std.mem.Allocator, obj: std.json.ObjectMap) !abi_types. const ComponentError = std.mem.Allocator.Error || error{ MissingType, UnknownType }; fn parseInputs(allocator: std.mem.Allocator, obj: std.json.ObjectMap, key: []const u8) ComponentError![]const AbiParam { - const val = obj.get(key) orelse return try allocator.alloc(AbiParam, 0); - if (val != .array) return try allocator.alloc(AbiParam, 0); + const val = obj.get(key) orelse return &.{}; + if (val != .array) return &.{}; var params: std.ArrayList(AbiParam) = .empty; - defer params.deinit(allocator); - for (val.array.items) |item| { if (item != .object) continue; - const param = try parseParam(allocator, item.object); - try params.append(allocator, param); + try params.append(allocator, try parseParam(allocator, item.object)); } - return try params.toOwnedSlice(allocator); + return params.toOwnedSlice(allocator); } fn parseParam(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ComponentError!AbiParam { @@ -193,9 +151,7 @@ fn parseParam(allocator: std.mem.Allocator, obj: std.json.ObjectMap) ComponentEr const indexed = jsonGetBool(obj, "indexed") orelse false; const abi_type = parseType(type_str) orelse return error.UnknownType; - const name = if (name_str.len > 0) try allocator.dupe(u8, name_str) else name_str; - errdefer if (name.len > 0) allocator.free(name); var components: []const AbiParam = &.{}; if (abi_type == .tuple) { @@ -665,3 +621,18 @@ test "ContractAbi.fromJson - non-array returns error" { const result = ContractAbi.fromJson(allocator, "{}"); try std.testing.expectError(error.InvalidAbiFormat, result); } + +test "ContractAbi.fromJson - no leak when a later entry fails to parse" { + // The first function parses and is appended; the second fails on an unknown + // input type. With per-allocation cleanup this leaked the already-built + // first entry's name/params -- std.testing.allocator turns that into a test + // failure. The arena makes the error path leak-free. + const allocator = std.testing.allocator; + const json = + \\[ + \\ {"type":"function","name":"good","inputs":[{"name":"a","type":"uint256"}],"outputs":[]}, + \\ {"type":"function","name":"bad","inputs":[{"name":"x","type":"not_a_real_type"}],"outputs":[]} + \\] + ; + try std.testing.expectError(error.UnknownType, ContractAbi.fromJson(allocator, json)); +} diff --git a/src/provider.zig b/src/provider.zig index af3aaa3..e93eccc 100644 --- a/src/provider.zig +++ b/src/provider.zig @@ -17,13 +17,30 @@ const runtime = @import("runtime.zig"); pub const Provider = struct { transport: *HttpTransport, allocator: std.mem.Allocator, - next_id: u64, + /// Monotonic JSON-RPC request id. Atomic so a Provider shared across threads + /// cannot hand out duplicate ids (note the underlying HttpTransport is still + /// single-threaded -- sharing a Provider is only id-safe, not call-safe). + next_id: std.atomic.Value(u64), + /// Diagnostics for the most recent JSON-RPC `error` response. See lastError(). + last_error: ?ErrorInfo = null, + /// Inline backing storage for `last_error.message` (truncated to fit). Inline + /// so the Provider holds no heap diagnostics state and needs no deinit. + last_error_storage: [256]u8 = undefined, + + /// Structured diagnostics for a failed JSON-RPC call. + pub const ErrorInfo = struct { + /// JSON-RPC error code, e.g. 3 = execution reverted, -32000 = server error. + code: i64, + /// Error message (may be empty; truncated to 256 bytes). Borrows + /// provider-owned storage; valid until the next call on this provider. + message: []const u8, + }; pub fn init(allocator: std.mem.Allocator, transport: *HttpTransport) Provider { return .{ .transport = transport, .allocator = allocator, - .next_id = 1, + .next_id = .init(1), }; } @@ -32,6 +49,14 @@ pub const Provider = struct { return self.transport.io; } + /// Diagnostics for the most recent JSON-RPC `error` response, or null if the + /// last call did not fail with `error.RpcError`. Lets callers tell an + /// on-chain revert (code 3) apart from a transport-level failure. The + /// message is valid until the next call on this provider. + pub fn lastError(self: *const Provider) ?ErrorInfo { + return self.last_error; + } + // ======================================================================== // Chain state // ======================================================================== @@ -41,7 +66,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_chainId, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -51,7 +76,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_blockNumber, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -68,7 +93,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getBalance, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -90,7 +115,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getTransactionCount, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -104,7 +129,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getCode, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexBytes(self.allocator, result_str); } @@ -128,7 +153,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getStorageAt, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return hex_mod.hexToBytesFixed(32, result_str) catch return error.InvalidResponse; } @@ -142,7 +167,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_gasPrice, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -152,7 +177,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_maxPriorityFeePerGas, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -170,7 +195,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_call, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexBytes(self.allocator, result_str); } @@ -197,7 +222,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_call, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexBytes(self.allocator, result_str); } @@ -210,7 +235,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_estimateGas, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -232,7 +257,7 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_sendRawTransaction, params); defer self.allocator.free(raw); - const result_str = try extractResultString(self.allocator, raw); + const result_str = try self.extractResult(raw); defer self.allocator.free(result_str); return primitives.hashFromHex(result_str) catch return error.InvalidResponse; } @@ -257,7 +282,10 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getTransactionReceipt, params); defer self.allocator.free(raw); - return parseTransactionReceipt(self.allocator, raw); + return parseTransactionReceipt(self.allocator, raw) catch |err| { + if (err == error.RpcError) self.captureRpcError(raw); + return err; + }; } // ======================================================================== @@ -282,7 +310,10 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getBlockByNumber, params); defer self.allocator.free(raw); - return parseBlockHeader(self.allocator, raw); + return parseBlockHeader(self.allocator, raw) catch |err| { + if (err == error.RpcError) self.captureRpcError(raw); + return err; + }; } // ======================================================================== @@ -298,7 +329,10 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getLogs, params); defer self.allocator.free(raw); - return parseLogsResponse(self.allocator, raw); + return parseLogsResponse(self.allocator, raw) catch |err| { + if (err == error.RpcError) self.captureRpcError(raw); + return err; + }; } // ======================================================================== @@ -306,11 +340,50 @@ pub const Provider = struct { // ======================================================================== fn rpcCall(self: *Provider, method: []const u8, params: []const u8) ![]u8 { - const id = self.next_id; - self.next_id += 1; + // Reset diagnostics at the single choke point every call flows through; + // the parse step records details if the response carries an RPC error. + self.last_error = null; + const id = self.next_id.fetchAdd(1, .monotonic); return self.transport.request(method, params, id); } + /// `extractResultString`, but capturing RPC error diagnostics into the + /// provider on `error.RpcError` so callers can inspect `lastError()`. + fn extractResult(self: *Provider, raw: []const u8) ![]u8 { + return extractResultString(self.allocator, raw) catch |err| { + if (err == error.RpcError) self.captureRpcError(raw); + return err; + }; + } + + /// Best-effort: parse `raw` for a JSON-RPC `error` object and record its + /// code and (truncated) message into `last_error`. Runs only on the error + /// path, so the extra parse never touches the success hot path. + fn captureRpcError(self: *Provider, raw: []const u8) void { + var code: i64 = 0; + var message: []const u8 = ""; + if (std.json.parseFromSlice(std.json.Value, self.allocator, raw, .{})) |parsed| { + defer parsed.deinit(); + if (parsed.value == .object) { + if (parsed.value.object.get("error")) |ev| { + if (ev == .object) { + if (ev.object.get("code")) |c| { + if (c == .integer) code = c.integer; + } + if (ev.object.get("message")) |m| { + if (m == .string) message = m.string; + } + } + } + } + const n = @min(message.len, self.last_error_storage.len); + @memcpy(self.last_error_storage[0..n], message[0..n]); + self.last_error = .{ .code = code, .message = self.last_error_storage[0..n] }; + } else |_| { + self.last_error = .{ .code = 0, .message = "" }; + } + } + fn formatAddressAndBlock(self: *Provider, address: [20]u8, block_tag: []const u8) ![]u8 { const addr_hex = primitives.addressToHex(&address); @@ -452,8 +525,7 @@ pub const BatchCaller = struct { const ids = try self.allocator.alloc(u64, n); defer self.allocator.free(ids); - const base_id = self.provider.next_id; - self.provider.next_id += n; + const base_id = self.provider.next_id.fetchAdd(n, .monotonic); for (0..n) |i| { ids[i] = base_id + i; @@ -1184,6 +1256,35 @@ test "extractResultString - rpc error" { try std.testing.expectError(error.RpcError, extractResultString(allocator, raw)); } +test "Provider.lastError - null before any error" { + var transport = HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo()); + defer transport.deinit(); + var provider = Provider.init(std.testing.allocator, &transport); + try std.testing.expect(provider.lastError() == null); +} + +test "Provider.captureRpcError - records code and message" { + var transport = HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo()); + defer transport.deinit(); + var provider = Provider.init(std.testing.allocator, &transport); + + provider.captureRpcError( + \\{"jsonrpc":"2.0","id":1,"error":{"code":3,"message":"execution reverted"}} + ); + + const info = provider.lastError() orelse return error.TestExpectedDiagnostic; + try std.testing.expectEqual(@as(i64, 3), info.code); + try std.testing.expectEqualStrings("execution reverted", info.message); +} + +test "Provider.next_id - atomic, starts at 1 and increments" { + var transport = HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo()); + defer transport.deinit(); + var provider = Provider.init(std.testing.allocator, &transport); + try std.testing.expectEqual(@as(u64, 1), provider.next_id.fetchAdd(1, .monotonic)); + try std.testing.expectEqual(@as(u64, 2), provider.next_id.load(.monotonic)); +} + test "parseHexU64 - basic values" { try std.testing.expectEqual(@as(u64, 1), try parseHexU64("0x1")); try std.testing.expectEqual(@as(u64, 255), try parseHexU64("0xff")); @@ -1779,7 +1880,7 @@ test "Provider.init" { defer transport.deinit(); const provider = Provider.init(allocator, &transport); - try std.testing.expectEqual(@as(u64, 1), provider.next_id); + try std.testing.expectEqual(@as(u64, 1), provider.next_id.load(.monotonic)); } test "BatchCaller.init and deinit" { diff --git a/src/uint256.zig b/src/uint256.zig index cf017d8..c96fc62 100644 --- a/src/uint256.zig +++ b/src/uint256.zig @@ -2,13 +2,23 @@ const std = @import("std"); const hex = @import("hex.zig"); /// Convert a u256 to a big-endian 32-byte array. +/// +/// Uses `std.mem.writeInt` with an explicit `.big` endianness so the result is +/// correct on any host. On a little-endian target this lowers to the same byte +/// swap as before; on a big-endian target it is a no-op rather than (wrongly) +/// reversing the bytes. pub fn toBigEndianBytes(value: u256) [32]u8 { - return @bitCast(@byteSwap(value)); + var buf: [32]u8 = undefined; + std.mem.writeInt(u256, &buf, value, .big); + return buf; } /// Convert a big-endian 32-byte array to u256. +/// +/// The mirror of `toBigEndianBytes`: reads the bytes as a big-endian integer on +/// any host (see that function for the endianness rationale). pub fn fromBigEndianBytes(bytes: [32]u8) u256 { - return @byteSwap(@as(u256, @bitCast(bytes))); + return std.mem.readInt(u256, &bytes, .big); } /// Convert a hex string (with optional "0x" prefix) to u256. @@ -564,6 +574,18 @@ test "toBigEndianBytes known value" { // Last byte should be 1, all others 0 try std.testing.expectEqual(@as(u8, 1), bytes[31]); try std.testing.expectEqual(@as(u8, 0), bytes[0]); + + // Pin the exact big-endian byte order for a multi-byte value. This is + // host-endianness-independent: 0xdeadbeef must occupy the four + // least-significant (rightmost) bytes regardless of the build target. + var expected: [32]u8 = @splat(0); + expected[28] = 0xde; + expected[29] = 0xad; + expected[30] = 0xbe; + expected[31] = 0xef; + const b2 = toBigEndianBytes(@as(u256, 0xdeadbeef)); + try std.testing.expectEqualSlices(u8, &expected, &b2); + try std.testing.expectEqual(@as(u256, 0xdeadbeef), fromBigEndianBytes(b2)); } test "fromHex basic" { diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index e04dc63..8a82d00 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -382,14 +382,14 @@ test "provider next_id increments across calls" { defer transport.deinit(); var provider = eth.provider.Provider.init(allocator, &transport); - try std.testing.expectEqual(@as(u64, 1), provider.next_id); + try std.testing.expectEqual(@as(u64, 1), provider.next_id.load(.monotonic)); _ = try provider.getChainId(); - try std.testing.expect(provider.next_id > 1); + try std.testing.expect(provider.next_id.load(.monotonic) > 1); - const id_after_first = provider.next_id; + const id_after_first = provider.next_id.load(.monotonic); _ = try provider.getBlockNumber(); - try std.testing.expect(provider.next_id > id_after_first); + try std.testing.expect(provider.next_id.load(.monotonic) > id_after_first); } // ============================================================================