HFT Bot on Sonic Using Goldrush Streaming API (Part 1) — Real-Time On-Chain Feeds and a Deterministic Arbitrage Signal

Joseph Appolos
Content Writer
Build a Sonic HFT loop: use Goldrush Streams for real-time on-chain data, keep clean prices in memory, and trigger a deterministic after-fee arbitrage signal.

Introduction

High-frequency trading (HFT) on-chain is simple to describe and hard to do well: see price changes first, size correctly, and get filled before the edge disappears. This series starts with the only thing that makes the rest possible—real-time data.

In Part one, we lay the groundwork: what HFT bots actually do in DeFi, why latency dominates outcomes, what Sonic brings to the table, and how Goldrush Streams fits into a practical build.

What is an HFT bot (on-chain)?

An HFT bot is an automated trader designed to react to very small, short-lived price gaps. On-chain, these gaps appear across pools and venues as liquidity moves.

These bots do a few simple things: they buy a token where it’s a bit cheaper and sell it where it’s a bit pricier, sometimes loop through three tokens on one DEX (A→B→C→A) to lock in a small gain and rebalance holdings so they don’t become overly concentrated on one side.

On-chain, the ground rules differ from a centralized exchange; your transaction sits in a public mempool before it lands, block builders decide the order, you pay gas for every attempt, and MEV searchers race you for the same edge.

If your data is late or your route is naive, a faster bot takes the profit first. Using private or MEV-aware submission (such as Flashbots) helps reduce information leakage and preserves the edge that your Goldrush data uncovered. 

Why people build them: fast convergence improves price consistency across fragmented AMMs, and well-designed bots can capture pennies that add up, provided execution costs and failure rates are controlled.

Why many fail: stale data, poor sizing against shallow liquidity, and frontrunning. The fixes are straightforward. Consume events in real-time, maintain a clean local state, and route with MEV-aware practices.

HFT in recent On-Chain markets

Note: MEV is the umbrella term (encompassing arbitrage, sandwiches, liquidations, etc.). Here, we use HFT to refer to the latency-sensitive, always-on capture of tiny arbitrage gaps across pools/venues—not sandwiching.

On-chain trading is no longer niche, with DEX volume consistently setting new highs. March 2024 set a record of nearly $268 billion, and October 2025 reached a new monthly peak above $600 billion, with the DEX/CEX ratio pushing ~20%. That shift means that more price discovery occurs directly on-chain, and tiny gaps are filled in quickly.

As flow rises, opportunities get smaller and shorter-lived. Bots that poll every few seconds miss it; bots that subscribe to events and compute prices in memory get a shot. The practical playbook is straightforward: fresh data, accurate decimals, fees accounted for, and post-fee spreads evaluated in real-time. We’ll use Goldrush for the feed.

Market structure also changed. Concentrated-liquidity AMMs (Uniswap v3-style) make liquidity more active, as quotes now reside in ranges and move with the market; therefore, a significant amount of edge lies near range boundaries. That design rewards fast “nudge” behaviors with quick rebalances, cross-pool checks, and tiny, repeatable trades.

Derivatives echo the same trend. Perp DEX volume reached a record of over $1.2T in October 2025, tightening the link between spot and futures and creating more triangulation signals across venues. The windows are brief, but there are more of them—if your inputs are live.

Can I Make Money with HFT Bots?

Yes, especially in the arbitrage lane, but it’s competitive, and costs decide outcomes. You can view it live on public dashboards: EigenPhi breaks down daily arbitrage revenue on Ethereum and other chains, displaying a steady stream of captured gaps as prices resync across venues. That’s the category we’re building for in this series.

There are concrete, traceable wins. For example, this single Ethereum arbitrage transaction cleared over $8,087 in profit after costs. These aren’t one-offs; you’ll find many similar transactions on active days, just smaller and shorter-lived as competition thickens.

Zooming out, researchers have measured CEX-to-DEX arbitrage at scale. A 2025 study estimates that $233.8 million was extracted by 19 major searchers. Over 7.2 million identified opportunities were found between August 2023 and March 2025, with profits concentrated among the best-integrated players, indicating that speed and routing are crucial factors. ESMA’s 2025 brief echoes the same split between arbitrage and sandwiches, tracking profit-to-revenue ratios by category.

