From e4b1d94013134dab78542ef8490bc26f7ef60922 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Tue, 14 Jul 2026 18:03:48 -0400 Subject: [PATCH 1/2] feat: multi-endpoint fallback provider for the read path The live path was a single HTTP endpoint with no failover, so one degraded RPC could stall a liquidator's detection reads. This routes the READ path through eth.zig's FallbackProvider (per-endpoint health tracking, failover after a threshold of consecutive failures, and periodic recovery probing). - EthChainClient.createWithFallback(rpc_urls, private_key, opts): builds a standalone primary (writes + callBatch + callRaw stay here) plus a heap-owned FallbackProvider over owned copies of the endpoint URLs. The read methods (call / getLogs / simulate) route through the fallback when present; the single-endpoint create() path is unchanged (fallback null). - PerpCityContext.initWithFallback(rpc_urls, private_key, opts, deployments). - destroy() tears down the fallback provider before its borrowed URL copies. Construction is network-free (transports connect lazily), so the tests regression-guard the allocation/free discipline: multi- and single-endpoint build leak-clean under the testing allocator, URLs are owned copies, and an empty list errors NoEndpoints. Failover behavior itself is integration. zig build test 499, contract-test 98 (Debug + ReleaseFast). --- src/chain_client.zig | 84 +++++++++++++++++++++++++++- src/context.zig | 24 ++++++++ tests/contract/chain_client_test.zig | 32 +++++++++++ 3 files changed, 139 insertions(+), 1 deletion(-) diff --git a/src/chain_client.zig b/src/chain_client.zig index 4711484..cd7b4d6 100644 --- a/src/chain_client.zig +++ b/src/chain_client.zig @@ -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()`. @@ -339,8 +347,67 @@ 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); + + // 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(); + + 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(); @@ -349,6 +416,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); @@ -379,6 +455,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); } @@ -404,6 +481,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); } @@ -414,6 +495,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); } diff --git a/src/context.zig b/src/context.zig index a3bb12b..1b37ede 100644 --- a/src/context.zig +++ b/src/context.zig @@ -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). diff --git a/tests/contract/chain_client_test.zig b/tests/contract/chain_client_test.zig index ab43f2c..0831c8f 100644 --- a/tests/contract/chain_client_test.zig +++ b/tests/contract/chain_client_test.zig @@ -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 From abba16a753e434a5944d0706d3c9f41d4be83b2a Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Tue, 14 Jul 2026 18:09:29 -0400 Subject: [PATCH 2/2] chain_client: deinit wallet on createWithFallback rollback CodeRabbit (Minor): in createWithFallback the errdefer only freed the wallet's memory, skipping wallet.deinit() that the normal destroy() runs. If the URL/fallback allocation after wallet init fails, the wallet's resources would leak. Add errdefer wallet.deinit() so rollback mirrors destroy(). (create/createWithKms have no fallible op after wallet init.) --- src/chain_client.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/chain_client.zig b/src/chain_client.zig index cd7b4d6..01306b0 100644 --- a/src/chain_client.zig +++ b/src/chain_client.zig @@ -379,6 +379,9 @@ pub const EthChainClient = struct { 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);