Building HFT Bot on Sonic Using Goldrush Streaming API (Part 3) — Live Execution on Sonic with Guardrails

Joseph Appolos
Content Writer
caps, cooldown and dry-run, then execute guarded two-leg DEX swaps.

Introduction

This is the “fills” chapter. You already have a live signal that’s grounded in real-time on-chain data (Part 1) and a backtest loop you can defend (Part 2). Now we wire live execution on Sonic so the bot can actually buy on one venue and sell on another—safely.

Why this matters: execution is where edge leaks. Public mempools, fast competition, fee math, and shallow liquidity can turn a paper win into a live loss. The fix isn’t magic; it’s discipline: quote before you fire, set hard slippage bounds, use a cooldown, and add circuit breakers for latency and price drift. We’ll keep things explainable and EVM-plain so you can run it anywhere Sonic exposes a Uniswap-v2–style router.

What we’ll build

  • A minimal two-leg arbitrage executor (Router A → Router B) with approvals, quotes, and slippage protection.

  • A trade planner that turns your post-fee signal into concrete sizes and minOut values.

  • Guardrails: dry-run toggle, cooldown, latency and drift checks, revert handling, and basic telemetry.

  • A simple “live” runner that listens to your Part-1 signal and fires only when quotes confirm the edge.

Assumptions

  • You’re arbitraging the same token pair across two v2-style routers on Sonic (A then B, or B then A).

  • Tokens are ERC-20; you have addresses/decimals for tokenIn (quote) and tokenOut (base).

  • You have an RPC endpoint and a funded private key for Sonic.

  • You accept that two sequential swaps aren’t atomic. (For full atomicity, you’d use a flash-swap contract—out of scope here. Guardrails mitigate most day-one risks.)

What “live execution” means here

We’re moving from signals to real swaps on Sonic DEXs. In practice, that’s two legs of a simple cross-venue arbitrage: buy on venue A, sell on venue B, same token pair, tight time window. Your bot must (1) quote both legs in real time, (2) cap slippage, (3) submit transactions with sensible gas/timeout settings, and (4) only fire when quotes still show an edge after fees.

Sonic’s DEX landscape (where you’ll actually trade)

The Sonic chain is active; there’s a handful of turnover and a mix of router types to target. Based on 24-hour trading volume, you will frequently see platforms like Shadow Exchange, SwapX (Algebra), Beets V3 (Beethoven X), Metropolis DLMM, Equalizer, and SpookySwap appear often. These platforms collectively cover various pool types: 

  • v2-style pools with simpler paths and fast quotes

  • CLAMMs like SwapX and Uniswap v3, which include fee tiers and price ranges

  • Balancer-style weighted pools, utilizing basket math and multi-asset paths 

  • DLMM/Liquidity Book designs with bin steps that can create sharp micro-moves

The mix matters because it changes how you quote, how you set minOut, and where tiny edges are most likely to appear. Knowing the router type matters for quoting and slippage math (our tutorial uses v2-style for clarity; CLAMM/DLMM can be added later with their quoter functions).

Sonic’s speed and fee structure make it a suitable venue for learning with small notional sizes. The DEX mix gives you both familiar v2 routers and modern CL/DLMM designs so that you can start with v2 today and layer in more sophisticated quoting later.

Public metrics show the chain has steady DEX activity, enough to make data-driven arb worth attempting, but still competitive enough that process beats hero trades. The goal isn’t to catch everything; it’s only to fire when numbers and quotes line up.

How to aim your bot: Picking venues and pairs 

Start with high-touch pairs (e.g., USDC-major, wrapped majors) on two venues that consistently post depth. Use your own capture to pick spots where small gaps actually turn into fills.

Cross-check which routers dominate Sonic in your timezone; Shadow Exchange and SwapX (Algebra) often carry CL-style pools, Beets V3 handles weighted baskets and core pairs, while Metropolis DLMM brings Liquidity Book mechanics that can produce sharp micro-moves between bins.

This mix creates short-lived mismatches your signal can catch, provided your quotes and fees are accurate. The specific steps for aiming your bot are outlined below. 

  • Pick two v2 routers that list the same pair (e.g., USDC ↔ major). Skip CL/DLMM/weighted pools for now.

  • Capture 3–7 days with Goldrush. Keep pools with a steady 1% depth and predictable slippage.

  • Measure post-fee spreads between the two venues—trade in the hours where your threshold is crossed most often.

  • Budget every trade: poolA fee + poolB fee + slippage guard + gas. If round-trip costs ≤ 0, don’t fire.

  • Start narrow: one pair × two venues. Size up only after a few days of hit rate and PnL

