Build a Real-Time Agent on Base with GoldRush (Part 2) — Make the Agent Profitable: Cost-Aware Execution And PnL Backfill

Joseph Appolos
Content Writer
Learn how to make your on-chain AI agent profitable on Base with GoldRush (part 2): Master cost-aware execution, real-time data, and PnL backfill analysis.

In Part 1, you spun up a live GoldRush OHLCV stream for a Uniswap v3 pool on Base and turned those candles into a simple, deterministic z-score signal. Part 2 will turn that signal into profitable on-chain action and then prove it with PnL backfill. Think of it as turning a good idea into a disciplined, auditable loop: GoldRush for data, Base for execution, Uniswap for liquidity.

What we’ll build in this part

  1. Budget the fees so “fast” doesn’t eat your edge: The transaction fees on Base, which are in two components ( L2 and an L1 fee), must be translated into basis points (bp) of your trade size so your signal only acts when it clears the total cost, not just gas.

  2. Price the actual costs, then execute: From your z-score, you estimate an expected edge, then request a firm quote on Base via the 0x Swap API v2 (which taps Uniswap liquidity). Set a slippageBps cap, estimate L2+L1 fees, and only send the tx if the conditions are right. We’ll also handle the one-time token approval that the API calls out.

  3. Prove the trade with backfill: After you trade, pull your wallet’s txs via GoldRush Transactions v3, decode token movements, and roll up realized PnL. That ledger lets you tighten thresholds and buffers with evidence, not vibes.

Profitability in practice on Base and beyond

When you start wiring an agent for real-world execution, the theory ends and the math begins. The line between a profitable and a losing trade often comes down to whether your edge can survive the cost of acting on it. That’s where cost-aware execution and PnL backfill come in.

What does cost-aware execution mean?

Cost-aware execution is exactly what it sounds like. It's trading only when it’s actually worth it after fees, slippage, and uncertainty. Instead of reacting to every signal your model throws, the agent first checks whether the expected gain clears the total cost of doing business. On Base, those costs are more nuanced than just gas.

A Base transaction includes:

  • An L2 execution fee: Which is the gas you pay to run the transaction on Base, and

  • An L1 data fee: This is the cost of posting that transaction back to Ethereum for security. The L1 component fluctuates with Ethereum L1 conditions; the L2 part depends on load and your gas settings.

Base Network is fast, but a few basis points of fees often decide whether your edge pays or gets eaten. Since mid-2025, Base has led the L2 landscape, accounting for more than 80% of all L2 transaction fees according to Dune’s July report, meaning most onchain “alpha races” are happening here first.

Yes, Flashblocks changed the latency profile on Base, allowing streams to occur at over 200ms preconfirmations. This means strategies that react quickly (and price their costs correctly) are the ones that’ll actually win fills without waiting for full blocks.

Profitability on Base is simple to write, relentless to meet; therefore, to stay profitable,

“trade only when expected_edge_bps > fee_bps + slippage_bps + safety_buffer_bps.

This might feel cumbersome to understand. Let’s break it down:

(bps = “basis points” = 0.01%)

  1. expected_edge_bps: your model’s expected advantage for this trade, expressed in basis points of notional.

    1. In Part 1, we mapped signal strength to edge, e.g., edge_bps = |z| × 30.

    2. If z = 3.0, then edge_bps = 90 (i.e., +0.90% expected).

  2. fee_bps: the network fee for sending the transaction on Base, converted into bps of the trade size.

    1. Compute the result from estimateGas * gasPrice, convert wei to ETH, then ETH to your quote currency (e.g., USDC), divide by notional, and multiply by 10,000.

  3. slippage_bps: your worst-case price movement during execution, in bps of notional.

    1. From a firm quote (0x v2 on Base), either use your cap (e.g., slippageBps = 50) or derive it:
      slippage_bps = (1 − guaranteedPrice / price) × 10,000.

  4. safety_buffer_bps: a cushion for things you didn’t price perfectly (small drifts, rounding, micro-latency).

    1. Start with 10–25 bps; tighten once you’ve logged real fills.

This guarantees your predicted gain (in bps) beats your all-in costs (also in bps).

Example (Uniswap on Base)

  • Notional: 100 USDC

  • Model edge: z = 3.0, mapping 30 bps per σ → expected_edge_bps = 90

  • Fee estimate: your provider says the tx will cost 0.000005 ETH; if ETH≈$4,000, that’s $0.04 = 0.04 USDC

    • fee_bps = (0.04 / 100) × 10,000 = 4.0

  • Slippage cap: slippage_bps = 50 (0.50%)

  • Safety buffer: safety_bps = 15 (0.15%)

Decision

90 > 4.0 + 50 + 15 → 90 > 69.0trade

If z = 2.0 (edge 60 bps), then 60 < 69.0 is falseskip

That’s the whole idea: express everything in the same units (bps), then let a single inequality decide—no vibes, no guesswork.

What does PnL backfill mean?

PnL (profit and loss) backfill is how you prove whether your model worked after each transaction. Every fill, fee, and transfer gets recorded on-chain. GoldRush decodes that data for you so you can reconstruct the full trade history and measure whether your agent’s predicted edge actually showed up in real dollars.

This feedback is vital, and without it, you’re flying blind. With it, you can fine-tune thresholds, adjust buffers, and evolve from “reactive” trading to statistically disciplined execution.

While the cost-aware analysis above produces a clean rule for deciding whether to make a trade, PnL backfill actually measures what actually happened during and after the trade.

This is exactly where GoldRush shines. With its unified blockchain data API, you can pull every fill, gas receipt, token transfer, and fee adjustment directly from Base, then map them into your profitability models.

For example, if your Base strategy consistently overestimates edge during volatile hours, PnL backfill from GoldRush will reveal that pattern in seconds. You can then adjust your thresholds,  raising your safety buffer or skipping specific pairs when gas volatility spikes instead of finding out after your USDC balance dips.

Why Uniswap + GoldRush for signals (on Base)

In Part 1, you brought GoldRush Streaming online and produced a deterministic z-score from live OHLCV. We’ll keep that setup, but be explicit about the venue: for Base, we’ll use Uniswap v3 pools as our market source and subscribe via GoldRush’s OHLCV Pairs stream. For Uniswap on Base, you can confirm deployments and canonical addresses in Uniswap’s docs before you pick the pool.

The toolchain we’ll use in this Part

  • Signals: the z-score you built from GoldRush OHLCV Pairs on Base (Uniswap pool).

  • Firm quotes & slippage caps: the 0x Swap API v2 on chainId 8453 (Base); we’ll request firm quotes that include {to, data, value} so we can send the tx verbatim and enforce slippageBps. Check their chain support and Swap docs.

  • Execution: ethers.js against a Base RPC (avoid the rate-limited public URL; use a provider, assert chainId 8453).

  • PnL/backfill: GoldRush Foundational API (Transactions v3) to decode fills and compute realized PnL after the fact.

What you’ll build 

You’ll wrap your z-score in a profit gate that prices fees, enforces a slippage cap, and only sends the Uniswap trade when the inequality clears. Then you’ll backfill fills and fees to produce a daily PnL—closing the loop between predicted edge and realized results. When you’re done, the agent won’t just trade on Base; it will prove why each trade made (or lost) money, with timestamps you can audit.

Step-by-step Tutorial: cost gates, execution, and PnL (Uniswap on Base)

You’re continuing the same project from Part 1. Keep your existing .env and dependencies. If you’re starting fresh, complete Part 1’s setup first (GoldRush SDK + Node 18+, .env with GOLDRUSH_API_KEY, BASE_RPC_URL, and your Uniswap pool address).

a1

Confirm project architecture/layout

Before we add code, here’s the small, expected file layout for the Part 2 pieces we’ll add. Keep this at the root of your project (the same project you built in Part 1):
base-agent/
├── .env         # GOLDRUSH_API_KEY, BASE_RPC_URL, BOT_WALLET_PK,OX_API_KEY
├── package.json
├── tsconfig.json
└── src/
    ├── stream.ts        # Part 1: GoldRush OHLCV stream
    ├── predict.ts       # Part 1: z-score model
    ├── agent.ts         # Part 1: harness (calls stream + predict)
    ├── rpc.ts           # <-- this file (connect to Base mainnet)
    ├── quote.ts         # Part 2: 0x firm quote helper
    ├── costs.ts         # Part 2: fee -> bps helper
    ├── trade.ts         # Part 2: gate + send tx
    └── pnl.ts           # Part 2: backfill scaffold (Transactions v3)
1

Connect to Base mainnet — src/rpc.ts

