Skip to content

Commit 6d3e403

Browse files
committed
feat: implement HTTP(S) JSON-RPC transport [skip release]
HttpTransport.send now drives std.http.Client (over a std.Io.Threaded loop) instead of returning error.NotImplemented, so the provider makes real network calls and https:// endpoints work via the client's TLS. Fix latent 0.16-migration bugs the stubbed transport hid: var-bind the *ToJson wrappers so their *self deinit type-checks, pass the allocator to ArrayList/ObjectMap deinit, and make JsonObjectWrapper.deinit recurse so a topics filter array doesn't leak its hex strings. Verified live vs Arbitrum Sepolia (chainId/blockNumber/getLogs, no leaks). Tests 315/316 (1 skip) incl. a new topics-filter leak guard.
1 parent 5ce7e20 commit 6d3e403

6 files changed

Lines changed: 141 additions & 22 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,35 @@ All notable changes to the zigeth library will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.6.0] - 2026-07-07
9+
10+
### ✨ Working HTTP(S) JSON-RPC transport
11+
12+
`HttpTransport.send` is now implemented on top of Zig 0.16's
13+
`std.http.Client` (driven by a `std.Io.Threaded` loop), replacing the
14+
`error.NotImplemented` stub. TLS is handled by the client, so `https://`
15+
RPC endpoints work out of the box. The whole provider surface
16+
(`provider.getEth().chainId()` / `blockNumber()` / `getLogs(filter)` …)
17+
now performs real network calls.
18+
19+
Verified live against Arbitrum Sepolia: `eth_chainId`, `eth_blockNumber`,
20+
and `eth_getLogs` all round-trip over HTTPS with no leaks.
21+
22+
### 🐛 Fixes surfaced by exercising the RPC path
23+
24+
These were latent since the 0.15 → 0.16 migration — the code paths only
25+
compiled once a real transport forced their analysis:
26+
27+
- `eth.zig` — `filterOptionsToJson` / `callParamsToJson` /
28+
`transactionParamsToJson` results are bound with `var` so their
29+
`deinit` (which takes `*self`) type-checks.
30+
- `eth.zig` `getLogs` — the errdefer `logs.deinit()` now passes the
31+
allocator (`std.ArrayList` is unmanaged in 0.16).
32+
- `free_json.zig` — `ObjectMap.deinit` now passes the allocator.
33+
- `JsonObjectWrapper.deinit` — recursively frees value data, fixing a
34+
leak of the hex strings inside a `topics` filter array (regression
35+
test added).
36+
837
## [0.5.1] - 2026-07-07
938

1039
### 🔧 CI + cross-platform

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.5.1
1+
0.6.0

‎build.zig.zon‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
.name = .zigeth,
33

44
// Semantic version
5-
.version = "0.5.1",
5+
.version = "0.6.0",
66

77
// Unique package identifier
88
// DO NOT change this unless forking the project

‎src/rpc/client.zig‎

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -100,28 +100,51 @@ pub const RpcClient = struct {
100100
}
101101
};
102102

