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.
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.
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.

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.

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.
To act in milliseconds instead of minutes, a bot needs three dependable ingredients:
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.
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.
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 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.
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.
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.
This section ships a small, reliable service that:
subscribes to live pool events,
maintains prices in memory, and
prints a deterministic post-fee arbitrage signal.
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 --initENV 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=10Notes:
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.
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.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.STREAM_URL, GOLDRUSH_API_KEY, POOL_A_ADDRESS, etc.POOLS → { A, B } (lowercased addresses)DECIMALS → { base, quote }THRESHOLDS → { spreadBps, feeBps }STREAM → { url, apiKey }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
};
2Sync (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).
v2Abi for stream.ts. No side effects.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;sqrtPriceX96. In this guide, we use v2 for clarity.3updatePool(id, reserve0, reserve1, blockNumber) (called by the stream) and DECIMALS from config.getPrice('A'|'B') → number|null (null until first event)getLatencyMs('A'|'B') → number|null
snapshot() → { A:{price,latencyMs}, B:{price,latencyMs} }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') },
};
}
4state.getPrice(...), thresholds from THRESHOLDS.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).ok: true with side and spreadBps, or ok: false with a short note.index.ts as a single line per second, e.g.:… | SIGNAL: Buy B / Sell A | edge 35.3 bpsstate.ts; incorrect inputs can create false positives.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.' };
}
5open/high/low/close/volume on a short interval).updatePool(...) so the rest of your code (state, signal, index) doesn’t change.Sync reserves. In Part 2, you can switch to a price-native state to skip this shim.STREAM URL, GoldRush api Key, POOLS, v2Abi, and network log frames.Sync arrives. Optional onReady() fires after we send the subscription.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);
});
}
graphql-ws:@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.6stream.ts reconnects and the ticker keeps running. There’s also a clean shutdown on CTRL+C, so your terminal isn’t left hanging.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);
});
| no edge (Waiting for both pool prices…) Once both pools are live, typical lines look like:lat = milliseconds since the last event arrived for that pool (quick health check)edge = after-fee spread (basis points) that cleared your threshold
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.
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.
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().
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.
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).
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.)
GraphQL stream error: 401/403: Bad/missing key. Ensure GOLDRUSH_API_KEY is set and passed in connectionParams when creating the WS client.
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.
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
Wired a Goldrush streaming feed into a minimal, resilient loop.
Normalized inputs (decimals, ordering), so prices are reproducible.
Turned raw prices into an after-fee “go/no-go” signal you can reason about.
Built a console heartbeat that doubles as a quick health check (price + latency).
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.