Skip to content

Example: L2 liquidation keeper bot (Base / OP Stack) #39

@koko1123

Description

@koko1123

Motivation

L2 liquidation keepers are a distinct and underserved use case for Ethereum libraries. Unlike L1 MEV (which requires Flashbots bundles and gas auctions), L2 FCFS sequencers reward raw speed — exactly where eth.zig's performance advantage is most tangible.

This example also serves as the reference implementation for perpcity liquidation keepers, demonstrating eth.zig's production readiness on Base.

What the bot does

  1. Subscribe to PerpManager contract logs via WebSocket to maintain an in-memory position state
  2. On each position open/adjust/close event: recompute proximity to liquidation threshold
  3. Maintain a priority queue of positions sorted by distance from liquidation
  4. For near-threshold positions: pre-sign the liquidation transaction (ready to broadcast, zero compute on hot path)
  5. When a position crosses the threshold: broadcast the pre-signed tx directly to the Base sequencer RPC

Why L2 is different

On L1, liquidation bots compete via gas price auctions. On Base (FCFS), the winner is whoever reaches the sequencer first. This means:

  • Pre-signing txs (so broadcast is a single syscall) matters
  • WebSocket latency to the sequencer RPC matters
  • Computation on the hot path (between threshold detection and broadcast) must be zero

eth.zig is uniquely positioned here: comptime ABI decoding, minimal allocations, and direct WebSocket transport with no JS runtime overhead.

Implementation outline

examples/10_liquidation_keeper.zig

// Pre-computed event topic at compile time
const POSITION_OPENED = comptime eth.keccak.eventTopic(
    "PositionOpened(address,bytes32,uint256,int256,int256)"
);

// Priority queue: positions sorted by (current_margin_ratio - liq_threshold)
var at_risk = PriorityQueue(PositionRisk).init(allocator);

// Pre-signed tx cache: position_id -> signed_tx_bytes (ready to broadcast)
var presigned = HashMap([32]u8, []u8).init(allocator);

// Hot path: called when position crosses threshold
fn onLiquidatable(position_id: [32]u8) !void {
    const tx = presigned.get(position_id) orelse return error.NotPresigned;
    _ = try provider.sendRawTransaction(tx);  // single RPC call, pre-signed
}

Key eth.zig features showcased

  • Comptime event topic hashing for zero-cost log matching
  • WebSocket newHeads + getLogs subscription (via watchLogs — see watchLogs: block-scoped log subscription helper #36)
  • In-memory state management with pre-signed transaction cache
  • Direct eth_sendRawTransaction to Base RPC
  • EIP-1559 transaction construction optimized for Base (low base fee)

Configuration

const Config = struct {
    rpc_ws_url: []const u8,       // wss://base-mainnet.g.alchemy.com/...
    rpc_http_url: []const u8,     // for fallback
    private_key: [32]u8,          // keeper wallet
    perp_manager: [20]u8,         // PerpManager contract address
    presign_buffer_bps: u64 = 50, // pre-sign when within 0.5% of liq threshold
};

Prerequisites

Deliverable

examples/10_liquidation_keeper.zig with:

  • Works against Anvil fork of Base
  • Configurable via environment variables
  • README section explaining the L2 FCFS advantage and why pre-signing matters
  • Benchmark: time from threshold detection to sendRawTransaction call (should be <1ms on Apple Silicon)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestexampleExample code / showcase

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions