From 1e043b4e534388be014dab2051669ad4f1cf9274 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Tue, 14 Jul 2026 17:53:27 -0400 Subject: [PATCH 1/2] feat: KMS-backed signer for the write path (createWithKms / initWithKms) The SDK could only sign from a raw 32-byte private key, so a production liquidator had to hold key material in process memory. This adds a KMS signing path (eth.zig KmsSigner) so the money path signs with the key staying in AWS KMS. - EthChainClient.createWithKms(rpc_url, region, key_id): builds the wallet via eth.signer.Signer.fromKms over a heap-owned, stable KmsSigner; owns a copy of key_id (the signer borrows it) and derives+caches the wallet address from KMS at construction. destroy() deinits and frees the signer. - PerpCityContext.initWithKms(rpc_url, region, key_id, deployments): the KMS counterpart of init(). The raw-key init() is unchanged. The KMS key must be ECC_SECG_P256K1; credentials resolve from the env / container role at call time. Tests: create/destroy on the raw-key path is network-free (address derives locally), so it regression-guards the destroy() change (kms_signer == null frees clean under the testing allocator); the KMS constructors are referenced at compile time. The KMS signing path itself hits kms:GetPublicKey and is exercised by integration, not CI. zig build test 497, contract-test 96 (Debug + ReleaseFast). --- src/chain_client.zig | 62 +++++++++++++++++++++++++++- src/context.zig | 25 +++++++++++ tests/contract/chain_client_test.zig | 42 +++++++++++++++++++ tests/contract_tests.zig | 1 + 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 tests/contract/chain_client_test.zig diff --git a/src/chain_client.zig b/src/chain_client.zig index 57eb6b2..4711484 100644 --- a/src/chain_client.zig +++ b/src/chain_client.zig @@ -251,6 +251,12 @@ pub const EthChainClient = struct { transport: *eth.http_transport.HttpTransport, provider: *eth.provider.Provider, wallet: *eth.wallet.Wallet, + /// The heap-owned KMS signer when this client signs via AWS KMS (see + /// `createWithKms`); null for the raw-key path. Kept stable because the + /// wallet's `Signer` holds a borrowed pointer to it. + 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, /// Allocate and wire the eth.zig objects on the heap. Returns the heap /// pointer -- keep it and hand out `ChainClient`s via `client()`. @@ -284,11 +290,65 @@ pub const EthChainClient = struct { return self; } + /// Like `create`, but signs via AWS KMS: the private key never leaves KMS. + /// `region` is e.g. "us-west-2" and `key_id` is a KMS key id, ARN, or + /// `alias/...` (must be an `ECC_SECG_P256K1` key). Credentials are resolved + /// from the environment / container role at call time. The signer derives + /// and caches the wallet address from KMS during construction (one + /// `kms:GetPublicKey`), so this makes a network call. + pub fn createWithKms( + allocator: std.mem.Allocator, + rpc_url: []const u8, + region: []const u8, + key_id: []const u8, + ) !*EthChainClient { + const self = try allocator.create(EthChainClient); + errdefer allocator.destroy(self); + + const transport = try allocator.create(eth.http_transport.HttpTransport); + errdefer allocator.destroy(transport); + transport.* = eth.http_transport.HttpTransport.init(allocator, rpc_url, 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); + + // The signer borrows key_id for its lifetime, so own a copy here. + const key_id_owned = try allocator.dupe(u8, key_id); + errdefer allocator.free(key_id_owned); + + // Heap-owned + stable: the wallet's Signer holds a borrowed pointer. + const kms_signer = try allocator.create(eth.signer.KmsSigner); + errdefer allocator.destroy(kms_signer); + kms_signer.* = try eth.signer.KmsSigner.init(allocator, eth.runtime.blockingIo(), region, key_id_owned); + errdefer kms_signer.deinit(); + + const wallet = try allocator.create(eth.wallet.Wallet); + errdefer allocator.destroy(wallet); + wallet.* = eth.wallet.Wallet.init(allocator, eth.signer.Signer.fromKms(kms_signer), provider); + + self.* = .{ + .allocator = allocator, + .transport = transport, + .provider = provider, + .wallet = wallet, + .kms_signer = kms_signer, + .kms_key_id = key_id_owned, + }; + return self; + } + /// Tear down the wallet/transport and free every heap allocation, including - /// `self`. + /// `self` and (when signing via KMS) the KMS signer. pub fn destroy(self: *EthChainClient) void { self.wallet.deinit(); self.transport.deinit(); + if (self.kms_signer) |ks| { + ks.deinit(); + self.allocator.destroy(ks); + } + if (self.kms_key_id) |kid| self.allocator.free(kid); self.allocator.destroy(self.wallet); self.allocator.destroy(self.provider); self.allocator.destroy(self.transport); diff --git a/src/context.zig b/src/context.zig index 77819b2..a3bb12b 100644 --- a/src/context.zig +++ b/src/context.zig @@ -83,6 +83,31 @@ pub const PerpCityContext = struct { }; } + /// Like `init`, but signs the write path via AWS KMS -- the private key + /// never leaves KMS. `region` is e.g. "us-west-2"; `key_id` is a KMS key id, + /// ARN, or `alias/...` (an `ECC_SECG_P256K1` key). Credentials resolve from + /// the environment / container role. Derives the wallet address from KMS at + /// construction, so this makes a network call. + pub fn initWithKms( + allocator: std.mem.Allocator, + rpc_url: []const u8, + region: []const u8, + key_id: []const u8, + deployments: types.PerpCityDeployments, + ) !Self { + const ec = try EthChainClient.createWithKms(allocator, rpc_url, region, key_id); + 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_url, + }; + } + /// 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 new file mode 100644 index 0000000..453aac5 --- /dev/null +++ b/tests/contract/chain_client_test.zig @@ -0,0 +1,42 @@ +const std = @import("std"); +const sdk = @import("perpcity_sdk"); + +const EthChainClient = sdk.chain_client.EthChainClient; + +// The raw-key path constructs and tears down without a network call (the +// address derives locally from the key), so this regression-guards the +// `destroy` changes made for the KMS variant: the `kms_signer == null` branch +// must free cleanly under the testing allocator. +test "EthChainClient raw-key create/destroy is leak-clean and has no KMS signer" { + const alloc = std.testing.allocator; + const private_key = [_]u8{0x11} ** 32; + + const ec = try EthChainClient.create(alloc, "http://localhost:8545", private_key); + defer ec.destroy(); + + try std.testing.expect(ec.kms_signer == null); + try std.testing.expect(ec.kms_key_id == null); + + // Address derivation is local (secp256k1), so this needs no node. + var cc = ec.client(); + const addr = try cc.address(); + var all_zero = true; + for (addr) |b| { + if (b != 0) { + all_zero = false; + break; + } + } + try std.testing.expect(!all_zero); +} + +// The KMS constructors are referenced here so a signature change breaks the +// build. They are not invoked: KmsSigner.init calls kms:GetPublicKey (a network +// call needing AWS credentials), so the KMS signing path is exercised only by +// integration tests, not CI. +test "KMS constructors are wired (compile-time reference only)" { + const ctx_kms = @TypeOf(sdk.context.PerpCityContext.initWithKms); + const ec_kms = @TypeOf(EthChainClient.createWithKms); + try std.testing.expect(@typeInfo(ctx_kms) == .@"fn"); + try std.testing.expect(@typeInfo(ec_kms) == .@"fn"); +} diff --git a/tests/contract_tests.zig b/tests/contract_tests.zig index 4491d37..e0ca74a 100644 --- a/tests/contract_tests.zig +++ b/tests/contract_tests.zig @@ -13,4 +13,5 @@ comptime { _ = @import("contract/revert_test.zig"); _ = @import("contract/context_simulate_override_test.zig"); _ = @import("contract/context_multicall_test.zig"); + _ = @import("contract/chain_client_test.zig"); } From 92965679a2da13be44c90f3310d0b93100067610 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Tue, 14 Jul 2026 17:58:16 -0400 Subject: [PATCH 2/2] test: assert exact KMS constructor signatures CodeRabbit (Minor): the @typeInfo == .fn check passed for any signature. Assert the exact parameter types and return payload of createWithKms / initWithKms via @typeInfo, so a change to parameter order/types or the return type breaks the build. Still not invoked (KMS init is network). --- tests/contract/chain_client_test.zig | 30 +++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/tests/contract/chain_client_test.zig b/tests/contract/chain_client_test.zig index 453aac5..ab43f2c 100644 --- a/tests/contract/chain_client_test.zig +++ b/tests/contract/chain_client_test.zig @@ -30,13 +30,25 @@ test "EthChainClient raw-key create/destroy is leak-clean and has no KMS signer" try std.testing.expect(!all_zero); } -// The KMS constructors are referenced here so a signature change breaks the -// build. They are not invoked: KmsSigner.init calls kms:GetPublicKey (a network -// call needing AWS credentials), so the KMS signing path is exercised only by -// integration tests, not CI. -test "KMS constructors are wired (compile-time reference only)" { - const ctx_kms = @TypeOf(sdk.context.PerpCityContext.initWithKms); - const ec_kms = @TypeOf(EthChainClient.createWithKms); - try std.testing.expect(@typeInfo(ctx_kms) == .@"fn"); - try std.testing.expect(@typeInfo(ec_kms) == .@"fn"); +// 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 +// AWS credentials), so the KMS signing path is exercised by integration, not CI. +test "KMS constructors have the intended signatures (compile-time)" { + const create_info = @typeInfo(@TypeOf(EthChainClient.createWithKms)).@"fn"; + try std.testing.expectEqual(@as(usize, 4), create_info.params.len); + try std.testing.expect(create_info.params[0].type.? == std.mem.Allocator); + try std.testing.expect(create_info.params[1].type.? == []const u8); // rpc_url + try std.testing.expect(create_info.params[2].type.? == []const u8); // region + try std.testing.expect(create_info.params[3].type.? == []const u8); // key_id + try std.testing.expect(@typeInfo(create_info.return_type.?).error_union.payload == *EthChainClient); + + const init_info = @typeInfo(@TypeOf(sdk.context.PerpCityContext.initWithKms)).@"fn"; + try std.testing.expectEqual(@as(usize, 5), init_info.params.len); + try std.testing.expect(init_info.params[0].type.? == std.mem.Allocator); + try std.testing.expect(init_info.params[1].type.? == []const u8); // rpc_url + try std.testing.expect(init_info.params[2].type.? == []const u8); // region + try std.testing.expect(init_info.params[3].type.? == []const u8); // key_id + try std.testing.expect(init_info.params[4].type.? == sdk.types.PerpCityDeployments); + try std.testing.expect(@typeInfo(init_info.return_type.?).error_union.payload == sdk.context.PerpCityContext); }