Cross-chain bridges hold billions in locked value and are consistently among the highest-severity exploit targets in crypto. The Ronin bridge lost $625M. Wormhole lost $325M. Nomad lost $190M. In most cases, the on-chain movement was visible in real time — the problem was no one was watching for the right patterns.

This tutorial shows how to monitor bridge contracts and associated hot wallets for unusual withdrawal activity across Ethereum mainnet, Base, Arbitrum, and Optimism using the 0watch TypeScript SDK.


What you'll build


Architecture

Bridge Contracts (multi-chain)
  │  ETH Mainnet: Lock contract
  │  Arbitrum: Outbox / withdrawal contract
  │  Base: Bridge withdrawal handler
  │  Optimism: L2 -> L1 message relay
        │
        ▼
  0watch Indexer  ←── indexes all four chains in real time
        │
        ▼ (webhook POST per chain)
  Bridge Monitor Service
        │
        ├── Large withdrawal        →  PagerDuty P1 (if > 5x normal)
        ├── Withdrawal velocity     →  PagerDuty P2 (many small withdrawals)
        └── Failed withdrawal       →  Slack #bridge-security

Bridge exploits often look like one of two patterns: a single catastrophic withdrawal (Ronin-style) or a rapid series of smaller ones designed to drain without triggering per-transaction thresholds (Nomad-style). Monitoring both patterns is important.


Prerequisites


Install the SDK

npm install @0agent/0watch-sdk

Step 1: Define your bridge configuration

// bridges.ts
export interface BridgeAddress {
  address: `0x${string}`;
  label: string;
  chain: 'ethereum' | 'base' | 'arbitrum' | 'optimism';
  // Normal hourly withdrawal volume — used to set thresholds
  normalVolumeEth: number;
  // Alert if a single withdrawal exceeds this
  singleWithdrawalThresholdEth: number;
}

export const MONITORED_BRIDGES: BridgeAddress[] = [
  // ETH Mainnet — the lock contract, where real value lives
  {
    address: '0xYourBridgeL1Lock',
    label: 'Bridge L1 Lock',
    chain: 'ethereum',
    normalVolumeEth: 500,
    singleWithdrawalThresholdEth: 1000,
  },
  // Arbitrum — outbox where withdrawals finalize
  {
    address: '0xYourArbitrumOutbox',
    label: 'Arbitrum Outbox',
    chain: 'arbitrum',
    normalVolumeEth: 200,
    singleWithdrawalThresholdEth: 500,
  },
  // Base — bridge withdrawal handler
  {
    address: '0xYourBaseWithdrawal',
    label: 'Base Bridge Withdrawal',
    chain: 'base',
    normalVolumeEth: 100,
    singleWithdrawalThresholdEth: 300,
  },
  // Optimism — L2->L1 relay
  {
    address: '0xYourOpWithdrawal',
    label: 'Optimism Withdrawal Relay',
    chain: 'optimism',
    normalVolumeEth: 150,
    singleWithdrawalThresholdEth: 400,
  },
];

Set singleWithdrawalThresholdEth conservatively at first — you can raise it as you understand normal volume. False positives are far less costly than missed incidents.


Step 2: Register contracts and configure webhooks

import { ZeroWatchClient } from '@0agent/0watch-sdk';
import { MONITORED_BRIDGES } from './bridges.js';

const client = new ZeroWatchClient({
  apiKey: process.env.ZEROWATCH_API_KEY,
});

async function setupBridgeMonitoring() {
  for (const bridge of MONITORED_BRIDGES) {
    // Register contract address for monitoring
    const wallet = await client.createWallet({
      address: bridge.address,
      label: bridge.label,
      chain: bridge.chain,
    }).catch((err) => {
      if (err.status === 409) {
        console.log(`Already registered: ${bridge.label}`);
        return null;
      }
      throw err;
    });

    // Configure webhook for this contract
    const webhook = await client.createWebhook({
      url: process.env.WEBHOOK_URL!,
      wallet_address: bridge.address,
      threshold_eth: bridge.singleWithdrawalThresholdEth,
    });

    console.log(
      `[${bridge.chain.toUpperCase()}] Monitoring ${bridge.label} — ` +
      `threshold: ${bridge.singleWithdrawalThresholdEth} ETH — webhook: ${webhook.id}`
    );
  }
}

setupBridgeMonitoring().catch(console.error);

Step 3: Build the webhook handler

import express from 'express';
import crypto from 'node:crypto';

const app = express();

interface AlertPayload {
  event: 'high_value_transaction';
  walletAddress: string;
  thresholdEth: number;
  valueEth: number;
  transaction: {
    hash: string;
    blockNumber: number;
    timestamp: number;
    fromAddress: string;
    toAddress: string | null;
    valueWei: string;
    txType: 'eth_transfer' | 'erc20_transfer' | 'erc20_approval' | 'uniswap_swap' | 'unknown';
    status: 0 | 1;
    chainId: number;
  };
}

app.post('/hooks/bridge', express.raw({ type: '*/*' }), async (req, res) => {
  const rawBody = req.body.toString('utf-8');
  const signature = req.headers['x-0watch-signature'] as string;

  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SIGNING_SECRET!)
    .update(rawBody)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(signature ?? '', 'hex'))) {
    return res.status(401).json({ error: 'unauthorized' });
  }

  res.json({ ok: true });

  const alert: AlertPayload = JSON.parse(rawBody);
  processBridgeAlert(alert).catch(console.error);
});

app.listen(3002, () => console.log('Bridge monitor running on :3002'));

Step 4: Detect and classify withdrawal patterns

import { ZeroWatchClient } from '@0agent/0watch-sdk';
import { MONITORED_BRIDGES } from './bridges.js';

const client = new ZeroWatchClient({ apiKey: process.env.ZEROWATCH_API_KEY });

async function processBridgeAlert(alert: AlertPayload) {
  const { walletAddress, valueEth, transaction } = alert;
  const bridge = MONITORED_BRIDGES.find(
    (b) => b.address.toLowerCase() === walletAddress.toLowerCase()
  );

  // Enrich: pull recent transactions for this contract
  const { transactions } = await client.getWalletTransactions(
    walletAddress as `0x${string}`,
    { limit: 50 }
  );

  // Analyze the last hour of activity
  const oneHourAgo = Date.now() - 3_600_000;
  const recentTxs = transactions.filter((t) => t.timestamp > oneHourAgo);
  const recentVolumeEth = recentTxs.reduce((sum, t) => {
    const eth = Number(BigInt(t.valueWei)) / 1e18;
    return sum + eth;
  }, 0);

  const context = {
    bridge: bridge?.label ?? walletAddress,
    chain: chainLabel(transaction.chainId),
    txHash: transaction.hash,
    valueEth: valueEth.toFixed(4),
    txType: transaction.txType,
    blockNumber: transaction.blockNumber,
    recentTxCount: recentTxs.length,
    recentVolumeEth: recentVolumeEth.toFixed(4),
    normalVolumeEth: bridge?.normalVolumeEth ?? 0,
    failed: transaction.status === 0,
  };

  // Failed withdrawal — always investigate
  if (transaction.status === 0) {
    await notifySecuritySlack({
      title: `Failed withdrawal on ${context.bridge}`,
      context,
      severity: 'warning',
    });
    return;
  }

  // Velocity anomaly: recent volume > 3x normal hourly
  const velocityAnomaly =
    bridge && recentVolumeEth > bridge.normalVolumeEth * 3;

  // Single large withdrawal
  const singleLargeWithdrawal =
    bridge && valueEth > bridge.singleWithdrawalThresholdEth * 2;

  if (singleLargeWithdrawal || velocityAnomaly) {
    // P1 incident
    await notifyPagerDuty({
      summary: `BRIDGE ANOMALY — ${context.bridge} [${context.chain}]`,
      severity: singleLargeWithdrawal ? 'critical' : 'high',
      details: context,
    });
  } else {
    // Standard threshold alert — informational
    await notifySecuritySlack({
      title: `Large withdrawal: ${context.valueEth} ETH — ${context.bridge}`,
      context,
      severity: 'info',
    });
  }
}

function chainLabel(chainId: number): string {
  const labels: Record<number, string> = {
    1: 'Ethereum',
    8453: 'Base',
    42161: 'Arbitrum',
    10: 'Optimism',
  };
  return labels[chainId] ?? `Chain ${chainId}`;
}

The velocity check is the key addition for bridge security. Exploit patterns like Nomad's exploit involved many transactions totaling $190M — no single transaction was "large" relative to the total locked value, but the cumulative 1-hour volume was catastrophically abnormal.


Step 5: Aggregate cross-chain health checks

Run a periodic check that pulls the current alert state across all monitored contracts:

async function runBridgeHealthCheck() {
  const results: Array<{
    label: string;
    chain: string;
    alertsLast24h: number;
    lastAlert: string | null;
  }> = [];

  for (const bridge of MONITORED_BRIDGES) {
    const { alerts } = await client.getWalletAlerts(bridge.address, {
      limit: 50,
    });

    const dayAgo = Date.now() - 86_400_000;
    const recentAlerts = alerts.filter((a) => a.detectedAt > dayAgo);

    results.push({
      label: bridge.label,
      chain: bridge.chain,
      alertsLast24h: recentAlerts.length,
      lastAlert:
        recentAlerts.length > 0
          ? new Date(recentAlerts[0].detectedAt).toISOString()
          : null,
    });
  }

  console.table(results);
  return results;
}

