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
- Multi-chain monitoring for bridge contracts and their hot wallets
- Alert routing logic that surfaces large withdrawals and unusual transaction velocity
- A pattern detector that flags withdrawals above expected norms
- An incident response webhook that posts structured data for your security team
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
- Node.js 18+
- 0watch API key
- Your bridge contract addresses on each monitored chain
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:
- Deploy with thresholds set at 50% of your expected normal volume
- Monitor for one week and track false positive rate
- Raise thresholds to 150% of observed P95 single-transaction volume
- 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
- Add Optimism's L2OutputOracle and Arbitrum's RollupUserLogic contracts as additional monitored addresses
- Build a weekly bridge security report using
listAlertHistorypaginated across all contracts - Integrate with your existing incident runbooks to auto-populate incident details
For full API reference, see the 0watch API docs.