diff --git a/src/abi_decode.zig b/src/abi_decode.zig index 672ff0d..fce7d5c 100644 --- a/src/abi_decode.zig +++ b/src/abi_decode.zig @@ -632,3 +632,181 @@ test "decode two dynamic strings" { try testing.expectEqualSlices(u8, "abc", decoded[0].string); try testing.expectEqualSlices(u8, "def", decoded[1].string); } + +test "encode-decode roundtrip fixed_bytes1" { + const allocator = testing.allocator; + const encode_mod = @import("abi_encode.zig"); + + var fb = AbiValue.FixedBytes{ .len = 1 }; + fb.data[0] = 0xAB; + + const original = [_]AbiValue{ + .{ .fixed_bytes = fb }, + }; + + const encoded = try encode_mod.encodeValues(allocator, &original); + defer allocator.free(encoded); + + const types = [_]AbiType{.bytes1}; + const decoded = try decodeValues(encoded, &types, allocator); + defer freeValues(decoded, allocator); + + try testing.expectEqual(@as(u8, 1), decoded[0].fixed_bytes.len); + try testing.expectEqual(@as(u8, 0xAB), decoded[0].fixed_bytes.data[0]); +} + +test "encode-decode roundtrip fixed_bytes16" { + const allocator = testing.allocator; + const encode_mod = @import("abi_encode.zig"); + + var fb = AbiValue.FixedBytes{ .len = 16 }; + for (0..16) |i| { + fb.data[i] = 0xCC; + } + + const original = [_]AbiValue{ + .{ .fixed_bytes = fb }, + }; + + const encoded = try encode_mod.encodeValues(allocator, &original); + defer allocator.free(encoded); + + const types = [_]AbiType{.bytes16}; + const decoded = try decodeValues(encoded, &types, allocator); + defer freeValues(decoded, allocator); + + try testing.expectEqual(@as(u8, 16), decoded[0].fixed_bytes.len); + for (0..16) |i| { + try testing.expectEqual(@as(u8, 0xCC), decoded[0].fixed_bytes.data[i]); + } +} + +test "decode large dynamic bytes 100 bytes" { + const allocator = testing.allocator; + + // Word 0: offset = 0x20 (32) + // Word 1: length = 100 (0x64) + // Words 2-5: 100 bytes of 0xAA + 28 bytes zero padding = 128 bytes + // Total: 32 + 32 + 128 = 192 bytes + var data: [192]u8 = [_]u8{0} ** 192; + data[31] = 0x20; // offset = 32 + data[63] = 0x64; // length = 100 + for (64..164) |i| { + data[i] = 0xAA; + } + + const types = [_]AbiType{.bytes}; + const values = try decodeValues(&data, &types, allocator); + defer freeValues(values, allocator); + + try testing.expectEqual(@as(usize, 100), values[0].bytes.len); + for (values[0].bytes) |b| { + try testing.expectEqual(@as(u8, 0xAA), b); + } +} + +test "decode large string 128 bytes" { + const allocator = testing.allocator; + + // Word 0: offset = 0x20 (32) + // Word 1: length = 128 (0x80) + // Words 2-5: 128 bytes of 'B' (0x42), exactly 4 words, no padding needed + // Total: 32 + 32 + 128 = 192 bytes + var data: [192]u8 = [_]u8{0} ** 192; + data[31] = 0x20; // offset = 32 + data[62] = 0x00; + data[63] = 0x80; // length = 128 + for (64..192) |i| { + data[i] = 0x42; // 'B' + } + + const types = [_]AbiType{.string}; + const values = try decodeValues(&data, &types, allocator); + defer freeValues(values, allocator); + + try testing.expectEqual(@as(usize, 128), values[0].string.len); + for (values[0].string) |b| { + try testing.expectEqual(@as(u8, 'B'), b); + } +} + +test "decode offset out of bounds" { + const allocator = testing.allocator; + + // 64-byte buffer where word 0 has offset pointing to position 200 + var data: [64]u8 = [_]u8{0} ** 64; + data[31] = 200; // offset = 200 (way past end of 64-byte buffer) + + const types = [_]AbiType{.bytes}; + const result = decodeValues(&data, &types, allocator); + try testing.expectError(error.OffsetOutOfBounds, result); +} + +test "decode length out of bounds" { + const allocator = testing.allocator; + + // 96-byte buffer: word 0 = offset 0x20, word 1 = length 9999 + var data: [96]u8 = [_]u8{0} ** 96; + data[31] = 0x20; // offset = 32 + data[62] = 0x27; // 9999 = 0x270F + data[63] = 0x0F; + + const types = [_]AbiType{.bytes}; + const result = decodeValues(&data, &types, allocator); + try testing.expectError(error.LengthOutOfBounds, result); +} + +test "decode bytes exact 32-byte alignment" { + const allocator = testing.allocator; + + // 64 bytes of data = exactly 2 words, no padding needed + // Word 0: offset = 0x20, Word 1: length = 64, Words 2-3: 64 bytes of 0xBB + // Total: 32 + 32 + 64 = 128 bytes + var data: [128]u8 = [_]u8{0} ** 128; + data[31] = 0x20; // offset = 32 + data[63] = 0x40; // length = 64 + for (64..128) |i| { + data[i] = 0xBB; + } + + const types = [_]AbiType{.bytes}; + const values = try decodeValues(&data, &types, allocator); + defer freeValues(values, allocator); + + try testing.expectEqual(@as(usize, 64), values[0].bytes.len); + for (values[0].bytes) |b| { + try testing.expectEqual(@as(u8, 0xBB), b); + } +} + +test "encode-decode ERC20 transfer return bool true" { + const allocator = testing.allocator; + const encode_mod = @import("abi_encode.zig"); + + // Encode bool(true), decode, verify + const original_true = [_]AbiValue{ + .{ .boolean = true }, + }; + + const encoded_true = try encode_mod.encodeValues(allocator, &original_true); + defer allocator.free(encoded_true); + + const types = [_]AbiType{.bool}; + const decoded_true = try decodeValues(encoded_true, &types, allocator); + defer freeValues(decoded_true, allocator); + + try testing.expect(decoded_true[0].boolean); + + // Encode bool(false), decode, verify + const original_false = [_]AbiValue{ + .{ .boolean = false }, + }; + + const encoded_false = try encode_mod.encodeValues(allocator, &original_false); + defer allocator.free(encoded_false); + + const decoded_false = try decodeValues(encoded_false, &types, allocator); + defer freeValues(decoded_false, allocator); + + try testing.expect(!decoded_false[0].boolean); +} diff --git a/src/abi_encode.zig b/src/abi_encode.zig index f90459c..9c29862 100644 --- a/src/abi_encode.zig +++ b/src/abi_encode.zig @@ -900,3 +900,292 @@ test "encodeFunctionCall - balanceOf(address)" { try testing.expectEqual(@as(usize, 36), encoded.len); try testing.expectEqualSlices(u8, &.{ 0x70, 0xa0, 0x82, 0x31 }, encoded[0..4]); } + +test "encode fixed_bytes1" { + const allocator = testing.allocator; + var fb = AbiValue.FixedBytes{ .len = 1 }; + fb.data[0] = 0xAB; + + const values = [_]AbiValue{.{ .fixed_bytes = fb }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + try testing.expectEqual(@as(usize, 32), encoded.len); + try testing.expectEqual(@as(u8, 0xAB), encoded[0]); + for (encoded[1..32]) |b| { + try testing.expectEqual(@as(u8, 0), b); + } +} + +test "encode fixed_bytes16" { + const allocator = testing.allocator; + var fb = AbiValue.FixedBytes{ .len = 16 }; + @memset(fb.data[0..16], 0xCC); + + const values = [_]AbiValue{.{ .fixed_bytes = fb }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + try testing.expectEqual(@as(usize, 32), encoded.len); + for (encoded[0..16]) |b| { + try testing.expectEqual(@as(u8, 0xCC), b); + } + for (encoded[16..32]) |b| { + try testing.expectEqual(@as(u8, 0), b); + } +} + +test "encode fixed_bytes31" { + const allocator = testing.allocator; + var fb = AbiValue.FixedBytes{ .len = 31 }; + @memset(fb.data[0..31], 0xDD); + + const values = [_]AbiValue{.{ .fixed_bytes = fb }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + try testing.expectEqual(@as(usize, 32), encoded.len); + for (encoded[0..31]) |b| { + try testing.expectEqual(@as(u8, 0xDD), b); + } + // Byte 31 is right-padded with zero + try testing.expectEqual(@as(u8, 0x00), encoded[31]); +} + +test "encode fixed_array static uint256[3]" { + const allocator = testing.allocator; + const items = [_]AbiValue{ + .{ .uint256 = 10 }, + .{ .uint256 = 20 }, + .{ .uint256 = 30 }, + }; + const values = [_]AbiValue{.{ .fixed_array = &items }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + // All elements are static, so encoding is inline: 3 * 32 = 96 bytes + try testing.expectEqual(@as(usize, 96), encoded.len); + // Value 10 = 0x0a at byte 31 + try testing.expectEqual(@as(u8, 0x0a), encoded[31]); + // Value 20 = 0x14 at byte 63 + try testing.expectEqual(@as(u8, 0x14), encoded[63]); + // Value 30 = 0x1e at byte 95 + try testing.expectEqual(@as(u8, 0x1e), encoded[95]); +} + +test "encode Solidity spec baz(uint32,bool)" { + const allocator = testing.allocator; + const keccak_mod = @import("keccak.zig"); + const sel = keccak_mod.selector("baz(uint32,bool)"); + + // Verify the selector matches the Solidity ABI spec + try testing.expectEqualSlices(u8, &.{ 0xcd, 0xcd, 0x77, 0xc0 }, &sel); + + const values = [_]AbiValue{ + .{ .uint256 = 69 }, + .{ .boolean = true }, + }; + const encoded = try encodeFunctionCall(allocator, sel, &values); + defer allocator.free(encoded); + + // 4 bytes selector + 2 * 32 bytes args = 68 bytes + try testing.expectEqual(@as(usize, 68), encoded.len); + + // Selector + try testing.expectEqualSlices(u8, &.{ 0xcd, 0xcd, 0x77, 0xc0 }, encoded[0..4]); + + // uint32(69) = 0x45 at byte 4+31 = 35 + try testing.expectEqual(@as(u8, 0x45), encoded[35]); + + // bool(true) = 0x01 at byte 4+63 = 67 + try testing.expectEqual(@as(u8, 0x01), encoded[67]); +} + +test "encode Solidity spec sam(bytes,bool,uint256[])" { + const allocator = testing.allocator; + + // Encode the arguments of sam("dave", true, [1,2,3]) using encodeValues + const array_items = [_]AbiValue{ + .{ .uint256 = 1 }, + .{ .uint256 = 2 }, + .{ .uint256 = 3 }, + }; + const values = [_]AbiValue{ + .{ .bytes = "dave" }, + .{ .boolean = true }, + .{ .array = &array_items }, + }; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + // Head: 3 words (96 bytes) - offset_bytes, bool, offset_array + // Tail for bytes("dave"): 32 (length=4) + 32 (data padded) = 64 bytes + // Tail for array([1,2,3]): 32 (length=3) + 3*32 (elements) = 128 bytes + // Total: 96 + 64 + 128 = 288 bytes + try testing.expectEqual(@as(usize, 288), encoded.len); + + // Head word 0: offset to bytes tail = 96 = 0x60 + try testing.expectEqual(@as(u8, 0x60), encoded[31]); + + // Head word 1: bool(true) = 0x01 + try testing.expectEqual(@as(u8, 0x01), encoded[63]); + + // Head word 2: offset to array tail = 96 + 64 = 160 = 0xa0 + try testing.expectEqual(@as(u8, 0xa0), encoded[95]); + + // Tail for bytes: length = 4 at offset 96 + try testing.expectEqual(@as(u8, 0x04), encoded[127]); + + // Tail for bytes: "dave" at offset 128 + try testing.expectEqualSlices(u8, "dave", encoded[128..132]); + + // Tail for array: length = 3 at offset 160 + try testing.expectEqual(@as(u8, 0x03), encoded[191]); + + // Array elements: 1, 2, 3 + try testing.expectEqual(@as(u8, 0x01), encoded[223]); + try testing.expectEqual(@as(u8, 0x02), encoded[255]); + try testing.expectEqual(@as(u8, 0x03), encoded[287]); +} + +test "encode ERC20 approve exact bytes" { + const allocator = testing.allocator; + const keccak_mod = @import("keccak.zig"); + const sel = keccak_mod.selector("approve(address,uint256)"); + + // approve selector should be 0x095ea7b3 + try testing.expectEqualSlices(u8, &.{ 0x09, 0x5e, 0xa7, 0xb3 }, &sel); + + // UniswapV3 router address + const spender = [20]u8{ + 0x68, 0xb3, 0x46, 0x58, 0x33, 0xfb, 0x72, 0xA7, 0x0e, 0xcD, + 0xF4, 0x85, 0xE0, 0xe4, 0xC7, 0xbD, 0x86, 0x65, 0xFc, 0x45, + }; + + // MAX u256 (all 0xff) + const max_u256: u256 = std.math.maxInt(u256); + + const values = [_]AbiValue{ + .{ .address = spender }, + .{ .uint256 = max_u256 }, + }; + const encoded = try encodeFunctionCall(allocator, sel, &values); + defer allocator.free(encoded); + + // 4 bytes selector + 2 * 32 bytes = 68 bytes + try testing.expectEqual(@as(usize, 68), encoded.len); + + // Verify selector + try testing.expectEqualSlices(u8, &.{ 0x09, 0x5e, 0xa7, 0xb3 }, encoded[0..4]); + + // Amount word (bytes 36..68) should be all 0xff + for (encoded[36..68]) |b| { + try testing.expectEqual(@as(u8, 0xff), b); + } +} + +test "encode large string 100 bytes" { + const allocator = testing.allocator; + const data = [_]u8{'A'} ** 100; + const values = [_]AbiValue{.{ .string = &data }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + // Head: 1 offset word (32 bytes) = 0x20 + // Tail: length word (32 bytes) + data (100 bytes) + padding ((32 - 100%32)%32 = 28 bytes) = 160 bytes + // Total: 32 + 32 + 128 = 192 bytes + try testing.expectEqual(@as(usize, 192), encoded.len); + + // Offset = 32 = 0x20 + try testing.expectEqual(@as(u8, 0x20), encoded[31]); + + // Length = 100 = 0x64 + try testing.expectEqual(@as(u8, 0x64), encoded[63]); + + // Data: 100 'A' bytes starting at offset 64 + for (encoded[64..164]) |b| { + try testing.expectEqual(@as(u8, 'A'), b); + } + + // Padding: 28 zero bytes + for (encoded[164..192]) |b| { + try testing.expectEqual(@as(u8, 0), b); + } +} + +test "encode int256 negative -128" { + const allocator = testing.allocator; + const values = [_]AbiValue{.{ .int256 = -128 }}; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + try testing.expectEqual(@as(usize, 32), encoded.len); + // -128 in two's complement is 0xFF...FF80 + for (encoded[0..31]) |b| { + try testing.expectEqual(@as(u8, 0xFF), b); + } + try testing.expectEqual(@as(u8, 0x80), encoded[31]); +} + +test "encodeFunctionCall totalSupply no args" { + const allocator = testing.allocator; + const keccak_mod = @import("keccak.zig"); + const sel = keccak_mod.selector("totalSupply()"); + + // totalSupply() selector should be 0x18160ddd + try testing.expectEqualSlices(u8, &.{ 0x18, 0x16, 0x0d, 0xdd }, &sel); + + const values = [_]AbiValue{}; + const encoded = try encodeFunctionCall(allocator, sel, &values); + defer allocator.free(encoded); + + // No arguments, so result is just the 4-byte selector + try testing.expectEqual(@as(usize, 4), encoded.len); + try testing.expectEqualSlices(u8, &.{ 0x18, 0x16, 0x0d, 0xdd }, encoded[0..4]); +} + +test "encode two dynamic arrays different lengths" { + const allocator = testing.allocator; + const items1 = [_]AbiValue{ + .{ .uint256 = 1 }, + .{ .uint256 = 2 }, + }; + const items2 = [_]AbiValue{ + .{ .uint256 = 3 }, + .{ .uint256 = 4 }, + .{ .uint256 = 5 }, + }; + const values = [_]AbiValue{ + .{ .array = &items1 }, + .{ .array = &items2 }, + }; + const encoded = try encodeValues(allocator, &values); + defer allocator.free(encoded); + + // Head: 2 offset words = 64 bytes + // Tail for array1: 32 (length=2) + 2*32 (elements) = 96 bytes + // Tail for array2: 32 (length=3) + 3*32 (elements) = 128 bytes + // Total: 64 + 96 + 128 = 288 bytes + try testing.expectEqual(@as(usize, 288), encoded.len); + + // First offset = 64 = 0x40 + try testing.expectEqual(@as(u8, 0x40), encoded[31]); + + // Second offset = 64 + 96 = 160 = 0xa0 + try testing.expectEqual(@as(u8, 0xa0), encoded[63]); + + // First array length = 2 + try testing.expectEqual(@as(u8, 0x02), encoded[95]); + + // First array elements: 1, 2 + try testing.expectEqual(@as(u8, 0x01), encoded[127]); + try testing.expectEqual(@as(u8, 0x02), encoded[159]); + + // Second array length = 3 + try testing.expectEqual(@as(u8, 0x03), encoded[191]); + + // Second array elements: 3, 4, 5 + try testing.expectEqual(@as(u8, 0x03), encoded[223]); + try testing.expectEqual(@as(u8, 0x04), encoded[255]); + try testing.expectEqual(@as(u8, 0x05), encoded[287]); +} diff --git a/src/eip712.zig b/src/eip712.zig index d13fb00..4f33ef8 100644 --- a/src/eip712.zig +++ b/src/eip712.zig @@ -1000,3 +1000,207 @@ test "hashTypedData - verify domain and struct hashes independently" { const expected_final = try hex.hexToBytesFixed(32, "be609aee343fb3c4b28e1df9e632fca64fcfaede20f02e86244efddf30957bd2"); try testing.expectEqualSlices(u8, &expected_final, &manual_final); } + +test "ERC20 Permit typed data hash" { + const allocator = testing.allocator; + + const domain = DomainSeparator{ + .name = "MyToken", + .version = "1", + .chain_id = 1, + .verifying_contract = try hex.hexToBytesFixed(20, "0x0000000000000000000000000000000000000001"), + }; + + const permit_type = TypeDef{ + .name = "Permit", + .fields = &.{ + .{ .name = "owner", .type_str = "address" }, + .{ .name = "spender", .type_str = "address" }, + .{ .name = "value", .type_str = "uint256" }, + .{ .name = "nonce", .type_str = "uint256" }, + .{ .name = "deadline", .type_str = "uint256" }, + }, + }; + + const type_defs = [_]TypeDef{permit_type}; + + const max_uint256: u256 = std.math.maxInt(u256); + + const permit_msg = StructValue{ + .type_name = "Permit", + .fields = &.{ + .{ + .name = "owner", + .type_str = "address", + .value = .{ .address = try hex.hexToBytesFixed(20, "0x0000000000000000000000000000000000000001") }, + }, + .{ + .name = "spender", + .type_str = "address", + .value = .{ .address = try hex.hexToBytesFixed(20, "0x0000000000000000000000000000000000000002") }, + }, + .{ + .name = "value", + .type_str = "uint256", + .value = .{ .uint256 = 1000000000000000000 }, + }, + .{ + .name = "nonce", + .type_str = "uint256", + .value = .{ .uint256 = 0 }, + }, + .{ + .name = "deadline", + .type_str = "uint256", + .value = .{ .uint256 = max_uint256 }, + }, + }, + }; + + const hash1 = try hashTypedData(allocator, domain, permit_msg, &type_defs); + const hash2 = try hashTypedData(allocator, domain, permit_msg, &type_defs); + + // Verify deterministic 32-byte result + try testing.expectEqual(@as(usize, 32), hash1.len); + try testing.expectEqualSlices(u8, &hash1, &hash2); +} + +test "hashDomain with all 5 fields" { + const allocator = testing.allocator; + + // Verify the encodeType string for EIP712Domain with all 5 fields + const domain_fields = [_]FieldDef{ + .{ .name = "name", .type_str = "string" }, + .{ .name = "version", .type_str = "string" }, + .{ .name = "chainId", .type_str = "uint256" }, + .{ .name = "verifyingContract", .type_str = "address" }, + .{ .name = "salt", .type_str = "bytes32" }, + }; + + const type_str = try encodeType(allocator, "EIP712Domain", &domain_fields, &.{}); + defer allocator.free(type_str); + + try testing.expectEqualStrings( + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract,bytes32 salt)", + type_str, + ); + + // Now hash the domain with all 5 fields populated + const domain = DomainSeparator{ + .name = "Test App", + .version = "2", + .chain_id = 137, + .verifying_contract = try hex.hexToBytesFixed(20, "0x1234567890123456789012345678901234567890"), + .salt = [_]u8{0xAB} ** 32, + }; + + const hash1 = try hashDomain(allocator, domain); + const hash2 = try hashDomain(allocator, domain); + + // Verify deterministic 32-byte result + try testing.expectEqual(@as(usize, 32), hash1.len); + try testing.expectEqualSlices(u8, &hash1, &hash2); +} + +test "hashDomain with only chainId" { + const allocator = testing.allocator; + + // Verify the encodeType string for EIP712Domain with only chainId + const domain_fields = [_]FieldDef{ + .{ .name = "chainId", .type_str = "uint256" }, + }; + + const type_str = try encodeType(allocator, "EIP712Domain", &domain_fields, &.{}); + defer allocator.free(type_str); + + try testing.expectEqualStrings( + "EIP712Domain(uint256 chainId)", + type_str, + ); + + // Hash the domain with only chain_id set + const domain = DomainSeparator{ + .chain_id = 1, + }; + + const hash1 = try hashDomain(allocator, domain); + const hash2 = try hashDomain(allocator, domain); + + // Verify deterministic 32-byte result + try testing.expectEqual(@as(usize, 32), hash1.len); + try testing.expectEqualSlices(u8, &hash1, &hash2); +} + +test "Permit2 PermitSingle nested struct" { + const allocator = testing.allocator; + + const token_permissions_type = TypeDef{ + .name = "TokenPermissions", + .fields = &.{ + .{ .name = "token", .type_str = "address" }, + .{ .name = "amount", .type_str = "uint256" }, + }, + }; + + const permit_single_type = TypeDef{ + .name = "PermitSingle", + .fields = &.{ + .{ .name = "details", .type_str = "TokenPermissions" }, + .{ .name = "spender", .type_str = "address" }, + .{ .name = "sigDeadline", .type_str = "uint256" }, + }, + }; + + const type_defs = [_]TypeDef{ permit_single_type, token_permissions_type }; + + const domain = DomainSeparator{ + .name = "Permit2", + .chain_id = 1, + }; + + const max_uint256: u256 = std.math.maxInt(u256); + + const details = StructValue{ + .type_name = "TokenPermissions", + .fields = &.{ + .{ + .name = "token", + .type_str = "address", + .value = .{ .address = try hex.hexToBytesFixed(20, "0x0000000000000000000000000000000000000001") }, + }, + .{ + .name = "amount", + .type_str = "uint256", + .value = .{ .uint256 = 1000 }, + }, + }, + }; + + const permit_single = StructValue{ + .type_name = "PermitSingle", + .fields = &.{ + .{ + .name = "details", + .type_str = "TokenPermissions", + .value = .{ .struct_val = details }, + }, + .{ + .name = "spender", + .type_str = "address", + .value = .{ .address = try hex.hexToBytesFixed(20, "0x0000000000000000000000000000000000000002") }, + }, + .{ + .name = "sigDeadline", + .type_str = "uint256", + .value = .{ .uint256 = max_uint256 }, + }, + }, + }; + + const hash1 = try hashTypedData(allocator, domain, permit_single, &type_defs); + const hash2 = try hashTypedData(allocator, domain, permit_single, &type_defs); + + // Verify deterministic 32-byte result + try testing.expectEqual(@as(usize, 32), hash1.len); + try testing.expectEqualSlices(u8, &hash1, &hash2); +} diff --git a/src/ens/namehash.zig b/src/ens/namehash.zig index 1af62e9..44f1a7f 100644 --- a/src/ens/namehash.zig +++ b/src/ens/namehash.zig @@ -134,3 +134,29 @@ test "namehash consistency - foo.eth decomposed" { const result = namehash("foo.eth"); try std.testing.expectEqualSlices(u8, &expected, &result); } + +test "namehash vitalik.eth official" { + const result = namehash("vitalik.eth"); + const expected = try hex.hexToBytesFixed(32, "ee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835"); + try std.testing.expectEqualSlices(u8, &expected, &result); +} + +test "namehash resolver.eth" { + // Manually compute: node starts as zeros, then hash "eth", then hash "resolver" + var node: [32]u8 = [_]u8{0} ** 32; + const eth_label = keccak.hash("eth"); + node = keccak.hashConcat(&.{ &node, ð_label }); + const resolver_label = keccak.hash("resolver"); + node = keccak.hashConcat(&.{ &node, &resolver_label }); + + const result = namehash("resolver.eth"); + try std.testing.expectEqualSlices(u8, &node, &result); +} + +test "namehash trailing dot handling" { + // A trailing dot produces an empty label at the end which gets skipped + // by the `if (label.len > 0)` check, so "foo.eth." should equal "foo.eth" + const with_dot = namehash("foo.eth."); + const without_dot = namehash("foo.eth"); + try std.testing.expectEqualSlices(u8, &without_dot, &with_dot); +} diff --git a/src/hd_wallet.zig b/src/hd_wallet.zig index 3e314a9..50d3944 100644 --- a/src/hd_wallet.zig +++ b/src/hd_wallet.zig @@ -226,3 +226,81 @@ test "known BIP-39 mnemonic to address" { const addr2 = key2.toAddress(); try std.testing.expectEqualSlices(u8, &addr, &addr2); } + +test "BIP-32 master key from known seed" { + const hex_mod = @import("hex.zig"); + // Known 64-byte seed (first 32 bytes from hex, rest zero) + var seed: [64]u8 = [_]u8{0} ** 64; + const first_half = try hex_mod.hexToBytesFixed(32, "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + @memcpy(seed[0..32], &first_half); + + const master = try masterKeyFromSeed(seed); + // Master key should be non-zero + var key_nonzero = false; + for (master.key) |b| if (b != 0) { + key_nonzero = true; + break; + }; + try std.testing.expect(key_nonzero); + + // Chain code should be non-zero + var cc_nonzero = false; + for (master.chain_code) |b| if (b != 0) { + cc_nonzero = true; + break; + }; + try std.testing.expect(cc_nonzero); + + // Deterministic + const master2 = try masterKeyFromSeed(seed); + try std.testing.expectEqualSlices(u8, &master.key, &master2.key); + try std.testing.expectEqualSlices(u8, &master.chain_code, &master2.chain_code); +} + +test "known mnemonic abandon...about to exact address" { + const mnemonic_mod = @import("mnemonic.zig"); + const hex_mod = @import("hex.zig"); + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed = try mnemonic_mod.toSeed(&words, ""); + const key = try deriveEthAccount(seed, 0); + const addr = key.toAddress(); + const addr_hex = primitives.addressToChecksum(&addr); + // Well-known address for this mnemonic + const expected = try hex_mod.hexToBytesFixed(20, "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"); + try std.testing.expectEqualSlices(u8, &expected, &addr); + _ = addr_hex; +} + +test "BIP-44 second account index produces different key and address" { + const mnemonic_mod = @import("mnemonic.zig"); + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed = try mnemonic_mod.toSeed(&words, ""); + const key0 = try deriveEthAccount(seed, 0); + const key1 = try deriveEthAccount(seed, 1); + // Keys must differ + try std.testing.expect(!std.mem.eql(u8, &key0.key, &key1.key)); + // Addresses must differ + const addr0 = key0.toAddress(); + const addr1 = key1.toAddress(); + try std.testing.expect(!std.mem.eql(u8, &addr0, &addr1)); +} + +test "deriveEthAccount key bytes deterministic" { + var seed: [64]u8 = undefined; + for (&seed, 0..) |*b, i| b.* = @intCast(i % 256); + const key1 = try deriveEthAccount(seed, 0); + const key2 = try deriveEthAccount(seed, 0); + try std.testing.expectEqualSlices(u8, &key1.key, &key2.key); + try std.testing.expectEqualSlices(u8, &key1.chain_code, &key2.chain_code); + const addr1 = key1.toAddress(); + const addr2 = key2.toAddress(); + try std.testing.expectEqualSlices(u8, &addr1, &addr2); +} diff --git a/src/hex.zig b/src/hex.zig index 8798f3a..46b17c7 100644 --- a/src/hex.zig +++ b/src/hex.zig @@ -160,3 +160,36 @@ test "isValidHex" { try std.testing.expect(!isValidHex("0xdea")); // odd try std.testing.expect(!isValidHex("0xgg")); // invalid chars } + +test "hexToBytes uppercase input" { + var buf: [4]u8 = undefined; + const result = try hexToBytes(&buf, "DEADBEEF"); + try std.testing.expectEqualSlices(u8, &.{ 0xde, 0xad, 0xbe, 0xef }, result); +} + +test "hexToBytes mixed case input" { + var buf: [4]u8 = undefined; + const result = try hexToBytes(&buf, "DeAdBeEf"); + try std.testing.expectEqualSlices(u8, &.{ 0xde, 0xad, 0xbe, 0xef }, result); +} + +test "bytesToHex hexToBytes 256-byte roundtrip" { + const allocator = std.testing.allocator; + + var original: [256]u8 = undefined; + for (0..256) |i| { + original[i] = @intCast(i); + } + + const hex_result = try bytesToHex(allocator, &original); + defer allocator.free(hex_result); + + var decoded: [256]u8 = undefined; + const decoded_slice = try hexToBytes(&decoded, hex_result); + try std.testing.expectEqualSlices(u8, &original, decoded_slice); +} + +test "hexToBytesFixed 0X uppercase prefix" { + const result = try hexToBytesFixed(4, "0XDEADBEEF"); + try std.testing.expectEqualSlices(u8, &.{ 0xde, 0xad, 0xbe, 0xef }, &result); +} diff --git a/src/keccak.zig b/src/keccak.zig index cca3758..dddf8ec 100644 --- a/src/keccak.zig +++ b/src/keccak.zig @@ -82,3 +82,53 @@ test "comptime hash equals runtime hash" { const runtime_result = hash("test"); try std.testing.expectEqualSlices(u8, &comptime_result, &runtime_result); } + +test "keccak256 abc" { + const result = hash("abc"); + const hex = @import("hex.zig"); + const expected = try hex.hexToBytesFixed(32, "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"); + try std.testing.expectEqualSlices(u8, &expected, &result); +} + +test "keccak256 testing" { + const result = hash("testing"); + const hex = @import("hex.zig"); + const expected = try hex.hexToBytesFixed(32, "5f16f4c7f149ac4f9510d9cf8cf384038ad348b3bcdc01915f95de12df9d1b02"); + try std.testing.expectEqualSlices(u8, &expected, &result); +} + +test "keccak256 256 zero bytes" { + const zero_bytes = [_]u8{0} ** 256; + const result1 = hash(&zero_bytes); + const result2 = hash(&zero_bytes); + // Verify 32-byte output + try std.testing.expectEqual(@as(usize, 32), result1.len); + // Verify determinism + try std.testing.expectEqualSlices(u8, &result1, &result2); +} + +test "keccak256 4KB input" { + const big_input = [_]u8{0x42} ** 4096; + const result1 = hash(&big_input); + const result2 = hash(&big_input); + // Verify determinism + try std.testing.expectEqualSlices(u8, &result1, &result2); +} + +test "keccak256 is not SHA3-256" { + const hex = @import("hex.zig"); + const result = hash(""); + // Keccak-256 of empty string + const keccak_expected = try hex.hexToBytesFixed(32, "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"); + try std.testing.expectEqualSlices(u8, &keccak_expected, &result); + // SHA3-256 of empty string (must NOT match) + const sha3_expected = try hex.hexToBytesFixed(32, "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"); + try std.testing.expect(!std.mem.eql(u8, &sha3_expected, &result)); +} + +test "DeFi function selectors" { + try std.testing.expectEqualSlices(u8, &[_]u8{ 0x09, 0x5e, 0xa7, 0xb3 }, &selector("approve(address,uint256)")); + try std.testing.expectEqualSlices(u8, &[_]u8{ 0x23, 0xb8, 0x72, 0xdd }, &selector("transferFrom(address,address,uint256)")); + try std.testing.expectEqualSlices(u8, &[_]u8{ 0x18, 0x16, 0x0d, 0xdd }, &selector("totalSupply()")); + try std.testing.expectEqualSlices(u8, &[_]u8{ 0x31, 0x3c, 0xe5, 0x67 }, &selector("decimals()")); +} diff --git a/src/mnemonic.zig b/src/mnemonic.zig index 600ef8e..bfec8ac 100644 --- a/src/mnemonic.zig +++ b/src/mnemonic.zig @@ -305,3 +305,54 @@ test "mnemonicToString" { defer allocator.free(result); try std.testing.expectEqualStrings("hello world", result); } + +test "BIP-39 TREZOR passphrase exact seed vector" { + const hex_mod = @import("hex.zig"); + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed = try toSeed(&words, "TREZOR"); + // First 32 bytes of the known BIP-39 test vector + const expected_first32 = try hex_mod.hexToBytesFixed(32, "c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e7e24052f0b7c87c2"); + try std.testing.expectEqualSlices(u8, &expected_first32, seed[0..32]); + // Last 32 bytes + const expected_last32 = try hex_mod.hexToBytesFixed(32, "3758f2f0de3f11ae174a810db40ab2d489cfb1412b145f51585fd05da5a1b6ae"); + try std.testing.expectEqualSlices(u8, &expected_last32, seed[32..64]); +} + +test "BIP-39 all-ones entropy produces zoo...wrong" { + const entropy = [_]u8{0xFF} ** 16; + const words = entropyToMnemonic(.@"128", &entropy); + for (0..11) |i| { + try std.testing.expectEqualStrings("zoo", words[i]); + } + try std.testing.expectEqualStrings("wrong", words[11]); +} + +test "validate 24-word mnemonic from 256-bit entropy" { + // Use a known 32-byte entropy + var entropy: [32]u8 = undefined; + for (&entropy, 0..) |*b, i| b.* = @intCast(i); + const words = entropyToMnemonic(.@"256", &entropy); + // Should validate successfully + try validate(&words); + // Should have 24 words + try std.testing.expectEqual(@as(usize, 24), words.len); +} + +test "toSeed different passphrases produce different seeds" { + const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", + }; + const seed_empty = try toSeed(&words, ""); + const seed_trezor = try toSeed(&words, "TREZOR"); + const seed_custom = try toSeed(&words, "my secret passphrase"); + // All three should differ + try std.testing.expect(!std.mem.eql(u8, &seed_empty, &seed_trezor)); + try std.testing.expect(!std.mem.eql(u8, &seed_empty, &seed_custom)); + try std.testing.expect(!std.mem.eql(u8, &seed_trezor, &seed_custom)); +} diff --git a/src/primitives.zig b/src/primitives.zig index 6e53f30..536961f 100644 --- a/src/primitives.zig +++ b/src/primitives.zig @@ -137,3 +137,45 @@ test "ZERO_HASH is 32 zero bytes" { try std.testing.expectEqual(@as(u8, 0), byte); } } + +test "EIP-55 all lowercase vector" { + const addr = try addressFromHex("0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed"); + const checksum = addressToChecksum(&addr); + try std.testing.expectEqualStrings("0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", &checksum); +} + +test "EIP-55 all uppercase vector" { + const addr = try addressFromHex("0xFB6916095CA1DF60BB79CE92CE3EA74C37C5D359"); + const checksum = addressToChecksum(&addr); + try std.testing.expectEqualStrings("0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", &checksum); +} + +test "EIP-55 mixed case vectors" { + { + const addr = try addressFromHex("0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB"); + const checksum = addressToChecksum(&addr); + try std.testing.expectEqualStrings("0xdbF03B407c01E7cD3CBea99509d93f8DDDC8C6FB", &checksum); + } + { + const addr = try addressFromHex("0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb"); + const checksum = addressToChecksum(&addr); + try std.testing.expectEqualStrings("0xD1220A0cf47c7B9Be7A2E6BA89F429762e7b9aDb", &checksum); + } +} + +test "addressFromHex invalid hex error" { + try std.testing.expectError(error.InvalidHexCharacter, addressFromHex("0xGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG")); +} + +test "addressFromHex wrong length error" { + try std.testing.expectError(error.InvalidHexLength, addressFromHex("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbe")); +} + +test "addressToHex all-0xFF roundtrip" { + const addr: Address = [_]u8{0xFF} ** 20; + const hex = addressToHex(&addr); + try std.testing.expectEqualStrings("0xffffffffffffffffffffffffffffffffffffffff", &hex); + + const parsed = try addressFromHex(&hex); + try std.testing.expect(addressEql(&addr, &parsed)); +} diff --git a/src/rlp.zig b/src/rlp.zig index d0f9cd7..4524abb 100644 --- a/src/rlp.zig +++ b/src/rlp.zig @@ -651,3 +651,113 @@ test "encode-decode roundtrip u256 max" { const decoded = try decode(u256, encoded); try std.testing.expectEqual(max, decoded.value); } + +test "encode long string 56 bytes" { + const allocator = std.testing.allocator; + const data: [56]u8 = .{0xAA} ** 56; + const result = try encode(allocator, @as([]const u8, &data)); + defer allocator.free(result); + // Long string: prefix = 0xb8 (0x80 + 55 + 1), length byte = 0x38 (56) + try std.testing.expectEqual(@as(usize, 58), result.len); + try std.testing.expectEqualSlices(u8, &.{ 0xb8, 0x38 }, result[0..2]); +} + +test "encode long string 256 bytes" { + const allocator = std.testing.allocator; + const data: [256]u8 = .{0xBB} ** 256; + const result = try encode(allocator, @as([]const u8, &data)); + defer allocator.free(result); + // Long string: prefix = 0xb9 (0x80 + 55 + 2), length = 256 = 0x01 0x00 + try std.testing.expectEqual(@as(usize, 259), result.len); + try std.testing.expectEqualSlices(u8, &.{ 0xb9, 0x01, 0x00 }, result[0..3]); +} + +test "encode u32 1000" { + const allocator = std.testing.allocator; + const result = try encode(allocator, @as(u32, 1000)); + defer allocator.free(result); + // 1000 = 0x03E8, needs 2 bytes + try std.testing.expectEqualSlices(u8, &.{ 0x82, 0x03, 0xe8 }, result); +} + +test "encode u32 100000" { + const allocator = std.testing.allocator; + const result = try encode(allocator, @as(u32, 100000)); + defer allocator.free(result); + // 100000 = 0x0186A0, needs 3 bytes + try std.testing.expectEqualSlices(u8, &.{ 0x83, 0x01, 0x86, 0xa0 }, result); +} + +test "encode byte array [1]u8{0x00}" { + const allocator = std.testing.allocator; + const data = [1]u8{0x00}; + const result = try encode(allocator, data); + defer allocator.free(result); + // 0x00 < 0x80, so single byte encoding + try std.testing.expectEqualSlices(u8, &.{0x00}, result); +} + +test "encode byte array [1]u8{0x7f}" { + const allocator = std.testing.allocator; + const data = [1]u8{0x7f}; + const result = try encode(allocator, data); + defer allocator.free(result); + // 0x7f < 0x80, so single byte encoding + try std.testing.expectEqualSlices(u8, &.{0x7f}, result); +} + +test "encode byte array [1]u8{0x80}" { + const allocator = std.testing.allocator; + const data = [1]u8{0x80}; + const result = try encode(allocator, data); + defer allocator.free(result); + // 0x80 >= 0x80, so short string encoding: prefix + byte + try std.testing.expectEqualSlices(u8, &.{ 0x81, 0x80 }, result); +} + +test "decode u8 zero" { + // RLP encoding of 0 is 0x80 (empty string) + const result = try decode(u8, &.{0x80}); + try std.testing.expectEqual(@as(u8, 0), result.value); + try std.testing.expectEqual(@as(usize, 0), result.rest.len); +} + +test "decode invalid non-canonical single byte" { + // 0x81 0x42 encodes byte 0x42 using 1-byte string form, + // but 0x42 < 0x80 should have been encoded as just {0x42} + const result = decode(u8, &.{ 0x81, 0x42 }); + try std.testing.expectError(error.NonCanonical, result); +} + +test "encode-decode roundtrip u64 max" { + const allocator = std.testing.allocator; + const max: u64 = std.math.maxInt(u64); + const encoded = try encode(allocator, max); + defer allocator.free(encoded); + + const decoded = try decode(u64, encoded); + try std.testing.expectEqual(max, decoded.value); +} + +test "encode u16 0x400" { + const allocator = std.testing.allocator; + const result = try encode(allocator, @as(u16, 0x400)); + defer allocator.free(result); + // 0x400 = 1024, needs 2 bytes: 0x04, 0x00 + try std.testing.expectEqualSlices(u8, &.{ 0x82, 0x04, 0x00 }, result); +} + +test "encode-decode roundtrip [32]u8" { + const allocator = std.testing.allocator; + var data: [32]u8 = undefined; + for (0..32) |i| { + data[i] = @intCast(i); + } + const encoded = try encode(allocator, data); + defer allocator.free(encoded); + + const decoded = try decode([32]u8, encoded); + for (0..32) |i| { + try std.testing.expectEqual(@as(u8, @intCast(i)), decoded.value[i]); + } +} diff --git a/src/secp256k1.zig b/src/secp256k1.zig index 674d3dd..ebb2f4d 100644 --- a/src/secp256k1.zig +++ b/src/secp256k1.zig @@ -450,3 +450,87 @@ test "sign and recover with second test key" { const recovered_addr = try recoverAddress(sig, message_hash); try std.testing.expectEqualSlices(u8, &expected_address, &recovered_addr); } + +test "sign with small private key (key=1)" { + // Private key = 1 (31 zero bytes + 0x01) + var private_key: [32]u8 = [_]u8{0} ** 32; + private_key[31] = 0x01; + + const message_hash = keccak.hash("test"); + const sig = try sign(private_key, message_hash); + + // Recover address from the signature + const recovered_addr = try recoverAddress(sig, message_hash); + + // Derive address directly from the private key + const pubkey = try derivePublicKey(private_key); + const expected_addr = pubkeyToAddress(pubkey); + + try std.testing.expectEqualSlices(u8, &expected_addr, &recovered_addr); +} + +test "sign and recover produces correct address for multiple messages" { + const hex = @import("hex.zig"); + // Hardhat account #0 + const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + const messages = [_][]const u8{ "msg1", "msg2", "msg3", "msg4", "msg5" }; + + for (messages) |msg| { + const message_hash = keccak.hash(msg); + const sig = try sign(private_key, message_hash); + const recovered_addr = try recoverAddress(sig, message_hash); + try std.testing.expectEqualSlices(u8, &expected_address, &recovered_addr); + } +} + +test "derivePublicKey then pubkeyToAddress matches Signer.address" { + const hex = @import("hex.zig"); + // Hardhat account #0 + const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + const expected_address = try hex.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266"); + + const pubkey = try derivePublicKey(private_key); + const addr = pubkeyToAddress(pubkey); + try std.testing.expectEqualSlices(u8, &expected_address, &addr); +} + +test "sign produces low-s (EIP-2) canonical signature" { + const hex = @import("hex.zig"); + // Hardhat account #0 + const private_key = try hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); + + // n/2 = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0 + const half_n: [32]u8 = .{ + 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0x5D, 0x57, 0x6E, 0x73, 0x57, 0xA4, 0x50, 0x1D, + 0xDF, 0xE9, 0x2F, 0x46, 0x68, 0x1B, 0x20, 0xA0, + }; + + const messages = [_][]const u8{ "canonical1", "canonical2", "canonical3", "canonical4", "canonical5" }; + + for (messages) |msg| { + const message_hash = keccak.hash(msg); + const sig = try sign(private_key, message_hash); + + // Extract s as big-endian bytes and verify s <= n/2 + const s_bytes = sig.s; + var s_is_lte = false; + for (0..32) |i| { + if (s_bytes[i] < half_n[i]) { + s_is_lte = true; + break; + } else if (s_bytes[i] > half_n[i]) { + break; + } + } + // If we didn't break early, all bytes were equal (s == n/2), which is also valid + if (!s_is_lte) { + // Check if all bytes are equal (s == n/2) + s_is_lte = std.mem.eql(u8, &s_bytes, &half_n); + } + try std.testing.expect(s_is_lte); + } +} diff --git a/src/signer.zig b/src/signer.zig index 7874247..0848688 100644 --- a/src/signer.zig +++ b/src/signer.zig @@ -205,3 +205,45 @@ test "Signer deterministic signatures" { try std.testing.expect(sig1.eql(sig2)); } + +test "Signer with Hardhat account #2" { + const hex = @import("hex.zig"); + const private_key = try hex.hexToBytesFixed(32, "5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"); + const expected_address = try hex.hexToBytesFixed(20, "3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + + const signer = Signer.init(private_key); + const addr = try signer.address(); + try std.testing.expectEqualSlices(u8, &expected_address, &addr); +} + +test "Signer with Hardhat account #3" { + const hex = @import("hex.zig"); + const private_key = try hex.hexToBytesFixed(32, "7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6"); + const expected_address = try hex.hexToBytesFixed(20, "90F79bf6EB2c4f870365E785982E1f101E93b906"); + + const signer = Signer.init(private_key); + const addr = try signer.address(); + try std.testing.expectEqualSlices(u8, &expected_address, &addr); +} + +test "Signer with Hardhat account #4" { + const hex = @import("hex.zig"); + const private_key = try hex.hexToBytesFixed(32, "47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a"); + const expected_address = try hex.hexToBytesFixed(20, "15d34AAf54267DB7D7c367839AAf71A00a2C6A65"); + + const signer = Signer.init(private_key); + const addr = try signer.address(); + try std.testing.expectEqualSlices(u8, &expected_address, &addr); +} + +test "hashPersonalMessage with 100-byte message" { + const message: [100]u8 = [_]u8{'A'} ** 100; + const hash = Signer.hashPersonalMessage(&message); + + // The prefix for a 100-byte message includes "100" (3 chars) + const prefix = "\x19Ethereum Signed Message:\n"; + const len_str = "100"; + const slices: []const []const u8 = &.{ prefix, len_str, &message }; + const expected = keccak.hashConcat(slices); + try std.testing.expectEqualSlices(u8, &expected, &hash); +} diff --git a/src/transaction.zig b/src/transaction.zig index 74cbec6..0f38129 100644 --- a/src/transaction.zig +++ b/src/transaction.zig @@ -837,3 +837,285 @@ test "hashForSigning produces different hashes for different txs" { try std.testing.expect(!std.mem.eql(u8, &h1, &h2)); } + +test "legacy tx alloy.rs test vector" { + const allocator = std.testing.allocator; + + const to_addr = try hex_mod.hexToBytesFixed(20, "F0109fC8DF283027b6285cc889F5aA624EaC1F55"); + + const tx = Transaction{ + .legacy = .{ + .nonce = 0, + .gas_price = 21_000_000_000, // 21 gwei + .gas_limit = 2_000_000, + .to = to_addr, + .value = 1_000_000_000, // 1 gwei + .data = &.{}, + .chain_id = 1, + }, + }; + + // Hash twice and verify determinism + const hash1 = try hashForSigning(allocator, tx); + const hash2 = try hashForSigning(allocator, tx); + try std.testing.expectEqualSlices(u8, &hash1, &hash2); + + // Verify the payload starts with RLP list prefix and has reasonable length + const payload = try serializeForSigning(allocator, tx); + defer allocator.free(payload); + try std.testing.expect(payload[0] >= 0xc0); + try std.testing.expect(payload.len > 40); +} + +test "eip1559 with specific parameters" { + const allocator = std.testing.allocator; + + const to_addr = try hex_mod.hexToBytesFixed(20, "6069a6c32cf691f5982febae4faf8a6f3ab2f0f6"); + + const data_hex = "a22cb4650000000000000000000000005eee75727d804a2b13038928d36f8b188945a57a0000000000000000000000000000000000000000000000000000000000000000"; + var data_buf: [68]u8 = undefined; + _ = try hex_mod.hexToBytes(&data_buf, data_hex); + + const tx = Transaction{ + .eip1559 = .{ + .chain_id = 1, + .nonce = 0x42, + .max_priority_fee_per_gas = 1_000_000_000, // 1 gwei + .max_fee_per_gas = 20_000_000_000, // 20 gwei + .gas_limit = 44386, + .to = to_addr, + .value = 0, + .data = &data_buf, + .access_list = &.{}, + }, + }; + + const payload = try serializeForSigning(allocator, tx); + defer allocator.free(payload); + + // Must start with type prefix 0x02 + try std.testing.expectEqual(@as(u8, 0x02), payload[0]); + // Should have reasonable length (includes 68 bytes of data) + try std.testing.expect(payload.len > 68); + + // Hash is deterministic + const hash1 = try hashForSigning(allocator, tx); + const hash2 = try hashForSigning(allocator, tx); + try std.testing.expectEqualSlices(u8, &hash1, &hash2); +} + +test "transaction with 256-byte data" { + const allocator = std.testing.allocator; + + const data = [_]u8{0x42} ** 256; + + const tx = Transaction{ .legacy = .{ + .nonce = 1, + .gas_price = 30_000_000_000, + .gas_limit = 500_000, + .to = [_]u8{0xAA} ** 20, + .value = 0, + .data = &data, + .chain_id = 1, + } }; + + const payload = try serializeForSigning(allocator, tx); + defer allocator.free(payload); + + // Payload must be longer than 256 bytes since it includes the data + try std.testing.expect(payload.len > 256); +} + +test "access list with multiple entries" { + const allocator = std.testing.allocator; + + const key_aa = [_]u8{0xAA} ** 32; + const key_bb = [_]u8{0xBB} ** 32; + const key_cc = [_]u8{0xCC} ** 32; + + const keys_1 = [_][32]u8{ key_aa, key_bb }; + const keys_2 = [_][32]u8{key_cc}; + + const items = [_]AccessListItem{ + .{ .address = [_]u8{0x11} ** 20, .storage_keys = &keys_1 }, + .{ .address = [_]u8{0x22} ** 20, .storage_keys = &keys_2 }, + .{ .address = [_]u8{0x33} ** 20, .storage_keys = &.{} }, + }; + + const tx = Transaction{ .eip2930 = .{ + .chain_id = 1, + .nonce = 0, + .gas_price = 1_000_000_000, + .gas_limit = 100_000, + .to = [_]u8{0xFF} ** 20, + .value = 0, + .data = &.{}, + .access_list = &items, + } }; + + const payload = try serializeForSigning(allocator, tx); + defer allocator.free(payload); + + // Must start with type prefix 0x01 + try std.testing.expectEqual(@as(u8, 0x01), payload[0]); + // Must include all access list data: 3 addresses + 3 storage keys + try std.testing.expect(payload.len > 100); +} + +test "eip1559 with large fees" { + const allocator = std.testing.allocator; + + const tx = Transaction{ + .eip1559 = .{ + .chain_id = 1, + .nonce = 0, + .max_priority_fee_per_gas = 100_000_000_000, // 100 gwei + .max_fee_per_gas = 500_000_000_000, // 500 gwei + .gas_limit = 21000, + .to = [_]u8{0xBB} ** 20, + .value = 1_000_000_000_000_000_000, // 1 ETH + .data = &.{}, + .access_list = &.{}, + }, + }; + + const payload = try serializeForSigning(allocator, tx); + defer allocator.free(payload); + + // Hash is deterministic + const hash1 = try hashForSigning(allocator, tx); + const hash2 = try hashForSigning(allocator, tx); + try std.testing.expectEqualSlices(u8, &hash1, &hash2); + + // Payload should be larger than a simple tx due to large fee values + const simple_tx = Transaction{ .eip1559 = .{ + .chain_id = 1, + .nonce = 0, + .max_priority_fee_per_gas = 0, + .max_fee_per_gas = 0, + .gas_limit = 21000, + .to = [_]u8{0xBB} ** 20, + .value = 0, + .data = &.{}, + .access_list = &.{}, + } }; + + const simple_payload = try serializeForSigning(allocator, simple_tx); + defer allocator.free(simple_payload); + + try std.testing.expect(payload.len > simple_payload.len); +} + +test "eip4844 with three blob hashes" { + const allocator = std.testing.allocator; + + const hash1 = [_]u8{0x01} ++ [_]u8{0xAA} ** 31; + const hash2 = [_]u8{0x01} ++ [_]u8{0xBB} ** 31; + const hash3 = [_]u8{0x01} ++ [_]u8{0xCC} ** 31; + + const three_hashes = [_][32]u8{ hash1, hash2, hash3 }; + const one_hash = [_][32]u8{hash1}; + + const tx_three = Transaction{ + .eip4844 = .{ + .chain_id = 1, + .nonce = 10, + .max_priority_fee_per_gas = 1_000_000_000, // 1 gwei + .max_fee_per_gas = 50_000_000_000, // 50 gwei + .gas_limit = 100_000, + .to = [_]u8{0xDD} ** 20, + .value = 0, + .data = &.{}, + .access_list = &.{}, + .max_fee_per_blob_gas = 1_000_000_000, // 1 gwei + .blob_versioned_hashes = &three_hashes, + }, + }; + + const tx_one = Transaction{ .eip4844 = .{ + .chain_id = 1, + .nonce = 10, + .max_priority_fee_per_gas = 1_000_000_000, + .max_fee_per_gas = 50_000_000_000, + .gas_limit = 100_000, + .to = [_]u8{0xDD} ** 20, + .value = 0, + .data = &.{}, + .access_list = &.{}, + .max_fee_per_blob_gas = 1_000_000_000, + .blob_versioned_hashes = &one_hash, + } }; + + const payload_three = try serializeForSigning(allocator, tx_three); + defer allocator.free(payload_three); + + const payload_one = try serializeForSigning(allocator, tx_one); + defer allocator.free(payload_one); + + // Must start with type prefix 0x03 + try std.testing.expectEqual(@as(u8, 0x03), payload_three[0]); + // Three hashes should be larger than one hash + try std.testing.expect(payload_three.len > payload_one.len); +} + +test "signed eip1559 tx structure" { + const allocator = std.testing.allocator; + + const tx = Transaction{ .eip1559 = .{ + .chain_id = 1, + .nonce = 0, + .max_priority_fee_per_gas = 1_500_000_000, + .max_fee_per_gas = 30_000_000_000, + .gas_limit = 21000, + .to = [_]u8{0xcc} ** 20, + .value = 0, + .data = &.{}, + .access_list = &.{}, + } }; + + const r = [_]u8{0x11} ** 32; + const s = [_]u8{0x22} ** 32; + const v: u8 = 0; + + const signed = try serializeSigned(allocator, tx, r, s, v); + defer allocator.free(signed); + + // Signed tx starts with 0x02 + try std.testing.expectEqual(@as(u8, 0x02), signed[0]); + // Followed by RLP list + try std.testing.expect(signed[1] >= 0xc0); + + // Signed version must be longer than unsigned version + const unsigned = try serializeForSigning(allocator, tx); + defer allocator.free(unsigned); + try std.testing.expect(signed.len > unsigned.len); +} + +test "hashForSigning different chain IDs produce different hashes" { + const allocator = std.testing.allocator; + + const tx_chain1 = Transaction{ .legacy = .{ + .nonce = 0, + .gas_price = 20_000_000_000, + .gas_limit = 21000, + .to = [_]u8{0xaa} ** 20, + .value = 1_000_000_000_000_000_000, + .data = &.{}, + .chain_id = 1, + } }; + + const tx_chain5 = Transaction{ .legacy = .{ + .nonce = 0, + .gas_price = 20_000_000_000, + .gas_limit = 21000, + .to = [_]u8{0xaa} ** 20, + .value = 1_000_000_000_000_000_000, + .data = &.{}, + .chain_id = 5, + } }; + + const h1 = try hashForSigning(allocator, tx_chain1); + const h5 = try hashForSigning(allocator, tx_chain5); + + try std.testing.expect(!std.mem.eql(u8, &h1, &h5)); +} diff --git a/src/uint256.zig b/src/uint256.zig index f624e4e..ff5f0f0 100644 --- a/src/uint256.zig +++ b/src/uint256.zig @@ -449,3 +449,83 @@ test "mulDiv result overflow" { // (MAX * MAX) / 1 overflows u256 try std.testing.expectEqual(@as(?u256, null), mulDiv(MAX, MAX, 1)); } + +test "fastDiv power-of-2 divisors" { + try std.testing.expectEqual(MAX / (@as(u256, 1) << 64), fastDiv(MAX, @as(u256, 1) << 64)); + try std.testing.expectEqual(MAX / (@as(u256, 1) << 128), fastDiv(MAX, @as(u256, 1) << 128)); + try std.testing.expectEqual(MAX / (@as(u256, 1) << 192), fastDiv(MAX, @as(u256, 1) << 192)); +} + +test "fastDiv 1-limb values" { + // Both fit in u64 + try std.testing.expectEqual(@as(u256, 142857), fastDiv(1_000_000, 7)); +} + +test "fastDiv 2-limb numerator 1-limb divisor" { + const a: u256 = (@as(u256, 1) << 100) + 999; + const b: u256 = 1_000_000_007; + try std.testing.expectEqual(a / b, fastDiv(a, b)); +} + +test "fastDiv identity a / 1" { + try std.testing.expectEqual(@as(u256, 0), fastDiv(0, 1)); + try std.testing.expectEqual(@as(u256, 1), fastDiv(1, 1)); + try std.testing.expectEqual(MAX, fastDiv(MAX, 1)); +} + +test "fastDiv identity a / a" { + try std.testing.expectEqual(@as(u256, 1), fastDiv(1, 1)); + try std.testing.expectEqual(@as(u256, 1), fastDiv(42, 42)); + try std.testing.expectEqual(@as(u256, 1), fastDiv(MAX, MAX)); +} + +test "mulDiv edge cases" { + try std.testing.expectEqual(@as(?u256, 0), mulDiv(0, MAX, 1)); + try std.testing.expectEqual(@as(?u256, 1), mulDiv(1, 1, 1)); + try std.testing.expectEqual(@as(?u256, 1), mulDiv(MAX, 1, MAX)); + try std.testing.expectEqual(@as(?u256, 0), mulDiv(0, 0, 1)); +} + +test "mulDiv Q96 arithmetic" { + // Identity: Q96 * Q96 / Q96 == Q96 + try std.testing.expectEqual(@as(?u256, Q96), mulDiv(Q96, Q96, Q96)); + // (Q96 * 2) * Q96 / (Q96 * 2) == Q96 + try std.testing.expectEqual(@as(?u256, Q96), mulDiv(Q96 * 2, Q96, Q96 * 2)); +} + +test "mulDiv large non-overflow" { + // (1 << 200) * (1 << 55) = 1 << 255 fits in u256 + // (1 << 255) / (1 << 100) = 1 << 155 + const a: u256 = @as(u256, 1) << 200; + const b: u256 = @as(u256, 1) << 55; + const d: u256 = @as(u256, 1) << 100; + try std.testing.expectEqual(@as(?u256, @as(u256, 1) << 155), mulDiv(a, b, d)); +} + +test "fromHex toHex roundtrip comprehensive" { + const allocator = std.testing.allocator; + const values = [_]u256{ 0, 1, 0xFF, 0x100, 0x1234567890abcdef, MAX }; + for (values) |v| { + const hex_str = try toHex(allocator, v); + defer allocator.free(hex_str); + const recovered = try fromHex(hex_str); + try std.testing.expectEqual(v, recovered); + } +} + +test "fromBigEndianBytes zero" { + const zero_bytes = [_]u8{0} ** 32; + try std.testing.expectEqual(@as(u256, 0), fromBigEndianBytes(zero_bytes)); +} + +test "fromBigEndianBytes and toBigEndianBytes MAX" { + const bytes = toBigEndianBytes(MAX); + const recovered = fromBigEndianBytes(bytes); + try std.testing.expectEqual(MAX, recovered); +} + +test "fastMul small values" { + try std.testing.expectEqual(@as(u256, 20000), fastMul(100, 200)); + try std.testing.expectEqual(@as(u256, 0), fastMul(0, MAX)); + try std.testing.expectEqual(MAX, fastMul(1, MAX)); +} diff --git a/src/utils/units.zig b/src/utils/units.zig index a166504..4e4b139 100644 --- a/src/utils/units.zig +++ b/src/utils/units.zig @@ -49,3 +49,27 @@ test "formatEther" { test "formatGwei" { try std.testing.expectApproxEqAbs(@as(f64, 20.0), formatGwei(20_000_000_000), 1e-10); } + +test "parseEther zero" { + try std.testing.expectEqual(@as(u256, 0), parseEther(0.0)); +} + +test "parseEther large value" { + try std.testing.expectEqual(@as(u256, 10_000_000_000_000_000_000_000), parseEther(10000.0)); +} + +test "formatEther zero" { + try std.testing.expectApproxEqAbs(@as(f64, 0.0), formatEther(0), 1e-10); +} + +test "formatGwei zero" { + try std.testing.expectApproxEqAbs(@as(f64, 0.0), formatGwei(0), 1e-10); +} + +test "parseEther formatEther roundtrip" { + try std.testing.expectApproxEqAbs(@as(f64, 1.5), formatEther(parseEther(1.5)), 1e-6); +} + +test "parseGwei formatGwei roundtrip" { + try std.testing.expectApproxEqAbs(@as(f64, 30.0), formatGwei(parseGwei(30.0)), 1e-6); +}