Sonic at a glance (for HFT)

What it is. Sonic is a next-generation, EVM-compatible L1 developed by the Fantom team, marketed around high throughput and sub-second finality. Independent explainers and ecosystem write-ups cite 10,000+ TPS targets with sub-second finality; Sonic’s own site markets much higher theoretical ceilings. Take the numbers as directional, but the design point is clear: faster blocks and quicker finality reduce the window where latency and reorg risk eat your edge.

Why it matters: faster blocks and quick finality tighten the loop between detection and settlement. If your inputs are fresh and your envelopes (slippage, gas, size) are conservative, you will realize PnL benefits.

In practice, Sonic’s pitch is lowering time-to-fill while staying EVM-compatible and maintaining a clean bridge to Ethereum liquidity, which is helpful if your bot reads events, prices a spread, and needs to settle quickly without exotic wrappers.

What exactly do HFT bots need?

To act in milliseconds instead of minutes, a bot needs three dependable ingredients:

  1. Fresh state: Pool reserves, swaps, and new pair listings delivered as events, not polled snapshots. Event streams reduce detection latency and ensure calculations are reproducible.

  2. Deterministic math. Prices computed from the same facts your execution will face (correct token order and decimals), with fees and slippage baked in up front.

  3. Orderflow hygiene. Even a perfect signal loses money if it’s broadcast into the wrong rail. Private or MEV-aware submission helps preserve edge.

Goldrush Streaming API: the data layer we’ll build on

Goldrush gives you push-based, real-time feeds over WebSockets (plus GraphQL) for the on-chain events an HFT bot actually needs, such as DEX pairs, logs, wallet activity, OHLCV, so you don’t poll and you don’t miss short windows.

The developer guide outlines the two planes: the Streaming API for sub-second updates and the Foundational REST for historical/backtests. That split allows you to maintain a fast, in-memory price loop for signals while retrieving history for PnL and audits.

Operationally, Goldrush advertises a 99.99% uptime SLA with multi-region failover and is distributed via mainstream channels (e.g., Google Cloud Marketplace), making access and billing straightforward for teams. For our build, the critical bit is simple: subscribe once, filter to the pools you care about, and keep prices current with minimal hops.

The Sonic + HFT + Goldrush triangle

  • Sonic shortens the distance from intent to inclusion with faster blocks and finality. That reduces slippage drift between signal and fill, if your inputs are fresh.

  • Goldrush Streams provides you with those inputs in near real-time, filtered to the pools you care about. 

  • Your bot converts that into a deterministic “go/no-go” signal after fees.

What we’ll build

In the tutorial section of Part 1, we’ll wire up a live feed → clean price → post-fee arbitrage signal loop. No predictions yet—just ground truth. That gives us a stable base to record ticks and backtest in Part 2, then graduate to Sonic execution in Part 3 with slippage envelopes, size rules, and PnL tracking. The sequencing mirrors real desks: truth first, model second, fills last.

Step-by-Step Tutorial: Build the Part-1 HFT Signal Service

This section ships a small, reliable service that:

  1. subscribes to live pool events,

  2. maintains prices in memory, and

  3. prints a deterministic post-fee arbitrage signal.

Prerequisites

OS: Windows, macOS, or Linux

Tools: Node.js 18+ (20 recommended), Git, a code editor (VS Code is fine)

Install Dependencies

mkdir hft-sonic-part1 && cd hft-sonic-part1 npm init -y npm install graphql-ws npm install typescript ts-node @types/node dotenv zod ws ethers npx tsc --init

ENV Parameters