Create the file - src/rpc.ts to connect to the Base Mainnet. The file creates a single, shared ethers provider wired to your Base RPC URL (from .env), then provides a tiny helper assertBase() that checks the provider is connected to chainId 8453 and throws early if not. You’ll import provider and assertBase from other modules (costs, trade, etc.), so every piece of code uses the same node and network checks. This keeps network checks consistent and prevents you from accidentally signing on the wrong chain.
Paste this content into src/rpc.ts
// src/rpc.ts import { ethers } from "ethers"; /** * .env must contain: * BASE_RPC_URL=https://your-dedicated-base-rpc.example * * NOTE: avoid the public rate-limited endpoints for production. */ export const provider = new ethers.JsonRpcProvider(process.env.BASE_RPC_URL!); /** * Call this before sending transactions or making fee decisions. * Throws if the connected chain is not Base mainnet (8453). */ export async function assertBase() { const net = await provider.getNetwork(); if (net.chainId !== 8453n) { throw new Error(`Wrong chain connected: ${net.chainId.toString()} (need 8453 for Base mainnet)`); } }
What you should see/expected behavior
  • No console output by itself — this module just exports provider and assertBase.
  • When another script calls assertBase(), it will either:
    • return quietly (OK), or
    • throw an error with a clear message, such as "Wrong chain connected: 1 (need 8453 ...)," which indicates that the node is pointed at the wrong network.
2

Request a firm quote on Base — src/quote.ts

In this step, the agent moves from analyzing market data to preparing an actual trade. We use the 0x Swap API to fetch a firm quote. This transaction-ready payload specifies exactly how much of one token can be swapped for another, along with its guaranteed price and encoded transaction data. This step doesn’t replace GoldRush; rather, it complements it.
While GoldRush powers the data intelligence layer — streaming OHLCV data, feeding signals to our z-score model, and backfilling PnL later — 0x acts as the execution layer, providing a reliable route and price quote before we commit to a swap. Together, they form the “sense and act” loop: GoldRush helps the agent see the opportunity, and 0x helps it act on it safely.
This module requests a firm execution quote from the 0x Swap API on Base (chainId 8453). A firm quote returns the exact transaction payload you can submit (to, data, value) along with price and guaranteedPrice fields you’ll use to compute slippage. We prefer firm quotes because they let you compare a model-predicted edge to a slippage-protected execution path before signing anything. The helper below also returns the API’s issues block (e.g., the spender that needs an approval), so your trade flow can handle one-time approvals automatically.
Paste this code into src/quote.ts in your environment.
// src/quote.ts /** * getFirmQuoteUSDCtoWETH * * What to set in .env: * ZEROX_API_KEY=your_0x_key * * Notes: * - sellAmountUSDC is provided in USDC's raw units (6 decimals), e.g. "100000000" = 100 USDC. * - taker should be the address that will send the transaction (your bot's wallet). * - slippageBps is a safety cap (e.g., 50 = 0.50%). * * Returns the JSON response from 0x which includes: * - to, data, value (tx payload) * - price, guaranteedPrice * - gas (estimated gas) * - issues.allowance.spender (if approval is required) */ export const USDC_BASE = "0x833589fCD6EDb6E08f4c7C32D4f71B54Bda02913"; // example; confirm on Base export const WETH_BASE = "0x4200000000000000000000000000000000000006"; // canonical WETH on Base export async function getFirmQuoteUSDCtoWETH( sellAmountUSDC: string, // e.g. "100000000" = 100 USDC taker: string, // your bot address (0x...) slippageBps = 50 // default slippage cap (basis points) ) { if (!process.env.ZEROX_API_KEY) { throw new Error("ZEROX_API_KEY missing in .env"); } const url = new URL("https://api.0x.org/swap/allowance-holder/quote"); url.searchParams.set("chainId", "8453"); // Base mainnet url.searchParams.set("sellToken", USDC_BASE); url.searchParams.set("buyToken", WETH_BASE); url.searchParams.set("sellAmount", sellAmountUSDC); url.searchParams.set("taker", taker); url.searchParams.set("slippageBps", String(slippageBps)); const res = await fetch(url.toString(), { method: "GET", headers: { "0x-api-key": process.env.ZEROX_API_KEY!, “0x-version”: 'v2' "Accept": "application/json" } }); if (!res.ok) { const body = await res.text(); throw new Error(`0x quote failed: ${res.status} ${res.statusText} — ${body}`); } const payload = await res.json(); // payload contains: to, data, value, price, guaranteedPrice, gas, estimatedGas, issues, ... return payload; }
What this returns
  • to, data, value — the exact transaction payload you can pass to wallet.sendTransaction(...).
  • price and guaranteedPrice — use these to calculate slippage:
    slippage_bps ≈ (1 - guaranteedPrice/price) * 10_000.
  • gas / estimatedGas — useful as a sanity check before calling provider.estimateGas.
  • issues.allowance.spender — if present, that contract needs an ERC20 approval before you can execute the returned payload.

