AI Driven DeFi Exploit Detection (Part 3) — Live Risk Scoring with Anomaly Signatures

Joseph Appolos
Content Writer
Connect anomaly signatures to your live DeFi monitor and turn GoldRush stream signals into ranked LOW/HIGH/CRITICAL exploit risk alerts.

In Part 1, we stopped treating exploits as surprises and started treating them as data. You built a live DeFi exploit-detection monitor on Base that monitored treasury and governance wallets via GoldRush Streams and generated simple signals when something looked off. In practice, that gave you a lightweight crypto exploit scanner focused on your most important addresses.

In Part 2, you gave that monitor a memory. You pulled recent history for those wallets with the GoldRush Foundational API, turned it into hourly features, and scored which hours looked most unusual. That moved you from ad-hoc log reading to a repeatable DeFi security monitoring workflow, where you could say “this window was weird” with numbers instead of intuition.

Part 3 is where those pieces start to behave like a model and plug into your broader smart contract security process. Instead of reading anomaly reports by hand and eyeballing timelines, you’ll export the most unusual hours into a small “signature” file, load that file back into the live monitor, and turn each suspicious transaction into a combined risk alert that reflects both:

  • What the transaction looks like in isolation, and

  • What the surrounding hour has historically looked like.

The goal isn’t to build a full-blown ML stack inside this guide. It’s to add a thin, AI-shaped decision layer on top of the telemetry you already have, so your DeFi exploit detection pipeline is ready for heavier models later without changing its shape.

Why Basic Anomaly Monitoring Fails 

By the end of Part 2, you could answer two questions:

  • What is this wallet doing right now?” — via the live stream.

  • When did this wallet behave differently from usual?” — via hourly anomaly scores.

That’s already useful for day-to-day DeFi security monitoring. But it still leaves humans doing a lot of stitching:

  • You see a high-severity transfer in the console.

  • You remember that the same hour had a high anomaly score in the last scan.

  • You mentally connect that to a governance proposal or upgrade window.

The machine is providing ingredients; you’re doing the cooking. For a smart contract security team, that doesn’t scale.

The missing piece is a decision layer that can, at minimum:

  • Recognise when a live event lands inside a historically unusual window, and

  • Reflect that in a single, ranked risk label: LOW, MEDIUM, HIGH, or CRITICAL.

Once you have that, you can move from “here are some numbers” to “here are the top 5 things we should look at today.”

From anomaly windows to simple signatures

You already have the core ingredients for a first-pass signature system: a set of high-value watch wallets (treasury, governance, key LPs), hourly feature buckets that capture their activity (transaction count, USD volume, large transfers, approvals), and anomaly scores that show when an hour deviates from normal. Together, that gives you a clear view of who you care about, how they usually behave, and when that behaviour breaks the pattern.

Part 3 treats the top N anomalous hours for each wallet as candidate exploit windows and exports them as a JSON file. Each entry is a compact record that contains:

  • which wallet,

  • which hour (bucketStart → bucketEnd),

  • how anomalous it was, and

  • a couple of useful aggregates (tx count, total value).

At runtime, the monitor can answer a simple question that any crypto exploit scanner should be able to answer:
“Is this transaction landing in one of the hours that looked most abnormal for this wallet?”

If yes, it boosts the risk score. If not, it falls back to the heuristics from Part 1. It’s deliberately simple, but the pattern is correct: you handle the heavy lifting offline, where you crunch history and export signatures, then you keep the live path lean, where the monitor loads those signatures and scores incoming events against them in real time.

Later, you can replace the scoring curve or the export logic with something more advanced without rewriting the whole DeFi exploit detection stack.

Where AI fits in this stack

Throughout this series, “AI” has meant something particular: stream data from GoldRush, turn it into structured features and anomaly scores instead of raw logs, and run a model-style function on top that converts those features into an ordered risk signal for DeFi exploits.

Part 3 is the first time the monitor behaves like it has a model (an AI “brain”) inside your smart contract security tooling:

  • It reads from a file that encodes patterns learned from history.

  • It turns each live transaction into a combination of rule-based severity and signature-based risk score.

  • It emits alerts that tell you not just what happened, but how much attention it deserves compared with everything else in your DeFi security monitoring queue.

You’re still in TypeScript. You’re still working with simple JSON. But the plumbing now looks a lot closer to what a real AI-assisted DeFi exploit detection system would use.

