← Назад к обучению

Безопасность и кросс-чейн разработка

Уязвимости смарт-контрактов привели к миллиардным потерям. В этом руководстве рассматриваются наиболее распространённые векторы атак, паттерны безопасности оракулов, стратегии тестирования и основы создания кросс-чейн DeFi-приложений.

Атаки повторного входа (Reentrancy)

Reentrancy остаётся самой печально известной уязвимостью смарт-контрактов. Она возникает, когда внешний вызов повторно входит в контракт до завершения обновления состояния, позволяя злоумышленнику выводить средства, многократно вызывая withdraw до обнуления баланса.

Исправление имеет два уровня: обновлять состояние перед внешними вызовами (checks-effects-interactions) и использовать ReentrancyGuard от OpenZeppelin в качестве второй линии обороны. Современный Solidity 0.8+ устраняет переполнение целых чисел как способствующий фактор, но базовый паттерн reentrancy по-прежнему применим.

Уязвимый и безопасный вывод средств
// VULNERABLE — external call before state update
function withdraw() external {
    uint256 amount = balances[msg.sender];
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
    balances[msg.sender] = 0; // Too late!
}

// SECURE — state update before external call + guard
function withdraw() external nonReentrant {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0; // State updated first
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success, "Transfer failed");
}

Контроль доступа

Ролевой контроль доступа разделяет привилегии администратора, оператора и pauser. Это предотвращает контроль всего протокола одним скомпрометированным ключом. AccessControl от OpenZeppelin предоставляет проверенную в боях реализацию с иерархиями ролей.

Критические операции — изменение ставок вознаграждения, приостановка контракта, обновление реализаций — должны требовать разных ролей. DEFAULT_ADMIN_ROLE может выдавать и отзывать другие роли, поэтому она должна принадлежать мультиподписи или timelock-контракту, а никогда — одному EOA.

Ролевой контроль доступа
import "@openzeppelin/contracts/access/AccessControl.sol";

contract SecureProtocol is AccessControl {
    bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
    bytes32 public constant OPERATOR_ROLE = keccak256("OPERATOR_ROLE");
    bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");

    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(ADMIN_ROLE, msg.sender);
    }

    function setRewardRate(uint256 newRate) external onlyRole(ADMIN_ROLE) {
        require(newRate <= 10000, "Rate too high");
        rewardRate = newRate;
    }

    function pause() external onlyRole(PAUSER_ROLE) { paused = true; }
    function unpause() external onlyRole(ADMIN_ROLE) { paused = false; }
}

Безопасность оракулов

Манипуляция ценовым оракулом — один из самых распространённых векторов атак в DeFi. Протокол, который доверяет единственному ценовому фиду без валидации, уязвим к манипуляции ценой с помощью флеш-займов, устаревшим данным во время перегрузки сети и простоям оракула.

Эшелонированная защита означает проверку устаревания (отклонять цены старше порогового значения), валидацию правдоподобности (отклонять отрицательные цены или экстремальные отклонения от последней известной корректной цены) и реализацию агрегации нескольких оракулов со взвешенным голосованием. Если все оракулы отказывают, протокол должен приостановиться, а не работать на плохих данных.

Несколько оракулов со взвешенной агрегацией
function getWeightedPrice() public view returns (uint256) {
    uint256 totalWeight = 0;
    uint256 weightedSum = 0;

    for (uint256 i = 0; i < oracleKeys.length; i++) {
        OracleData memory oracle = oracles[oracleKeys[i]];
        if (!oracle.isActive) continue;

        try oracle.feed.latestRoundData() returns (
            uint80, int256 price, uint256, uint256 updatedAt, uint80
        ) {
            if (price > 0 && block.timestamp - updatedAt <= 3600) {
                weightedSum += uint256(price) * oracle.weight;
                totalWeight += oracle.weight;
            }
        } catch {
            continue; // Skip failed oracle
        }
    }

    require(totalWeight > 0, "No valid oracles");
    return weightedSum / totalWeight;
}

Тестирование и верификация

Готовому к продакшену DeFi-контракту нужны модульные тесты для каждой функции, интеграционные тесты для сложных рабочих процессов, fuzz-тесты для случайных входных данных и, в идеале, формальная верификация критических инвариантов. Стремитесь как минимум к 95 % покрытию тестами перед любым аудитом.

