diff --git a/build.zig b/build.zig index 92eb642c4..0ca39a686 100644 --- a/build.zig +++ b/build.zig @@ -39,6 +39,13 @@ pub fn build(b: *std.Build) void { c_kzg_mod.addIncludePath(b.path("packages/voltaire-zig/lib/c-kzg-4844/src")); c_kzg_mod.addIncludePath(b.path("packages/voltaire-zig/lib/c-kzg-4844/blst/bindings")); + // eth.zig - pure Zig Ethereum primitives (HD wallet, transaction utils) + const eth_zig_dep = b.dependency("eth_zig", .{ + .target = target, + .optimize = optimize, + }); + const eth_zig_mod = eth_zig_dep.module("eth"); + // Crypto module - export for external packages const crypto_mod = b.addModule("crypto", .{ .root_source_file = b.path("packages/voltaire-zig/src/crypto/root.zig"), @@ -46,6 +53,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); crypto_mod.addImport("c_kzg", c_kzg_mod); + crypto_mod.addImport("eth_zig", eth_zig_mod); crypto_mod.addIncludePath(b.path("packages/voltaire-zig/lib")); // For keccak_wrapper.h // z-ens-normalize module @@ -73,6 +81,7 @@ pub fn build(b: *std.Build) void { primitives_mod.addImport("crypto", crypto_mod); primitives_mod.addImport("z_ens_normalize", z_ens_normalize_mod); primitives_mod.addImport("primitives", primitives_mod); + primitives_mod.addImport("eth_zig", eth_zig_mod); // JSON-RPC module - Ethereum JSON-RPC type system (65 methods) const jsonrpc_mod = b.addModule("jsonrpc", .{ diff --git a/build.zig.zon b/build.zig.zon index 0f809c52f..fccbc8ec1 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -58,6 +58,10 @@ .path = "packages/voltaire-zig/lib/libwally-core", .lazy = true, }, + .eth_zig = .{ + .url = "https://github.com/StrobeLabs/eth.zig/archive/v0.2.3.tar.gz", + .hash = "eth_zig-0.2.3-efEm-leZCQBWQBTN6L_ABVvjvAaRHgaXSlte6ZgQd4mB", + }, }, // Specifies the set of files and directories that are included in this package. diff --git a/packages/voltaire-zig/src/crypto/HDWallet/hdwallet.zig b/packages/voltaire-zig/src/crypto/HDWallet/hdwallet.zig new file mode 100644 index 000000000..c4ee41afc --- /dev/null +++ b/packages/voltaire-zig/src/crypto/HDWallet/hdwallet.zig @@ -0,0 +1,262 @@ +//! BIP-32/44 Hierarchical Deterministic Wallet +//! +//! Implementation powered by eth.zig. +//! Supports BIP-32 key derivation, BIP-44 Ethereum paths, and BIP-39 mnemonics. +//! +//! ## Usage +//! +//! ```zig +//! const hdwallet = @import("crypto").hdwallet; +//! +//! // From mnemonic +//! const words = [_][]const u8{ "abandon", "abandon", ... , "about" }; +//! const seed = try hdwallet.Mnemonic.toSeed(&words, ""); +//! const wallet = try hdwallet.HDWallet.fromSeed(seed); +//! const eth_wallet = try wallet.deriveEthereum(0); +//! const addr = eth_wallet.getAddress(); +//! ``` + +const std = @import("std"); +const eth = @import("eth_zig"); + +/// BIP-32 Hierarchical Deterministic wallet. +/// Wraps a BIP-32 extended key (private key + chain code). +pub const HDWallet = struct { + extended_key: eth.hd_wallet.ExtendedKey, + + /// Create a master HD wallet from a 64-byte BIP-39 seed. + pub fn fromSeed(seed: [64]u8) !HDWallet { + const key = eth.hd_wallet.masterKeyFromSeed(seed) catch return error.InvalidSeed; + return .{ .extended_key = key }; + } + + /// Derive a child key at the given BIP-32 index. + /// Use `index | HARDENED` for hardened derivation. + pub fn deriveChild(self: HDWallet, index: u32) !HDWallet { + const child = eth.hd_wallet.deriveChild(self.extended_key, index) catch return error.DerivationFailed; + return .{ .extended_key = child }; + } + + /// Derive a key from a BIP-32 path string (e.g., "m/44'/60'/0'/0/0"). + pub fn derivePath(seed: [64]u8, path: []const u8) !HDWallet { + const key = eth.hd_wallet.derivePath(seed, path) catch return error.InvalidPath; + return .{ .extended_key = key }; + } + + /// Derive an Ethereum account at BIP-44 path m/44'/60'/0'/0/{account_index} + /// from the current key. Assumes self is the master key. + pub fn deriveEthereum(self: HDWallet, account_index: u32) !HDWallet { + var key = self.extended_key; + key = eth.hd_wallet.deriveChild(key, 44 | HARDENED) catch return error.DerivationFailed; + key = eth.hd_wallet.deriveChild(key, ETH_COIN_TYPE | HARDENED) catch return error.DerivationFailed; + key = eth.hd_wallet.deriveChild(key, 0 | HARDENED) catch return error.DerivationFailed; + key = eth.hd_wallet.deriveChild(key, 0) catch return error.DerivationFailed; + key = eth.hd_wallet.deriveChild(key, account_index) catch return error.DerivationFailed; + return .{ .extended_key = key }; + } + + /// Convenience: derive Ethereum account directly from seed. + pub fn deriveEthereumFromSeed(seed: [64]u8, account_index: u32) !HDWallet { + const key = eth.hd_wallet.deriveEthAccount(seed, account_index) catch return error.DerivationFailed; + return .{ .extended_key = key }; + } + + /// Get the raw 32-byte private key. + pub fn getPrivateKey(self: HDWallet) [32]u8 { + return self.extended_key.key; + } + + /// Get the compressed 33-byte public key (SEC1 format). + pub fn getPublicKey(self: HDWallet) [33]u8 { + const Secp256k1 = std.crypto.ecc.Secp256k1; + const privkey_scalar = Secp256k1.scalar.Scalar.fromBytes(self.extended_key.key, .big) catch return std.mem.zeroes([33]u8); + const pubkey_point = Secp256k1.basePoint.mul(privkey_scalar.toBytes(.big), .big) catch return std.mem.zeroes([33]u8); + return pubkey_point.toCompressedSec1(); + } + + /// Get the 20-byte Ethereum address derived from the private key. + pub fn getAddress(self: HDWallet) [20]u8 { + return self.extended_key.toAddress(); + } + + /// Get the 32-byte chain code. + pub fn getChainCode(self: HDWallet) [32]u8 { + return self.extended_key.chain_code; + } + + /// Check if a derivation index is hardened. + pub fn isHardened(index: u32) bool { + return (index & HARDENED) != 0; + } +}; + +/// Hardened derivation flag (BIP-32). +pub const HARDENED: u32 = eth.hd_wallet.HARDENED; + +/// BIP-44 Ethereum coin type. +pub const ETH_COIN_TYPE: u32 = eth.hd_wallet.ETH_COIN_TYPE; + +/// BIP-39 mnemonic phrase utilities. +pub const Mnemonic = struct { + /// Generate a random 12-word mnemonic. + pub fn generate() [12][]const u8 { + return eth.mnemonic.generate(.@"128"); + } + + /// Generate a random 24-word mnemonic. + pub fn generate24() [24][]const u8 { + return eth.mnemonic.generate(.@"256"); + } + + /// Derive a 64-byte seed from mnemonic words and optional passphrase. + /// Uses PBKDF2-HMAC-SHA512 with 2048 iterations per BIP-39. + pub fn toSeed(words: []const []const u8, passphrase: []const u8) ![64]u8 { + return eth.mnemonic.toSeed(words, passphrase); + } + + /// Validate a mnemonic phrase (word count + checksum). + pub fn validate(words: []const []const u8) !void { + eth.mnemonic.validate(words) catch return error.InvalidMnemonic; + } + + /// Convert entropy bytes to a 12-word mnemonic. + pub fn fromEntropy128(entropy: *const [16]u8) [12][]const u8 { + return eth.mnemonic.entropyToMnemonic(.@"128", entropy); + } + + /// Convert entropy bytes to a 24-word mnemonic. + pub fn fromEntropy256(entropy: *const [32]u8) [24][]const u8 { + return eth.mnemonic.entropyToMnemonic(.@"256", entropy); + } +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "HDWallet: fromSeed produces deterministic results" { + const seed = [_]u8{0x01} ** 64; + const w1 = try HDWallet.fromSeed(seed); + const w2 = try HDWallet.fromSeed(seed); + try std.testing.expectEqualSlices(u8, &w1.getPrivateKey(), &w2.getPrivateKey()); + try std.testing.expectEqualSlices(u8, &w1.getChainCode(), &w2.getChainCode()); +} + +test "HDWallet: deriveChild produces different keys for different indices" { + const seed = [_]u8{0x42} ** 64; + const master = try HDWallet.fromSeed(seed); + const child0 = try master.deriveChild(HARDENED); + const child1 = try master.deriveChild(1 | HARDENED); + try std.testing.expect(!std.mem.eql(u8, &child0.getPrivateKey(), &child1.getPrivateKey())); +} + +test "HDWallet: derivePath matches deriveEthereumFromSeed" { + const seed = [_]u8{0xab} ** 64; + const key_path = try HDWallet.derivePath(seed, "m/44'/60'/0'/0/0"); + const key_eth = try HDWallet.deriveEthereumFromSeed(seed, 0); + try std.testing.expectEqualSlices(u8, &key_path.getPrivateKey(), &key_eth.getPrivateKey()); +} + +test "HDWallet: deriveEthereum matches deriveEthereumFromSeed" { + const seed = [_]u8{0xab} ** 64; + const master = try HDWallet.fromSeed(seed); + const from_master = try master.deriveEthereum(0); + const from_seed = try HDWallet.deriveEthereumFromSeed(seed, 0); + try std.testing.expectEqualSlices(u8, &from_seed.getPrivateKey(), &from_master.getPrivateKey()); + try std.testing.expectEqualSlices(u8, &from_seed.getAddress(), &from_master.getAddress()); +} + +test "HDWallet: getAddress produces 20-byte non-zero address" { + const seed = [_]u8{0xef} ** 64; + const wallet = try HDWallet.deriveEthereumFromSeed(seed, 0); + const addr = wallet.getAddress(); + var all_zero = true; + for (addr) |b| { + if (b != 0) { + all_zero = false; + break; + } + } + try std.testing.expect(!all_zero); +} + +test "HDWallet: different accounts produce different addresses" { + const seed = [_]u8{0x11} ** 64; + const w0 = try HDWallet.deriveEthereumFromSeed(seed, 0); + const w1 = try HDWallet.deriveEthereumFromSeed(seed, 1); + try std.testing.expect(!std.mem.eql(u8, &w0.getAddress(), &w1.getAddress())); +} + +test "HDWallet: getPublicKey returns compressed 33-byte key" { + const seed = [_]u8{0x33} ** 64; + const wallet = try HDWallet.fromSeed(seed); + const pubkey = wallet.getPublicKey(); + // Compressed SEC1 starts with 0x02 or 0x03 + try std.testing.expect(pubkey[0] == 0x02 or pubkey[0] == 0x03); +} + +test "HDWallet: known mnemonic abandon...about to exact address" { + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed = try Mnemonic.toSeed(&words, ""); + const wallet = try HDWallet.deriveEthereumFromSeed(seed, 0); + const addr = wallet.getAddress(); + + // Well-known address for "abandon...about" mnemonic, account 0 + const expected = [20]u8{ 0x98, 0x58, 0xEf, 0xFD, 0x23, 0x2B, 0x40, 0x33, 0xE4, 0x7d, 0x90, 0x00, 0x3D, 0x41, 0xEC, 0x34, 0xEc, 0xaE, 0xda, 0x94 }; + try std.testing.expectEqualSlices(u8, &expected, &addr); +} + +test "HDWallet: BIP-39 TREZOR passphrase exact seed vector" { + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed = try Mnemonic.toSeed(&words, "TREZOR"); + // First 32 bytes of the known BIP-39 test vector + const expected_first32 = [32]u8{ + 0xc5, 0x52, 0x57, 0xc3, 0x60, 0xc0, 0x7c, 0x72, + 0x02, 0x9a, 0xeb, 0xc1, 0xb5, 0x3c, 0x05, 0xed, + 0x03, 0x62, 0xad, 0xa3, 0x8e, 0xad, 0x3e, 0x3e, + 0x7e, 0x24, 0x05, 0x2f, 0x0b, 0x7c, 0x87, 0xc2, + }; + try std.testing.expectEqualSlices(u8, &expected_first32, seed[0..32]); +} + +test "Mnemonic: validate accepts known valid mnemonic" { + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + try Mnemonic.validate(&words); +} + +test "Mnemonic: validate rejects bad checksum" { + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + }; + try std.testing.expectError(error.InvalidMnemonic, Mnemonic.validate(&words)); +} + +test "Mnemonic: fromEntropy128 known vector" { + const entropy = [_]u8{0} ** 16; + const words = Mnemonic.fromEntropy128(&entropy); + for (0..11) |i| { + try std.testing.expectEqualStrings("abandon", words[i]); + } + try std.testing.expectEqualStrings("about", words[11]); +} + +test "isHardened" { + try std.testing.expect(HDWallet.isHardened(0x80000000)); + try std.testing.expect(HDWallet.isHardened(44 | HARDENED)); + try std.testing.expect(!HDWallet.isHardened(0)); + try std.testing.expect(!HDWallet.isHardened(44)); +} diff --git a/packages/voltaire-zig/src/crypto/root.zig b/packages/voltaire-zig/src/crypto/root.zig index 069a2a7e7..e8e413758 100644 --- a/packages/voltaire-zig/src/crypto/root.zig +++ b/packages/voltaire-zig/src/crypto/root.zig @@ -99,5 +99,8 @@ pub const bls12_381_ffi = Crypto.bls12_381; // BIP-39 mnemonic implementation pub const bip39 = @import("bip39.zig"); +// BIP-32/44 HD Wallet via eth.zig +pub const hdwallet = @import("HDWallet/hdwallet.zig"); + // Export Keccak256 from std.crypto for primitives modules pub const Keccak256 = @import("std").crypto.hash.sha3.Keccak256; diff --git a/packages/voltaire-zig/src/primitives/Transaction/Transaction.zig b/packages/voltaire-zig/src/primitives/Transaction/Transaction.zig index 946f2b6c2..8bda9fbbb 100644 --- a/packages/voltaire-zig/src/primitives/Transaction/Transaction.zig +++ b/packages/voltaire-zig/src/primitives/Transaction/Transaction.zig @@ -105,6 +105,9 @@ const Authorization = authorization.Authorization; const VersionedHash = blob.VersionedHash; const Allocator = std.mem.Allocator; +// Transaction builder utilities (signing, sender recovery) via eth.zig +pub const utils = @import("transaction_utils.zig"); + // Transaction error types pub const TransactionError = error{ InvalidTransactionType, diff --git a/packages/voltaire-zig/src/primitives/Transaction/transaction_utils.zig b/packages/voltaire-zig/src/primitives/Transaction/transaction_utils.zig new file mode 100644 index 000000000..31ced512a --- /dev/null +++ b/packages/voltaire-zig/src/primitives/Transaction/transaction_utils.zig @@ -0,0 +1,416 @@ +//! Transaction Builder Utilities +//! +//! Higher-level transaction operations built on top of the existing +//! Transaction types: signing for all tx types, sender recovery, +//! and generic hash computation. +//! +//! Uses eth.zig's secp256k1 for ECDSA recovery where the existing +//! crypto module's ecrecover path is not available. + +const std = @import("std"); +const Transaction = @import("Transaction.zig"); +const crypto_pkg = @import("crypto"); +const hash_mod = crypto_pkg.Hash; +const crypto = crypto_pkg.Crypto; +const eth = @import("eth_zig"); + +const Allocator = std.mem.Allocator; +const Hash = hash_mod.Hash; +const Address = Transaction.Address; +const Signature = crypto.Signature; + +/// Sign an EIP-1559 transaction with the given private key. +/// Returns a new transaction with y_parity, r, s fields populated. +pub fn signEip1559Transaction( + allocator: Allocator, + tx: Transaction.Eip1559Transaction, + private_key: crypto.PrivateKey, +) !Transaction.Eip1559Transaction { + // Encode unsigned transaction for signing + const unsigned_tx = Transaction.Eip1559Transaction{ + .chain_id = tx.chain_id, + .nonce = tx.nonce, + .max_priority_fee_per_gas = tx.max_priority_fee_per_gas, + .max_fee_per_gas = tx.max_fee_per_gas, + .gas_limit = tx.gas_limit, + .to = tx.to, + .value = tx.value, + .data = tx.data, + .access_list = tx.access_list, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const encoded = try Transaction.encodeEip1559ForSigning(allocator, unsigned_tx); + defer allocator.free(encoded); + + // Hash and sign + const h = hash_mod.keccak256(encoded); + const signature = try crypto.unaudited_signHash(h, private_key); + + // Return signed transaction + var signed_tx = tx; + signed_tx.y_parity = signature.yParity(); + std.mem.writeInt(u256, &signed_tx.r, signature.r, .big); + std.mem.writeInt(u256, &signed_tx.s, signature.s, .big); + + return signed_tx; +} + +/// Sign an EIP-4844 blob transaction with the given private key. +pub fn signEip4844Transaction( + allocator: Allocator, + tx: Transaction.Eip4844Transaction, + private_key: crypto.PrivateKey, +) !Transaction.Eip4844Transaction { + const unsigned_tx = Transaction.Eip4844Transaction{ + .chain_id = tx.chain_id, + .nonce = tx.nonce, + .max_priority_fee_per_gas = tx.max_priority_fee_per_gas, + .max_fee_per_gas = tx.max_fee_per_gas, + .gas_limit = tx.gas_limit, + .to = tx.to, + .value = tx.value, + .data = tx.data, + .access_list = tx.access_list, + .max_fee_per_blob_gas = tx.max_fee_per_blob_gas, + .blob_versioned_hashes = tx.blob_versioned_hashes, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const encoded = try Transaction.encodeEip4844ForSigning(allocator, unsigned_tx); + defer allocator.free(encoded); + + const h = hash_mod.keccak256(encoded); + const signature = try crypto.unaudited_signHash(h, private_key); + + var signed_tx = tx; + signed_tx.y_parity = signature.yParity(); + std.mem.writeInt(u256, &signed_tx.r, signature.r, .big); + std.mem.writeInt(u256, &signed_tx.s, signature.s, .big); + + return signed_tx; +} + +/// Sign an EIP-7702 transaction with the given private key. +pub fn signEip7702Transaction( + allocator: Allocator, + tx: Transaction.Eip7702Transaction, + private_key: crypto.PrivateKey, +) !Transaction.Eip7702Transaction { + const unsigned_tx = Transaction.Eip7702Transaction{ + .chain_id = tx.chain_id, + .nonce = tx.nonce, + .max_priority_fee_per_gas = tx.max_priority_fee_per_gas, + .max_fee_per_gas = tx.max_fee_per_gas, + .gas_limit = tx.gas_limit, + .to = tx.to, + .value = tx.value, + .data = tx.data, + .access_list = tx.access_list, + .authorization_list = tx.authorization_list, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const encoded = try Transaction.encodeEip7702ForSigning(allocator, unsigned_tx); + defer allocator.free(encoded); + + const h = hash_mod.keccak256(encoded); + const signature = try crypto.unaudited_signHash(h, private_key); + + var signed_tx = tx; + signed_tx.y_parity = signature.yParity(); + std.mem.writeInt(u256, &signed_tx.r, signature.r, .big); + std.mem.writeInt(u256, &signed_tx.s, signature.s, .big); + + return signed_tx; +} + +/// Recover the sender address from a signed legacy transaction. +/// Uses eth.zig's secp256k1 ecrecover for public key recovery. +pub fn recoverLegacySender( + allocator: Allocator, + tx: Transaction.LegacyTransaction, + chain_id: u64, +) !Address { + // Get the signing hash (unsigned payload) + const unsigned_tx = Transaction.LegacyTransaction{ + .nonce = tx.nonce, + .gas_price = tx.gas_price, + .gas_limit = tx.gas_limit, + .to = tx.to, + .value = tx.value, + .data = tx.data, + .v = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const encoded = try Transaction.encodeLegacyForSigning(allocator, unsigned_tx, chain_id); + defer allocator.free(encoded); + + const msg_hash = hash_mod.keccak256(encoded); + + // Recover the v value (EIP-155: v = recovery_id + chain_id * 2 + 35) + const recovery_id: u8 = @intCast(tx.v - (chain_id * 2) - 35); + + // Build eth.zig Signature for recovery + const sig = eth.signature.Signature{ + .r = tx.r, + .s = tx.s, + .v = recovery_id, + }; + + // Use eth.zig ecrecover + const addr_bytes = eth.secp256k1.recoverAddress(sig, msg_hash) catch return error.InvalidSignature; + + return Address{ .bytes = addr_bytes }; +} + +/// Recover the sender address from a signed EIP-1559 transaction. +pub fn recoverEip1559Sender( + allocator: Allocator, + tx: Transaction.Eip1559Transaction, +) !Address { + const unsigned_tx = Transaction.Eip1559Transaction{ + .chain_id = tx.chain_id, + .nonce = tx.nonce, + .max_priority_fee_per_gas = tx.max_priority_fee_per_gas, + .max_fee_per_gas = tx.max_fee_per_gas, + .gas_limit = tx.gas_limit, + .to = tx.to, + .value = tx.value, + .data = tx.data, + .access_list = tx.access_list, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const encoded = try Transaction.encodeEip1559ForSigning(allocator, unsigned_tx); + defer allocator.free(encoded); + + const msg_hash = hash_mod.keccak256(encoded); + + const sig = eth.signature.Signature{ + .r = tx.r, + .s = tx.s, + .v = tx.y_parity, + }; + + const addr_bytes = eth.secp256k1.recoverAddress(sig, msg_hash) catch return error.InvalidSignature; + + return Address{ .bytes = addr_bytes }; +} + +/// Detect the transaction type from raw encoded bytes. +pub const detectTransactionType = Transaction.detectTransactionType; + +// ============================================================================ +// Tests +// ============================================================================ + +test "signEip1559Transaction and recoverEip1559Sender roundtrip" { + const allocator = std.testing.allocator; + + // Use a known private key (Hardhat account #0) + const privkey: [32]u8 = [_]u8{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + + const empty_addr = Address{ .bytes = [_]u8{0x42} ** 20 }; + const tx = Transaction.Eip1559Transaction{ + .chain_id = 1, + .nonce = 0, + .max_priority_fee_per_gas = 2_000_000_000, + .max_fee_per_gas = 20_000_000_000, + .gas_limit = 21_000, + .to = empty_addr, + .value = 1_000_000_000_000_000_000, + .data = &[_]u8{}, + .access_list = &[_]Transaction.AccessListItem{}, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const signed = try signEip1559Transaction(allocator, tx, privkey); + + // r and s should be non-zero after signing + try std.testing.expect(!std.mem.eql(u8, &signed.r, &std.mem.zeroes([32]u8))); + try std.testing.expect(!std.mem.eql(u8, &signed.s, &std.mem.zeroes([32]u8))); + + // Recover sender + const recovered = try recoverEip1559Sender(allocator, signed); + + // Derive expected address from private key + const expected_addr = try crypto.unaudited_publicKeyToAddress( + try crypto.unaudited_generatePublicKey(privkey), + ); + + try std.testing.expectEqualSlices(u8, &expected_addr.bytes, &recovered.bytes); +} + +test "signEip4844Transaction produces valid signature" { + const allocator = std.testing.allocator; + + const privkey: [32]u8 = [_]u8{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + + const dest = Address{ .bytes = [_]u8{0x42} ** 20 }; + const blob_hash = [_]u8{0x01} ++ ([_]u8{0xaa} ** 31); + const hashes = [_][32]u8{blob_hash}; + + const tx = Transaction.Eip4844Transaction{ + .chain_id = 1, + .nonce = 5, + .max_priority_fee_per_gas = 1_000_000_000, + .max_fee_per_gas = 30_000_000_000, + .gas_limit = 21_000, + .to = dest, + .value = 0, + .data = &[_]u8{}, + .access_list = &[_]Transaction.AccessListItem{}, + .max_fee_per_blob_gas = 1_000_000, + .blob_versioned_hashes = &hashes, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const signed = try signEip4844Transaction(allocator, tx, privkey); + + // Signature fields must be populated + try std.testing.expect(!std.mem.eql(u8, &signed.r, &std.mem.zeroes([32]u8))); + try std.testing.expect(!std.mem.eql(u8, &signed.s, &std.mem.zeroes([32]u8))); + // y_parity must be 0 or 1 + try std.testing.expect(signed.y_parity == 0 or signed.y_parity == 1); + // Transaction fields must be preserved + try std.testing.expectEqual(@as(u64, 5), signed.nonce); + try std.testing.expectEqual(@as(u64, 1), signed.chain_id); +} + +test "signEip7702Transaction produces valid signature" { + const allocator = std.testing.allocator; + + const privkey: [32]u8 = [_]u8{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + + const dest = Address{ .bytes = [_]u8{0x42} ** 20 }; + + const tx = Transaction.Eip7702Transaction{ + .chain_id = 1, + .nonce = 10, + .max_priority_fee_per_gas = 2_000_000_000, + .max_fee_per_gas = 50_000_000_000, + .gas_limit = 100_000, + .to = dest, + .value = 0, + .data = &[_]u8{0xde, 0xad, 0xbe, 0xef}, + .access_list = &[_]Transaction.AccessListItem{}, + .authorization_list = &[_]Transaction.Authorization{}, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const signed = try signEip7702Transaction(allocator, tx, privkey); + + try std.testing.expect(!std.mem.eql(u8, &signed.r, &std.mem.zeroes([32]u8))); + try std.testing.expect(!std.mem.eql(u8, &signed.s, &std.mem.zeroes([32]u8))); + try std.testing.expect(signed.y_parity == 0 or signed.y_parity == 1); + try std.testing.expectEqual(@as(u64, 10), signed.nonce); +} + +test "different private keys produce different signatures" { + const allocator = std.testing.allocator; + + const privkey1: [32]u8 = [_]u8{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + const privkey2: [32]u8 = [_]u8{ + 0xde, 0xad, 0xbe, 0xef, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + + const dest = Address{ .bytes = [_]u8{0x42} ** 20 }; + const tx = Transaction.Eip1559Transaction{ + .chain_id = 1, + .nonce = 0, + .max_priority_fee_per_gas = 2_000_000_000, + .max_fee_per_gas = 20_000_000_000, + .gas_limit = 21_000, + .to = dest, + .value = 1_000_000_000_000_000_000, + .data = &[_]u8{}, + .access_list = &[_]Transaction.AccessListItem{}, + .y_parity = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const signed1 = try signEip1559Transaction(allocator, tx, privkey1); + const signed2 = try signEip1559Transaction(allocator, tx, privkey2); + + // Different keys must produce different r values + try std.testing.expect(!std.mem.eql(u8, &signed1.r, &signed2.r)); + + // Recovered senders must differ + const addr1 = try recoverEip1559Sender(allocator, signed1); + const addr2 = try recoverEip1559Sender(allocator, signed2); + try std.testing.expect(!std.mem.eql(u8, &addr1.bytes, &addr2.bytes)); +} + +test "signLegacyTransaction and recoverLegacySender roundtrip" { + const allocator = std.testing.allocator; + + const privkey: [32]u8 = [_]u8{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfb, + 0xd7, 0xa0, 0x1c, 0xab, 0xfe, 0x8c, 0x2f, 0x0e, + }; + + const empty_addr = Address{ .bytes = [_]u8{0x42} ** 20 }; + const tx = Transaction.LegacyTransaction{ + .nonce = 0, + .gas_price = 20_000_000_000, + .gas_limit = 21_000, + .to = empty_addr, + .value = 1_000_000_000_000_000_000, + .data = &[_]u8{}, + .v = 0, + .r = std.mem.zeroes([32]u8), + .s = std.mem.zeroes([32]u8), + }; + + const signed = try Transaction.signLegacyTransaction(allocator, tx, privkey, 1); + const recovered = try recoverLegacySender(allocator, signed, 1); + + const expected_addr = try crypto.unaudited_publicKeyToAddress( + try crypto.unaudited_generatePublicKey(privkey), + ); + + try std.testing.expectEqualSlices(u8, &expected_addr.bytes, &recovered.bytes); +} diff --git a/packages/voltaire-zig/src/primitives/root.zig b/packages/voltaire-zig/src/primitives/root.zig index a4975c9d4..1c6412987 100644 --- a/packages/voltaire-zig/src/primitives/root.zig +++ b/packages/voltaire-zig/src/primitives/root.zig @@ -336,15 +336,6 @@ pub const ZERO_ADDRESS = Address.ZERO_ADDRESS; pub const EMPTY_CODE_HASH = State.EMPTY_CODE_HASH; pub const EMPTY_TRIE_ROOT = State.EMPTY_TRIE_ROOT; -// Light client primitives -pub const ConsensusSpec = @import("ConsensusSpec/ConsensusSpec.zig"); -pub const ForkConfig = @import("ForkConfig/ForkConfig.zig"); -pub const LightClientUpdate = @import("LightClientUpdate/LightClientUpdate.zig"); -pub const LightClientHeader = @import("LightClientHeader/LightClientHeader.zig"); -pub const SyncCommittee = @import("SyncCommittee/SyncCommittee.zig"); -pub const SyncAggregate = @import("SyncAggregate/SyncAggregate.zig"); -pub const consensus = @import("consensus/consensus.zig"); - // Expose crypto package for primitives submodules that need hashing // Enables imports via `@import("root").crypto` within this package pub const crypto = @import("crypto");