← All posts

Engineering · 2026.09.21 · 6 min · Updated 2026.10.26

An AI Trading Bot on Monad Mainnet: Requoting Every 300ms Costs Ten Times More Gas Than the Spread

Running an AI trading demo on Monad mainnet showed requoting every block costs ten times more gas than the spread earns; I also fixed a margin bug reverting 83% to 95% of orders.

Reading
Theme
Size
Width

Once my AI trading bot was on Monad mainnet, most of its orders were reverted on chain, and failed transactions still paid gas.

The cause was a margin accounting bug that got 83% to 95% of orders reverted. With it fixed, the cost structure finally showed: gas is a fixed cost, and it's an order of magnitude larger than the spread the bot can earn.

A bot that requotes on every block can't make money that way.

The project is a fork. The original repo has TypeSafe's Jev model decide buy or sell once per Monad block (about 300ms). The bot then places a post-only limit order on Kuru's MON-USDC order book.

The original author is explicit that the point is to show "an AI making a real trading decision every 300ms, with inference cheaper than gas," not to turn a profit. What I wanted to know was whether it could make money.

This post covers the engineering problems I hit trading live. Whether the model has any predictive power is in the honest dry run that tested the AI trading model's hit rate. Trading used a freshly generated burner wallet, and the private key never went into the repo.

Monad RPC latency is distance

Running locally in Taiwan, the late rate was 57.8%. In more than half the blocks the model didn't answer in time, and no order went out for that slot.

Here are the numbers from a local machine in Taiwan and from a US server:

Local, Taiwan US server
One way to Monad's public RPC 221ms 17ms
Jev inference p50 327ms 169ms
Late rate 57.8% 11% to 14%

Almost all of the difference in Jev inference p50 was the round trip across the Pacific. Switching RPC providers wouldn't have helped. The problem was distance.

After moving to the US, loop p50 was 186ms, which fits inside a block.

I had been considering turning the loop into a pipeline so consecutive blocks overlap. That was no longer needed, which also spared me live-trading risks like nonce collisions and several in-flight orders pushing past the position limit at once.

For on-chain bot latency, measure distance first. Before switching providers or changing the architecture, check whether it's just physics.

The bot thought it still had margin

Once on mainnet, most orders were reverted on chain. On Monad that's expensive: it charges by gas limit, so a failed transaction still costs 0.0436 MON. Monad's docs put it plainly:

The gas charged for a transaction is the gas limit.

That's from Monad's gas pricing docs. How much gas a transaction actually uses doesn't change what you pay.

I guessed wrong twice. First I assumed margin was too thin, but adding more only took reverts from 94.8% to 83.2%. There was enough money.

Then I assumed out of gas, since the receipt's gasUsed equaled gasLimit, but every successful transaction equaled the limit too. The receipt reports what was charged. The only way to tell whether gas was sufficient is eth_estimateGas.

The real cause was in allowed(). It uses market.margin to decide whether an order on a given side can go out, but that balance is only updated in refresh() every 200 blocks (about 60 seconds), and fills don't touch it.

When the model leans hard one way, a few buys fill in a row, USDC turns into MON, and that side's margin is empty.

The bot is still holding the number from a minute ago and keeps sending buys. Each one reverts for insufficient margin, and on that path Kuru takes the entire gas limit.

Kuru locks margin the moment an order is placed, so I keep a local ledger that follows the order lifecycle. Margin is deducted on send and given back on revert, loss, or cancel, and a fill credits the asset received on the other side.

TypeScript
lockQuote(side: Side, sizeMon: number, price: number) {
  if (side === "buy") this.margin.usdc -= sizeMon * price;
  else this.margin.mon -= sizeMon;
}

creditFill(side: Side, sizeMon: number, price: number) {
  if (side === "buy") this.margin.mon += sizeMon;
  else this.margin.usdc += sizeMon * price;
}

Cancelling has its own catch. Stopping the bot doesn't cancel resting orders on Kuru, and they keep locking margin. Orders placed with batchUpdate can't be cancelled with batchCancelOrders (it reverts), so use batchUpdate with a cancel list instead.

A local mirror will always drift from the chain, so I split margin reconciliation out of refresh() and run it every 10 blocks. That shrinks the staleness window from 60 seconds to 3.

An order with no receipt after ten blocks might actually have landed. I chose to release it optimistically and let the next reconciliation correct it.

Dry run can't verify any of this, because without a wallet allowed() just returns true. The only options were a unit test reproducing "after three buy fills in a row, there isn't enough USDC for a fourth," then confirming on mainnet.

A periodically refreshed balance will go stale in a system with back-to-back fills. Keep a local ledger that follows the order lifecycle, then converge with short-interval reconciliation.

Illustration: before, market.margin updated only every 200 blocks and fills didn't touch it, so after three buy fills the on-chain buy-side margin was empty while the bot still held the old number, and every further buy reverted with the full gas limit charged. After, the local ledger deducts on send, credits fills, refunds reverts, and reconciles every 10 blocks, so the bot stops sending buys when USDC runs out.

Gas costs ten times the spread

After the fix, reverts fell to 11.1%, but 50.7% of blocks placed no order. This time the blocker was the position limit I had tightened back when the model kept buying.

When the model flipped to 99% sell, two fills hit the limit, and the buy side couldn't quote because the USDC had already been spent. Parameters are tuned to current market conditions, and once the model flips they can become the new bottleneck.

After loosening it, blocks with no order dropped to 0% and reverts sat at 33.1%.

Breaking the remaining reverts down by side, position size, and so on, every slice landed between 30% and 38%. My guess is a post-only timing race: the price is computed from block N's book, but the order lands in N+1. I haven't verified that.

With the bug gone, the cost structure was finally visible. Over 385 measured fills, each fill could earn at most $0.0006 in theory, while gas per fill came to $0.0063, ten times more. On average it took 5.7 orders to get one fill.

Gas ran $11 to $13 an hour. One order per block is roughly 12,000 transactions an hour, and you pay for all of them whether they fill or not.

To break even, order size would have to grow to about 2,000 MON each, and that still assumes the model actually has predictive power.

The original author's claim does hold: Jev's inference costs roughly one sixteenth of the gas. The AI really is cheaper than gas. The two together just cost more than they earn.

Bar chart from 385 measured fills: each fill could earn at most $0.0006 in theory, while gas per fill was $0.0063, ten times more; Jev inference cost roughly one sixteenth of the gas, and it took 5.7 orders on average to get one fill.

I opened a PR with the margin fix for the original author (jev-trader#4). It was still waiting for a reply when I wrote this.

#Monad#Algorithmic Trading#On-Chain Trading

Letters

Loading comments…

Next

Investing

Does Naked Candlestick Trading Work? Backtesting 1,026 Taiwan Stocks, It Lost to Random Entries Out of Sample

I turned a YouTuber's naked candlestick method into rules, backtested it on daily bars for over 1,000 Taiwan stocks against a random-timing control, and out of sample it lost by 2.62 standard deviations.