Error and Troubleshooting

  • 401/403 from 0x: check ZEROX_API_KEY in .env and confirm it's valid and not rate-limited.
  • No route / empty result: the liquidity for that exact amount may be insufficient; try a smaller sellAmount to test.
  • issues.allowance.spender present: call approve(spender, amount) from your bot wallet (one-time) before attempting to send the quoted payload.
  • Mismatch in sellToken/buyToken: verify token addresses on a block explorer for Base; using a token address instead of a pool or a wrong token address will break routes.
  • Slippage vs guaranteedPrice: if guaranteedPrice is missing, prefer the explicit slippageBps cap and treat the quote as non-guaranteed.

Security note

Treat the returned to/data as a signed instruction: always sanity-check to and data before sending, and keep a router/allowlist if you want to restrict execution targets.
3

Convert estimated fees to bps — src/costs.ts

This module translates the estimated gas cost of a trade into basis points (bps) of your notional. In other words, it helps the agent express Base’s L2 and L1 execution costs in the same unit as your signal’s expected gain. When combined with slippage and buffer checks later, it ensures your trades only execute when the predicted edge actually clears the total cost.
In the architecture, this function runs quietly under the hood. When your agent receives a firm quote from 0x, it passes that payload here to compute the transaction’s fee impact. You won’t run this file directly — trade.ts use it as part of the decision gate.
Paste this code into src/costs.ts
// src/costs.ts import { provider } from "./rpc"; import { ethers } from "ethers"; /** * Minimal shape of the 0x quote used here */ type Quote = { to: string; data: string; value?: string | number; price?: string | number; // optional, from 0x guaranteedPrice?: string | number; }; /** * Convert native (ETH) cost into USDC using a price source. * Replace this with a proper oracle or API call in production. */ async function ethToUSDC(ethAmount: number, ethUsdPrice: number) { return ethAmount * ethUsdPrice; // approximate conversion } /** * Compute transaction fee in basis points (bps) */ export async function feeBpsForQuote( quote: Quote, sellNotionalUSDC: string, // e.g., "100000000" = 100 USDC ethUsdPrice: number // current ETH→USD price, e.g., 2500 ) { if (!quote?.to || !quote?.data) throw new Error("Invalid quote payload"); // Estimate gas cost for this quote const txReq = { to: quote.to, data: quote.data, value: (quote.value ? ethers.BigInt(String(quote.value)) : undefined) as any }; const gasUsed = await provider.estimateGas(txReq); const feeData = await provider.getFeeData(); const gasPrice = feeData.maxFeePerGas ?? feeData.gasPrice; if (!gasPrice) throw new Error("Missing gas price data"); const txCostWei = gasUsed * gasPrice; const txCostEth = Number(ethers.formatEther(txCostWei)); const txCostUSDC = await ethToUSDC(txCostEth, ethUsdPrice); const notionalUSD = Number(sellNotionalUSDC) / 1e6; // USDC has 6 decimals const feeBps = (txCostUSDC / notionalUSD) * 10_000; return { feeBps, txCostUSDC }; }
Error And Troubleshooting
  • Invalid quote payload:
    This happens if your 0x API response is malformed or missing the "to" and "data" fields.
    → Re-check your API key, Base chain ID (8453), and token addresses.
  • Missing gas price or estimate failure:
    Some RPC providers (especially rate-limited or public ones) may fail to return maxFeePerGas or gasPrice.
    → Use a dedicated Base RPC (Alchemy, QuickNode, or Ankr) instead of https://mainnet.base.org.
  • Overestimated gas → inflated fee_bps:
    estimateGas() sometimes overshoots, especially for dynamic router calls.
    → Log gas used from real transaction receipts (via GoldRush backfill later) and update your buffer.
Inaccurate ETH→USDC conversion:
The default ethToUSDC() function assumes a static price.
→ Replace it with a reliable 0x /price query, Chainlink oracle, or GoldRush price feed for production.
4

Gate and execute trades safely — src/trade.ts

