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
87 changes: 86 additions & 1 deletion src/chain_client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,14 @@ pub const EthChainClient = struct {
kms_signer: ?*eth.signer.KmsSigner = null,
/// Owned copy of the KMS key id (the signer borrows it), freed on `destroy`.
kms_key_id: ?[]u8 = null,
/// Multi-endpoint read provider with health tracking + failover, when built
/// via `createWithFallback`; null for the single-endpoint path. When set,
/// the READ methods (`call`/`getLogs`/`simulate`) route through it; writes,
/// `callBatch`, and `callRaw` stay on the primary `provider`.
fallback: ?*eth.fallback_provider.FallbackProvider = null,
/// Owned copies of the fallback endpoint URLs (the provider borrows them),
/// freed on `destroy` after the fallback provider.
fallback_urls: ?[][]const u8 = null,

/// Allocate and wire the eth.zig objects on the heap. Returns the heap
/// pointer -- keep it and hand out `ChainClient`s via `client()`.
Expand Down Expand Up @@ -339,8 +347,70 @@ pub const EthChainClient = struct {
return self;
}

/// Like `create`, but routes the READ path through a multi-endpoint
/// `FallbackProvider` (health tracking + failover + recovery probing) over
/// `rpc_urls`, ordered by preference. Writes/`callBatch`/`callRaw` still use
/// the primary endpoint (`rpc_urls[0]`); a liquidation bot's high-frequency
/// detection reads (`eth_call`/`eth_getLogs`) get the resilience.
///
/// `opts` tunes the failover threshold and recovery-probe interval. The
/// endpoint URLs are copied and owned by the client.
pub fn createWithFallback(
allocator: std.mem.Allocator,
rpc_urls: []const []const u8,
private_key: [32]u8,
opts: eth.fallback_provider.FallbackOpts,
) !*EthChainClient {
if (rpc_urls.len == 0) return error.NoEndpoints;

const self = try allocator.create(EthChainClient);
errdefer allocator.destroy(self);

// Standalone primary (writes + batch + raw), on the preferred endpoint.
const transport = try allocator.create(eth.http_transport.HttpTransport);
errdefer allocator.destroy(transport);
transport.* = eth.http_transport.HttpTransport.init(allocator, rpc_urls[0], eth.runtime.blockingIo());
errdefer transport.deinit();

const provider = try allocator.create(eth.provider.Provider);
errdefer allocator.destroy(provider);
provider.* = eth.provider.Provider.init(allocator, transport);

const wallet = try allocator.create(eth.wallet.Wallet);
errdefer allocator.destroy(wallet);
wallet.* = eth.wallet.Wallet.initLocal(allocator, private_key, provider);
// Fallible URL/fallback allocation follows, so tear the wallet down (not
// just free its memory) on rollback, mirroring the normal destroy path.
errdefer wallet.deinit();

// Own copies of the endpoint URLs (the fallback provider borrows them).
const owned = try allocator.alloc([]const u8, rpc_urls.len);
errdefer allocator.free(owned);
var duped: usize = 0;
errdefer for (owned[0..duped]) |u| allocator.free(u);
for (rpc_urls, 0..) |u, i| {
owned[i] = try allocator.dupe(u8, u);
duped = i + 1;
}

const fallback = try allocator.create(eth.fallback_provider.FallbackProvider);
errdefer allocator.destroy(fallback);
fallback.* = try eth.fallback_provider.FallbackProvider.init(allocator, owned, eth.runtime.blockingIo(), opts);
errdefer fallback.deinit();
Comment thread
coderabbitai[bot] marked this conversation as resolved.

self.* = .{
.allocator = allocator,
.transport = transport,
.provider = provider,
.wallet = wallet,
.fallback = fallback,
.fallback_urls = owned,
};
return self;
}

/// Tear down the wallet/transport and free every heap allocation, including
/// `self` and (when signing via KMS) the KMS signer.
/// `self`, the KMS signer (if any), and the fallback provider (if any).
pub fn destroy(self: *EthChainClient) void {
self.wallet.deinit();
self.transport.deinit();
Expand All @@ -349,6 +419,15 @@ pub const EthChainClient = struct {
self.allocator.destroy(ks);
}
if (self.kms_key_id) |kid| self.allocator.free(kid);
// Free the fallback provider before its borrowed URLs.
if (self.fallback) |fb| {
fb.deinit();
self.allocator.destroy(fb);
}
if (self.fallback_urls) |urls| {
for (urls) |u| self.allocator.free(u);
self.allocator.free(urls);
}
self.allocator.destroy(self.wallet);
self.allocator.destroy(self.provider);
self.allocator.destroy(self.transport);
Expand Down Expand Up @@ -379,6 +458,7 @@ pub const EthChainClient = struct {
// the read helper, so the caller can free with `allocator`.
_ = allocator;
const self: *EthChainClient = @ptrCast(@alignCast(ptr));
if (self.fallback) |fb| return fb.call(to, data);
return self.provider.call(to, data);
}

Expand All @@ -404,6 +484,10 @@ pub const EthChainClient = struct {
// `from` and errors iff the tx would revert, so it is the from-aware
// revert preflight; the returned gas estimate is discarded.
const self: *EthChainClient = @ptrCast(@alignCast(ptr));
if (self.fallback) |fb| {
_ = try fb.estimateGas(to, data, from);
return;
}
_ = try self.provider.estimateGas(to, data, from);
}

Expand All @@ -414,6 +498,7 @@ pub const EthChainClient = struct {
// `freeLogs`.
_ = allocator;
const self: *EthChainClient = @ptrCast(@alignCast(ptr));
if (self.fallback) |fb| return fb.getLogs(filter);
return self.provider.getLogs(filter);
}

Expand Down
24 changes: 24 additions & 0 deletions src/context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,30 @@ pub const PerpCityContext = struct {
};
}

/// Like `init`, but routes the READ path through a multi-endpoint fallback
/// provider over `rpc_urls` (ordered by preference), with health tracking,
/// failover, and recovery probing. Writes stay on the primary endpoint.
/// `opts` tunes the failover threshold / recovery-probe interval.
pub fn initWithFallback(
allocator: std.mem.Allocator,
rpc_urls: []const []const u8,
private_key: [32]u8,
opts: eth.fallback_provider.FallbackOpts,
deployments: types.PerpCityDeployments,
) !Self {
const ec = try EthChainClient.createWithFallback(allocator, rpc_urls, private_key, opts);
return Self{
.allocator = allocator,
.client = ec.client(),
.eth_client = ec,
.deployments = deployments,
.approved_perps = std.AutoHashMap(types.Address, void).init(allocator),
.config_cache = std.AutoHashMap(types.Address, CacheEntry).init(allocator),
.state_cache = state_cache_mod.StateCache.init(allocator, .{}),
.rpc_url = rpc_urls[0],
};
}

/// Build a context around an already-constructed `ChainClient` (for tests
/// with an in-memory mock). The context does not own the client, so
/// `deinit` leaves it alone (`eth_client` is null).
Expand Down
32 changes: 32 additions & 0 deletions tests/contract/chain_client_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,38 @@ test "EthChainClient raw-key create/destroy is leak-clean and has no KMS signer"
try std.testing.expect(!all_zero);
}

