Web3 и блокчейн Смарт-контракты: Solidity, жизненный цикл, тестирование, деплой
0%

Смарт-контракты: Solidity, жизненный цикл, тестирование, деплой

Смарт-контракты: 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. Путь вызова: от кнопки до записи в состояние

Три вывода. eth_call и реальная транзакция — не одно и то же: сухой прогон идёт на состоянии текущего блока, за время до включения оно успеет измениться. Мемпул публичен, поэтому любая логика «кто первый, того и приз» будет обыграна ботами. Revert стоит денег: откат возвращает состояние, но не газ.

3. Solidity: минимум, который надо держать в голове

Solidity — статически типизированный язык с синтаксисом от JavaScript и семантикой ближе к C++ с ручным управлением областями данных.

Категория Примеры Что важно
Целые uint8uint256, int256 с 0.8.0 переполнение по умолчанию ревертит
Адрес address, address payable 20 байт; payable нужен для отправки эфира
Фиксированные байты bytes1bytes32 дешевле динамических, влезают в слот
Динамические 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 байта. Компилятор укладывает переменные по порядку объявления, упаковывая соседние маленькие поля в один слот.

Раскладка переменных по слотам storage в Solidity

Упаковка экономит на чтении (три поля из одного слота — один 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, а CREATE2keccak256(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.

Прокси и delegatecall: где живёт код и где живёт состояние

Вариант Кто делает апгрейд Особенности
Transparent (ERC-1967) админ через ProxyAdmin админ и пользователь не могут вызывать одни функции; прокси толще
UUPS (ERC-1822) upgradeToAndCall внутри реализации прокси дешевле; реализация без функции апгрейда = апгрейды потеряны навсегда
Beacon обновление контракта-маяка один апгрейд на сотни прокси сразу
Diamond (ERC-2535) добавление и удаление фасетов обходит лимит 24 КБ, сложность выше в разы
  1. Конструктора у реализации нет — он бы писал в её собственный storage, до которого прокси не дотянется. Вместо него initialize() с модификатором initializer, вызываемая через прокси один раз и обязательно атомарно с деплоем: между деплоем и инициализацией кто-то успевает вклиниться.
  2. В конструкторе реализации обязателен _disableInitializers(). Иначе кто угодно вызовет initialize() напрямую на реализации и станет её владельцем; для UUPS это прямой путь к upgradeToAndCall на самой реализации и превращению прокси в кирпич.
  3. Раскладка storage только дополняется: новые поля строго в конец, ничего не удалять, не переставлять, не менять тип. Для резерва держат uint256[50] private __gap, проверяют forge inspect storageLayout в CI.
  4. Право апгрейда — главный риск протокола. Норма — мультисиг плюс таймлок: изменение объявляется публично и вступает в силу через 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, переполнения, оракулы, аудит

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов