Skip to main content
AI tutorials

Build and Run a Block-by-Block Monad Market Maker with jev-trader

Learn how to install jev-trader, run it safely in dry-run mode, connect the Jev model, inspect its HTTP and SSE feeds, interpret quotes and fills, and understand the latency optimizations that keep its trading loop within Monad’s roughly 300 ms block time.

Build and Run a Block-by-Block Monad Market Maker with jev-trader

What jev-trader does

jev-trader is a block-by-block trading bot for the Kuru MON-USDC order book on Monad. On every new block, it reads the live order book, asks a model whether to buy or sell, and creates a post-only limit-order intent on the selected side.

The order is priced QUOTE_INSIDE_TICKS ticks inside the current touch. If the spread is too narrow, the price is clamped to the touch. The bot cancels its previously resting orders and posts the replacement in a single Kuru batchUpdate.

Because the order is post-only, it is intended to rest on the book rather than execute immediately. A fill occurs when another participant submits a taker order that crosses its price. This allows the strategy to earn the spread instead of paying it, although execution, inventory, gas, and market risks still apply.

Key features

  • One decision per Monad block: The bot processes the newest block approximately every 300 ms.
  • Live Kuru order-book data: A custom reader decodes getL2Book and merges the AMM vault into the book.
  • Two model options: The default mock model uses a momentum heuristic, while MODEL=jev enables the TypeSafe Jev model.
  • Safe dry-run behavior: Without PRIVATE_KEY, the bot uses the real book and real market prints but simulates its own orders and fills.
  • Post-only order replacement: Each update cancels known resting orders and submits a new post-only order in one batch.
  • Position and P&L tracking: Events report position, realized and unrealized results, gas costs, fill counts, and other totals.
  • HTTP and SSE monitoring: A small Bun server exposes the latest snapshot, up to 1,000 historical block events, and a live event stream.
  • Asynchronous confirmations: Live transactions are submitted on the hot path, while receipts and related checks are handled later.

Project architecture

The source tree separates block ingestion, market access, model evaluation, trading logic, and monitoring:

  • src/config.ts loads environment configuration.
  • src/chain.ts receives WebSocket newHeads updates, uses polling as a backstop, and keeps only the newest block.
  • src/book.ts reads and decodes the order book with one eth_call.
  • src/market.ts handles Kuru book reads, margin deposits, local nonces, batch updates, and asynchronous confirmations.
  • src/model.ts defines the model interface and provides JevModel and MockModel.
  • src/trader.ts runs the trading loop and tracks positions and P&L.
  • src/server.ts serves snapshots, history, and Server-Sent Events with Bun.serve.

Installation and setup

1. Prepare the environment file

From the repository directory, copy the example configuration:

cp .env.example .env

Review the resulting .env file before starting the application. The most important behavior is controlled by the presence or absence of PRIVATE_KEY.

Leave PRIVATE_KEY unset for your first run. The application will dry-run against real market data without broadcasting its own orders.

2. Install dependencies

The project uses Bun for dependency management and execution:

bun install

3. Start the trader

bun run start

With the default configuration, the mock momentum model acts as an inference stand-in. The server then makes the trader state and event stream available through its HTTP endpoints.

Choosing the decision model

Use the default mock model

The default mock model is suitable for testing the complete pipeline without calling Jev. It makes decisions using a momentum heuristic and lets you validate book reads, simulated orders, fills, accounting, and event streaming.

Enable the Jev model

To use Jev, configure these values in .env:

MODEL=jev
TYPESAFE_AI_API_KEY=your_api_key

Every block, the selected model is asked about the expected move over HORIZON_BLOCKS. Its default value is 100 blocks, or roughly 30 seconds according to the project README.

The normal decision is either buy or sell. A hold result does not represent a third model strategy: it appears only when the model misses the current block deadline. In that case, decision.late is true and no order is posted for that block.

Running safely in dry-run mode

When PRIVATE_KEY is not configured, jev-trader does not submit live transactions. It still reads the real Kuru book and produces real model decisions.

A simulated quote has status: "sim". It rests for one block and is considered filled only when a real market print crosses its price. The resulting fill has simulated: true.

This mode is useful for verifying:

  • Whether book updates are arriving reliably.
  • Whether model latency stays within the block budget.
  • How frequently quotes would be filled.
  • How positions and P&L change under the simulation rules.
  • Whether the dashboard or another SSE client handles every event type correctly.

Inspecting the HTTP endpoints

A deployed dry-run instance using the mock model is available at https://jev-trader-production.up.railway.app.

Read the latest snapshot

The root endpoint reports the active model, wallet, dry-run state, and most recent block event:

curl https://jev-trader-production.up.railway.app/

Read recent history

The history endpoint returns the last 1,000 block events:

curl https://jev-trader-production.up.railway.app/history

Follow the live SSE stream

Use curl -N to keep the connection open and display events as they arrive:

curl -N https://jev-trader-production.up.railway.app/events

The stream sends a snapshot event when the client connects, followed by one block event per processed block. Live transaction receipts produce quote updates, and detected executions produce fill events.

Understanding a block event

A block event combines market state, the model decision, the new quote intent, resting liquidity, position information, and cumulative totals. A shortened example looks like this:

{
  "block": 105488269,
  "mid": 0.022636,
  "bestBid": 0.022628,
  "bestAsk": 0.022644,
  "spreadBps": 7.07,
  "decision": {
    "action": "buy",
    "probabilities": { "buy": 0.77, "sell": 0.23, "hold": 0 },
    "upIn10": 0.77,
    "latencyMs": 81,
    "late": false
  },
  "quote": {
    "side": "buy",
    "price": 0.022629,
    "size": 200,
    "status": "sent",
    "capped": false
  }
}
  • mid, bestBid, and bestAsk describe the observed market.
  • spreadBps expresses the current spread in basis points.
  • decision.action is the model’s selected direction.
  • decision.probabilities preserves the model’s buy and sell probabilities.
  • upIn10 is equal to the buy probability.
  • decision.latencyMs reports model evaluation latency.
  • quote describes the order attempted for this block.
  • quote.cancel identifies previously resting orders included for cancellation.
  • resting records the bot’s known bid and ask size after the update.
  • position reports direction, size, entry price, and unrealized results.
  • totals accumulates blocks, decisions, quotes, fills, reversions, late blocks, model cost, gas, and P&L.

Position and funding caps

If a position cap prevents the requested side, or live margin funds are insufficient for it, the quote is placed on the opposite side. In this situation, quote.capped is true. The original model probabilities remain unchanged, allowing consumers to distinguish the model’s call from the side actually quoted.

Understanding live quote confirmations

Live sends are fired and forgotten so the trading loop does not wait for a receipt. Therefore, the initial block event contains an intent with status: "sent".

The reported gasMon is calculated as the gas limit multiplied by the last known base fee plus priority fee. The project notes that Monad charges the gas limit, so this is treated as the real cost whether the transaction succeeds or reverts.

A receipt normally arrives one or two blocks later and produces a separate quote event:

event: quote
data: {
  "block": 105488269,
  "quote": {
    "status": "placed",
    "orderId": 100295812,
    "gasMon": 0.0357
  }
}

A live quote can progress to one of these states:

  • placed: The transaction succeeded and includes an order ID.
  • reverted: The book moved through the submitted price before inclusion, or a requested cancellation targeted an order that had already filled.
  • lost: No receipt was found after 10 blocks.

Understanding fill events

Fills do not occur in the bot’s own placement transaction. They occur when another trader’s taker order hits a resting order. The bot detects the corresponding Trade log through the same eth_getLogs polling process that supplies market information to the model.

event: fill
data: {
  "block": 105488271,
  "fill": {
    "side": "buy",
    "size": 200,
    "price": 0.022629,
    "txHash": "0x…",
    "orderId": 100295812,
    "simulated": false
  }
}

The fill’s txHash belongs to the taker transaction. When a fill is processed, the stream also reflects updated position data, realized P&L, and cumulative fill totals.

Moving to live trading

Setting PRIVATE_KEY changes the application from dry-run behavior to live transaction submission. Before doing so, verify the environment configuration, margin funding, position limits, trade size, quote placement, fee cap, and RPC reliability.

Live mode submits real post-only Kuru orders and incurs real gas costs. Test the complete workflow in dry-run mode first and use conservative limits when enabling a funded key.

The primary strategy-related settings described by the project are:

  • TRADE_SIZE_MON for the order size.
  • QUOTE_INSIDE_TICKS for placement relative to the touch.
  • HORIZON_BLOCKS for the model’s prediction horizon.
  • MAX_FEE_GWEI for the type-2 transaction fee cap.
  • READ_RPC_URL for order-book reads.
  • RPC_URL for transaction submission.

How the bot meets the 300 ms budget

The hot path is designed around exactly two RPC round trips:

  1. One eth_call reads the current order book through READ_RPC_URL.
  2. One eth_sendRawTransaction submits the signed update through RPC_URL and returns once accepted.

The loop deliberately avoids several potentially expensive operations:

  • No per-block eth_estimateGas; the gas limit is hardcoded or derived once at startup.
  • No eth_sendRawTransactionSync, which waits until the transaction is proposed.
  • No gas-price lookup on the hot path; transactions use static type-2 fee settings with a 2 gwei priority fee and the MAX_FEE_GWEI cap.
  • No receipt waiting in the decision loop.
  • No inline vault or fee checks that could delay the order.

The README reports a dry-run mock-model benchmark of roughly 18 ms p50 for the book read and 100 ms p50 for the whole loop, with about 80 ms attributed to the mock inference stand-in.

Validation and benchmarking

Benchmark the custom book reader

Run the included benchmark to compare the custom reader with the SDK for exactness and latency:

bun run scripts/bench-read.ts

Verify transaction encoding offline

The dry encoding script signs a buy and a sell without broadcasting them, then checks that the generated calldata matches the SDK:

bun run scripts/dry-encode.ts

This is especially useful before enabling live mode because the performance-oriented market module uses hand-encoded batchUpdate calldata.

Advanced operating tips

  • Watch late decisions: Track decision.late and totals.lateBlocks. A late model response means no order was posted for that block.
  • Separate intent from confirmation: Do not treat status: "sent" as a placed order. Wait for the later quote event with placed, reverted, or lost.
  • Track capped quotes: When capped is true, compare the submitted side with the unchanged model probabilities to understand why execution diverged from the signal.
  • Use the SSE snapshot: New monitoring clients should initialize from the first snapshot event and then apply subsequent block, quote, and fill updates.
  • Measure both RPC paths: Slow reads delay decisions, while slow submissions reduce the chance that a quote lands before the market moves.
  • Account for charged gas limits: A reverted transaction can still have a meaningful cost, so monitor gasMon, gasUsd, and reversion totals rather than evaluating fills alone.
  • Validate with dry-run prints: Simulated fills depend on real prints crossing an order price, making dry-run mode more realistic than an immediate-fill assumption while still differing from actual queue position and execution.

Conclusion

jev-trader combines a live Kuru book reader, a mock or Jev decision model, optimized Monad transaction submission, position accounting, and an SSE monitoring service. Start without PRIVATE_KEY, inspect the snapshot and event stream, benchmark the critical path, and verify encoding before considering live execution. In production, monitor confirmations, late blocks, caps, reversions, inventory, P&L, and gas costs together.