Create a .env file with all the necessary credentials

  • STREAM_URL: WebSocket endpoint for live events (wss://…). Get it from Goldrush Streams WS URL (dashboard).

  • GOLDRUSH_API_KEY: Sign up for the GoldRush platform and create your API key in straightforward steps.

  • POOL_A_ADDRESS / POOL_B_ADDRESS: Pool contract addresses for the same token pair on two venues (v2-style). Get it from the DEX pair page.

  • BASE_DECIMALS / QUOTE_DECIMALS: ERC-20 decimals for base/quote tokens. Obtain it from the token contract (e.g., WETH = 18, USDC = 6).

  • SPREAD_BPS_THRESHOLD: Minimum after-fee edge to alert (bps). Pick: 20–40 for low-fee pools, should be greater than your noise floor.

  • FEE_BPS: Round-trip fees in bps (feeA + feeB; optionally add small gas bps). Example: two 0.05% pools ⇒ 5 + 5 = 10; two 0.30% pools ⇒ 60.

# .env Sample STREAM_URL=wss://YOUR_STREAM_ENDPOINT STREAM_API_KEY=YOUR_OPTIONAL_BEARER_KEY POOL_A_ADDRESS=0xYourPoolA POOL_B_ADDRESS=0xYourPoolB BASE_DECIMALS=18 QUOTE_DECIMALS=6 SPREAD_BPS_THRESHOLD=30 FEE_BPS=10

Notes: 

  • STREAM_URL/GOLDRUSH_API_KEY drives the live feed.

  • Pool addresses must be for the same pair.

  • Decimals must be correct; otherwise, prices will be incorrect.

  • Thresholds/fees make signals realistic.

Project Architecture

Here’s the project layout for part one, which includes a .env file for the Goldrush API key and pool settings, as well as a small src/directory split into streaming, state, pricing, and signal files. Use it as a map while following the steps—each file has a specific job, allowing you to copy, run, and debug quickly.

1

Validate env and expose typed config (src/config.ts)

What it does
Loads .env, validates each value (URLs, addresses, numbers), normalizes addresses to lowercase, and exposes a typed config used everywhere else. It fails fast with a readable error if anything is off.
Inputs: Env variables specified above, such as: STREAM_URL, GOLDRUSH_API_KEY, POOL_A_ADDRESS, etc.
What to expect
Exports:
  • POOLS → { A, B } (lowercased addresses)
  • DECIMALS → { base, quote }
  • THRESHOLDS → { spreadBps, feeBps }
  • STREAM → { url, apiKey }
No console output; used by other modules.
Create src/config.ts and paste this code in it:
// src/config.ts import 'dotenv/config'; import { z } from 'zod'; const Env = z.object({ STREAM_URL: z.string().url(), GOLDRUSH_API_KEY: z.string().optional(), POOL_A_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/), POOL_B_ADDRESS: z.string().regex(/^0x[a-fA-F0-9]{40}$/), BASE_DECIMALS: z.coerce.number().int().nonnegative(), QUOTE_DECIMALS: z.coerce.number().int().nonnegative(), SPREAD_BPS_THRESHOLD: z.coerce.number().nonnegative(), FEE_BPS: z.coerce.number().nonnegative(), }); export const env = Env.parse(process.env); export const POOLS = { A: env.POOL_A_ADDRESS.toLowerCase(), B: env.POOL_B_ADDRESS.toLowerCase(), }; export const DECIMALS = { base: env.BASE_DECIMALS, quote: env.QUOTE_DECIMALS, }; export const THRESHOLDS = { spreadBps: env.SPREAD_BPS_THRESHOLD, feeBps: env.FEE_BPS, }; export const STREAM = { url: env.STREAM_URL, apiKey: env.GOLDRUSH_API_KEY, // optional; some providers need a bearer key };
2

Define a minimal ABI for v2 Sync (src/dexSyncAbi.ts)

The code in this step ships a small ABI for the Uniswap-v2 style Sync (reserve0, reserve1) event, allowing us to decode logs quickly.
ABI specifies how to interact with smart contracts on the blockchain, standardizing event and function call communication between decentralized applications and the Ethereum virtual machine (EVM).
Inputs: None at runtime.
What to expect: Exports v2Abi for stream.ts. No side effects.
Create src/dexSyncAbi.ts and paste this code in it:
// src/dexSyncAbi.ts export const v2Abi = [ { "anonymous": false, "inputs": [ { "indexed": false, "internalType": "uint112", "name": "reserve0", "type": "uint112" }, { "indexed": false, "internalType": "uint112", "name": "reserve1", "type": "uint112" } ], "name": "Sync", "type": "event" } ] as const;
Note: If you want to use a CLMM or Uniswap v3 pool, you’ll decode Swap and derive price from sqrtPriceX96. In this guide, we use v2 for clarity.
3

Keep in-memory state and compute prices (src/state.ts)

This module holds the latest reserves and block information per pool, computes a mid-price (quote per base) using your decimals, and tracks latency as milliseconds since the last event—helpful in spotting stalled feeds.
Inputs:  updatePool(id, reserve0, reserve1, blockNumber) (called by the stream) and DECIMALS from config.
What to expect
  • getPrice('A'|'B') → number|null (null until first event)
  • getLatencyMs('A'|'B') → number|null
    snapshot() → { A:{price,latencyMs}, B:{price,latencyMs} }
No console output.
Create src/state.ts and paste this code in it:
// src/state.ts import { DECIMALS } from './config'; export type PoolId = 'A' | 'B'; type PoolState = { reserve0: bigint | null; reserve1: bigint | null; lastBlock: number | null; lastTsMs: number | null; }; const ten = (n: number) => BigInt(10) ** BigInt(n); const state: Record<PoolId, PoolState> = { A: { reserve0: null, reserve1: null, lastBlock: null, lastTsMs: null }, B: { reserve0: null, reserve1: null, lastBlock: null, lastTsMs: null }, }; export function updatePool( id: PoolId, reserve0: bigint, reserve1: bigint, blockNumber: number ) { state[id] = { reserve0, reserve1, lastBlock: blockNumber, lastTsMs: Date.now() }; } // price = quote per base (assumes token0=base, token1=quote) export function getPrice(id: PoolId): number | null { const s = state[id]; if (s.reserve0 == null || s.reserve1 == null) return null; const base = Number(s.reserve0) / Number(ten(DECIMALS.base)); const quote = Number(s.reserve1) / Number(ten(DECIMALS.quote)); if (base === 0) return null; return quote / base; } export function getLatencyMs(id: PoolId): number | null { const s = state[id]; if (s.lastTsMs == null) return null; return Date.now() - s.lastTsMs; } export function snapshot() { return { A: { price: getPrice('A'), latencyMs: getLatencyMs('A') }, B: { price: getPrice('B'), latencyMs: getLatencyMs('B') }, }; }
4

Turn prices into a post-fee decision (src/signal.ts)

The code in this step evaluates both directions of the trade (A vs B, B vs A), subtracts your combined fees, and checks if the remainder clears your threshold. Returns a tiny, deterministic object.
Inputs: Prices from state.getPrice(...), thresholds from THRESHOLDS.
What to expect
evalSignal() checks both directions (A vs B and B vs A), subtracts FEE_BPS, and compares to your SPREAD_BPS_THRESHOLD (in your .env file).
  • It returns a small object—either ok: true with side and spreadBps, or ok: false with a short note.
  • You won’t see output from this file itself; the effect shows up in index.ts as a single line per second, e.g.:
… | SIGNAL: Buy B / Sell A | edge 35.3 bps
  • If you see rapid on/off signals around the threshold, raise the threshold slightly or (in Part 2) add a tiny EMA/smoothing to the spread before comparison.
  • If signals appear too good to be true, double-check the decimals and token order in state.ts; incorrect inputs can create false positives.
Create src/signal.ts and paste this code in it:
// src/signal.ts import { THRESHOLDS } from './config'; import { getPrice } from './state'; const toBps = (x: number) => x * 10_000; export type Signal = { ok: boolean; side?: 'Buy A / Sell B' | 'Buy B / Sell A'; spreadBps?: number; priceA?: number | null; priceB?: number | null; note?: string; }; export function evalSignal(): Signal { const priceA = getPrice('A'); const priceB = getPrice('B'); if (priceA == null || priceB == null) { return { ok: false, note: 'Waiting for both pool prices…', priceA, priceB }; } const fee = THRESHOLDS.feeBps; const gapAB = toBps(priceB / priceA - 1); // long A, short B if B>A const gapBA = toBps(priceA / priceB - 1); // long B, short A if A>B if (gapAB > THRESHOLDS.spreadBps + fee) { return { ok: true, side: 'Buy A / Sell B', spreadBps: gapAB - fee, priceA, priceB }; } if (gapBA > THRESHOLDS.spreadBps + fee) { return { ok: true, side: 'Buy B / Sell A', spreadBps: gapBA - fee, priceA, priceB }; } return { ok: false, priceA, priceB, note: 'No edge after fees.' }; }
5

Subscribe, decode, and update state (src/stream.ts)

This model does the following:
  • Opens a GraphQL WebSocket to Goldrush.
  • Subscribes to OHLCV candles for each pair (you’ll get open/high/low/close/volume on a short interval).
  • Converts the latest close price into a synthetic reserve snapshot and calls updatePool(...) so the rest of your code (state, signal, index) doesn’t change.
Note: We synthesize reserves because our Part 1 pipeline expects v2 Sync reserves. In Part 2, you can switch to a price-native state to skip this shim.
Inputs: STREAM URL, GoldRush api Key, POOLS, v2Abi, and network log frames.
What to expect: No return value; the side effect is that in-memory state updates whenever a Sync arrives. Optional onReady() fires after we send the subscription.
Create src/stream.ts and paste this code in it:
// src/stream.ts import { STREAM, POOLS } from './config'; import { WebSocket } from 'ws'; import { Interface } from 'ethers'; import { v2Abi } from './dexSyncAbi'; import { updatePool } from './state'; const iface = new Interface(v2Abi); const syncTopic = iface.getEventTopic('Sync'); type LogMsg = { address: string; topics: string[]; data: string; blockNumber: string | number; }; function parseBlockNumber(bn: string | number): number { if (typeof bn === 'number') return bn; const s = bn.toString(); return s.startsWith('0x') ? parseInt(s, 16) : parseInt(s, 10); } export function startStream(onReady?: () => void) { const ws = new WebSocket(STREAM.url, { headers: STREAM.apiKey ? { Authorization: `Bearer ${STREAM.apiKey}` } : undefined, }); ws.on('open', () => { // If your provider needs a different payload (e.g., GraphQL), replace this object only. const sub = { id: 1, method: 'eth_subscribe', params: [ 'logs', { address: [POOLS.A, POOLS.B], topics: [syncTopic] }, ], }; ws.send(JSON.stringify(sub)); onReady?.(); }); ws.on('message', (data) => { try { const msg = JSON.parse(data.toString()); const log: LogMsg | undefined = msg.params?.result ?? msg.result ?? undefined; if (!log || !log.topics || !log.address) return; if ((log.topics[0] || '').toLowerCase() !== syncTopic.toLowerCase()) return; const addr = log.address.toLowerCase(); const decoded = iface.decodeEventLog('Sync', log.data, log.topics); const reserve0 = decoded.reserve0 as bigint; const reserve1 = decoded.reserve1 as bigint; const bn = parseBlockNumber(log.blockNumber); if (addr === POOLS.A) updatePool('A', reserve0, reserve1, bn); else if (addr === POOLS.B) updatePool('B', reserve0, reserve1, bn); } catch { // ignore malformed frames } }); ws.on('error', (err) => console.error('WS error:', err)); ws.on('close', () => { console.error('Stream closed. Reconnecting in 3s…'); setTimeout(() => startStream(), 3000); }); }
If you prefer the SDK instead of graphql-ws:
Use @covalenthq/client-sdk and call the streaming method you need (e.g., OHLCV pairs), which handles WS, auth, and retries for you. Then call updatePool(...) exactly as above with the price you receive.
6

Orchestrate and print one clear line per second (src/index.ts)

This is your entry point. It boots the process, starts the Goldrush stream, and runs a 1-second ticker. Each tick, it (1) reads the latest in-memory prices/latencies, (2) calls the signal logic, and (3) prints one concise line: price A, price B, per-pool latency, and either “no edge” (with a note) or a SIGNAL (side + after-fee edge in bps).
It’s intentionally minimal: human-readable logs, a steady cadence (one line/sec), clear timestamps (ISO 8601), and resilience—if the WebSocket drops, stream.ts reconnects and the ticker keeps running. There’s also a clean shutdown on CTRL+C, so your terminal isn’t left hanging.
Create src/index.ts and paste this code in it:
// src/index.ts import 'dotenv/config'; import { startStream } from './stream'; import { snapshot } from './state'; import { evalSignal } from './signal'; const TICK_MS = 1000; function printStatus() { const snap = snapshot(); const sig = evalSignal(); const ts = new Date().toISOString(); const line = `[${ts}] ` + `A=${snap.A.price?.toFixed(6) ?? '…'} (lat ${snap.A.latencyMs ?? '…'}ms) | ` + `B=${snap.B.price?.toFixed(6) ?? '…'} (lat ${snap.B.latencyMs ?? '…'}ms) | ` + (sig.ok ? `SIGNAL: ${sig.side} | edge ${sig.spreadBps?.toFixed(1)} bps` : `no edge${sig.note ? ` (${sig.note})` : ''}`); console.log(line); } startStream(() => { console.log('Stream connected. Waiting for live prices…'); }); const timer = setInterval(printStatus, TICK_MS); // Graceful shutdown (CTRL+C) process.on('SIGINT', () => { clearInterval(timer); console.log('\nShutting down. Bye.'); process.exit(0); });
Output
While waiting for the first events, you’ll see: | no edge (Waiting for both pool prices…) Once both pools are live, typical lines look like:
Notes:
  • lat = milliseconds since the last event arrived for that pool (quick health check)
  • edge = after-fee spread (basis points) that cleared your threshold

Errors and debugging

  1. Stuck on “Waiting for live prices…”: Your stream isn’t delivering data. Check STREAM_URL (must be wss://…) and GOLDRUSH_API_KEY in .env. Verify the subscription in the Goldrush Playground, and confirm the pair address exists on Sonic.

  2. Prices show … or never update: You haven’t received any ticks for either pool. Confirm POOL_A_ADDRESS / POOL_B_ADDRESS are pair/pool contracts (not router/factory) and that you used the correct chain.

  3. Prices appear absurd (e.g., 0.0002 instead of ~2500): The token decimals are incorrect. Re-check BASE_DECIMALS / QUOTE_DECIMALS from each token’s contract decimals().

  4. Latency grows or flips to null: The WebSocket dropped, or the feed is idle—the stream auto-reconnects. If the issue persists, re-check the URL, network, and subscription filter.

  5. Always “no edge”: Your bar is too high. Lower SPREAD_BPS_THRESHOLD a bit or confirm FEE_BPS isn’t oversized (sum of both pool fees, plus small gas bps if you added it).

  6. Signals flicker on/off around the threshold: You’re at the noise floor. Raise SPREAD_BPS_THRESHOLD slightly. (In Part 2, we’ll add a tiny EMA to smooth this.)

  7. GraphQL stream error: 401/403: Bad/missing key. Ensure GOLDRUSH_API_KEY is set and passed in connectionParams when creating the WS client.

  8. ohlcvCandlesForPair returns empty/undefined: Wrong field or bad variable. Confirm the subscription name/args match the Goldrush schema, and the pair_address is correct for Sonic.

No logs but high CPU/rapid prints: You’re just seeing the 1-second heartbeat. If needed, increase TICK_MS in src/index.ts to slow the cadence.

Closing thoughts

You now have a small, reliable service that listens to real-time on-chain data, keeps clean prices in memory, and emits a deterministic, post-fee arbitrage signal once per second. The code is modular by design, featuring streams to state, price, and signal, as well as logging capabilities. That separation makes it easy to test, swap data sources, or extend logic without having to rewrite the entire code.

What you accomplished

  1. Wired a Goldrush streaming feed into a minimal, resilient loop.

  2. Normalized inputs (decimals, ordering), so prices are reproducible.

  3. Turned raw prices into an after-fee “go/no-go” signal you can reason about.

  4. Built a console heartbeat that doubles as a quick health check (price + latency).

What you’ll build next

In Part 2, we'll enhance the signal loop by incorporating measurable features, such as capture and replay, along with basic metrics, as well as simple sizing and guardrails. This will include the ability to adjust configurations easily through a config file.YAML file without altering the code. 

Then, in Part 3, we’ll connect to live fills on Sonic, implementing DEX routing with conservative parameters and graceful revert handling. We’ll add basic telemetry for monitoring performance and ensure safety with dry-run and circuit breaker features, creating a cautious and production-ready loop.

Get Started

Get started with GoldRush API in minutes. Sign up for a free API key and start building.

Support

Explore multiple support options! From FAQs for self-help to real-time interactions on Discord.

Contact Sales

Interested in our professional or enterprise plans? Contact our sales team to learn more.