Web3 и блокчейн Ethereum и EVM: аккаунты, транзакции, газ, состояние
0%

Ethereum и EVM: аккаунты, транзакции, газ, состояние

Ethereum и EVM: аккаунты, транзакции, газ, состояние

В прошлой статье мы разобрали, как сеть договаривается о порядке блоков. Но консенсус отвечает только на вопрос «какая цепочка правильная» — он ничего не говорит о том, что именно происходит внутри блока. Bitcoin отвечает на это скромно: «переложить монеты из одних выходов в другие». Ethereum отвечает амбициознее: «выполнить произвольную детерминированную программу». Эта статья — про внутренности второго ответа, без метафор про «всемирный компьютер»: реальная структура данных, реальный формат транзакции, реальный набор команд и реальный счётчик, который не даёт всему этому взорваться.

Ethereum как реплицированная машина состояний

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

S_(n+1) = Π(S_n, B_(n+1)),   где Π применяет по одной:  S' = Υ(S, T)

Три свойства делают конструкцию работоспособной.

Детерминизм. Υ обязана давать одинаковый результат на любой машине: та же арифметика, тот же порядок. Отсюда фиксированные целые числа без плавающей точки, отсутствие потоков, отсутствие системного времени (только block.timestamp из заголовка) и отсутствие сетевых вызовов. Контракт не может «сходить в HTTP» — за этим стоят оракулы, о них в статье про безопасность.

Верифицируемость. Любой узел переигрывает блок и проверяет, что заявленный stateRoot совпал. Это единственный способ доверять чужой работе, не доверяя исполнителю.

Ограниченность. Программа может зациклиться, а проблема остановки неразрешима. Ethereum не пытается её решать: он берёт плату за каждый шаг и останавливает исполнение, когда деньги кончились. Это и есть газ.

Цена конструкции высока и её стоит проговорить честно: каждый полный узел выполняет каждую транзакцию. Это не масштабирование, а репликация — сеть работает со скоростью одного узла плюс накладные расходы на криптографию и синхронизацию. Всё, что дальше называется «оптимизацией газа», — прямое следствие этого факта.

Два типа аккаунтов

Ethereum отказался от UTXO-модели Bitcoin (см. основы блокчейна) в пользу модели аккаунтов: состояние — это отображение «20-байтовый адрес → объект аккаунта».

Четыре поля — это весь аккаунт, больше в состоянии ничего нет:

Поле Что означает Практический смысл
nonce у EOA — число отправленных транзакций, у контракта — число созданных им контрактов защита от повторного проигрывания и источник детерминизма адресов при CREATE
balance баланс в wei (1 ETH = 10^18 wei) целое число, никакой плавающей точки
storageRoot корень дерева хранилища аккаунта у EOA — корень пустого дерева
codeHash keccak256 от кода у EOA — keccak256(""), то есть 0xc5d2...a470

Адреса получаются по-разному, и это важная деталь:

EOA:      address = keccak256(публичный ключ, 64 байта без префикса)[12:32]
CREATE:   address = keccak256(RLP([отправитель, nonce]))[12:32]
CREATE2:  address = keccak256(0xff ++ отправитель ++ salt ++ keccak256(initcode))[12:32]

CREATE2 (EIP-1014) даёт предсказуемый адрес до деплоя — на этом построены счётные кошельки, детерминированные фабрики пулов и паттерн «адрес депозита, который развернём позже». Обратная сторона: если initcode содержал SELFDESTRUCT, по одному адресу исторически можно было развернуть разный код. EIP-6780 (Cancun, 2024) оставил SELFDESTRUCT удаляющим аккаунт только в той же транзакции, где он создан, — класс сюрпризов почти закрыт.

Отдельно стоит EIP-7702 (Pectra, 2025): EOA может выставить себе указатель делегирования — короткий код 0xef0100 ++ адрес, и вызовы к EOA исполняют логику указанного контракта в контексте самого EOA. Граница «EOA не имеет кода» перестала быть абсолютной, и проверка «это точно человек, потому что code.length == 0» теперь ошибается. Подробнее — в статье про кошельки.

Состояние: почему дерево, а не хеш-таблица

Все аккаунты лежат в одной структуре — Merkle Patricia Trie (MPT): гибрид префиксного дерева и дерева Меркла, где ключ — keccak256(адрес), разложенный на 64 полубайта, а каждый узел адресуется хешем своего содержимого.

Модель состояния Ethereum: заголовок блока, дерево состояния, аккаунт, хранилище

Почему не хеш-таблица? От структуры требуется не только поиск, но и компактное доказательство. Лёгкий клиент, не хранящий состояние, спрашивает «какой баланс у адреса X в блоке N» и получает ответ вместе с цепочкой узлов от листа до корня — единицы килобайт вместо сотен гигабайт. Хеш-таблица такого не умеет.

Свойства с точки зрения сложности: поиск и вставка ограничены длиной ключа в 64 полубайта, то есть формально O(1), практически 6–9 обращений к диску на аккаунт из-за случайного распределения хешей; доказательство включения — O(глубина) узлов по 532 байта максимум (ветвящийся узел из 17 ссылок); обновление одного слота требует пересчёта всех узлов на пути до корня — это и есть скрытая цена SSTORE.

Ключи хешируются намеренно: иначе злоумышленник подобрал бы адреса с общим длинным префиксом и вырастил вырожденную ветку. Плата за защиту — потеря локальности: соседние по адресу аккаунты лежат в случайных местах дерева, и база превращается в поток случайных чтений. Поэтому синхронизация полного узла упирается в IOPS диска, а не в CPU. Разработка идёт в сторону деревьев с более дешёвыми доказательствами (Verkle на векторных обязательствах, затем бинарные деревья с SNARK-дружественным хешем), но это исследования, а не факт продакшена: смотрите исполняемые спецификации, а не статьи трёхлетней давности.

Транзакция изнутри

Транзакция — единственный способ изменить состояние, и всегда её инициирует EOA. Контракт сам по себе не просыпается: «крона в блокчейне» не существует, за регулярные вызовы платят офчейн-кееперы.

С EIP-2718 транзакция — типизированный конверт: первый байт задаёт тип, остальное — RLP-нагрузка. Legacy остались как исключение (начинаются с байта ≥ 0xc0, что RLP не путает с типом).

Тип EIP Что добавил
0x00 legacy nonce, gasPrice, gasLimit, to, value, data, v, r, s
0x01 2930 явный chainId и accessList — предоплата за «прогрев» адресов и слотов
0x02 1559 maxFeePerGas и maxPriorityFeePerGas вместо gasPrice
0x03 4844 maxFeePerBlobGas, blobVersionedHashes — блобы для роллапов
0x04 7702 authorizationList — делегирование кода на EOA
{
  "type": "0x2", "chainId": "0x1", "nonce": "0x11",
  "maxPriorityFeePerGas": "0x3b9aca00", "maxFeePerGas": "0x2540be400",
  "gasLimit": "0x186a0", "to": "0xdAC17F958D2ee523a2206206994597C13D831ec7", "value": "0x0",
  "data": "0xa9059cbb000000000000000000000000d8da6bf2...00000000000000000000000000000f4240",
  "accessList": [], "yParity": "0x1", "r": "0x8f2c...", "s": "0x3b71..."
}

Поле data — ABI-кодирование вызова transfer(address,uint256): четыре байта селектора (keccak256("transfer(address,uint256)")[0:4] = 0xa9059cbb) плюс аргументы, дополненные нулями до 32 байт. Разбор ABI — в статье про смарт-контракты.

RLP (Recursive Length Prefix) — примитивный, но однозначный формат: кодируются только байтовые строки и списки, никаких типов и имён полей.

0x00..0x7f      один байт кодирует сам себя
"dog"           0x83 'd' 'o' 'g'              (0x80 + длина строки)
["cat", "dog"]  0xc8 0x83 c a t 0x83 d o g    (0xc0 + длина нагрузки)

Однозначность критична: если бы значение кодировалось двумя способами, у одной транзакции было бы два хеша и защита от повторов рассыпалась бы. Поэтому RLP запрещает ведущие нули и требует минимальную форму длины.

Подпись и восстановление отправителя

