diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..86d50fa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,26 @@ +--- +name: Bug Report +about: Report a bug in eth.zig +title: '' +labels: bug +assignees: '' +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To reproduce** +Steps to reproduce the behavior: +1. Code snippet or minimal reproduction +2. Expected output vs actual output + +**Expected behavior** +What you expected to happen. + +**Environment** +- Zig version: [e.g. 0.15.2] +- OS: [e.g. Ubuntu 24.04, macOS 15] +- eth.zig version: [e.g. 0.1.0] + +**Additional context** +Any other context, stack traces, or error messages. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..333fb36 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature Request +about: Suggest a new feature for eth.zig +title: '' +labels: enhancement +assignees: '' +--- + +**What problem does this solve?** +A clear description of the use case or pain point. + +**Proposed solution** +How you think this could be implemented. + +**Alternatives considered** +Any alternative solutions or workarounds you've considered. + +**Additional context** +Any other context, references to EIPs, or examples from other libraries. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..40b711b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,68 @@ +# Changelog + +All notable changes to eth.zig will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.1.0] - 2026-02-26 + +Initial release of eth.zig -- a feature-complete, pure Zig Ethereum client library. + +### Added + +**Primitives** +- `Address` (20 bytes), `Hash` (32 bytes), `Bytes32` types with hex conversions +- EIP-55 checksummed address formatting +- `u256` helpers: `fromBigEndianBytes`, `toBigEndianBytes`, `fromHex`, `toHex` +- Hex encode/decode utilities + +**Encoding** +- RLP encoding/decoding per Ethereum Yellow Paper (structs, tuples, slices, integers) +- ABI encoding: all Solidity types (uint8..uint256, address, bool, bytes, string, arrays, tuples) +- ABI decoding: typed decoding of return values and event data +- Comptime ABI: compile-time function selector and event topic computation +- JSON ABI parser: parse Solidity JSON ABI files into eth.zig types + +**Crypto** +- Keccak-256 hashing (wraps `std.crypto.hash.sha3.Keccak256`) +- secp256k1 ECDSA signing with recovery ID (RFC 6979, EIP-2 low-S) +- Signature types with compact format support +- EIP-155 replay protection + +**Transaction Types** +- Legacy, EIP-2930, EIP-1559, EIP-4844 transaction types +- Transaction serialization (unsigned and signed) +- Receipt, block header, log, and access list types +- EIP-4844 blob types and sidecar construction helpers + +**Accounts** +- BIP-39 mnemonic generation and validation (2048-word English wordlist) +- BIP-32/44 HD wallet key derivation +- Private key signing: messages (EIP-191) and transactions + +**Transport** +- HTTP JSON-RPC transport (`std.http.Client`) +- WebSocket JSON-RPC transport with TLS support +- Subscription management (newHeads, logs, newPendingTransactions) +- JSON-RPC 2.0 request/response types with batch support + +**Client** +- Provider: 24+ read-only RPC methods (getBalance, getBlock, call, estimateGas, getLogs, etc.) +- Wallet: signing client with sendTransaction, waitForReceipt, auto nonce/gas +- Contract: high-level read/write helpers with ABI encoding +- Multicall3: batched contract calls in a single RPC round-trip +- Event log decoding and filtering + +**Standards** +- EIP-712 typed structured data hashing and signing +- ENS resolution: forward (name -> address), reverse (address -> name), namehash +- ERC-20 and ERC-721 contract interaction helpers + +**Chains** +- Chain definitions: Ethereum, Arbitrum, Optimism, Base, Polygon (mainnet + testnets) +- Includes Multicall3 addresses, ENS registries, block explorer URLs + +**Utilities** +- Wei/Gwei/Ether unit conversions +- Common constants (zero address, max uint256, etc.) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a3c499b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,93 @@ +# Contributing to eth.zig + +Thanks for your interest in contributing to eth.zig! This document covers everything you need to get started. + +## Prerequisites + +- [Zig >= 0.15.2](https://ziglang.org/download/) +- Git + +## Getting Started + +```bash +git clone https://github.com/StrobeLabs/eth.zig.git +cd eth.zig + +# Build +zig build + +# Run tests +zig build test + +# Check formatting +zig fmt --check src/ tests/ + +# Format code +zig fmt src/ tests/ +``` + +## Architecture + +eth.zig uses a layered architecture where each layer only depends on layers below it. This makes every layer independently testable. + +``` +Layer 1: Primitives (zero deps, no allocator needed) + primitives.zig, uint256.zig, hex.zig + +Layer 2: Encoding (-> primitives) + rlp.zig, abi_encode.zig, abi_decode.zig, abi_types.zig, abi_comptime.zig + +Layer 3: Crypto (-> primitives) + keccak.zig, secp256k1.zig, signature.zig + +Layer 4: Types (-> primitives, encoding, crypto) + transaction.zig, receipt.zig, block.zig, log.zig, access_list.zig, blob.zig + +Layer 5: Signer (-> crypto, types) + signer.zig, eip155.zig, hd_wallet.zig, mnemonic.zig + +Layer 6: Transport (-> types) + http_transport.zig, ws_transport.zig, json_rpc.zig, subscription.zig + +Layer 7: Client (-> transport, types, encoding) + provider.zig, wallet.zig + +Layer 8: Contract (-> client, encoding, signer) + contract.zig, event.zig, multicall.zig + +Layer 9: Standards (-> client, contract, crypto) + eip712.zig, ens/ + +Layer 10: Chains (pure data, zero deps) + chains/ +``` + +Layers 1-3 have zero I/O. Layers 1-5 have zero network dependencies. + +## Pull Requests + +1. Fork the repo and create a branch from `main` +2. Write your code -- follow existing patterns in the codebase +3. Add tests for new functionality +4. Run `zig fmt src/ tests/` to format your code +5. Run `zig build test` to make sure all tests pass +6. Open a PR against `main` + +## Code Style + +- Follow `zig fmt` formatting (enforced by CI) +- Use descriptive variable names +- Keep functions focused and small +- Add doc comments (`///`) to public functions +- Prefer comptime over runtime where possible -- this is a core design principle +- No external dependencies -- everything builds on Zig's standard library + +## Reporting Issues + +- Use the bug report template for bugs +- Use the feature request template for new features +- Include Zig version, OS, and steps to reproduce + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/README.md b/README.md index dcf43a5..993ece7 100644 --- a/README.md +++ b/README.md @@ -4,32 +4,17 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Zig](https://img.shields.io/badge/Zig-%E2%89%A5%200.15.2-orange)](https://ziglang.org/) -Pure Zig Ethereum client library. Zero dependencies. Comptime-first. +**The Ethereum library for Zig.** -eth.zig provides everything you need to interact with Ethereum from Zig: signing transactions, encoding ABI calls, managing HD wallets, talking to nodes over JSON-RPC, and more -- all built on Zig's standard library with no external dependencies. +eth.zig provides everything you need to interact with Ethereum from Zig -- signing transactions, encoding ABI calls, managing HD wallets, reading ERC-20 tokens, talking to nodes over JSON-RPC, and more. -## Installation +## Why eth.zig? -Add eth.zig as a dependency in your `build.zig.zon`: +**Zero dependencies** -- Built entirely on Zig's standard library. No C bindings, no vendored C code, no system libraries. Just `zig build` and go. -```zig -.dependencies = .{ - .eth = .{ - .url = "https://github.com/strobelabs/eth.zig/archive/refs/tags/v0.1.0.tar.gz", - .hash = "...", // zig build will tell you the correct hash - }, -}, -``` +**Comptime-first** -- Function selectors and event topics are computed at compile time with zero runtime cost. The compiler does the hashing so your program doesn't have to. -Then import it in your `build.zig`: - -```zig -const eth_dep = b.dependency("eth", .{ - .target = target, - .optimize = optimize, -}); -exe.root_module.addImport("eth", eth_dep.module("eth")); -``` +**Pure Zig crypto** -- secp256k1 ECDSA, Keccak-256, BIP-32/39/44 HD wallets -- all implemented in pure Zig. No OpenSSL, no libsecp256k1, no FFI. ## Quick Start @@ -41,7 +26,7 @@ const eth = @import("eth"); const private_key = try eth.hex.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"); const signer = eth.signer.Signer.init(private_key); const addr = try signer.address(); -const checksum = eth.primitives.addressToChecksum(addr); +const checksum = eth.primitives.addressToChecksum(&addr); // "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" ``` @@ -61,18 +46,19 @@ const tx_hash = try wallet.sendTransaction(.{ }); ``` -### Read a smart contract +### Read an ERC-20 token ```zig const eth = @import("eth"); -const selector = eth.keccak.selector("balanceOf(address)"); -const args = [_]eth.abi_encode.AbiValue{.{ .address = holder }}; -const calldata = try eth.abi_encode.encodeFunctionCall(allocator, selector, &args); -defer allocator.free(calldata); +// Comptime selectors -- zero runtime cost +const balance_sel = eth.erc20.selectors.balanceOf; -const result = try provider.call(token_address, calldata); -defer allocator.free(result); +// Or use the typed wrapper +var token = eth.erc20.ERC20.init(allocator, token_addr, &provider); +const balance = try token.balanceOf(holder_addr); +const name = try token.name(); +defer allocator.free(name); ``` ### Comptime function selectors and event topics @@ -93,12 +79,63 @@ const transfer_topic = eth.abi_comptime.comptimeTopic("Transfer(address,address, ```zig const eth = @import("eth"); -const words = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; -const seed = try eth.mnemonic.mnemonicToSeed(allocator, words, ""); -defer allocator.free(seed); +const words = [_][]const u8{ + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "abandon", + "abandon", "abandon", "abandon", "about", +}; +const seed = try eth.mnemonic.toSeed(&words, ""); +const key = try eth.hd_wallet.deriveEthAccount(seed, 0); +const addr = key.toAddress(); +``` + +## Installation + +**One-liner:** -const master = try eth.hd_wallet.masterKeyFromSeed(seed); -const child = try eth.hd_wallet.deriveEthereumKey(master, 0); +```bash +zig fetch --save git+https://github.com/StrobeLabs/eth.zig.git#v0.1.0 +``` + +**Or add manually** to your `build.zig.zon`: + +```zig +.dependencies = .{ + .eth = .{ + .url = "git+https://github.com/StrobeLabs/eth.zig.git#v0.1.0", + .hash = "...", // run `zig build` and it will tell you the expected hash + }, +}, +``` + +Then import in your `build.zig`: + +```zig +const eth_dep = b.dependency("eth", .{ + .target = target, + .optimize = optimize, +}); +exe.root_module.addImport("eth", eth_dep.module("eth")); +``` + +## Examples + +The [`examples/`](examples/) directory contains self-contained programs demonstrating each major feature: + +| Example | Description | Requires RPC | +|---------|-------------|:---:| +| `01_derive_address` | Derive address from private key | No | +| `02_check_balance` | Query ETH balance via JSON-RPC | Yes | +| `03_sign_message` | EIP-191 personal message signing | No | +| `04_send_transaction` | Send ETH with Wallet | Yes (Anvil) | +| `05_read_erc20` | ERC-20 module API showcase | Yes | +| `06_hd_wallet` | BIP-44 HD wallet derivation | No | +| `07_comptime_selectors` | Comptime function selectors | No | + +Run any example: + +```bash +cd examples && zig build && ./zig-out/bin/01_derive_address ``` ## Modules @@ -112,8 +149,8 @@ const child = try eth.hd_wallet.deriveEthereumKey(master, 0); | **Accounts** | `mnemonic`, `hd_wallet` | BIP-32/39/44 HD wallets and mnemonic generation | | **Transport** | `http_transport`, `ws_transport`, `json_rpc`, `provider`, `subscription` | HTTP and WebSocket JSON-RPC transports | | **ENS** | `ens_namehash`, `ens_resolver`, `ens_reverse` | ENS name resolution and reverse lookup | -| **Client** | `wallet`, `contract`, `multicall`, `event` | Signing wallet, contract interaction, Multicall3 | -| **Standards** | `eip712` | EIP-712 typed structured data signing | +| **Client** | `wallet`, `contract`, `multicall`, `event`, `erc20`, `erc721` | Signing wallet, contract interaction, Multicall3, token wrappers | +| **Standards** | `eip712`, `abi_json` | EIP-712 typed data signing, Solidity JSON ABI parsing | | **Chains** | `chains` | Ethereum, Arbitrum, Optimism, Base, Polygon definitions | ## Features @@ -140,11 +177,30 @@ const child = try eth.hd_wallet.deriveEthereumKey(master, 0); | Event log decoding and filtering | Complete | | Chain definitions (5 networks) | Complete | | Unit conversions (Wei/Gwei/Ether) | Complete | +| ERC-20 typed wrapper | Complete | +| ERC-721 typed wrapper | Complete | +| JSON ABI parsing | Complete | | EIP-7702 transactions | Planned | | IPC transport | Planned | | Provider middleware (retry, caching) | Planned | | Hardware wallet signers | Planned | +## Feature Comparison vs Zabi + +| Feature | eth.zig | Zabi | +|---------|---------|------| +| Dependencies | 0 | 0 | +| Comptime selectors | Yes | No | +| Pure Zig crypto (secp256k1) | Yes | No (C binding) | +| ABI encode/decode | Yes | Yes | +| HD wallets (BIP-32/39/44) | Yes | Yes | +| ERC-20/721 wrappers | Yes | No | +| JSON ABI parsing | Yes | Yes | +| WebSocket transport | Yes | Yes | +| ENS resolution | Yes | Yes | +| EIP-712 typed data | Yes | Yes | +| Multicall3 | Yes | No | + ## Requirements - Zig >= 0.15.2 @@ -156,8 +212,18 @@ zig build test # Unit tests zig build integration-test # Integration tests (requires Anvil) ``` +## Contributing + +Contributions are welcome. Please open an issue or pull request on [GitHub](https://github.com/StrobeLabs/eth.zig). + +Before submitting: + +1. Run `zig build test` and ensure all tests pass. +2. Follow the existing code style -- no external dependencies, comptime where possible. +3. Add tests for any new functionality. + ## License MIT -- see [LICENSE](LICENSE) for details. -Copyright 2025 Strobe Labs +Copyright 2025-2026 Strobe Labs diff --git a/bench/bench.zig b/bench/bench.zig new file mode 100644 index 0000000..0e138fa --- /dev/null +++ b/bench/bench.zig @@ -0,0 +1,313 @@ +const std = @import("std"); +const eth = @import("eth"); + +// ============================================================================ +// Benchmark harness +// ============================================================================ + +const BenchResult = struct { + name: []const u8, + iterations: u64, + total_ns: u64, + + fn nsPerOp(self: BenchResult) u64 { + if (self.total_ns == 0) return 0; + return self.total_ns / self.iterations; + } + + fn opsPerSec(self: BenchResult) u64 { + if (self.total_ns == 0) return 0; + return self.iterations * 1_000_000_000 / self.total_ns; + } +}; + +fn bench(name: []const u8, warmup: u32, iterations: u32, comptime func: fn () void) BenchResult { + for (0..warmup) |_| { + func(); + } + + var timer = std.time.Timer.start() catch @panic("timer unavailable"); + for (0..iterations) |_| { + func(); + } + const elapsed = timer.read(); + + return .{ + .name = name, + .iterations = iterations, + .total_ns = elapsed, + }; +} + +fn benchAlloc(name: []const u8, warmup: u32, iterations: u32, allocator: std.mem.Allocator, comptime func: fn (std.mem.Allocator) void) BenchResult { + for (0..warmup) |_| { + func(allocator); + } + + var timer = std.time.Timer.start() catch @panic("timer unavailable"); + for (0..iterations) |_| { + func(allocator); + } + const elapsed = timer.read(); + + return .{ + .name = name, + .iterations = iterations, + .total_ns = elapsed, + }; +} + +fn printResults(results: []const BenchResult) void { + var buf: [8192]u8 = undefined; + var writer_impl = std.fs.File.stdout().writer(&buf); + const w = &writer_impl.interface; + + w.print("\neth.zig benchmarks (ReleaseFast)\n", .{}) catch {}; + w.print("{s}\n", .{"=" ** 62}) catch {}; + w.print("{s: <34} {s: >12} {s: >12}\n", .{ "Benchmark", "ops/sec", "ns/op" }) catch {}; + w.print("{s}\n", .{"-" ** 62}) catch {}; + + for (results) |r| { + const ops = r.opsPerSec(); + const ns = r.nsPerOp(); + + // Format ops/sec with comma separators + var ops_buf: [24]u8 = undefined; + const ops_str = formatWithCommas(ops, &ops_buf); + + w.print("{s: <34} {s: >12} {d: >12}\n", .{ r.name, ops_str, ns }) catch {}; + } + + w.print("{s}\n\n", .{"=" ** 62}) catch {}; + w.flush() catch {}; +} + +fn formatWithCommas(value: u64, out_buf: []u8) []const u8 { + if (value == 0) { + out_buf[0] = '0'; + return out_buf[0..1]; + } + + // Write digits to a temp buffer (reversed order: least significant first) + var digits: [20]u8 = undefined; + var digit_count: usize = 0; + var v = value; + while (v > 0) { + digits[digit_count] = @intCast(v % 10 + '0'); + digit_count += 1; + v /= 10; + } + + // Write with commas. We iterate from the least significant digit, + // inserting a comma every 3 digits, then reverse the whole thing. + var tmp: [28]u8 = undefined; // 20 digits + up to 6 commas + slack + var pos: usize = 0; + for (0..digit_count) |i| { + if (i > 0 and i % 3 == 0) { + tmp[pos] = ','; + pos += 1; + } + tmp[pos] = digits[i]; + pos += 1; + } + + // Reverse into out_buf + for (0..pos) |i| { + out_buf[i] = tmp[pos - 1 - i]; + } + return out_buf[0..pos]; +} + +// ============================================================================ +// Test data (Anvil account 0 -- well-known test key) +// ============================================================================ + +const TEST_PRIVKEY: [32]u8 = .{ + 0xac, 0x09, 0x74, 0xbe, 0xc3, 0x9a, 0x17, 0xe3, + 0x6b, 0xa4, 0xa6, 0xb4, 0xd2, 0x38, 0xff, 0x94, + 0x4b, 0xac, 0xb4, 0x78, 0xcb, 0xed, 0x5e, 0xfc, + 0xae, 0x78, 0x4d, 0x7b, 0xf4, 0xf2, 0xff, 0x80, +}; + +// keccak256("") -- a well-known hash +const TEST_MSG_HASH: [32]u8 = .{ + 0xc5, 0xd2, 0x46, 0x01, 0x86, 0xf7, 0x23, 0x3c, + 0x92, 0x7e, 0x7d, 0xb2, 0xdc, 0xc7, 0x03, 0xc0, + 0xe5, 0x00, 0xb6, 0x53, 0xca, 0x82, 0x27, 0x3b, + 0x7b, 0xfa, 0xd8, 0x04, 0x5d, 0x85, 0xa4, 0x70, +}; + +const TEST_ADDR: [20]u8 = .{ + 0xf3, 0x9F, 0xd6, 0xe5, 0x1a, 0xad, 0x88, 0xF6, + 0xF4, 0xce, 0x6a, 0xB8, 0x82, 0x72, 0x79, 0xcf, + 0xfF, 0xb9, 0x22, 0x66, +}; + +// BIP-39 test seed from "abandon" x11 + "about" +const TEST_SEED: [64]u8 = .{ + 0x5e, 0xb0, 0x0b, 0xbd, 0xdc, 0xf0, 0x69, 0x08, + 0x48, 0x89, 0xa8, 0xab, 0x91, 0x55, 0x56, 0x81, + 0x65, 0xf5, 0xc4, 0x53, 0xcc, 0xb8, 0x5e, 0x70, + 0x81, 0x1a, 0xae, 0xd6, 0xf6, 0xda, 0x5f, 0xc1, + 0x9a, 0x5a, 0xc4, 0x0b, 0x38, 0x9c, 0xd3, 0x70, + 0xd0, 0x86, 0x20, 0x6d, 0xec, 0x8a, 0xa6, 0xc4, + 0x3d, 0xae, 0xa6, 0x69, 0x0f, 0x20, 0xad, 0x3d, + 0x8d, 0x48, 0xb2, 0xd2, 0xce, 0x9e, 0x38, 0xe4, +}; + +// Precomputed selector for transfer(address,uint256) +const TRANSFER_SELECTOR: [4]u8 = .{ 0xa9, 0x05, 0x9c, 0xbb }; + +// ============================================================================ +// Benchmark functions +// ============================================================================ + +fn benchKeccak32() void { + const data: [32]u8 = TEST_MSG_HASH; + const result = eth.keccak.hash(&data); + std.mem.doNotOptimizeAway(&result); +} + +fn benchKeccak1k() void { + const data: [1024]u8 = .{0xAB} ** 1024; + const result = eth.keccak.hash(&data); + std.mem.doNotOptimizeAway(&result); +} + +fn benchSecp256k1Sign() void { + const sig = eth.secp256k1.sign(TEST_PRIVKEY, TEST_MSG_HASH) catch unreachable; + std.mem.doNotOptimizeAway(&sig); +} + +fn benchSecp256k1Recover() void { + const sig = eth.secp256k1.sign(TEST_PRIVKEY, TEST_MSG_HASH) catch unreachable; + const pubkey = eth.secp256k1.recover(sig, TEST_MSG_HASH) catch unreachable; + std.mem.doNotOptimizeAway(&pubkey); +} + +fn benchAddressDerivation() void { + const s = eth.signer.Signer.init(TEST_PRIVKEY); + const addr = s.address() catch unreachable; + std.mem.doNotOptimizeAway(&addr); +} + +fn benchAbiEncodeTransfer(allocator: std.mem.Allocator) void { + const args = [_]eth.abi_encode.AbiValue{ + .{ .address = TEST_ADDR }, + .{ .uint256 = 1_000_000_000_000_000_000 }, // 1 ETH in wei + }; + const result = eth.abi_encode.encodeFunctionCall(allocator, TRANSFER_SELECTOR, &args) catch unreachable; + defer allocator.free(result); + std.mem.doNotOptimizeAway(result.ptr); +} + +fn benchAbiDecodeUint256(allocator: std.mem.Allocator) void { + // Pre-encoded uint256 value: 1 ETH in wei, left-padded to 32 bytes + const encoded: [32]u8 = .{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x0D, 0xE0, 0xB6, 0xB3, 0xA7, 0x64, 0x00, 0x00, + }; + const types = [_]eth.abi_types.AbiType{.uint256}; + const values = eth.abi_decode.decodeValues(&encoded, &types, allocator) catch unreachable; + defer eth.abi_decode.freeValues(values, allocator); + std.mem.doNotOptimizeAway(values.ptr); +} + +fn benchRlpEncodeTx(allocator: std.mem.Allocator) void { + const tx = eth.transaction.Transaction{ + .eip1559 = .{ + .chain_id = 1, + .nonce = 42, + .max_priority_fee_per_gas = 2_000_000_000, // 2 gwei + .max_fee_per_gas = 100_000_000_000, // 100 gwei + .gas_limit = 21000, + .to = TEST_ADDR, + .value = 1_000_000_000_000_000_000, // 1 ETH + .data = &.{}, + .access_list = &.{}, + }, + }; + const serialized = eth.transaction.serializeForSigning(allocator, tx) catch unreachable; + defer allocator.free(serialized); + std.mem.doNotOptimizeAway(serialized.ptr); +} + +fn benchHdWalletDerive10() void { + const master = eth.hd_wallet.masterKeyFromSeed(TEST_SEED) catch unreachable; + for (0..10) |i| { + const child = eth.hd_wallet.deriveChild(master, @intCast(i)) catch unreachable; + std.mem.doNotOptimizeAway(&child); + } +} + +fn benchEip712Hash(allocator: std.mem.Allocator) void { + const domain = eth.eip712.DomainSeparator{ + .name = "TestDApp", + .version = "1", + .chain_id = 1, + .verifying_contract = TEST_ADDR, + }; + + const transfer_type = eth.eip712.TypeDef{ + .name = "Transfer", + .fields = &.{ + .{ .name = "to", .type_str = "address" }, + .{ .name = "amount", .type_str = "uint256" }, + }, + }; + + const message = eth.eip712.StructValue{ + .type_name = "Transfer", + .fields = &.{ + .{ .name = "to", .type_str = "address", .value = .{ .address = TEST_ADDR } }, + .{ .name = "amount", .type_str = "uint256", .value = .{ .uint256 = 1_000_000_000_000_000_000 } }, + }, + }; + + const result = eth.eip712.hashTypedData( + allocator, + domain, + message, + &.{transfer_type}, + ) catch unreachable; + std.mem.doNotOptimizeAway(&result); +} + +fn benchChecksumAddress() void { + const addr = TEST_ADDR; + const checksum = eth.primitives.addressToChecksum(&addr); + std.mem.doNotOptimizeAway(&checksum); +} + +// ============================================================================ +// Main +// ============================================================================ + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + const WARMUP = 100; + const ITERS = 10_000; + // Signing/EC ops are expensive -- use fewer iterations + const SIGN_ITERS = 1_000; + + const results = [_]BenchResult{ + bench("keccak256_32b", WARMUP, ITERS, benchKeccak32), + bench("keccak256_1kb", WARMUP, ITERS, benchKeccak1k), + bench("secp256k1_sign", WARMUP, SIGN_ITERS, benchSecp256k1Sign), + bench("secp256k1_sign_recover", WARMUP, SIGN_ITERS, benchSecp256k1Recover), + bench("address_derivation", WARMUP, SIGN_ITERS, benchAddressDerivation), + benchAlloc("abi_encode_transfer", WARMUP, ITERS, allocator, benchAbiEncodeTransfer), + benchAlloc("abi_decode_uint256", WARMUP, ITERS, allocator, benchAbiDecodeUint256), + benchAlloc("rlp_encode_eip1559_tx", WARMUP, ITERS, allocator, benchRlpEncodeTx), + bench("hd_wallet_derive_10", WARMUP, SIGN_ITERS, benchHdWalletDerive10), + benchAlloc("eip712_hash_typed_data", WARMUP, ITERS, allocator, benchEip712Hash), + bench("checksum_address", WARMUP, ITERS, benchChecksumAddress), + }; + + printResults(&results); +} diff --git a/build.zig b/build.zig index a3b4f72..802f712 100644 --- a/build.zig +++ b/build.zig @@ -42,4 +42,27 @@ pub fn build(b: *std.Build) void { const run_integration_tests = b.addRunArtifact(integration_tests); const integration_step = b.step("integration-test", "Run integration tests (requires Anvil)"); integration_step.dependOn(&run_integration_tests.step); + + // Benchmarks (always ReleaseFast for meaningful numbers) + const bench_module = b.addModule("eth_bench", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + .optimize = .ReleaseFast, + }); + + const bench_exe = b.addExecutable(.{ + .name = "bench", + .root_module = b.createModule(.{ + .root_source_file = b.path("bench/bench.zig"), + .target = target, + .optimize = .ReleaseFast, + .imports = &.{ + .{ .name = "eth", .module = bench_module }, + }, + }), + }); + + const run_bench = b.addRunArtifact(bench_exe); + const bench_step = b.step("bench", "Run benchmarks (ReleaseFast)"); + bench_step.dependOn(&run_bench.step); } diff --git a/src/provider.zig b/src/provider.zig index b5495d8..c05bc64 100644 --- a/src/provider.zig +++ b/src/provider.zig @@ -33,7 +33,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_chainId, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -42,7 +43,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_blockNumber, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -58,7 +60,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getBalance, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -70,7 +73,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getTransactionCount, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -83,7 +87,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getCode, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexBytes(self.allocator, result_str); } @@ -106,7 +111,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_getStorageAt, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return hex_mod.hexToBytesFixed(32, result_str) catch return error.InvalidResponse; } @@ -119,7 +125,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_gasPrice, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -128,7 +135,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_maxPriorityFeePerGas, "[]"); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU256(result_str); } @@ -145,7 +153,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_call, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexBytes(self.allocator, result_str); } @@ -157,7 +166,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_estimateGas, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return parseHexU64(result_str); } @@ -178,7 +188,8 @@ pub const Provider = struct { const raw = try self.rpcCall(json_rpc.Method.eth_sendRawTransaction, params); defer self.allocator.free(raw); - const result_str = try extractResultString(raw); + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); return primitives.hashFromHex(result_str) catch return error.InvalidResponse; } @@ -302,7 +313,8 @@ pub const Provider = struct { /// Extract the "result" string value from a JSON-RPC response. /// Handles both quoted string results and null. -fn extractResultString(raw: []const u8) ![]const u8 { +/// Caller owns the returned memory. +fn extractResultString(allocator: std.mem.Allocator, raw: []const u8) ![]u8 { const parsed = std.json.parseFromSlice(std.json.Value, std.heap.page_allocator, raw, .{}) catch { return error.InvalidResponse; }; @@ -321,7 +333,7 @@ fn extractResultString(raw: []const u8) ![]const u8 { const result_val = root.object.get("result") orelse return error.InvalidResponse; return switch (result_val) { - .string => |s| s, + .string => |s| allocator.dupe(u8, s) catch return error.InvalidResponse, .null => error.NullResult, else => error.InvalidResponse, }; @@ -410,13 +422,15 @@ fn parseHash(hex_str: []const u8) ![32]u8 { fn parseOptionalHash(hex_str: ?[]const u8) !?[32]u8 { const s = hex_str orelse return null; if (s.len == 0) return null; - return parseHash(s); + const val = try parseHash(s); + return val; } /// Parse an optional hex u64 value. fn parseOptionalHexU64(hex_str: ?[]const u8) !?u64 { const s = hex_str orelse return null; - return parseHexU64(s); + const val = try parseHexU64(s); + return val; } /// Get a string value from a JSON object, returning null if not present or null. @@ -749,25 +763,29 @@ fn appendJsonField(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), key: [ // ============================================================================ test "extractResultString - string result" { + const allocator = std.testing.allocator; const raw = \\{"jsonrpc":"2.0","id":1,"result":"0xff"} ; - const result = try extractResultString(raw); + const result = try extractResultString(allocator, raw); + defer allocator.free(result); try std.testing.expectEqualStrings("0xff", result); } test "extractResultString - null result" { + const allocator = std.testing.allocator; const raw = \\{"jsonrpc":"2.0","id":1,"result":null} ; - try std.testing.expectError(error.NullResult, extractResultString(raw)); + try std.testing.expectError(error.NullResult, extractResultString(allocator, raw)); } test "extractResultString - rpc error" { + const allocator = std.testing.allocator; const raw = \\{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}} ; - try std.testing.expectError(error.RpcError, extractResultString(raw)); + try std.testing.expectError(error.RpcError, extractResultString(allocator, raw)); } test "parseHexU64 - basic values" { diff --git a/src/wallet.zig b/src/wallet.zig index 596f355..a62282d 100644 --- a/src/wallet.zig +++ b/src/wallet.zig @@ -134,7 +134,7 @@ pub const Wallet = struct { if (try self.provider.getTransactionReceipt(tx_hash)) |receipt| { return receipt; } - std.time.sleep(1_000_000_000); // 1 second + std.Thread.sleep(1_000_000_000); // 1 second } return null; } diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index e4e193e..c50aa3a 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -1,6 +1,331 @@ -// Integration tests will be added once the transport + provider layers are implemented. -// These tests require a running Anvil instance. +// Integration tests for eth.zig against a local Anvil instance. +// These tests require Anvil running at http://127.0.0.1:8545. +// +// Start Anvil before running: +// anvil +// +// Run tests: +// zig build integration-test -test "placeholder" { - // Placeholder to allow build to succeed +const std = @import("std"); +const eth = @import("eth"); + +const ANVIL_URL = "http://127.0.0.1:8545"; +const ANVIL_HOST = "127.0.0.1"; +const ANVIL_PORT = 8545; + +// Anvil pre-funded account #0 +const ACCOUNT_0_KEY_HEX = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const ACCOUNT_0_ADDR_HEX = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +// Anvil pre-funded account #1 +const ACCOUNT_1_ADDR_HEX = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + +// Anvil default chain ID +const ANVIL_CHAIN_ID: u64 = 31337; + +/// Check if Anvil is reachable by opening a TCP connection to 127.0.0.1:8545. +/// This avoids going through the HTTP client which can crash on connection refused. +fn isAnvilAvailable() bool { + const addr = std.net.Address.parseIp4(ANVIL_HOST, ANVIL_PORT) catch return false; + const stream = std.posix.socket(addr.any.family, std.posix.SOCK.STREAM, 0) catch return false; + defer std.posix.close(stream); + std.posix.connect(stream, &addr.any, addr.getOsSockLen()) catch return false; + return true; +} + +// ============================================================================ +// Chain state tests +// ============================================================================ + +test "getChainId returns 31337 (Anvil default)" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const chain_id = try provider.getChainId(); + try std.testing.expectEqual(ANVIL_CHAIN_ID, chain_id); +} + +test "getBlockNumber returns a value" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const block_number = try provider.getBlockNumber(); + // Anvil starts at block 0; any non-negative value is acceptable. + try std.testing.expect(block_number >= 0); +} + +// ============================================================================ +// Account state tests +// ============================================================================ + +test "getBalance of funded account" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const addr = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + const balance = try provider.getBalance(addr); + + // Anvil accounts start with 10000 ETH. Even after some tests run the + // balance should be well above 1 ETH (= 10^18 wei). + const one_ether = eth.units.parseEther(1.0); + try std.testing.expect(balance >= one_ether); +} + +test "getTransactionCount of account" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const addr = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + const nonce = try provider.getTransactionCount(addr); + + // Nonce is a non-negative integer. On a fresh Anvil it is 0, but we + // do not assert equality because prior test runs may have sent txns. + try std.testing.expect(nonce >= 0); +} + +test "getCode of EOA returns empty" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const addr = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + const code = try provider.getCode(addr); + defer allocator.free(code); + + // An externally-owned account has no code. + try std.testing.expectEqual(@as(usize, 0), code.len); +} + +// ============================================================================ +// Gas tests +// ============================================================================ + +test "getGasPrice returns non-zero" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const gas_price = try provider.getGasPrice(); + try std.testing.expect(gas_price > 0); +} + +test "getMaxPriorityFee returns a value" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + // This should not error. The value can be 0 on Anvil. + _ = try provider.getMaxPriorityFee(); +} + +// ============================================================================ +// Block tests +// ============================================================================ + +test "getBlock for block 0 returns genesis" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const maybe_block = try provider.getBlock(0); + try std.testing.expect(maybe_block != null); + + const header = maybe_block.?; + defer allocator.free(header.extra_data); + + try std.testing.expectEqual(@as(u64, 0), header.number); + // Genesis block parent hash is all zeros. + try std.testing.expectEqualSlices(u8, &([_]u8{0} ** 32), &header.parent_hash); +} + +test "getBlock for non-existent block returns null" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + // Use a very large block number that cannot exist. + const maybe_block = try provider.getBlock(999_999_999); + try std.testing.expect(maybe_block == null); +} + +// ============================================================================ +// Wallet address derivation test +// ============================================================================ + +test "Wallet.address derives correct address from private key" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const private_key = try eth.hex.hexToBytesFixed(32, ACCOUNT_0_KEY_HEX); + const wallet = eth.wallet.Wallet.init(allocator, private_key, &provider); + + const addr = try wallet.address(); + const expected = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + try std.testing.expectEqualSlices(u8, &expected, &addr); +} + +// ============================================================================ +// Transaction tests +// ============================================================================ + +test "send ETH transfer and verify receipt" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const private_key = try eth.hex.hexToBytesFixed(32, ACCOUNT_0_KEY_HEX); + var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider); + + const recipient = try eth.primitives.addressFromHex(ACCOUNT_1_ADDR_HEX); + const send_value = eth.units.parseEther(0.01); + + // Record initial balance of recipient. + const balance_before = try provider.getBalance(recipient); + + // Send 0.01 ETH from account #0 to account #1. + const tx_hash = try wallet.sendTransaction(.{ + .to = recipient, + .value = send_value, + }); + + // Anvil mines transactions immediately, so the receipt should be available + // right away. Poll up to 10 times (1 second each) just in case. + const maybe_receipt = try wallet.waitForReceipt(tx_hash, 10); + try std.testing.expect(maybe_receipt != null); + + const receipt = maybe_receipt.?; + + // Verify receipt fields. + try std.testing.expectEqual(@as(u8, 1), receipt.status); // success + try std.testing.expectEqualSlices(u8, &tx_hash, &receipt.transaction_hash); + + // Verify the recipient address in the receipt. + if (receipt.to) |to_addr| { + try std.testing.expectEqualSlices(u8, &recipient, &to_addr); + } else { + return error.TestUnexpectedResult; + } + + // Verify the sender address in the receipt. + const sender = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + try std.testing.expectEqualSlices(u8, &sender, &receipt.from); + + // Verify recipient balance increased by the sent amount. + const balance_after = try provider.getBalance(recipient); + try std.testing.expectEqual(balance_before + send_value, balance_after); +} + +test "estimateGas for simple ETH transfer" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const from = try eth.primitives.addressFromHex(ACCOUNT_0_ADDR_HEX); + const to = try eth.primitives.addressFromHex(ACCOUNT_1_ADDR_HEX); + + const gas = try provider.estimateGas(to, &.{}, from); + + // A simple ETH transfer costs exactly 21000 gas. + try std.testing.expectEqual(@as(u64, 21000), gas); +} + +test "sendTransactionAndWait returns receipt directly" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + const private_key = try eth.hex.hexToBytesFixed(32, ACCOUNT_0_KEY_HEX); + var wallet = eth.wallet.Wallet.init(allocator, private_key, &provider); + + const recipient = try eth.primitives.addressFromHex(ACCOUNT_1_ADDR_HEX); + const send_value = eth.units.parseEther(0.001); + + const receipt = try wallet.sendTransactionAndWait(.{ + .to = recipient, + .value = send_value, + }, 10); + + try std.testing.expectEqual(@as(u8, 1), receipt.status); + try std.testing.expect(receipt.gas_used > 0); +} + +test "getTransactionReceipt for unknown hash returns null" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + // A made-up transaction hash that does not exist. + const fake_hash = [_]u8{0xab} ** 32; + const receipt = try provider.getTransactionReceipt(fake_hash); + try std.testing.expect(receipt == null); +} + +// ============================================================================ +// Multiple calls test (stateful provider) +// ============================================================================ + +test "provider next_id increments across calls" { + if (!isAnvilAvailable()) return; + const allocator = std.testing.allocator; + + var transport = eth.http_transport.HttpTransport.init(allocator, ANVIL_URL); + defer transport.deinit(); + var provider = eth.provider.Provider.init(allocator, &transport); + + try std.testing.expectEqual(@as(u64, 1), provider.next_id); + + _ = try provider.getChainId(); + try std.testing.expect(provider.next_id > 1); + + const id_after_first = provider.next_id; + _ = try provider.getBlockNumber(); + try std.testing.expect(provider.next_id > id_after_first); }