Аудит чеклист перед деплоем
Практический чеклист проверок перед деплоем смарт-контракта: роли, адреса, параметры сети, права и smoke-test.
Что проверять перед деплоем
Pre-deploy аудит — это последняя проверка контракта, параметров сети и прав до отправки транзакции деплоя.
Цель не в том, чтобы "найти все баги за 10 минут". Цель — не задеплоить контракт с нулевым owner, неправильной сетью, тестовым адресом, открытой admin-функцией или сломанным constructor.
Пишем контракт с явными проверками
Создайте файл contracts/DeployChecklistVault.sol:
// 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:
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);Запуск:
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.
Что дальше
- Проверка адресов и calldata — как валидировать target и calldata перед внешним вызовом
- Verify контракта в Etherscan — как опубликовать исходники после деплоя
- Proxy-контракты — какие дополнительные проверки нужны для upgradeable-деплоя