Skip to content
Merged
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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- EIP-7702 SetCode transactions (type 0x04), shipped in Pectra and live on mainnet (#66). New `transaction.Authorization` tuple `{chain_id, address, nonce, y_parity, r, s}` and `transaction.Eip7702Transaction` (note: `to` is non-nullable -- type-0x04 cannot create contracts), wired into the `Transaction` union and the `serializeForSigning`/`hashForSigning`/`serializeSigned` dispatch. The signing payload is `0x04 || rlp([chain_id, nonce, max_priority_fee_per_gas, max_fee_per_gas, gas_limit, to, value, data, access_list, authorization_list])` (signed appends `y_parity, r, s`), each authorization encoded as `[chain_id, address, nonce, y_parity, r, s]`. `signer.signAuthorization(allocator, chain_id, address, nonce)` signs the authorization hash `keccak256(0x05 || rlp([chain_id, address, nonce]))` (MAGIC = 0x05) and fills `y_parity/r/s`; `signer.hashAuthorization` exposes that hash. `rpc_transaction.RpcTransaction` gains an optional `authorization_list`, parsed best-effort from an RPC `authorizationList`. Verified via sign-then-recover round trip (recovered signer equals the authority) plus fixed RLP-shape assertions; no official EIP-7702 signing test vector was found in the EIP or this repo, so correctness rests on the round trip plus shape checks
- `fallback_provider` module: multi-RPC failover provider (#70). `FallbackProvider.init(allocator, endpoints, opts)` owns one `HttpTransport`+`Provider` per endpoint and exposes the same read method surface as `Provider` (`getBalance`, `getBlockNumber`, `call`, `getLogs`, `sendRawTransaction`, ...) as a drop-in replacement. On a transport/connection failure it fails over to the next healthy endpoint; after `failover_threshold` consecutive failures an endpoint is marked unhealthy and skipped, and a periodic probe (`recovery_probe_ms` after its last failure) restores it, with lower-index endpoints always preferred so a recovered primary is reclaimed. Crucially, failover fires ONLY on transport errors (`error.ConnectionFailed`/`error.HttpError`/socket errors); an `error.RpcError` is the node's real answer (a revert, unsupported method, rate-limit) and is returned to the caller WITHOUT failover. The failover/recovery state machine is a pure function `selectEndpoint(health, now_ms, opts, tried)` over injectable timestamps, and the failover-vs-RpcError decision is the pure `isFailoverError(err)`, both unit-tested deterministically without network
- `keystore` module: encrypted JSON keystore support (Web3 Secret Storage v3), the on-disk format used by geth/ethers/foundry/MyEtherWallet (#65). `decrypt(allocator, json, password)` parses a v3 document, derives the key via either KDF (`scrypt` or `pbkdf2`-HMAC-SHA256), verifies `MAC = keccak256(derived_key[16..32] ++ ciphertext)` in constant time (`std.crypto.timing_safe.eql`, returning `error.InvalidPassword` on mismatch), and AES-128-CTR decrypts to recover the 32-byte private key. `encrypt(allocator, key, password, opts)` produces a v3 JSON string (defaulting to scrypt N=2^18 + aes-128-ctr, with a v4 UUID and a random salt/IV drawn from the runtime `Io`). All derived keys, the plaintext key copy, and the ciphertext buffer are wiped with `secureZero` on exit. Verified against the canonical Web3 Secret Storage scrypt and pbkdf2 test vectors (both decrypt to `7a28b5ba...fe9d`), proving interop with geth/ethers/foundry
- `keystore` module: encrypted JSON keystore support (Web3 Secret Storage v3), the on-disk format used by geth/ethers/foundry/MyEtherWallet (#65). `decrypt(allocator, json, password)` parses a v3 document, derives the key via either KDF (`scrypt` or `pbkdf2`-HMAC-SHA256), verifies `MAC = keccak256(derived_key[16..32] ++ ciphertext)` in constant time (`std.crypto.timing_safe.eql`, returning `error.InvalidPassword` on mismatch), and AES-128-CTR decrypts to recover the 32-byte private key. `encrypt(allocator, key, password, io, opts)` produces a v3 JSON string (defaulting to scrypt N=2^18 + aes-128-ctr, with a v4 UUID and a random salt/IV drawn from the caller's `Io`). All derived keys, the plaintext key copy, and the ciphertext buffer are wiped with `secureZero` on exit. Verified against the canonical Web3 Secret Storage scrypt and pbkdf2 test vectors (both decrypt to `7a28b5ba...fe9d`), proving interop with geth/ethers/foundry

### Changed
- **BREAKING:** `std.Io` is now an explicit parameter on every network constructor and on the functions that need randomness or sleep, removing the hidden global `Io` and aligning with Zig 0.16's guidance to pass I/O handles explicitly rather than reaching for a global default. New signatures: `HttpTransport.init(allocator, url, io)`, `WsTransport.connect(allocator, url, io)`, `WsClient.connect(allocator, url, io, opts)`, `flashbots.Relay.init(allocator, url, auth_key, io)`, `FallbackProvider.init(allocator, endpoints, io, opts)`, `MevShareClient.init(allocator, relay_url, stream_url, auth_key, io)` / `initMainnet(allocator, auth_key, io)`, `mnemonic.generate(io, comptime entropy_size)`, `keystore.encrypt(allocator, key, password, io, opts)`, and the runtime helpers `runtime.milliTimestamp(io)` / `runtime.sleepMs(io, ms)`. `Provider`, `Wallet`, `RetryingProvider`, and `NonceManager` inherit the `Io` of the transport/provider they wrap (via `Provider.io()`), so they take no new parameter. `runtime.defaultIo()` is renamed to `runtime.blockingIo()` and is now just a convenient blocking `Io` value to pass explicitly -- pass `eth.runtime.blockingIo()` for the previous default blocking behavior. Because the `Io` is caller-provided, eth.zig can now run on your own event loop.

## [0.6.0] - 2026-06-10

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/batch-calls.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ eth.zig supports [JSON-RPC batch requests](https://www.jsonrpc.org/specification
const eth = @import("eth");

// Set up provider
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/ens.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Resolve an ENS name to an address:
```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://eth.llamarpc.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://eth.llamarpc.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/examples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const checksum = eth.primitives.addressToChecksum(&addr);
```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down Expand Up @@ -132,7 +132,7 @@ const v2_swap = comptime eth.keccak.selector(
"swapExactTokensForTokens(uint256,uint256,address[],address,uint256)",
);

var client = eth.mev_share.MevShareClient.init(allocator, relay_url, stream_url, auth_key);
var client = eth.mev_share.MevShareClient.init(allocator, relay_url, stream_url, auth_key, eth.runtime.blockingIo());
defer client.deinit();

// Subscribe to the SSE stream; the callback fires per pending-tx hint.
Expand Down
4 changes: 2 additions & 2 deletions docs/content/docs/fallback-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ var fb = try eth.fallback_provider.FallbackProvider.init(allocator, &.{
"https://primary.example.com",
"https://backup-a.example.com",
"https://backup-b.example.com",
}, .{});
}, eth.runtime.blockingIo(), .{});
defer fb.deinit();

// Same surface as Provider. If the primary's connection fails, this call
Expand Down Expand Up @@ -108,7 +108,7 @@ pub const FallbackOpts = struct {
```

```zig
var fb = try eth.fallback_provider.FallbackProvider.init(allocator, endpoints, .{
var fb = try eth.fallback_provider.FallbackProvider.init(allocator, endpoints, eth.runtime.blockingIo(), .{
.failover_threshold = 5,
.recovery_probe_ms = 10_000,
});
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/introduction.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ pub fn main() !void {
_ = checksum;

// Sign and send a transaction
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
6 changes: 3 additions & 3 deletions docs/content/docs/keystore.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ The cipher is always `aes-128-ctr`, the only cipher the v3 spec defines.
geth, ethers, or cast. The caller owns the returned slice.

```zig
const json = try eth.keystore.encrypt(allocator, private_key, "my password", .{});
const json = try eth.keystore.encrypt(allocator, private_key, "my password", eth.runtime.blockingIo(), .{});
defer allocator.free(json);
// json is a complete {"version":3,"id":...,"crypto":{...}} document.
```
Expand All @@ -61,11 +61,11 @@ write. Those defaults are deliberately expensive; override them through

```zig
// Faster scrypt for a test fixture (N = 2^12 = 4096):
const fast = try eth.keystore.encrypt(allocator, key, "pw", .{ .scrypt_log_n = 12 });
const fast = try eth.keystore.encrypt(allocator, key, "pw", eth.runtime.blockingIo(), .{ .scrypt_log_n = 12 });
defer allocator.free(fast);

// pbkdf2-HMAC-SHA256 instead of scrypt:
const pb = try eth.keystore.encrypt(allocator, key, "pw", .{
const pb = try eth.keystore.encrypt(allocator, key, "pw", eth.runtime.blockingIo(), .{
.kdf = .pbkdf2,
.pbkdf2_c = 262144,
});
Expand Down
4 changes: 3 additions & 1 deletion docs/content/docs/mev-share.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ API are public and need no authentication.
const eth = @import("eth");

// auth_key is your Flashbots reputation key (NOT a funded key).
var client = eth.mev_share.MevShareClient.initMainnet(allocator, auth_key);
const io = eth.runtime.blockingIo();
var client = eth.mev_share.MevShareClient.initMainnet(allocator, auth_key, io);
defer client.deinit();

// Or with explicit endpoints (e.g. Sepolia):
Expand All @@ -29,6 +30,7 @@ var sepolia = eth.mev_share.MevShareClient.init(
"https://relay-sepolia.flashbots.net",
eth.mev_share.sepolia_stream_url,
auth_key,
io,
);
defer sepolia.deinit();
```
Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/nonce-manager.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ single atomic operation -- no RPC and no lock on the hot path.
```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
2 changes: 1 addition & 1 deletion docs/content/docs/transactions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The `Wallet` handles the full lifecycle: auto-filling nonce, gas, and chain ID f
```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", eth.runtime.blockingIo());
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
36 changes: 34 additions & 2 deletions docs/content/docs/websockets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,13 @@ on sequential heads) re-fetch the replaced range.
```zig
const eth = @import("eth");

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com");
const io = eth.runtime.blockingIo();

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", io);
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

const client = try eth.ws_client.WsClient.connect(allocator, "wss://rpc.example.com/ws", .{});
const client = try eth.ws_client.WsClient.connect(allocator, "wss://rpc.example.com/ws", io, .{});
defer client.deinit();

var watcher = try eth.log_watcher.LogWatcher.init(allocator, &provider, client, .{
Expand All @@ -111,3 +113,33 @@ Notes:
- `start_block_lag` lets the first poll start N blocks behind the first observed
head (useful for warm-up).
- A callback-style wrapper `eth.log_watcher.watchLogs(...)` is also available.

## Pluggable Io

Every network entry point in eth.zig takes a `std.Io` explicitly, in line with
Zig 0.16's guidance to pass I/O handles rather than reaching for a hidden global.
This includes `HttpTransport.init`, `WsTransport.connect`, `WsClient.connect`,
`flashbots.Relay.init`, and `FallbackProvider.init`, as well as functions that
need randomness or sleep (`mnemonic.generate`, `keystore.encrypt`,
`runtime.milliTimestamp`, `runtime.sleepMs`). Wrappers like `Provider`,
`Wallet`, `RetryingProvider`, and `NonceManager` inherit the `Io` of the
transport or provider they wrap, so you only pass it once at construction.

Because the `Io` is yours to provide, you can drive eth.zig on your own event
loop -- a `std.Io.Evented` implementation, a custom green-thread scheduler, or
anything that satisfies the `std.Io` interface. For the common blocking case,
`eth.runtime.blockingIo()` returns a ready-to-use synchronous `Io`:

```zig
const io = eth.runtime.blockingIo();

var transport = eth.http_transport.HttpTransport.init(allocator, "https://rpc.example.com", io);
defer transport.deinit();

const client = try eth.ws_client.WsClient.connect(allocator, "wss://rpc.example.com/ws", io, .{});
defer client.deinit();
```

In an executable that uses the `pub fn main(init: std.process.Init) !void`
entry point, pass `init.io` instead to thread the process's real `Io` all the
way down.
2 changes: 1 addition & 1 deletion examples/01_derive_address.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const eth = @import("eth");

pub fn main() !void {
var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.blockingIo(), &buf);
const stdout = &stdout_impl.interface;

// Hardhat/Anvil account #0 private key (DO NOT use in production)
Expand Down
6 changes: 4 additions & 2 deletions examples/02_check_balance.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ const std = @import("std");
const eth = @import("eth");

pub fn main() !void {
const io = eth.runtime.blockingIo();

var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(io, &buf);
const stdout = &stdout_impl.interface;

var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Connect to local node
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545");
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545", io);
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
2 changes: 1 addition & 1 deletion examples/03_sign_message.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const eth = @import("eth");

pub fn main() !void {
var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.blockingIo(), &buf);
const stdout = &stdout_impl.interface;

// Hardhat/Anvil account #0 private key
Expand Down
6 changes: 4 additions & 2 deletions examples/04_send_transaction.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ const std = @import("std");
const eth = @import("eth");

pub fn main() !void {
const io = eth.runtime.blockingIo();

var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(io, &buf);
const stdout = &stdout_impl.interface;

var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Connect to local Anvil node
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545");
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545", io);
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
6 changes: 4 additions & 2 deletions examples/05_read_erc20.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@ const std = @import("std");
const eth = @import("eth");

pub fn main() !void {
const io = eth.runtime.blockingIo();

var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(io, &buf);
const stdout = &stdout_impl.interface;

var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();

// Connect to local node
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545");
var transport = eth.http_transport.HttpTransport.init(allocator, "http://127.0.0.1:8545", io);
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
2 changes: 1 addition & 1 deletion examples/06_hd_wallet.zig
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const eth = @import("eth");

pub fn main() !void {
var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.blockingIo(), &buf);
const stdout = &stdout_impl.interface;

// Standard test mnemonic (DO NOT use in production)
Expand Down
2 changes: 1 addition & 1 deletion examples/07_selectors.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const eth = @import("eth");

pub fn main() !void {
var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.blockingIo(), &buf);
const stdout = &stdout_impl.interface;

// Comptime: evaluated at compile time, zero runtime cost
Expand Down
7 changes: 4 additions & 3 deletions examples/08_mev_share_backrunner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -288,9 +288,10 @@ fn parseKey(s: []const u8) ![32]u8 {
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const env = init.environ_map;
const io = init.io;

var buf: [4096]u8 = undefined;
var stdout_impl = std.Io.File.stdout().writerStreaming(eth.runtime.defaultIo(), &buf);
var stdout_impl = std.Io.File.stdout().writerStreaming(io, &buf);
const stdout = &stdout_impl.interface;

// -- Read configuration ---------------------------------------------------
Expand Down Expand Up @@ -319,12 +320,12 @@ pub fn main(init: std.process.Init) !void {

// MevShareClient bundles the authenticated relay (mev_sendBundle) with
// the public SSE stream endpoint.
var client = eth.mev_share.MevShareClient.init(allocator, relay_url, stream_url, auth_key);
var client = eth.mev_share.MevShareClient.init(allocator, relay_url, stream_url, auth_key, io);
defer client.deinit();
const auth_address = try client.relay.authAddress();

// -- Live mode needs an RPC for nonce / fees / target block --------------
var transport = eth.http_transport.HttpTransport.init(allocator, rpc_url);
var transport = eth.http_transport.HttpTransport.init(allocator, rpc_url, io);
defer transport.deinit();
var provider = eth.provider.Provider.init(allocator, &transport);

Expand Down
3 changes: 2 additions & 1 deletion src/contract.zig
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const std = @import("std");
const provider_mod = @import("provider.zig");
const http_transport_mod = @import("http_transport.zig");
const runtime = @import("runtime.zig");
const wallet_mod = @import("wallet.zig");
const abi_encode = @import("abi_encode.zig");
const abi_decode = @import("abi_decode.zig");
Expand Down Expand Up @@ -110,7 +111,7 @@ pub const Contract = struct {

test "Contract.init sets fields correctly" {
const contract_addr = @as([20]u8, @splat(0xaa));
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545");
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(std.testing.allocator, &transport);
const contract = Contract.init(std.testing.allocator, contract_addr, &provider);
Expand Down
Loading
Loading