This module is the “do it only if it’s worth it” switch. It takes your model’s expected edge (bps), asks 0x for a firm quote on Base (chainId 8453), converts the fee to bps, derives slippage bps from guaranteedPrice, adds a small safety buffer, and executes the transaction via ethers only if the inequality clears:
expected_edge_bps > fee_bps + slippage_bps + safety_bps
It returns a structured result (skip/proceed/needs_approval) so the calling harness can act accordingly. You won’t run this file directly; it’s imported and called from your agent.ts decision loop.
Paste this code into src/trade.ts
// src/trade.ts import { ethers } from "ethers"; import { provider } from "./rpc"; import { getFirmQuoteUSDCtoWETH } from "./quote"; import { feeBpsForQuote } from "./costs"; /** * Inputs needed to evaluate + execute a trade */ export type TradeRequest = { sellAmountUSDC: string; // raw units, e.g. "100000000" = 100 USDC expectedEdgeBps: number; // from your model (e.g., z-score mapping) slippageCapBps?: number; // guard cap, default 50 (0.50%) safetyBps?: number; // buffer for micro-drifts, default 15 bps ethUsdPrice: number; // USD/ETH used to convert gas -> USD botPrivateKey: string; // signer for execution }; export type TradeDecision = | { status: "skip"; reason: string; context?: any } | { status: "needs_approval"; spender: string; context?: any } | { status: "submitted"; txHash: string; context?: any } | { status: "failed"; error: string; context?: any }; /** * Slippage in bps with 0x v2-first logic, v1 fallback, then cap. * v2: buyAmount/minBuyAmount (numbers in raw units) * v1: price/guaranteedPrice (strings) */ function slippageBpsFromQuote(quote: any, capBps: number) { // --- v2 shape --- const buyAmount = Number(quote?.buyAmount ?? 0); const minBuyAmount = Number(quote?.minBuyAmount ?? 0); if (buyAmount > 0 && minBuyAmount > 0 && minBuyAmount <= buyAmount) { return ((buyAmount - minBuyAmount) / buyAmount) * 10_000; } // --- v1 shape --- const price = Number(quote?.price ?? 0); const gp = Number(quote?.guaranteedPrice ?? 0); if (price > 0 && gp > 0) { return (1 - gp / price) * 10_000; } // --- fallback --- return capBps; } /** * Gate and (optionally) execute a USDC->WETH trade on Base via 0x, only if profitable. */ export async function tryTradeIfProfitable(req: TradeRequest): Promise<TradeDecision> { const slippageCap = req.slippageCapBps ?? 50; const safety = req.safetyBps ?? 15; // 1) Ensure we’re on Base mainnet (8453) const net = await provider.getNetwork(); if (net.chainId !== 8453n) { return { status: "skip", reason: `wrong chain ${net.chainId.toString()} (need 8453)` }; } // 2) Signer let wallet: ethers.Wallet; try { wallet = new ethers.Wallet(req.botPrivateKey, provider); } catch (e) { return { status: "failed", error: "invalid private key", context: { e: String(e) } }; } const takerAddr = await wallet.getAddress(); // 3) Firm quote on Base let quote: any; try { quote = await getFirmQuoteUSDCtoWETH(req.sellAmountUSDC, takerAddr, slippageCap); } catch (e) { return { status: "failed", error: "0x quote failed", context: { e: String(e) } }; } // 4) Allowance requirement? const spender = quote?.issues?.allowance?.spender; if (spender) { return { status: "needs_approval", spender, context: { quoteHint: { to: (quote.transaction || quote)?.to } } }; } // 5) Fee (bps) for the quoted payload (v2 nests under .transaction) let feeBps: number, txCostUSDC: number; try { const txForFees = quote?.transaction || quote; const feeRes = await feeBpsForQuote(txForFees, req.sellAmountUSDC, req.ethUsdPrice); feeBps = feeRes.feeBps; txCostUSDC = feeRes.txCostUSDC; } catch (e) { return { status: "failed", error: "fee estimation failed", context: { e: String(e) } }; } // 6) Slippage (bps) with v2-first logic const slippageBps = slippageBpsFromQuote(quote, slippageCap); // 7) Profitability gate const hurdle = feeBps + slippageBps + safety; if (req.expectedEdgeBps <= hurdle) { return { status: "skip", reason: "edge <= costs", context: { edge: req.expectedEdgeBps, feeBps, slippageBps, safetyBps: safety, hurdle } }; } // 8) Execute the provided transaction (handle v2 nesting) try { const txData = quote?.transaction || quote; const tx = await wallet.sendTransaction({ to: txData.to, data: txData.data, value: txData.value ?? 0 }); const rec = await tx.wait(); return { status: "submitted", txHash: tx.hash, context: { blockNumber: rec?.blockNumber, edge_bps: req.expectedEdgeBps, fee_bps: feeBps, slippage_bps: slippageBps, safety_bps: safety, txCostUSDC } }; } catch (e) { return { status: "failed", error: "sendTransaction failed", context: { e: String(e) } }; } }

Error and Troubleshooting

  • Wrong chain (need 8453): Fix BASE_RPC_URL to a Base mainnet provider.
  • 0x quote failed: Check ZEROX_API_KEY, token addresses, or reduce sellAmountUSDC.
  • Needs approval: Use USDC approve(spender, amount) once, then retry.
  • Fee estimation failed: Use a reliable RPC; ensure you pass quote.to/data/value exactly.
  • Skipped: edge ≤ costs: Gate working as intended—wait for a stronger signal or tune slippageCapBps/safetyBps.
  • Stale ETH→USD price: Replace static ethUsdPrice with a live source (0x /price, Chainlink, or GoldRush pricing).