Understanding Guardrails and Their Application on Sonic

Guardrails are simple, explicit rules that prevent you from turning a statistical edge into a live loss. Your build should perform these checks before implementing a trade. Some of them include:

  • Pre-trade quotes (both legs): Always call the router’s quote (getAmountsOut for v2; quoter for CLAMM/DLMM) on current size. If the after-cost round-trip is below your minimum profit bps, skip.

  • Hard slippage caps: Set minOut from quotes with a bps haircut (per leg). No fill > no loss.

  • Latency + drift checks: Skip if your input latency spikes or if the computed spread just collapsed.

  • Cooldown: A short, enforced pause between attempts to avoid thrash when ranges are moving.

  • Gas/timeout policy: Cap gas price and set a short deadline; stale intent = no transaction.

  • Dry-run first: Log everything and only flip to live when replay/backtest parity looks clean.

  • Non-atomic risk awareness: Two swaps can diverge. If you need atomicity later, graduate to a flash-swap contract; for Part 3, we keep it simple and defensive.

We implement these as plain, readable checks in the runner before any transaction is sent (you’ll see them in code). If Sonic or your provider offers private rails later, you can plug that in for less leakage; if not, these guardrails still carry you a long way.

Goldrush’s Role in Part 3 

Goldrush Streams stays in the loop during execution: it feeds live tick data to your runner, which helps it decide whether a trade still makes sense (current reserves, inferred prices, latency). It also powers ongoing capture, so every live attempt has a matching record for replay and post-trade review.

What it doesn’t do

Goldrush doesn’t submit transactions, route orders, or aggregate DEX quotes. Fills happen via your Sonic RPC, wallet, and the DEX routers you configure.

Why it still matters for fills

Execution discipline depends on the fresh state: you quote both legs at the size you’ll trade, enforce minOut, and skip if latency or drift fail your guardrails. Goldrush keeps that state hot and records exactly what you saw when you pressed “go.”

Quick checklist

  • Keep your Goldrush filters narrow (only the two pools you trade).

  • Leave capture on during live runs for replay and audits.

  • Alert on stream stalls or latency spikes before sending any swap.

What to have ready before you code

  • Two Sonic DEX routers you can name and reach (v2 to start).

  • A single pair to focus on (stablecoin ↔ primary).

  • Your Goldrush stream filters narrowed to those pools.

  • A funded Sonic wallet and RPC with stable latency.

A conservative policy file: slippage caps, min profit bps, cooldown, and dry-run = true.

Step-by-Step Tutorial (Part 3): Quote → Guard → Execute on Sonic

Prerequisites

Add dependencies:

npm install ethers dotenv

Extend scripts (merge/append into package.json):

Please include the runnable scripts that we will create in this section, specifically the exec and dryrun scripts, in the packet.json file we made in the previous section.

{ "scripts": { "dev": "ts-node src/index.ts", "replay": "ts-node src/replay.ts", "backtest": "ts-node src/backtest.ts", "approve": "ts-node src/approve.ts", "exec": "ts-node src/execute.ts", "dryrun": "DRY_RUN=1 ts-node src/execute.ts" } }
  • ethers: lets us talk to Sonic—read balances/allowances, call router getAmountsOut, and send swaps.

  • dotenv: loads secrets from .env so RPC URLs and keys aren’t hard-coded.

.env (fill with your values):

SONIC_RPC_URL=https://<your-sonic-rpc> PRIVATE_KEY=0x<your_private_key_for_small_hot_wallet>
  • sonic_rpc_url: an HTTPS RPC endpoint for Sonic (from your node provider). Faster endpoints reduce quote→submit delay.

  • private_key: a small, dedicated wallet for testing/execution—never your primary key. Fund sparingly.

Tip: Add .env to .gitignore so you don’t commit secrets.

Note: Use a small, dedicated key; fund it only with what you can lose while testing.

Project architecture

This diagram shows the Part 3 runtime flow: it includes Goldrush feed prices in the signal loop; we fetch a fresh quote, apply guardrails, and execute a two-leg swap on Sonic: approvals, wallet wiring, and a tiny PnL print round out a production-ready path.

1a

Extend config with execution fields (config.yaml + reload)

We add an execution block to centralize addresses and sizing (routers, tokens, slippage caps, cooldown, gas guard). Keeping these in config means you can tune without touching code.
Files:
  • config.yaml (update)
  • src/configFile.ts (minor additions)
Edit config.yaml — add execution:
Add a new execution block that holds everything the live executor needs: two v2 router addresses (A/B), tokenIn/tokenOut addresses + decimals, trade size, and guardrails (gas cap, staleness cap, cooldown, default dry-run).
How: paste the provided block at the end of config.yaml, keep existing keys, fill addresses from Sonic’s DEX UI/explorer, and start with a tiny trade_notional_quote.
execution: routerA: "0xRouterAddressA" # Sonic v2-style router routerB: "0xRouterAddressB" # Sonic v2-style router tokenIn: "0xTokenIn" # e.g., USDC tokenOut: "0xTokenOut" # e.g., WETH decimalsIn: 6 decimalsOut: 18 trade_notional_quote: 100 # quote units (e.g., 100 USDC) max_gas_gwei: 20 # cap gas price max_staleness_ms: 800 # quote→submit window cooldown_ms: 2000 # after any attempt dry_run_default: true # overridden by DRY_RUN env
1b

Update the new config—src/configFile.ts

Extend AppConfig with an execution object matching the YAML fields, so the rest of the code can use cfg.execution.* with type safety.
How: add the execution type to AppConfig and recompile/run; if TypeScript complains, your YAML keys/indentation don’t match.
// src/configFile.ts import fs from 'fs'; import path from 'path'; import YAML from 'yaml'; export type AppConfig = { data: { out_dir: string; tick_file_prefix: string; replay_glob: string }; fees: { poolA_bps: number; poolB_bps: number; gas_bps?: number }; features: { ema_window: number; rv_window: number; depth_floor: number; penalty_bps: number }; policy: { spread_threshold_bps: number; slippage_bps: number; min_notional_quote: number; cooldown_ms: number }; backtest: { train_days: number; test_days: number }; execution: { routerA: string; routerB: string; tokenIn: string; tokenOut: string; decimalsIn: number; decimalsOut: number; trade_notional_quote: number; max_gas_gwei: number; max_staleness_ms: number; cooldown_ms: number; dry_run_default: boolean; }; }; export function loadConfig(): AppConfig { const p = path.resolve(process.cwd(), 'config.yaml'); const raw = fs.readFileSync(p, 'utf8'); const cfg = YAML.parse(raw) as AppConfig; return cfg; }
What to expect: Other modules read execution from loadConfig(); you don’t run this directly.
2

Add Minimal ABIs (src/abis.ts)

In this step, we add a tiny set of ABI fragments so the app can talk to two things during execution:
  • ERC-20: decimals, symbol, balanceOf, allowance, approve to check wallet balances/allowances and grant the router permission to spend tokens.
  • v2 Router: getAmountsOut, swapExactTokensForTokens used to quote the actual trade size you’ll send, then execute with a hard minOut.
This keeps the ABI surface minimal and auditable. If you later add CLAMM/DLMM support, you’ll introduce their specific quoter and swap ABIs in a separate file; don’t mix them here.
Inputs you must provide: None in this file. Just ensure execution.routerA/routerB, execution.tokenIn/tokenOut, and their decimals are set correctly in config.yaml. The ABI is static; the addresses and sizes are supplied at runtime.
Create src/abis.ts and paste the code below into it. No edits are needed per-chain; the token/router addresses come from config.yaml’s execution block, not from this file.
// src/abis.ts export const ERC20_ABI = [ "function decimals() view returns (uint8)", "function symbol() view returns (string)", "function balanceOf(address) view returns (uint256)", "function allowance(address owner, address spender) view returns (uint256)", "function approve(address spender, uint256 value) returns (bool)" ]; export const V2_ROUTER_ABI = [ "function getAmountsOut(uint amountIn, address[] calldata path) view returns (uint[] memory amounts)", "function swapExactTokensForTokens(uint amountIn, uint amountOutMin, address[] calldata path, address to, uint deadline) returns (uint[] memory amounts)" ];
3