Step-by-Step Tutorial: Add a simple AI risk layer to your monitor

In this section, we will explore the steps to add a simple AI risk layer to your monitor to enhance its capabilities and improve your overall monitoring process.

Details of what you’ll build in Part 3

This section turns the idea we’ve outlined above into code. By the end of this section, you’ll have:

  • An extended offline job that still prints anomaly reports and writes model/anomaly_windows.json — a compact list of the top anomalous hours per watch wallet.

  • A focused model.ts helper that loads that file and, for any (wallet, timestamp), returns a 0–100 model score for the matching anomaly window.

  • An upgraded stream pipeline that fuses Part-1 heuristic severity with this model score to emit a single, ranked risk label for every suspicious transaction

You’ll still run everything from the same repo and workflow.

npm run dev # live monitor with risk labels npm run analyze # historical baseline + anomaly scan + signature export
1

Project layout for Part 3

All work stays in the same project:
cd defi-exploit-signals-part1
By the end of Part 3, your layout will look like the image below:
You don’t create model/anomaly_windows.json by hand. npm run analyze will write it for you.
2

Extend the offline scan to export anomaly windows (src/offline.ts)

In Part 2, offline.ts pulled history for each watch wallet, built hourly features, scored anomalies, and printed a top-N report.
Now you’ll keep all of that, but also:
  • Collect the top anomalous hours in memory, and
  • Write them out to model/anomaly_windows.json at the end.
Open src/offline.ts and replace its content with:
// src/offline.ts import fs from 'fs'; import path from 'path'; import { CONFIG } from './config'; import { fetchHistoryForWallet } from './history'; import { buildFeatureSeries, scoreAnomalies } from './features'; const TOP_N = 10; type AnomalySignature = { wallet: string; bucketStart: string; bucketEnd: string; anomalyScore: number; txCount: number; totalValueUsd: number; }; const signatures: AnomalySignature[] = []; async function analyseWallet(wallet: string) { console.log('='.repeat(80)); console.log(`Wallet: ${wallet}`); console.log(`History window: last ${CONFIG.historyDays} day(s)\n`); const history = await fetchHistoryForWallet(wallet); if (!history.length) { console.log('[report] No transactions in the selected window.'); return; } const features = buildFeatureSeries(wallet, history); const anomalies = scoreAnomalies(features); const sorted = anomalies .slice() .sort((a, b) => b.anomalyScore - a.anomalyScore); console.log( `[report] Computed ${features.length} hourly bucket(s). Showing top ${Math.min( TOP_N, sorted.length )} anomalous hours:\n` ); for (const b of sorted.slice(0, TOP_N)) { const line = [ `${b.bucketStart}`, `score=${b.anomalyScore.toFixed(2)}`, `tx=${b.txCount}`, `vol≈$${b.totalValueUsd.toFixed(0)}`, `large_tx=${b.largeTransferCount}`, `approvals=${b.approvalCount}`, `peers=${b.uniqueCounterparties}`, ].join(' | '); console.log(' -', line); const bucketStart = b.bucketStart; const bucketEnd = new Date(bucketStart); bucketEnd.setHours(bucketEnd.getHours() + 1); signatures.push({ wallet, bucketStart, bucketEnd: bucketEnd.toISOString(), anomalyScore: b.anomalyScore, txCount: b.txCount, totalValueUsd: b.totalValueUsd, }); } console.log('\n[report] You can cross-check these hours against:'); console.log(' • Governance proposals or upgrades.'); console.log(' • Liquidity changes for your pools.'); console.log(' • Any known incident timelines.\n'); } function writeSignaturesToDisk() { const modelDir = path.join(__dirname, '..', 'model'); const modelFile = path.join(modelDir, 'anomaly_windows.json'); if (!fs.existsSync(modelDir)) { fs.mkdirSync(modelDir, { recursive: true }); } // De-duplicate by (wallet, bucketStart) const key = (s: AnomalySignature) => `${s.wallet}|${s.bucketStart}`; const seen = new Set<string>(); const unique = signatures.filter((s) => { const k = key(s); if (seen.has(k)) return false; seen.add(k); return true; }); fs.writeFileSync(modelFile, JSON.stringify(unique, null, 2)); console.log(`[model] Wrote ${unique.length} anomaly window(s) to ${modelFile}`); } async function main() { console.log('DeFi Exploit Baseline & Anomaly Scan — Part 2/3'); console.log('From heuristics to exploit signatures\n'); for (const wallet of CONFIG.watchWallets) { await analyseWallet(wallet); } writeSignaturesToDisk(); console.log('Done.'); } main().catch((err) => { console.error('[offline] Fatal error:', err); process.exit(1); });
What this step does
  • Keeps the same console report from Part 2.
  • Adds a signatures array that collects top anomalous hours across all wallets.
  • Writes them to model/anomaly_windows.json at the end of the run.
