Three major DeFi exploits hit in the first two weeks of March 2026: Venus Protocol ($3.7M oracle manipulation), Solv Protocol ($2.7M minting vulnerability), and a trader's $50M Aave slippage disaster amplified by MEV bots. Every single one exhibited on-chain warning signals minutes to hours before the actual drain.
The difference between a protocol that loses millions and one that pauses in time? Real-time detection infrastructure. In this guide, I'll walk through building custom Forta detection bots that would have caught each of these exploits — with production-ready TypeScript code you can deploy today.
Why Forta in 2026?
Forta Network has matured into the de facto real-time monitoring layer for Web3. Over 50 DeFi protocols use it. The Forta Firewall now screens 100,000+ transactions daily across Celo, Gelato rollups, and more. But the real power isn't in Forta's off-the-shelf bots — it's in custom detection logic tailored to your protocol's specific risk surface.
Here's what we're building:
| Bot | Detects | Would Have Caught |
|---|---|---|
| Oracle Deviation Monitor | Price feed manipulation beyond threshold | Venus Protocol exploit |
| Abnormal Minting Detector | Token minting without proper authorization flow | Solv Protocol exploit |
| Slippage Anomaly Alert | Swaps with extreme price impact + MEV sandwich patterns | Aave $50M incident |
Setting Up Your Detection Bot
# Install Forta CLI
npm install -g forta-agent
# Initialize a new bot project
npx forta-agent init --typescript
# Project structure
├── src/
│ ├── agent.ts # Main detection logic
│ ├── constants.ts # Addresses, thresholds
│ └── utils.ts # Helper functions
├── package.json
└── forta.config.json
Install dependencies we'll need:
npm install ethers@6 bignumber.js
Bot 1: Oracle Price Deviation Monitor
The Venus Protocol exploit worked because an attacker manipulated THE token's price from $0.27 to nearly $5 — an 18x spike — using thin on-chain liquidity. A simple price deviation monitor catches this instantly.
// src/oracle-monitor.ts
import {
Finding,
FindingSeverity,
FindingType,
HandleTransaction,
TransactionEvent,
} from "forta-agent";
import { ethers } from "ethers";
// Chainlink-style aggregator interface
const AGGREGATOR_ABI = [
"event AnswerUpdated(int256 indexed current, uint256 indexed roundId, uint256 updatedAt)",
];
// Configure monitored price feeds
const MONITORED_FEEDS: Record<string, { name: string; maxDeviationPct: number }> = {
"0x1234...": { name: "THE/USD", maxDeviationPct: 50 },
"0x5678...": { name: "CAKE/USD", maxDeviationPct: 30 },
// Add your protocol's oracle feeds here
};
// Track recent prices per feed
const priceHistory: Map<string, { price: bigint; timestamp: number }[]> = new Map();
const LOOKBACK_WINDOW = 10; // Track last 10 updates
const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
for (const [feedAddress, config] of Object.entries(MONITORED_FEEDS)) {
const events = txEvent.filterLog(
AGGREGATOR_ABI[0],
feedAddress
);
for (const event of events) {
const newPrice = BigInt(event.args.current.toString());
const history = priceHistory.get(feedAddress) || [];
if (history.length > 0) {
const lastPrice = history[history.length - 1].price;
const deviationPct = Number(
((newPrice - lastPrice) * 100n) / lastPrice
);
if (Math.abs(deviationPct) > config.maxDeviationPct) {
findings.push(
Finding.fromObject({
name: "Extreme Oracle Price Deviation",
description: `${config.name} price moved ${deviationPct.toFixed(1)}% in a single update (${lastPrice} → ${newPrice})`,
alertId: "ORACLE-DEVIATION-1",
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
metadata: {
feed: feedAddress,
previousPrice: lastPrice.toString(),
newPrice: newPrice.toString(),
deviationPct: deviationPct.toString(),
roundId: event.args.roundId.toString(),
},
})
);
}
}
// Update history
history.push({ price: newPrice, timestamp: txEvent.timestamp });
if (history.length > LOOKBACK_WINDOW) history.shift();
priceHistory.set(feedAddress, history);
}
}
return findings;
};
export default { handleTransaction };
Key insight: Venus's oracle reported the manipulated price faithfully — the manipulation happened in the underlying liquidity pool, not the oracle itself. A production version should also monitor DEX pool reserves:
// Monitor Uniswap V2/V3 style pool reserve changes
const POOL_SYNC_ABI = [
"event Sync(uint112 reserve0, uint112 reserve1)"
];
function detectReserveManipulation(
reserve0: bigint,
reserve1: bigint,
previousReserve0: bigint,
previousReserve1: bigint
): boolean {
// If one reserve changed by >80% in a single tx while the
// other barely moved, it's likely manipulation
const change0 = Math.abs(
Number(((reserve0 - previousReserve0) * 100n) / previousReserve0)
);
const change1 = Math.abs(
Number(((reserve1 - previousReserve1) * 100n) / previousReserve1)
);
// Asymmetric reserve change = red flag
return (change0 > 80 && change1 < 10) || (change1 > 80 && change0 < 10);
}
Bot 2: Unauthorized Minting Detector
Solv Protocol lost $2.7M because an attacker exploited the ERC-3525 token minting mechanism — generating tokens without proper authorization. This bot monitors for minting patterns that bypass expected governance flows.
// src/mint-monitor.ts
import {
Finding,
FindingSeverity,
FindingType,
HandleTransaction,
TransactionEvent,
} from "forta-agent";
const ERC20_MINT_SIGNATURES = [
"event Transfer(address indexed from, address indexed to, uint256 value)",
];
// ERC-3525 specific events
const ERC3525_EVENTS = [
"event TransferValue(uint256 indexed _fromTokenId, uint256 indexed _toTokenId, uint256 _value)",
"event SlotChanged(uint256 indexed _tokenId, uint256 indexed _oldSlot, uint256 indexed _newSlot)",
];
const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
// Known legitimate minters (governance, timelock, multisig)
const AUTHORIZED_MINTERS: Record<string, Set<string>> = {
"0xProtocolToken...": new Set([
"0xGovernanceTimelock...",
"0xMultisig...",
]),
};
// Track minting volume per address per block window
const mintVolume: Map<string, { amount: bigint; blockStart: number }> = new Map();
const VOLUME_THRESHOLD = BigInt("1000000000000000000000"); // 1000 tokens
const BLOCK_WINDOW = 5;
const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
// Detect ERC-20 mints (Transfer from zero address)
const mintEvents = txEvent.filterLog(ERC20_MINT_SIGNATURES[0]);
for (const event of mintEvents) {
const from = event.args.from.toLowerCase();
const to = event.args.to.toLowerCase();
const value = BigInt(event.args.value.toString());
if (from !== ZERO_ADDRESS) continue; // Not a mint
const tokenAddress = event.address.toLowerCase();
const authorizedSet = AUTHORIZED_MINTERS[tokenAddress];
// Check 1: Is the transaction sender an authorized minter?
const txFrom = txEvent.from.toLowerCase();
if (authorizedSet && !authorizedSet.has(txFrom)) {
findings.push(
Finding.fromObject({
name: "Unauthorized Token Minting",
description: `${value} tokens minted to ${to} by unauthorized address ${txFrom}`,
alertId: "MINT-UNAUTHORIZED-1",
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
metadata: {
token: tokenAddress,
minter: txFrom,
recipient: to,
amount: value.toString(),
txHash: txEvent.hash,
},
})
);
}
// Check 2: Abnormal minting volume in short window
const key = `${tokenAddress}-${to}`;
const existing = mintVolume.get(key);
const currentBlock = txEvent.blockNumber;
if (existing && currentBlock - existing.blockStart < BLOCK_WINDOW) {
const totalVolume = existing.amount + value;
if (totalVolume > VOLUME_THRESHOLD) {
findings.push(
Finding.fromObject({
name: "Abnormal Minting Volume Spike",
description: `${totalVolume} tokens minted to ${to} within ${BLOCK_WINDOW} blocks`,
alertId: "MINT-VOLUME-1",
severity: FindingSeverity.High,
type: FindingType.Suspicious,
metadata: {
token: tokenAddress,
recipient: to,
totalVolume: totalVolume.toString(),
blockWindow: BLOCK_WINDOW.toString(),
},
})
);
}
mintVolume.set(key, { amount: totalVolume, blockStart: existing.blockStart });
} else {
mintVolume.set(key, { amount: value, blockStart: currentBlock });
}
}
// Detect ERC-3525 value transfers that could indicate SFT manipulation
const valueTransfers = txEvent.filterLog(ERC3525_EVENTS[0]);
for (const event of valueTransfers) {
const fromTokenId = event.args._fromTokenId;
const value = BigInt(event.args._value.toString());
// Value transfer FROM tokenId 0 = new value creation
if (fromTokenId.toString() === "0" && value > VOLUME_THRESHOLD) {
findings.push(
Finding.fromObject({
name: "Large ERC-3525 Value Creation",
description: `${value} units created in ERC-3525 value transfer (potential exploit)`,
alertId: "ERC3525-MINT-1",
severity: FindingSeverity.High,
type: FindingType.Suspicious,
metadata: {
toTokenId: event.args._toTokenId.toString(),
value: value.toString(),
contract: event.address,
},
})
);
}
}
return findings;
};
export default { handleTransaction };
Bot 3: MEV Sandwich + Slippage Anomaly Detector
The Aave $50M incident wasn't a protocol bug — it was a trader executing a massive swap with essentially no slippage protection, allowing MEV bots to sandwich-attack them for ~$34M in profit. This bot detects both sides: risky swaps before they happen (in the mempool) and sandwich patterns after execution.
// src/sandwich-detector.ts
import {
Finding,
FindingSeverity,
FindingType,
HandleBlock,
HandleTransaction,
TransactionEvent,
BlockEvent,
} from "forta-agent";
const SWAP_EVENT = "event Swap(address indexed sender, uint256 amount0In, uint256 amount1In, uint256 amount0Out, uint256 amount1Out, address indexed to)";
const UNISWAP_V3_SWAP = "event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)";
// Track swaps within a block to detect sandwich patterns
interface SwapRecord {
txIndex: number;
txHash: string;
sender: string;
pool: string;
amountIn: bigint;
amountOut: bigint;
direction: "buy" | "sell";
}
const blockSwaps: Map<number, SwapRecord[]> = new Map();
const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
// Monitor for extremely large swaps with potential high slippage
const v2Swaps = txEvent.filterLog(SWAP_EVENT);
const v3Swaps = txEvent.filterLog(UNISWAP_V3_SWAP);
for (const swap of [...v2Swaps, ...v3Swaps]) {
const pool = swap.address.toLowerCase();
let amountIn: bigint;
let amountOut: bigint;
let direction: "buy" | "sell";
// Parse V2 vs V3 swap format
if (swap.args.sqrtPriceX96 !== undefined) {
// V3 swap
const amount0 = BigInt(swap.args.amount0.toString());
const amount1 = BigInt(swap.args.amount1.toString());
amountIn = amount0 > 0n ? amount0 : amount1;
amountOut = amount0 < 0n ? -amount0 : -amount1;
direction = amount0 > 0n ? "buy" : "sell";
} else {
// V2 swap
const a0In = BigInt(swap.args.amount0In.toString());
const a1In = BigInt(swap.args.amount1In.toString());
const a0Out = BigInt(swap.args.amount0Out.toString());
const a1Out = BigInt(swap.args.amount1Out.toString());
amountIn = a0In > 0n ? a0In : a1In;
amountOut = a0Out > 0n ? a0Out : a1Out;
direction = a0In > 0n ? "buy" : "sell";
}
// Record for sandwich detection
const record: SwapRecord = {
txIndex: txEvent.transactionIndex,
txHash: txEvent.hash,
sender: txEvent.from.toLowerCase(),
pool,
amountIn,
amountOut,
direction,
};
const blockNum = txEvent.blockNumber;
const swaps = blockSwaps.get(blockNum) || [];
swaps.push(record);
blockSwaps.set(blockNum, swaps);
// Alert on extremely large single swaps (>$1M equivalent)
// This is simplified — production should use price feeds
const LARGE_SWAP_THRESHOLD = BigInt("500000000000000000000"); // 500 ETH
if (amountIn > LARGE_SWAP_THRESHOLD) {
findings.push(
Finding.fromObject({
name: "Extremely Large Swap Detected",
description: `Swap of ${amountIn} on pool ${pool} — high slippage/MEV risk`,
alertId: "SWAP-LARGE-1",
severity: FindingSeverity.Medium,
type: FindingType.Info,
metadata: {
pool,
sender: txEvent.from,
amountIn: amountIn.toString(),
amountOut: amountOut.toString(),
direction,
txHash: txEvent.hash,
},
})
);
}
}
return findings;
};
// Analyze completed blocks for sandwich patterns
const handleBlock: HandleBlock = async (blockEvent: BlockEvent) => {
const findings: Finding[] = [];
const blockNum = blockEvent.blockNumber;
const swaps = blockSwaps.get(blockNum) || [];
// Clean old block data
blockSwaps.delete(blockNum - 2);
// Group swaps by pool
const poolSwaps: Map<string, SwapRecord[]> = new Map();
for (const swap of swaps) {
const existing = poolSwaps.get(swap.pool) || [];
existing.push(swap);
poolSwaps.set(swap.pool, existing);
}
// Detect sandwich pattern: BUY(attacker) → SWAP(victim) → SELL(attacker)
for (const [pool, poolSwapList] of poolSwaps) {
if (poolSwapList.length < 3) continue;
// Sort by transaction index
poolSwapList.sort((a, b) => a.txIndex - b.txIndex);
for (let i = 0; i < poolSwapList.length - 2; i++) {
const front = poolSwapList[i];
const victim = poolSwapList[i + 1];
const back = poolSwapList[i + 2];
// Classic sandwich: same attacker, opposite directions, different victim
if (
front.sender === back.sender &&
front.sender !== victim.sender &&
front.direction === "buy" &&
back.direction === "sell"
) {
// Calculate attacker profit
const profit = back.amountOut - front.amountIn;
if (profit > 0n) {
findings.push(
Finding.fromObject({
name: "MEV Sandwich Attack Detected",
description: `Sandwich attack on pool ${pool}: attacker ${front.sender} profited ${profit} at victim ${victim.sender}'s expense`,
alertId: "MEV-SANDWICH-1",
severity: FindingSeverity.High,
type: FindingType.Exploit,
metadata: {
pool,
attacker: front.sender,
victim: victim.sender,
victimTxHash: victim.txHash,
frontrunTxHash: front.txHash,
backrunTxHash: back.txHash,
profit: profit.toString(),
},
})
);
}
}
}
}
return findings;
};
export default { handleTransaction, handleBlock };
Composing Bots Into an Alert Pipeline
Individual bots are useful. A composed pipeline that correlates signals is what actually saves protocols. Here's how to wire them together:
// src/agent.ts — Combined detection pipeline
import oracleMonitor from "./oracle-monitor";
import mintMonitor from "./mint-monitor";
import sandwichDetector from "./sandwich-detector";
import {
Finding,
HandleTransaction,
HandleBlock,
TransactionEvent,
BlockEvent,
} from "forta-agent";
// Correlation engine: escalate when multiple signals fire together
const recentFindings: Finding[] = [];
const CORRELATION_WINDOW_BLOCKS = 10;
const handleTransaction: HandleTransaction = async (txEvent: TransactionEvent) => {
const findings: Finding[] = [];
// Run all detectors in parallel
const [oracleFindings, mintFindings, sandwichFindings] = await Promise.all([
oracleMonitor.handleTransaction(txEvent),
mintMonitor.handleTransaction(txEvent),
sandwichDetector.handleTransaction(txEvent),
]);
findings.push(...oracleFindings, ...mintFindings, ...sandwichFindings);
// Correlation: if oracle deviation + large swap in same block = CRITICAL
const hasOracleAlert = oracleFindings.length > 0;
const hasLargeSwap = sandwichFindings.some(
(f) => f.alertId === "SWAP-LARGE-1"
);
if (hasOracleAlert && hasLargeSwap) {
findings.push(
Finding.fromObject({
name: "CORRELATED: Oracle Manipulation + Large Swap",
description: "Oracle price deviation detected alongside abnormally large swap — likely active exploit in progress",
alertId: "CORRELATED-EXPLOIT-1",
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
metadata: {
oracleAlerts: oracleFindings.length.toString(),
swapAlerts: sandwichFindings.length.toString(),
block: txEvent.blockNumber.toString(),
},
})
);
}
return findings;
};
const handleBlock: HandleBlock = async (blockEvent: BlockEvent) => {
return sandwichDetector.handleBlock(blockEvent);
};
export default { handleTransaction, handleBlock };
Deploying and Testing
# Test against a historical exploit transaction
npx forta-agent run --tx 0x<venus-exploit-tx-hash>
# Run against live blocks
npx forta-agent run --block 19500000
# Deploy to Forta Network
npx forta-agent publish
Connecting Alerts to Emergency Response
Detection without response is just expensive logging. Wire your bot to your protocol's emergency infrastructure:
// src/alerter.ts
// This runs outside Forta — in your protocol's ops infrastructure
import { AlertEvent } from "forta-agent";
interface EmergencyAction {
type: "pause" | "notify" | "escalate";
target?: string;
}
function determineAction(alert: AlertEvent): EmergencyAction[] {
const actions: EmergencyAction[] = [];
switch (alert.alertId) {
case "CORRELATED-EXPLOIT-1":
// Correlated signal = highest confidence → auto-pause
actions.push({ type: "pause", target: "lending-pool" });
actions.push({ type: "notify", target: "pagerduty" });
actions.push({ type: "escalate", target: "war-room" });
break;
case "ORACLE-DEVIATION-1":
// Single signal = high confidence → notify + prepare
actions.push({ type: "notify", target: "slack-security" });
actions.push({ type: "escalate", target: "on-call" });
break;
case "MEV-SANDWICH-1":
// Informational for post-incident analysis
actions.push({ type: "notify", target: "slack-mev-alerts" });
break;
}
return actions;
}
Forta Firewall: The Next Step
In 2026, Forta's Firewall goes beyond detection — it blocks malicious transactions before they execute. If you're building a new protocol, integrating Firewall means your detection bots can actually prevent exploits, not just alert on them:
// Simplified Forta Firewall integration
import {IFortaFirewall} from "@forta/firewall/IFortaFirewall.sol";
contract LendingPool {
IFortaFirewall public firewall;
modifier fortaProtected() {
require(
firewall.checkTransaction(msg.sender, msg.data) == true,
"Transaction blocked by Forta Firewall"
);
_;
}
function borrow(address asset, uint256 amount)
external
fortaProtected
{
// Your lending logic
}
}
What We Learned
Three things separate protocols that survive exploits from those that don't:
- Correlated detection — single signals produce false positives; correlated signals (oracle deviation + unusual minting + large swap) produce actionable intelligence
- Sub-block response time — if your alert pipeline takes 30 seconds, you've already lost; integrate with Forta Firewall for pre-execution blocking
- Custom logic over generic bots — Forta's built-in bots catch generic patterns; your protocol's unique risk surface needs custom detection logic that understands your invariants
The $56M lost across these three March 2026 incidents shared one thing in common: detectable on-chain precursors that, with the right monitoring infrastructure, would have triggered emergency responses before the drain completed.
Build your detection bots before you need them. Because by the time you need them, it's already too late.
This is part of my DeFi Security Research series. Previous entries covered Solana ZK proof vulnerabilities, ERC-4337 smart account security, and custom Slither detectors.
Top comments (0)