Your DAO treasury multisig holds $40M in ETH and stablecoins. A signer key gets compromised. A governance execution fires unexpectedly. An automated rebalancer behaves badly in an edge case.

In each scenario, the chain records what happened at the moment it happened. Your monitoring layer finds out when someone checks Etherscan.

This tutorial walks through setting up real-time treasury monitoring using the 0watch TypeScript SDK. By the end you'll have a webhook handler that fires on large outflows, governance executions, and unusual transaction velocity — and a Node.js service that routes those alerts to your ops team.


What you'll build


Architecture

Treasury Multisig (on-chain)
        │
        ▼
  0watch Indexer  ←── watches chain in real time
        │
        ▼ (webhook POST)
  Your Webhook Server
        │
        ├── Large transfer  →  PagerDuty P1
        ├── High velocity   →  Slack #treasury-ops
        └── Failed tx       →  Slack #treasury-ops

0watch watches your wallet addresses continuously. When a transaction crosses a configured threshold, it POSTs a signed payload to your endpoint. You handle routing and response.


Prerequisites


Install the SDK

npm install @0agent/0watch-sdk

Step 1: Initialize the client and register your wallets

import { ZeroWatchClient } from '@0agent/0watch-sdk';

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

// Register primary treasury multisig
const treasury = await client.createWallet({
  address: '0xYourTreasuryMultisig',
  label: 'Main Treasury',
  chain: 'ethereum',
});

// Register ops wallet (lower threshold, separate webhook)
const opsWallet = await client.createWallet({
  address: '0xYourOpsWallet',
  label: 'Ops Multisig',
  chain: 'ethereum',
});

console.log('Registered:', treasury.address, opsWallet.address);

If you manage wallets across multiple chains, chain accepts 'ethereum', 'base', 'arbitrum', 'optimism', or a chain ID integer.


Step 2: Configure webhooks

// High-threshold alert for the main treasury: any transfer > 50 ETH
const treasuryWebhook = await client.createWebhook({
  url: 'https://ops.yourprotocol.xyz/hooks/treasury',
  wallet_address: '0xYourTreasuryMultisig',
  threshold_eth: 50,
});

// Ops wallet — lower threshold, same endpoint
const opsWebhook = await client.createWebhook({
  url: 'https://ops.yourprotocol.xyz/hooks/treasury',
  wallet_address: '0xYourOpsWallet',
  threshold_eth: 5,
});

console.log('Webhook IDs:', treasuryWebhook.id, opsWebhook.id);
// Save these — you'll need them to verify delivery and rotate secrets

Each webhook has a signingSecret that 0watch uses to sign the request body with HMAC-SHA256. Always validate this before processing.


Step 3: Build the webhook handler

Create a minimal Express server to receive and process alerts:

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

const app = express();
app.use(express.json({ type: '*/*' }));

// Type the webhook payload
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;
  };
}

function verifySignature(
  payload: string,
  signingSecret: string,
  signature: string
): boolean {
  const expected = crypto
    .createHmac('sha256', signingSecret)
    .update(payload)
    .digest('hex');
  return crypto.timingSafeEqual(
    Buffer.from(expected, 'hex'),
    Buffer.from(signature, 'hex')
  );
}

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

  // Validate — reject anything that doesn't verify
  if (!verifySignature(rawBody, process.env.WEBHOOK_SIGNING_SECRET!, signature)) {
    console.warn('Signature mismatch — rejected');
    return res.status(401).json({ error: 'unauthorized' });
  }

  const alert: AlertPayload = JSON.parse(rawBody);
  handleAlert(alert);

  res.json({ ok: true });
});

app.listen(3000);

Return 200 quickly. If your downstream notification takes time, process it async. 0watch retries on non-2xx responses, but repeated slow handlers will exhaust the retry window.


Step 4: Route alerts based on type and severity

async function handleAlert(alert: AlertPayload) {
  const { walletAddress, valueEth, thresholdEth, transaction } = alert;

  // Build a summary object for all routing paths
  const summary = {
    wallet: walletAddress,
    txHash: transaction.hash,
    valueEth: valueEth.toFixed(4),
    from: transaction.fromAddress,
    to: transaction.toAddress,
    type: transaction.txType,
    blockNumber: transaction.blockNumber,
    chainId: transaction.chainId,
    timestamp: new Date(transaction.timestamp).toISOString(),
    failed: transaction.status === 0,
  };

  console.log('[0watch alert]', JSON.stringify(summary));

  // Failed transaction on treasury — always worth investigating
  if (transaction.status === 0) {
    await notifySlack({
      channel: '#treasury-ops',
      message: `Failed transaction on ${walletAddress}\nTx: ${transaction.hash}`,
      severity: 'warning',
    });
    return;
  }

  // Large outbound transfer — P1 if above 5x threshold
  if (valueEth > thresholdEth * 5) {
    await notifyPagerDuty({
      summary: `CRITICAL: ${valueEth.toFixed(2)} ETH outflow from ${walletAddress}`,
      severity: 'critical',
      details: summary,
    });
  } else {
    await notifySlack({
      channel: '#treasury-ops',
      message: `Large transfer: ${valueEth.toFixed(4)} ETH from ${walletAddress}\nTx: ${transaction.hash}`,
      severity: 'warning',
    });
  }
}

Step 5: Enrich with historical context

When an alert fires, pull recent transactions to contextualize it:

import { ZeroWatchClient } from '@0agent/0watch-sdk';
const client = new ZeroWatchClient({ apiKey: process.env.ZEROWATCH_API_KEY });

async function enrichAlert(walletAddress: string, alertTxHash: string) {
  // Recent transactions for this wallet
  const { transactions } = await client.getWalletTransactions(
    walletAddress as `0x${string}`,
    { limit: 10, chain: 'ethereum' }
  );

  // Any other anomalies detected on this wallet
  const { alerts } = await client.getWalletAlerts(
    walletAddress as `0x${string}`,
    { limit: 5 }
  );

  // Activity summary
  const summary = await client.getWalletSummary(
    walletAddress as `0x${string}`
  );

  return {
    recentTxCount: transactions.length,
    recentTxHashes: transactions.map(t => t.hash),
    recentAlerts: alerts.map(a => ({
      type: a.anomalyType,
      severity: a.severity,
      txHash: a.txHash,
    })),
    totalAlertsTriggered: summary.alertsTriggered,
  };
}

Use this in your alert handler to decide routing — a wallet with 10 alerts in the last hour is a different situation than an isolated large transfer.


Step 6: Monitoring multiple treasury components

Real DAO treasuries span multiple wallets: the main multisig, a grants committee wallet, a yield strategy deployer, an emergency pause admin. Register them all and use labels to route alerts correctly:

const TREASURY_WALLETS = [
  { address: '0xMainMultisig', label: 'Main Treasury', threshold: 100 },
  { address: '0xGrantsWallet', label: 'Grants Committee', threshold: 10 },
  { address: '0xStrategyDeployer', label: 'Yield Strategy', threshold: 25 },
  { address: '0xPauseAdmin', label: 'Emergency Pause Admin', threshold: 0.1 },
];

async function setupMonitoring() {
  for (const w of TREASURY_WALLETS) {
    const wallet = await client.createWallet({
      address: w.address as `0x${string}`,
      label: w.label,
      chain: 'ethereum',
    });

    await client.createWebhook({
      url: process.env.WEBHOOK_URL!,
      wallet_address: w.address as `0x${string}`,
      threshold_eth: w.threshold,
    });

    console.log(`Monitoring ${w.label}: ${wallet.address}`);
  }
}

Expected alert payload

When a threshold is crossed, your endpoint receives:

{
  "event": "high_value_transaction",
  "walletAddress": "0xYourTreasuryMultisig",
  "thresholdEth": 50,
  "valueEth": 183.7,
  "transaction": {
    "hash": "0xabc123...",
    "blockNumber": 19876543,
    "timestamp": 1710432000000,
    "fromAddress": "0xYourTreasuryMultisig",
    "toAddress": "0xRecipient",
    "valueWei": "183700000000000000000",
    "txType": "eth_transfer",
    "status": 1,
    "chainId": 1
  }
}

txType values: eth_transfer, erc20_transfer, erc20_approval, uniswap_swap, unknown. Filter on this to detect governance executions (which often show up as unknown or erc20_approval) vs. direct transfers.


Operationalizing it

A few practices that make this reliable in production:

Idempotency — 0watch may retry deliveries. Include the transaction.hash as an idempotency key in your handler so you don't send duplicate pages.

Webhook rotation — Rotate signing secrets regularly. The SDK's updateWebhook method lets you rotate with zero downtime:

await client.updateWebhook(webhookId, { rotate_signing_secret: true });
const updated = await client.listWebhooks();
// update your env with the new secret

Historical auditlistAlertHistory gives you a paginated log of all past alerts with delivery status. Useful for postmortems.

const history = await client.listAlertHistory({ limit: 50 });
console.log(history.alerts.map(a => ({
  wallet: a.walletAddress,
  tx: a.txHash,
  status: a.status,
  timestamp: new Date(a.timestamp).toISOString(),
})));

Next steps

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