DEV Community

ohmygod
ohmygod

Posted on

Building Custom Forta Detection Bots: How Real-Time Monitoring Could Have Saved $56M in March 2026 DeFi Exploits

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
Enter fullscreen mode Exit fullscreen mode

Install dependencies we'll need:

npm install ethers@6 bignumber.js
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

What We Learned

Three things separate protocols that survive exploits from those that don't:

  1. Correlated detection — single signals produce false positives; correlated signals (oracle deviation + unusual minting + large swap) produce actionable intelligence
  2. Sub-block response time — if your alert pipeline takes 30 seconds, you've already lost; integrate with Forta Firewall for pre-execution blocking
  3. 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)