eth.zig

WebSocket Subscriptions

Real-time Ethereum event streaming with WebSocket transport in eth.zig.

eth.zig includes a WebSocket transport for real-time subscriptions to Ethereum events -- new blocks, pending transactions, and log events.

Connecting

const eth = @import("eth");

var ws = eth.ws_transport.WebSocketTransport.init(allocator, "wss://eth-mainnet.ws.alchemyapi.io/v2/YOUR_KEY");
defer ws.deinit();

The WebSocket transport supports both ws:// and wss:// (TLS) URLs. TLS is handled natively in pure Zig.

Subscriptions

Subscribe to real-time events:

const eth = @import("eth");

var ws = eth.ws_transport.WebSocketTransport.init(allocator, "wss://rpc.example.com");
defer ws.deinit();

// Subscribe to new block headers
var sub = try eth.subscription.Subscription.init(allocator, &ws, .newHeads);
defer sub.deinit();

// Process incoming events
while (try sub.next()) |event| {
    // Handle new block header
    _ = event;
}

JSON-RPC over WebSocket

You can also use WebSocket for regular JSON-RPC calls (lower latency than HTTP for frequent requests):

const eth = @import("eth");

var ws = eth.ws_transport.WebSocketTransport.init(allocator, "wss://rpc.example.com");
defer ws.deinit();
var provider = eth.provider.Provider.init(allocator, &ws);

const block_number = try provider.getBlockNumber();
const balance = try provider.getBalance(address);

When to Use WebSocket

Use CaseTransport
One-off RPC callsHTTP
High-frequency readsWebSocket
Real-time event streamingWebSocket
Subscription to new blocksWebSocket
Watching for specific eventsWebSocket

URL Format

ws://host:port/path    -- unencrypted
wss://host:port/path   -- TLS encrypted (recommended)

Default ports: 80 for ws://, 443 for wss://.

Watching Logs Per Block (LogWatcher)

The pattern "on each new block, fetch filtered logs and process them" is the core of most on-chain bots (liquidation keepers, arbitrage, searchers). log_watcher packages it: a newHeads subscription drives block-scoped eth_getLogs calls, blocks missed across reconnects are back-filled, and reorgs (parent-hash mismatch on sequential heads) re-fetch the replaced range.

const eth = @import("eth");

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", io, .{});
defer client.deinit();

var watcher = try eth.log_watcher.LogWatcher.init(allocator, &provider, client, .{
    .address = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2", // WETH
    .topics = &.{ "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" }, // Transfer
}, .{ .start_block_lag = 0, .handle_reorgs = true });
defer watcher.deinit();

while (true) {
    const logs = try watcher.pollOnce(); // blocks until the next head
    defer eth.log_watcher.freeLogs(allocator, logs);
    for (logs) |log| {
        // process log.address / log.topics / log.data
    }
}

Notes:

  • On reorgs, logs for the replaced range are re-delivered; key your processing on (block_hash, log_index) to deduplicate.
  • 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:

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.

On this page