// createWithFallback constructs without any network call (transports connect
// lazily), so this regression-guards the fallback allocation/free discipline:
// the provider + owned URL copies must free cleanly under the testing allocator.
test "createWithFallback builds a multi-endpoint read client, leak-clean" {
const alloc = std.testing.allocator;
const private_key = [_]u8{0x22} ** 32;
const urls = [_][]const u8{ "http://primary:8545", "http://backup:8545" };

const ec = try EthChainClient.createWithFallback(alloc, &urls, private_key, .{});
defer ec.destroy();

try std.testing.expect(ec.fallback != null);
try std.testing.expect(ec.fallback_urls != null);
try std.testing.expectEqual(@as(usize, 2), ec.fallback_urls.?.len);
// The URLs are owned copies, not the caller's slices.
try std.testing.expect(ec.fallback_urls.?[0].ptr != urls[0].ptr);
try std.testing.expectEqualStrings("http://primary:8545", ec.fallback_urls.?[0]);
}

test "createWithFallback accepts a single endpoint and rejects an empty list" {
const alloc = std.testing.allocator;
const private_key = [_]u8{0x33} ** 32;

const one = [_][]const u8{"http://only:8545"};
const ec = try EthChainClient.createWithFallback(alloc, &one, private_key, .{});
defer ec.destroy();
try std.testing.expectEqual(@as(usize, 1), ec.fallback_urls.?.len);

const empty = [_][]const u8{};
try std.testing.expectError(error.NoEndpoints, EthChainClient.createWithFallback(alloc, &empty, private_key, .{}));
}

// The KMS constructors' exact signatures are asserted at compile time, so a
// change to parameter order/types or the return payload breaks the build. They
// are not invoked: KmsSigner.init calls kms:GetPublicKey (a network call needing
Expand Down
Loading