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{