Where this sits in the flow
  • Before: predict.ts produced expectedEdgeBps from your z-score.
  • Here: trade.ts requests 0x for the route and payload, converts fees and slippage to bps, gates, and executes.
Next: Step 5/6 record and backfill PnL with GoldRush Transactions v3 so you can validate whether executions actually made money and tighten your buffers.

PnL and Backfill tracking

Up to this point, your agent can see the market (GoldRush stream), decide (z-score + profitability check), and act (guarded 0x execution).

Now it’s time to teach it how to remember.

Quick check (no transactions sent)

Before moving to the next stage, we need to verify if the codes for the cost-aware executions are working properly. The output should tell us whether the trade is viable in terms of gas, slippage, etc.

To do this, we create a tiny probe that stops before sendTransaction and just prints the math. This validates your quote, fee bps, and slippage bps.

Create a src/decision_probe.ts file and paste this code into it.

// src/trade.ts import { ethers } from "ethers"; import { provider } from "./rpc"; const ERC20_ABI = [ "function allowance(address owner, address spender) view returns (uint256)", "function approve(address spender, uint256 amount) returns (bool)", "function decimals() view returns (uint8)" ]; // USDC on Base (confirm once in your project) export const USDC_BASE = "0x833589fCD6EDb6E08f4c7C32D4f71B54Bda02913"; export type EnsureAllowanceInput = { token?: string; // defaults to USDC_BASE ownerPk: string; // BOT_WALLET_PK spender: string; // from quote.issues.allowance.spender minAmountRaw: string; // e.g., "100000000" = 100 USDC maxAmountRaw?: string; // optional, e.g., MaxUint256 for “approve max” }; export async function ensureAllowance({ token = USDC_BASE, ownerPk, spender, minAmountRaw, maxAmountRaw }: EnsureAllowanceInput) { const wallet = new ethers.Wallet(ownerPk, provider); const owner = await wallet.getAddress(); const erc20 = new ethers.Contract(token, ERC20_ABI, wallet); // 1) Check current allowance const current = await erc20.allowance(owner, spender); const need = ethers.BigInt(minAmountRaw); if (current >= need) { return { status: "ok", action: "skip_approve", allowance: current.toString() }; } // 2) Approve (either exact min or a higher, managed ceiling) const amount = ethers.BigInt(maxAmountRaw ?? minAmountRaw); const tx = await erc20.approve(spender, amount); const rec = await tx.wait(); return { status: "approved", txHash: tx.hash, blockNumber: rec?.blockNumber, allowanceSet: amount.toString() }; }

What you should see (examples):

  • Healthy proceed
    { expectedEdgeBps: 90, feeBps: 4.2, slippageBps: 28.1, safetyBps: 15, decision: 'PROCEED' }

  • Correct skip
    { expectedEdgeBps: 60, feeBps: 4.8, slippageBps: 50.0, safetyBps: 15, decision: 'SKIP' }

  • Allowance required (from the quote)
    quote.issues.allowance.spender present → you’ll handle with ensureAllowance(...) in Step 5.

If this probe prints reasonable numbers and a sensible decision, your cost math is correct.

5

One-time ERC-20 approval helper — src/approve.ts

When 0x returns issues.allowance.spender, your bot must approve that spender to move your USDC before any swap can execute. This helper checks the current allowance and, if needed, submits an approve(spender, amount) from your bot wallet. It’s called only when trade.ts returns status: "needs_approval".
Paste this code into src/approve.ts
// src/approve.ts import { ethers } from "ethers"; import { provider } from "./rpc"; const ERC20_ABI = [ "function allowance(address owner, address spender) view returns (uint256)", "function approve(address spender, uint256 amount) returns (bool)", "function decimals() view returns (uint8)" ]; // USDC on Base (confirm once in your project) export const USDC_BASE = "0x833589fCD6EDb6E08f4c7C32D4f71B54Bda02913"; export type EnsureAllowanceInput = { token?: string; // defaults to USDC_BASE ownerPk: string; // BOT_WALLET_PK spender: string; // from quote.issues.allowance.spender minAmountRaw: string; // e.g., "100000000" = 100 USDC maxAmountRaw?: string; // optional, e.g., MaxUint256 for “approve max” }; export async function ensureAllowance({ token = USDC_BASE, ownerPk, spender, minAmountRaw, maxAmountRaw }: EnsureAllowanceInput) { const wallet = new ethers.Wallet(ownerPk, provider); const owner = await wallet.getAddress(); const erc20 = new ethers.Contract(token, ERC20_ABI, wallet); // 1) Check current allowance const current = await erc20.allowance(owner, spender); const need = ethers.BigInt(minAmountRaw); if (current >= need) { return { status: "ok", action: "skip_approve", allowance: current.toString() }; } // 2) Approve (either exact min or a higher, managed ceiling) const amount = ethers.BigInt(maxAmountRaw ?? minAmountRaw); const tx = await erc20.approve(spender, amount); const rec = await tx.wait(); return { status: "approved", txHash: tx.hash, blockNumber: rec?.blockNumber, allowanceSet: amount.toString() }; }
Where it plugs in
  • trade.ts returns { status: "needs_approval", spender }.
  • Your harness calls ensureAllowance({ ownerPk: BOT_WALLET_PK, spender, minAmountRaw: sellAmountUSDC }).
  • On success, call tryTradeIfProfitable(...) again with the same inputs.

