From b7bc5b44f935ccec2f7d7dcd6a0cbb2296573c62 Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Fri, 1 May 2026 15:46:03 -0400 Subject: [PATCH 1/3] Add eth_call state overrides Adds Provider.callWithOverrides so simulators can answer "what if?" questions (modified balances, nonces, code, or storage) without forking a node. Maps to the geth-style third parameter of eth_call: eth_call(transaction, blockTag, stateOverrideObject) Supported by Anvil, Geth, Reth, Alchemy, Infura, and QuickNode. New module: src/state_overrides.zig pub const StateOverrides = struct { pub fn setBalance(addr, value) !void pub fn setNonce(addr, value) !void pub fn setCode(addr, bytecode) !void // deep-copies the bytes pub fn setStorageAt(addr, slot, value) !void // overlay onto existing state pub fn setFullStorage(addr, slot, value) !void // replace state entirely pub fn serializeJson(allocator) ![]u8 }; setStorageAt feeds the JSON-RPC `stateDiff` field; setFullStorage feeds `state`. Multiple addresses, multiple slots, and arbitrary combinations of fields per address are supported. Provider: - callWithOverrides(to, data, *const StateOverrides) ![]u8 - formatCallParamsWithOverrides() helper that produces the three-arg eth_call body shape. Tests: - 9 unit tests in state_overrides.zig (empty -> {}, balance / nonce / code combined, stateDiff merging, full-state replacement, setCode overwrite without leak, multiple addresses, hex encoding edge cases). - 2 unit tests in provider.zig for formatCallParamsWithOverrides (with and without overrides; verifies wire shape). - 3 integration tests against Anvil: - code + balance override (deploy ADDRESS BALANCE return-bytecode, verify the overridden balance is observed) - stateDiff (deploy SLOAD return-bytecode, verify slot value) - empty overrides path matches plain eth_call All 1300+ unit tests pass; 21/21 integration tests pass against Anvil. Closes #12. --- README.md | 19 ++ src/provider.zig | 110 +++++++++++ src/root.zig | 2 + src/state_overrides.zig | 383 ++++++++++++++++++++++++++++++++++++ tests/integration_tests.zig | 87 ++++++++ 5 files changed, 601 insertions(+) create mode 100644 src/state_overrides.zig diff --git a/README.md b/README.md index 25383fd..e97efca 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,25 @@ const name = try token.name(); defer allocator.free(name); ``` +### Simulate eth_call with state overrides + +Lets searchers answer "what if?" questions without forking a node: what if this token balance were larger, what if this storage slot held a different value, what if this address ran alternative bytecode? Maps to the geth-style third argument to `eth_call`. + +```zig +const eth = @import("eth"); + +var overrides = eth.state_overrides.StateOverrides.init(allocator); +defer overrides.deinit(); + +// Pretend this account is rich, has nonce 0, and the pool has a hand-tuned reserve. +try overrides.setBalance(searcher_addr, 1_000_000 * std.math.pow(u256, 10, 18)); +try overrides.setNonce(searcher_addr, 0); +try overrides.setStorageAt(pool_addr, reserves_slot, new_reserves_word); + +const result = try provider.callWithOverrides(target_contract, calldata, &overrides); +defer allocator.free(result); +``` + ### Function selectors and event topics ```zig diff --git a/src/provider.zig b/src/provider.zig index db23afb..7c11257 100644 --- a/src/provider.zig +++ b/src/provider.zig @@ -5,6 +5,7 @@ const uint256_mod = @import("uint256.zig"); const primitives = @import("primitives.zig"); const receipt_mod = @import("receipt.zig"); const block_mod = @import("block.zig"); +const state_overrides_mod = @import("state_overrides.zig"); const HttpTransport = @import("http_transport.zig").HttpTransport; /// Read-only Ethereum JSON-RPC provider. @@ -158,6 +159,33 @@ pub const Provider = struct { return parseHexBytes(self.allocator, result_str); } + /// Executes a message call (eth_call) against the latest block with + /// state overrides applied. Lets simulators answer "what if?" questions + /// (modified balances, code, storage) without forking a node. + /// + /// Maps to the third parameter of the geth-style eth_call: + /// `eth_call(transaction, blockTag, stateOverrideObject)`. + /// Most production providers (Alchemy, Infura, QuickNode, Anvil) + /// accept this third argument. + /// + /// Caller owns the returned memory. + pub fn callWithOverrides( + self: *Provider, + to: [20]u8, + data: []const u8, + overrides: *const state_overrides_mod.StateOverrides, + ) ![]u8 { + const params = try self.formatCallParamsWithOverrides(to, data, null, overrides); + defer self.allocator.free(params); + + const raw = try self.rpcCall(json_rpc.Method.eth_call, params); + defer self.allocator.free(raw); + + const result_str = try extractResultString(self.allocator, raw); + defer self.allocator.free(result_str); + return parseHexBytes(self.allocator, result_str); + } + /// Estimates the gas needed to execute the given transaction. pub fn estimateGas(self: *Provider, to: [20]u8, data: []const u8, from: ?[20]u8) !u64 { const params = try self.formatCallParams(to, data, from); @@ -305,6 +333,44 @@ pub const Provider = struct { return buf.toOwnedSlice(self.allocator); } + + /// Like `formatCallParams`, but emits the third state-override + /// argument so eth_call can be invoked with simulated state. + pub fn formatCallParamsWithOverrides( + self: *Provider, + to: [20]u8, + data: []const u8, + from: ?[20]u8, + overrides: *const state_overrides_mod.StateOverrides, + ) ![]u8 { + const to_hex = primitives.addressToHex(&to); + const data_hex = try hex_mod.bytesToHex(self.allocator, data); + defer self.allocator.free(data_hex); + + const overrides_json = try overrides.serializeJson(self.allocator); + defer self.allocator.free(overrides_json); + + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(self.allocator); + try buf.appendSlice(self.allocator, "[{"); + + if (from) |f| { + const from_hex = primitives.addressToHex(&f); + try buf.appendSlice(self.allocator, "\"from\":\""); + try buf.appendSlice(self.allocator, &from_hex); + try buf.appendSlice(self.allocator, "\","); + } + + try buf.appendSlice(self.allocator, "\"to\":\""); + try buf.appendSlice(self.allocator, &to_hex); + try buf.appendSlice(self.allocator, "\",\"data\":\""); + try buf.appendSlice(self.allocator, data_hex); + try buf.appendSlice(self.allocator, "\"},\"latest\","); + try buf.appendSlice(self.allocator, overrides_json); + try buf.append(self.allocator, ']'); + + return buf.toOwnedSlice(self.allocator); + } }; // ============================================================================ @@ -1110,6 +1176,50 @@ test "Provider.formatCallParams - with from" { try std.testing.expect(std.mem.indexOf(u8, params, "\"to\":\"0xdead000000000000000000000000000000000000\"") != null); } +test "Provider.formatCallParamsWithOverrides - balance override" { + const allocator = std.testing.allocator; + var transport = HttpTransport.init(allocator, "http://localhost:8545"); + defer transport.deinit(); + + var provider = Provider.init(allocator, &transport); + const to = try primitives.addressFromHex("0xdead000000000000000000000000000000000000"); + const data = &[_]u8{ 0x12, 0x34 }; + + var overrides = state_overrides_mod.StateOverrides.init(allocator); + defer overrides.deinit(); + const target = try primitives.addressFromHex("0xcafe000000000000000000000000000000000000"); + try overrides.setBalance(target, 0xdeadbeef); + + const params = try provider.formatCallParamsWithOverrides(to, data, null, &overrides); + defer allocator.free(params); + + // Shape: [{...},"latest",{...overrides...}] + try std.testing.expect(std.mem.indexOf(u8, params, "\"to\":\"0xdead000000000000000000000000000000000000\"") != null); + try std.testing.expect(std.mem.indexOf(u8, params, "\"data\":\"0x1234\"") != null); + try std.testing.expect(std.mem.indexOf(u8, params, ",\"latest\",{") != null); + try std.testing.expect(std.mem.indexOf(u8, params, "\"0xcafe000000000000000000000000000000000000\":{\"balance\":\"0xdeadbeef\"}") != null); + try std.testing.expect(std.mem.endsWith(u8, params, "}}]")); +} + +test "Provider.formatCallParamsWithOverrides - empty overrides emits {}" { + const allocator = std.testing.allocator; + var transport = HttpTransport.init(allocator, "http://localhost:8545"); + defer transport.deinit(); + + var provider = Provider.init(allocator, &transport); + const to = try primitives.addressFromHex("0xdead000000000000000000000000000000000000"); + const data = &[_]u8{}; + + var overrides = state_overrides_mod.StateOverrides.init(allocator); + defer overrides.deinit(); + + const params = try provider.formatCallParamsWithOverrides(to, data, null, &overrides); + defer allocator.free(params); + + // Even with no overrides, the third positional argument is present. + try std.testing.expect(std.mem.endsWith(u8, params, "\"latest\",{}]")); +} + test "parseTransactionReceipt - successful receipt" { const allocator = std.testing.allocator; const raw = diff --git a/src/root.zig b/src/root.zig index fb22bde..50c1717 100644 --- a/src/root.zig +++ b/src/root.zig @@ -37,6 +37,7 @@ pub const ws_transport = @import("ws_transport.zig"); pub const sse_transport = @import("sse_transport.zig"); pub const subscription = @import("subscription.zig"); pub const ws_client = @import("ws_client.zig"); +pub const state_overrides = @import("state_overrides.zig"); pub const provider = @import("provider.zig"); pub const retry_provider = @import("retry_provider.zig"); pub const RetryingProvider = retry_provider.RetryingProvider; @@ -111,6 +112,7 @@ test { _ = @import("sse_transport.zig"); _ = @import("subscription.zig"); _ = @import("ws_client.zig"); + _ = @import("state_overrides.zig"); _ = @import("provider.zig"); _ = @import("retry_provider.zig"); // Layer 7: Client diff --git a/src/state_overrides.zig b/src/state_overrides.zig new file mode 100644 index 0000000..5625ae4 --- /dev/null +++ b/src/state_overrides.zig @@ -0,0 +1,383 @@ +const std = @import("std"); +const primitives = @import("primitives.zig"); +const hex_mod = @import("hex.zig"); + +/// State overrides for `eth_call`. Lets simulators answer "what if?" +/// questions without forking a node: what if this token balance was X? +/// what if this storage slot held Y? what if this address ran this +/// alternative bytecode? +/// +/// Maps to the third parameter of `eth_call`: +/// `eth_call(transaction, blockTag, stateOverrideObject)`. +/// See the Geth docs: +/// https://geth.ethereum.org/docs/interacting-with-geth/rpc/ns-eth#eth-call +/// +/// Usage: +/// var overrides = StateOverrides.init(allocator); +/// defer overrides.deinit(); +/// try overrides.setBalance(target, 100 * std.math.pow(u256, 10, 18)); +/// try overrides.setStorageAt(pool, slot, new_value); +/// const result = try provider.callWithOverrides(target, calldata, &overrides); +/// +/// Memory: `setCode` deep-copies the bytecode, so callers may free their +/// reference immediately after the call. Storage maps are owned by the +/// `Override` value. +pub const StorageMap = std.AutoHashMap([32]u8, [32]u8); + +pub const Override = struct { + balance: ?u256 = null, + nonce: ?u64 = null, + /// Heap-owned bytecode bytes. + code: ?[]u8 = null, + /// Full state replacement: any slot not listed here is treated as zero. + /// Mutually exclusive with `state_diff`; if both are set, `state` wins + /// per the JSON-RPC spec. + state: ?StorageMap = null, + /// Partial state overlay: listed slots take these values, others stay + /// at the chain's current value. + state_diff: ?StorageMap = null, +}; + +pub const StateOverrides = struct { + overrides: std.AutoHashMap([20]u8, Override), + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) StateOverrides { + return .{ + .overrides = std.AutoHashMap([20]u8, Override).init(allocator), + .allocator = allocator, + }; + } + + pub fn deinit(self: *StateOverrides) void { + var it = self.overrides.iterator(); + while (it.next()) |entry| { + const ov = entry.value_ptr; + if (ov.code) |c| self.allocator.free(c); + if (ov.state) |*m| @constCast(m).deinit(); + if (ov.state_diff) |*m| @constCast(m).deinit(); + } + self.overrides.deinit(); + } + + /// Get-or-create the per-address Override slot. + fn ensureOverride(self: *StateOverrides, addr: [20]u8) !*Override { + const result = try self.overrides.getOrPut(addr); + if (!result.found_existing) { + result.value_ptr.* = .{}; + } + return result.value_ptr; + } + + pub fn setBalance(self: *StateOverrides, addr: [20]u8, value: u256) !void { + const ov = try self.ensureOverride(addr); + ov.balance = value; + } + + pub fn setNonce(self: *StateOverrides, addr: [20]u8, value: u64) !void { + const ov = try self.ensureOverride(addr); + ov.nonce = value; + } + + /// Override the bytecode at `addr`. The bytes are deep-copied; the + /// caller may free their reference immediately. + pub fn setCode(self: *StateOverrides, addr: [20]u8, bytecode: []const u8) !void { + const ov = try self.ensureOverride(addr); + const copy = try self.allocator.alloc(u8, bytecode.len); + @memcpy(copy, bytecode); + if (ov.code) |old| self.allocator.free(old); + ov.code = copy; + } + + /// Set a single storage slot via the `stateDiff` mechanism (overlay + /// onto the chain's current storage). Subsequent calls accumulate into + /// the same map. + pub fn setStorageAt(self: *StateOverrides, addr: [20]u8, slot: [32]u8, value: [32]u8) !void { + const ov = try self.ensureOverride(addr); + if (ov.state_diff == null) { + ov.state_diff = StorageMap.init(self.allocator); + } + try ov.state_diff.?.put(slot, value); + } + + /// Replace storage at `addr` entirely with the given key/value pair. + /// Any slot not subsequently `setFullStorage`'d at this address is + /// treated as zero by the node. Useful for sandboxing a contract. + pub fn setFullStorage(self: *StateOverrides, addr: [20]u8, slot: [32]u8, value: [32]u8) !void { + const ov = try self.ensureOverride(addr); + if (ov.state == null) { + ov.state = StorageMap.init(self.allocator); + } + try ov.state.?.put(slot, value); + } + + /// True when no overrides have been set; the JSON serializer should + /// emit nothing rather than an empty object. + pub fn isEmpty(self: *const StateOverrides) bool { + return self.overrides.count() == 0; + } + + /// Serialize the overrides as the JSON object expected by `eth_call`'s + /// third parameter. Caller owns the returned memory. + /// Example output: + /// {"0xaaa...":{"balance":"0x...","stateDiff":{"0x...":"0x..."}}} + pub fn serializeJson(self: *const StateOverrides, allocator: std.mem.Allocator) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(allocator); + + try buf.append(allocator, '{'); + var first_addr = true; + var addr_it = self.overrides.iterator(); + while (addr_it.next()) |addr_entry| { + if (!first_addr) try buf.append(allocator, ','); + first_addr = false; + + const addr_hex = primitives.addressToHex(addr_entry.key_ptr); + try buf.append(allocator, '"'); + try buf.appendSlice(allocator, &addr_hex); + try buf.appendSlice(allocator, "\":{"); + try writeOverride(allocator, &buf, addr_entry.value_ptr.*); + try buf.append(allocator, '}'); + } + try buf.append(allocator, '}'); + + return buf.toOwnedSlice(allocator); + } +}; + +// --------------------------------------------------------------------------- +// JSON helpers +// --------------------------------------------------------------------------- + +fn writeOverride(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), ov: Override) !void { + var first_field = true; + + if (ov.balance) |balance| { + if (!first_field) try buf.append(allocator, ','); + first_field = false; + try buf.appendSlice(allocator, "\"balance\":\""); + try writeHexU256(allocator, buf, balance); + try buf.append(allocator, '"'); + } + + if (ov.nonce) |nonce| { + if (!first_field) try buf.append(allocator, ','); + first_field = false; + try buf.appendSlice(allocator, "\"nonce\":\""); + try writeHexU64(allocator, buf, nonce); + try buf.append(allocator, '"'); + } + + if (ov.code) |code| { + if (!first_field) try buf.append(allocator, ','); + first_field = false; + try buf.appendSlice(allocator, "\"code\":\""); + const hex = try hex_mod.bytesToHex(allocator, code); + defer allocator.free(hex); + try buf.appendSlice(allocator, hex); + try buf.append(allocator, '"'); + } + + if (ov.state) |state_map| { + if (!first_field) try buf.append(allocator, ','); + first_field = false; + try buf.appendSlice(allocator, "\"state\":"); + try writeStorageMap(allocator, buf, state_map); + } + + if (ov.state_diff) |diff_map| { + if (!first_field) try buf.append(allocator, ','); + first_field = false; + try buf.appendSlice(allocator, "\"stateDiff\":"); + try writeStorageMap(allocator, buf, diff_map); + } +} + +fn writeStorageMap(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), map: StorageMap) !void { + try buf.append(allocator, '{'); + var first = true; + var it = map.iterator(); + while (it.next()) |entry| { + if (!first) try buf.append(allocator, ','); + first = false; + const slot_hex = primitives.hashToHex(entry.key_ptr); + const value_hex = primitives.hashToHex(entry.value_ptr); + try buf.append(allocator, '"'); + try buf.appendSlice(allocator, &slot_hex); + try buf.appendSlice(allocator, "\":\""); + try buf.appendSlice(allocator, &value_hex); + try buf.append(allocator, '"'); + } + try buf.append(allocator, '}'); +} + +/// Write `0x` for a u256 -- no leading zeros except for "0x0". +fn writeHexU256(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), value: u256) !void { + var tmp: [66]u8 = undefined; + const written = std.fmt.bufPrint(&tmp, "0x{x}", .{value}) catch unreachable; + try buf.appendSlice(allocator, written); +} + +fn writeHexU64(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), value: u64) !void { + var tmp: [20]u8 = undefined; + const written = std.fmt.bufPrint(&tmp, "0x{x}", .{value}) catch unreachable; + try buf.appendSlice(allocator, written); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "StateOverrides - empty serializes to {}" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + try std.testing.expect(ov.isEmpty()); + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + try std.testing.expectEqualStrings("{}", json); +} + +test "StateOverrides - balance only" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xaa} ** 20; + try ov.setBalance(addr, 0x1234); + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + + // The address is lowercase hex with 0x prefix, balance is minimal hex. + try std.testing.expect(std.mem.indexOf(u8, json, "\"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\":{") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"balance\":\"0x1234\"") != null); +} + +test "StateOverrides - nonce, code, balance combined for one address" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xbb} ** 20; + try ov.setBalance(addr, 100); + try ov.setNonce(addr, 7); + try ov.setCode(addr, &.{ 0x60, 0x80 }); + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"balance\":\"0x64\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"nonce\":\"0x7\"") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"code\":\"0x6080\"") != null); +} + +test "StateOverrides - stateDiff merges multiple slots" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xcc} ** 20; + var slot1: [32]u8 = .{0} ** 32; + slot1[31] = 0x01; + var slot2: [32]u8 = .{0} ** 32; + slot2[31] = 0x02; + var val: [32]u8 = .{0} ** 32; + val[31] = 0xff; + + try ov.setStorageAt(addr, slot1, val); + try ov.setStorageAt(addr, slot2, val); + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"stateDiff\":{") != null); + // Both slots should appear (order is hash-map dependent so we don't + // pin it). + try std.testing.expect(std.mem.indexOf(u8, json, "0x0000000000000000000000000000000000000000000000000000000000000001") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "0x0000000000000000000000000000000000000000000000000000000000000002") != null); +} + +test "StateOverrides - full state replacement uses state not stateDiff" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xdd} ** 20; + var slot: [32]u8 = .{0} ** 32; + slot[31] = 0x05; + var val: [32]u8 = .{0} ** 32; + val[31] = 0x42; + try ov.setFullStorage(addr, slot, val); + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"state\":{") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"stateDiff\"") == null); +} + +test "StateOverrides - setCode replaces previous bytecode without leak" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xee} ** 20; + try ov.setCode(addr, &.{ 0x01, 0x02 }); + try ov.setCode(addr, &.{ 0xab, 0xcd, 0xef }); + // The testing allocator catches leaks; this asserts that the first + // bytecode buffer was freed when the second setCode replaced it. + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + try std.testing.expect(std.mem.indexOf(u8, json, "\"code\":\"0xabcdef\"") != null); +} + +test "StateOverrides - setBalance is idempotent on the same address" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr = [_]u8{0xff} ** 20; + try ov.setBalance(addr, 1); + try ov.setBalance(addr, 2); + try ov.setBalance(addr, 3); + + try std.testing.expectEqual(@as(usize, 1), ov.overrides.count()); + const ov_entry = ov.overrides.get(addr).?; + try std.testing.expectEqual(@as(?u256, 3), ov_entry.balance); +} + +test "StateOverrides - multiple addresses round-trip" { + const allocator = std.testing.allocator; + var ov = StateOverrides.init(allocator); + defer ov.deinit(); + + const addr_a = [_]u8{0x11} ** 20; + const addr_b = [_]u8{0x22} ** 20; + try ov.setBalance(addr_a, 5); + try ov.setBalance(addr_b, 7); + + const json = try ov.serializeJson(allocator); + defer allocator.free(json); + + try std.testing.expect(std.mem.indexOf(u8, json, "\"0x1111111111111111111111111111111111111111\":{\"balance\":\"0x5\"}") != null); + try std.testing.expect(std.mem.indexOf(u8, json, "\"0x2222222222222222222222222222222222222222\":{\"balance\":\"0x7\"}") != null); +} + +test "writeHexU256 - zero is 0x0 not 0x" { + const allocator = std.testing.allocator; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(allocator); + try writeHexU256(allocator, &buf, 0); + try std.testing.expectEqualStrings("0x0", buf.items); +} + +test "writeHexU256 - large value" { + const allocator = std.testing.allocator; + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(allocator); + try writeHexU256(allocator, &buf, 0xdeadbeef); + try std.testing.expectEqualStrings("0xdeadbeef", buf.items); +} diff --git a/tests/integration_tests.zig b/tests/integration_tests.zig index 56e660a..3272052 100644 --- a/tests/integration_tests.zig +++ b/tests/integration_tests.zig @@ -330,6 +330,93 @@ test "provider next_id increments across calls" { try std.testing.expect(provider.next_id > id_after_first); } +// ============================================================================ +// eth_call state overrides (issue #12) +// ============================================================================ + +test "callWithOverrides applies code + balance override" { + 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); + + // Pick a fresh, otherwise-empty address. + const target = try eth.primitives.addressFromHex("0xc0dec0dec0dec0dec0dec0dec0dec0dec0dec0de"); + + // Bytecode: ADDRESS BALANCE PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + // Returns the contract's own balance as a 32-byte word. + const bytecode = [_]u8{ 0x30, 0x31, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3 }; + + var overrides = eth.state_overrides.StateOverrides.init(allocator); + defer overrides.deinit(); + try overrides.setCode(target, &bytecode); + try overrides.setBalance(target, 0xdeadbeef); + + const result = try provider.callWithOverrides(target, &.{}, &overrides); + defer allocator.free(result); + + // Returned 32 bytes encode the overridden balance. + try std.testing.expectEqual(@as(usize, 32), result.len); + var expected = [_]u8{0} ** 32; + expected[28] = 0xde; + expected[29] = 0xad; + expected[30] = 0xbe; + expected[31] = 0xef; + try std.testing.expectEqualSlices(u8, &expected, result); +} + +test "callWithOverrides applies stateDiff (storage slot)" { + 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 target = try eth.primitives.addressFromHex("0xbeefbeefbeefbeefbeefbeefbeefbeefbeefbeef"); + + // Bytecode: PUSH1 0x05 SLOAD PUSH1 0x00 MSTORE PUSH1 0x20 PUSH1 0x00 RETURN + // Returns storage slot 5 as a 32-byte word. + const bytecode = [_]u8{ 0x60, 0x05, 0x54, 0x60, 0x00, 0x52, 0x60, 0x20, 0x60, 0x00, 0xf3 }; + + var slot = [_]u8{0} ** 32; + slot[31] = 0x05; + var value = [_]u8{0} ** 32; + value[30] = 0xab; + value[31] = 0xcd; + + var overrides = eth.state_overrides.StateOverrides.init(allocator); + defer overrides.deinit(); + try overrides.setCode(target, &bytecode); + try overrides.setStorageAt(target, slot, value); + + const result = try provider.callWithOverrides(target, &.{}, &overrides); + defer allocator.free(result); + + try std.testing.expectEqualSlices(u8, &value, result); +} + +test "callWithOverrides without an override matches plain call" { + 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); + + // Calling an empty address with empty calldata returns empty bytes. + const target = try eth.primitives.addressFromHex("0x9999999999999999999999999999999999999999"); + + var overrides = eth.state_overrides.StateOverrides.init(allocator); + defer overrides.deinit(); + + const result = try provider.callWithOverrides(target, &.{}, &overrides); + defer allocator.free(result); + try std.testing.expectEqual(@as(usize, 0), result.len); +} + // ============================================================================ // WsClient: resilient WebSocket subscriptions (issue #35) // ============================================================================ From d1dedf077c35a5548a9f53a9c482d8cc9f35e12a Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Fri, 1 May 2026 15:48:20 -0400 Subject: [PATCH 2/3] Address CodeRabbit: errdefer setCode copy for future-proof safety --- src/state_overrides.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/src/state_overrides.zig b/src/state_overrides.zig index 5625ae4..c8cf72d 100644 --- a/src/state_overrides.zig +++ b/src/state_overrides.zig @@ -84,6 +84,7 @@ pub const StateOverrides = struct { pub fn setCode(self: *StateOverrides, addr: [20]u8, bytecode: []const u8) !void { const ov = try self.ensureOverride(addr); const copy = try self.allocator.alloc(u8, bytecode.len); + errdefer self.allocator.free(copy); @memcpy(copy, bytecode); if (ov.code) |old| self.allocator.free(old); ov.code = copy; From e23f47b2e072bf088633b8c1ed99803064080b2b Mon Sep 17 00:00:00 2001 From: Koko Bhadra Date: Fri, 1 May 2026 15:51:27 -0400 Subject: [PATCH 3/3] Address CodeRabbit: drop redundant @constCast in deinit --- src/state_overrides.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/state_overrides.zig b/src/state_overrides.zig index c8cf72d..0b72240 100644 --- a/src/state_overrides.zig +++ b/src/state_overrides.zig @@ -54,8 +54,8 @@ pub const StateOverrides = struct { while (it.next()) |entry| { const ov = entry.value_ptr; if (ov.code) |c| self.allocator.free(c); - if (ov.state) |*m| @constCast(m).deinit(); - if (ov.state_diff) |*m| @constCast(m).deinit(); + if (ov.state) |*m| m.deinit(); + if (ov.state_diff) |*m| m.deinit(); } self.overrides.deinit(); }