diff --git a/src/abigen.zig b/src/abigen.zig
index 833bfb0..58b468e 100644
--- a/src/abigen.zig
+++ b/src/abigen.zig
@@ -1365,7 +1365,7 @@ test "send typechecks against a Wallet built over a (dummy) provider" {
var transport = http_transport_mod.HttpTransport.init(testing.allocator, "http://127.0.0.1:1", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(testing.allocator, &transport);
- var wallet = wallet_mod.Wallet.init(testing.allocator, private_key, &provider);
+ var wallet = wallet_mod.Wallet.initLocal(testing.allocator, private_key, &provider);
defer wallet.deinit();
const token = Erc20.at(@splat(0xAB));
diff --git a/src/flashbots.zig b/src/flashbots.zig
index 3ab5544..793c4ba 100644
--- a/src/flashbots.zig
+++ b/src/flashbots.zig
@@ -193,7 +193,7 @@ pub const FlashbotsError = error{
/// where id(body) = keccak256(toUtf8Bytes(body)) as hex string.
///
/// Returns allocator-owned string: "0x
:0x"
-fn computeAuthHeader(allocator: std.mem.Allocator, auth_signer: signer_mod.Signer, body: []const u8) ![]u8 {
+fn computeAuthHeader(allocator: std.mem.Allocator, auth_signer: signer_mod.LocalSigner, body: []const u8) ![]u8 {
// 1. Hash the request body: keccak256(body) -> 32 bytes
const body_hash = keccak.hash(body);
@@ -203,7 +203,7 @@ fn computeAuthHeader(allocator: std.mem.Allocator, auth_signer: signer_mod.Signe
// 3. EIP-191 prefix hash of the hex string (as 66-byte UTF-8 message)
// = keccak256("\x19Ethereum Signed Message:\n66" + body_hash_hex)
- const prefixed_hash = signer_mod.Signer.hashPersonalMessage(&body_hash_hex);
+ const prefixed_hash = signer_mod.LocalSigner.hashPersonalMessage(&body_hash_hex);
// 4. Sign the prefixed hash
const sig = auth_signer.signHash(prefixed_hash) catch return error.SigningFailed;
@@ -236,7 +236,7 @@ fn computeAuthHeader(allocator: std.mem.Allocator, auth_signer: signer_mod.Signe
pub const Relay = struct {
allocator: std.mem.Allocator,
url: []const u8,
- auth_signer: signer_mod.Signer,
+ auth_signer: signer_mod.LocalSigner,
client: std.http.Client,
next_id: u64,
@@ -247,13 +247,14 @@ pub const Relay = struct {
return .{
.allocator = allocator,
.url = url,
- .auth_signer = signer_mod.Signer.init(auth_key),
+ .auth_signer = signer_mod.LocalSigner.init(auth_key),
.client = .{ .allocator = allocator, .io = io },
.next_id = 1,
};
}
pub fn deinit(self: *Relay) void {
+ self.auth_signer.deinit();
self.client.deinit();
}
@@ -884,7 +885,7 @@ test "computeAuthHeader format and recovery" {
const private_key = try hex_mod.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex_mod.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const auth_signer = signer_mod.Signer.init(private_key);
+ const auth_signer = signer_mod.LocalSigner.init(private_key);
const body = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_sendBundle\",\"params\":[{}],\"id\":1}";
const header = try computeAuthHeader(std.testing.allocator, auth_signer, body);
@@ -907,7 +908,7 @@ test "computeAuthHeader format and recovery" {
// Reconstruct the hash that was signed
const body_hash = keccak.hash(body);
const body_hash_hex = hex_mod.bytesToHexBuf(32, &body_hash);
- const prefixed_hash = signer_mod.Signer.hashPersonalMessage(&body_hash_hex);
+ const prefixed_hash = signer_mod.LocalSigner.hashPersonalMessage(&body_hash_hex);
const recovered = try secp256k1.recoverAddress(sig, prefixed_hash);
try std.testing.expectEqualSlices(u8, &expected_address, &recovered);
diff --git a/src/kms.zig b/src/kms.zig
new file mode 100644
index 0000000..4674e70
--- /dev/null
+++ b/src/kms.zig
@@ -0,0 +1,543 @@
+//! Minimal AWS KMS client for Ethereum signing with `ECC_SECG_P256K1` keys.
+//!
+//! Provides just the two KMS operations an Ethereum signer needs - `Sign` and
+//! `GetPublicKey` - plus the SigV4 request signing and credential resolution
+//! they require. The private key never leaves KMS: this client only asks KMS to
+//! sign a 32-byte digest (returning a DER ECDSA signature) and to return the
+//! public key (for address derivation).
+//!
+//! Credentials are resolved per call from the standard sources: static
+//! environment variables, then the ECS/Fargate container-credentials endpoint.
+//! SigV4 uses HMAC-SHA256 (baseline-CPU safe - no wide-integer math), so this
+//! builds and runs on Fargate with `-Dcpu=baseline`.
+
+const std = @import("std");
+
+const Sha256 = std.crypto.hash.sha2.Sha256;
+const HmacSha256 = std.crypto.auth.hmac.sha2.HmacSha256;
+
+pub const KmsError = error{
+ CredentialsUnavailable,
+ RequestFailed,
+ Unauthorized,
+ ResponseInvalid,
+} || std.mem.Allocator.Error;
+
+/// Resolved AWS credentials. All fields are allocator-owned.
+pub const Credentials = struct {
+ access_key_id: []const u8,
+ secret_access_key: []const u8,
+ session_token: ?[]const u8,
+
+ pub fn deinit(self: *Credentials, allocator: std.mem.Allocator) void {
+ allocator.free(self.access_key_id);
+ allocator.free(self.secret_access_key);
+ if (self.session_token) |t| allocator.free(t);
+ self.* = undefined;
+ }
+};
+
+/// AWS KMS client. Holds an HTTP client bound to the given `io`; the caller owns
+/// it and must keep it alive for the client's lifetime.
+pub const Client = struct {
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ /// AWS region, e.g. "us-west-2". Borrowed; caller keeps it alive.
+ region: []const u8,
+ http: std.http.Client,
+
+ pub fn init(allocator: std.mem.Allocator, io: std.Io, region: []const u8) Client {
+ return .{
+ .allocator = allocator,
+ .io = io,
+ .region = region,
+ .http = .{ .allocator = allocator, .io = io },
+ };
+ }
+
+ pub fn deinit(self: *Client) void {
+ self.http.deinit();
+ }
+
+ /// KMS `Sign` over a 32-byte digest with `ECDSA_SHA_256`. Returns the raw
+ /// `(r, s)` as 64 big-endian bytes (r||s), decoded from KMS's DER signature.
+ /// `s` is NOT normalized here - the caller applies EIP-2 low-s.
+ pub fn sign(self: *Client, key_id: []const u8, digest: [32]u8) KmsError![64]u8 {
+ var msg_b64_buf: [64]u8 = undefined;
+ const msg_b64 = std.base64.standard.Encoder.encode(&msg_b64_buf, &digest);
+
+ const body = try std.fmt.allocPrint(
+ self.allocator,
+ "{{\"KeyId\":\"{s}\",\"Message\":\"{s}\",\"MessageType\":\"DIGEST\",\"SigningAlgorithm\":\"ECDSA_SHA_256\"}}",
+ .{ key_id, msg_b64 },
+ );
+ defer self.allocator.free(body);
+
+ const resp = try self.call("TrentService.Sign", body);
+ defer self.allocator.free(resp);
+
+ const der = try decodeBase64Field(self.allocator, resp, "Signature");
+ defer self.allocator.free(der);
+
+ return decodeDerSignature(der);
+ }
+
+ /// KMS `GetPublicKey`. Returns the 65-byte uncompressed secp256k1 public key
+ /// (`0x04 || X || Y`) extracted from the DER SubjectPublicKeyInfo.
+ pub fn getPublicKey(self: *Client, key_id: []const u8) KmsError![65]u8 {
+ const body = try std.fmt.allocPrint(self.allocator, "{{\"KeyId\":\"{s}\"}}", .{key_id});
+ defer self.allocator.free(body);
+
+ const resp = try self.call("TrentService.GetPublicKey", body);
+ defer self.allocator.free(resp);
+
+ const spki = try decodeBase64Field(self.allocator, resp, "PublicKey");
+ defer self.allocator.free(spki);
+
+ return extractUncompressedPubkey(spki);
+ }
+
+ /// Perform a SigV4-signed KMS POST and return the response body (owned).
+ fn call(self: *Client, target: []const u8, body: []const u8) KmsError![]u8 {
+ var creds = try resolveCredentials(self.allocator, self.io);
+ defer creds.deinit(self.allocator);
+
+ const host = try std.fmt.allocPrint(self.allocator, "kms.{s}.amazonaws.com", .{self.region});
+ defer self.allocator.free(host);
+ const url = try std.fmt.allocPrint(self.allocator, "https://{s}/", .{host});
+ defer self.allocator.free(url);
+
+ const ts = try nowTimestamps(self.io);
+ const authorization = try signRequestV4(self.allocator, .{
+ .region = self.region,
+ .host = host,
+ .target = target,
+ .body = body,
+ .creds = creds,
+ .amz_date = ts.amz_date(),
+ .date_stamp = ts.date_stamp(),
+ });
+ defer self.allocator.free(authorization);
+
+ var response_body: std.Io.Writer.Allocating = .init(self.allocator);
+ errdefer response_body.deinit();
+
+ var header_buf: [5]std.http.Header = undefined;
+ var n: usize = 0;
+ header_buf[n] = .{ .name = "Content-Type", .value = "application/x-amz-json-1.1" };
+ n += 1;
+ header_buf[n] = .{ .name = "X-Amz-Target", .value = target };
+ n += 1;
+ header_buf[n] = .{ .name = "X-Amz-Date", .value = ts.amz_date() };
+ n += 1;
+ header_buf[n] = .{ .name = "Authorization", .value = authorization };
+ n += 1;
+ if (creds.session_token) |tok| {
+ header_buf[n] = .{ .name = "X-Amz-Security-Token", .value = tok };
+ n += 1;
+ }
+
+ const result = self.http.fetch(.{
+ .location = .{ .url = url },
+ .method = .POST,
+ .payload = body,
+ .extra_headers = header_buf[0..n],
+ .response_writer = &response_body.writer,
+ }) catch {
+ response_body.deinit();
+ return KmsError.RequestFailed;
+ };
+
+ if (result.status == .ok) return response_body.toOwnedSlice();
+
+ response_body.deinit();
+ return switch (result.status) {
+ .forbidden, .unauthorized => KmsError.Unauthorized,
+ else => KmsError.RequestFailed,
+ };
+ }
+};
+
+// ============================================================================
+// Credentials
+// ============================================================================
+
+/// Resolve AWS credentials: static env vars first, then the ECS/Fargate
+/// container-credentials endpoint.
+pub fn resolveCredentials(allocator: std.mem.Allocator, io: std.Io) KmsError!Credentials {
+ if (envOwned(allocator, "AWS_ACCESS_KEY_ID")) |access_key| {
+ if (envOwned(allocator, "AWS_SECRET_ACCESS_KEY")) |secret_key| {
+ return .{
+ .access_key_id = access_key,
+ .secret_access_key = secret_key,
+ .session_token = envOwned(allocator, "AWS_SESSION_TOKEN"),
+ };
+ } else {
+ allocator.free(access_key);
+ }
+ }
+
+ // ECS/Fargate container credentials provider.
+ const url = containerCredentialsUrl(allocator) orelse return KmsError.CredentialsUnavailable;
+ defer allocator.free(url);
+
+ var http: std.http.Client = .{ .allocator = allocator, .io = io };
+ defer http.deinit();
+
+ var response_body: std.Io.Writer.Allocating = .init(allocator);
+ defer response_body.deinit();
+
+ const result = http.fetch(.{
+ .location = .{ .url = url },
+ .method = .GET,
+ .response_writer = &response_body.writer,
+ }) catch return KmsError.CredentialsUnavailable;
+ if (result.status != .ok) return KmsError.CredentialsUnavailable;
+
+ const Parsed = struct {
+ AccessKeyId: []const u8,
+ SecretAccessKey: []const u8,
+ Token: ?[]const u8 = null,
+ };
+ const parsed = std.json.parseFromSlice(Parsed, allocator, response_body.written(), .{
+ .ignore_unknown_fields = true,
+ }) catch return KmsError.CredentialsUnavailable;
+ defer parsed.deinit();
+
+ // Dupe field-by-field with errdefer so an allocation failure mid-way frees
+ // the fields already duplicated instead of leaking them.
+ const access_key_id = try allocator.dupe(u8, parsed.value.AccessKeyId);
+ errdefer allocator.free(access_key_id);
+ const secret_access_key = try allocator.dupe(u8, parsed.value.SecretAccessKey);
+ errdefer allocator.free(secret_access_key);
+ const session_token = if (parsed.value.Token) |t| try allocator.dupe(u8, t) else null;
+
+ return .{
+ .access_key_id = access_key_id,
+ .secret_access_key = secret_access_key,
+ .session_token = session_token,
+ };
+}
+
+/// Read an environment variable via libc `getenv`, returning an allocator-owned
+/// copy or null. Zig 0.16 moved ambient env access behind the `Io` model and
+/// removed `std.process.getEnvVarOwned`; eth.zig links libc (for its crypto C),
+/// so `std.c.getenv` is the portable POSIX path (the same one `std.start` and the
+/// bots use).
+fn envOwned(allocator: std.mem.Allocator, name: [*:0]const u8) ?[]const u8 {
+ const raw = std.c.getenv(name) orelse return null;
+ return allocator.dupe(u8, std.mem.span(raw)) catch null;
+}
+
+/// Build the container-credentials URL from the standard env vars, or null if
+/// neither is set. `AWS_CONTAINER_CREDENTIALS_FULL_URI` wins; otherwise
+/// `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` is appended to the ECS metadata IP.
+fn containerCredentialsUrl(allocator: std.mem.Allocator) ?[]const u8 {
+ if (std.c.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI")) |full| {
+ return allocator.dupe(u8, std.mem.span(full)) catch null;
+ }
+ if (std.c.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")) |rel| {
+ return std.fmt.allocPrint(allocator, "http://169.254.170.2{s}", .{std.mem.span(rel)}) catch null;
+ }
+ return null;
+}
+
+// ============================================================================
+// SigV4
+// ============================================================================
+
+const SignParams = struct {
+ region: []const u8,
+ host: []const u8,
+ target: []const u8,
+ body: []const u8,
+ creds: Credentials,
+ amz_date: []const u8, // YYYYMMDDTHHMMSSZ
+ date_stamp: []const u8, // YYYYMMDD
+};
+
+/// Compute the SigV4 `Authorization` header value for a KMS POST. Returns an
+/// allocator-owned string.
+pub fn signRequestV4(allocator: std.mem.Allocator, p: SignParams) KmsError![]u8 {
+ const service = "kms";
+
+ // Canonical + signed headers. Names must be lowercase and sorted; the set
+ // here is always: content-type, host, x-amz-date, [x-amz-security-token], x-amz-target.
+ const payload_hash = hexSha256(p.body);
+
+ const canonical_headers = if (p.creds.session_token) |tok|
+ try std.fmt.allocPrint(allocator, "content-type:application/x-amz-json-1.1\nhost:{s}\nx-amz-date:{s}\nx-amz-security-token:{s}\nx-amz-target:{s}\n", .{ p.host, p.amz_date, tok, p.target })
+ else
+ try std.fmt.allocPrint(allocator, "content-type:application/x-amz-json-1.1\nhost:{s}\nx-amz-date:{s}\nx-amz-target:{s}\n", .{ p.host, p.amz_date, p.target });
+ defer allocator.free(canonical_headers);
+
+ const signed_headers: []const u8 = if (p.creds.session_token != null)
+ "content-type;host;x-amz-date;x-amz-security-token;x-amz-target"
+ else
+ "content-type;host;x-amz-date;x-amz-target";
+
+ const canonical_request = try std.fmt.allocPrint(
+ allocator,
+ "POST\n/\n\n{s}\n{s}\n{s}",
+ .{ canonical_headers, signed_headers, &payload_hash },
+ );
+ defer allocator.free(canonical_request);
+
+ const credential_scope = try std.fmt.allocPrint(
+ allocator,
+ "{s}/{s}/{s}/aws4_request",
+ .{ p.date_stamp, p.region, service },
+ );
+ defer allocator.free(credential_scope);
+
+ const cr_hash = hexSha256(canonical_request);
+ const string_to_sign = try std.fmt.allocPrint(
+ allocator,
+ "AWS4-HMAC-SHA256\n{s}\n{s}\n{s}",
+ .{ p.amz_date, credential_scope, &cr_hash },
+ );
+ defer allocator.free(string_to_sign);
+
+ const signing_key = deriveSigningKey(p.creds.secret_access_key, p.date_stamp, p.region, service);
+ var sig_raw: [32]u8 = undefined;
+ HmacSha256.create(&sig_raw, string_to_sign, &signing_key);
+ const signature = std.fmt.bytesToHex(sig_raw, .lower);
+
+ return std.fmt.allocPrint(
+ allocator,
+ "AWS4-HMAC-SHA256 Credential={s}/{s}, SignedHeaders={s}, Signature={s}",
+ .{ p.creds.access_key_id, credential_scope, signed_headers, &signature },
+ );
+}
+
+/// SigV4 signing key: HMAC chain over "AWS4"+secret, date, region, service.
+fn deriveSigningKey(secret: []const u8, date_stamp: []const u8, region: []const u8, service: []const u8) [32]u8 {
+ var k_secret_buf: [4 + 128]u8 = undefined;
+ const prefix = "AWS4";
+ @memcpy(k_secret_buf[0..4], prefix);
+ const secret_len = @min(secret.len, k_secret_buf.len - 4);
+ @memcpy(k_secret_buf[4 .. 4 + secret_len], secret[0..secret_len]);
+ const k_secret = k_secret_buf[0 .. 4 + secret_len];
+
+ var k_date: [32]u8 = undefined;
+ HmacSha256.create(&k_date, date_stamp, k_secret);
+ var k_region: [32]u8 = undefined;
+ HmacSha256.create(&k_region, region, &k_date);
+ var k_service: [32]u8 = undefined;
+ HmacSha256.create(&k_service, service, &k_region);
+ var k_signing: [32]u8 = undefined;
+ HmacSha256.create(&k_signing, "aws4_request", &k_service);
+ return k_signing;
+}
+
+fn hexSha256(data: []const u8) [64]u8 {
+ var digest: [32]u8 = undefined;
+ Sha256.hash(data, &digest, .{});
+ return std.fmt.bytesToHex(digest, .lower);
+}
+
+// ============================================================================
+// Timestamps
+// ============================================================================
+
+const Timestamps = struct {
+ buf_amz: [16]u8,
+ buf_date: [8]u8,
+
+ fn amz_date(self: *const Timestamps) []const u8 {
+ return &self.buf_amz;
+ }
+ fn date_stamp(self: *const Timestamps) []const u8 {
+ return &self.buf_date;
+ }
+};
+
+/// Current UTC time formatted for SigV4: `YYYYMMDDTHHMMSSZ` and `YYYYMMDD`.
+/// Zig 0.16 reads wall-clock time through `Io` (`std.time.timestamp` was removed).
+fn nowTimestamps(io: std.Io) KmsError!Timestamps {
+ const now = std.Io.Clock.now(.real, io).toSeconds();
+ if (now < 0) return KmsError.RequestFailed;
+ const epoch_secs = std.time.epoch.EpochSeconds{ .secs = @intCast(now) };
+ const day = epoch_secs.getEpochDay();
+ const year_day = day.calculateYearDay();
+ const month_day = year_day.calculateMonthDay();
+ const ds = epoch_secs.getDaySeconds();
+
+ const year: u16 = year_day.year;
+ const month: u8 = month_day.month.numeric();
+ const dom: u8 = month_day.day_index + 1;
+ const hour: u8 = ds.getHoursIntoDay();
+ const minute: u8 = ds.getMinutesIntoHour();
+ const second: u8 = ds.getSecondsIntoMinute();
+
+ var out: Timestamps = .{ .buf_amz = undefined, .buf_date = undefined };
+ _ = std.fmt.bufPrint(&out.buf_amz, "{d:0>4}{d:0>2}{d:0>2}T{d:0>2}{d:0>2}{d:0>2}Z", .{ year, month, dom, hour, minute, second }) catch return KmsError.RequestFailed;
+ _ = std.fmt.bufPrint(&out.buf_date, "{d:0>4}{d:0>2}{d:0>2}", .{ year, month, dom }) catch return KmsError.RequestFailed;
+ return out;
+}
+
+// ============================================================================
+// JSON + base64 + DER decoding
+// ============================================================================
+
+/// Parse `resp` as JSON and base64-decode the string field `field`. Owned result.
+fn decodeBase64Field(allocator: std.mem.Allocator, resp: []const u8, comptime field: []const u8) KmsError![]u8 {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, resp, .{}) catch return KmsError.ResponseInvalid;
+ defer parsed.deinit();
+
+ const obj = switch (parsed.value) {
+ .object => |o| o,
+ else => return KmsError.ResponseInvalid,
+ };
+ const val = obj.get(field) orelse return KmsError.ResponseInvalid;
+ const b64 = switch (val) {
+ .string => |s| s,
+ else => return KmsError.ResponseInvalid,
+ };
+
+ const decoded_len = std.base64.standard.Decoder.calcSizeForSlice(b64) catch return KmsError.ResponseInvalid;
+ const out = try allocator.alloc(u8, decoded_len);
+ errdefer allocator.free(out);
+ std.base64.standard.Decoder.decode(out, b64) catch return KmsError.ResponseInvalid;
+ return out;
+}
+
+/// Decode a DER-encoded ECDSA signature `SEQUENCE { INTEGER r, INTEGER s }`
+/// into 64 big-endian bytes `r||s` (each 32 bytes, left-padded). Strict: the
+/// SEQUENCE length must span exactly the rest of the input, and both INTEGERs
+/// must consume it fully - trailing or embedded extra bytes are rejected.
+pub fn decodeDerSignature(der: []const u8) KmsError![64]u8 {
+ var pos: usize = 0;
+ if (der.len < 8 or der[pos] != 0x30) return KmsError.ResponseInvalid;
+ pos += 1;
+ // SEQUENCE length (short form only; a secp256k1 ECDSA sig is < 128 bytes)
+ // and it must match the remaining input exactly.
+ const seq_len = der[pos];
+ if (seq_len & 0x80 != 0) return KmsError.ResponseInvalid;
+ pos += 1;
+ if (der.len != pos + seq_len) return KmsError.ResponseInvalid;
+
+ var out: [64]u8 = @splat(0);
+ pos = try readDerInteger(der, pos, out[0..32]);
+ pos = try readDerInteger(der, pos, out[32..64]);
+ if (pos != der.len) return KmsError.ResponseInvalid;
+ return out;
+}
+
+/// Read a DER INTEGER at `der[pos]` into the 32-byte big-endian `dst`
+/// (right-aligned). Returns the position just past the integer.
+fn readDerInteger(der: []const u8, pos_in: usize, dst: *[32]u8) KmsError!usize {
+ var pos = pos_in;
+ if (pos + 2 > der.len or der[pos] != 0x02) return KmsError.ResponseInvalid;
+ pos += 1;
+ const len = der[pos];
+ pos += 1;
+ if (len == 0 or len & 0x80 != 0 or pos + len > der.len) return KmsError.ResponseInvalid;
+
+ var start = pos;
+ var remaining = len;
+ // Strip a single leading 0x00 (present when the high bit would set the sign).
+ while (remaining > 1 and der[start] == 0x00) {
+ start += 1;
+ remaining -= 1;
+ }
+ if (remaining > 32) return KmsError.ResponseInvalid;
+ @memcpy(dst[32 - remaining .. 32], der[start .. start + remaining]);
+ return pos + len;
+}
+
+/// Extract the 65-byte uncompressed public key (`0x04 || X || Y`) from a DER
+/// SubjectPublicKeyInfo. The key is the final 65 bytes of the SPKI.
+pub fn extractUncompressedPubkey(spki: []const u8) KmsError![65]u8 {
+ if (spki.len < 65) return KmsError.ResponseInvalid;
+ const start = spki.len - 65;
+ if (spki[start] != 0x04) return KmsError.ResponseInvalid;
+ var out: [65]u8 = undefined;
+ @memcpy(&out, spki[start..]);
+ return out;
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+test "SigV4 signing key + signature match the AWS documented vector" {
+ // From "Examples of the complete Version 4 signing process" (AWS docs):
+ // GET https://iam.amazonaws.com/?Action=ListUsers&Version=2010-05-08
+ const secret = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY";
+ const date_stamp = "20150830";
+ const region = "us-east-1";
+ const service = "iam";
+ const string_to_sign =
+ "AWS4-HMAC-SHA256\n" ++
+ "20150830T123600Z\n" ++
+ "20150830/us-east-1/iam/aws4_request\n" ++
+ "f536975d06c0309214f805bb90ccff089219ecd68b2577efef23edd43b7e1a59";
+
+ const signing_key = deriveSigningKey(secret, date_stamp, region, service);
+ var sig_raw: [32]u8 = undefined;
+ HmacSha256.create(&sig_raw, string_to_sign, &signing_key);
+ const signature = std.fmt.bytesToHex(sig_raw, .lower);
+
+ try std.testing.expectEqualStrings(
+ "5d672d79c15b13162d9279b0855cfba6789a8edb4c82c400e06b5924a6f2b5d7",
+ &signature,
+ );
+}
+
+test "hexSha256 of empty string" {
+ const h = hexSha256("");
+ try std.testing.expectEqualStrings(
+ "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+ &h,
+ );
+}
+
+test "decodeDerSignature round-trips r and s (no padding)" {
+ // SEQUENCE(len=6) { INTEGER(1) 0x01, INTEGER(1) 0x02 }
+ const der = [_]u8{ 0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02 };
+ const rs = try decodeDerSignature(&der);
+ try std.testing.expectEqual(@as(u8, 0x01), rs[31]);
+ try std.testing.expectEqual(@as(u8, 0x02), rs[63]);
+ // Everything else is zero-padded.
+ for (rs[0..31]) |b| try std.testing.expectEqual(@as(u8, 0), b);
+ for (rs[32..63]) |b| try std.testing.expectEqual(@as(u8, 0), b);
+}
+
+test "decodeDerSignature strips leading sign byte" {
+ // r = 0x00FF... (leading zero to keep it positive) should decode to 0xFF in
+ // the last byte, not shift the value.
+ const der = [_]u8{ 0x30, 0x08, 0x02, 0x02, 0x00, 0xff, 0x02, 0x02, 0x00, 0x80 };
+ const rs = try decodeDerSignature(&der);
+ try std.testing.expectEqual(@as(u8, 0xff), rs[31]);
+ try std.testing.expectEqual(@as(u8, 0x80), rs[63]);
+}
+
+test "decodeDerSignature rejects trailing bytes after the sequence" {
+ // Valid minimal sig followed by one garbage byte.
+ const der = [_]u8{ 0x30, 0x06, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02, 0xff };
+ try std.testing.expectError(KmsError.ResponseInvalid, decodeDerSignature(&der));
+}
+
+test "decodeDerSignature rejects sequence length mismatch" {
+ // SEQUENCE claims 7 bytes of content but only 6 follow.
+ const der = [_]u8{ 0x30, 0x07, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02 };
+ try std.testing.expectError(KmsError.ResponseInvalid, decodeDerSignature(&der));
+}
+
+test "decodeDerSignature rejects zero-length integers" {
+ // r is INTEGER of length 0 (padded with an extra s byte to pass the min-len gate).
+ const der = [_]u8{ 0x30, 0x07, 0x02, 0x00, 0x02, 0x03, 0x01, 0x02, 0x03 };
+ try std.testing.expectError(KmsError.ResponseInvalid, decodeDerSignature(&der));
+}
+
+test "extractUncompressedPubkey pulls the trailing 65 bytes" {
+ var spki: [88]u8 = @splat(0xaa);
+ spki[spki.len - 65] = 0x04;
+ // Fill X||Y with a recognizable pattern.
+ for (spki[spki.len - 64 ..], 0..) |*b, i| b.* = @intCast(i & 0xff);
+ const pk = try extractUncompressedPubkey(&spki);
+ try std.testing.expectEqual(@as(u8, 0x04), pk[0]);
+ try std.testing.expectEqual(@as(u8, 0), pk[1]);
+ try std.testing.expectEqual(@as(u8, 63), pk[64]);
+}
diff --git a/src/root.zig b/src/root.zig
index 6222736..9a88db0 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -17,6 +17,7 @@ pub const abi_decode = @import("abi_decode.zig");
pub const signature = @import("signature.zig");
pub const secp256k1 = @import("secp256k1.zig");
pub const signer = @import("signer.zig");
+pub const kms = @import("kms.zig");
pub const eip155 = @import("eip155.zig");
// -- Layer 4: Types --
@@ -109,6 +110,7 @@ test {
_ = @import("signature.zig");
_ = @import("secp256k1.zig");
_ = @import("signer.zig");
+ _ = @import("kms.zig");
_ = @import("eip155.zig");
// Layer 4
_ = @import("access_list.zig");
diff --git a/src/signer.zig b/src/signer.zig
index 4948a2a..a4efac1 100644
--- a/src/signer.zig
+++ b/src/signer.zig
@@ -3,40 +3,54 @@ const keccak = @import("keccak.zig");
const primitives = @import("primitives.zig");
const secp256k1 = @import("secp256k1.zig");
const rlp = @import("rlp.zig");
+const kms = @import("kms.zig");
const Signature = @import("signature.zig").Signature;
const Authorization = @import("transaction.zig").Authorization;
+/// The error set surfaced by the `Signer` interface. It is the union of every
+/// implementation's failure modes so the interface's methods have a stable error
+/// set across `LocalSigner` (secp256k1) and `KmsSigner` (AWS KMS over the network).
+pub const SignerError =
+ secp256k1.SignError ||
+ secp256k1.RecoverError ||
+ kms.KmsError ||
+ error{AddressMismatch};
+
/// EIP-7702 authorization signing magic byte.
/// The authorization hash is `keccak256(MAGIC || rlp([chain_id, address, nonce]))`.
pub const EIP7702_MAGIC: u8 = 0x05;
-/// An Ethereum account signer backed by a secp256k1 private key.
+/// An Ethereum account signer backed by an in-memory secp256k1 private key.
/// Provides message signing with EIP-191 personal message prefix support.
-pub const Signer = struct {
+///
+/// This is the local-key implementation of the `Signer` interface (the union
+/// below). For a key that is generated in and never leaves AWS KMS, use
+/// `KmsSigner`.
+pub const LocalSigner = struct {
private_key: [32]u8,
const secureZero = @import("utils/constants.zig").secureZero;
- /// Create a new Signer from a 32-byte private key.
- pub fn init(private_key: [32]u8) Signer {
+ /// Create a new LocalSigner from a 32-byte private key.
+ pub fn init(private_key: [32]u8) LocalSigner {
return .{ .private_key = private_key };
}
- /// Securely zero the private key. Call when the Signer is no longer needed.
- pub fn deinit(self: *Signer) void {
+ /// Securely zero the private key. Call when the LocalSigner is no longer needed.
+ pub fn deinit(self: *LocalSigner) void {
secureZero(&self.private_key);
}
/// Derive the Ethereum address corresponding to this signer's private key.
/// pubkey -> keccak256(pubkey_xy) -> last 20 bytes
- pub fn address(self: Signer) secp256k1.SignError!primitives.Address {
+ pub fn address(self: LocalSigner) secp256k1.SignError!primitives.Address {
const pubkey = try secp256k1.derivePublicKey(self.private_key);
return secp256k1.pubkeyToAddress(pubkey);
}
/// Sign a 32-byte message hash directly (raw ECDSA sign).
/// The hash is typically keccak256 of some data.
- pub fn signHash(self: Signer, message_hash: [32]u8) secp256k1.SignError!Signature {
+ pub fn signHash(self: LocalSigner, message_hash: [32]u8) secp256k1.SignError!Signature {
return secp256k1.sign(self.private_key, message_hash);
}
@@ -44,7 +58,7 @@ pub const Signer = struct {
/// keccak256("\x19Ethereum Signed Message:\n" ++ len_str ++ message)
///
/// This is the standard used by eth_sign, personal_sign, etc.
- pub fn signMessage(self: Signer, message: []const u8) secp256k1.SignError!Signature {
+ pub fn signMessage(self: LocalSigner, message: []const u8) secp256k1.SignError!Signature {
const prefixed_hash = hashPersonalMessage(message);
return self.signHash(prefixed_hash);
}
@@ -58,7 +72,7 @@ pub const Signer = struct {
///
/// A `chain_id` of 0 makes the authorization valid on any chain.
pub fn signAuthorization(
- self: Signer,
+ self: LocalSigner,
allocator: std.mem.Allocator,
chain_id: u256,
delegate: [20]u8,
@@ -90,6 +104,141 @@ pub const Signer = struct {
}
};
+/// secp256k1 curve order N and its half, used for EIP-2 low-s normalization.
+const SECP256K1_N: u256 = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141;
+const SECP256K1_HALF_N: u256 = SECP256K1_N / 2;
+
+/// An Ethereum account signer whose secp256k1 private key lives in AWS KMS and
+/// never leaves it. Signing calls `kms:Sign`; the address is derived once at
+/// construction via `kms:GetPublicKey` and cached.
+///
+/// The caller owns the `KmsSigner` and must keep it (and the `region` / `key_id`
+/// slices it borrows) alive for its lifetime; wrap it into a `Signer` with
+/// `Signer.fromKms(&kms_signer)`.
+pub const KmsSigner = struct {
+ client: kms.Client,
+ /// KMS key identifier: key id, key ARN, or alias (e.g. "alias/perpcity/...").
+ /// Borrowed; caller keeps it alive.
+ key_id: []const u8,
+ cached_address: primitives.Address,
+
+ /// Create a KmsSigner, deriving and caching the Ethereum address via
+ /// `kms:GetPublicKey`. `io` backs the HTTP client used for KMS calls.
+ pub fn init(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ region: []const u8,
+ key_id: []const u8,
+ ) SignerError!KmsSigner {
+ // Derive the address through a short-lived client so the client we keep
+ // starts with an empty connection pool (safe to return by value).
+ var probe = kms.Client.init(allocator, io, region);
+ const pubkey = probe.getPublicKey(key_id) catch |e| {
+ probe.deinit();
+ return e;
+ };
+ probe.deinit();
+
+ return .{
+ .client = kms.Client.init(allocator, io, region),
+ .key_id = key_id,
+ .cached_address = secp256k1.pubkeyToAddress(pubkey),
+ };
+ }
+
+ pub fn deinit(self: *KmsSigner) void {
+ self.client.deinit();
+ }
+
+ /// The cached Ethereum address (derived at construction).
+ pub fn address(self: *const KmsSigner) primitives.Address {
+ return self.cached_address;
+ }
+
+ /// Sign a 32-byte hash via `kms:Sign`, then apply EIP-2 low-s normalization
+ /// and recover the parity `v` by matching against the cached address.
+ pub fn signHash(self: *KmsSigner, message_hash: [32]u8) SignerError!Signature {
+ var rs = try self.client.sign(self.key_id, message_hash);
+
+ const s_val = std.mem.readInt(u256, rs[32..64], .big);
+ if (s_val > SECP256K1_HALF_N) {
+ std.mem.writeInt(u256, rs[32..64], SECP256K1_N - s_val, .big);
+ }
+
+ var v: u8 = 0;
+ while (v < 2) : (v += 1) {
+ const sig = Signature{ .r = rs[0..32].*, .s = rs[32..64].*, .v = v };
+ const recovered = secp256k1.recoverAddress(sig, message_hash) catch continue;
+ if (std.mem.eql(u8, &recovered, &self.cached_address)) return sig;
+ }
+ return SignerError.AddressMismatch;
+ }
+
+ /// Sign a message with the EIP-191 personal-message prefix.
+ pub fn signMessage(self: *KmsSigner, message: []const u8) SignerError!Signature {
+ return self.signHash(LocalSigner.hashPersonalMessage(message));
+ }
+};
+
+/// The signer interface used by `Wallet` and other consumers. A tagged union so
+/// it can be owned inline (value semantics) with no allocation or lifetime
+/// fix-ups: `local` is stored inline; `kms` borrows a caller-owned `KmsSigner`.
+/// The method surface mirrors alloy's `Signer` trait (`address`, `signHash`,
+/// `signMessage`).
+pub const Signer = union(enum) {
+ local: LocalSigner,
+ kms: *KmsSigner,
+
+ /// Wrap a local private key.
+ pub fn fromPrivateKey(private_key: [32]u8) Signer {
+ return .{ .local = LocalSigner.init(private_key) };
+ }
+
+ /// Wrap an existing LocalSigner.
+ pub fn fromLocal(local_signer: LocalSigner) Signer {
+ return .{ .local = local_signer };
+ }
+
+ /// Wrap a caller-owned KmsSigner (must outlive this Signer).
+ pub fn fromKms(kms_signer: *KmsSigner) Signer {
+ return .{ .kms = kms_signer };
+ }
+
+ /// The signer's Ethereum address.
+ pub fn address(self: *const Signer) SignerError!primitives.Address {
+ return switch (self.*) {
+ .local => |s| try s.address(),
+ .kms => |k| k.address(),
+ };
+ }
+
+ /// Sign a 32-byte hash (raw ECDSA), returning a recoverable signature.
+ pub fn signHash(self: *const Signer, message_hash: [32]u8) SignerError!Signature {
+ return switch (self.*) {
+ .local => |s| try s.signHash(message_hash),
+ .kms => |k| try k.signHash(message_hash),
+ };
+ }
+
+ /// Sign a message with the EIP-191 personal-message prefix.
+ pub fn signMessage(self: *const Signer, message: []const u8) SignerError!Signature {
+ return switch (self.*) {
+ .local => |s| try s.signMessage(message),
+ .kms => |k| try k.signMessage(message),
+ };
+ }
+
+ /// Release resources owned by this Signer: zeroes a local key. The `kms`
+ /// variant is borrowed (see `fromKms`), so its owner is responsible for
+ /// calling `KmsSigner.deinit` - it is deliberately NOT released here.
+ pub fn deinit(self: *Signer) void {
+ switch (self.*) {
+ .local => |*s| s.deinit(),
+ .kms => {},
+ }
+ }
+};
+
/// Compute the EIP-7702 authorization signing hash:
/// `keccak256(0x05 || rlp([chain_id, address, nonce]))`.
///
@@ -177,7 +326,7 @@ test "Signer.address returns correct address for Hardhat #0" {
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const addr = try signer.address();
try std.testing.expectEqualSlices(u8, &expected_address, &addr);
}
@@ -187,7 +336,7 @@ test "Signer.signHash and recover" {
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const message_hash = keccak.hash("test hash signing");
const sig = try signer.signHash(message_hash);
@@ -201,11 +350,11 @@ test "Signer.signMessage with known message" {
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const sig = try signer.signMessage("Hello, Ethereum!");
// To verify: recover from the EIP-191 prefixed hash
- const prefixed_hash = Signer.hashPersonalMessage("Hello, Ethereum!");
+ const prefixed_hash = LocalSigner.hashPersonalMessage("Hello, Ethereum!");
const recovered = try secp256k1.recoverAddress(sig, prefixed_hash);
try std.testing.expectEqualSlices(u8, &expected_address, &recovered);
}
@@ -215,10 +364,10 @@ test "Signer.signMessage empty message" {
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const sig = try signer.signMessage("");
- const prefixed_hash = Signer.hashPersonalMessage("");
+ const prefixed_hash = LocalSigner.hashPersonalMessage("");
const recovered = try secp256k1.recoverAddress(sig, prefixed_hash);
try std.testing.expectEqualSlices(u8, &expected_address, &recovered);
}
@@ -226,7 +375,7 @@ test "Signer.signMessage empty message" {
test "hashPersonalMessage produces correct hash" {
// The prefix for "hello" (5 bytes) should be:
// keccak256("\x19Ethereum Signed Message:\n5hello")
- const hash = Signer.hashPersonalMessage("hello");
+ const hash = LocalSigner.hashPersonalMessage("hello");
// Compute expected manually
const expected = keccak.hash("\x19Ethereum Signed Message:\n5hello");
@@ -235,13 +384,13 @@ test "hashPersonalMessage produces correct hash" {
test "hashPersonalMessage with longer message" {
// 13 bytes: "Hello, World!"
- const hash = Signer.hashPersonalMessage("Hello, World!");
+ const hash = LocalSigner.hashPersonalMessage("Hello, World!");
const expected = keccak.hash("\x19Ethereum Signed Message:\n13Hello, World!");
try std.testing.expectEqualSlices(u8, &expected, &hash);
}
test "hashPersonalMessage with empty message" {
- const hash = Signer.hashPersonalMessage("");
+ const hash = LocalSigner.hashPersonalMessage("");
const expected = keccak.hash("\x19Ethereum Signed Message:\n0");
try std.testing.expectEqualSlices(u8, &expected, &hash);
}
@@ -268,12 +417,12 @@ test "Signer with Hardhat account #1" {
const private_key = try hex.hexToBytesFixed(32, "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d");
const expected_address = try hex.hexToBytesFixed(20, "70997970C51812dc3A010C7d01b50e0d17dc79C8");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const addr = try signer.address();
try std.testing.expectEqualSlices(u8, &expected_address, &addr);
const sig = try signer.signMessage("test from account 1");
- const prefixed_hash = Signer.hashPersonalMessage("test from account 1");
+ const prefixed_hash = LocalSigner.hashPersonalMessage("test from account 1");
const recovered = try secp256k1.recoverAddress(sig, prefixed_hash);
try std.testing.expectEqualSlices(u8, &expected_address, &recovered);
}
@@ -282,7 +431,7 @@ test "Signer deterministic signatures" {
const hex = @import("hex.zig");
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const sig1 = try signer.signMessage("deterministic");
const sig2 = try signer.signMessage("deterministic");
@@ -294,7 +443,7 @@ test "Signer with Hardhat account #2" {
const private_key = try hex.hexToBytesFixed(32, "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a");
const expected_address = try hex.hexToBytesFixed(20, "3C44CdDdB6a900fa2b585dd299e03d12FA4293BC");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const addr = try signer.address();
try std.testing.expectEqualSlices(u8, &expected_address, &addr);
}
@@ -304,7 +453,7 @@ test "Signer with Hardhat account #3" {
const private_key = try hex.hexToBytesFixed(32, "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6");
const expected_address = try hex.hexToBytesFixed(20, "90F79bf6EB2c4f870365E785982E1f101E93b906");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const addr = try signer.address();
try std.testing.expectEqualSlices(u8, &expected_address, &addr);
}
@@ -314,7 +463,7 @@ test "Signer with Hardhat account #4" {
const private_key = try hex.hexToBytesFixed(32, "47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a");
const expected_address = try hex.hexToBytesFixed(20, "15d34AAf54267DB7D7c367839AAf71A00a2C6A65");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const addr = try signer.address();
try std.testing.expectEqualSlices(u8, &expected_address, &addr);
}
@@ -357,7 +506,7 @@ test "signAuthorization round-trips: recovered signer equals authority" {
const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const delegate = @as([20]u8, @splat(0xde));
const auth = try signer.signAuthorization(allocator, 1, delegate, 42);
@@ -381,7 +530,7 @@ test "signAuthorization chain_id=0 (any-chain) round-trips" {
const private_key = try hex.hexToBytesFixed(32, "59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d");
const expected_address = try hex.hexToBytesFixed(20, "70997970C51812dc3A010C7d01b50e0d17dc79C8");
- const signer = Signer.init(private_key);
+ const signer = LocalSigner.init(private_key);
const delegate = @as([20]u8, @splat(0x01));
const auth = try signer.signAuthorization(allocator, 0, delegate, 0);
@@ -393,7 +542,7 @@ test "signAuthorization chain_id=0 (any-chain) round-trips" {
test "hashPersonalMessage with 100-byte message" {
const message: [100]u8 = @as([100]u8, @splat('A'));
- const hash = Signer.hashPersonalMessage(&message);
+ const hash = LocalSigner.hashPersonalMessage(&message);
// The prefix for a 100-byte message includes "100" (3 chars)
const prefix = "\x19Ethereum Signed Message:\n";
diff --git a/src/wallet.zig b/src/wallet.zig
index 1d0e069..6ceaf46 100644
--- a/src/wallet.zig
+++ b/src/wallet.zig
@@ -30,7 +30,8 @@ pub const WalletError = error{
/// transaction lifecycle: fill nonce/gas from the provider, construct an
/// EIP-1559 transaction, sign it, serialize it, and broadcast it.
pub const Wallet = struct {
- signer_instance: signer_mod.Signer,
+ /// The account signer (local key or KMS). Owned inline (value semantics).
+ signer: signer_mod.Signer,
provider: *provider_mod.Provider,
allocator: std.mem.Allocator,
chain_id: ?u64,
@@ -41,12 +42,12 @@ pub const Wallet = struct {
/// frees it. See `nonce_manager.NonceManager`.
nonce_manager: ?*nonce_manager_mod.NonceManager = null,
- /// Create a new Wallet from a private key and provider.
+ /// Create a new Wallet from a `Signer` (local key or KMS) and a provider.
/// The chain_id is initially null and will be fetched from the provider
/// on the first transaction if not set manually.
- pub fn init(allocator: std.mem.Allocator, private_key: [32]u8, provider: *provider_mod.Provider) Wallet {
+ pub fn init(allocator: std.mem.Allocator, signer: signer_mod.Signer, provider: *provider_mod.Provider) Wallet {
return .{
- .signer_instance = signer_mod.Signer.init(private_key),
+ .signer = signer,
.provider = provider,
.allocator = allocator,
.chain_id = null,
@@ -54,14 +55,23 @@ pub const Wallet = struct {
};
}
- /// Securely zero the private key. Call when the Wallet is no longer needed.
+ /// Convenience: create a Wallet from a raw local private key (wraps a
+ /// `LocalSigner`). For a KMS-backed key, build a `signer.KmsSigner` and pass
+ /// `signer.Signer.fromKms(&kms_signer)` to `init`.
+ pub fn initLocal(allocator: std.mem.Allocator, private_key: [32]u8, provider: *provider_mod.Provider) Wallet {
+ return init(allocator, signer_mod.Signer.fromPrivateKey(private_key), provider);
+ }
+
+ /// Release signer resources owned by this wallet (zeroes a local key). A
+ /// KMS signer is borrowed - its owner calls `KmsSigner.deinit` separately.
+ /// Call when the Wallet is no longer needed.
pub fn deinit(self: *Wallet) void {
- self.signer_instance.deinit();
+ self.signer.deinit();
}
- /// Return the Ethereum address derived from this wallet's private key.
+ /// Return the Ethereum address of this wallet's signer.
pub fn address(self: *const Wallet) ![20]u8 {
- return try self.signer_instance.address();
+ return try self.signer.address();
}
/// Ensure chain_id is populated by fetching it from the provider if needed.
@@ -180,7 +190,7 @@ pub const Wallet = struct {
const msg_hash = try transaction_mod.hashForSigning(self.allocator, wrapped);
// Sign the hash
- const sig = self.signer_instance.signHash(msg_hash) catch return error.SigningFailed;
+ const sig = self.signer.signHash(msg_hash) catch return error.SigningFailed;
// For EIP-1559 (type 2) transactions, v is the raw recovery id (0 or 1)
return try transaction_mod.serializeSigned(self.allocator, wrapped, sig.r, sig.s, sig.v);
@@ -198,7 +208,7 @@ test "Wallet.init sets fields correctly" {
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(std.testing.allocator, &transport);
- var wallet = Wallet.init(std.testing.allocator, private_key, &provider);
+ var wallet = Wallet.initLocal(std.testing.allocator, private_key, &provider);
try std.testing.expect(wallet.chain_id == null);
try std.testing.expect(wallet.provider == &provider);
@@ -217,7 +227,7 @@ test "Wallet accepts an optional nonce manager without breaking init" {
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(std.testing.allocator, &transport);
- var wallet = Wallet.init(std.testing.allocator, private_key, &provider);
+ var wallet = Wallet.initLocal(std.testing.allocator, private_key, &provider);
var nonces = nonce_manager_mod.NonceManager.init(&provider, try wallet.address());
wallet.nonce_manager = &nonces;
@@ -231,7 +241,7 @@ test "Wallet.signTransaction produces valid signed bytes" {
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(std.testing.allocator, &transport);
- var wallet = Wallet.init(std.testing.allocator, private_key, &provider);
+ var wallet = Wallet.initLocal(std.testing.allocator, private_key, &provider);
wallet.chain_id = 1;
const tx = transaction_mod.Eip1559Transaction{
@@ -262,7 +272,7 @@ test "Wallet.signTransaction is deterministic" {
var transport = http_transport_mod.HttpTransport.init(std.testing.allocator, "http://localhost:8545", runtime.blockingIo());
defer transport.deinit();
var provider = provider_mod.Provider.init(std.testing.allocator, &transport);
- var wallet = Wallet.init(std.testing.allocator, private_key, &provider);
+ var wallet = Wallet.initLocal(std.testing.allocator, private_key, &provider);
wallet.chain_id = 1;
const tx = transaction_mod.Eip1559Transaction{