Error & Troubleshooting

  • Allowance never increases: Some tokens require resetting to 0 before raising allowance. Try approving zero first, then approving the target amount.
  • Spender changes across quotes: Don’t hard-code; always read quote.issues.allowance.spender and approve that address.
Approving “max” vs exact: Max approvals reduce friction but increase risk if a key leaks. If you use a high ceiling, combine with router allow-lists and a low-balance executor wallet.
6

Backfill and PnL Tracking (src/pnl.ts)

Now that your agent can trade, the next step is to verify what happened — whether those trades actually made money after fees and slippage. This is where PnL (Profit and Loss) backfill comes in.
While the streaming and execution steps run in real time, the backfill runs after the fact — fetching your wallet’s transactions from GoldRush’s Foundational API (Transactions v3) and calculating realized PnL.
Think of this as your audit layer. It’s what separates “looks good in logs” from “actually profitable.”
What happens in this module
  • It fetches transactions from your agent’s wallet on Base (chainId 8453) using GoldRush’s Transactions v3 endpoint.
  • It extracts token inflows and outflows from decoded logs (Transfers, Swaps).
  • It values those flows in USD (or USDC equivalent) to compute realized PnL.
  • Finally, it aggregates PnL over time (e.g., per day or per hour) for performance tracking.
This connects the dots:
  • GoldRush Streaming shows what your agent saw.
  • GoldRush Transactions v3 shows what your agent did — and what it earned.
Paste this code into src/pnl.ts
// src/pnl.ts import 'dotenv/config'; import { GoldRushClient } from '@covalenthq/client-sdk'; const client = new GoldRushClient(process.env.GOLDRUSH_API_KEY!); // Basic types type Tx = any; type TokenDelta = { token: string; amount: string }; type PricedFlow = { ts: number; value_usd: number }; // 1) Fetch paginated transactions for a wallet on Base export async function fetchTxPageBase(address: string, page = 0) { const res = await client.TransactionService.getPaginatedTransactionsForAddress({ chainName: 'base-mainnet', walletAddress: address, page }); return res.data; } // 2) Iterate through pages to get a date range export async function fetchTxnsRangeBase(address: string, startTs: number, endTs: number) { const out: Tx[] = []; for (let page = 0; page < 10; page++) { const data = await fetchTxPageBase(address, page); const items: Tx[] = data?.items ?? []; for (const tx of items) { const ts = new Date(tx.timestamp ?? tx.block_signed_at).getTime(); if (ts < startTs) return out; if (ts <= endTs) out.push(tx); } if (!items.length) break; } return out; } // 3) Compute token deltas (in/out) for your wallet export function tokenDeltasForWallet(tx: Tx, wallet: string): TokenDelta[] { const deltas: Record<string, bigint> = {}; const logs = tx?.log_events ?? []; for (const ev of logs) { if (ev?.decoded?.name === 'Transfer') { const params = ev.decoded.params ?? []; const from = params.find((p:any)=>p.name==='from')?.value?.toLowerCase?.(); const to = params.find((p:any)=>p.name==='to')?.value?.toLowerCase?.(); const val = params.find((p:any)=>p.name==='value')?.value as string; const token = ev?.sender_address; if (!token || !val) continue; const amt = BigInt(val); if (from === wallet.toLowerCase()) deltas[token] = (deltas[token] ?? 0n) - amt; if (to === wallet.toLowerCase()) deltas[token] = (deltas[token] ?? 0n) + amt; } } return Object.entries(deltas).map(([token, amount]) => ({ token, amount: amount.toString() })); } // 4) Value flows in USD (stub: replace with real pricing source) export async function priceAtTsUSDC(token: string, ts: number, amountRaw: string): Promise<number> { // Plug in GoldRush pricing endpoint here return 0; // placeholder } // 5) Compute realized PnL for a transaction export async function realizedPnlForTx(tx: Tx, wallet: string) { const ts = new Date(tx.timestamp ?? tx.block_signed_at).getTime(); const flows = tokenDeltasForWallet(tx, wallet); const values: PricedFlow[] = []; for (const f of flows) { const v = await priceAtTsUSDC(f.token, ts, f.amount); values.push({ ts, value_usd: v }); } const grossUSD = values.reduce((a, b) => a + b.value_usd, 0); const gasUSD = 0; // Add later using tx.fees_paid and pricing helper const realized = grossUSD - gasUSD; return { ts, grossUSD, gasUSD, realizedUSD: realized }; } // 6) Roll up daily PnL export function rollupDailyPnL(rows: { ts: number; realizedUSD: number }[]) { const day = (ms:number) => new Date(new Date(ms).toISOString().slice(0,10)).getTime(); const map: Record<string, number> = {}; for (const r of rows) { const d = day(r.ts).toString(); map[d] = (map[d] ?? 0) + r.realizedUSD; } return Object.entries(map) .map(([d, v]) => ({ day: new Date(Number(d)).toISOString().slice(0,10), pnl_usd: v })) .sort((a,b)=>a.day.localeCompare(b.day)); }

