From 5ae1054477eab020fd121a8c2a430f4beec9aaec Mon Sep 17 00:00:00 2001 From: Amlandeep Bhadra Date: Tue, 8 Sep 2026 22:51:09 -0400 Subject: [PATCH 1/2] feat: add typed multi-block simulation with capability errors --- CHANGELOG.md | 4 + build.zig | 3 + docs/content/docs/meta.json | 1 + docs/content/docs/simulation.mdx | 67 ++++++ src/fallback_provider.zig | 1 + src/json_rpc.zig | 1 + src/provider.zig | 78 ++++++- src/root.zig | 2 + src/simulation.zig | 379 +++++++++++++++++++++++++++++++ tests/integration_tests.zig | 38 +++- 10 files changed, 568 insertions(+), 6 deletions(-) create mode 100644 docs/content/docs/simulation.mdx create mode 100644 src/simulation.zig diff --git a/CHANGELOG.md b/CHANGELOG.md index 60c248a..fcef05a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Typed multi-block `eth_simulateV1` requests and results with reusable state overrides, full per-call revert data, and explicit unsupported-method errors. + + ### Added - abigen writes: state-changing contract calls via `*Wallet` -- `send(self, wallet, comptime name, args) ![32]u8` and `sendValue(..., value)` build the same `selector ++ encode(args)` calldata as `call` (shared `encodeCall`) and submit through `Wallet.sendTransaction`; `sendAndWait(..., max_attempts) !TransactionReceipt` waits for the receipt. Naming a `view`/`pure` function in `send` is a `@compileError` pointing you to `call`. Completes the abigen reads + events + writes surface (#68) - `kzg` module: real EIP-4844 KZG support, vendoring the C reference implementations (`c-kzg-4844` v2.1.1 + its `blst` v0.3.14 dependency) and exposing a small Zig API for building blob-transaction sidecars. `kzg.init(allocator)`/`kzg.deinit()` load and free the mainnet trusted setup, which is `@embedFile`d (the KZG ceremony `trusted_setup.txt`) so consumers need no external file; init is idempotent and guarded by an atomic once-flag. `kzg.blobToKzgCommitment(blob)` -> `[48]u8` (c-kzg `blob_to_kzg_commitment`), `kzg.computeBlobKzgProof(blob, commitment)` -> `[48]u8` (`compute_blob_kzg_proof`), `kzg.verifyBlobKzgProof(blob, commitment, proof)` -> `bool` (`verify_blob_kzg_proof`), plus `kzg.verifyBlobKzgProofBatch(...)`. c-kzg's `C_KZG_RET` codes map to a Zig `KzgError` set. `blob.buildSidecar(allocator, raw_blob)` fills a `BlobSidecar` (blob + commitment + proof) via the above, and `blob.computeVersionedHash` derives the EIP-4844 versioned hash from a commitment. blst is built in its portable no-assembly C mode (`-D__BLST_NO_ASM__ -D__BLST_PORTABLE__`, 32-bit limbs) so the build is robust across targets with no per-arch assembly. Verified byte-for-byte against the official ethereum/c-kzg-4844 v2.1.1 test vectors (`blob_to_kzg_commitment`, `compute_blob_kzg_proof`, `verify_blob_kzg_proof` correct/incorrect) embedded under `src/crypto/c-kzg/test_vectors/`, plus round-trip and init/deinit lifecycle tests diff --git a/build.zig b/build.zig index d27e359..d6fc0eb 100644 --- a/build.zig +++ b/build.zig @@ -69,6 +69,8 @@ pub fn build(b: *std.Build) void { docs_step.dependOn(&install_docs.step); // Integration tests (requires Anvil) + const integration_options = b.addOptions(); + integration_options.addOption(u16, "anvil_port", b.option(u16, "anvil-port", "Local Anvil port for integration tests") orelse 8545); const integration_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("tests/integration_tests.zig"), @@ -81,6 +83,7 @@ pub fn build(b: *std.Build) void { }); const run_integration_tests = b.addRunArtifact(integration_tests); + integration_tests.root_module.addOptions("integration_options", integration_options); const integration_step = b.step("integration-test", "Run integration tests (requires Anvil)"); integration_step.dependOn(&run_integration_tests.step); diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 6d9f8ff..17f5894 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -6,6 +6,7 @@ "---Guides---", "examples", "transactions", + "simulation", "kzg", "contracts", "abigen", diff --git a/docs/content/docs/simulation.mdx b/docs/content/docs/simulation.mdx new file mode 100644 index 0000000..08556b7 --- /dev/null +++ b/docs/content/docs/simulation.mdx @@ -0,0 +1,67 @@ +--- +title: Transaction simulation +description: Simulate several calls and blocks with state overrides before broadcasting. +--- + +`Provider.simulateV1` executes calls against temporary node state without +broadcasting transactions. It supports several simulated blocks, block +overrides, and the same `StateOverrides` used by `callWithOverrides`. + +```zig +var overrides = eth.state_overrides.StateOverrides.init(allocator); +defer overrides.deinit(); +try overrides.setBalance(sender, 1_000_000_000_000_000_000); + +var result = try provider.simulateV1(.{ + .block_state_calls = &.{.{ + .state_overrides = &overrides, + .calls = &.{.{ .from = sender, .to = recipient, .value = 100 }}, + }}, + .trace_transfers = true, +}, .{ .tag = .latest }); +defer result.deinit(); + +for (result.value) |block| { + for (block.calls) |call| { + // call.status, call.return_data, call.gas_used, call.logs + if (call.failure) |failure| { + // failure.code, failure.message, failure.data + _ = failure; + } + } +} +``` + +## Result ownership and errors + +The result owns an arena. Call `deinit()` once; all nested strings, log data, +return bytes and raw JSON remain valid until then. Do not free them separately. +Each block also exposes `raw`, preserving the full response, including optional +client-specific fields and full transactions when requested. + +A reverted call has `.status = .failure` and can coexist with successful calls +in the same block. Its error data is preserved in full, including payloads +longer than the diagnostic buffer used by `Provider.lastError()`. + +`error.MethodNotFound` means the endpoint answered with JSON-RPC code `-32601`. +Choose an endpoint that exposes the method, or fall back in your application. +This error is not eligible for transport failover. Other RPC errors remain +`error.RpcError`, with details in `Provider.lastError()`. + +Requests accept 1–256 explicit blocks. Nodes apply their own gas limits and +also count generated gap blocks toward their limit. `validation` defaults to +false, following `eth_call` semantics; enable it for transaction validation. +Simulation is an estimate against a particular state, not a promise of later +execution results. See the [Geth API](https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-eth#eth-simulatev1) +for node behavior and limits. + +## Local integration test + +```sh +anvil --silent --port 18545 +# In another terminal: +zig build integration-test -Danvil-port=18545 +``` + +The suite verifies an overridden contract revert alongside a successful call, +and checks that the simulation leaves the account balance unchanged. diff --git a/src/fallback_provider.zig b/src/fallback_provider.zig index a52b4e6..27d7f9b 100644 --- a/src/fallback_provider.zig +++ b/src/fallback_provider.zig @@ -397,6 +397,7 @@ test "isFailoverError - transport errors trigger failover" { test "isFailoverError - RpcError is a real answer and does NOT fail over" { // The critical classification: a node that answered must not be retried. try testing.expect(!isFailoverError(error.RpcError)); + try testing.expect(!isFailoverError(error.MethodNotFound)); } test "isFailoverError - local errors do NOT fail over" { diff --git a/src/json_rpc.zig b/src/json_rpc.zig index e0d7424..4827ec0 100644 --- a/src/json_rpc.zig +++ b/src/json_rpc.zig @@ -57,6 +57,7 @@ pub const Method = struct { // Transactions pub const eth_call = "eth_call"; + pub const eth_simulateV1 = "eth_simulateV1"; pub const eth_estimateGas = "eth_estimateGas"; pub const eth_sendRawTransaction = "eth_sendRawTransaction"; pub const eth_getTransactionByHash = "eth_getTransactionByHash"; diff --git a/src/provider.zig b/src/provider.zig index bea7c61..7578813 100644 --- a/src/provider.zig +++ b/src/provider.zig @@ -9,6 +9,7 @@ const state_overrides_mod = @import("state_overrides.zig"); const rpc_transaction_mod = @import("rpc_transaction.zig"); const HttpTransport = @import("http_transport.zig").HttpTransport; const runtime = @import("runtime.zig"); +const simulation = @import("simulation.zig"); /// Read-only Ethereum JSON-RPC provider. /// @@ -59,7 +60,7 @@ pub const Provider = struct { } /// 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 + /// last call did not fail with `error.RpcError` or `error.MethodNotFound`. 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 { @@ -209,6 +210,42 @@ pub const Provider = struct { return parseHexBytes(self.allocator, result_str); } + /// Simulate multiple calls/blocks without broadcasting. The returned + /// owner frees all nested data with deinit(). MethodNotFound means this + /// endpoint does not expose eth_simulateV1; it is not a transport failure. + pub fn simulateV1(self: *Provider, payload: simulation.SimulatePayload, block: json_rpc.BlockParam) !simulation.SimulateResult { + const params = try simulation.formatParams(self.allocator, payload, block); + defer self.allocator.free(params); + const raw = try self.requestJson(json_rpc.Method.eth_simulateV1, params); + defer self.allocator.free(raw); + return simulation.parseResult(self.allocator, raw); + } + + /// Raw escape hatch for structured RPCs. Returns the JSON result value + /// (not the JSON-RPC envelope), owned by the caller. params must encode a + /// JSON array. Preserves RPC diagnostics and distinguishes MethodNotFound. + pub fn requestJson(self: *Provider, method: []const u8, params: []const u8) ![]u8 { + const raw = try self.rpcCall(method, params); + defer self.allocator.free(raw); + return self.extractJsonResult(raw); + } + + fn extractJsonResult(self: *Provider, raw: []const u8) ![]u8 { + const parsed = std.json.parseFromSlice(std.json.Value, self.allocator, raw, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return error.InvalidResponse, + }; + defer parsed.deinit(); + const obj = try simulation.object(parsed.value); + if (simulation.optional(obj, "error")) |rpc_error| { + const failure = try simulation.parseFailure(rpc_error); + self.captureRpcError(raw); + if (failure.code == json_rpc.ErrorCode.method_not_found) return error.MethodNotFound; + return error.RpcError; + } + return std.json.Stringify.valueAlloc(self.allocator, try simulation.field(obj, "result"), .{}); + } + /// Executes a message call (eth_call) against the latest block with /// state overrides applied. Lets simulators answer "what if?" questions /// (modified balances, code, storage) without forking a node. @@ -989,8 +1026,9 @@ pub fn parseSingleTransaction(allocator: std.mem.Allocator, obj: std.json.Object // Typed transactions may report only `yParity`; `v` is a legacy alias. const v_str = jsonGetString(obj, "v") orelse jsonGetString(obj, "yParity") orelse return error.InvalidResponse; const v = try parseHexU256(v_str); - const r = try parseHash(jsonGetString(obj, "r") orelse return error.InvalidResponse); - const s = try parseHash(jsonGetString(obj, "s") orelse return error.InvalidResponse); + // Signature scalars are RPC quantities and may omit leading zero nibbles. + const r = uint256_mod.toBigEndianBytes(try parseHexU256(jsonGetString(obj, "r") orelse return error.InvalidResponse)); + const s = uint256_mod.toBigEndianBytes(try parseHexU256(jsonGetString(obj, "s") orelse return error.InvalidResponse)); const type_val: u8 = if (jsonGetString(obj, "type")) |t| try parseHexU8(t) else 0; const chain_id = try parseOptionalHexU64(jsonGetString(obj, "chainId")); @@ -1783,6 +1821,21 @@ test "parseSingleTransaction - yParity accepted when v missing" { try std.testing.expectEqual(@as(u256, 1), tx.v); } +test "parseSingleTransaction - signature quantities are padded to 32 bytes" { + const a = std.testing.allocator; + const raw = + \\{"hash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + \\ "nonce":"0x0","from":"0x1111111111111111111111111111111111111111", + \\ "gas":"0x5208","v":"0x1","r":"0xabc","s":"0x1"} + ; + const parsed = try std.json.parseFromSlice(std.json.Value, a, raw, .{}); + defer parsed.deinit(); + const tx = try parseSingleTransaction(a, parsed.value.object); + defer rpc_transaction_mod.freeRpcTransaction(a, tx); + try std.testing.expectEqual(@as(u256, 0xabc), uint256_mod.fromBigEndianBytes(tx.r)); + try std.testing.expectEqual(@as(u256, 1), uint256_mod.fromBigEndianBytes(tx.s)); +} + test "parseSingleTransaction - malformed fields are rejected" { const allocator = std.testing.allocator; // Base object is valid; each case corrupts or removes one field. @@ -2046,3 +2099,22 @@ test "parseBatchResponse partial failure" { else => return error.TestUnexpectedResult, } } + +test "structured RPC errors distinguish missing methods and retain diagnostics" { + var transport = HttpTransport.init(std.testing.allocator, "http://127.0.0.1:1", runtime.blockingIo()); + defer transport.deinit(); + var provider = Provider.init(std.testing.allocator, &transport); + try std.testing.expectError(error.MethodNotFound, provider.extractJsonResult( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":-32601,\"message\":\"method not found\"}}", + )); + try std.testing.expectEqual(@as(i64, -32601), provider.lastError().?.code); + try std.testing.expectError(error.RpcError, provider.extractJsonResult( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"error\":{\"code\":3,\"message\":\"execution reverted\",\"data\":\"0xdeadbeef\"}}", + )); + try std.testing.expectEqualStrings("0xdeadbeef", provider.lastError().?.data); + const result = try provider.extractJsonResult("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"calls\":[]}}"); + defer std.testing.allocator.free(result); + try std.testing.expectEqualStrings("{\"calls\":[]}", result); + try std.testing.expectError(error.InvalidResponse, provider.extractJsonResult("[]")); + try std.testing.expectError(error.InvalidResponse, provider.extractJsonResult("{}")); +} diff --git a/src/root.zig b/src/root.zig index 3340c59..e3fe552 100644 --- a/src/root.zig +++ b/src/root.zig @@ -42,6 +42,7 @@ pub const subscription = @import("subscription.zig"); pub const ws_client = @import("ws_client.zig"); pub const state_overrides = @import("state_overrides.zig"); pub const provider = @import("provider.zig"); +pub const simulation = @import("simulation.zig"); pub const retry_provider = @import("retry_provider.zig"); pub const RetryingProvider = retry_provider.RetryingProvider; pub const RetryOpts = retry_provider.RetryOpts; @@ -135,6 +136,7 @@ test { _ = @import("ws_client.zig"); _ = @import("state_overrides.zig"); _ = @import("provider.zig"); + _ = @import("simulation.zig"); _ = @import("retry_provider.zig"); _ = @import("fallback_provider.zig"); // Layer 7: Client diff --git a/src/simulation.zig b/src/simulation.zig new file mode 100644 index 0000000..c048625 --- /dev/null +++ b/src/simulation.zig @@ -0,0 +1,379 @@ +//! Typed eth_simulateV1 requests and results. Simulation never broadcasts. +//! See https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-eth. +const std = @import("std"); +const json_rpc = @import("json_rpc.zig"); +const hex = @import("hex.zig"); +const uint256 = @import("uint256.zig"); +const state = @import("state_overrides.zig"); +const receipt = @import("receipt.zig"); +const access = @import("access_list.zig"); + +/// A simulated transaction. Null fields are omitted so the node applies its +/// defaults. A null destination represents contract creation. +pub const Call = struct { + from: ?[20]u8 = null, + to: ?[20]u8 = null, + gas: ?u64 = null, + gas_price: ?u256 = null, + max_fee_per_gas: ?u256 = null, + max_priority_fee_per_gas: ?u256 = null, + value: ?u256 = null, + nonce: ?u64 = null, + chain_id: ?u64 = null, + data: ?[]const u8 = null, + access_list: ?[]const access.AccessListItem = null, + + pub fn jsonStringify(self: Call, j: *std.json.Stringify) std.json.Stringify.Error!void { + try j.beginObject(); + if (self.from) |v| try hexField(j, "from", &v); + if (self.to) |v| try hexField(j, "to", &v); + try quantityField(j, "gas", self.gas); + try quantityField(j, "gasPrice", self.gas_price); + try quantityField(j, "maxFeePerGas", self.max_fee_per_gas); + try quantityField(j, "maxPriorityFeePerGas", self.max_priority_fee_per_gas); + try quantityField(j, "value", self.value); + try quantityField(j, "nonce", self.nonce); + try quantityField(j, "chainId", self.chain_id); + if (self.data) |v| try hexField(j, "input", v); + if (self.access_list) |items| { + try j.objectField("accessList"); + try j.beginArray(); + for (items) |item| { + try j.beginObject(); + try hexField(j, "address", &item.address); + try j.objectField("storageKeys"); + try j.beginArray(); + for (item.storage_keys) |key| try j.print("\"0x{x}\"", .{&key}); + try j.endArray(); + try j.endObject(); + } + try j.endArray(); + } + try j.endObject(); + } +}; + +pub const Withdrawal = struct { + index: u64, + validator_index: u64, + address: [20]u8, + amount: u64, + + pub fn jsonStringify(self: Withdrawal, j: *std.json.Stringify) std.json.Stringify.Error!void { + try j.beginObject(); + try quantityField(j, "index", @as(?u64, self.index)); + try quantityField(j, "validatorIndex", @as(?u64, self.validator_index)); + try hexField(j, "address", &self.address); + try quantityField(j, "amount", @as(?u64, self.amount)); + try j.endObject(); + } +}; + +pub const BlockOverrides = struct { + number: ?u64 = null, + time: ?u64 = null, + gas_limit: ?u64 = null, + base_fee_per_gas: ?u256 = null, + blob_base_fee: ?u64 = null, + prev_randao: ?[32]u8 = null, + fee_recipient: ?[20]u8 = null, + withdrawals: ?[]const Withdrawal = null, + + pub fn jsonStringify(self: BlockOverrides, j: *std.json.Stringify) std.json.Stringify.Error!void { + try j.beginObject(); + try quantityField(j, "number", self.number); + try quantityField(j, "time", self.time); + try quantityField(j, "gasLimit", self.gas_limit); + try quantityField(j, "baseFeePerGas", self.base_fee_per_gas); + try quantityField(j, "blobBaseFee", self.blob_base_fee); + if (self.prev_randao) |v| try hexField(j, "prevRandao", &v); + if (self.fee_recipient) |v| try hexField(j, "feeRecipient", &v); + if (self.withdrawals) |v| { + try j.objectField("withdrawals"); + try j.write(v); + } + try j.endObject(); + } +}; + +pub const BlockStateCalls = struct { + calls: []const Call, + block_overrides: ?BlockOverrides = null, + /// Borrows the existing state override set. Serialization uses its + /// canonical JSON representation, including storage and bytecode. + state_overrides: ?*const state.StateOverrides = null, +}; + +pub const SimulatePayload = struct { + block_state_calls: []const BlockStateCalls, + validation: bool = false, + trace_transfers: bool = false, + return_full_transactions: bool = false, +}; + +/// One arena owns all nested data. Call deinit once; do not individually free +/// borrowed strings, raw JSON, logs, or return data inside value. +pub fn Owned(comptime T: type) type { + return struct { + arena: std.heap.ArenaAllocator, + value: T, + + pub fn deinit(self: *@This()) void { + self.arena.deinit(); + self.* = undefined; + } + }; +} + +pub const RpcFailure = struct { + code: i64, + message: []const u8, + /// Preserves the complete error payload, with no provider diagnostic cap. + data: ?std.json.Value = null, +}; + +pub const CallResult = struct { + status: enum { success, failure }, + return_data: []const u8, + gas_used: u64, + logs: []const receipt.Log, + failure: ?RpcFailure, +}; + +pub const BlockResult = struct { + number: ?u64, + hash: ?[32]u8, + calls: []const CallResult, + /// The complete block result, including client-specific header fields and + /// transactions. Simulated block identity varies across client versions. + raw: std.json.Value, +}; + +pub const SimulateResult = Owned([]const BlockResult); + +/// Encode the two positional eth_simulateV1 parameters. Caller owns the JSON. +pub fn formatParams(allocator: std.mem.Allocator, payload: SimulatePayload, block: json_rpc.BlockParam) ![]u8 { + if (payload.block_state_calls.len == 0 or payload.block_state_calls.len > 256) return error.InvalidArgument; + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const temp = arena.allocator(); + const WireBlock = struct { + calls: []const Call, + blockOverrides: ?BlockOverrides, + stateOverrides: ?std.json.Value, + }; + const blocks = try temp.alloc(WireBlock, payload.block_state_calls.len); + for (blocks, payload.block_state_calls) |*wire, input| { + wire.* = .{ .calls = input.calls, .blockOverrides = input.block_overrides, .stateOverrides = null }; + if (input.state_overrides) |overrides| { + const raw = try overrides.serializeJson(temp); + wire.stateOverrides = try std.json.parseFromSliceLeaky(std.json.Value, temp, raw, .{}); + } + } + var block_buf: [20]u8 = undefined; + return std.json.Stringify.valueAlloc(allocator, .{ .{ + .blockStateCalls = blocks, + .validation = payload.validation, + .traceTransfers = payload.trace_transfers, + .returnFullTransactions = payload.return_full_transactions, + }, block.toString(&block_buf) }, .{ .emit_null_optional_fields = false }); +} + +/// Parse an eth_simulateV1 result value (without the JSON-RPC envelope). +pub fn parseResult(allocator: std.mem.Allocator, raw: []const u8) !SimulateResult { + var arena = std.heap.ArenaAllocator.init(allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + const root = try parseJson(a, raw); + const values = try array(root); + const blocks = try a.alloc(BlockResult, values.len); + for (blocks, values) |*block, value| { + const obj = try object(value); + const calls_json = try array(try field(obj, "calls")); + const calls = try a.alloc(CallResult, calls_json.len); + for (calls, calls_json) |*call, call_json| { + const call_obj = try object(call_json); + const result_status = try quantity(u8, try field(call_obj, "status")); + if (result_status > 1) return error.InvalidResponse; + call.* = .{ + .status = if (result_status == 1) .success else .failure, + .return_data = try dataBytes(a, try field(call_obj, "returnData")), + .gas_used = try quantity(u64, try field(call_obj, "gasUsed")), + .logs = try parseLogs(a, optional(call_obj, "logs")), + .failure = if (optional(call_obj, "error")) |err| try parseFailure(err) else null, + }; + } + block.* = .{ + .number = if (optional(obj, "number")) |v| try quantity(u64, v) else null, + .hash = if (optional(obj, "hash")) |v| try fixedHex(32, v) else null, + .calls = calls, + .raw = value, + }; + } + return .{ .arena = arena, .value = blocks }; +} + +pub fn quantityField(j: *std.json.Stringify, name: []const u8, value: anytype) std.json.Stringify.Error!void { + if (value) |v| { + try j.objectField(name); + try j.print("\"0x{x}\"", .{v}); + } +} + +pub fn hexField(j: *std.json.Stringify, name: []const u8, value: []const u8) std.json.Stringify.Error!void { + try j.objectField(name); + try j.print("\"0x{x}\"", .{value}); +} + +pub fn parseJson(allocator: std.mem.Allocator, raw: []const u8) !std.json.Value { + return std.json.parseFromSliceLeaky(std.json.Value, allocator, raw, .{ .allocate = .alloc_always }) catch |err| switch (err) { + error.OutOfMemory => error.OutOfMemory, + else => error.InvalidResponse, + }; +} + +pub fn object(value: std.json.Value) !std.json.ObjectMap { + if (value != .object) return error.InvalidResponse; + return value.object; +} + +pub fn array(value: std.json.Value) ![]const std.json.Value { + if (value != .array) return error.InvalidResponse; + return value.array.items; +} + +pub fn field(obj: std.json.ObjectMap, name: []const u8) !std.json.Value { + return obj.get(name) orelse error.InvalidResponse; +} + +pub fn optional(obj: std.json.ObjectMap, name: []const u8) ?std.json.Value { + const value = obj.get(name) orelse return null; + return if (value == .null) null else value; +} + +pub fn string(value: std.json.Value) ![]const u8 { + if (value != .string) return error.InvalidResponse; + return value.string; +} + +pub fn quantity(comptime T: type, value: std.json.Value) !T { + const s = try string(value); + if (s.len < 3 or !std.mem.startsWith(u8, s, "0x")) return error.InvalidResponse; + const n = uint256.fromHex(s) catch return error.InvalidResponse; + if (n > std.math.maxInt(T)) return error.InvalidResponse; + return @intCast(n); +} + +pub fn fixedHex(comptime size: usize, value: std.json.Value) ![size]u8 { + const s = try string(value); + if (s.len != 2 + size * 2 or !std.mem.startsWith(u8, s, "0x")) return error.InvalidResponse; + return hex.hexToBytesFixed(size, s) catch return error.InvalidResponse; +} + +pub fn dataBytes(allocator: std.mem.Allocator, value: std.json.Value) ![]const u8 { + const s = try string(value); + if (s.len < 2 or s.len % 2 != 0 or !std.mem.startsWith(u8, s, "0x")) return error.InvalidResponse; + const out = try allocator.alloc(u8, (s.len - 2) / 2); + errdefer allocator.free(out); + return hex.hexToBytes(out, s) catch return error.InvalidResponse; +} + +pub fn parseFailure(value: std.json.Value) !RpcFailure { + const obj = try object(value); + const code = try field(obj, "code"); + if (code != .integer) return error.InvalidResponse; + return .{ + .code = code.integer, + .message = try string(try field(obj, "message")), + .data = optional(obj, "data"), + }; +} + +fn parseLogs(allocator: std.mem.Allocator, value: ?std.json.Value) ![]const receipt.Log { + const values = try array(value orelse return &.{}); + const logs = try allocator.alloc(receipt.Log, values.len); + for (logs, values) |*log, v| { + const obj = try object(v); + const topics_json = try array(try field(obj, "topics")); + const topics = try allocator.alloc([32]u8, topics_json.len); + for (topics, topics_json) |*topic, t| topic.* = try fixedHex(32, t); + log.* = .{ + .address = try fixedHex(20, try field(obj, "address")), + .topics = topics, + .data = try dataBytes(allocator, try field(obj, "data")), + .block_number = if (optional(obj, "blockNumber")) |n| try quantity(u64, n) else null, + .transaction_hash = if (optional(obj, "transactionHash")) |h| try fixedHex(32, h) else null, + .block_hash = if (optional(obj, "blockHash")) |h| try fixedHex(32, h) else null, + .transaction_index = if (optional(obj, "transactionIndex")) |n| try quantity(u32, n) else null, + .log_index = if (optional(obj, "logIndex")) |n| try quantity(u32, n) else null, + .removed = false, + }; + } + return logs; +} + +test "simulation request reuses state overrides and encodes quantities" { + const a = std.testing.allocator; + var overrides = state.StateOverrides.init(a); + defer overrides.deinit(); + const addr: [20]u8 = @splat(0xab); + try overrides.setBalance(addr, 100); + try overrides.setStorageAt(addr, @splat(0), @splat(1)); + const raw = try formatParams(a, .{ + .block_state_calls = &.{.{ + .calls = &.{.{ .from = addr, .to = addr, .value = 0, .data = &.{ 0x12, 0x34 } }}, + .state_overrides = &overrides, + .block_overrides = .{ .base_fee_per_gas = 9 }, + }}, + .trace_transfers = true, + }, .{ .number = 15 }); + defer a.free(raw); + const parsed = try std.json.parseFromSlice(std.json.Value, a, raw, .{}); + defer parsed.deinit(); + const params = parsed.value.array.items; + try std.testing.expectEqualStrings("0xf", params[1].string); + const payload = params[0].object; + try std.testing.expect(payload.get("traceTransfers").?.bool); + const block = payload.get("blockStateCalls").?.array.items[0].object; + try std.testing.expectEqualStrings("0x9", block.get("blockOverrides").?.object.get("baseFeePerGas").?.string); + const call = block.get("calls").?.array.items[0].object; + try std.testing.expectEqualStrings("0x1234", call.get("input").?.string); + try std.testing.expectEqualStrings("0x0", call.get("value").?.string); + try std.testing.expect(call.get("gas") == null); + const account = block.get("stateOverrides").?.object.get("0xabababababababababababababababababababab").?.object; + try std.testing.expectEqualStrings("0x64", account.get("balance").?.string); + try std.testing.expect(account.get("stateDiff") != null); +} + +const fixture = + \\[{"number":"0x1","calls":[{"status":"0x1","gasUsed":"0x5208","returnData":"0x1234","logs":[{"address":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","topics":[],"data":"0x01"}]},{"status":"0x0","gasUsed":"0x100","returnData":"0xdeadbeef","error":{"code":3,"message":"execution reverted","data":"0xdeadbeef"}}]},{"number":"0x2","calls":[{"status":"0x1","gasUsed":"0x0","returnData":"0x","logs":[]}]}] +; + +fn checkResult(allocator: std.mem.Allocator) !void { + var result = try parseResult(allocator, fixture); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 2), result.value.len); + try std.testing.expectEqual(@as(u64, 21000), result.value[0].calls[0].gas_used); + try std.testing.expectEqualSlices(u8, &.{ 0x12, 0x34 }, result.value[0].calls[0].return_data); + try std.testing.expectEqual(@as(usize, 1), result.value[0].calls[0].logs.len); + try std.testing.expectEqual(.failure, result.value[0].calls[1].status); + try std.testing.expectEqual(@as(i64, 3), result.value[0].calls[1].failure.?.code); + try std.testing.expectEqualStrings("0xdeadbeef", result.value[0].calls[1].failure.?.data.?.string); +} + +test "multi-block simulation preserves successful calls and per-call reverts" { + try checkResult(std.testing.allocator); +} + +test "simulation result frees all nested allocations on allocation failure" { + try std.testing.checkAllAllocationFailures(std.testing.allocator, checkResult, .{}); +} + +test "simulation rejects malformed results and invalid request block counts" { + const a = std.testing.allocator; + for ([_][]const u8{ + "{}", "[{}]", "[{\"calls\":[{}]}]", + "[{\"calls\":[{\"status\":\"0x2\",\"gasUsed\":\"0x0\",\"returnData\":\"0x\"}]}]", "[{\"calls\":[{\"status\":\"0x1\",\"gasUsed\":\"0x10000000000000000\",\"returnData\":\"0x\"}]}]", "[{\"calls\":[{\"status\":\"0x1\",\"gasUsed\":\"0x0\",\"returnData\":\"0xz0\"}]}]", + }) |raw| try std.testing.expectError(error.InvalidResponse, parseResult(a, raw)); + try std.testing.expectError(error.InvalidArgument, formatParams(a, .{ .block_state_calls = &.{} }, .{ .tag = .latest })); +} diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index 64ea19d..31342e7 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -10,9 +10,9 @@ const std = @import("std"); const eth = @import("eth"); -const ANVIL_URL = "http://127.0.0.1:8545"; +const ANVIL_PORT = @import("integration_options").anvil_port; +const ANVIL_URL = std.fmt.comptimePrint("http://127.0.0.1:{d}", .{ANVIL_PORT}); const ANVIL_HOST = "127.0.0.1"; -const ANVIL_PORT = 8545; // Anvil pre-funded account #0 const ACCOUNT_0_KEY_HEX = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; @@ -483,7 +483,7 @@ test "callWithOverrides without an override matches plain call" { // WsClient: resilient WebSocket subscriptions (issue #35) // ============================================================================ -const ANVIL_WS_URL = "ws://127.0.0.1:8545"; +const ANVIL_WS_URL = std.fmt.comptimePrint("ws://127.0.0.1:{d}", .{ANVIL_PORT}); /// Trigger a block on Anvil so newHeads subscriptions emit a notification. fn anvilMineOne(allocator: std.mem.Allocator) !void { @@ -783,3 +783,35 @@ test "ENS resolve: CCIP-Read wildcard name surfaces OffchainLookupRequired" { const result = eth.ens_resolver.resolve(allocator, &provider, "1.offchainexample.eth"); try std.testing.expectError(error.OffchainLookupRequired, result); } + +test "simulateV1 applies overrides and returns success plus a reverting call" { + if (!isAnvilAvailable()) return error.SkipZigTest; + const allocator = std.testing.allocator; + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL, eth.runtime.blockingIo()); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + var overrides = eth.state_overrides.StateOverrides.init(allocator); + defer overrides.deinit(); + const sender = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + const reverting: [20]u8 = @splat(0xaa); + const recipient = try eth.primitives.addressFromHex(ACCOUNT_1_ADDR_HEX); + // PUSH1 0, PUSH1 0, REVERT. Installed only in simulation state. + try overrides.setCode(reverting, &.{ 0x60, 0, 0x60, 0, 0xfd }); + const before = try provider.getBalance(recipient); + var result = try provider.simulateV1(.{ + .block_state_calls = &.{.{ + .state_overrides = &overrides, + .calls = &.{ + .{ .from = sender, .to = recipient, .value = 100, .gas = 21_000 }, + .{ .from = sender, .to = reverting, .gas = 100_000 }, + }, + }}, + }, .{ .tag = .latest }); + defer result.deinit(); + try std.testing.expectEqual(@as(usize, 1), result.value.len); + try std.testing.expectEqual(@as(usize, 2), result.value[0].calls.len); + try std.testing.expectEqual(.success, result.value[0].calls[0].status); + try std.testing.expectEqual(@as(u64, 21_000), result.value[0].calls[0].gas_used); + try std.testing.expectEqual(.failure, result.value[0].calls[1].status); + try std.testing.expectEqual(before, try provider.getBalance(recipient)); +} From 7c37115f96a44c661fddfe82b2225280b6f5414d Mon Sep 17 00:00:00 2001 From: Amlandeep Bhadra Date: Tue, 8 Sep 2026 23:12:11 -0400 Subject: [PATCH 2/2] docs: clarify custom Anvil port in integration setup --- tests/integration_tests.zig | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index 31342e7..7fd8187 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -1,5 +1,6 @@ // Integration tests for eth.zig against a local Anvil instance. -// These tests require Anvil running at http://127.0.0.1:8545. +// These tests require local Anvil; port 8545 is the default. +// For a custom port, pair `anvil --port PORT` with `-Danvil-port=PORT` below. // // Start Anvil before running: // anvil