Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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);

Expand Down
1 change: 1 addition & 0 deletions docs/content/docs/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"---Guides---",
"examples",
"transactions",
"simulation",
"kzg",
"contracts",
"abigen",
Expand Down
67 changes: 67 additions & 0 deletions docs/content/docs/simulation.mdx
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/fallback_provider.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
1 change: 1 addition & 0 deletions src/json_rpc.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
78 changes: 75 additions & 3 deletions src/provider.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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("{}"));
}
2 changes: 2 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading