Post-deployment security is the phase that most teams underinvest in. An audit catches bugs before launch; monitoring catches everything that happens after. The two operate on fundamentally different threat models. Audits assume a static codebase. Monitoring assumes a live adversarial environment — one where attackers probe, compose unexpected interactions, and move faster than any human review cycle.
This article covers the full stack of production monitoring: what on-chain surveillance can and cannot realistically detect, how to write Forta detection bots, how OpenZeppelin Defender automates response, how to encode protocol-specific invariants, how to watch the mempool for early warning signals, how to route alerts into incident response workflows, and how to think about monitoring as an economic decision rather than a checklist item.
What On-Chain Monitoring Can and Cannot Detect
Understanding the capability boundary of on-chain monitoring prevents both overconfidence and underinvestment.
What it can detect
- Abnormal value flows: unusually large withdrawals, single-block drain patterns, or token transfers that violate expected ratios.
- Function call sequences that precede known attack patterns: flash loan initiation followed by price-sensitive operations followed by repayment within the same block.
- Invariant violations: TVL drops below a threshold, collateralization ratios outside safe bands, reserve imbalances.
- Access control changes: ownership transfers, role grants, proxy upgrades, timelock queue activity.
- Contract deployments from privileged addresses: an admin EOA deploying a contract is unusual and worth flagging.
- Reentrancy-consistent call patterns: a contract calling back into itself across multiple frames before the original call resolves.
- Oracle manipulation signatures: sudden price deviations larger than historical volatility, price reads immediately after a large swap.
What it cannot detect
- Intent: a large withdrawal by a legitimate whale and a large withdrawal by an attacker look identical on-chain. Context and correlation are required.
- Off-chain key compromise before any transaction is broadcast: until the attacker moves funds, there is nothing to see.
- Logic bugs that have not yet been triggered: monitoring confirms anomalies; it cannot enumerate unexplored code paths.
- MEV that is entirely within normal parameters: sandwich attacks on users are harmful but structurally indistinguishable from ordinary arbitrage without application-layer context.
- Social engineering or governance manipulation: if a governance vote passes legitimately, the resulting transaction is valid by definition.
- Zero-day exploits on infrastructure below the EVM layer: node-level, RPC-level, or bridge relayer compromises may not produce immediately suspicious on-chain signatures.
Forta Network and Writing Custom Detection Bots
Forta is a decentralized monitoring network where independent scan nodes run detection bots against every new block across multiple chains. Bots are Docker containers that receive block and transaction data and emit alerts when conditions are met.
Bot architecture
A Forta bot exposes two handler functions: handleBlock and handleTransaction. The SDK provides typed access to transaction receipts, logs, traces, and block metadata.
// forta-bot/src/agent.ts
import {
BlockEvent,
Finding,
FindingSeverity,
FindingType,
HandleBlock,
HandleTransaction,
TransactionEvent,
ethers,
} from "forta-agent";
const VAULT_ADDRESS = "0xYourVaultAddressHere";
const LARGE_WITHDRAWAL_THRESHOLD = ethers.utils.parseEther("500");
// ABI fragment for the Withdrawal event
const WITHDRAWAL_EVENT_ABI =
"event Withdrawal(address indexed user, uint256 amount)";
export const handleTransaction: HandleTransaction = async (
txEvent: TransactionEvent
): Promise<Finding[]> => {
const findings: Finding[] = [];
// Filter for Withdrawal events emitted by the vault
const withdrawalEvents = txEvent.filterLog(
WITHDRAWAL_EVENT_ABI,
VAULT_ADDRESS
);
for (const event of withdrawalEvents) {
const { user, amount } = event.args;
if (amount.gt(LARGE_WITHDRAWAL_THRESHOLD)) {
findings.push(
Finding.fromObject({
name: "Large Vault Withdrawal",
description: `Address ${user} withdrew ${ethers.utils.formatEther(
amount
)} ETH from vault`,
alertId: "VAULT-LARGE-WITHDRAWAL-1",
severity: FindingSeverity.High,
type: FindingType.Suspicious,
metadata: {
user,
amount: amount.toString(),
txHash: txEvent.hash,
},
})
);
}
}
return findings;
};
export const handleBlock: HandleBlock = async (
_blockEvent: BlockEvent
): Promise<Finding[]> => {
return [];
};
Detecting flash-loan attack patterns
Flash loan attacks follow a predictable call pattern: borrow → manipulate → repay within a single transaction. You can detect the signature by looking for flash loan initiation events colocated with large position changes in your protocol.
// forta-bot/src/flashloan-detector.ts
import {
Finding,
FindingSeverity,
FindingType,
HandleTransaction,
TransactionEvent,
ethers,
} from "forta-agent";
// Common flash loan provider signatures
const AAVE_FLASH_LOAN_TOPIC =
"0x631042c832b07452973831137f2d73e395028b44b250dedc5abb0ee766e168ac";
const UNISWAP_FLASH_TOPIC =
"0xbef19f534aef58a3e4e4e4ac91f38b99a45c8c62d6b3ea5c80d9214e1b7d17af";
const PROTOCOL_CONTRACT = "0xYourProtocolAddress";
const BORROW_ABI = "event Borrow(address indexed borrower, uint256 amount)";
export const handleFlashLoanTransaction: HandleTransaction = async (
txEvent: TransactionEvent
): Promise<Finding[]> => {
const findings: Finding[] = [];
const hasFlashLoan = txEvent.filterLog([
AAVE_FLASH_LOAN_TOPIC,
UNISWAP_FLASH_TOPIC,
]).length > 0;
if (!hasFlashLoan) return findings;
// Check if the same transaction also interacts with our protocol
const protocolBorrows = txEvent.filterLog(BORROW_ABI, PROTOCOL_CONTRACT);
if (protocolBorrows.length > 0) {
const totalBorrowed = protocolBorrows.reduce(
(sum, log) => sum.add(log.args.amount),
ethers.BigNumber.from(0)
);
findings.push(
Finding.fromObject({
name: "Flash Loan + Protocol Borrow in Same Transaction",
description: `Transaction combines flash loan with ${protocolBorrows.length} protocol borrow(s)`,
alertId: "FLASH-LOAN-PROTOCOL-INTERACTION-1",
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
metadata: {
txHash: txEvent.hash,
from: txEvent.from,
totalProtocolBorrowed: totalBorrowed.toString(),
blockNumber: txEvent.blockNumber.toString(),
},
})
);
}
return findings;
};
Tracking governance and access control events
Privilege escalation and unexpected ownership changes are among the highest-value things to monitor. Any protocol that uses role-based access control should have bots watching for role grants, ownership transfers, and proxy upgrade calls.
// forta-bot/src/access-control-monitor.ts
import {
Finding,
FindingSeverity,
FindingType,
HandleTransaction,
TransactionEvent,
} from "forta-agent";
const MONITORED_CONTRACTS = [
"0xProxyAddress",
"0xTimelockAddress",
"0xGovernorAddress",
];
const SENSITIVE_EVENT_ABIS = [
"event OwnershipTransferred(address indexed previousOwner, address indexed newOwner)",
"event RoleGranted(bytes32 indexed role, address indexed account, address indexed sender)",
"event Upgraded(address indexed implementation)",
"event CallScheduled(bytes32 indexed id, uint256 index, address target, uint256 value, bytes data, bytes32 predecessor, uint256 delay)",
];
export const handleAccessControl: HandleTransaction = async (
txEvent: TransactionEvent
): Promise<Finding[]> => {
const findings: Finding[] = [];
for (const contractAddress of MONITORED_CONTRACTS) {
const sensitiveEvents = txEvent.filterLog(
SENSITIVE_EVENT_ABIS,
contractAddress
);
for (const event of sensitiveEvents) {
findings.push(
Finding.fromObject({
name: `Sensitive Access Control Event: ${event.name}`,
description: `${event.name} emitted on ${contractAddress}`,
alertId: `ACCESS-CONTROL-${event.name.toUpperCase().replace(/\s/g, "-")}`,
severity: FindingSeverity.High,
type: FindingType.Suspicious,
metadata: {
contract: contractAddress,
eventName: event.name,
args: JSON.stringify(
Object.fromEntries(
Object.entries(event.args).filter(([k]) => isNaN(Number(k)))
)
),
txHash: txEvent.hash,
},
})
);
}
}
return findings;
};
Testing and deploying bots
Forta provides a local testing framework that replays real transaction data against your bot logic before you deploy to the network.
# Install Forta CLI
npm install -g forta-agent
# Initialize a new bot project
forta-agent init --typescript
# Run against a specific transaction hash for unit testing
npm run tx 0xYourTransactionHashHere
# Run against the latest block
npm run block latest
# Run in live mode against a local node
npm run start
A well-structured bot repository separates detection logic from handler wiring, maintains a comprehensive test suite with known malicious and benign transactions, and publishes findings with enough metadata that a human operator can take action without re-investigating from scratch.
OpenZeppelin Defender and Automated Response Actions
Forta detects. Defender responds. The two tools are designed to work together through Defender’s Autotask and Sentinel systems.
Sentinels
A Sentinel monitors a contract for events or function calls and triggers a workflow when conditions are met. The workflow can call an Autotask (a serverless function with a managed private key), send a notification to Slack, PagerDuty, or a webhook, or pause a contract if it implements the Pausable interface.
A minimal Autotask that pauses a contract on receiving a Forta alert:
// defender-autotask/pause-on-alert.js
const { ethers } = require("ethers");
const PAUSABLE_ABI = ["function pause() external", "function paused() external view returns (bool)"];
exports.handler = async function (credentials) {
const provider = new ethers.providers.JsonRpcProvider(
credentials.secrets.RPC_URL
);
// Defender injects a managed signer — no private key management required
const signer = credentials.relayer
? new ethers.Wallet(credentials.secrets.RELAYER_KEY, provider)
: provider.getSigner();
const contract = new ethers.Contract(
credentials.secrets.CONTRACT_ADDRESS,
PAUSABLE_ABI,
signer
);
const alreadyPaused = await contract.paused();
if (alreadyPaused) {
console.log("Contract already paused — no action needed.");
return { status: "already-paused" };
}
console.log("Pausing contract in response to security alert...");
const tx = await contract.pause({
gasLimit: 100_000,
});
const receipt = await tx.wait();
console.log(`Contract paused in block ${receipt.blockNumber}, tx: ${tx.hash}`);
return {
status: "paused",
txHash: tx.hash,
blockNumber: receipt.blockNumber,
};
};
Relayer management
Defender Relayers hold private keys in a managed KMS. This means your automated response actions do not require embedding private keys in environment variables or CI systems. Relayers can be configured with spending limits, address whitelists, and per-transaction gas caps — constraints that prevent a compromised Autotask from being weaponized.
Tiered response workflows
Not every alert warrants an immediate pause. A tiered response structure scales the response to the confidence and severity of the signal:
| Alert Severity | Confidence | Response |
|---|---|---|
| Informational | Any | Log to dashboard, no action |
| Medium | Low | Notify on-call via Slack |
| Medium | High | Page on-call, begin investigation |
| High | Any | Page on-call + rate-limit sensitive functions |
| Critical | Any | Auto-pause + page + open incident channel |
Implement tiering by inspecting the severity field from the incoming Forta alert payload inside the Autotask handler before deciding which action to take.
Anomaly Detection for Protocol-Specific Invariants
Generic monitoring tools detect generic attack patterns. The highest-value monitoring is specific to your protocol’s invariants — the conditions that must always hold for the system to be solvent and correct.
Defining invariants
Before writing detection code, enumerate your protocol’s invariants explicitly:
- AMM:
reserve0 * reserve1 == k(constant product, adjusted for fees) - Lending protocol:
totalBorrows <= totalDeposits * maxUtilization - Stablecoin:
totalSupply * pegPrice <= totalCollateralValue * minCollateralizationRatio - Vault:
totalAssets >= totalSharesOutstanding * pricePerShare
Monitoring invariants on-chain
The cleanest way to monitor invariants is to write a view function on the contract itself that returns a boolean, then call it in a Forta bot’s handleBlock handler:
// forta-bot/src/invariant-monitor.ts
import {
BlockEvent,
Finding,
FindingSeverity,
FindingType,
HandleBlock,
getEthersProvider,
ethers,
} from "forta-agent";
const LENDING_POOL_ABI = [
"function totalBorrows() external view returns (uint256)",
"function totalDeposits() external view returns (uint256)",
"function maxUtilizationBps() external view returns (uint256)",
];
const LENDING_POOL_ADDRESS = "0xYourLendingPoolAddress";
const BASIS_POINTS = ethers.BigNumber.from(10000);
export const handleInvariantBlock: HandleBlock = async (
blockEvent: BlockEvent
): Promise<Finding[]> => {
const findings: Finding[] = [];
const provider = getEthersProvider();
const pool = new ethers.Contract(
LENDING_POOL_ADDRESS,
LENDING_POOL_ABI,
provider
);
const [totalBorrows, totalDeposits, maxUtilBps] = await Promise.all([
pool.totalBorrows({ blockTag: blockEvent.blockNumber }),
pool.totalDeposits({ blockTag: blockEvent.blockNumber }),
pool.maxUtilizationBps({ blockTag: blockEvent.blockNumber }),
]);
if (totalDeposits.isZero()) return findings;
const utilizationBps = totalBorrows.mul(BASIS_POINTS).div(totalDeposits);
if (utilizationBps.gt(maxUtilBps)) {
findings.push(
Finding.fromObject({
name: "Lending Pool Utilization Invariant Violated",
description: `Utilization ${utilizationBps.toString()} bps exceeds max ${maxUtilBps.toString()} bps`,
alertId: "LENDING-UTILIZATION-INVARIANT-1",
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
metadata: {
totalBorrows: totalBorrows.toString(),
totalDeposits: totalDeposits.toString(),
utilizationBps: utilizationBps.toString(),
maxUtilBps: maxUtilBps.toString(),
blockNumber: blockEvent.blockNumber.toString(),
},
})
);
}
return findings;
};
Block-level invariant checks add latency of one block (~12 seconds on Ethereum mainnet), which is acceptable for most financial thresholds but insufficient for stopping an in-progress exploit within a single transaction. For that, invariant checks must be embedded in the contract itself using modifiers — monitoring is the safety net, not the lock.
Monitoring the Mempool for Suspicious Patterns
The mempool is the pre-confirmation layer. Transactions sit there for seconds to minutes before inclusion, providing a narrow but real window for frontrunning a defense.
What mempool monitoring adds
- Early warning of large attacks: a transaction that will drain $50M does not become visible at the smart contract layer until it is mined. In the mempool, it is visible as soon as it is broadcast — potentially seconds earlier.
- Detection of known attacker addresses: if a wallet associated with prior exploits broadcasts a transaction to your protocol, you can alert before the block is confirmed.
- Sandwich attack visibility: seeing pending swaps lets you quantify MEV exposure even if individual transactions are benign.
Subscribing to pending transactions
// mempool-monitor/src/index.ts
import { ethers } from "ethers";
const WSS_URL = process.env.WSS_RPC_URL!;
const PROTOCOL_ADDRESS = "0xYourProtocolAddress".toLowerCase();
const KNOWN_ATTACKER_ADDRESSES = new Set([
"0xknownattacker1",
"0xknownattacker2",
]);
async function monitorMempool() {
const provider = new ethers.providers.WebSocketProvider(WSS_URL);
provider.on("pending", async (txHash: string) => {
try {
const tx = await provider.getTransaction(txHash);
if (!tx || !tx.to) return;
const isTargetingProtocol =
tx.to.toLowerCase() === PROTOCOL_ADDRESS;
const isFromKnownAttacker = KNOWN_ATTACKER_ADDRESSES.has(
tx.from.toLowerCase()
);
if (isFromKnownAttacker && isTargetingProtocol) {
await triggerAlert({
level: "CRITICAL",
message: `Known attacker ${tx.from} sent tx to protocol`,
txHash,
gasPrice: tx.gasPrice?.toString(),
value: tx.value.toString(),
data: tx.data.slice(0, 10), // function selector
});
}
// Flag suspiciously high gas price (potential frontrun or urgency)
const baseFee = await provider
.getBlock("latest")
.then((b) => b.baseFeePerGas);
if (
isTargetingProtocol &&
baseFee &&
tx.maxFeePerGas &&
tx.maxFeePerGas.gt(baseFee.mul(5))
) {
await triggerAlert({
level: "HIGH",
message: `Transaction to protocol with unusually high gas priority`,
txHash,
maxFeePerGas: tx.maxFeePerGas.toString(),
baseFee: baseFee.toString(),
});
}
} catch {
// Transactions may be evicted before fetch completes — safe to swallow
}
});
console.log("Mempool monitor active.");
}
async function triggerAlert(payload: Record<string, string>) {
// Route to PagerDuty, Slack, or your incident management system
console.log("ALERT:", JSON.stringify(payload, null, 2));
}
monitorMempool().catch(console.error);
Limitations of mempool monitoring
Private mempools (Flashbots MEV-Boost bundles, private RPC endpoints used by sophisticated attackers) bypass the public mempool entirely. A transaction submitted as a bundle is never visible until it lands in a block. Mempool monitoring is therefore a useful layer, but it cannot be the only layer.
Incident Response Integration
Monitoring without a connected incident response process is instrumentation theater. Alerts that fire but do not trigger defined actions create alert fatigue and erode team trust in the monitoring system.
Alert routing architecture
Structure your alert pipeline so that every alert has exactly one owner at any given time:
Forta Bot / Mempool Monitor / Defender Sentinel
│
▼
Alert Aggregator (e.g., custom webhook receiver)
│
├── Severity: INFO → Logging system (Datadog, Grafana)
├── Severity: MEDIUM → Slack #security-alerts channel
├── Severity: HIGH → PagerDuty → On-call engineer
└── Severity: CRITICAL → PagerDuty + auto-pause + open incident bridge
Runbooks
Every alert ID should have a corresponding runbook. A runbook answers three questions without requiring the on-call engineer to think under pressure:
- What does this alert mean? What invariant was violated, what pattern was detected.
- How do I verify it? The exact query or block explorer link to confirm the signal is not a false positive.
- What are my response options? Pause the contract, revoke a role, contact the multisig team, drain to a safe address, do nothing and monitor.
## Runbook: VAULT-LARGE-WITHDRAWAL-1
**What it means**: A single withdrawal exceeded 500 ETH from the vault.
**Verification**:
1. Open the transaction on Etherscan: https://etherscan.io/tx/{txHash}
2. Confirm the withdrawal event parameters match the alert metadata.
3. Check if the withdrawing address is a known entity (team wallet, large LP).
4. Check if there was a preceding flash loan in the same block.
**Response options**:
- If address is known and expected: Mark false positive, no action.
- If address is unknown and total withdrawals exceed 20% TVL:
Trigger Defender Autotask `pause-vault` immediately and escalate.
- If flash loan is present in the same block:
Treat as exploit, trigger full incident response protocol.
Post-incident review loop
Every alert that triggers a human response — even one that turns out to be a false positive — should produce a five-minute post-incident note:
- Was this a true positive, false positive, or true negative?
- Was the runbook sufficient? Did the on-call engineer know what to do?
- Should the detection threshold be adjusted?
- Was the response time within the target SLA?
This feedback loop is what converts a monitoring system from a set of tools into an improving process.
The Economics of Monitoring
Monitoring has real costs: infrastructure, engineering time, alert management, and the opportunity cost of false-positive-driven pauses. Evaluating those costs against the expected value of the protection they provide is how you decide where to invest.
Cost components
| Component | Typical cost driver |
|---|---|
| Forta bot deployment | FORT staking requirement + node fees |
| Private RPC for mempool | ~$200–$2,000/month depending on throughput |
| Defender Pro subscription | Per-network, per-relayer pricing |
| On-call engineering time | Pager load, runbook maintenance, review cycles |
| False-positive response cost | Engineer time + potential revenue lost from unnecessary pauses |
Value at risk framework
The expected value of monitoring is roughly:
EV(monitoring) = P(exploit detected early) × Value preserved
− Cost(monitoring infrastructure)
− Cost(false positive responses)
Where “value preserved” is the TVL or protocol revenue at risk, discounted by the fraction of value that early detection actually saves. A protocol with $100M TVL where a single exploit could drain 80% of assets has $80M at risk. If monitoring reduces the expected drain from 80% to 20% in 40% of realistic attack scenarios, the expected value preserved is $24M. Infrastructure costs of $50K/year are trivially justified.
For a protocol with $500K TVL, the calculus is different. A comprehensive monitoring stack may cost more in operational burden than the value it protects. The right answer there is usually:
- Use Forta’s community bots for generic attack detection (no marginal cost)
- Set up one Defender Sentinel on the most critical function
- Write one invariant check that pages on critical violation
- Skip mempool monitoring until TVL grows
When monitoring cost is clearly justified
- Any protocol holding user funds above $1M TVL
- Any protocol with admin keys or upgradeability — the blast radius of key compromise is amplified by upgrade capability
- Any protocol that has been audited but not formally verified — monitoring is the practical substitute for mathematical proof
- Any protocol operating in a high-MEV environment — oracle manipulation is faster and cheaper than it used to be
Monitoring Complements Audits, Not Replaces Them
The most important framing is the one in the lead of this article: audits and monitoring are complements that operate at different points in the security lifecycle and against different threat classes.
What audits do that monitoring cannot
- Static analysis across all code paths: auditors can reason about code that has never been executed.
- Economic modeling of attack vectors: a skilled auditor thinks through incentive structures and multi-step exploits that produce no anomalous on-chain signature until the final step.
- Formal specification review: auditors can verify that the code matches the intended specification, a judgment that requires reading documentation, not transactions.
- Pre-deployment gatekeeping: an audit is the last line of defense before code reaches mainnet. Monitoring assumes the code is already live.
What monitoring does that audits cannot
- Continuous coverage: code is audited once (or a few times). Monitoring runs every block, indefinitely.
- Behavioral detection: monitoring sees how the system actually behaves under real usage, with real composability, against real adversaries. Audits reason about how it might behave.
- Response time compression: an audit finding becomes actionable weeks before launch. A monitoring alert becomes actionable in seconds.
- Coverage of deployment configuration errors: an audit reviews code; it does not verify that the correct bytecode was deployed to the correct address with the correct constructor parameters. Monitoring can confirm that the deployed system behaves as expected.
- Composability risk detection: DeFi protocols interact with protocols that did not exist when the audit was conducted. A flash loan provider deployed six months post-audit creates a new attack surface. Monitoring sees the actual call patterns; a point-in-time audit cannot.
The security lifecycle
Design → Audit → Testnet Monitoring → Mainnet Launch → Production Monitoring
│
┌────────────────────┤
│ │
Re-audit on Continuous
major changes invariant
monitoring
The correct position is to require audits before deployment and monitoring after deployment, treating them as serial gates in the security lifecycle rather than alternatives.
Putting It Together: A Minimal Production Stack
For a protocol launching with modest TVL and a small engineering team, a practical starting configuration looks like this:
-
Forta community bots: Subscribe to existing bots for reentrancy detection, large transfer monitoring, and access control changes. Zero marginal cost.
-
One custom Forta bot: Encode your single most critical protocol invariant. Deploy it to the Forta network with appropriate staking.
-
One Defender Sentinel + Autotask: Watch for the
Upgradedevent on your proxy (if upgradeable) and the largest withdrawal event on your vault. Autotask pauses on critical alert. -
PagerDuty or equivalent: Route Critical and High alerts to on-call. Route Medium to Slack. Route Informational to a log sink.
-
A runbook document: One page per alert ID. Live in your team’s wiki. Reviewed quarterly.
-
A post-incident process: Even for false positives. The feedback loop is what makes the system improve.
As TVL grows, expand the Forta bot coverage, add mempool monitoring via a dedicated node subscription, move toward a more sophisticated tiered-response Autotask, and invest in block-level invariant checks for every core financial relationship in the protocol.
Summary
Post-deployment monitoring is not optional for protocols holding user funds. On-chain surveillance detects behavioral anomalies that static code review cannot anticipate. Forta provides the decentralized bot execution layer; writing protocol-specific bots against invariants you define provides detection coverage that generic tools miss. Defender closes the loop between detection and response, automating the actions that must happen faster than any human can act. Mempool monitoring adds seconds of early warning for attacks that broadcast before confirmation. Incident response integration transforms alerts into actions and actions into learning. And the economics, for any protocol above trivial TVL, strongly favor investment.
None of this replaces an audit. The combination of a rigorous pre-deployment audit and a mature production monitoring stack represents the current practical ceiling of smart contract security — defense in depth across both the static and dynamic dimensions of a live protocol.