Разработка системы circuit breaker для DeFi-протоколов
Название пришло из финансового рынка: на NYSE и Nasdaq торги автоматически останавливаются при падении индекса на 7%, 13%, 20%. Смысл — предотвратить паническую спираль и дать рынку время «остыть». DeFi нужен аналог, потому что смарт-контракты исполняются без паузы, а эксплойты часто опустошают протокол за секунды.
Euler Finance ($197M), Compound ($90M в oracle manipulation), Mango Markets ($117M) — все эти инциденты объединяет одно: если бы выводы/заимствования остановились при первых аномальных признаках, масштаб потерь был бы на порядок меньше.
Circuit breaker для DeFi — это система автоматических триггеров, останавливающих или ограничивающих ключевые функции протокола при обнаружении аномалий. Задача — минимизировать потери при атаке или техническом сбое, с возможностью быстрого восстановления нормальной работы.
Что останавливает circuit breaker: классификация триггеров
Не каждый параметр одинаково значим как триггер. Хороший circuit breaker срабатывает достаточно редко, чтобы не мешать нормальной работе, но достаточно чувствительно, чтобы поймать атаку до значительного ущерба.
Группа 1: Триггеры по объёму вывода
Самые распространённые и понятные. Если за короткое время выводится нетипично большой объём — это либо bank run, либо эксплойт.
contract WithdrawalCircuitBreaker {
struct FlowMetrics {
uint256 withdrawalsInWindow; // сумма выводов за window
uint256 depositsInWindow; // сумма депозитов за window
uint256 windowStartTime;
uint256 windowStartBlock;
}
uint256 public constant WINDOW_DURATION = 1 hours;
uint256 public constant MAX_WITHDRAWAL_PERCENT_BPS = 1500; // 15% TVL за окно
uint256 public constant NET_OUTFLOW_LIMIT_BPS = 2000; // -20% net за окно
FlowMetrics public currentWindow;
uint256 public totalTVL;
bool public withdrawalsPaused;
event CircuitBreakerTriggered(string reason, uint256 triggeredAt, uint256 amount);
event CircuitBreakerReset(uint256 resetAt, address resetBy);
modifier notPaused() {
require(!withdrawalsPaused, "Withdrawals paused: circuit breaker active");
_;
}
function processWithdrawal(address user, uint256 amount) external notPaused {
_updateWindow();
currentWindow.withdrawalsInWindow += amount;
// Проверка абсолютного объёма
uint256 maxWithdrawalAmount = totalTVL * MAX_WITHDRAWAL_PERCENT_BPS / 10000;
if (currentWindow.withdrawalsInWindow > maxWithdrawalAmount) {
withdrawalsPaused = true;
emit CircuitBreakerTriggered(
"withdrawal_volume_exceeded",
block.timestamp,
currentWindow.withdrawalsInWindow
);
revert("Circuit breaker: withdrawal limit exceeded");
}
// Проверка net outflow
int256 netFlow = int256(currentWindow.depositsInWindow) -
int256(currentWindow.withdrawalsInWindow);
uint256 netOutflow = netFlow < 0 ? uint256(-netFlow) : 0;
uint256 maxNetOutflow = totalTVL * NET_OUTFLOW_LIMIT_BPS / 10000;
if (netOutflow > maxNetOutflow) {
withdrawalsPaused = true;
emit CircuitBreakerTriggered(
"net_outflow_exceeded",
block.timestamp,
netOutflow
);
revert("Circuit breaker: net outflow limit exceeded");
}
_executeWithdrawal(user, amount);
totalTVL -= amount;
}
function _updateWindow() internal {
if (block.timestamp >= currentWindow.windowStartTime + WINDOW_DURATION) {
currentWindow.withdrawalsInWindow = 0;
currentWindow.depositsInWindow = 0;
currentWindow.windowStartTime = block.timestamp;
}
}
}
Группа 2: Триггеры по аномальным ценам оракула
Oracle manipulation — распространённый вектор атаки на lending протоколы. Атакующий манипулирует ценой залогового токена для получения незаконных займов.
contract OracleCircuitBreaker {
struct PriceSnapshot {
uint256 price;
uint256 timestamp;
}
mapping(address => PriceSnapshot[]) public priceHistory;
mapping(address => bool) public oraclePaused;
uint256 public constant MAX_PRICE_DEVIATION_BPS = 500; // 5% от TWAP
uint256 public constant TWAP_PERIODS = 8; // 8 snapshot'ов
uint256 public constant SNAPSHOT_INTERVAL = 15 minutes;
function checkOracleHealth(address token, uint256 currentPrice)
external returns (bool healthy)
{
_recordSnapshot(token, currentPrice);
uint256 twap = _calculateTWAP(token);
if (twap == 0) return true; // Недостаточно данных
uint256 deviation;
if (currentPrice > twap) {
deviation = (currentPrice - twap) * 10000 / twap;
} else {
deviation = (twap - currentPrice) * 10000 / twap;
}
if (deviation > MAX_PRICE_DEVIATION_BPS) {
oraclePaused[token] = true;
emit CircuitBreakerTriggered(
"oracle_deviation",
block.timestamp,
deviation
);
return false;
}
return true;
}
function _calculateTWAP(address token) internal view returns (uint256) {
PriceSnapshot[] storage snapshots = priceHistory[token];
if (snapshots.length < 2) return 0;
uint256 start = snapshots.length > TWAP_PERIODS
? snapshots.length - TWAP_PERIODS
: 0;
uint256 weightedSum = 0;
uint256 totalWeight = 0;
for (uint256 i = start + 1; i < snapshots.length; i++) {
uint256 timeDelta = snapshots[i].timestamp - snapshots[i-1].timestamp;
weightedSum += snapshots[i-1].price * timeDelta;
totalWeight += timeDelta;
}
return totalWeight > 0 ? weightedSum / totalWeight : 0;
}
function _recordSnapshot(address token, uint256 price) internal {
PriceSnapshot[] storage snapshots = priceHistory[token];
// Записываем снапшот не чаще чем раз в SNAPSHOT_INTERVAL
if (snapshots.length > 0 &&
block.timestamp < snapshots[snapshots.length-1].timestamp + SNAPSHOT_INTERVAL) {
return;
}
snapshots.push(PriceSnapshot({
price: price,
timestamp: block.timestamp
}));
// Храним только последние 24 снапшота
if (snapshots.length > 24) {
// Сдвиг массива (дорого, в production — circular buffer)
for (uint256 i = 0; i < snapshots.length - 24; i++) {
snapshots[i] = snapshots[i + 24 - snapshots.length + 1];
}
// В реальном контракте лучше использовать маппинг + счётчик
}
}
}
Группа 3: Ончейн-аномалии смарт-контракта
Некоторые аномалии сигнализируют о нарушении инвариантов протокола.
Invariant checks: базовые арифметические инварианты протокола должны выполняться всегда. Для lending: total_borrows <= total_deposits * (1 - reserve_factor). Нарушение — немедленный стоп.
Utilization rate spike: для lending протокола резкий рост utilization выше 95% за короткое время — аномалия.
Flash loan volume spike: если в одном блоке происходит flash loan на сумму > X% от TVL — повышенный риск, требует проверки.
contract InvariantMonitor {
uint256 public constant MAX_UTILIZATION_BPS = 9500; // 95%
uint256 public constant FLASH_LOAN_TVL_LIMIT_BPS = 5000; // 50% TVL за блок
uint256 public constant INVARIANT_TOLERANCE_BPS = 100; // 1% допуск
uint256 public lastBlockFlashLoanVolume;
uint256 public lastFlashLoanBlock;
function checkInvariants() external view returns (bool) {
uint256 totalDeposited = getTotalDeposited();
uint256 totalBorrowed = getTotalBorrowed();
uint256 reserveFactor = getReserveFactor();
// Базовый инвариант: borrows не превышают доступный капитал
uint256 maxBorrowable = totalDeposited * (10000 - reserveFactor) / 10000;
if (totalBorrowed > maxBorrowable * (10000 + INVARIANT_TOLERANCE_BPS) / 10000) {
return false; // Инвариант нарушен
}
// Проверка utilization
if (totalDeposited > 0) {
uint256 utilization = totalBorrowed * 10000 / totalDeposited;
if (utilization > MAX_UTILIZATION_BPS) {
return false;
}
}
return true;
}
modifier checkInvariantsAfter() {
_;
require(checkInvariants(), "Protocol invariant violated: circuit breaker");
}
function trackFlashLoan(uint256 amount) internal {
if (block.number > lastFlashLoanBlock) {
lastBlockFlashLoanVolume = 0;
lastFlashLoanBlock = block.number;
}
lastBlockFlashLoanVolume += amount;
uint256 currentTVL = getTotalDeposited();
uint256 flashLoanLimit = currentTVL * FLASH_LOAN_TVL_LIMIT_BPS / 10000;
if (lastBlockFlashLoanVolume > flashLoanLimit) {
// Не блокируем flash loan полностью (это сломает легитимные арбитражи),
// но логируем для мониторинга и уведомляем
emit AnomalyDetected("flash_loan_spike", lastBlockFlashLoanVolume, block.number);
}
}
}
Gradual vs Hard Circuit Breaker
Бинарная остановка («работает / не работает») слишком груба. Правильная система имеет несколько уровней реакции.
Level 0 — Normal: всё работает штатно.
Level 1 — Monitoring: аномальные показатели, но в допустимом диапазоне. Повышенная частота проверок, уведомления команде. Никаких ограничений для пользователей.
Level 2 — Throttling: небольшие аномалии. Снижение лимитов: максимальный withdrawal за транзакцию уменьшается, вводится cooldown между крупными операциями.
Level 3 — Partial Pause: значительные аномалии. Заморозка конкретной функции (например, только новых заимствований), остальное работает. Депозиты и вывод депозитов обычно остаются активными дольше всего.
Level 4 — Full Pause: критическая аномалия или подтверждённый exploit. Полная остановка всех транзакций кроме emergency withdraw.
enum CircuitBreakerLevel { Normal, Monitoring, Throttling, PartialPause, FullPause }
contract GradualCircuitBreaker {
CircuitBreakerLevel public currentLevel;
struct LevelConfig {
uint256 maxSingleWithdrawal; // максимум за транзакцию
uint256 withdrawalCooldown; // cooldown между крупными выводами
bool newBorrowsAllowed;
bool newDepositsAllowed;
bool withdrawalsAllowed;
bool liquidationsAllowed;
}
mapping(CircuitBreakerLevel => LevelConfig) public levelConfigs;
constructor() {
levelConfigs[CircuitBreakerLevel.Normal] = LevelConfig({
maxSingleWithdrawal: type(uint256).max,
withdrawalCooldown: 0,
newBorrowsAllowed: true,
newDepositsAllowed: true,
withdrawalsAllowed: true,
liquidationsAllowed: true
});
levelConfigs[CircuitBreakerLevel.Throttling] = LevelConfig({
maxSingleWithdrawal: 1_000_000e6, // $1M max per tx
withdrawalCooldown: 5 minutes,
newBorrowsAllowed: true,
newDepositsAllowed: true,
withdrawalsAllowed: true,
liquidationsAllowed: true
});
levelConfigs[CircuitBreakerLevel.PartialPause] = LevelConfig({
maxSingleWithdrawal: 500_000e6,
withdrawalCooldown: 30 minutes,
newBorrowsAllowed: false, // Новые займы заморожены
newDepositsAllowed: true,
withdrawalsAllowed: true,
liquidationsAllowed: true // Ликвидации должны работать для health
});
levelConfigs[CircuitBreakerLevel.FullPause] = LevelConfig({
maxSingleWithdrawal: 0,
withdrawalCooldown: type(uint256).max,
newBorrowsAllowed: false,
newDepositsAllowed: false,
withdrawalsAllowed: false,
liquidationsAllowed: false
});
}
function escalateLevel(CircuitBreakerLevel newLevel, string calldata reason)
external onlyRiskManager
{
require(uint8(newLevel) > uint8(currentLevel), "Can only escalate");
emit LevelEscalated(currentLevel, newLevel, reason, block.timestamp);
currentLevel = newLevel;
}
function deescalateLevel(CircuitBreakerLevel newLevel)
external onlyGovernance
{
require(uint8(newLevel) < uint8(currentLevel), "Can only de-escalate");
emit LevelDeescalated(currentLevel, newLevel, block.timestamp);
currentLevel = newLevel;
}
}
Управление: кто может нажать стоп
Автоматические триггеры покрывают предсказуемые аномалии, но реальные атаки часто новы и нестандартны. Нужны люди с правом экстренной остановки.
Конфликт интересов: если один человек или команда может остановить протокол — это centralization risk. Они могут злоупотребить этим правом для манипуляции рынком.
Решение: Security Council с timelock-bypass
Схема, используемая Arbitrum, Optimism, Compound: отдельный multisig с N подписантами (часто независимые security-эксперты и исследователи). Security Council может:
- Остановить протокол немедленно (в отличие от обычного governance с timelock)
- Выполнить экстренные патчи с сокращённым timelock (24–72 часа вместо 7+ дней)
Они не могут: распоряжаться treasury, изменять токеномику, принимать обычные governance решения.
contract SecurityCouncil {
address[] public members;
uint256 public constant REQUIRED_SIGNATURES = 5; // из 9 членов
mapping(bytes32 => mapping(address => bool)) public signatures;
mapping(bytes32 => uint256) public signatureCount;
function emergencyPause(address protocol) external onlyMember {
IProtocol(protocol).emergencyPause();
emit EmergencyPauseExecuted(protocol, msg.sender, block.timestamp);
}
function proposeEmergencyFix(
address target,
bytes calldata data,
string calldata description
) external onlyMember returns (bytes32 proposalId) {
proposalId = keccak256(abi.encodePacked(target, data, block.number));
signatures[proposalId][msg.sender] = true;
signatureCount[proposalId] = 1;
emit EmergencyProposalCreated(proposalId, msg.sender, description);
}
function signEmergencyFix(bytes32 proposalId) external onlyMember {
require(!signatures[proposalId][msg.sender], "Already signed");
signatures[proposalId][msg.sender] = true;
signatureCount[proposalId]++;
if (signatureCount[proposalId] >= REQUIRED_SIGNATURES) {
_executeProposal(proposalId);
}
}
}
Off-chain мониторинг и интеграция с on-chain триггерами
On-chain circuit breaker реагирует только на то, что происходит непосредственно в транзакциях. Более богатая картина — у off-chain мониторинга, который видит mempool, кросс-протокольные паттерны, социальные сигналы.
class DeFiMonitoringService {
constructor(provider, protocolAddress, alertService) {
this.provider = provider;
this.protocol = new ethers.Contract(protocolAddress, PROTOCOL_ABI, provider);
this.alertService = alertService;
this.metrics = {};
}
async monitorBlock(blockNumber) {
const [tvl, borrows, deposits, prices] = await Promise.all([
this.protocol.getTotalTVL({ blockTag: blockNumber }),
this.protocol.getTotalBorrows({ blockTag: blockNumber }),
this.protocol.getTotalDeposits({ blockTag: blockNumber }),
this.getPricesSnapshot(blockNumber)
]);
const prevMetrics = this.metrics[blockNumber - 1] || {};
// Детект резкого изменения TVL
if (prevMetrics.tvl) {
const tvlChangePct = Math.abs(tvl - prevMetrics.tvl) / prevMetrics.tvl;
if (tvlChangePct > 0.05) { // 5% за блок = ~12 секунд
await this.alertService.sendCritical({
type: 'tvl_spike',
change: tvlChangePct,
blockNumber,
current: tvl.toString(),
previous: prevMetrics.tvl.toString()
});
}
}
// Детект oracle аномалий
for (const [token, price] of Object.entries(prices)) {
if (prevMetrics.prices?.[token]) {
const priceChange = Math.abs(price - prevMetrics.prices[token]) /
prevMetrics.prices[token];
if (priceChange > 0.03) { // 3% за блок
await this.alertService.sendHigh({
type: 'price_anomaly',
token,
priceChange,
blockNumber
});
// Попытка on-chain триггера если есть права
if (this.hasEmergencyRole) {
await this.protocol.triggerOracleCircuitBreaker(token);
}
}
}
}
this.metrics[blockNumber] = { tvl, borrows, deposits, prices };
}
startMonitoring() {
this.provider.on('block', async (blockNumber) => {
try {
await this.monitorBlock(blockNumber);
} catch (e) {
console.error(`Monitor error at block ${blockNumber}:`, e);
}
});
}
}
Важные edge cases
Легитимные большие выводы: whale может вывести $50M легально. Circuit breaker не должен делать это невозможным. Решение: whitelist крупных известных адресов (DAO treasury, крупные LP) с отдельными лимитами, или двухэтапный вывод крупных сумм (сначала request, потом execution через таймаут).
Cascade liquidations: при падении рынка массовые ликвидации выглядят как bank run. Circuit breaker не должен блокировать ликвидации — это разрушит протокол. Ликвидации исключаются из withdrawal limits или имеют отдельные параметры.
Governance attacks: если governance токен скомпрометирован, атакующий может попытаться сбросить circuit breaker через governance предложение. Timelock на сброс breaker должен быть длиннее обычного timelock (например, 7 дней вместо 2).
False positives в volatile market: в периоды высокой волатильности (crypto bull run, macro события) нормальные объёмы могут превышать пороговые значения. Параметры circuit breaker должны быть governance-управляемыми для адаптации к рыночным условиям.
Параметризация и настройка порогов
Выбор правильных пороговых значений — критическая задача, требующая анализа исторических данных протокола.
import pandas as pd
import numpy as np
def analyze_historical_flows(withdrawals_df: pd.DataFrame,
tvl_df: pd.DataFrame) -> dict:
"""
Анализ исторических данных для подбора оптимальных порогов circuit breaker
"""
# Рассчитываем rolling window метрики
merged = withdrawals_df.merge(tvl_df, on='timestamp')
merged['withdrawal_pct'] = merged['withdrawal_amount'] / merged['tvl']
# 1-часовые окна
merged_hourly = merged.set_index('timestamp').resample('1H').sum()
merged_hourly['tvl'] = tvl_df.set_index('timestamp').resample('1H').last()['tvl']
merged_hourly['hourly_withdrawal_pct'] = (
merged_hourly['withdrawal_amount'] / merged_hourly['tvl']
)
# Статистика нормального режима (исключаем известные кризисные периоды)
normal_periods = merged_hourly[merged_hourly['hourly_withdrawal_pct'] < 0.5]
stats = {
'mean_hourly_withdrawal_pct': normal_periods['hourly_withdrawal_pct'].mean(),
'std_hourly_withdrawal_pct': normal_periods['hourly_withdrawal_pct'].std(),
'p95_hourly_withdrawal_pct': normal_periods['hourly_withdrawal_pct'].quantile(0.95),
'p99_hourly_withdrawal_pct': normal_periods['hourly_withdrawal_pct'].quantile(0.99),
'max_observed_normal': normal_periods['hourly_withdrawal_pct'].max(),
}
# Рекомендованные пороги: P99 × 1.5 для первого уровня,
# P99 × 2.5 для остановки
stats['recommended_throttle_threshold'] = stats['p99_hourly_withdrawal_pct'] * 1.5
stats['recommended_pause_threshold'] = stats['p99_hourly_withdrawal_pct'] * 2.5
return stats
Пороги нужно пересматривать при значительном росте протокола: что было аномалией при TVL $10M, становится нормой при TVL $1B.
Стек и сроки разработки
Смарт-контракты: Solidity, OpenZeppelin (Pausable, AccessControl), Foundry для тестирования включая invariant tests. 8–12 недель на полную систему с градуальными уровнями и governance.
Off-chain мониторинг: Node.js / Go сервис, The Graph для исторических данных, PagerDuty / OpsGenie для алертов, Grafana для дашборда метрик.
Security Council смарт-контракт: Gnosis Safe как base + кастомный модуль для emergency actions. 2–3 недели.
Аудит: обязателен, особенно для логики сброса паузы — это потенциальный вектор атаки на сам circuit breaker.
Полный цикл разработки: 4–6 месяцев для production-grade системы с on-chain + off-chain компонентами, Security Council и документированными процедурами реагирования.