В транзакции нет поля «отправитель»: адрес восстанавливается из подписи. Это экономит 20 байт в каждой транзакции и даёт три неочевидных последствия.

  • EIP-155. До 2016 года подпись не включала chainId, и валидная транзакция проигрывалась в любой совместимой сети. Сейчас chainId входит в подписываемые данные. Форк без смены chainId — не «совместимость», а дыра.
  • Пластичность подписи. EIP-2 требует s <= n/2, иначе из (r, s) делается вторая валидная подпись (r, n - s) с другим хешем транзакции. Детали — в статье про криптографию.
  • ecrecover не проверяет существование аккаунта. Он вернёт адрес для любой валидной пары, поэтому «подписал сообщение» и «владеет аккаунтом» — разные утверждения, а нулевой адрес из ecrecover в контракте нужно отсекать явно.

Жизненный цикл транзакции

Что из этого важно на практике. Nonce строго последователен: транзакция с nonce = 8 не выполнится, пока не выполнится седьмая. Классический инцидент бэкенда — сервис шлёт транзакции параллельно с разных горутин, получает от RPC один и тот же nonce, и всё, кроме одной, отваливается; лечится единым сериализованным менеджером nonce на отправителя. Застрявшую транзакцию нельзя отменить, только заменить: тот же nonce, нулевой value, to на себя и цена выше минимум на 10% (правило замены в пулах geth-подобных клиентов). status = 0 — не «ничего не произошло»: газ списан полностью до точки отката, провалившаяся транзакция стоит реальных денег. И включение не равно финальности — до финализации блок может уйти в реорганизацию, сколько ждать подтверждений разбиралось в статье про консенсус.

Блок: три корня и один заголовок

Заголовок — обязательство сразу к нескольким деревьям Меркла:

Поле Роль
parentHash связь в цепочку
stateRoot корень MPT после применения всех транзакций блока
transactionsRoot корень дерева транзакций — что именно исполнялось
receiptsRoot корень дерева квитанций — результат исполнения
logsBloom 2048-битный фильтр Блума по логам, чтобы клиент быстро отсеивал ненужные блоки
gasLimit, gasUsed, baseFeePerGas экономика блока
prevRandao случайность из слоя консенсуса (бывшее поле mixHash)
withdrawalsRoot, blobGasUsed, excessBlobGas, parentBeaconBlockRoot добавлены Shanghai и Cancun

Квитанция содержит status, cumulativeGasUsed, logsBloom и логи. Важный нюанс: логи не читаются из контрактов. LOG0..LOG4 пишут в квитанцию, а не в состояние, поэтому они дёшевы (375 газа за операцию плюс 375 за топик плюс 8 за байт против 20 000 за SSTORE), но доступны только офчейн — индексаторам и фронтенду. Отсюда фундаментальный приём: что нужно только читателям снаружи — в события, что нужно логике контракта — в хранилище. Про индексацию см. архитектуру dApp.

После The Merge узел физически разделён на два процесса: execution client (geth, nethermind, besu, erigon, reth) исполняет транзакции и держит состояние, consensus client (prysm, lighthouse, teku, nimbus, lodestar) отвечает за PoS. Общаются по Engine API поверх localhost с JWT-аутентификацией: engine_forkchoiceUpdated («вот голова цепи, готовь блок») и engine_newPayload («проверь этот блок»). Если нода отдаёт устаревшие данные — в девяти случаях из десяти рассинхронизировалась именно эта пара.

EVM: стековая машина на 256-битных словах

Контекст исполнения EVM: стек, память, calldata, returndata, код и хранилище

EVM — стековая машина без регистров, с размером слова 256 бит. Почему 256, а не 64? Потому что keccak256 выдаёт 256 бит, и адреса, и балансы в wei, и ключи в дереве — всё это 32-байтовые значения; машина спроектирована так, чтобы криптографические операции были естественными, а не составными. Плата за решение: арифметика на 256 бит на 64-битном хосте эмулируется в четыре машинных слова, и uint8 в Solidity не дешевле uint256, а иногда дороже — компилятору приходится вставлять маскирование.

Область Время жизни Адресация Цена
Stack фрейм вызова LIFO, 1024 слова, доступ к 16 верхним 3 газа
Memory фрейм вызова байтовая, линейная, обнуляется 3 газа + квадратичное расширение
Calldata транзакция байтовая, только чтение оплачена при отправке
Returndata до следующего CALL байтовая, только чтение 3 газа за копирование
Storage постоянно 2^256 слотов по 32 байта 100 / 2100 / 2900 / 20 000

Плюс transient storage (TSTORE/TLOAD, EIP-1153, Cancun) — по интерфейсу как storage, но живёт до конца транзакции и не попадает в дерево состояния. Ровно то, что нужно для флага защиты от повторного входа: 100 газа вместо 20 000 плюс 2900 за пару «запись — сброс».

Проверка JUMPDEST — не формальность, а защита: без неё можно прыгнуть в середину операнда PUSH32 и превратить данные в код, а статический анализ байткода стал бы невозможен.

Мини-EVM: модель в исполняемом виде

"""Учебный мини-EVM: стек, память, хранилище и честный счётчик газа."""
MOD = 2 ** 256                                   # вся арифметика по модулю 2^256

class OutOfGas(Exception): ...                   # весь газ фрейма сгорает
class Revert(Exception): ...                     # остаток газа возвращается вызывающему

def mem_cost(words: int) -> int:                 # линейная часть плюс квадратичный штраф
    return 3 * words + words * words // 512

class Frame:
    def __init__(self, code: bytes, gas: int):
        self.code, self.gas, self.pc = code, gas, 0
        self.stack: list[int] = []
        self.memory = bytearray()
        self.storage: dict[int, int] = {}
        self.warm: set[int] = set()               # прогретые слоты, EIP-2929

    def charge(self, amount: int) -> None:
        if self.gas < amount:
            raise OutOfGas
        self.gas -= amount

    def expand(self, offset: int, size: int) -> None:
        if size == 0:
            return
        need, have = (offset + size + 31) // 32, len(self.memory) // 32
        if need > have:
            self.charge(mem_cost(need) - mem_cost(have))
            self.memory.extend(b"\x00" * ((need - have) * 32))

def run(f: Frame) -> bytes:
    while f.pc < len(f.code):
        op, f.pc = f.code[f.pc], f.pc + 1
        if op == 0x00:                                        # STOP
            return b""
        elif 0x60 <= op <= 0x7F:                              # PUSH1..PUSH32
            n = op - 0x5F
            f.charge(3)
            f.stack.append(int.from_bytes(f.code[f.pc:f.pc + n], "big"))
            f.pc += n
        elif op == 0x01:                                      # ADD
            f.charge(3)
            f.stack.append((f.stack.pop() + f.stack.pop()) % MOD)   # молчаливое переполнение
        elif op == 0x52:                                      # MSTORE
            f.charge(3)
            off, val = f.stack.pop(), f.stack.pop()
            f.expand(off, 32)
            f.memory[off:off + 32] = val.to_bytes(32, "big")
        elif op == 0x54:                                      # SLOAD
            key = f.stack.pop()
            f.charge(100 if key in f.warm else 2100)
            f.warm.add(key)
            f.stack.append(f.storage.get(key, 0))
        elif op == 0x55:                                      # SSTORE
            key, val = f.stack.pop(), f.stack.pop()
            if key not in f.warm:
                f.charge(2100)                                # холодный доступ
                f.warm.add(key)
            old = f.storage.get(key, 0)
            f.charge(20_000 if old == 0 and val != 0 else 2_900)
            f.storage[key] = val
        elif op in (0xF3, 0xFD):                              # RETURN / REVERT
            off, size = f.stack.pop(), f.stack.pop()
            f.expand(off, size)
            data = bytes(f.memory[off:off + size])
            if op == 0xFD:
                raise Revert(data)
            return data
        else:
            raise ValueError(f"неизвестный опкод 0x{op:02x}")
    return b""

frame = Frame(bytes.fromhex("602a60005500"), gas=100_000)  # PUSH1 2a, PUSH1 00, SSTORE, STOP
run(frame)
assert frame.storage[0] == 42
assert 100_000 - frame.gas == 3 + 3 + 2100 + 20_000        # ровно 22 106 газа

Сложность цикла — O(k) по времени, где k — число выполненных опкодов, и O(m + s) по памяти, где m — размер линейной памяти, s — число тронутых слотов. Обратите внимание: k ограничен не таймаутом, а газом. Именно газ делает исполнение завершимым. А строчка (a + b) % MOD — не абстракция, а реальное поведение EVM: до Solidity 0.8 переполнение молча заворачивалось и на этом строились целые классы эксплойтов. Компилятор 0.8+ вставляет проверки и REVERT, но в unchecked-блоках и в inline-assembly вы снова один на один с модульной арифметикой.

Как выглядит скомпилированный контракт

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

contract Counter {
    uint256 public count;          // слот 0

    function increment() external {
        count += 1;                // SLOAD + ADD + SSTORE
    }
}

Начало runtime-байткода в дизассемблированном виде:

PUSH1 0x80 PUSH1 0x40 MSTORE              ; free memory pointer = 0x80
CALLVALUE DUP1 ISZERO PUSH2 .. JUMPI      ; функции не payable -> revert при value > 0
PUSH1 0x04 CALLDATASIZE LT PUSH2 .. JUMPI ; меньше 4 байт -> в fallback
PUSH0 CALLDATALOAD PUSH1 0xE0 SHR         ; достать 4-байтовый селектор
DUP1 PUSH4 0x06661abd EQ PUSH2 .. JUMPI   ; count()
DUP1 PUSH4 0xd09de08a EQ PUSH2 .. JUMPI   ; increment()
PUSH0 PUSH0 REVERT                        ; ни один селектор не подошёл

Отсюда видно сразу несколько вещей. Диспетчер — линейная цепочка сравнений, поэтому функция «дальше по списку селекторов» стоит на несколько десятков газа дороже (при большом числе функций компилятор строит двоичный поиск). PUSH0 (EIP-3855, Shanghai) стоит 2 газа вместо 3 у PUSH1 0x00 — мелочь, экономящая на каждом вызове. И главное: у контракта нет «функций», есть один вход и if по первым четырём байтам calldata; всё остальное — соглашение ABI. Разбирать опкоды удобно на evm.codes — там интерактивная таблица со стоимостью и отладчик.

Газ: экономика вычислений

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

ADD                       3        чистое вычисление
KECCAK256                 30 + 6 за слово
BALANCE тёплый/холодный   100 / 2600            чтение из кеша или с диска
SLOAD  тёплый/холодный    100 / 2100
SSTORE 0 -> X             20 000                новая запись в дерево навсегда
SSTORE X -> Y / X -> 0    2 900, во втором случае возврат до 4 800
LOG1                      375 + 375 + 8 за байт    в состояние не попадает
CREATE                    32 000 + 200 за байт кода

До первого опкода транзакция уже стоит внутренней (intrinsic) цены:

21 000                            базовая стоимость любой транзакции, +32 000 если это деплой
+ 4 за нулевой байт calldata      +16 за ненулевой байт
+ 2400 за адрес и 1900 за слот в accessList

С EIP-7623 (Pectra) добавился «пол» для тяжёлых по данным транзакций: gasUsed = 21000 + max(4·tokens + execution, 10·tokens), где tokens = нулевые_байты + 4·ненулевые. Смысл прямой: транзакции, которые почти не считают, но несут много данных, перестали быть искусственно дешёвыми — для больших объёмов есть блобы EIP-4844, о них в статье про масштабирование. Практический вывод, экономящий реальные деньги: нулевые байты calldata вчетверо дешевле ненулевых. Отсюда и адреса с ведущими нулями, и упаковка аргументов, и то, что для роллапов сжатие calldata — вопрос экономики, а не эстетики.

EIP-1559: базовая комиссия и сжигание

До London комиссия была слепым аукционом первой цены: угадал — попал в блок, не угадал — переплатил или ждал. Теперь у каждого блока есть baseFeePerGas, вычисляемая протоколом от заполненности предыдущего:

target        = gasLimit / 2
base_fee_next = base_fee * (1 + (gas_used - target) / target / 8)   # шаг максимум ±12.5%

effective_price = min(maxFeePerGas, baseFee + maxPriorityFeePerGas)
валидатору      = (effective_price - baseFee) * gasUsed
сжигается       = baseFee * gasUsed
возвращается    = (gasLimit - gasUsed) * effective_price

Три следствия. baseFee сжигается, а не платится валидатору — это убирает стимул накручивать комиссии и связывает эмиссию с нагрузкой. Цена предсказуема на несколько блоков вперёд, потому что шаг ограничен 12.5%, поэтому кошелёк ставит maxFeePerGas с запасом и не боится переплаты: разница возвращается. maxPriorityFeePerGas — реальный рычаг скорости: при заполненных блоках порядок внутри блока определяет билдер, туда же приходит MEV (см. архитектуру dApp).

Возвраты, правило 63/64 и OOG

Возврат газа остался только за освобождение хранилища: EIP-3529 срезал его с 15 000 до 4 800 и ограничил суммарный возврат величиной gasUsed / 5. Раньше на этом работали «газовые токены» — покупаешь хранилище дёшево, продаёшь дорого; класс приёмов закрыт намеренно. Правило 63/64 (EIP-150): вложенный вызов получает максимум 63/64 оставшегося газа, значит вызывающий всегда переживёт OOG в вызываемом и сможет обработать неудачу — и значит глубина вызовов на практике ограничена не 1024, а газом: после трёх десятков уровней остаток съедается. И запомните разницу: OOG сжигает весь газ, REVERT — нет, поэтому require с внятным сообщением дешевле для пользователя, чем «пусть само упадёт по газу».

# стоимость вызова прямо в сети, текущая базовая комиссия, профиль газа по строкам
cast estimate 0x5FbDB...aa3 "increment()" --rpc-url "$RPC"
cast base-fee --rpc-url "$RPC"
forge test --gas-report

eth_estimateGas — симуляция поверх текущего состояния. К моменту включения состояние изменится: тёплый слот окажется холодным, ветка if пойдёт иначе. Рабочее правило — буфер 15–30%, а для вызовов с внешними зависимостями больше. И помните, что gasLimit — только потолок: неизрасходованное вернётся, но будет заблокировано на время исполнения.

Раскладка хранилища: где на самом деле лежат переменные

Solidity раскладывает состояние по слотам детерминированно, и это не деталь реализации — от неё зависят и цена, и безопасность обновляемых контрактов.

contract Layout {
    uint128 a;      // слот 0, байты 0..15
    uint128 b;      // слот 0, байты 16..31  — упакованы вместе
    address owner;  // слот 1, байты 0..19
    bool paused;    // слот 1, байт 20       — влез в тот же слот
    uint256 big;    // слот 2 целиком
    mapping(address => uint256) balances;  // слот 3 зарезервирован, данных там нет
    uint256[] items;                       // слот 4 хранит длину
}
mapping:             slot(key) = keccak256(pad32(key) ++ pad32(p))
динамический массив: slot(i)   = keccak256(pad32(p)) + i
вложенный mapping:   keccak256(pad32(k2) ++ keccak256(pad32(k1) ++ pad32(p)))

Упаковка экономит газ, но не всегда. Два uint128 в одном слоте — одна запись вместо двух (около 20 000 экономии при первой записи), но если они пишутся в разных транзакциях, компилятор добавит чтение-маскирование-запись и экономия схлопнется. Упаковывайте то, что меняется вместе. Хранилище нельзя перебрать: итерации по mapping не существует, ключи не хранятся — хранятся только keccak-производные слоты, поэтому список ключей приходится вести отдельно, и это стоит газа. Порядок объявления — часть ABI обновляемого контракта: прокси-паттерны держат состояние в слотах прокси, и вставленная в середину переменная сдвигает всё следующее, повреждая состояние. Отсюда storage gaps и выделенные слоты по EIP-1967 вида keccak256("eip1967.proxy.implementation") - 1. Каноническое описание — в документации Solidity.

Вызовы между контрактами

Опкод msg.sender внутри Чьё хранилище меняется Можно менять состояние
CALL вызывающий контракт вызываемого да
STATICCALL вызывающий контракт ничьё нет, любая запись — revert
DELEGATECALL исходный msg.sender вызывающего да
CREATE / CREATE2 нового аккаунта да

DELEGATECALL — это «выполни чужой код на моих данных». На нём стоят все апгрейдируемые прокси и все библиотеки. На нём же произошёл инцидент Parity Multisig 2017 года: библиотека была обычным контрактом, её удалось инициализировать напрямую и вызвать SELFDESTRUCT — код исчез, а сотни кошельков, делегировавших к нему, превратились в кирпичи с замороженными средствами. Разбор подобных случаев — в статье про безопасность контрактов.

Две ловушки уровня EVM, о которые спотыкаются регулярно. Низкоуровневый call не бросает исключение, он возвращает bool; игнорирование этого булева даёт «успешную» транзакцию, в которой перевод не прошёл. Вызов несуществующего адреса возвращает success = true с пустым returndata, поэтому проверять надо либо extcodesize, либо длину ответа, либо и то и другое (с оговоркой, что во время конструктора extcodesize равен нулю).

Эволюция правил

Урок из ленты один: тарифы газа не константы. Код, «оптимальный» сегодня, после форка может оказаться посредственным, а хардкод лимита газа (.transfer() с его 2300) уже дважды становился причиной массовых поломок. Не хардкодьте лимиты газа.

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

  1. Считать view-функцию бесплатной всегда. Она бесплатна через eth_call; вызванная из транзакции другим контрактом — платная как любой код.
  2. Хранить в состоянии то, что нужно только UI — строки, URL, метаданные, историю. Это события и внешнее хранилище, см. децентрализованные хранилища.
  3. Циклы по массиву неограниченной длины. Массив вырос — функция перестала влезать в блок, контракт заблокирован навсегда. Нужен пагинированный или pull-вариант.
  4. Полагаться на block.timestamp как на точное время. У proposer есть люфт в секунды: для интервалов в часы нормально, для розыгрышей и коротких окон нет.
  5. Забыть про chainId при подписи офчейн-сообщений. Подпись без домена EIP-712 переиспользуется в другой сети или другом контракте.
  6. Отправлять ETH через transfer(). Фиксированные 2300 газа ломаются о получателей с нетривиальным receive; используйте call{value: ...}("") с проверкой результата и защитой от повторного входа.
  7. Считать estimateGas гарантией. Это прогноз по устаревшему состоянию, не более.

Итог

  • Ethereum — детерминированная машина состояний, реплицированная на тысячи узлов; состояние есть отображение «адрес → (nonce, balance, storageRoot, codeHash)», зафиксированное корнем MPT в заголовке каждого блока.
  • Транзакция — типизированный RLP-конверт без поля отправителя: адрес восстанавливается из подписи secp256k1, привязанной к chainId, а nonce задаёт строгий порядок и защищает от повторов.
  • EVM — стековая машина на 256-битных словах с пятью областями данных, различающимися временем жизни и ценой на четыре порядка: от 3 газа за операцию на стеке до 20 000 за новую запись в хранилище.
  • Газ решает проблему остановки экономически, а не алгоритмически, и служит аукционом за место в блоке: baseFee сжигается, приоритетная комиссия достаётся валидатору, остаток возвращается.
  • Всё, что кажется неудобным в EVM — 256 бит, дорогое хранилище, отсутствие итерации по mapping, отсутствие сетевых вызовов — прямые следствия требования, чтобы любой узел мог независимо перепроверить каждую транзакцию.

Источники

  • Ethereum Yellow Paper — формальная спецификация функции перехода, газа и опкодов.
  • ethereum/execution-specs — исполняемая спецификация на Python, самый честный ответ на «а как оно на самом деле».
  • evm.codes — интерактивная таблица опкодов со стоимостью и отладчик.
  • Ethereum Developer Docs — обзорная документация по аккаунтам, транзакциям, газу и EVM.
  • EIP-1559, EIP-2718, EIP-2929, EIP-3529, EIP-1153, EIP-7702.
  • Mastering Ethereum — Andreas Antonopoulos, Gavin Wood; местами устарела по газу, но модель объясняет отлично.
  • Layout of State Variables in Storage — раскладка хранилища в документации Solidity.

Что дальше

Мы разобрали машину. Дальше — язык, на котором для неё пишут, и полный путь от исходника до задеплоенного адреса: компиляция, тестирование, деплой и то, что ломается между ними.

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

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

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

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

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