китаев.tech

Аудит чеклист перед деплоем

Практический чеклист проверок перед деплоем смарт-контракта: роли, адреса, параметры сети, права и smoke-test.

Что проверять перед деплоем

Pre-deploy аудит — это последняя проверка контракта, параметров сети и прав до отправки транзакции деплоя.

Цель не в том, чтобы "найти все баги за 10 минут". Цель — не задеплоить контракт с нулевым owner, неправильной сетью, тестовым адресом, открытой admin-функцией или сломанным constructor.


Пишем контракт с явными проверками

Создайте файл contracts/DeployChecklistVault.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DeployChecklistVault {
    address public immutable owner;
    address public immutable treasury;
    uint256 public immutable withdrawLimit;

    mapping(address => bool) public operators;

    error ZeroAddress();
    error ZeroLimit();
    error OnlyOwner();
    error OnlyOperator();
    error LimitExceeded();
    error EthTransferFailed();

    event OperatorUpdated(address indexed operator, bool allowed);
    event Withdrawn(address indexed receiver, uint256 amount);

    constructor(address initialOwner, address initialTreasury, uint256 initialWithdrawLimit) payable {
        if (initialOwner == address(0) || initialTreasury == address(0)) {
            revert ZeroAddress();
        }

        if (initialWithdrawLimit == 0) {
            revert ZeroLimit();
        }

        owner = initialOwner;
        treasury = initialTreasury;
        withdrawLimit = initialWithdrawLimit;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) {
            revert OnlyOwner();
        }

        _;
    }

    modifier onlyOperator() {
        if (!operators[msg.sender]) {
            revert OnlyOperator();
        }

        _;
    }

    function setOperator(address operator, bool allowed) external onlyOwner {
        if (operator == address(0)) {
            revert ZeroAddress();
        }

        operators[operator] = allowed;

        emit OperatorUpdated(operator, allowed);
    }

    function withdraw(uint256 amount) external onlyOperator {
        if (amount > withdrawLimit) {
            revert LimitExceeded();
        }

        (bool success,) = treasury.call{value: amount}("");

        if (!success) {
            revert EthTransferFailed();
        }

        emit Withdrawn(treasury, amount);
    }
}

Этот контракт специально небольшой: в нём видны роли, constructor-параметры, лимит вывода и внешний ETH-перевод.


Запускаем preflight перед деплоем

Создайте файл scripts/audit-checklist.ts:

typescript
import { network } from "hardhat";

const { ethers } = await network.create();

const EXPECTED_CHAIN_ID = 31337n;
const WITHDRAW_LIMIT = ethers.parseEther("0.5");

function assert(condition: boolean, message: string): asserts condition {
  if (!condition) {
    throw new Error(`Preflight failed: ${message}`);
  }
}

function assertAddress(address: string, label: string) {
  assert(ethers.isAddress(address), `${label} is not a valid address`);
  assert(address !== ethers.ZeroAddress, `${label} is zero address`);
}

const [deployer, owner, treasury, operator, stranger] = await ethers.getSigners();
const { chainId } = await ethers.provider.getNetwork();

assert(chainId === EXPECTED_CHAIN_ID, `wrong chainId: ${chainId}`);

assertAddress(deployer.address, "deployer");
assertAddress(owner.address, "owner");
assertAddress(treasury.address, "treasury");
assertAddress(operator.address, "operator");

assert(owner.address !== deployer.address, "owner must not be deployer");
assert(treasury.address !== owner.address, "treasury must not be owner");
assert(WITHDRAW_LIMIT > 0n, "withdraw limit must be positive");

const deployerBalance = await ethers.provider.getBalance(deployer.address);
assert(deployerBalance > ethers.parseEther("1"), "deployer has not enough ETH");

const vault = await ethers.deployContract(
  "DeployChecklistVault",
  [owner.address, treasury.address, WITHDRAW_LIMIT],
  {
    value: ethers.parseEther("1"),
  },
);

await vault.waitForDeployment();

const vaultAddress = await vault.getAddress();
const deployedCode = await ethers.provider.getCode(vaultAddress);

assert(deployedCode !== "0x", "vault code was not deployed");
assert((await vault.owner()) === owner.address, "owner mismatch");
assert((await vault.treasury()) === treasury.address, "treasury mismatch");
assert((await vault.withdrawLimit()) === WITHDRAW_LIMIT, "withdraw limit mismatch");

try {
  await vault.connect(stranger).setOperator(operator.address, true);
  throw new Error("stranger changed operator");
} catch (error) {
  const message = String(error);
  assert(message.includes("OnlyOwner") || message.includes("reverted"), "unexpected access-control error");
}

await vault.connect(owner).setOperator(operator.address, true);
assert(await vault.operators(operator.address), "operator was not enabled");

const treasuryBefore = await ethers.provider.getBalance(treasury.address);

await vault.connect(operator).withdraw(ethers.parseEther("0.1"));

const treasuryAfter = await ethers.provider.getBalance(treasury.address);
assert(treasuryAfter - treasuryBefore === ethers.parseEther("0.1"), "withdraw smoke-test failed");

console.log("Preflight passed");
console.log("Vault:", vaultAddress);
console.log("Owner:", owner.address);
console.log("Treasury:", treasury.address);
console.log("Operator:", operator.address);

Запуск:

bash
npx hardhat run scripts/audit-checklist.ts

Если одна проверка падает, скрипт останавливает деплой. Это дешевле, чем исправлять уже опубликованный контракт.


Как это работает

EXPECTED_CHAIN_ID — защита от деплоя в неправильную сеть. Для mainnet, Base, Arbitrum и Polygon значение должно быть разным.

assertAddress() — проверяет формат адреса и запрещает address(0).

owner.address !== deployer.address — пример проектного правила. Во многих командах deployer не должен оставаться владельцем контракта.

getCode(vaultAddress) — проверяет, что по адресу реально появился bytecode.

stranger.setOperator() — negative test. Он доказывает, что посторонний адрес не может вызвать owner-функцию.

operator.withdraw() — smoke-test основной функции. После него скрипт проверяет изменение баланса treasury.


Чеклист перед деплоем

Проверьте сеть: chainId, RPC endpoint, explorer и адрес deployer.

Проверьте constructor: owner, treasury, token, oracle, router, лимиты, fee и deadline не должны быть тестовыми или нулевыми.

Проверьте роли: admin-функции закрыты, deployer не получает лишние права, multisig указан там, где нужен multisig.

Проверьте внешние адреса: контракты имеют bytecode, адреса соответствуют нужной сети, decimals и interfaces совпадают.

Проверьте failure cases: чужой адрес не может вызвать admin-функции, лимиты срабатывают, нулевая сумма отклоняется.

Проверьте события: критичные изменения ролей, лимитов и выводов средств должны эмитить events.


Частые ошибки

Деплоить с тестовым owner → храните адреса по сетям и проверяйте их в preflight-скрипте.

Проверять только happy path → добавьте negative tests для ролей, лимитов и нулевых адресов.

Доверять frontend-конфигу → constructor-параметры и адреса внешних контрактов должны проверяться в deploy script.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.