diff --git a/build.zig.zon b/build.zig.zon
index 59fb9aa..986c11d 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -1,6 +1,6 @@
.{
.name = .eth_zig,
- .version = "0.2.3",
+ .version = "0.3.0",
.fingerprint = 0xd0f21900fa26f179,
.minimum_zig_version = "0.15.2",
.dependencies = .{},
diff --git a/src/flashbots.zig b/src/flashbots.zig
new file mode 100644
index 0000000..f6f4a4c
--- /dev/null
+++ b/src/flashbots.zig
@@ -0,0 +1,1048 @@
+const std = @import("std");
+const keccak = @import("keccak.zig");
+const hex_mod = @import("hex.zig");
+const signer_mod = @import("signer.zig");
+const secp256k1 = @import("secp256k1.zig");
+const primitives = @import("primitives.zig");
+const uint256_mod = @import("uint256.zig");
+const json_rpc = @import("json_rpc.zig");
+const HttpTransport = @import("http_transport.zig").HttpTransport;
+
+// ============================================================================
+// Types
+// ============================================================================
+
+/// Options for eth_sendBundle (Flashbots v1).
+pub const SendBundleOpts = struct {
+ /// Raw signed transaction bytes.
+ transactions: []const []const u8,
+ /// Target block number for inclusion.
+ block_number: u64,
+ /// Minimum timestamp for bundle validity.
+ min_timestamp: ?u64 = null,
+ /// Maximum timestamp for bundle validity.
+ max_timestamp: ?u64 = null,
+ /// Transaction hashes allowed to revert without invalidating the bundle.
+ reverting_tx_hashes: ?[]const [32]u8 = null,
+ /// UUID for bundle replacement (use with eth_cancelBundle).
+ replacement_uuid: ?[]const u8 = null,
+};
+
+/// Result from eth_sendBundle.
+pub const SendBundleResult = struct {
+ bundle_hash: [32]u8,
+};
+
+/// Options for eth_callBundle (bundle simulation).
+pub const CallBundleOpts = struct {
+ /// Raw signed transaction bytes.
+ transactions: []const []const u8,
+ /// Block number to simulate at.
+ block_number: u64,
+ /// State block number or tag (e.g. "latest"). Defaults to block_number if null.
+ state_block_number: ?json_rpc.BlockParam = null,
+ /// Timestamp to use for simulation.
+ timestamp: ?u64 = null,
+};
+
+/// Result from eth_callBundle.
+pub const CallBundleResult = struct {
+ bundle_hash: [32]u8,
+ bundle_gas_price: u256,
+ coinbase_diff: u256,
+ eth_sent_to_coinbase: u256,
+ gas_fees: u256,
+ total_gas_used: u64,
+};
+
+/// Options for eth_cancelBundle.
+pub const CancelBundleOpts = struct {
+ replacement_uuid: []const u8,
+};
+
+/// A single body element in a mev_sendBundle request.
+pub const MevBundleBody = union(enum) {
+ tx: MevTx,
+ hash: [32]u8,
+
+ pub const MevTx = struct {
+ data: []const u8,
+ can_revert: bool = false,
+ };
+};
+
+/// Options for mev_sendBundle (MEV-Share).
+pub const MevSendBundleOpts = struct {
+ body: []const MevBundleBody,
+ inclusion: Inclusion,
+ validity: ?Validity = null,
+ privacy: ?Privacy = null,
+
+ pub const Inclusion = struct {
+ block: u64,
+ max_block: ?u64 = null,
+ };
+
+ pub const Validity = struct {
+ refund: ?[]const Refund = null,
+ refund_config: ?[]const RefundConfig = null,
+
+ pub const Refund = struct { body_idx: u32, percent: u32 };
+ pub const RefundConfig = struct { address: [20]u8, percent: u32 };
+ };
+
+ pub const Privacy = struct {
+ hints: ?[]const []const u8 = null,
+ builders: ?[]const []const u8 = null,
+ };
+};
+
+/// Result from mev_sendBundle.
+pub const MevSendBundleResult = struct {
+ bundle_hash: [32]u8,
+};
+
+// ============================================================================
+// Bundle builder
+// ============================================================================
+
+/// Convenience builder for collecting signed transactions into a bundle.
+/// Stores borrowed slices -- callers must keep transaction data alive
+/// for the lifetime of the Bundle.
+pub const Bundle = struct {
+ txs: std.ArrayList([]const u8),
+ allocator: std.mem.Allocator,
+
+ pub fn init(allocator: std.mem.Allocator) Bundle {
+ return .{
+ .txs = .empty,
+ .allocator = allocator,
+ };
+ }
+
+ pub fn deinit(self: *Bundle) void {
+ self.txs.deinit(self.allocator);
+ }
+
+ /// Add a raw signed transaction (bytes, not hex).
+ /// The slice is borrowed, not copied -- caller retains ownership.
+ pub fn addTransaction(self: *Bundle, signed_tx: []const u8) !void {
+ try self.txs.append(self.allocator, signed_tx);
+ }
+
+ /// Return the collected transactions as borrowed slices.
+ pub fn transactions(self: *const Bundle) []const []const u8 {
+ return self.txs.items;
+ }
+};
+
+// ============================================================================
+// Flashbots auth signing
+// ============================================================================
+
+pub const FlashbotsError = error{
+ InvalidPrivateKey,
+ SigningFailed,
+ HttpError,
+ ConnectionFailed,
+ InvalidResponse,
+ RpcError,
+ NullResult,
+};
+
+/// Compute the X-Flashbots-Signature header value.
+/// Matches the ethers.js reference: wallet.signMessage(id(body))
+/// where id(body) = keccak256(toUtf8Bytes(body)) as hex string.
+///
+/// Returns allocator-owned string: "0x
:0x"
+fn computeAuthHeader(allocator: std.mem.Allocator, auth_signer: signer_mod.Signer, body: []const u8) ![]u8 {
+ // 1. Hash the request body: keccak256(body) -> 32 bytes
+ const body_hash = keccak.hash(body);
+
+ // 2. Convert to hex string: "0x" + 64 hex chars = 66 bytes
+ // This matches ethers.utils.id(body) output format
+ const body_hash_hex = hex_mod.bytesToHexBuf(32, &body_hash);
+
+ // 3. EIP-191 prefix hash of the hex string (as 66-byte UTF-8 message)
+ // = keccak256("\x19Ethereum Signed Message:\n66" + body_hash_hex)
+ const prefixed_hash = signer_mod.Signer.hashPersonalMessage(&body_hash_hex);
+
+ // 4. Sign the prefixed hash
+ const sig = auth_signer.signHash(prefixed_hash) catch return error.SigningFailed;
+
+ // 5. Get the signer address
+ const addr = auth_signer.address() catch return error.InvalidPrivateKey;
+
+ // 6. Format: "0x:0x"
+ // address hex = 42 chars, colon = 1, signature hex = 132 chars = 175 total
+ const addr_hex = primitives.addressToHex(&addr);
+ const sig_bytes = sig.toBytes();
+ const sig_hex = hex_mod.bytesToHexBuf(65, &sig_bytes);
+
+ var result = try allocator.alloc(u8, 42 + 1 + 132);
+ @memcpy(result[0..42], &addr_hex);
+ result[42] = ':';
+ @memcpy(result[43..175], &sig_hex);
+
+ return result;
+}
+
+// ============================================================================
+// Relay client
+// ============================================================================
+
+/// Client for submitting MEV bundles to Flashbots-compatible relays.
+///
+/// Handles JSON-RPC request construction, Flashbots auth signing
+/// (X-Flashbots-Signature header), and response parsing.
+pub const Relay = struct {
+ allocator: std.mem.Allocator,
+ url: []const u8,
+ auth_signer: signer_mod.Signer,
+ client: std.http.Client,
+ next_id: u64,
+
+ /// Create a new Relay client.
+ /// auth_key is the private key used for Flashbots authentication
+ /// (separate from the transaction signing key).
+ pub fn init(allocator: std.mem.Allocator, url: []const u8, auth_key: [32]u8) Relay {
+ return .{
+ .allocator = allocator,
+ .url = url,
+ .auth_signer = signer_mod.Signer.init(auth_key),
+ .client = .{ .allocator = allocator },
+ .next_id = 1,
+ };
+ }
+
+ pub fn deinit(self: *Relay) void {
+ self.client.deinit();
+ }
+
+ /// Submit a bundle via eth_sendBundle (Flashbots v1).
+ pub fn sendBundle(self: *Relay, opts: SendBundleOpts) !SendBundleResult {
+ const params = try buildSendBundleParams(self.allocator, opts);
+ defer self.allocator.free(params);
+
+ const raw = try self.authenticatedRequest("eth_sendBundle", params);
+ defer self.allocator.free(raw);
+
+ return parseBundleHashResult(self.allocator, raw);
+ }
+
+ /// Simulate a bundle via eth_callBundle.
+ pub fn callBundle(self: *Relay, opts: CallBundleOpts) !CallBundleResult {
+ const params = try buildCallBundleParams(self.allocator, opts);
+ defer self.allocator.free(params);
+
+ const raw = try self.authenticatedRequest("eth_callBundle", params);
+ defer self.allocator.free(raw);
+
+ return parseCallBundleResult(self.allocator, raw);
+ }
+
+ /// Submit a bundle via mev_sendBundle (MEV-Share).
+ pub fn mevSendBundle(self: *Relay, opts: MevSendBundleOpts) !MevSendBundleResult {
+ const params = try buildMevSendBundleParams(self.allocator, opts);
+ defer self.allocator.free(params);
+
+ const raw = try self.authenticatedRequest("mev_sendBundle", params);
+ defer self.allocator.free(raw);
+
+ const result = try parseBundleHashResult(self.allocator, raw);
+ return MevSendBundleResult{ .bundle_hash = result.bundle_hash };
+ }
+
+ /// Cancel a bundle via eth_cancelBundle.
+ pub fn cancelBundle(self: *Relay, opts: CancelBundleOpts) !void {
+ const params = try buildCancelBundleParams(self.allocator, opts);
+ defer self.allocator.free(params);
+
+ const raw = try self.authenticatedRequest("eth_cancelBundle", params);
+ defer self.allocator.free(raw);
+
+ // Just verify no RPC error in response
+ try checkRpcError(self.allocator, raw);
+ }
+
+ /// Return the Ethereum address of the auth signer.
+ pub fn authAddress(self: *const Relay) !primitives.Address {
+ return self.auth_signer.address() catch return error.InvalidPrivateKey;
+ }
+
+ // -- Internal --
+
+ fn authenticatedRequest(self: *Relay, method: []const u8, params_json: []const u8) ![]u8 {
+ const id = self.next_id;
+ self.next_id += 1;
+
+ // Build JSON-RPC body (reuse HttpTransport utility)
+ const body = try HttpTransport.buildRequestBody(self.allocator, method, params_json, id);
+ defer self.allocator.free(body);
+
+ // Compute Flashbots auth header
+ const auth_header = try computeAuthHeader(self.allocator, self.auth_signer, body);
+ defer self.allocator.free(auth_header);
+
+ // Send HTTP POST with custom headers
+ var response_body: std.Io.Writer.Allocating = .init(self.allocator);
+ errdefer response_body.deinit();
+
+ const result = self.client.fetch(.{
+ .location = .{ .url = self.url },
+ .method = .POST,
+ .payload = body,
+ .extra_headers = &.{
+ .{ .name = "Content-Type", .value = "application/json" },
+ .{ .name = "X-Flashbots-Signature", .value = auth_header },
+ },
+ .response_writer = &response_body.writer,
+ });
+
+ if (result) |res| {
+ if (res.status != .ok) {
+ response_body.deinit();
+ return error.HttpError;
+ }
+ return response_body.toOwnedSlice();
+ } else |_| {
+ response_body.deinit();
+ return error.ConnectionFailed;
+ }
+ }
+};
+
+// ============================================================================
+// JSON params builders
+// ============================================================================
+
+/// Append a JSON-escaped string to buf (without surrounding quotes).
+/// Escapes quotes, backslashes, and control characters per RFC 8259.
+fn appendJsonEscaped(allocator: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) !void {
+ for (s) |c| {
+ switch (c) {
+ '"' => try buf.appendSlice(allocator, "\\\""),
+ '\\' => try buf.appendSlice(allocator, "\\\\"),
+ '\n' => try buf.appendSlice(allocator, "\\n"),
+ '\r' => try buf.appendSlice(allocator, "\\r"),
+ '\t' => try buf.appendSlice(allocator, "\\t"),
+ else => if (c < 0x20) {
+ // Control character: \u00XX
+ const hex_chars = "0123456789abcdef";
+ try buf.appendSlice(allocator, "\\u00");
+ try buf.append(allocator, hex_chars[c >> 4]);
+ try buf.append(allocator, hex_chars[c & 0xf]);
+ } else {
+ try buf.append(allocator, c);
+ },
+ }
+ }
+}
+
+fn formatHexU64(buf: *[18]u8, value: u64) []const u8 {
+ buf[0] = '0';
+ buf[1] = 'x';
+ const hex_chars = "0123456789abcdef";
+ if (value == 0) {
+ buf[2] = '0';
+ return buf[0..3];
+ }
+ var val = value;
+ var len: usize = 0;
+ while (val > 0) : (val >>= 4) {
+ len += 1;
+ }
+ val = value;
+ var i: usize = len;
+ while (i > 0) {
+ i -= 1;
+ buf[2 + i] = hex_chars[@intCast(val & 0xf)];
+ val >>= 4;
+ }
+ return buf[0 .. 2 + len];
+}
+
+fn buildSendBundleParams(allocator: std.mem.Allocator, opts: SendBundleOpts) ![]u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(allocator);
+
+ try buf.appendSlice(allocator, "[{\"txs\":[");
+
+ // Hex-encode each transaction
+ for (opts.transactions, 0..) |tx, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.append(allocator, '"');
+ const tx_hex = try hex_mod.bytesToHex(allocator, tx);
+ defer allocator.free(tx_hex);
+ try buf.appendSlice(allocator, tx_hex);
+ try buf.append(allocator, '"');
+ }
+
+ try buf.appendSlice(allocator, "],\"blockNumber\":\"");
+ var hex_buf: [18]u8 = undefined;
+ try buf.appendSlice(allocator, formatHexU64(&hex_buf, opts.block_number));
+ try buf.append(allocator, '"');
+
+ if (opts.min_timestamp) |ts| {
+ try buf.appendSlice(allocator, ",\"minTimestamp\":");
+ var ts_buf: [20]u8 = undefined;
+ const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{ts}) catch unreachable;
+ try buf.appendSlice(allocator, ts_str);
+ }
+
+ if (opts.max_timestamp) |ts| {
+ try buf.appendSlice(allocator, ",\"maxTimestamp\":");
+ var ts_buf: [20]u8 = undefined;
+ const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{ts}) catch unreachable;
+ try buf.appendSlice(allocator, ts_str);
+ }
+
+ if (opts.reverting_tx_hashes) |hashes| {
+ try buf.appendSlice(allocator, ",\"revertingTxHashes\":[");
+ for (hashes, 0..) |hash, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.append(allocator, '"');
+ const hash_hex = primitives.hashToHex(&hash);
+ try buf.appendSlice(allocator, &hash_hex);
+ try buf.append(allocator, '"');
+ }
+ try buf.append(allocator, ']');
+ }
+
+ if (opts.replacement_uuid) |uuid| {
+ try buf.appendSlice(allocator, ",\"replacementUuid\":\"");
+ try appendJsonEscaped(allocator, &buf, uuid);
+ try buf.append(allocator, '"');
+ }
+
+ try buf.appendSlice(allocator, "}]");
+ return buf.toOwnedSlice(allocator);
+}
+
+fn buildCallBundleParams(allocator: std.mem.Allocator, opts: CallBundleOpts) ![]u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(allocator);
+
+ try buf.appendSlice(allocator, "[{\"txs\":[");
+
+ for (opts.transactions, 0..) |tx, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.append(allocator, '"');
+ const tx_hex = try hex_mod.bytesToHex(allocator, tx);
+ defer allocator.free(tx_hex);
+ try buf.appendSlice(allocator, tx_hex);
+ try buf.append(allocator, '"');
+ }
+
+ try buf.appendSlice(allocator, "],\"blockNumber\":\"");
+ var hex_buf: [18]u8 = undefined;
+ try buf.appendSlice(allocator, formatHexU64(&hex_buf, opts.block_number));
+ try buf.append(allocator, '"');
+
+ try buf.appendSlice(allocator, ",\"stateBlockNumber\":\"");
+ if (opts.state_block_number) |sbp| {
+ var sbp_buf: [20]u8 = undefined;
+ try buf.appendSlice(allocator, sbp.toString(&sbp_buf));
+ } else {
+ try buf.appendSlice(allocator, formatHexU64(&hex_buf, opts.block_number));
+ }
+ try buf.append(allocator, '"');
+
+ if (opts.timestamp) |ts| {
+ try buf.appendSlice(allocator, ",\"timestamp\":");
+ var ts_buf: [20]u8 = undefined;
+ const ts_str = std.fmt.bufPrint(&ts_buf, "{d}", .{ts}) catch unreachable;
+ try buf.appendSlice(allocator, ts_str);
+ }
+
+ try buf.appendSlice(allocator, "}]");
+ return buf.toOwnedSlice(allocator);
+}
+
+fn buildMevSendBundleParams(allocator: std.mem.Allocator, opts: MevSendBundleOpts) ![]u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(allocator);
+
+ try buf.appendSlice(allocator, "[{\"version\":\"v0.1\",\"inclusion\":{\"block\":\"");
+ var hex_buf: [18]u8 = undefined;
+ try buf.appendSlice(allocator, formatHexU64(&hex_buf, opts.inclusion.block));
+ try buf.append(allocator, '"');
+
+ if (opts.inclusion.max_block) |mb| {
+ try buf.appendSlice(allocator, ",\"maxBlock\":\"");
+ try buf.appendSlice(allocator, formatHexU64(&hex_buf, mb));
+ try buf.append(allocator, '"');
+ }
+
+ try buf.appendSlice(allocator, "},\"body\":[");
+
+ for (opts.body, 0..) |item, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ switch (item) {
+ .tx => |tx| {
+ try buf.appendSlice(allocator, "{\"tx\":\"");
+ const tx_hex = try hex_mod.bytesToHex(allocator, tx.data);
+ defer allocator.free(tx_hex);
+ try buf.appendSlice(allocator, tx_hex);
+ try buf.append(allocator, '"');
+ if (tx.can_revert) {
+ try buf.appendSlice(allocator, ",\"canRevert\":true");
+ }
+ try buf.append(allocator, '}');
+ },
+ .hash => |hash| {
+ try buf.appendSlice(allocator, "{\"hash\":\"");
+ const hash_hex = primitives.hashToHex(&hash);
+ try buf.appendSlice(allocator, &hash_hex);
+ try buf.appendSlice(allocator, "\"}");
+ },
+ }
+ }
+
+ try buf.append(allocator, ']');
+
+ if (opts.validity) |validity| {
+ try buf.appendSlice(allocator, ",\"validity\":{");
+ var first = true;
+
+ if (validity.refund) |refunds| {
+ try buf.appendSlice(allocator, "\"refund\":[");
+ for (refunds, 0..) |r, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.appendSlice(allocator, "{\"bodyIdx\":");
+ var num_buf: [20]u8 = undefined;
+ const idx_str = std.fmt.bufPrint(&num_buf, "{d}", .{r.body_idx}) catch unreachable;
+ try buf.appendSlice(allocator, idx_str);
+ try buf.appendSlice(allocator, ",\"percent\":");
+ const pct_str = std.fmt.bufPrint(&num_buf, "{d}", .{r.percent}) catch unreachable;
+ try buf.appendSlice(allocator, pct_str);
+ try buf.append(allocator, '}');
+ }
+ try buf.append(allocator, ']');
+ first = false;
+ }
+
+ if (validity.refund_config) |configs| {
+ if (!first) try buf.append(allocator, ',');
+ try buf.appendSlice(allocator, "\"refundConfig\":[");
+ for (configs, 0..) |c, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.appendSlice(allocator, "{\"address\":\"");
+ const addr_hex = primitives.addressToHex(&c.address);
+ try buf.appendSlice(allocator, &addr_hex);
+ try buf.appendSlice(allocator, "\",\"percent\":");
+ var num_buf: [20]u8 = undefined;
+ const pct_str = std.fmt.bufPrint(&num_buf, "{d}", .{c.percent}) catch unreachable;
+ try buf.appendSlice(allocator, pct_str);
+ try buf.append(allocator, '}');
+ }
+ try buf.append(allocator, ']');
+ }
+
+ try buf.append(allocator, '}');
+ }
+
+ if (opts.privacy) |privacy| {
+ try buf.appendSlice(allocator, ",\"privacy\":{");
+ var first = true;
+
+ if (privacy.hints) |hints| {
+ try buf.appendSlice(allocator, "\"hints\":[");
+ for (hints, 0..) |h, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.append(allocator, '"');
+ try appendJsonEscaped(allocator, &buf, h);
+ try buf.append(allocator, '"');
+ }
+ try buf.append(allocator, ']');
+ first = false;
+ }
+
+ if (privacy.builders) |builders| {
+ if (!first) try buf.append(allocator, ',');
+ try buf.appendSlice(allocator, "\"builders\":[");
+ for (builders, 0..) |b, i| {
+ if (i > 0) try buf.append(allocator, ',');
+ try buf.append(allocator, '"');
+ try appendJsonEscaped(allocator, &buf, b);
+ try buf.append(allocator, '"');
+ }
+ try buf.append(allocator, ']');
+ }
+
+ try buf.append(allocator, '}');
+ }
+
+ try buf.appendSlice(allocator, "}]");
+ return buf.toOwnedSlice(allocator);
+}
+
+fn buildCancelBundleParams(allocator: std.mem.Allocator, opts: CancelBundleOpts) ![]u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(allocator);
+
+ try buf.appendSlice(allocator, "[{\"replacementUuid\":\"");
+ try appendJsonEscaped(allocator, &buf, opts.replacement_uuid);
+ try buf.appendSlice(allocator, "\"}]");
+
+ return buf.toOwnedSlice(allocator);
+}
+
+// ============================================================================
+// Response parsing
+// ============================================================================
+
+fn checkRpcError(allocator: std.mem.Allocator, raw: []const u8) !void {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, raw, .{}) catch {
+ return error.InvalidResponse;
+ };
+ defer parsed.deinit();
+
+ const root = parsed.value;
+ if (root != .object) return error.InvalidResponse;
+
+ if (root.object.get("error")) |err_val| {
+ if (err_val == .object) return error.RpcError;
+ }
+}
+
+fn parseBundleHashResult(allocator: std.mem.Allocator, raw: []const u8) !SendBundleResult {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, raw, .{}) catch {
+ return error.InvalidResponse;
+ };
+ defer parsed.deinit();
+
+ const root = parsed.value;
+ if (root != .object) return error.InvalidResponse;
+
+ if (root.object.get("error")) |err_val| {
+ if (err_val == .object) return error.RpcError;
+ }
+
+ const result_val = root.object.get("result") orelse return error.InvalidResponse;
+ if (result_val == .null) return error.NullResult;
+ if (result_val != .object) return error.InvalidResponse;
+
+ const obj = result_val.object;
+ const hash_str = switch (obj.get("bundleHash") orelse return error.InvalidResponse) {
+ .string => |s| s,
+ else => return error.InvalidResponse,
+ };
+
+ const bundle_hash = primitives.hashFromHex(hash_str) catch return error.InvalidResponse;
+ return SendBundleResult{ .bundle_hash = bundle_hash };
+}
+
+fn parseCallBundleResult(allocator: std.mem.Allocator, raw: []const u8) !CallBundleResult {
+ const parsed = std.json.parseFromSlice(std.json.Value, allocator, raw, .{}) catch {
+ return error.InvalidResponse;
+ };
+ defer parsed.deinit();
+
+ const root = parsed.value;
+ if (root != .object) return error.InvalidResponse;
+
+ if (root.object.get("error")) |err_val| {
+ if (err_val == .object) return error.RpcError;
+ }
+
+ const result_val = root.object.get("result") orelse return error.InvalidResponse;
+ if (result_val == .null) return error.NullResult;
+ if (result_val != .object) return error.InvalidResponse;
+
+ const obj = result_val.object;
+
+ const bundle_hash = primitives.hashFromHex(
+ jsonGetString(obj, "bundleHash") orelse return error.InvalidResponse,
+ ) catch return error.InvalidResponse;
+
+ const bundle_gas_price = parseHexOrIntU256(obj, "bundleGasPrice") catch return error.InvalidResponse;
+ const coinbase_diff = parseHexOrIntU256(obj, "coinbaseDiff") catch return error.InvalidResponse;
+ const eth_sent = parseHexOrIntU256(obj, "ethSentToCoinbase") catch return error.InvalidResponse;
+ const gas_fees = parseHexOrIntU256(obj, "gasFees") catch return error.InvalidResponse;
+ const total_gas = parseHexOrIntU64(obj, "totalGasUsed") catch return error.InvalidResponse;
+
+ return CallBundleResult{
+ .bundle_hash = bundle_hash,
+ .bundle_gas_price = bundle_gas_price,
+ .coinbase_diff = coinbase_diff,
+ .eth_sent_to_coinbase = eth_sent,
+ .gas_fees = gas_fees,
+ .total_gas_used = total_gas,
+ };
+}
+
+fn jsonGetString(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 {
+ const val = obj.get(key) orelse return null;
+ return switch (val) {
+ .string => |s| s,
+ else => null,
+ };
+}
+
+/// Parse a decimal string into u256.
+fn parseDecimalU256(s: []const u8) !u256 {
+ if (s.len == 0) return error.InvalidResponse;
+ const max_div_10 = std.math.maxInt(u256) / 10;
+ const max_rem: u8 = @intCast(std.math.maxInt(u256) % 10);
+ var result: u256 = 0;
+ for (s) |c| {
+ if (c < '0' or c > '9') return error.InvalidResponse;
+ const digit: u8 = c - '0';
+ if (result > max_div_10 or (result == max_div_10 and digit > max_rem))
+ return error.InvalidResponse;
+ result = result * 10 + digit;
+ }
+ return result;
+}
+
+/// Parse a value that may be a hex string, decimal string, or integer.
+/// Flashbots returns decimal strings for bundleGasPrice, coinbaseDiff, etc.
+fn parseHexOrIntU256(obj: std.json.ObjectMap, key: []const u8) !u256 {
+ const val = obj.get(key) orelse return error.InvalidResponse;
+ return switch (val) {
+ .string => |s| if (s.len >= 2 and s[0] == '0' and (s[1] == 'x' or s[1] == 'X'))
+ uint256_mod.fromHex(s) catch return error.InvalidResponse
+ else
+ parseDecimalU256(s) catch return error.InvalidResponse,
+ .integer => |i| if (i < 0) error.InvalidResponse else @intCast(@as(u128, @intCast(i))),
+ else => error.InvalidResponse,
+ };
+}
+
+fn parseHexOrIntU64(obj: std.json.ObjectMap, key: []const u8) !u64 {
+ const val = obj.get(key) orelse return error.InvalidResponse;
+ return switch (val) {
+ .string => |s| blk: {
+ const v = if (s.len >= 2 and s[0] == '0' and (s[1] == 'x' or s[1] == 'X'))
+ uint256_mod.fromHex(s) catch return error.InvalidResponse
+ else
+ parseDecimalU256(s) catch return error.InvalidResponse;
+ if (v > std.math.maxInt(u64)) return error.InvalidResponse;
+ break :blk @intCast(v);
+ },
+ .integer => |i| if (i < 0) error.InvalidResponse else @intCast(@as(u128, @intCast(i))),
+ else => error.InvalidResponse,
+ };
+}
+
+// ============================================================================
+// Tests
+// ============================================================================
+
+test "computeAuthHeader format and recovery" {
+ const private_key = try hex_mod.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
+ const expected_address = try hex_mod.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
+
+ const auth_signer = signer_mod.Signer.init(private_key);
+ const body = "{\"jsonrpc\":\"2.0\",\"method\":\"eth_sendBundle\",\"params\":[{}],\"id\":1}";
+
+ const header = try computeAuthHeader(std.testing.allocator, auth_signer, body);
+ defer std.testing.allocator.free(header);
+
+ // Verify format: "0x<40 hex>:0x<130 hex>" = 175 chars
+ try std.testing.expectEqual(@as(usize, 175), header.len);
+ try std.testing.expectEqual(@as(u8, ':'), header[42]);
+ try std.testing.expectEqualStrings("0x", header[0..2]);
+ try std.testing.expectEqualStrings("0x", header[43..45]);
+
+ // Verify the address portion matches expected
+ const addr_hex = primitives.addressToHex(&expected_address);
+ try std.testing.expectEqualStrings(&addr_hex, header[0..42]);
+
+ // Verify signature is recoverable to the auth address
+ const sig_bytes = try hex_mod.hexToBytesFixed(65, header[45..175]);
+ const sig = @import("signature.zig").Signature.fromBytes(sig_bytes);
+
+ // Reconstruct the hash that was signed
+ const body_hash = keccak.hash(body);
+ const body_hash_hex = hex_mod.bytesToHexBuf(32, &body_hash);
+ const prefixed_hash = signer_mod.Signer.hashPersonalMessage(&body_hash_hex);
+
+ const recovered = try secp256k1.recoverAddress(sig, prefixed_hash);
+ try std.testing.expectEqualSlices(u8, &expected_address, &recovered);
+}
+
+test "Bundle init, add, deinit" {
+ var bundle = Bundle.init(std.testing.allocator);
+ defer bundle.deinit();
+
+ const tx1 = &[_]u8{ 0x02, 0xf8, 0x50 };
+ const tx2 = &[_]u8{ 0x02, 0xf8, 0x60 };
+
+ try bundle.addTransaction(tx1);
+ try bundle.addTransaction(tx2);
+
+ const txs = bundle.transactions();
+ try std.testing.expectEqual(@as(usize, 2), txs.len);
+ try std.testing.expectEqualSlices(u8, tx1, txs[0]);
+ try std.testing.expectEqualSlices(u8, tx2, txs[1]);
+}
+
+test "buildSendBundleParams - basic" {
+ const allocator = std.testing.allocator;
+ const tx1 = &[_]u8{ 0xde, 0xad };
+ const tx2 = &[_]u8{ 0xbe, 0xef };
+ const txs = [_][]const u8{ tx1, tx2 };
+
+ const params = try buildSendBundleParams(allocator, .{
+ .transactions = &txs,
+ .block_number = 0x100,
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"txs\":[\"0xdead\",\"0xbeef\"]") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"blockNumber\":\"0x100\"") != null);
+ // No optional fields
+ try std.testing.expect(std.mem.indexOf(u8, params, "minTimestamp") == null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "maxTimestamp") == null);
+}
+
+test "buildSendBundleParams - with optional fields" {
+ const allocator = std.testing.allocator;
+ const tx = &[_]u8{0xff};
+ const txs = [_][]const u8{tx};
+ const hash = [_]u8{0xaa} ** 32;
+ const hashes = [_][32]u8{hash};
+
+ const params = try buildSendBundleParams(allocator, .{
+ .transactions = &txs,
+ .block_number = 42,
+ .min_timestamp = 1000,
+ .max_timestamp = 2000,
+ .reverting_tx_hashes = &hashes,
+ .replacement_uuid = "test-uuid-123",
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"minTimestamp\":1000") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"maxTimestamp\":2000") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"revertingTxHashes\":[\"0x") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"replacementUuid\":\"test-uuid-123\"") != null);
+}
+
+test "buildCallBundleParams - basic" {
+ const allocator = std.testing.allocator;
+ const tx = &[_]u8{ 0xab, 0xcd };
+ const txs = [_][]const u8{tx};
+
+ const params = try buildCallBundleParams(allocator, .{
+ .transactions = &txs,
+ .block_number = 0xff,
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"txs\":[\"0xabcd\"]") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"blockNumber\":\"0xff\"") != null);
+ // state_block_number defaults to block_number
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"stateBlockNumber\":\"0xff\"") != null);
+}
+
+test "buildCallBundleParams - with latest tag" {
+ const allocator = std.testing.allocator;
+ const tx = &[_]u8{ 0xab, 0xcd };
+ const txs = [_][]const u8{tx};
+
+ const params = try buildCallBundleParams(allocator, .{
+ .transactions = &txs,
+ .block_number = 0xff,
+ .state_block_number = .{ .tag = .latest },
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"stateBlockNumber\":\"latest\"") != null);
+}
+
+test "buildMevSendBundleParams - basic" {
+ const allocator = std.testing.allocator;
+ const tx_data = &[_]u8{ 0xde, 0xad };
+ const body = [_]MevBundleBody{
+ .{ .tx = .{ .data = tx_data } },
+ };
+
+ const params = try buildMevSendBundleParams(allocator, .{
+ .body = &body,
+ .inclusion = .{ .block = 100 },
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"version\":\"v0.1\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"inclusion\":{\"block\":\"0x64\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"body\":[{\"tx\":\"0xdead\"}]") != null);
+}
+
+test "buildMevSendBundleParams - with hash body and privacy" {
+ const allocator = std.testing.allocator;
+ const hash = [_]u8{0xbb} ** 32;
+ const body = [_]MevBundleBody{
+ .{ .hash = hash },
+ };
+ const hints = [_][]const u8{ "hash", "logs" };
+
+ const params = try buildMevSendBundleParams(allocator, .{
+ .body = &body,
+ .inclusion = .{ .block = 50, .max_block = 55 },
+ .privacy = .{ .hints = &hints },
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"hash\":\"0x") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"maxBlock\":\"0x37\"") != null);
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"privacy\":{\"hints\":[\"hash\",\"logs\"]}") != null);
+}
+
+test "buildMevSendBundleParams - with canRevert" {
+ const allocator = std.testing.allocator;
+ const tx_data = &[_]u8{0xff};
+ const body = [_]MevBundleBody{
+ .{ .tx = .{ .data = tx_data, .can_revert = true } },
+ };
+
+ const params = try buildMevSendBundleParams(allocator, .{
+ .body = &body,
+ .inclusion = .{ .block = 1 },
+ });
+ defer allocator.free(params);
+
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"canRevert\":true") != null);
+}
+
+test "buildCancelBundleParams" {
+ const allocator = std.testing.allocator;
+ const params = try buildCancelBundleParams(allocator, .{
+ .replacement_uuid = "550e8400-e29b-41d4-a716-446655440000",
+ });
+ defer allocator.free(params);
+
+ try std.testing.expectEqualStrings(
+ "[{\"replacementUuid\":\"550e8400-e29b-41d4-a716-446655440000\"}]",
+ params,
+ );
+}
+
+test "parseBundleHashResult - success" {
+ const raw =
+ \\{"jsonrpc":"2.0","id":1,"result":{"bundleHash":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}
+ ;
+
+ const result = try parseBundleHashResult(std.testing.allocator, raw);
+ const expected = [_]u8{0xaa} ** 32;
+ try std.testing.expectEqualSlices(u8, &expected, &result.bundle_hash);
+}
+
+test "parseBundleHashResult - rpc error" {
+ const raw =
+ \\{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"bundle failed"}}
+ ;
+ try std.testing.expectError(error.RpcError, parseBundleHashResult(std.testing.allocator, raw));
+}
+
+test "parseCallBundleResult - success with decimal strings" {
+ // Flashbots returns decimal strings for gas/value fields
+ const raw =
+ \\{"jsonrpc":"2.0","id":1,"result":{
+ \\"bundleHash":"0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
+ \\"bundleGasPrice":"476190476193",
+ \\"coinbaseDiff":"20000000000126000",
+ \\"ethSentToCoinbase":"20000000000000000",
+ \\"gasFees":"126000",
+ \\"totalGasUsed":42000
+ \\}}
+ ;
+
+ const result = try parseCallBundleResult(std.testing.allocator, raw);
+ const expected_hash = [_]u8{0xbb} ** 32;
+ try std.testing.expectEqualSlices(u8, &expected_hash, &result.bundle_hash);
+ try std.testing.expectEqual(@as(u256, 476190476193), result.bundle_gas_price);
+ try std.testing.expectEqual(@as(u256, 20000000000126000), result.coinbase_diff);
+ try std.testing.expectEqual(@as(u256, 20000000000000000), result.eth_sent_to_coinbase);
+ try std.testing.expectEqual(@as(u256, 126000), result.gas_fees);
+ try std.testing.expectEqual(@as(u64, 42000), result.total_gas_used);
+}
+
+test "Relay.init sets fields correctly" {
+ const auth_key = try hex_mod.hexToBytesFixed(32, "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80");
+ var relay = Relay.init(std.testing.allocator, "https://relay.flashbots.net", auth_key);
+ defer relay.deinit();
+
+ try std.testing.expectEqualStrings("https://relay.flashbots.net", relay.url);
+ try std.testing.expectEqual(@as(u64, 1), relay.next_id);
+
+ const expected_address = try hex_mod.hexToBytesFixed(20, "f39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
+ const addr = try relay.authAddress();
+ try std.testing.expectEqualSlices(u8, &expected_address, &addr);
+}
+
+test "formatHexU64 - various values" {
+ var buf: [18]u8 = undefined;
+
+ try std.testing.expectEqualStrings("0x0", formatHexU64(&buf, 0));
+ try std.testing.expectEqualStrings("0x1", formatHexU64(&buf, 1));
+ try std.testing.expectEqualStrings("0xff", formatHexU64(&buf, 255));
+ try std.testing.expectEqualStrings("0x100", formatHexU64(&buf, 256));
+ try std.testing.expectEqualStrings("0x1036640", formatHexU64(&buf, 17000000));
+}
+
+test "parseDecimalU256 - basic values" {
+ try std.testing.expectEqual(@as(u256, 0), try parseDecimalU256("0"));
+ try std.testing.expectEqual(@as(u256, 42000), try parseDecimalU256("42000"));
+ try std.testing.expectEqual(@as(u256, 476190476193), try parseDecimalU256("476190476193"));
+ try std.testing.expectEqual(@as(u256, 20000000000126000), try parseDecimalU256("20000000000126000"));
+ try std.testing.expectError(error.InvalidResponse, parseDecimalU256(""));
+ try std.testing.expectError(error.InvalidResponse, parseDecimalU256("0xabc"));
+ // max u256 should parse ok
+ try std.testing.expectEqual(std.math.maxInt(u256), try parseDecimalU256("115792089237316195423570985008687907853269984665640564039457584007913129639935"));
+ // max u256 + 1 should overflow
+ try std.testing.expectError(error.InvalidResponse, parseDecimalU256("115792089237316195423570985008687907853269984665640564039457584007913129639936"));
+}
+
+test "appendJsonEscaped - special characters" {
+ const allocator = std.testing.allocator;
+ var buf: std.ArrayList(u8) = .empty;
+ defer buf.deinit(allocator);
+
+ try appendJsonEscaped(allocator, &buf, "hello\"world\\test\nnewline");
+ try std.testing.expectEqualStrings("hello\\\"world\\\\test\\nnewline", buf.items);
+}
+
+test "buildSendBundleParams - replacement_uuid with special chars" {
+ const allocator = std.testing.allocator;
+ const tx = &[_]u8{0xff};
+ const txs = [_][]const u8{tx};
+
+ const params = try buildSendBundleParams(allocator, .{
+ .transactions = &txs,
+ .block_number = 1,
+ .replacement_uuid = "uuid\"with\\quotes",
+ });
+ defer allocator.free(params);
+
+ // Verify the JSON is valid - special chars are escaped
+ try std.testing.expect(std.mem.indexOf(u8, params, "\"replacementUuid\":\"uuid\\\"with\\\\quotes\"") != null);
+}
+
+test "SendBundleOpts defaults" {
+ const tx = &[_]u8{0x00};
+ const txs = [_][]const u8{tx};
+ const opts = SendBundleOpts{
+ .transactions = &txs,
+ .block_number = 1,
+ };
+
+ try std.testing.expect(opts.min_timestamp == null);
+ try std.testing.expect(opts.max_timestamp == null);
+ try std.testing.expect(opts.reverting_tx_hashes == null);
+ try std.testing.expect(opts.replacement_uuid == null);
+}
+
+test "MevSendBundleOpts defaults" {
+ const body = [_]MevBundleBody{};
+ const opts = MevSendBundleOpts{
+ .body = &body,
+ .inclusion = .{ .block = 1 },
+ };
+
+ try std.testing.expect(opts.validity == null);
+ try std.testing.expect(opts.privacy == null);
+ try std.testing.expect(opts.inclusion.max_block == null);
+}
diff --git a/src/json_rpc.zig b/src/json_rpc.zig
index e4045ba..e0d7424 100644
--- a/src/json_rpc.zig
+++ b/src/json_rpc.zig
@@ -83,6 +83,12 @@ pub const Method = struct {
// Web3
pub const web3_clientVersion = "web3_clientVersion";
+
+ // Flashbots / MEV
+ pub const eth_sendBundle = "eth_sendBundle";
+ pub const eth_callBundle = "eth_callBundle";
+ pub const eth_cancelBundle = "eth_cancelBundle";
+ pub const mev_sendBundle = "mev_sendBundle";
};
/// Standard JSON-RPC error codes.
diff --git a/src/root.zig b/src/root.zig
index 1515a2f..cfd684b 100644
--- a/src/root.zig
+++ b/src/root.zig
@@ -44,6 +44,7 @@ pub const ens_reverse = @import("ens/reverse.zig");
// -- Layer 8: Client --
pub const wallet = @import("wallet.zig");
+pub const flashbots = @import("flashbots.zig");
pub const contract = @import("contract.zig");
pub const multicall = @import("multicall.zig");
pub const event = @import("event.zig");
@@ -101,6 +102,7 @@ test {
_ = @import("provider.zig");
// Layer 7: Client
_ = @import("wallet.zig");
+ _ = @import("flashbots.zig");
_ = @import("contract.zig");
_ = @import("multicall.zig");
_ = @import("event.zig");