This is a tiny helper that builds an RPC provider and signer from .env, plus small utilities (chainId(), deadlineSeconds(), gwei()). This keeps network access and tx signing in one place and makes gas/staleness guards easy to enforce. Inputs you must provide: Set these in .env (already added in Step 0): SONIC_RPC_URL=… PRIVATE_KEY=0x… File: src/wallet.ts

This is a tiny helper that builds an RPC provider and signer from .env, plus small utilities (chainId(), deadlineSeconds(), gwei()). This keeps network access and tx signing in one place and makes gas/staleness guards easy to enforce.
Inputs you must provide: Set these in .env (already added in Step 0):
  • SONIC_RPC_URL=…
  • PRIVATE_KEY=0x…
File: src/wallet.ts
// src/wallet.ts import 'dotenv/config'; import { ethers } from 'ethers'; export function getProvider() { const url = process.env.SONIC_RPC_URL!; if (!url) throw new Error("SONIC_RPC_URL missing"); return new ethers.JsonRpcProvider(url); } export function getSigner() { const pk = process.env.PRIVATE_KEY!; if (!pk) throw new Error("PRIVATE_KEY missing"); return new ethers.Wallet(pk, getProvider()); } export async function chainId(): Promise<number> { const net = await getProvider().getNetwork(); return Number(net.chainId); } export function deadlineSeconds(secs: number = 60): number { return Math.floor(Date.now() / 1000) + secs; } export function gwei(n: number) { return ethers.parseUnits(n.toString(), "gwei"); }
What to expect: Imported by approval, quoter, and executor; not run directly.
4

Approvals for both routers (src/approve.ts)

What you’re adding (and why):
A tiny CLI that sends four ERC-20 approvals so Router A/B can spend tokenIn and tokenOut. It’s a one-time setup per wallet/token/router combo; needed before any swap.
Inputs (required, no CLI args):
  • From config.yaml › execution: routerA, routerB, tokenIn, tokenOut.
  • From .env: SONIC_RPC_URL, PRIVATE_KEY.
Paste the file into src/approve.ts.
// src/approve.ts import { ethers } from 'ethers'; import { loadConfig } from './configFile'; import { getSigner } from './wallet'; import { ERC20_ABI } from './abis'; async function approve(token: string, spender: string, amount: bigint) { const signer = getSigner(); const erc = new ethers.Contract(token, ERC20_ABI, signer); const sym = await erc.symbol().catch(() => "TOKEN"); const tx = await erc.approve(spender, amount); console.log(`Approve ${sym} for ${spender}… tx=${tx.hash}`); await tx.wait(); console.log(`Approve ${sym}: confirmed.`); } async function main() { const cfg = loadConfig(); const max = ethers.MaxUint256; await approve(cfg.execution.tokenIn, cfg.execution.routerA, max); await approve(cfg.execution.tokenOut, cfg.execution.routerA, max); await approve(cfg.execution.tokenIn, cfg.execution.routerB, max); await approve(cfg.execution.tokenOut, cfg.execution.routerB, max); } main().catch(e => { console.error(e); process.exit(1); });
Run the harness to verify the code so far:
npm run approve
What to expect: four approval tx hashes, then confirmations.
5

Quoter for both legs (src/quoter.ts)

This is a small helper that quotes both legs of the round-trip using live router getAmountsOut for the exact size you plan to trade. It returns minOut values with your fee and slippage guard baked in, plus a timestamp so the executor can reject stale quotes.
Inputs (read from config, no CLI args):
  • execution.routerA, execution.routerB
  • execution.tokenIn, execution.tokenOut
  • execution.decimalsIn, execution.decimalsOut
  • execution.trade_notional_quote (size used for quoting)
  • policy.slippage_bps, fees.poolA_bps, fees.poolB_bps, fees.gas_bps (for minOut)