Run the script:

You can create a simple demo runner to test:
  • Create a demo runner with the name — src/pnl_demo.ts. Paste the code below into the file.
// src/pnl_demo.ts import { fetchTxnsRangeBase, realizedPnlForTx, rollupDailyPnL } from './pnl'; const WALLET = '0xYourWalletHere'; const endTs = Date.now(); const startTs = endTs - 7 * 24 * 60 * 60 * 1000; (async () => { const txs = await fetchTxnsRangeBase(WALLET, startTs, endTs); const rows = await Promise.all(txs.map(tx => realizedPnlForTx(tx, WALLET))); console.table(rollupDailyPnL(rows)); })();
Then, run it
npm run dev -- src/pnl_demo.ts

Expected Output

If everything is configured properly (.env set, wallet has on-chain activity on Base, and the GoldRush API key is valid). In that case, your terminal should print a table of daily realized PnL values — something like this:

Each row corresponds to:

  • day → UTC date of your trade activity

  • pnl_usd → realised net profit or loss (USD or USDC equivalent) after subtracting fees

If there’s no trading history, you’ll instead see an empty table [].

Or if your pricing function (priceAtTsUSDC) still returns 0, all PnL will appear as pnl_usd = 0

Behind the Output

Each PnL value represents the sum of token inflows/outflows for your agent’s wallet over a 24-hour window, converted into USD.
So if your bot made several small swaps on a given day, the script adds all realized profits/losses to a single number.

You can later pipe this into a visualization (e.g., Grafana, Dune, or a simple chart) to monitor daily returns, variance, and hit rates.

Error and Troubleshooting

  • No transactions found: Check your wallet address or widen the date range.

  • Value is 0 for all flows: Replace priceAtTsUSDC() stub with GoldRush’s pricing endpoint.

  • Out-of-memory errors: Reduce history depth or use pagination to limit fetch size.

Duplicate transactions: GoldRush API paginates cleanly, but store results by tx_hash to dedupe.

Wrapping Up — From Signals to Proven Profitability

By this point, your agent has evolved from a reactive listener into a self-contained trading loop that can see, decide, act, and remember — all on Base.

In Part 1, you built the foundation: a live OHLCV stream, a deterministic z-score signal, and a simple logic harness to prove it worked in real time.

Here, in Part 2, you gave that signal economic context. You taught the agent how to respect costs, quote trades safely through 0x, and verify its own performance with GoldRush’s decoded transaction data.

Together, those layers form a practical feedback loop:

  • GoldRush Streaming → real-time perception

  • 0x Swap API → safe, executable quotes

  • Base Network → low-latency settlement

  • GoldRush Transactions v3 → transparent post-trade truth

That loop is what makes an agent production-ready. It doesn’t just react — it learns from its own ledger.

In Part 3, you’ll extend this foundation:

  • introducing risk controls and safety limits,

  • integrating LLM-based planning for multi-step actions, and

  • connecting your metrics to a live monitoring dashboard.

When you’re done, you’ll have a fully measurable, verifiable trading agent that runs autonomously on Base that is fast, data-driven, and accountable.

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.