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

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.
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 export1cd defi-exploit-signals-part1model/anomaly_windows.json by hand. npm run analyze will write it for you.
2offline.ts pulled history for each watch wallet, built hourly features, scored anomalies, and printed a top-N report.model/anomaly_windows.json at the end.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);
});
signatures array that collects top anomalous hours across all wallets.model/anomaly_windows.json at the end of the run.npm run analyze3model/anomaly_windows.json and, for any given wallet and timestamp, return a 0–100 model score for the corresponding hour.model/anomaly_windows.json.modelScore = 0 for everything.anomalyScore to a 0–100 model score.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,
};
)4SecuritySignal from signals.ts for rule-based severity. logSignal now:modelScore.Risk label.anomaly_windows.json is missing or empty, ModelScore stays at 0 and the behaviour falls back to the Part-1 severity logic.4astream.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.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';import { CONFIG } from './config';
import { analyseTx, SecuritySignal, Severity } from './signals';
import { scoreWindowForWallet } from './model';// 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';4bfrom address is one of the WATCH_WALLETS, use that.to address is a watch wallet, use that.logSignal.src/stream.ts to where you have your local helpers, for example:type AnyTx = { ... };
function shortAddress(addr?: string | null): string { ... }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;
}
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;
}
4csignals.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.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';
}
function shortAddress(...) { ... }
function primaryWalletForTx(...) { ... }
function combinedRiskLevel(...) { ... }4dlogSignal only printed the headline (timestamp, from → to, tags, value), and the heuristic severity + reasons.combinedRiskLevel to merge heuristic severity and model score.Severity, ModelScore, and final Risk.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(
'; '
)}`
);
}
handleTx, startStream, or stopStream. They will continue to call logSignal(tx, sig) as before, but now each line in your console reflects both:5npm run analyze[model] Wrote 20 anomaly window(s) to /.../model/anomaly_windows.jsonnpm run devDeFi 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
[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 wallet0, but the risk is still MEDIUM or HIGH because the rule-based severity is doing most of the work.
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.
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.
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.
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.