Paste the provided code into src/quoter.ts.
// src/quoter.ts import { ethers } from 'ethers'; import { loadConfig } from './configFile'; import { getSigner } from './wallet'; import { V2_ROUTER_ABI } from './abis'; export type TwoLegQuote = { ts: number; amountInA: bigint; outA: bigint; amountInB: bigint; outB: bigint; minOutA: bigint; minOutB: bigint; }; export async function quoteTwoLeg(): Promise<TwoLegQuote> { const cfg = loadConfig(); const signer = getSigner(); const routerA = new ethers.Contract(cfg.execution.routerA, V2_ROUTER_ABI, signer); const routerB = new ethers.Contract(cfg.execution.routerB, V2_ROUTER_ABI, signer); const decIn = BigInt(10 ** cfg.execution.decimalsIn); const decOut = BigInt(10 ** cfg.execution.decimalsOut); // size in base units (trade_notional_quote assumed == tokenIn quote units) const amountIn = BigInt(Math.floor(cfg.execution.trade_notional_quote)) * decIn; // Path: tokenIn -> tokenOut, tokenOut -> tokenIn const pathAB = [cfg.execution.tokenIn, cfg.execution.tokenOut]; const pathBA = [cfg.execution.tokenOut, cfg.execution.tokenIn]; const amountsA = await routerA.getAmountsOut(amountIn, pathAB); const outA = BigInt(amountsA[1].toString()); const amountsB = await routerB.getAmountsOut(outA, pathBA); const outB = BigInt(amountsB[1].toString()); // Compute minOuts with policy slippage + fees (bps) const feesBps = cfg.fees.poolA_bps + cfg.fees.poolB_bps + (cfg.fees.gas_bps ?? 0); const slipBps = cfg.policy.slippage_bps; const guardBps = BigInt(feesBps + slipBps); const minOutA = outA - (outA * guardBps) / 10_000n; const minOutB = outB - (outB * guardBps) / 10_000n; return { ts: Date.now(), amountInA: amountIn, outA, amountInB: outA, outB, minOutA, minOutB }; }
What to expect:  quoteTwoLeg() returns { ts, amountInA, outA, amountInB, outB, minOutA, minOutB }. You don’t run this file directly.
6

Executor with guardrails (src/executor.ts)

This file is a safe executor that turns a fresh quote into two swaps (A → B, then B → A) only if all guards pass: minOut, quote staleness, gas cap, balance check, cooldown, and dry-run. If any guard fails, it skips with an apparent reason. 
Inputs (only if you add them here): DRY_RUN (env) — set to 1/true to force dry-run at the executor level.
Paste the provided file into src/executor.ts. It exports tryExecute(); your runner (src/execute.ts) calls it on signal.
// src/executor.ts import { ethers } from 'ethers'; import { loadConfig } from './configFile'; import { getSigner, deadlineSeconds, gwei } from './wallet'; import { ERC20_ABI, V2_ROUTER_ABI } from './abis'; import { quoteTwoLeg } from './quoter'; export type ExecResult = { sent: boolean; reason?: string; tx1?: string; tx2?: string }; function envDryRun(defaultOn: boolean) { const v = process.env.DRY_RUN; if (v === '0' || v === 'false') return false; if (v === '1' || v === 'true') return true; return defaultOn; } export async function tryExecute(): Promise<ExecResult> { const cfg = loadConfig(); const signer = getSigner(); const dryRun = envDryRun(cfg.execution.dry_run_default); // 1) Fresh quote const q = await quoteTwoLeg(); const age = Date.now() - q.ts; if (age > cfg.execution.max_staleness_ms) { return { sent: false, reason: `stale quote (${age}ms)` }; } // 2) Check balances and allowances const ercIn = new ethers.Contract(cfg.execution.tokenIn, ERC20_ABI, signer); const ercOut = new ethers.Contract(cfg.execution.tokenOut, ERC20_ABI, signer); const balIn = await ercIn.balanceOf(await signer.getAddress()); if (balIn < q.amountInA) return { sent: false, reason: "insufficient tokenIn balance" }; // 3) Gas guard const gp = await signer.provider!.getGasPrice(); const cap = gwei(cfg.execution.max_gas_gwei); if (gp > cap) return { sent: false, reason: `gas ${gp} > cap ${cap}` }; // 4) Prepare routers const routerA = new ethers.Contract(cfg.execution.routerA, V2_ROUTER_ABI, signer); const routerB = new ethers.Contract(cfg.execution.routerB, V2_ROUTER_ABI, signer); const to = await signer.getAddress(); const dl = deadlineSeconds(60); // Dry-run? if (dryRun) { console.log(`[DRY_RUN] LegA tokenIn->tokenOut amountIn=${q.amountInA.toString()} minOut=${q.minOutA.toString()}`); console.log(`[DRY_RUN] LegB tokenOut->tokenIn amountIn=${q.outA.toString()} minOut=${q.minOutB.toString()}`); return { sent: false, reason: "dry-run" }; } // 5) Execute leg A (buy tokenOut on routerA) const tx1 = await routerA.swapExactTokensForTokens( q.amountInA, q.minOutA, [cfg.execution.tokenIn, cfg.execution.tokenOut], to, dl, { gasPrice: gp } ); console.log(`Leg A sent: ${tx1.hash}`); const rc1 = await tx1.wait(); // 6) Check received amountOut (simple sanity via balance delta) const balOutAfterA = await ercOut.balanceOf(to); // 7) Execute leg B (sell tokenOut on routerB) using amount from quote (conservative) const amountForB = q.outA; // conservative; could use actual delta const tx2 = await routerB.swapExactTokensForTokens( amountForB, q.minOutB, [cfg.execution.tokenOut, cfg.execution.tokenIn], to, dl, { gasPrice: gp } ); console.log(`Leg B sent: ${tx2.hash}`); const rc2 = await tx2.wait(); // 8) Cooldown await new Promise(r => setTimeout(r, cfg.execution.cooldown_ms)); return { sent: true, tx1: tx1.hash, tx2: tx2.hash }; }
7

