In Part 1, we stopped treating DeFi exploits as mysterious one-off events and started treating them as data.
We:
Mapped out the main exploit patterns: contract flaws, liquidity and oracle issues, governance/admin misuse, and user-level compromise.
Looked at how those incidents show up on-chain as a small vocabulary of machine-readable symptoms: ownership changes, liquidity drains, odd approvals, and bursts of transfers.
Built a minimal signal monitor on Base using GoldRush Streaming API. It watched a set of “watch wallets” (treasury, multisig, key LPs), applied simple heuristics (large transfers, approvals, watch-wallet involvement), and printed human-readable security signals in real time.
That monitor is our data plane: a clean stream of “this looks interesting” events.
In this part, we’ll add something the real world exploits that our code doesn’t yet: context over time.
Handwritten rules are a good start. They’re:
Simple — you can explain them to anyone on the team.
Fast — they’re just comparisons on numbers and strings.
Deterministic — the same input always yields the same label.
But if you rely solely on heuristics, you quickly run into three problems.
Static thresholds age badly
A $50k transfer from a fresh governance wallet is scary. The same size transfer from a mature protocol treasury during a regular rebalance is boring. Thresholds that don’t adapt to the wallet’s growth or seasonality either spam you with false positives or miss slow-burning incidents.
You can’t see “unusual for this wallet.”
Imagine a governance wallet that has been quiet for months, suddenly firing a series of small parameter-change transactions. Each one is well below your USD thresholds. The sequence is unusual, but no single transaction crosses a line. Pure heuristics don’t capture that.
You can’t generalise from past incidents.
Once you’ve read a post-mortem, you have a mental pattern:
“Owner change → Proxy upgrade → Liquidity drain.”
Heuristics can flag each leg, from owner change to upgrade, and to big withdrawals, but they don’t automatically say: “This three-step sequence has appeared in exploit write-ups before; treat it as high risk even if absolute sizes are modest.”
To move beyond “if X then Y” rules, we need a way to measure behaviour over time and to encode past incidents as reusable signatures.
That’s what Part 2 is about.
When a security firm publishes a detailed write-up, they’re handing you more than a narrative. They’re giving you a signature, which is like an ordered pattern of events that distinguishes an exploit window from the surrounding regular activity.
For example:
Liquidity-drain signature
Rapid net outflow from a pool (>N standard deviations above normal).
Followed by a spike in DEX volume against a thin-liquidity asset.
Followed by a governance token price collapse. An example is the Curve Finance Vyper exploit in 2023.
Governance-takeover signature
Quiet governance address, low vote activity baseline.
Sudden concentration of voting power into a small cluster of addresses.
Proposal touching on upgradeability or collateral parameters.
Large liquidity movement within a short window of the proposal passing. Like the Beanstalk Farms governance attack in 2022.
The exact numbers differ by protocol, but the shape is reusable. In this part, we won’t train a heavy model on dozens of labelled incidents — that’s closer to a research project. Instead, we’ll build the machinery you need to get there. It will include:
A way to pull historical transactions for your watch wallets on Base.
A way to turn that history into time-bucketed features (tx counts, USD volume, large-transfer counts, approval counts).
A way to compute simple anomaly scores (e.g., z-scores versus baseline) that tell you “this hour/day looked unusual” in a principled way.
Once you have those pieces, plugging in a more advanced model, such as a gradient-boosted classifier, to a small, fine-tuned LLM becomes a much smaller step.
At a high level, we’ll treat each wallet or contract you care about as a small time series. For a given wallet and time bucket (say, 1 hour), we can compute:
tx_count – how many transactions touched this wallet?
total_value_usd – sum of value_quote or similar USD fields.
large_transfer_count – how many transactions crossed your LARGE_TX_USD threshold?
approval_count – how many approval-type interactions happened?
unique_counterparties – how many distinct to_address/from_address did it talk to?
Once you’ve computed these features for, say, the last 30 days, you can turn them into something more useful by establishing a baseline and an anomaly score. A baseline is just the mean and standard deviation for each feature over that window. An anomaly score can then be as simple as a z-score that tells you how far a given hour sits above (or below) its normal range.
Z = (x - μ) / 𝜎
and then
Anomaly_score = max[z(tx_count), z(total_value_usd), z(large_transfer_count), 0]
Buckets where the score is high are your most promising “this felt different” windows — whether or not they triggered any single hardcoded rule.
This still isn’t “AI” in the marketing sense, but it is the numeric substrate that AI models need, such as structured examples of normal vs unusual behaviour.
Part 1 leaned entirely on GoldRush Streaming API: GraphQL-over-WebSocket streams for wallet activity, transfers, and other decoded events.
For historical context, we’ll lean on Foundational API, accessed through the same TypeScript SDK. Some of the endpoints we’ll be using include:
TransactionService.getAllTransactionsForAddress lets you iterate through all transactions for a wallet, with decoded logs and USD quotes, across 100+ supported chains.
If you need higher volume, getPaginatedTransactionsForAddress and getTimeBucketTransactionsForAddress give you paginated and 15-minute-bucketed views, respectively, with decoded logs and quotes.
For our purposes in Part 2, we’ll use getAllTransactionsForAddress to fetch historical transactions for each watch wallet on base-mainnet, then bucket those transactions by hour and compute the features and anomaly scores described above.
The same COVALENT_API_KEY, WATCH_WALLETS, and threshold values from Part 1 continue to drive the configuration, keeping the series cohesive and allowing both the live stream and the historical analysis to speak the same language.
By the end of Part 2, you’ll have:
An offline job that pulls recent historical data for your watch wallets from the GoldRush Foundational API.
A small feature engine that turns the history into time buckets with tx counts, USD volume, and simple labels derived from your Part-1 heuristics.
A basic anomaly scorer that surfaces the “top N weird hours” for each wallet, with clear, numeric justification.
In Part 3, we’ll plug those anomaly scores and signatures back into the live stream so that your monitor can say not just “big transfer just happened,” but “this pattern looks like past exploit windows.”
What you’ll build in Part 2
This section turns that high-level idea into code. You’ll add an offline analysis mode on top of the existing project: it pulls recent history for your watch wallets using the GoldRush Foundational API, buckets it by hour. It computes simple anomaly scores so you can see which hours stood out for each wallet.
By the end, you’ll have two ways to run the same repo:
npm run dev — live streaming monitor.
npm run analyze — historical baseline and anomaly scan.
1cd defi-exploit-signals-part1src/ directory will look like this:src/
config.ts # Shared config (API key, wallets, thresholds, history window)
signals.ts # Real-time heuristics (from Part 1)
stream.ts # GoldRush Streaming client + handler (Part 1)
index.ts # Live monitor entry point (Part 1)
history.ts # Fetch historical txs from Foundational API
features.ts # Build hourly features + anomaly scores
offline.ts # Offline analysis entry point for Part 2config.ts with one extra setting (HISTORY_DAYS).history.ts, features.ts, and offline.ts to power the offline analysis.
2src/config.ts and replace its content with the code below:// src/config.ts
import 'dotenv/config';
import { z } from 'zod';
const EnvSchema = z.object({
COVALENT_API_KEY: z.string().min(1, 'COVALENT_API_KEY is required'),
WATCH_WALLETS: z.string().min(1, 'WATCH_WALLETS is required'),
LARGE_TX_USD: z.coerce.number().optional(),
SUSPICIOUS_APPROVAL_USD: z.coerce.number().optional(),
HISTORY_DAYS: z.coerce.number().optional(),
});
const env = EnvSchema.parse(process.env);
const largeTxUsd = env.LARGE_TX_USD ?? 50_000;
the suspiciousApprovalUsd = env.SUSPICIOUS_APPROVAL_USD ?? 10_000;
const historyDays = env.HISTORY_DAYS ?? 30;
export const CONFIG = {
apiKey: env.COVALENT_API_KEY,
watchWallets: env.WATCH_WALLETS.split(',')
.map((a) => a.trim().toLowerCase())
.filter((a) => a.length > 0),
thresholds: {
largeTxUsd,
suspiciousApprovalUsd,
},
historyDays,
};
if (CONFIG.watchWallets.length === 0) {
throw new Error('WATCH_WALLETS must contain at least one address');
}
if (CONFIG.historyDays <= 0) {
throw new Error('HISTORY_DAYS must be a positive integer');
}
signals.ts, stream.ts, index.ts) continues to work exactly as before; they just read the same CONFIG.3history.ts, a small helper module that wraps the GoldRush Foundational API. Its job is to take a single watch wallet, pull up to HISTORY_DAYS of historical transactions for that address on Base, and normalise each transaction into a lightweight shape that’s easy to feed into your feature engine in the next step.offline.ts calls fetchHistoryForWallet(wallet) for each address.fetchHistoryForWallet returns an array of HistoricalTx objects and prints a few progress lines to the console (how many days, how many transactions) while it’s working.src/history.ts and add:// src/history.ts
import { GoldRushClient, type Chain } from '@covalenthq/client-sdk';
import { CONFIG } from './config';
export type HistoricalTx = {
block_signed_at: string;
from_address: string;
to_address: string | null;
value_quote_usd: number;
decoded_type: string | null;
};
const BASE_CHAIN: Chain = 'base-mainnet';
let client: GoldRushClient | null = null;
function getClient(): GoldRushClient {
if (!client) {
client = new GoldRushClient(CONFIG.apiKey);
}
return client;
}
function toHistoricalTx(raw: any): HistoricalTx {
const valueQuote =
typeof raw.value_quote === 'number'
? raw.value_quote
: typeof raw.pretty_value_quote === 'number'
? raw.pretty_value_quote
: 0;
const firstLog = Array.isArray(raw.log_events) && raw.log_events[0];
const decodedType =
firstLog?.decoded?.name ??
firstLog?.decoded?.signature ??
null;
return {
block_signed_at: raw.block_signed_at,
from_address: (raw.from_address ?? '').toLowerCase(),
to_address: raw.to_address ? String(raw.to_address).toLowerCase() : null,
value_quote_usd: valueQuote || 0,
decoded_type: decodedType,
};
}
export async function fetchHistoryForWallet(
wallet: string
): Promise<HistoricalTx[]> {
const client = getClient();
const cutoff = Date.now() - CONFIG.historyDays * 24 * 60 * 60 * 1000;
const cutoffIso = new Date(cutoff).toISOString();
const results: HistoricalTx[] = [];
console.log(
`[history] Fetching up to ${CONFIG.historyDays} day(s) for ${wallet} on base-mainnet…`
);
for await (const page of client.TransactionService.getAllTransactionsForAddress(
BASE_CHAIN,
wallet,
{
quoteCurrency: 'USD',
noLogs: false,
blockSignedAtAsc: true,
}
)) {
const items = page.data?.items ?? [];
for (const tx of items) {
if (!tx.block_signed_at) continue;
const ts = new Date(tx.block_signed_at).toISOString();
if (ts < cutoffIso) {
continue;
}
results.push(toHistoricalTx(tx));
}
}
console.log(
`[history] Collected ${results.length} tx(s) for ${wallet} within window`
);
return results;
}
4features.ts, a pure logic module that sits between raw history and your final report. It takes the HistoricalTx[] array from history.ts, groups those transactions into 1-hour buckets.src/features.ts with the following code content:// src/features.ts
import { CONFIG } from './config';
import type { HistoricalTx } from './history';
export type FeatureBucket = {
bucketStart: string;
txCount: number;
totalValueUsd: number;
largeTransferCount: number;
approvalCount: number;
uniqueCounterparties: number;
};
export type AnomalyBucket = FeatureBucket & {
zTxCount: number;
zTotalValueUsd: number;
zLargeTransferCount: number;
anomalyScore: number;
};
function hourBucket(tsIso: string): string {
const d = new Date(tsIso);
d.setMinutes(0, 0, 0, 0);
return d.toISOString();
}
export function buildFeatureSeries(
wallet: string,
txs: HistoricalTx[]
): FeatureBucket[] {
const watch = wallet.toLowerCase();
const buckets = new Map<
string,
{
txCount: number;
totalValueUsd: number;
largeTransferCount: number;
approvalCount: number;
counterparties: Set<string>;
}
>();
for (const tx of txs) {
const bucketKey = hourBucket(tx.block_signed_at);
if (!buckets.has(bucketKey)) {
buckets.set(bucketKey, {
txCount: 0,
totalValueUsd: 0,
largeTransferCount: 0,
approvalCount: 0,
counterparties: new Set<string>(),
});
}
const b = buckets.get(bucketKey)!;
b.txCount += 1;
b.totalValueUsd += tx.value_quote_usd;
if (tx.value_quote_usd >= CONFIG.thresholds.largeTxUsd) {
b.largeTransferCount += 1;
}
if (
tx.decoded_type &&
tx.decoded_type.toUpperCase().includes('APPROVAL')
) {
b.approvalCount += 1;
}
const other =
tx.from_address.toLowerCase() === watch
? tx.to_address
: tx.from_address;
if (other) {
b.counterparties.add(other.toLowerCase());
}
}
const features: FeatureBucket[] = [];
for (const [bucketStart, b] of Array.from(buckets.entries()).sort(
([a], [z]) => (a < z ? -1 : a > z ? 1 : 0)
)) {
features.push({
bucketStart,
txCount: b.txCount,
totalValueUsd: b.totalValueUsd,
largeTransferCount: b.largeTransferCount,
approvalCount: b.approvalCount,
uniqueCounterparties: b.counterparties.size,
});
}
return features;
}
function mean(values: number[]): number {
if (!values.length) return 0;
const sum = values.reduce((acc, v) => acc + v, 0);
return sum / values.length;
}
function std(values: number[], mu: number): number {
if (values.length < 2) return 0;
const variance =
values.reduce((acc, v) => acc + (v - mu) ** 2, 0) / (values.length - 1);
return Math.sqrt(variance);
}
export function scoreAnomalies(buckets: FeatureBucket[]): AnomalyBucket[] {
if (!buckets.length) return [];
const txCounts = buckets.map((b) => b.txCount);
const totalValues = buckets.map((b) => b.totalValueUsd);
const largeCounts = buckets.map((b) => b.largeTransferCount);
const muTx = mean(txCounts);
const muVal = mean(totalValues);
const muLarge = mean(largeCounts);
const sdTx = std(txCounts, muTx) || 1;
const sdVal = std(totalValues, muVal) || 1;
const sdLarge = std(largeCounts, muLarge) || 1;
return buckets.map((b) => {
const zTxCount = (b.txCount - muTx) / sdTx;
const zTotalValueUsd = (b.totalValueUsd - muVal) / sdVal;
const zLargeTransferCount =
(b.largeTransferCount - muLarge) / sdLarge;
const anomalyScore = Math.max(
zTxCount,
zTotalValueUsd,
zLargeTransferCount,
0
);
return {
...b,
zTxCount,
zTotalValueUsd,
zLargeTransferCount,
anomalyScore,
};
});
}
offline.ts entry point will:buildFeatureSeries(wallet, history) to produce the hourly feature buckets.scoreAnomalies(buckets) to attach z-scores and an overall anomalyScore to each bucket before printing a human-readable summary.5offline.ts, the entry file for your historical analysis mode. It’s the orchestration layer for Part 2:config.ts (API key, watch wallets, HISTORY_DAYS).fetchHistoryForWallet from history.ts to pull recent transactions.src/offline.ts and paste the code below into it:// src/offline.ts
import { CONFIG } from './config';
import { fetchHistoryForWallet } from './history';
import { buildFeatureSeries, scoreAnomalies } from './features';
const TOP_N = 10;
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);
}
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');
}
async function main() {
console.log('DeFi Exploit Baseline & Anomaly Scan — Part 2');
console.log('From heuristics to exploit signatures\n');
for (const wallet of CONFIG.watchWallets) {
await analyseWallet(wallet);
}
console.log('Done.');
}
main().catch((err) => {
console.error('[offline] Fatal error:', err);
process.exit(1);
});
history.ts and features.ts, this file is meant to be run by the user. In package.json, you’ll wire it to a script like npm run analyze, which runs the offline scan alongside your existing npm run dev live monitor.6package.json and extend the scripts section, so you have both live and offline modes:// package.json
{
"scripts": {
"dev": "ts-node src/index.ts", // Part 1: live stream
"analyze": "ts-node src/offline.ts" // Part 2: historical scan
}
}
# Live monitor from Part 1
npm run dev
# Historical baseline & anomaly scan (Part 2)
npm run analyzeDeFi Exploit Baseline & Anomaly Scan — Part 2
From heuristics to exploit signatures
================================================================================
Wallet: 0xyourgov…
History window: last 30 day(s)
[history] Fetching up to 30 day(s) for 0xyourgov… on base-mainnet…
[history] Collected 184 tx(s) for 0xyourgov… within window
[report] Computed 72 hourly bucket(s). Showing top 10 anomalous hours:
- 2025-11-03T14:00:00.000Z | score=3.47 | tx=18 | vol≈$950000 | large_tx=4 | approvals=2 | peers=9
- 2025-11-12T09:00:00.000Z | score=2.93 | tx=11 | vol≈$610000 | large_tx=3 | approvals=1 | peers=5
...
[report] You can cross-check these hours against:
• Governance proposals or upgrades.
• Liquidity changes for your pools.
• Any known incident timelines.
================================================================================
Wallet: 0xyourtreasury…
History window: last 30 day(s)
...
Done
This is how the final output will look like post the scan

1. npm run analyze doesn’t start: If you see Missing script: 'analyze' or 'Cannot find module './history':
Check that package.json has: "analyze": "ts-node src/offline.ts".
Confirm that history.ts, features.ts, and offline.ts all live in src/, and that the imports use ./history and ./features.
2. Every wallet shows “No transactions in the selected window.: This usually means the window is too narrow for the addresses you picked:
Try changing HISTORY_DAYS (e.g., from 7 to 30) and re-run.
Make sure the wallets are active on Base, not another chain.
3. The scan runs, but anomaly scores are all ~0: If all rows look flat:
The wallet behaviour may actually be very steady (no obvious spikes).
Or the window is too small to compute a meaningful baseline; increase HISTORY_DAYS and re-run.
4. The scan feels slow or hangs on one wallet: This is usually a very active address plus an immense HISTORY_DAYS value:
Start with a smaller window (e.g., HISTORY_DAYS=7).
Temporarily test with a single wallet in WATCH_WALLETS before scaling up.