103-
/// HTTP transport for RPC client.
103+
/// Errors surfaced by the HTTP transport.
104+
pub const TransportError = error{
105+
/// The underlying std.http.Client.fetch call failed (DNS, TCP, TLS,
106+
/// timeout, etc.).
107+
HttpRequestFailed,
108+
/// The server responded with a non-2xx status.
109+
HttpStatusError,
110+
} || std.mem.Allocator.Error;
111+
112+
/// HTTP(S) transport for the JSON-RPC client.
104113
///
105-
/// Zig 0.16 rewrote std.http.Client around the new Io interface and
106-
/// dropped the response_writer field this used to plug into. Until the
107-
/// transport is ported to the new API, this is a compile-time stub —
108-
/// send() returns error.NotImplemented so consumers surface the gap
109-
/// rather than silently succeed.
114+
/// Built on `std.http.Client` (which performs TLS for `https://`
115+
/// endpoints) driven by a `std.Io.Threaded` event loop. Each transport
116+
/// owns its own client + loop; `RpcClient.call` currently constructs one
117+
/// per request, so the connection is not pooled across calls — fine for a
118+
/// low-frequency polling consumer. A future optimization is to hoist the
119+
/// client into `RpcClient` and reuse it (keep-alive) across calls.
110120
pub const HttpTransport = struct {
111121
allocator: std.mem.Allocator,
112122
url: []const u8,
113123
headers: std.StringHashMap([]const u8),
124+
threaded: *std.Io.Threaded,
125+
client: std.http.Client,
114126

115127
pub fn init(allocator: std.mem.Allocator, url: []const u8) !HttpTransport {
116128
const url_copy = try allocator.dupe(u8, url);
129+
errdefer allocator.free(url_copy);
130+
131+
const threaded = try allocator.create(std.Io.Threaded);
132+
errdefer allocator.destroy(threaded);
133+
threaded.* = std.Io.Threaded.init(allocator, .{});
134+
117135
return .{
118136
.allocator = allocator,
119137
.url = url_copy,
120138
.headers = std.StringHashMap([]const u8).init(allocator),
139+
.threaded = threaded,
140+
.client = .{ .allocator = allocator, .io = threaded.io() },
121141
};
122142
}
123143

124144
pub fn deinit(self: *HttpTransport) void {
145+
self.client.deinit();
146+
self.threaded.deinit();
147+
self.allocator.destroy(self.threaded);
125148
self.allocator.free(self.url);
126149

127150
var it = self.headers.iterator();
@@ -138,10 +161,34 @@ pub const HttpTransport = struct {
138161
try self.headers.put(key_copy, value_copy);
139162
}
140163

164+
/// POST `request` to the endpoint and return the response body. Caller
165+
/// owns the returned slice.
141166
pub fn send(self: *HttpTransport, request: []const u8) ![]u8 {
142-
_ = self;
143-
_ = request;
144-
return error.NotImplemented;
167+
// Build the extra-headers slice from the configured headers.
168+
var extra = try self.allocator.alloc(std.http.Header, self.headers.count());
169+
defer self.allocator.free(extra);
170+
var it = self.headers.iterator();
171+
var i: usize = 0;
172+
while (it.next()) |entry| : (i += 1) {
173+
extra[i] = .{ .name = entry.key_ptr.*, .value = entry.value_ptr.* };
174+
}
175+
176+
var resp = std.Io.Writer.Allocating.init(self.allocator);
177+
defer resp.deinit();
178+
179+
const result = self.client.fetch(.{
180+
.location = .{ .url = self.url },
181+
.method = .POST,
182+
.payload = request,
183+
.response_writer = &resp.writer,
184+
.extra_headers = extra,
185+
}) catch return error.HttpRequestFailed;
186+
187+
// Accept any 2xx.
188+
const code = @intFromEnum(result.status);
189+
if (code < 200 or code >= 300) return error.HttpStatusError;
190+
191+
return try self.allocator.dupe(u8, resp.written());
145192
}
146193
};
147194

‎src/rpc/eth.zig‎

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ pub const EthNamespace = struct {
169169

170170
/// eth_call - Executes a message call (doesn't create a transaction)
171171
pub fn call(self: EthNamespace, params: types.CallParams, block: types.BlockParameter) ![]u8 {
172-
const call_obj = try callParamsToJson(self.client.allocator, params);
172+
var call_obj = try callParamsToJson(self.client.allocator, params);
173173
defer call_obj.deinit();
174174

175175
const block_param = try blockParameterToString(self.client.allocator, block);
@@ -193,7 +193,7 @@ pub const EthNamespace = struct {
193193

194194
/// eth_estimateGas - Estimates gas needed for a transaction
195195
pub fn estimateGas(self: EthNamespace, params: types.CallParams) !u64 {
196-
const call_obj = try callParamsToJson(self.client.allocator, params);
196+
var call_obj = try callParamsToJson(self.client.allocator, params);
197197
defer call_obj.deinit();
198198

199199
var rpc_params = [_]std.json.Value{
@@ -318,7 +318,7 @@ pub const EthNamespace = struct {
318318

319319
/// eth_getLogs - Returns an array of logs matching the filter
320320
pub fn getLogs(self: EthNamespace, filter: types.FilterOptions) ![]Log {
321-
const filter_obj = try filterOptionsToJson(self.client.allocator, filter);
321+
var filter_obj = try filterOptionsToJson(self.client.allocator, filter);
322322
defer filter_obj.deinit();
323323

324324
var params = [_]std.json.Value{
@@ -337,7 +337,7 @@ pub const EthNamespace = struct {
337337
for (logs.items) |log| {
338338
log.deinit();
339339
}
340-
logs.deinit();
340+
logs.deinit(self.client.allocator);
341341
}
342342

343343
for (result.array.items) |log_json| {
@@ -373,7 +373,7 @@ pub const EthNamespace = struct {
373373

374374
/// eth_sendTransaction - Creates and sends a transaction
375375
pub fn sendTransaction(self: EthNamespace, params: types.TransactionParams) !Hash {
376-
const tx_obj = try transactionParamsToJson(self.client.allocator, params);
376+
var tx_obj = try transactionParamsToJson(self.client.allocator, params);
377377
defer tx_obj.deinit();
378378

379379
var rpc_params = [_]std.json.Value{
@@ -537,7 +537,7 @@ pub const EthNamespace = struct {
537537

538538
/// eth_signTransaction - Signs a transaction
539539
pub fn signTransaction(self: EthNamespace, params: types.TransactionParams) ![]u8 {
540-
const tx_obj = try transactionParamsToJson(self.client.allocator, params);
540+
var tx_obj = try transactionParamsToJson(self.client.allocator, params);
541541
defer tx_obj.deinit();
542542

543543
var rpc_params = [_]std.json.Value{
@@ -585,16 +585,36 @@ const JsonObjectWrapper = struct {
585585

586586
fn deinit(self: *JsonObjectWrapper) void {
587587
if (self.value == .object) {
588-
// Free all string values that were allocated
588+
// Object keys here are string literals (never allocated); the
589+
// values are heap-allocated (hex strings, or arrays of them, as
590+
// in a `topics` filter). Recursively free the values so nested
591+
// arrays don't leak, then drop the map itself.
589592
var iter = self.value.object.iterator();
590593
while (iter.next()) |entry| {
591-
if (entry.value_ptr.* == .string) {
592-
self.allocator.free(entry.value_ptr.string);
593-
}
594+
freeValue(self.allocator, entry.value_ptr.*);
594595
}
595596
self.value.object.deinit(self.allocator);
596597
}
597598
}
599+
600+
/// Recursively free a JSON value's allocated data. Object keys are left
601+
/// untouched — this wrapper only ever builds objects with literal keys.
602+
fn freeValue(allocator: std.mem.Allocator, value: std.json.Value) void {
603+
switch (value) {
604+
.string => |s| allocator.free(s),
605+
.number_string => |ns| allocator.free(ns),
606+
.array => |arr| {
607+
for (arr.items) |item| freeValue(allocator, item);
608+
@constCast(&arr).deinit();
609+
},
610+
.object => |obj| {
611+
var it = obj.iterator();
612+
while (it.next()) |e| freeValue(allocator, e.value_ptr.*);
613+
@constCast(&obj).deinit(allocator);
614+
},
615+
else => {},
616+
}
617+
}
598618
};
599619

600620
/// Convert CallParams to JSON object
@@ -1334,3 +1354,26 @@ test "call params to json" {
13341354
try std.testing.expect(json_obj.value.object.contains("to"));
13351355
try std.testing.expect(json_obj.value.object.contains("gas"));
13361356
}
1357+
1358+
test "filter options with topics to json frees cleanly" {
1359+
// Guards the JsonObjectWrapper.deinit leak fix: the topics array's hex
1360+
// strings must be freed, not just top-level string members. Runs under
1361+
// testing.allocator, which fails the test on any leak.
1362+
const allocator = std.testing.allocator;
1363+
1364+
const topic = Hash.fromBytes([_]u8{0xAB} ** 32);
1365+
var topics = [_]?Hash{topic};
1366+
const filter = types.FilterOptions{
1367+
.from_block = .{ .number = 100 },
1368+
.to_block = .{ .number = 200 },
1369+
.address = Address.fromBytes([_]u8{0xCD} ** 20),
1370+
.topics = topics[0..],
1371+
};
1372+
1373+
var obj = try filterOptionsToJson(allocator, filter);
1374+
defer obj.deinit();
1375+
1376+
try std.testing.expect(obj.value == .object);
1377+
try std.testing.expect(obj.value.object.contains("topics"));
1378+
try std.testing.expect(obj.value.object.contains("address"));
1379+
}

‎src/rpc/free_json.zig‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub fn freeJsonValue(allocator: std.mem.Allocator, value: std.json.Value) void {
1919
allocator.free(entry.key_ptr.*);
2020
freeJsonValue(allocator, entry.value_ptr.*);
2121
}
22-
@constCast(&obj).deinit();
22+
@constCast(&obj).deinit(allocator);
2323
},
2424
}
2525
}

0 commit comments

Comments
 (0)