You still invoke it with:
npm run analyze
3

Add a model helper to score windows (src/model.ts)

Next, you need a small helper that can load model/anomaly_windows.json and, for any given wallet and timestamp, return a 0–100 model score for the corresponding hour.
What this step does
  • On first use, it tries to read model/anomaly_windows.json.
  • If the file is missing or invalid, it logs a warning and returns modelScore = 0 for everything.
  • If a wallet + hour match a recorded anomaly window, it maps the anomalyScore to a 0–100 model score.
Create src/model.ts:
// src/model.ts import fs from 'fs'; import path from 'path'; export type AnomalySignature = { wallet: string; bucketStart: string; bucketEnd: string; anomalyScore: number; txCount: number; totalValueUsd: number; }; let signatures: AnomalySignature[] | null = null; function loadSignatures(): AnomalySignature[] { if (signatures) return signatures; const modelFile = path.join(__dirname, '..', 'model', 'anomaly_windows.json'); try { const raw = fs.readFileSync(modelFile, 'utf8'); const parsed = JSON.parse(raw) as AnomalySignature[]; signatures = parsed; console.log( `[model] Loaded ${parsed.length} anomaly window(s) from ${modelFile}` ); } catch { console.warn( '[model] No anomaly_windows.json found or failed to parse. Model scoring will be disabled.' ); signatures = []; } return signatures; } function toBucketStart(tsIso: string): string { const d = new Date(tsIso); d.setMinutes(0, 0, 0); return d.toISOString(); } /** * Map an anomalyScore (z-score-like) to a 0–100 model score. */ function toModelScore(anomalyScore: number): number { if (anomalyScore <= 0) return 0; if (anomalyScore >= 4) return 100; return Math.min(100, Math.round((anomalyScore / 4) * 100)); } export function scoreWindowForWallet( wallet: string, tsIso: string ): { modelScore: number; matched: AnomalySignature | null } { const sigs = loadSignatures(); if (!sigs.length) { return { modelScore: 0, matched: null }; } const bucketStart = toBucketStart(tsIso); const match = sigs.find( (s) => s.wallet.toLowerCase() === wallet.toLowerCase() && s.bucketStart === bucketStart ); if (!match) { return { modelScore: 0, matched: null }; } return { modelScore: toModelScore(match.anomalyScore), matched: match, }; )
4

Combine heuristic severity with model scores (src/stream.ts)

Now you’ll plug the model into the live monitor.
The idea:
  • Use your existing SecuritySignal from signals.ts for rule-based severity.
  • Ask the model whether this transaction lands in a known anomalous hour.
  • Combine both into a single risk label.
What this step does
For each suspicious transaction, logSignal now:
  • Picks the relevant watch wallet (if any).
  • Looks up the anomaly window for that wallet and hour.
  • Converts the anomaly score into a modelScore.
  • Combines that with rule-based severity to get a final Risk label.
If anomaly_windows.json is missing or empty, ModelScore stays at 0 and the behaviour falls back to the Part-1 severity logic.
Follow these substeps to complete this step.
4a

Add the new imports at the top of src/stream.ts

These imports let stream.ts read shared config (API key, watch wallets) from config.ts, use the existing heuristic signal logic from signals.ts and pull model-based scores for each wallet/hour from the new model.ts helper.
Where to put it
Open src/stream.ts and go to the very top of the file where your imports live. You should already have something like:
import WebSocket from 'ws'; import { GoldRushClient, StreamingChain, } from '@covalenthq/client-sdk';
Directly below those imports, add:
import { CONFIG } from './config'; import { analyseTx, SecuritySignal, Severity } from './signals'; import { scoreWindowForWallet } from './model';
Your complete import section should now look roughly like:
// src/stream.ts import WebSocket from 'ws'; import { GoldRushClient, StreamingChain, } from '@covalenthq/client-sdk'; import { CONFIG } from './config'; import { analyseTx, SecuritySignal, Severity } from './signals'; import { scoreWindowForWallet } from './model';
4b

