diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..be78807 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,49 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps to reproduce the behavior: +1. ... +2. ... +3. ... + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +What actually happened. + +## Code Sample + +```zig +// Your code here +``` + +## Environment + +- **OS**: [e.g., Ubuntu 22.04, macOS 13, Windows 11] +- **Zig Version**: [e.g., 0.14.1] +- **Zigeth Version**: [e.g., v0.1.0] + +## Additional Context + +Add any other context about the problem here. + +## Stack Trace + +``` +// Paste any relevant stack traces or error messages here +``` + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..da75e73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,49 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Feature Description + +A clear and concise description of the feature you'd like to see. + +## Problem Statement + +Is your feature request related to a problem? Please describe. +Ex. I'm always frustrated when [...] + +## Proposed Solution + +Describe the solution you'd like to see implemented. + +## Alternative Solutions + +Describe any alternative solutions or features you've considered. + +## Use Case + +Describe the use case for this feature. How would you use it? + +```zig +// Example code showing how the feature would be used +``` + +## Benefits + +What are the benefits of implementing this feature? +- ... +- ... + +## Potential Drawbacks + +Are there any potential drawbacks or considerations? +- ... +- ... + +## Additional Context + +Add any other context, mockups, or examples about the feature request here. + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9f89285 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,44 @@ +## Description + + + +## Type of Change + + + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Performance improvement +- [ ] Code refactoring +- [ ] Test improvement + +## Testing + + + +- [ ] All existing tests pass (`zig build test`) +- [ ] Added new tests for the changes +- [ ] Manual testing performed + +## Checklist + +- [ ] My code follows the Zig style guide (`zig build fmt`) +- [ ] Linting passes (`zig build lint`) +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have updated the documentation accordingly +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes + +## Related Issues + + + +Closes # + +## Additional Notes + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..432f810 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,166 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + lint: + name: Lint and Format Check + runs-on: ubuntu-latest + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Check formatting + run: zig build fmt-check + + - name: Run linting + run: zig build lint + + test: + name: Test on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Cache Zig artifacts + uses: actions/cache@v4 + with: + path: | + .zig-cache + zig-out + key: ${{ runner.os }}-zig-test-${{ hashFiles('build.zig', 'build.zig.zon', 'src/**/*.zig') }} + restore-keys: | + ${{ runner.os }}-zig-test- + ${{ runner.os }}-zig- + + - name: Build library + run: zig build + + - name: Run tests + run: zig build test + + build: + name: Build on ${{ matrix.os }} (${{ matrix.optimize }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + optimize: [Debug, ReleaseSafe, ReleaseFast, ReleaseSmall] + + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Cache Zig artifacts + uses: actions/cache@v4 + with: + path: | + .zig-cache + zig-out + key: ${{ runner.os }}-zig-${{ matrix.optimize }}-${{ hashFiles('build.zig', 'build.zig.zon', 'src/**/*.zig') }} + restore-keys: | + ${{ runner.os }}-zig-${{ matrix.optimize }}- + ${{ runner.os }}-zig- + + - name: Build (${{ matrix.optimize }}) + run: zig build -Doptimize=${{ matrix.optimize }} + + coverage: + name: Code Coverage + runs-on: ubuntu-latest + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Run tests with coverage + run: zig build test + + - name: Generate coverage report + run: | + echo "Coverage reporting not yet configured" + # TODO: Add coverage reporting when available + + docs: + name: Build Documentation + runs-on: ubuntu-latest + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Build documentation + run: zig build docs + + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: docs + path: zig-out/docs/ + + summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [lint, test, build] + if: always() + steps: + - name: Check results + run: | + if [ "${{ needs.lint.result }}" == "failure" ] || \ + [ "${{ needs.test.result }}" == "failure" ] || \ + [ "${{ needs.build.result }}" == "failure" ]; then + echo "❌ CI failed" + exit 1 + else + echo "✅ All CI checks passed" + fi + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f9a1281 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,97 @@ +name: Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + build-release: + name: Build Release (${{ matrix.os }}, ${{ matrix.target }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-linux + artifact: zigeth-linux-x86_64.tar.gz + + - os: ubuntu-latest + target: aarch64-linux + artifact: zigeth-linux-aarch64.tar.gz + + - os: macos-latest + target: x86_64-macos + artifact: zigeth-macos-x86_64.tar.gz + + - os: macos-latest + target: aarch64-macos + artifact: zigeth-macos-aarch64.tar.gz + + - os: windows-latest + target: x86_64-windows + artifact: zigeth-windows-x86_64.zip + + env: + ZIG_GLOBAL_CACHE_DIR: ${{ github.workspace }}/.zig-cache/global + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Zig + uses: goto-bus-stop/setup-zig@v2 + with: + version: 0.14.1 + + - name: Build Release + run: zig build -Doptimize=ReleaseFast + + - name: Create Archive (Unix) + if: runner.os != 'Windows' + run: | + cd zig-out + tar czf ../${{ matrix.artifact }} * + + - name: Create Archive (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + cd zig-out + Compress-Archive -Path * -DestinationPath ../${{ matrix.artifact }} + + - name: Upload Artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.artifact }} + + create-release: + name: Create Release + runs-on: ubuntu-latest + needs: build-release + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Display structure of downloaded files + run: ls -R artifacts/ + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: artifacts/**/* + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 181730e..47a893c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Zigeth +[![CI](https://github.com/yourusername/zigeth/actions/workflows/ci.yml/badge.svg)](https://github.com/yourusername/zigeth/actions/workflows/ci.yml) +[![Zig](https://img.shields.io/badge/Zig-0.14.1-orange.svg)](https://ziglang.org/) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + A comprehensive Ethereum library for Zig, providing primitives, RPC client, ABI/RLP encoding/decoding, contract interaction, and wallet management for seamless integration with Ethereum networks. ## 🏗️ Architecture diff --git a/build.zig b/build.zig index df8f649..1c7d6a8 100644 --- a/build.zig +++ b/build.zig @@ -31,19 +31,9 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - // Link system libraries for networking and crypto + // Link libc (required for some std library functions) lib.linkLibC(); - // Platform-specific linking - if (target.result.os.tag == .linux) { - lib.linkSystemLibrary("ssl"); - lib.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - // macOS has built-in crypto frameworks - lib.linkFramework("Security"); - lib.linkFramework("CoreFoundation"); - } - b.installArtifact(lib); // Build executable (CLI tool) @@ -57,15 +47,6 @@ pub fn build(b: *std.Build) void { exe.root_module.addImport("zigeth", zigeth_mod); exe.linkLibC(); - // Platform-specific linking for executable - if (target.result.os.tag == .linux) { - exe.linkSystemLibrary("ssl"); - exe.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - exe.linkFramework("Security"); - exe.linkFramework("CoreFoundation"); - } - b.installArtifact(exe); // Run command @@ -86,13 +67,6 @@ pub fn build(b: *std.Build) void { }); lib_unit_tests.linkLibC(); - if (target.result.os.tag == .linux) { - lib_unit_tests.linkSystemLibrary("ssl"); - lib_unit_tests.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - lib_unit_tests.linkFramework("Security"); - lib_unit_tests.linkFramework("CoreFoundation"); - } const run_lib_unit_tests = b.addRunArtifact(lib_unit_tests); @@ -166,14 +140,6 @@ pub fn build(b: *std.Build) void { example_exe.root_module.addImport("zigeth", zigeth_mod); example_exe.linkLibC(); - if (target.result.os.tag == .linux) { - example_exe.linkSystemLibrary("ssl"); - example_exe.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - example_exe.linkFramework("Security"); - example_exe.linkFramework("CoreFoundation"); - } - const install_example = b.addInstallArtifact(example_exe, .{ .dest_dir = .{ .override = .{ @@ -224,13 +190,6 @@ pub fn build(b: *std.Build) void { .optimize = .Debug, }); lint_lib.linkLibC(); - if (target.result.os.tag == .linux) { - lint_lib.linkSystemLibrary("ssl"); - lint_lib.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - lint_lib.linkFramework("Security"); - lint_lib.linkFramework("CoreFoundation"); - } const lint_lib_check = b.addInstallArtifact(lint_lib, .{ .dest_dir = .{ .override = .{ .custom = "lint" } }, @@ -246,13 +205,6 @@ pub fn build(b: *std.Build) void { }); lint_exe.root_module.addImport("zigeth", zigeth_mod); lint_exe.linkLibC(); - if (target.result.os.tag == .linux) { - lint_exe.linkSystemLibrary("ssl"); - lint_exe.linkSystemLibrary("crypto"); - } else if (target.result.os.tag == .macos) { - lint_exe.linkFramework("Security"); - lint_exe.linkFramework("CoreFoundation"); - } const lint_exe_check = b.addInstallArtifact(lint_exe, .{ .dest_dir = .{ .override = .{ .custom = "lint" } }, diff --git a/src/crypto/ecdsa.zig b/src/crypto/ecdsa.zig index e69de29..a29f94e 100644 --- a/src/crypto/ecdsa.zig +++ b/src/crypto/ecdsa.zig @@ -0,0 +1,174 @@ +const std = @import("std"); +const Hash = @import("../primitives/hash.zig").Hash; +const Address = @import("../primitives/address.zig").Address; +const Signature = @import("../primitives/signature.zig").Signature; +const secp256k1 = @import("./secp256k1.zig"); +const keccak = @import("./keccak.zig"); + +/// ECDSA signer for Ethereum transactions +pub const Signer = struct { + private_key: secp256k1.PrivateKey, + + /// Create a new signer with a private key + pub fn init(private_key: secp256k1.PrivateKey) Signer { + return .{ .private_key = private_key }; + } + + /// Sign a message hash + pub fn signHash(self: Signer, message_hash: Hash) !Signature { + return try secp256k1.sign(message_hash, self.private_key); + } + + /// Sign raw message bytes (will hash with Keccak-256) + pub fn signMessage(self: Signer, message: []const u8) !Signature { + const hash_result = keccak.hash(message); + return try self.signHash(hash_result); + } + + /// Sign with Ethereum's personal message format + /// Prepends "\x19Ethereum Signed Message:\n" + length + message + pub fn signPersonalMessage(self: Signer, allocator: std.mem.Allocator, message: []const u8) !Signature { + const prefix = "\x19Ethereum Signed Message:\n"; + const len_str = try std.fmt.allocPrint(allocator, "{d}", .{message.len}); + defer allocator.free(len_str); + + // Concatenate: prefix + length + message + const total_len = prefix.len + len_str.len + message.len; + const full_message = try allocator.alloc(u8, total_len); + defer allocator.free(full_message); + + @memcpy(full_message[0..prefix.len], prefix); + @memcpy(full_message[prefix.len .. prefix.len + len_str.len], len_str); + @memcpy(full_message[prefix.len + len_str.len ..], message); + + return try self.signMessage(full_message); + } + + /// Get the public key for this signer + pub fn getPublicKey(self: Signer) !secp256k1.PublicKey { + return try secp256k1.derivePublicKey(self.private_key); + } + + /// Get the Ethereum address for this signer + pub fn getAddress(self: Signer) !Address { + const pub_key = try self.getPublicKey(); + return pub_key.toAddress(); + } +}; + +/// Verify an ECDSA signature +pub fn verify(message_hash: Hash, signature: Signature, public_key: secp256k1.PublicKey) !bool { + return try secp256k1.verify(message_hash, signature, public_key); +} + +/// Recover the public key from a signature and message hash +pub fn recoverPublicKey(message_hash: Hash, signature: Signature) !secp256k1.PublicKey { + return try secp256k1.recoverPublicKey(message_hash, signature); +} + +/// Recover the Ethereum address from a signature and message hash +pub fn recoverAddress(message_hash: Hash, signature: Signature) !Address { + const pub_key = try recoverPublicKey(message_hash, signature); + return pub_key.toAddress(); +} + +/// Verify a personal message signature +pub fn verifyPersonalMessage( + allocator: std.mem.Allocator, + message: []const u8, + signature: Signature, + expected_address: Address, +) !bool { + const prefix = "\x19Ethereum Signed Message:\n"; + const len_str = try std.fmt.allocPrint(allocator, "{d}", .{message.len}); + defer allocator.free(len_str); + + const total_len = prefix.len + len_str.len + message.len; + const full_message = try allocator.alloc(u8, total_len); + defer allocator.free(full_message); + + @memcpy(full_message[0..prefix.len], prefix); + @memcpy(full_message[prefix.len .. prefix.len + len_str.len], len_str); + @memcpy(full_message[prefix.len + len_str.len ..], message); + + const hash_result = keccak.hash(full_message); + const recovered_address = try recoverAddress(hash_result, signature); + + return std.mem.eql(u8, &recovered_address.bytes, &expected_address.bytes); +} + +/// Sign transaction data for Ethereum +pub const TransactionSigner = struct { + signer: Signer, + chain_id: u64, + + pub fn init(private_key: secp256k1.PrivateKey, chain_id: u64) TransactionSigner { + return .{ + .signer = Signer.init(private_key), + .chain_id = chain_id, + }; + } + + /// Sign transaction hash with EIP-155 replay protection + pub fn signTransaction(self: TransactionSigner, tx_hash: Hash) !Signature { + // For EIP-155, we need to include chain_id in the signature + // The actual implementation would modify the v value + var signature = try self.signer.signHash(tx_hash); + + // Apply EIP-155: v = chain_id * 2 + 35 + recovery_id + const recovery_id = signature.getRecoveryId(); + signature.v = Signature.eip155V(self.chain_id, recovery_id); + + return signature; + } + + pub fn getAddress(self: TransactionSigner) !Address { + return try self.signer.getAddress(); + } +}; + +/// Deterministic k generation for ECDSA (RFC 6979) +/// This prevents nonce reuse attacks +pub fn generateDeterministicK( + message_hash: Hash, + private_key: secp256k1.PrivateKey, +) !secp256k1.PrivateKey { + // TODO: Implement RFC 6979 deterministic k generation + // For now, this is a placeholder + + _ = message_hash; + _ = private_key; + return error.NotImplemented; +} + +test "signer creation" { + var prng = std.rand.DefaultPrng.init(0); + const random = prng.random(); + + const pk = try secp256k1.PrivateKey.generate(random); + const signer = Signer.init(pk); + + // Verify signer was created + try std.testing.expect(!signer.private_key.toU256().isZero()); +} + +test "transaction signer with chain id" { + var prng = std.rand.DefaultPrng.init(0); + const random = prng.random(); + + const pk = try secp256k1.PrivateKey.generate(random); + const tx_signer = TransactionSigner.init(pk, 1); // Ethereum mainnet + + try std.testing.expectEqual(@as(u64, 1), tx_signer.chain_id); +} + +test "personal message format" { + // Test message formatting + const message = "Hello Ethereum!"; + const expected_prefix = "\x19Ethereum Signed Message:\n15"; + + const prefix_len = expected_prefix.len; + const total_len = prefix_len + message.len; + + try std.testing.expectEqual(@as(usize, 30), total_len); +} diff --git a/src/crypto/keccak.zig b/src/crypto/keccak.zig index e69de29..f215cdd 100644 --- a/src/crypto/keccak.zig +++ b/src/crypto/keccak.zig @@ -0,0 +1,151 @@ +const std = @import("std"); +const Hash = @import("../primitives/hash.zig").Hash; + +/// Keccak-256 hash function (NOT SHA3-256) +/// Ethereum uses Keccak-256, which is the original Keccak submission +/// before it was standardized as SHA3-256 with different padding +pub const Keccak256 = std.crypto.hash.sha3.Keccak256; + +/// Hash a byte slice using Keccak-256 +pub fn hash(data: []const u8) Hash { + var h: [32]u8 = undefined; + Keccak256.hash(data, &h, .{}); + return Hash.fromBytes(h); +} + +/// Hash multiple byte slices using Keccak-256 +pub fn hashMulti(parts: []const []const u8) Hash { + var hasher = Keccak256.init(.{}); + for (parts) |part| { + hasher.update(part); + } + var h: [32]u8 = undefined; + hasher.final(&h); + return Hash.fromBytes(h); +} + +/// Create a Keccak-256 hasher for incremental hashing +pub const Hasher = struct { + inner: Keccak256, + + pub fn init() Hasher { + return .{ + .inner = Keccak256.init(.{}), + }; + } + + pub fn update(self: *Hasher, data: []const u8) void { + self.inner.update(data); + } + + pub fn final(self: *Hasher) Hash { + var h: [32]u8 = undefined; + self.inner.final(&h); + return Hash.fromBytes(h); + } + + pub fn reset(self: *Hasher) void { + self.inner = Keccak256.init(.{}); + } +}; + +/// Hash a string (convenience function) +pub fn hashString(s: []const u8) Hash { + return hash(s); +} + +/// Hash two hashes together (useful for Merkle trees) +pub fn hashPair(a: Hash, b: Hash) Hash { + var data: [64]u8 = undefined; + @memcpy(data[0..32], &a.bytes); + @memcpy(data[32..64], &b.bytes); + return hash(&data); +} + +/// Calculate Ethereum function selector (first 4 bytes of function signature hash) +pub fn functionSelector(signature: []const u8) [4]u8 { + const h = hash(signature); + var selector: [4]u8 = undefined; + @memcpy(&selector, h.bytes[0..4]); + return selector; +} + +/// Calculate Ethereum event signature (full hash of event signature) +pub fn eventSignature(signature: []const u8) Hash { + return hash(signature); +} + +test "keccak256 basic" { + const data = "hello world"; + const h = hash(data); + + // Verify hash is not zero + try std.testing.expect(!h.isZero()); +} + +test "keccak256 empty string" { + const data = ""; + const h = hash(data); + + // Keccak-256 of empty string is a known value + const expected_hex = "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"; + const expected = try Hash.fromHex(expected_hex); + + try std.testing.expect(h.eql(expected)); +} + +test "keccak256 incremental" { + const part1 = "hello"; + const part2 = " "; + const part3 = "world"; + + // Hash all at once + const h1 = hash("hello world"); + + // Hash incrementally + var hasher = Hasher.init(); + hasher.update(part1); + hasher.update(part2); + hasher.update(part3); + const h2 = hasher.final(); + + try std.testing.expect(h1.eql(h2)); +} + +test "keccak256 multi" { + const parts = [_][]const u8{ "hello", " ", "world" }; + const h1 = hashMulti(&parts); + const h2 = hash("hello world"); + + try std.testing.expect(h1.eql(h2)); +} + +test "function selector" { + // transfer(address,uint256) -> 0xa9059cbb + const sig = "transfer(address,uint256)"; + const selector = functionSelector(sig); + + try std.testing.expectEqual(@as(u8, 0xa9), selector[0]); + try std.testing.expectEqual(@as(u8, 0x05), selector[1]); + try std.testing.expectEqual(@as(u8, 0x9c), selector[2]); + try std.testing.expectEqual(@as(u8, 0xbb), selector[3]); +} + +test "event signature" { + // Transfer(address,address,uint256) + const sig = "Transfer(address,address,uint256)"; + const event_sig = eventSignature(sig); + + // This should produce a specific hash + try std.testing.expect(!event_sig.isZero()); +} + +test "hash pair" { + const a = Hash.fromBytes([_]u8{1} ** 32); + const b = Hash.fromBytes([_]u8{2} ** 32); + + const combined = hashPair(a, b); + try std.testing.expect(!combined.isZero()); + try std.testing.expect(!combined.eql(a)); + try std.testing.expect(!combined.eql(b)); +} diff --git a/src/crypto/secp256k1.zig b/src/crypto/secp256k1.zig index e69de29..f4272cc 100644 --- a/src/crypto/secp256k1.zig +++ b/src/crypto/secp256k1.zig @@ -0,0 +1,249 @@ +const std = @import("std"); +const Address = @import("../primitives/address.zig").Address; +const Hash = @import("../primitives/hash.zig").Hash; +const Signature = @import("../primitives/signature.zig").Signature; +const U256 = @import("../primitives/uint.zig").U256; +const keccak = @import("./keccak.zig"); + +/// secp256k1 curve parameters +pub const Secp256k1 = struct { + /// Field prime (p) + pub const P = U256.fromInt(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F); + + /// Curve order (n) + pub const N = U256.fromInt(0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141); + + /// Generator point G + pub const G_X = U256.fromInt(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798); + pub const G_Y = U256.fromInt(0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8); +}; + +/// Private key (32 bytes) +pub const PrivateKey = struct { + bytes: [32]u8, + + /// Create from bytes + pub fn fromBytes(bytes: [32]u8) !PrivateKey { + // Verify the key is in valid range (0 < key < N) + const key_value = U256.fromBytes(bytes); + if (key_value.isZero() or key_value.gte(Secp256k1.N)) { + return error.InvalidPrivateKey; + } + return .{ .bytes = bytes }; + } + + /// Create from U256 + pub fn fromU256(value: U256) !PrivateKey { + if (value.isZero() or value.gte(Secp256k1.N)) { + return error.InvalidPrivateKey; + } + return .{ .bytes = value.toBytes() }; + } + + /// Generate a random private key + pub fn generate(random: std.rand.Random) !PrivateKey { + var bytes: [32]u8 = undefined; + random.bytes(&bytes); + + // Ensure the key is valid + const key_value = U256.fromBytes(bytes); + if (key_value.isZero() or key_value.gte(Secp256k1.N)) { + // Try again recursively (very unlikely to fail twice) + return try generate(random); + } + + return .{ .bytes = bytes }; + } + + /// Convert to U256 + pub fn toU256(self: PrivateKey) U256 { + return U256.fromBytes(self.bytes); + } +}; + +/// Public key (uncompressed: 64 bytes, compressed: 33 bytes) +pub const PublicKey = struct { + /// X coordinate (32 bytes) + x: [32]u8, + /// Y coordinate (32 bytes) + y: [32]u8, + + /// Create from uncompressed bytes (64 bytes) + pub fn fromUncompressed(bytes: []const u8) !PublicKey { + if (bytes.len != 64) { + return error.InvalidPublicKeyLength; + } + + var pk: PublicKey = undefined; + @memcpy(&pk.x, bytes[0..32]); + @memcpy(&pk.y, bytes[32..64]); + + return pk; + } + + /// Create from compressed bytes (33 bytes) + pub fn fromCompressed(bytes: []const u8) !PublicKey { + if (bytes.len != 33) { + return error.InvalidPublicKeyLength; + } + + // TODO: Implement point decompression + // For now, return error + return error.NotImplemented; + } + + /// Get uncompressed form (64 bytes) + pub fn toUncompressed(self: PublicKey, allocator: std.mem.Allocator) ![]u8 { + const result = try allocator.alloc(u8, 64); + @memcpy(result[0..32], &self.x); + @memcpy(result[32..64], &self.y); + return result; + } + + /// Get compressed form (33 bytes) + pub fn toCompressed(self: PublicKey, allocator: std.mem.Allocator) ![]u8 { + const result = try allocator.alloc(u8, 33); + + // Prefix byte: 0x02 if y is even, 0x03 if y is odd + const y_value = U256.fromBytes(self.y); + result[0] = if (y_value.toU64() & 1 == 0) 0x02 else 0x03; + + @memcpy(result[1..33], &self.x); + return result; + } + + /// Derive Ethereum address from public key + pub fn toAddress(self: PublicKey) Address { + // Ethereum address is the last 20 bytes of Keccak-256(public_key) + var pub_bytes: [64]u8 = undefined; + @memcpy(pub_bytes[0..32], &self.x); + @memcpy(pub_bytes[32..64], &self.y); + + const hash_result = keccak.hash(&pub_bytes); + + var addr_bytes: [20]u8 = undefined; + @memcpy(&addr_bytes, hash_result.bytes[12..32]); + + return Address.fromBytes(addr_bytes); + } +}; + +/// Derive public key from private key +/// Note: This is a placeholder. In production, use libsecp256k1 or a proper EC implementation +pub fn derivePublicKey(private_key: PrivateKey) !PublicKey { + // TODO: Implement proper scalar multiplication on secp256k1 + // For now, return a placeholder + // In production, this would use: public_key = private_key * G + + _ = private_key; + return error.NotImplemented; +} + +/// Sign a message hash with a private key +/// Note: This is a placeholder. In production, use libsecp256k1 +pub fn sign(message_hash: Hash, private_key: PrivateKey) !Signature { + // TODO: Implement proper ECDSA signing + // For now, return a placeholder + + _ = message_hash; + _ = private_key; + return error.NotImplemented; +} + +/// Verify a signature +/// Note: This is a placeholder. In production, use libsecp256k1 +pub fn verify(message_hash: Hash, signature: Signature, public_key: PublicKey) !bool { + // TODO: Implement proper ECDSA verification + + _ = message_hash; + _ = signature; + _ = public_key; + return error.NotImplemented; +} + +/// Recover public key from signature and message hash +/// Note: This is a placeholder. In production, use libsecp256k1 +pub fn recoverPublicKey(message_hash: Hash, signature: Signature) !PublicKey { + // TODO: Implement public key recovery from signature + + _ = message_hash; + _ = signature; + return error.NotImplemented; +} + +test "private key validation" { + // Valid private key + const valid_bytes = [_]u8{1} ** 32; + const pk = try PrivateKey.fromBytes(valid_bytes); + try std.testing.expect(!pk.toU256().isZero()); + + // Zero private key is invalid + const zero_bytes = [_]u8{0} ** 32; + const zero_result = PrivateKey.fromBytes(zero_bytes); + try std.testing.expectError(error.InvalidPrivateKey, zero_result); +} + +test "private key generation" { + var prng = std.rand.DefaultPrng.init(0); + const random = prng.random(); + + const pk = try PrivateKey.generate(random); + try std.testing.expect(!pk.toU256().isZero()); +} + +test "public key uncompressed format" { + const allocator = std.testing.allocator; + + var pk = PublicKey{ + .x = [_]u8{1} ** 32, + .y = [_]u8{2} ** 32, + }; + + const uncompressed = try pk.toUncompressed(allocator); + defer allocator.free(uncompressed); + + try std.testing.expectEqual(@as(usize, 64), uncompressed.len); + try std.testing.expectEqual(@as(u8, 1), uncompressed[0]); + try std.testing.expectEqual(@as(u8, 2), uncompressed[32]); +} + +test "public key to address" { + // Known test vector + var pk = PublicKey{ + .x = [_]u8{0xab} ** 32, + .y = [_]u8{0xcd} ** 32, + }; + + const addr = pk.toAddress(); + + // Address should be 20 bytes and not all zeros + try std.testing.expect(!addr.isZero()); +} + +test "public key compressed format" { + const allocator = std.testing.allocator; + + // Even y-coordinate + var pk_even = PublicKey{ + .x = [_]u8{1} ** 32, + .y = [_]u8{0} ** 32, // Even (last byte is 0) + }; + + const compressed_even = try pk_even.toCompressed(allocator); + defer allocator.free(compressed_even); + + try std.testing.expectEqual(@as(usize, 33), compressed_even.len); + try std.testing.expectEqual(@as(u8, 0x02), compressed_even[0]); + + // Odd y-coordinate + var pk_odd = PublicKey{ + .x = [_]u8{1} ** 32, + .y = [_]u8{1} ** 32, // Odd (last byte is 1) + }; + + const compressed_odd = try pk_odd.toCompressed(allocator); + defer allocator.free(compressed_odd); + + try std.testing.expectEqual(@as(usize, 33), compressed_odd.len); + try std.testing.expectEqual(@as(u8, 0x03), compressed_odd[0]); +} diff --git a/src/crypto/utils.zig b/src/crypto/utils.zig index e69de29..5709526 100644 --- a/src/crypto/utils.zig +++ b/src/crypto/utils.zig @@ -0,0 +1,293 @@ +const std = @import("std"); +const Hash = @import("../primitives/hash.zig").Hash; +const Address = @import("../primitives/address.zig").Address; +const keccak = @import("./keccak.zig"); + +/// Generate a random 32-byte value +pub fn randomBytes32(random: std.rand.Random) [32]u8 { + var bytes: [32]u8 = undefined; + random.bytes(&bytes); + return bytes; +} + +/// Generate random bytes of any length +pub fn randomBytes(allocator: std.mem.Allocator, random: std.rand.Random, len: usize) ![]u8 { + const bytes = try allocator.alloc(u8, len); + random.bytes(bytes); + return bytes; +} + +/// Constant-time comparison of two byte slices +pub fn constantTimeEqual(a: []const u8, b: []const u8) bool { + if (a.len != b.len) { + return false; + } + + var result: u8 = 0; + for (a, b) |a_byte, b_byte| { + result |= a_byte ^ b_byte; + } + + return result == 0; +} + +/// XOR two byte arrays of equal length +pub fn xorBytes(allocator: std.mem.Allocator, a: []const u8, b: []const u8) ![]u8 { + if (a.len != b.len) { + return error.UnequalLength; + } + + const result = try allocator.alloc(u8, a.len); + for (a, b, 0..) |a_byte, b_byte, i| { + result[i] = a_byte ^ b_byte; + } + + return result; +} + +/// Pad data to a multiple of block_size using PKCS#7 padding +pub fn pkcs7Pad(allocator: std.mem.Allocator, data: []const u8, block_size: usize) ![]u8 { + const padding_len = block_size - (data.len % block_size); + const padded_len = data.len + padding_len; + + const result = try allocator.alloc(u8, padded_len); + @memcpy(result[0..data.len], data); + + // Fill padding bytes with padding length value + @memset(result[data.len..], @intCast(padding_len)); + + return result; +} + +/// Remove PKCS#7 padding from data +pub fn pkcs7Unpad(data: []const u8) ![]const u8 { + if (data.len == 0) { + return error.InvalidPadding; + } + + const padding_len = data[data.len - 1]; + if (padding_len == 0 or padding_len > data.len) { + return error.InvalidPadding; + } + + // Verify all padding bytes are correct + const start = data.len - padding_len; + for (data[start..]) |byte| { + if (byte != padding_len) { + return error.InvalidPadding; + } + } + + return data[0..start]; +} + +/// Derive a key using a simple KDF (Key Derivation Function) +/// Note: For production, use proper KDFs like PBKDF2, scrypt, or Argon2 +pub fn deriveKey( + allocator: std.mem.Allocator, + password: []const u8, + salt: []const u8, + iterations: u32, + key_len: usize, +) ![]u8 { + if (iterations == 0) { + return error.InvalidIterations; + } + + const key = try allocator.alloc(u8, key_len); + + // Simple iterative hashing (NOT secure for production) + // Use PBKDF2 or better in production + var current = try allocator.alloc(u8, password.len + salt.len); + defer allocator.free(current); + + @memcpy(current[0..password.len], password); + @memcpy(current[password.len..], salt); + + var i: u32 = 0; + while (i < iterations) : (i += 1) { + const hash_result = keccak.hash(current); + @memcpy(current[0..32], &hash_result.bytes); + if (current.len > 32) { + @memcpy(current[32..], salt); + } + } + + const copy_len = @min(key_len, 32); + @memcpy(key[0..copy_len], current[0..copy_len]); + + return key; +} + +/// Calculate checksum for data using Keccak-256 +pub fn checksum(data: []const u8) [4]u8 { + const hash_result = keccak.hash(data); + var result: [4]u8 = undefined; + @memcpy(&result, hash_result.bytes[0..4]); + return result; +} + +/// Verify checksum +pub fn verifyChecksum(data: []const u8, expected: [4]u8) bool { + const actual = checksum(data); + return std.mem.eql(u8, &actual, &expected); +} + +/// Compute EIP-55 checksummed address +pub fn checksumAddress(address: Address, allocator: std.mem.Allocator) ![]u8 { + const hex = @import("../utils/hex.zig"); + + // Get lowercase hex without 0x prefix + const addr_hex = try hex.bytesToHex(allocator, &address.bytes); + defer allocator.free(addr_hex); + + const addr_lower = addr_hex[2..]; // Skip "0x" + + // Hash the lowercase address + const hash_result = keccak.hash(addr_lower); + + // Create checksummed version + const result = try allocator.alloc(u8, 42); // "0x" + 40 hex chars + result[0] = '0'; + result[1] = 'x'; + + for (addr_lower, 0..) |char, i| { + const hash_byte = hash_result.bytes[i / 2]; + const hash_nibble = if (i % 2 == 0) hash_byte >> 4 else hash_byte & 0x0F; + + if (char >= 'a' and char <= 'f') { + // Uppercase if hash nibble >= 8 + result[2 + i] = if (hash_nibble >= 8) + char - 32 // Convert to uppercase + else + char; + } else { + result[2 + i] = char; + } + } + + return result; +} + +/// Convert address to EIP-1191 checksummed format (chain-specific) +pub fn checksumAddressEip1191( + address: Address, + chain_id: u64, + allocator: std.mem.Allocator, +) ![]u8 { + const hex = @import("../utils/hex.zig"); + + // Format: chain_id + address (lowercase) + const addr_hex = try hex.bytesToHex(allocator, &address.bytes); + defer allocator.free(addr_hex); + + const addr_lower = addr_hex[2..]; // Skip "0x" + + const chain_str = try std.fmt.allocPrint(allocator, "{d}", .{chain_id}); + defer allocator.free(chain_str); + + // Concatenate chain_id + address + const to_hash = try std.fmt.allocPrint(allocator, "{s}{s}", .{ chain_str, addr_lower }); + defer allocator.free(to_hash); + + const hash_result = keccak.hash(to_hash); + + // Create checksummed version + const result = try allocator.alloc(u8, 42); + result[0] = '0'; + result[1] = 'x'; + + for (addr_lower, 0..) |char, i| { + const hash_byte = hash_result.bytes[i / 2]; + const hash_nibble = if (i % 2 == 0) hash_byte >> 4 else hash_byte & 0x0F; + + if (char >= 'a' and char <= 'f') { + result[2 + i] = if (hash_nibble >= 8) char - 32 else char; + } else { + result[2 + i] = char; + } + } + + return result; +} + +test "random bytes generation" { + var prng = std.rand.DefaultPrng.init(0); + const random = prng.random(); + + const bytes1 = randomBytes32(random); + const bytes2 = randomBytes32(random); + + // Should be different + try std.testing.expect(!std.mem.eql(u8, &bytes1, &bytes2)); +} + +test "constant time equal" { + const a = "hello"; + const b = "hello"; + const c = "world"; + + try std.testing.expect(constantTimeEqual(a, b)); + try std.testing.expect(!constantTimeEqual(a, c)); +} + +test "xor bytes" { + const allocator = std.testing.allocator; + + const a = [_]u8{ 0xFF, 0x00, 0xAA }; + const b = [_]u8{ 0x0F, 0xFF, 0x55 }; + + const result = try xorBytes(allocator, &a, &b); + defer allocator.free(result); + + try std.testing.expectEqual(@as(u8, 0xF0), result[0]); + try std.testing.expectEqual(@as(u8, 0xFF), result[1]); + try std.testing.expectEqual(@as(u8, 0xFF), result[2]); +} + +test "pkcs7 padding" { + const allocator = std.testing.allocator; + + const data = "hello"; + const padded = try pkcs7Pad(allocator, data, 8); + defer allocator.free(padded); + + // Should be padded to 8 bytes + try std.testing.expectEqual(@as(usize, 8), padded.len); + + // Last 3 bytes should be 0x03 (padding length) + try std.testing.expectEqual(@as(u8, 3), padded[5]); + try std.testing.expectEqual(@as(u8, 3), padded[6]); + try std.testing.expectEqual(@as(u8, 3), padded[7]); + + // Unpad + const unpadded = try pkcs7Unpad(padded); + try std.testing.expectEqualStrings("hello", unpadded); +} + +test "checksum calculation" { + const data = "hello world"; + const cs = checksum(data); + + // Should be 4 bytes + try std.testing.expect(cs.len == 4); + + // Verify checksum + try std.testing.expect(verifyChecksum(data, cs)); + + // Wrong checksum should fail + const wrong = [_]u8{ 0, 0, 0, 0 }; + try std.testing.expect(!verifyChecksum(data, wrong)); +} + +test "key derivation" { + const allocator = std.testing.allocator; + + const password = "my_password"; + const salt = "random_salt"; + + const key = try deriveKey(allocator, password, salt, 1000, 32); + defer allocator.free(key); + + try std.testing.expectEqual(@as(usize, 32), key.len); +} diff --git a/src/root.zig b/src/root.zig index 622167f..4d8df78 100644 --- a/src/root.zig +++ b/src/root.zig @@ -31,6 +31,15 @@ pub const types = struct { pub const crypto = struct { pub const keccak = @import("crypto/keccak.zig"); pub const secp256k1 = @import("crypto/secp256k1.zig"); + pub const ecdsa = @import("crypto/ecdsa.zig"); + pub const utils = @import("crypto/utils.zig"); + + // Re-export commonly used types + pub const Keccak256 = keccak.Keccak256; + pub const PrivateKey = secp256k1.PrivateKey; + pub const PublicKey = secp256k1.PublicKey; + pub const Signer = ecdsa.Signer; + pub const TransactionSigner = ecdsa.TransactionSigner; }; pub const abi = struct {