// Run every 15 minutes
setInterval(runBridgeHealthCheck, 15 * 60 * 1000);
runBridgeHealthCheck();

Use this output to feed a dashboard or include in a daily security digest.


Step 6: Build an incident summary endpoint

When a P1 fires, your on-call needs immediate context. Build an endpoint that aggregates everything:

import express from 'express';
import { ZeroWatchClient } from '@0agent/0watch-sdk';
import { MONITORED_BRIDGES } from './bridges.js';

const client = new ZeroWatchClient({ apiKey: process.env.ZEROWATCH_API_KEY });
const app = express();

app.get('/incident/:address', async (req, res) => {
  const address = req.params.address as `0x${string}`;

  const [walletData, recentAlerts, recentTxs, activitySummary] =
    await Promise.all([
      client.listWallets({ chain: 'ethereum' }),
      client.getWalletAlerts(address, { limit: 20 }),
      client.getWalletTransactions(address, { limit: 50 }),
      client.getWalletSummary(address),
    ]);

  const wallet = walletData.wallets.find(
    (w) => w.address.toLowerCase() === address.toLowerCase()
  );

  const oneHourAgo = Date.now() - 3_600_000;
  const txsLastHour = recentTxs.transactions.filter(
    (t) => t.timestamp > oneHourAgo
  );
  const volumeLastHour = txsLastHour
    .reduce((sum, t) => sum + Number(BigInt(t.valueWei)) / 1e18, 0)
    .toFixed(4);

  res.json({
    address,
    label: wallet?.label ?? 'Unknown',
    chain: wallet?.chain,
    alertsLast20: recentAlerts.alerts.map((a) => ({
      type: a.anomalyType,
      severity: a.severity,
      txHash: a.txHash,
      detectedAt: new Date(a.detectedAt).toISOString(),
    })),
    txsLastHour: txsLastHour.length,
    volumeLastHour: `${volumeLastHour} ETH`,
    totalAlertsAllTime: activitySummary.alertsTriggered,
    totalTxsMonitored: activitySummary.totalTransactionsMonitored,
    lastActivity: activitySummary.lastActivityTimestamp
      ? new Date(activitySummary.lastActivityTimestamp).toISOString()
      : null,
  });
});

app.listen(3003);

This endpoint is meant to be hit by your PagerDuty runbook during incident response — it gives the on-call all the context they need without requiring them to dig through Etherscan manually.


Expected alert payload

{
  "event": "high_value_transaction",
  "walletAddress": "0xYourBridgeL1Lock",
  "thresholdEth": 1000,
  "valueEth": 4750.2,
  "transaction": {
    "hash": "0xghi789...",
    "blockNumber": 19990123,
    "timestamp": 1710604800000,
    "fromAddress": "0xAttacker",
    "toAddress": "0xYourBridgeL1Lock",
    "valueWei": "4750200000000000000000",
    "txType": "eth_transfer",
    "status": 1,
    "chainId": 1
  }
}

For bridge contracts, txType: "unknown" often appears on complex multi-call transactions — e.g., an exploit that involves calling the bridge's withdraw function directly. Don't filter these out; unknown types on bridge contracts warrant closer attention, not less.


Tuning thresholds for your bridge

Start conservative and adjust based on observed traffic:

  1. Deploy with thresholds set at 50% of your expected normal volume
  2. Monitor for one week and track false positive rate
  3. Raise thresholds to 150% of observed P95 single-transaction volume
  4. Review weekly during the first month

Bridge volume varies significantly by market conditions. Volatile market periods see higher withdrawal volume — consider time-of-day and market context when evaluating alerts.


Multi-signature alert correlation

For the highest-confidence anomaly detection, correlate alerts across chains. A real bridge exploit often shows activity on both the source and destination chain within a short window:

// Simple cross-chain alert correlator
const recentAlerts = new Map<string, { chain: string; timestamp: number }>();

async function checkCrossChainCorrelation(alert: AlertPayload) {
  const key = alert.transaction.fromAddress.toLowerCase();
  const now = Date.now();

  // Check if same address triggered an alert on another chain in the last 5 minutes
  const prior = recentAlerts.get(key);
  if (prior && now - prior.timestamp < 5 * 60 * 1000) {
    const currentChain = chainLabel(alert.transaction.chainId);
    if (prior.chain !== currentChain) {
      await notifyPagerDuty({
        summary: `CROSS-CHAIN ALERT: ${key} active on ${prior.chain} AND ${currentChain}`,
        severity: 'critical',
        details: { address: key, chains: [prior.chain, currentChain] },
      });
    }
  }

  recentAlerts.set(key, {
    chain: chainLabel(alert.transaction.chainId),
    timestamp: now,
  });
}

Next steps

For full API reference, see the 0watch API docs.