Wire execution to your signal loop (src/execute.ts)

This step produces a small runner that listens to your live signal, when it fires pulls a fresh quote, applies guardrails, and either executes or cleanly skips. It prints structured lines so you can see fills, skips, and reasons in real time.
Inputs (only if you add them here):
  • DRY_RUN (env): set DRY_RUN=1 to force dry-run from the shell.
  • TICK_MS (in-file constant): optional—change the loop cadence if you want a slower/faster heartbeat.
Paste the provided code into src/execute.ts. Use npm run dryrun first, then npm run exec when ready.
// src/execute.ts import 'dotenv/config'; import { startStream } from './stream'; import { snapshot } from './state'; import { evalSignal } from './signal'; import { tryExecute } from './executor'; import { loadConfig } from './configFile'; const cfg = loadConfig(); const TICK_MS = 1000; let busy = false; function fmt(n?: number | null, p: number = 6) { return n == null ? '…' : n.toFixed(p); } async function loop() { if (busy) return; const snap = snapshot(); const sig = evalSignal(); const ts = new Date().toISOString(); const lineBase = `[${ts}] A=${fmt(snap.A.price)} (lat ${snap.A.latencyMs ?? '…'}ms) | ` + `B=${fmt(snap.B.price)} (lat ${snap.B.latencyMs ?? '…'}ms) | `; if (!sig.ok) { console.log(lineBase + `no edge${sig.note ? ` (${sig.note})` : ''}`); return; } busy = true; console.log(lineBase + `SIGNAL: ${sig.side} | edge ${sig.spreadBps?.toFixed(1)} bps → checking guards…`); try { const res = await tryExecute(); if (res.sent) { console.log(`EXECUTED: tx1=${res.tx1} tx2=${res.tx2}`); } else { console.log(`SKIP: ${res.reason}`); } } catch (e: any) { console.error(`ERROR: ${e?.message || e}`); } finally { busy = false; } } function main() { startStream(() => console.log('Stream connected. Waiting for live prices…')); setInterval(loop, TICK_MS); } main();
Run (dry-run first):
npm run dryrun
Run (live, be cautious):
npm run exec
What to expect
  • Dry-run: your usual heartbeat lines, plus a “SIGNAL → checking guards…” and then SKIP: dry-run.
Live: if all guards pass, two tx hashes; otherwise, a SKIP: <reason> (stale quote, gas cap, balance, etc.).
8

(Optional) Tiny PnL print after fills (src/pnl.ts + hook)

