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 полубайта, а каждый узел адресуется хешем своего содержимого.
Почему не хеш-таблица? От структуры требуется не только поиск, но и компактное доказательство. Лёгкий клиент, не хранящий состояние, спрашивает «какой баланс у адреса 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 — стековая машина без регистров, с размером слова 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) уже дважды становился причиной массовых поломок. Не хардкодьте лимиты газа.
Частые ошибки
- Считать
view-функцию бесплатной всегда. Она бесплатна черезeth_call; вызванная из транзакции другим контрактом — платная как любой код. - Хранить в состоянии то, что нужно только UI — строки, URL, метаданные, историю. Это события и внешнее хранилище, см. децентрализованные хранилища.
- Циклы по массиву неограниченной длины. Массив вырос — функция перестала влезать в блок, контракт заблокирован навсегда. Нужен пагинированный или pull-вариант.
- Полагаться на
block.timestampкак на точное время. У proposer есть люфт в секунды: для интервалов в часы нормально, для розыгрышей и коротких окон нет. - Забыть про
chainIdпри подписи офчейн-сообщений. Подпись без домена EIP-712 переиспользуется в другой сети или другом контракте. - Отправлять ETH через
transfer(). Фиксированные 2300 газа ломаются о получателей с нетривиальнымreceive; используйтеcall{value: ...}("")с проверкой результата и защитой от повторного входа. - Считать
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, жизненный цикл, тестирование, деплой