Add a helper to choose which wallet to score against

Your model file stores anomaly windows per watch wallet. For each transaction, you need to decide which wallet to look up:
  • If the from address is one of the WATCH_WALLETS, use that.
  • Otherwise, if the to address is a watch wallet, use that.
  • If neither matches, there’s nothing to score.
This helper encapsulates that logic so you don’t have to repeat it in logSignal.
Where to put it
Scroll down in src/stream.ts to where you have your local helpers, for example:
type AnyTx = { ... }; function shortAddress(addr?: string | null): string { ... }
Right after shortAddress (and before logSignal), add:
function primaryWalletForTx( from?: string, to?: string, watch: string[] = [] ): string | null { const lower = watch.map((w) => w.toLowerCase()); const fromL = from?.toLowerCase() ?? ''; const toL = to?.toLowerCase() ?? ''; if (lower.includes(fromL)) return fromL; if (lower.includes(toL)) return toL; return null; }
So this part of the file should now look like:
type AnyTx = { tx_hash?: string; from_address?: string; to_address?: string; block_signed_at?: string; timestamp?: string; decoded_type?: string; decoded?: { quote_usd?: number | null; [k: string]: unknown }; [k: string]: unknown; }; function shortAddress(addr?: string | null): string { if (!addr) return 'n/a'; return `${addr.slice(0, 6)}…${addr.slice(-4)}`; } function primaryWalletForTx( from?: string, to?: string, watch: string[] = [] ): string | null { const lower = watch.map((w) => w.toLowerCase()); const fromL = from?.toLowerCase() ?? ''; const toL = to?.toLowerCase() ?? ''; if (lower.includes(fromL)) return fromL; if (lower.includes(toL)) return toL; return null; }
4c

Add a helper that merges severity + model score into a risk label

The monitor already has a heuristic severity from signals.ts (info|medium|high). The model layer returns a 0–100 score for the hour. This helper combines both into a single risk level:
  • CRITICAL if the severity is high or the model score is very high.
  • HIGH/MEDIUM for intermediate cases.
  • LOW otherwise.
This gives you one final label to look at in the console.
Where to put it
Place this helper right after primaryWalletForTx and still before logSignal:
function combinedRiskLevel( severity: Severity, modelScore: number ): 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' { if (modelScore >= 80 || severity === 'high') { return 'CRITICAL'; } if (modelScore >= 60 || severity === 'medium') { return 'HIGH'; } if (modelScore >= 30) { return 'MEDIUM'; } return 'LOW'; }
So your helper section now flows as:
function shortAddress(...) { ... } function primaryWalletForTx(...) { ... } function combinedRiskLevel(...) { ... }
4d

Replace logSignal so it calls the model and prints the combined risk

Previously, logSignal only printed the headline (timestamp, from → to, tags, value), and the heuristic severity + reasons.
Now, you want it to:
  1. Determine which watch wallet this tx relates to (if any).
  2. Ask scoreWindowForWallet for a model score for that wallet + timestamp.
  3. Use combinedRiskLevel to merge heuristic severity and model score.
  4. Print everything in one line so you get: Severity, ModelScore, and final Risk.