Fuzz-тестирование выявляет граничные случаи, которые упускают ручные тесты. Запустите сотни случайных последовательностей внесения/вывода средств и убедитесь, что общая сумма депозитов всегда равна сумме отдельных балансов. Инструменты формальной верификации, такие как Certora, могут математически доказать, что инварианты выполняются для всех возможных входных данных, а не только для тех, которые вы додумались протестировать.

Fuzz-тест: согласованность состояния
describe("Fuzz Testing", function () {
  it("Should maintain state consistency across random operations", async function () {
    for (let i = 0; i < 100; i++) {
      const user = Math.random() > 0.5 ? user1 : user2;
      const action = Math.random() > 0.5 ? "deposit" : "withdraw";
      const amount = ethers.parseEther((Math.random() * 100).toFixed(2));

      try {
        if (action === "deposit") {
          await token.connect(user).approve(await protocol.getAddress(), amount);
          await protocol.connect(user).deposit(amount);
        } else {
          const balance = await protocol.userDeposits(user.address);
          if (balance > 0n) {
            const w = amount > balance ? balance : amount;
            await protocol.connect(user).withdraw(w);
          }
        }
      } catch (error) {
        expect(error.message).to.match(/(InsufficientBalance|InvalidAmount)/);
      }
    }

    // Invariant: totals must match
    const total = await protocol.totalDeposits();
    const b1 = await protocol.userDeposits(user1.address);
    const b2 = await protocol.userDeposits(user2.address);
    expect(total).to.equal(b1 + b2);
  });
});

Подготовка к аудиту

Прежде чем привлекать аудитора, пройдитесь по тщательному предаудитному чек-листу. Пункты качества кода: исчерпывающая NatSpec-документация, единообразный стиль, отсутствие неиспользуемых импортов, все TODO решены, применены оптимизации газа. Пункты безопасности: защита от reentrancy, контроль доступа, валидация входных данных, защита от манипуляции оракулом, механизм аварийной приостановки.

Требования к тестированию: модульные тесты для всех функций, интеграционные тесты для сложных рабочих процессов, тесты граничных случаев, fuzz-тесты и покрытие выше 95 %. Документация: техническая спецификация, диаграммы архитектуры, известные ограничения, инструкции по развёртыванию и руководство по аварийным процедурам (runbook).

Кросс-чейн архитектура

Мультичейн DeFi требует слоя абстракции цепочек, который сопоставляет идентификаторы цепочек с RPC-URL, адресами контрактов, блок-эксплорерами и нативными валютами. Это позволяет вашему фронтенду динамически переключаться между Ethereum, Polygon, Arbitrum и Base без жёстко закодированной логики по всей кодовой базе.

Кросс-чейн депозиты используют паттерн мостового (bridge) контракта: заблокировать токены в исходной цепочке, отправить сообщение через мост и выпустить (mint) или высвободить токены в целевой цепочке. Принимающий контракт должен проверить, что сообщение пришло от моста и от правильного адреса протокола исходной цепочки. Всегда реализуйте защиту от повторного воспроизведения (replay) и учитывайте гарантии финальности каждой цепочки.

Поток кросс-чейн депозита
function depositCrossChain(uint256 amount, uint256 targetChain)
    external nonReentrant
{
    require(chainProtocols[targetChain] != address(0), "Unsupported chain");
    IERC20(depositToken).transferFrom(msg.sender, address(this), amount);

    bytes memory message = abi.encode(msg.sender, amount);
    bytes32 messageId = bridge.sendMessage(
        targetChain,
        chainProtocols[targetChain],
        message
    );

    emit CrossChainDeposit(msg.sender, amount, targetChain, messageId);
}

function receiveMessage(
    uint256 sourceChain, address sourceAddress, bytes calldata message
) external {
    require(msg.sender == address(bridge), "Only bridge");
    require(sourceAddress == chainProtocols[sourceChain], "Invalid source");
    (address user, uint256 amount) = abi.decode(message, (address, uint256));
    userDeposits[user] += amount;
    totalDeposits += amount;
}
WarningНикогда не разворачивайте DeFi смарт-контракт в mainnet без хотя бы одного независимого аудита безопасности. Даже проверенные в боях паттерны могут быть применены неправильно, а стоимость аудита ничтожна по сравнению со стоимостью эксплойта.