Смарт-контракты: Solidity, жизненный цикл, тестирование, деплой
Название «смарт-контракт» — историческая неудача. Термин придумал Ник Сабо в 1994 году (оригинальный текст), и он не означает ни «умный», ни «контракт» в юридическом смысле. Технически это программа, чей байткод лежит в состоянии блокчейна, у которой есть собственный адрес, баланс и хранилище, и которая исполняется всеми узлами сети детерминированно и одинаково — конечный автомат, у которого переходы стоят денег, откатываются целиком при ошибке и не отменяются задним числом.
Мы уже разобрали устройство EVM, аккаунты и газ; здесь фундамент разработки: модель исполнения, язык, инструменты, цикл. Классы уязвимостей ждут в следующей статье. Честное предупреждение: ошибка в контракте не приводит к 500-й ошибке и алерту в дежурный канал — она приводит к необратимому выводу средств. За 2016–2025 годы через баги контрактов и мостов утекли миллиарды долларов (Rekt, DefiLlama Hacks), и всё, что написано ниже про тесты и инварианты, — прямое следствие этого свойства среды.
1. Что такое смарт-контракт технически
В Ethereum два вида аккаунтов, и различаются они ровно одним полем.
| Поле аккаунта | EOA (у человека) | Контракт |
|---|---|---|
nonce |
счётчик отправленных транзакций | счётчик созданных контрактов |
balance |
баланс в wei | баланс в wei |
storageRoot |
корень пустого дерева | корень дерева его хранилища |
codeHash |
keccak256 пустой строки | keccak256 байткода |
Контракт — это аккаунт с непустым codeHash. Всё остальное вытекает отсюда:
- У контракта нет приватного ключа — он не может подписать транзакцию и не может начать действие сам (про криптографию, про кошельки).
- Контракт просыпается только когда его вызывают. Нет фонового цикла, нет
setInterval, нет cron. «Раз в сутки начисляет проценты» означает: снаружи платят газ за вызов либо проценты считаются лениво при обращении. - Контракт не ходит в сеть. Нет
fetch, нет файлов, нет случайных чисел. Внешняя правда попадает внутрь только транзакцией от того, кому вы решили доверять, — это и есть проблема оракулов. - Исполнение детерминировано. Тысячи узлов обязаны получить бит в бит одинаковый результат, иначе консенсуса не будет: отсюда отсутствие плавающей арифметики и любого недетерминизма.
Рабочая модель: контракт — объект в базе данных с публичным API, где каждый вызов метода является отдельной транзакцией в смысле СУБД: атомарной и оплачиваемой по факту выполненной работы.
2. Путь вызова: от кнопки до записи в состояние
и сэндвич-атаки M->>E: включение в блок, исполнение, счёт газа alt успех E->>S: коммит storage, запись логов else revert E->>S: откат всех изменений, газ списан end S-->>W: логи через eth_getLogs, обновление UI
Три вывода. eth_call и реальная транзакция — не одно и то же: сухой прогон идёт на состоянии текущего
блока, за время до включения оно успеет измениться. Мемпул публичен, поэтому любая логика «кто первый,
того и приз» будет обыграна ботами. Revert стоит денег: откат возвращает состояние, но не газ.
3. Solidity: минимум, который надо держать в голове
Solidity — статически типизированный язык с синтаксисом от JavaScript и семантикой ближе к C++ с ручным управлением областями данных.
| Категория | Примеры | Что важно |
|---|---|---|
| Целые | uint8…uint256, int256 |
с 0.8.0 переполнение по умолчанию ревертит |
| Адрес | address, address payable |
20 байт; payable нужен для отправки эфира |
| Фиксированные байты | bytes1…bytes32 |
дешевле динамических, влезают в слот |
| Динамические | bytes, string, T[] |
длина плюс данные, дороже на порядок |
| Отображение | mapping(K => V) |
нельзя перебрать и нельзя узнать размер |
| Составные | struct, enum |
struct укладывается по слотам как отдельные поля |
Дробных чисел нет вообще: деньги считают в наименьшей единице целыми (1 ETH — это 10^18 wei), а проценты — через фиксированную точку 1e18 (PRBMath, solady) и правило «сначала умножить, потом делить».
Три области данных — источник половины ошибок новичка. storage живёт между транзакциями в дереве
состояния и стоит очень дорого; memory живёт до конца вызова, дёшев, но расширяется квадратично;
calldata — входные данные вызова, только для чтения и самый дешёвый вариант для больших аргументов.
Ловушка: User memory u = users[who] делает копию, и запись в неё теряется; User storage u = users[who]
берёт ссылку на слот, и запись становится SSTORE. Обе строки валидны, компилятор не подскажет.
Раскладка storage
Storage контракта — разреженный массив из 2^256 слотов по 32 байта. Компилятор укладывает переменные по порядку объявления, упаковывая соседние маленькие поля в один слот.
Упаковка экономит на чтении (три поля из одного слота — один SLOAD), но не всегда на записи: запись
одного упакованного поля требует read-modify-write. Порядок объявления полей — часть контракта хранилища:
для обычного контракта неважно, для контракта за прокси критично (раздел 11); проверяется командой
forge inspect MyContract storageLayout. Mapping нельзя перебрать — его слот всегда нулевой, значения
раскиданы по хешам, и для обхода придётся вести отдельный массив ключей. constant и immutable живут
в байткоде, а не в storage, и читаются бесплатно. И главное: private не означает секретность — всё storage
читается снаружи через eth_getStorageAt. Приватности в блокчейне нет по построению.
4. Первый настоящий контракт
Краудфандинг с возвратом: маленький, но задевает деньги, время, mapping, события, кастомные ошибки и безопасный порядок операций.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
/// @title Сбор средств с дедлайном: деньги забирает получатель при достижении цели,
/// иначе каждый вкладчик забирает своё сам
contract Crowdfunding {
address public immutable beneficiary; // immutable в байткоде: чтение бесплатно
uint64 public immutable deadline;
uint128 public immutable goal;
uint128 public totalPledged; // totalPledged и claimed упакованы в один слот
bool public claimed;
mapping(address => uint256) public pledgeOf;
event Pledged(address indexed backer, uint256 amount, uint256 total);
event Claimed(uint256 amount);
event Refunded(address indexed backer, uint256 amount);
// кастомная ошибка — 4 байта селектора вместо ABI-кодированной строки
error TooLate(); error TooEarly(); error GoalNotReached(); error GoalReached();
error NothingToRefund(); error AlreadyClaimed(); error TransferFailed();
constructor(address beneficiary_, uint64 deadline_, uint128 goal_) {
require(beneficiary_ != address(0) && goal_ > 0, "bad params");
require(deadline_ > block.timestamp, "deadline in the past");
beneficiary = beneficiary_; deadline = deadline_; goal = goal_;
}
function pledge() external payable { // payable — функция принимает эфир
if (block.timestamp >= deadline) revert TooLate();
pledgeOf[msg.sender] += msg.value;
// сужение до uint128 безопасно: эмиссия ETH много меньше 2^128 wei.
// Это допущение, и его надо проговаривать вслух, а не подразумевать
totalPledged += uint128(msg.value);
emit Pledged(msg.sender, msg.value, totalPledged);
}
function claim() external { // получатель забирает собранное
if (block.timestamp < deadline) revert TooEarly();
if (totalPledged < goal) revert GoalNotReached();
if (claimed) revert AlreadyClaimed();
claimed = true; // эффект ДО взаимодействия
emit Claimed(totalPledged);
(bool ok, ) = beneficiary.call{value: totalPledged}("");
if (!ok) revert TransferFailed();
}
function refund() external { // вкладчик забирает своё при недоборе
if (block.timestamp < deadline) revert TooEarly();
if (totalPledged >= goal) revert GoalReached();
uint256 amount = pledgeOf[msg.sender];
if (amount == 0) revert NothingToRefund();
pledgeOf[msg.sender] = 0; // обнуляем ДО отправки — иначе повторный вход
emit Refunded(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed();
}
}
Pull вместо push. Контракт не рассылает возвраты сам — каждый забирает своё вызовом refund(). Цикл for
с отправкой эфира сотне адресов упирается в лимит газа блока и ломается целиком, если один из получателей —
контракт, ревертящий при приёме; это withdrawal
pattern (документация).
Checks-Effects-Interactions. Сначала проверки, потом изменение состояния, внешний вызов — последним.
В refund() обнуление pledgeOf до call — единственное, что отделяет контракт от классической атаки
повторного входа. И call вместо transfer: устаревший addr.transfer(x) даёт стипендию 2300 газа,
которая после EIP-1884 начала ломать легитимных получателей вроде кошельков-мультисигов. Наконец, здесь
нет onlyOwner — правила зафиксированы в конструкторе, а каждая административная функция это лишняя
поверхность атаки и лишнее доверие, которое вы просите у пользователя.
5. ABI, селекторы и диспетчер
В байткоде нет имён функций — есть один вход и calldata, произвольный массив байтов. Соглашение
Contract ABI описывает, как из вызова получаются байты:
первые 4 байта — селектор bytes4(keccak256("transfer(address,uint256)")) (каноничные имена типов, без имён
параметров и пробелов), дальше аргументы, выровненные по 32 байта; динамические типы — смещением в «хвост».
Посмотреть руками: cast sig "transfer(address,uint256)" и cast 4byte-decode 0xa9059cbb... из Foundry.
Два следствия. Селектор — всего 4 байта, коллизии находятся перебором: для обычного контракта неопасно
(компилятор откажется собирать два одинаковых), но в Diamond-прокси, где селекторы маршрутизируются вручную,
это реальный вектор. И abi.encodePacked не выравнивает аргументы, поэтому ("a","bc") и ("ab","c")
дают одинаковые байты — если результат идёт в keccak256 для ключа или подписи, получаете коллизию на ровном
месте; для нескольких динамических аргументов используйте abi.encode.
6. Газ: почему каждая строка имеет цену
| Операция | Газ | Комментарий |
|---|---|---|
| Базовая стоимость транзакции | 21 000 | платится всегда, даже за пустую |
| Байт calldata: нулевой / ненулевой | 4 / 16 | EIP-2028; на L2 это основная статья расходов |
SSTORE из нуля в ненуль |
22 100 | 20 000 запись + 2 100 холодный доступ |
SSTORE изменение ненуля |
5 000 | 2 900 запись + 2 100 холодный доступ |
SSTORE в ноль |
2 900, возврат до 4 800 | EIP-3529 срезал возвраты, лимит возврата — 1/5 газа |
SLOAD холодный / тёплый |
2 100 / 100 | EIP-2929: первое обращение к слоту дороже |
CALL на холодный адрес |
2 600 | плюс 9 000, если передаётся эфир |
LOG с одним topic |
375 + 375 + 8 за байт | события дёшевы по сравнению со storage |
| Развёртывание кода | 200 за байт | плюс 32 000 за сам CREATE |
| Арифметика, сравнения | 3–5 | шум на фоне storage |
Одна запись в storage дороже тысячи арифметических операций — оптимизация контракта это почти всегда
оптимизация обращений к storage. Отсюда базовый приём: не писать total += xs[i] внутри цикла, а прочитать
total один раз в локальную переменную, накопить в стеке и записать обратно один раз; счётчик инкрементировать
в unchecked { ++i; }, потому что переполниться он не может. unchecked экономит ~30–40 газа на операцию,
но применять его можно только там, где переполнение невозможно доказуемо: счётчик, ограниченный длиной
массива, — да; арифметика с пользовательским вводом — нет.
Три ограничения, которые закладывают в архитектуру: лимит газа блока (~30 млн) — цикл по массиву, растущему
от действий пользователей, рано или поздно перестанет помещаться в блок и заблокирует контракт навсегда
(unbounded loop DoS); лимит размера кода 24 576 байт (EIP-170) и initcode 49 152 (EIP-3860) — большой
контракт придётся резать на библиотеки или фасеты; стоимость нельзя оценить на глаз — forge snapshot --diff
показывает изменение расхода газа после правки и является единственным честным способом спорить об оптимизации.
7. События и ошибки
События (опкод LOG) пишутся в receipt транзакции и попадают в bloom-фильтр заголовка блока, чтобы ноды быстро
отсеивали блоки при поиске. Ключевое: контракт не может прочитать свои собственные логи — они существуют
исключительно для внешнего мира. Для event Transfer(address indexed from, address indexed to, uint256 value)
topic0 всегда keccak256("Transfer(address,address,uint256)"), каждый indexed параметр становится отдельным
topic (их максимум 3), неиндексированные лежат в теле и в фильтре не участвуют; indexed для string хранит
хеш, исходную строку из лога уже не достать. Индексируйте то, по чему будете искать: адреса,
идентификаторы; суммы — почти никогда. Правило: всё, что нужно только фронтенду и аналитике, пишите
в события, а не в storage — разница в цене около двух порядков. На логах построены The Graph, Dune и любой
индексатор (архитектура dApp).
require(cond, ...) и revert MyError() — про нарушение входных условий, то есть ошибку вызывающего;
assert(cond) — про нарушение внутреннего инварианта, «этого не может быть», компилируется в Panic(uint256)
и означает баг в вашем коде. Ключевое свойство — атомарность: revert откатывает всё изменённое состояние
в рамках всего внешнего вызова, включая изменения вложенных контрактов. Это транзакция в самом строгом смысле,
только без изоляции от параллельных — их просто нет, исполнение последовательное. Тонкость, которая стоила
денег не одному проекту: низкоуровневый call не пробрасывает revert сам.
(bool ok, bytes memory ret) = target.call(data);
// без этой проверки выполнение продолжится после провала вызова,
// и контракт будет считать перевод состоявшимся
if (!ok) { assembly { revert(add(ret, 32), mload(ret)) } } // пробрасываем исходную причину
8. Из чего собирают контракты в проде
Никто не пишет ERC-20 с нуля. Стандарт индустрии — OpenZeppelin Contracts, проаудированная библиотека, которую читали тысячи людей. Ваш код — только то, что действительно уникально.
Объявляется это одной строкой — contract MyToken is ERC20, Ownable, Pausable — и дальше важны нюансы.
Множественное наследование идёт с C3-линеаризацией, как в Python: порядок в is A, B, C — от самого
базового к самому производному, и он влияет и на порядок модификаторов, и на раскладку storage.
Модификатор — не проверка, а обёртка: тело функции подставляется на место _;, поэтому код до и после
_; выполняется до и после функции, а модификатор с внешним вызовом до _; — это дыра. Ownable
не бесплатен: кто владелец, EOA на ноутбуке разработчика или мультисиг с таймлоком? Для пользователя это
разница между «протокол» и «сайт с кнопкой изъять всё». И SafeERC20 — часть популярных токенов (USDT)
не возвращает bool из transfer, нарушая стандарт, и прямой вызов IERC20.transfer на таком токене ревертит
при декодировании (про токены).
9. Тестирование: единственная защита, которая у вас есть
В обычной разработке тесты ускоряют изменения; здесь они не дают потерять чужие деньги, потому что исправить
задеплоенный контракт вы не сможете (общие принципы —
в треке по тестированию).
Foundry выиграл нишу по одной причине: тесты пишутся на том же языке, что
и контракты, и исполняются в нативной EVM без прослойки JavaScript. Читерские коды vm.* перематывают время,
меняют вызывающего, выдают эфир и подменяют слоты (Cheatcodes).
contract CrowdfundingTest is Test {
Crowdfunding internal c;
address internal beneficiary = makeAddr("beneficiary");
address internal alice = makeAddr("alice");
function setUp() public {
c = new Crowdfunding(beneficiary, uint64(block.timestamp + 7 days), 10 ether);
vm.deal(alice, 100 ether); // выдать баланс тестовому адресу
}
function test_ClaimSucceedsWhenGoalReached() public {
vm.prank(alice); c.pledge{value: 11 ether}(); // вызов от имени alice
vm.warp(c.deadline() + 1); // перемотать время блока
c.claim();
assertEq(beneficiary.balance, 11 ether);
assertEq(address(c).balance, 0);
}
function test_RevertWhen_ClaimBeforeDeadline() public {
vm.expectRevert(Crowdfunding.TooEarly.selector); // ожидаем конкретную ошибку
c.claim();
}
// Параметр в сигнатуре превращает тест в фаззинг: 256 прогонов случайными значениями
// с приоритетом на границы. bound лучше vm.assume — не тратит прогоны впустую
function testFuzz_PledgeThenRefundReturnsExactly(uint96 amount) public {
amount = uint96(bound(amount, 1, 9 ether)); // меньше цели: сработает возврат
vm.deal(alice, amount);
vm.prank(alice); c.pledge{value: amount}();
vm.warp(c.deadline() + 1);
vm.prank(alice); c.refund();
assertEq(alice.balance, amount); // вернулось ровно столько, сколько внесли
}
}
Инварианты — главный инструмент в этой области. Инвариантный тест формулирует утверждение, обязанное выполняться после любой последовательности любых вызовов. Фреймворк генерирует случайные цепочки транзакций от случайных актёров через контракт-handler: тот ограничивает диапазоны аргументов и ведёт «призрачный» независимый учёт, с которым сверяется состояние контракта.
contract CrowdfundingInvariants is Test {
Crowdfunding internal c; Handler internal h;
function setUp() public {
c = new Crowdfunding(makeAddr("b"), uint64(block.timestamp + 7 days), 10 ether);
h = new Handler(c);
targetContract(address(h)); // фаззер дёргает handler, а не контракт напрямую
}
/// Платёжеспособность: остаток плюс выплаченное всегда покрывает внесённое
function invariant_SolvencyHolds() public view {
assertGe(address(c).balance + h.ghostPaidOut(), h.ghostPledged());
}
}
Почти все крупные эксплойты нарушают ровно одно короткое утверждение: «сумма долей равна общему количеству», «контракт платёжеспособен», «цена не может уменьшиться». Тест, ищущий такое нарушение случайными последовательностями, находит то, что вы не подумали проверить; родственная техника в обычной разработке — property-based testing.
Интеграцию с чужими протоколами нельзя проверять на моках: мок повторяет ваше понимание чужого кода, а ошибка
обычно как раз в понимании. Форк-тест поднимает локальную EVM поверх реального состояния сети —
vm.createSelectFork(vm.envString("MAINNET_RPC"), 19_000_000); номер блока закрепляйте обязательно, иначе тест
начнёт случайно падать через неделю. Остальной набор в CI: forge test -vvv для трассировки падений,
forge coverage, forge snapshot --diff для газа и Slither — статанализ
с ~90 детекторами, который ловит reentrancy, неинициализированные переменные и неверный порядок операций
за секунды. Покрытие трактуйте жёстче обычного: меньше 100% по веткам в коде, который трогает деньги, —
повод объяснить каждую непокрытую ветку; обратное неверно, потому что покрытие не говорит об инвариантах.
10. Жизненный цикл: от исходника до верифицированного адреса
Что производит компилятор. forge build выдаёт ABI (описание функций и событий), creation bytecode
(initcode) — то, что кладётся в data транзакции развёртывания: содержит логику конструктора и возвращает
runtime bytecode, который и сохранится по адресу, — и metadata с точной версией компилятора, настройками
оптимизатора и хешами исходников. Хеш метаданных компилятор дописывает в конец байткода в CBOR-кодировке,
обычно как IPFS CID — прямая связка с темой
децентрализованных хранилищ; поэтому один и тот же исходник,
собранный из разных путей на диске, даёт разный байткод.
Развёртывание — транзакция с to = null и data = initcode ++ abi.encode(аргументы конструктора)
(forge script ... --broadcast --verify, и обязательно сухой прогон без --broadcast перед этим). Адрес
детерминирован и вычисляется до отправки: CREATE даёт keccak256(rlp([отправитель, nonce])) и зависит
от nonce, а CREATE2 — keccak256(0xff ++ отправитель ++ salt ++ keccak256(initcode)) и от nonce не зависит,
поэтому даёт одинаковый адрес в любой сети. Так получают совпадающие адреса протокола в Ethereum, Arbitrum
и Base; Foundry использует общий деплойер 0x4e59b44847b379578588920cA78FbF26c0B4956C.
Верификация — не формальность. Обозреватель пересобирает исходник с указанной версией компилятора
и настройками оптимизатора и сравнивает байткод. Неверифицированный контракт, которому предлагают дать доступ
к вашим средствам, — сам по себе достаточная причина отказаться. Кроме обозревателей есть
Sourcify — открытый реестр соответствий «байткод — исходник». Типовые причины
расхождения: другая версия solc, другое число прогонов оптимизатора, другой абсолютный путь к файлам,
забытые аргументы конструктора.
Про «удаление». selfdestruct больше не делает того, что описано в старых руководствах: после EIP-6780
(Cancun, 2024) он удаляет код и storage только если вызван в той же транзакции, в которой контракт создан;
иначе просто переводит баланс. Вывод: задеплоенный контракт живёт вечно, а «выключить» его можно только
заранее заложенным флагом паузы или миграцией пользователей на новый адрес.
11. Апгрейды: иммутабельность как фича и как проблема
Неизменяемость — источник доверия («правила не поменяют на ходу») и источник ужаса («баг не починить»).
Компромисс индустрии — прокси: адрес стабилен, логика подменяема. Механика держится на опкоде delegatecall:
контракт A исполняет код контракта B, но в своём контексте — своё хранилище, свой баланс, исходные
msg.sender и msg.value.
| Вариант | Кто делает апгрейд | Особенности |
|---|---|---|
| Transparent (ERC-1967) | админ через ProxyAdmin | админ и пользователь не могут вызывать одни функции; прокси толще |
| UUPS (ERC-1822) | upgradeToAndCall внутри реализации |
прокси дешевле; реализация без функции апгрейда = апгрейды потеряны навсегда |
| Beacon | обновление контракта-маяка | один апгрейд на сотни прокси сразу |
| Diamond (ERC-2535) | добавление и удаление фасетов | обходит лимит 24 КБ, сложность выше в разы |
- Конструктора у реализации нет — он бы писал в её собственный storage, до которого прокси не дотянется.
Вместо него
initialize()с модификаторомinitializer, вызываемая через прокси один раз и обязательно атомарно с деплоем: между деплоем и инициализацией кто-то успевает вклиниться. - В конструкторе реализации обязателен
_disableInitializers(). Иначе кто угодно вызоветinitialize()напрямую на реализации и станет её владельцем; для UUPS это прямой путь кupgradeToAndCallна самой реализации и превращению прокси в кирпич. - Раскладка storage только дополняется: новые поля строго в конец, ничего не удалять, не переставлять,
не менять тип. Для резерва держат
uint256[50] private __gap, проверяютforge inspect storageLayoutв CI. - Право апгрейда — главный риск протокола. Норма — мультисиг плюс таймлок: изменение объявляется публично и вступает в силу через 24–72 часа, чтобы у несогласных было время выйти.
Честно: прокси — не бесплатный обед. Он добавляет расход газа, усложняет верификацию, создаёт собственный класс уязвимостей и превращает «неизменяемый контракт» в «контракт, которому вы доверяете так же, как обычному SaaS». Если логика простая и завершённая, лучший апгрейд — его отсутствие.
12. Типичные ошибки и чеклист перед мейннетом
| Ошибка | Суть | Противоядие |
|---|---|---|
| Reentrancy | внешний вызов возвращается в контракт до обновления состояния | Checks-Effects-Interactions, nonReentrant |
Проверка tx.origin |
ломается при вызове через посредника | всегда msg.sender |
block.timestamp как случайность |
значение в пределах допуска выбирает валидатор | VRF или commit-reveal |
| Unbounded loop | цикл по растущему массиву перестаёт влезать в блок | pull-паттерн, пагинация, лимиты |
| Цена из спота DEX | манипулируется флеш-займом в одной транзакции | TWAP-оракул, несколько источников |
Нет проверки ok у call |
провал перевода воспринимается как успех | проверять всегда, пробрасывать причину |
| Деление до умножения | целочисленное деление теряет остаток | сначала умножить, потом делить |
Неограниченный approve |
компрометация контракта = потеря всего баланса токена | точная сумма, permit, отзыв |
Полный разбор — в следующей статье. Перед выходом в мейннет —
код: компилятор зафиксирован точной версией без ^; каждый внешний вызов идёт последним и его результат
проверяется; нет циклов по коллекциям, размер которых определяют пользователи; административные функции
перечислены и обоснованы. Тесты: 100% покрытия по веткам в денежном коде с объяснением каждого исключения;
фаззинг на все функции с числовым вводом; 3–7 инвариантов на платёжеспособность и целостность учёта; форк-тесты
на внешнюю интеграцию с закреплённым блоком; чистый slither. Деплой: сухой прогон на форке прошёл;
аргументы конструктора перепроверены вторым человеком; развёртывание и инициализация атомарны; владелец —
мультисиг, а не EOA деплоера; контракт верифицирован сразу; настроены мониторинг и алерты; отрепетирован план
на инцидент — кто нажимает pause и за сколько минут.
Итог
Смарт-контракт — детерминированная программа в общей базе данных, которая просыпается только от транзакции, платит за каждую операцию и не может быть исправлена после выхода. Эти четыре свойства объясняют весь инженерный стиль: pull вместо push, события вместо storage, инварианты вместо примеров, паранойя вместо оптимизма.
- Storage дорог, calldata и логи дёшевы. Состояние — минимальное, остальное отдавайте индексаторам.
- Порядок операций — это безопасность. Checks-Effects-Interactions не стилистика, а защита.
- Инварианты ловят то, чего вы не придумали. Самый высокий возврат на вложенное время во всём цикле.
- Апгрейдопригодность — доверие в обмен на возможность починить, и обмен должен быть осознанным.
- Верификация исходников обязательна. Неверифицированный контракт с чужими деньгами — красный флаг.
Источники: Solidity Documentation с разделом Security Considerations, Foundry Book, OpenZeppelin Contracts и Upgrades, Ethereum Yellow Paper, evm.codes как справочник опкодов с ценой газа, Smart Contract Best Practices от ConsenSys Diligence и EIPs — первоисточник по EIP-1967, EIP-2929, EIP-3529, EIP-6780.
Что дальше
Мы построили контракт, покрыли его тестами и вывели в сеть — но почти каждая механика упиралась в вопрос «а что если вызывающий недобросовестный?». Пора ответить на него системно.
Безопасность контрактов: reentrancy, переполнения, оракулы, аудит