Where to put it
Find your existing logSignal function in src/stream.ts from part-1/Part-2. Replace the entire function body with the version below (do not keep the old one):
function logSignal(tx: AnyTx, sig: SecuritySignal) { const ts = tx.block_signed_at || tx.timestamp || new Date().toISOString(); const from = shortAddress(tx.from_address); const to = shortAddress(tx.to_address); const valueUsd = typeof tx.decoded?.quote_usd === 'number' ? tx.decoded.quote_usd : undefined; const headlineParts = [ `[${ts}]`, `${from} → ${to}`, sig.tags.length ? `[${sig.tags.join(',')}]` : '', valueUsd !== undefined ? `≈ $${valueUsd.toFixed(0)}` : '', ].filter(Boolean); // Decide which watch wallet to score against (if any) const primaryWallet = primaryWalletForTx( tx.from_address, tx.to_address, CONFIG.watchWallets ); // Default to 0 if there is no matching wallet or no model file let modelScore = 0; if (primaryWallet) { const { modelScore: ms } = scoreWindowForWallet(primaryWallet, ts); modelScore = ms; } const riskLevel = combinedRiskLevel(sig.severity, modelScore); console.log(headlineParts.join(' ')); console.log( ` ↳ Severity: ${sig.severity} | ModelScore: ${modelScore} | Risk: ${riskLevel} | ${sig.reasons.join( '; ' )}` ); }
You don’t need to touch handleTx, startStream, or stopStream. They will continue to call logSignal(tx, sig) as before, but now each line in your console reflects both:
  • your rule-based severity, and
  • your signature-based model score for the surrounding hour.
5

Run the full pipeline

At this point, your workflow for this project looks like:
  1. Refresh anomaly windows and signatures: Whenever you want to update the model from recent history, run the workflow as follows:
npm run analyze
You should see the familiar anomaly report for each wallet, followed by a line like:
[model] Wrote 20 anomaly window(s) to /.../model/anomaly_windows.json
2. Start the live monitor with risk labels
npm run dev
On startup, you should see something similar to:
DeFi Exploit Signal Monitor — Part 3 From anomaly scores to live risk alerts [model] Loaded 20 anomaly window(s) from /.../model/anomaly_windows.json [stream] Watching 2 wallet(s) on Base mainnet - 0xyourgov… - 0xyourtreasury… [stream] Connecting to GoldRush… [stream] Connected
When a relevant transaction lands in one of the anomalous hours, the output might look like:
[2025-11-03T14:07:12.000Z] 0x1234…abcd → 0x9fed…0011 [large_transfer,from_watch_wallet] ≈ $950000 ↳ Severity: high | ModelScore: 88 | Risk: CRITICAL | Large transfer ≈ $950000; Activity involving watch wallet
During quieter periods, you’ll see lines where the model score is 0, but the risk is still MEDIUM or HIGH because the rule-based severity is doing most of the work.

Common issues that could arise in Part 3 

A few problems tend to come up when you add the model step:

1. ModelScore is always 0

  • Check that npm run analyze ran successfully and that model/anomaly_windows.json exists.

  • Confirm that the wallets in WATCH_WALLETS match those in the JSON (same chain, same checksum/lowercase).

  • Make sure your monitor is running with a timestamp format that matches what you bucketed (we use hour-aligned ISO strings on both sides).

2. The risk label is always CRITICAL

If every suspicious transaction shows Risk: CRITICAL:

  • Your thresholds may be too aggressive, or many hours genuinely have high anomaly scores.

  • You can soften the mapping in combinedRiskLevel, for example, by only treating modelScore >= 90 as a candidate for CRITICAL.

3. No difference between “before” and “after” adding the model

If the output looks identical to Part 1:

  • Either the anomaly scan is not finding anything unusual (flat behaviour), or

  • your HISTORY_DAYS window is too small, and all anomaly scores are near zero.

Try pointing the monitor at a busier wallet or increasing HISTORY_DAYS to give the baseline more data.

Wrapping up the series

By this point, you don’t just “watch wallets” — you’ve assembled a compact risk pipeline. Live telemetry comes in through GoldRush, you compress it into features and anomaly windows, and a thin model layer turns that into ranked alerts a human can act on.

What we’ve built

  • Part 1 – Live heuristics on Base
    A TypeScript monitor that subscribes to GoldRush Streams on Base, watches treasury/governance wallets, and raises clear, rule-based signals for large transfers, approvals, and watch-wallet activity.

  • Part 2 – Baselines and anomaly windows
    An offline job that uses the GoldRush Foundational API to pull recent history, bucket it into hourly features (tx count, USD volume, large transfers, approvals, peers), and compute anomaly scores so you can see when each wallet behaves “off baseline.”

  • Part 3 – Signatures and live risk scoring
    A signature file that stores the top anomalous hours per wallet, plus a small model helper and stream update that merge those signatures with heuristic severity into a single risk label (LOW / MEDIUM / HIGH / CRITICAL) for each suspicious transaction.

Where to go next

From here, you can deepen the stack without changing its shape:

  • Enrich the feature set with pool-level metrics (TVL, price/oracle shifts, bridge flows).

  • Swap the simple z-score logic for a stronger model that reads the same feature buckets.

  • Push alerts into dashboards or bots, and experiment with carefully scoped automated responses.

The key shift is done: exploits are no longer just stories you read after the fact — they’re signals your own system can prioritise in real time.

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.