This file produces a lightweight estimator that prints after-cost PnL in quote units right after a successful round-trip. It’s a quick gut-check in the console—not a ledger—so you can see if the edge you took likely paid after slippage and fees.
Inputs: None required. (You may optionally tweak a local rounding/formatting constant.)
a. Add the estimator file src/pnl.ts: It checks the edge BPs, slippage, fees, and compares them with the edge BPs to determine the PnL of the trade.
Paste the following code into src/pnl.ts.
// src/pnl.ts export function estPnlQuote( edgeBps: number, // post-fee edge your signal reported (bps) notionalQuote: number, // trade size in quote units slipBps: number, // slippage budget (bps) feesBps: number // total fees: poolA + poolB (+ gas proxy) in bps ) { const pnlBps = edgeBps - slipBps - feesBps; return (pnlBps / 10_000) * notionalQuote;
b. Hook it into the executor success branch that prints the estimate on success. Edit the  src/execute.ts file and add the import at the top, then print the estimate only when a fill succeeds.
// ADD near other imports: import { estPnlQuote } from './pnl'; // … inside your success path: if (res.sent) { const feesBps = cfg.fees.poolA_bps + cfg.fees.poolB_bps + (cfg.fees.gas_bps ?? 0); const est = estPnlQuote( sig.spreadBps ?? 0, cfg.execution.trade_notional_quote, cfg.policy.slippage_bps, feesBps ); console.log( `EXECUTED: tx1=${res.tx1} tx2=${res.tx2} | estPnL≈ ${est.toFixed(2)} quote` ); }
What to expect: On successful execution, you’ll see EXECUTED: tx1=… tx2=… | estPnL≈ 0.07 quote

Errors & Troubleshooting (quick fixes)

  1. No swaps ever fireSignal OK but executor idle. Check DRY_RUN isn’t set, cooldown is not blocking (wait a few seconds), and your spread_threshold_bps isn’t higher than typical edges.

  2. SKIP: stale quoteQuote timestamp too old. Reduce network hops, raise the staleness cap slightly, or call the quoter right before execution.

  3. INSUFFICIENT_ALLOWANCE / transferFrom failedApprovals missing or to the wrong router.  Re-run npm run approve after confirming routerA/routerB and token addresses in config.yaml.

  4. EXECUTION_REVERTED on leg A/BMinOut too tight or wrong decimals. Recheck token decimals, fee math, and slippage budget; verify minOut uses the same units as the router.

  5. Insufficient funds for gasWallet underfunded. Top up the native token for gas on Sonic; keep a buffer for spikes.

  6. Quotes look huge/tinyDecimals / amountIn mismatch. Confirm decimalsIn/decimalsOut and that trade_notional_quote is in quote units, not wei/base.

  7. SKIP: gas over capGas price spike. Raise the gas cap modestly or wait; don’t disable the guard.

  8. Invalid address / chainId mismatchWrong network/router. Ensure SONIC_RPC_URL is Sonic, and router/token addresses are Sonic deployments (not Ethereum).

  9. Nonce too low/stuck txCompeting sends or slow provider. Use a single runner, retry with a slightly higher gas price, or clear pending txs.

  10. File writes OK, but console shows … for pricesStream isn’t live. Restart the process; verify pool filters and API key; check your WebSocket connectivity.

If one keeps repeating, log the reason and values (quote age, gas, minOut, balances) next to the skip—most fixes become obvious with those numbers printed.

Closing: where we are, what’s left, what’s next

What you’ve built

You now have a working HFT arbitrage loop on Sonic, end-to-end: Goldrush Streams → clean prices → features/policy → capture/replay → walk-forward backtest → approvals → quoter → guarded executor → tiny PnL print. It runs live, gates every attempt with minOut/staleness/gas/cooldown, and logs enough context to debug quickly. The code is modular, so you can swap pools/routers and tune without touching core logic.

What’s still missing (before you scale)

Not blockers, but worth adding as you harden:

  • Persistence & restart safety (write fills/decisions to disk/DB; resume cleanly).

  • Telemetry & alerts (latency spikes, stale-quote rate, revert reasons, PnL drift).

  • Key & allowance hygiene (low balances, scoped approvals, periodic rotation/revocation).

  • Tests for edge cases (decimals/minOut math, quote age, one-in-flight control).

  • A real PnL ledger (beyond the quick estimate) and simple dashboards.

What’s next

  • Run small, live: keep size conservative; watch logs and trades.csv for hit rate, slippage, and skips.

  • Tune, then widen: adjust thresholds/windows; only then add more pairs/venues.

  • Optional upgrades: CLAMM/DLMM quoter support, private submission (if/when available on Sonic), containerize and supervise the process (PM2/systemd), and ship basic dashboards.

You can use the bot today in a cautious configuration. Treat it like a real system: measure, adjust, and scale only when the numbers stay boring and green.

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.