Часть III · Глава 9 из 43 · Spurious Dragon

EIP-155: привязываем подпись к своей цепочке

Подпись Ethereum до EIP-155 говорила, кто подписал и что подписал — но никогда не говорила, для какой сети это было. Так что в точности одни и те же подписанные байты были валидны на каждой цепочке, разделяющей ваш адрес, и транзакцию можно было воспроизвести через форк вроде ETH/ETC. EIP-155 вплетает chainId в то, что вы подписываете, так что подпись оказывается заперта на одной цепочке.

Обновлено 23 июн. 2026 г. · 10 мин
Предполагается
  • цифровые подписи
  • что такое транзакция

Подпись доказывает две вещи: кто подписал и что он подписал. Ранние транзакции Ethereum подписывались над [nonce, gasPrice, gasLimit, to, value, data] — действие, но не сеть, для которой оно предназначалось. Это упущение кажется безобидным, пока не вспомнить, что ваш закрытый ключ выводит один и тот же адрес на каждой Ethereum-подобной цепочке. Так что идеально валидная подписанная транзакция на одной сети — это идеально валидная подписанная транзакция и на другой: скопируйте байты, отправьте их в другом месте, и они выполнятся снова. Это обернулось катастрофой при расколе ETH/ETC в 2016 году, когда две цепочки внезапно стали делить адреса, балансы и nonce. EIP-155 — это идея в одну строку, которая это чинит: поместить цепочку внутрь подписи. Давайте выведем её.

1 шаг Подпись без цепочки

Проблема: подпись, валидная везде

Чтобы отправить транзакцию, вы подписываете хеш её полей. До EIP-155 этими полями были просто [nonce, gasPrice, gasLimit, to, value, data], а подпись (v, r, s) использовала v = 27 или 28 — чисто идентификатор восстановления Дополнительный бит (чётность), хранимый в v, который позволяет проверяющему восстановить открытый ключ подписанта из r и s. До EIP-155 это было просто 27 или 28 — один бит, не несущий больше никакой информации. (recovery id), сообщающий проверяющему, какой из двух возможных открытых ключей был восстановлен. Нигде в подписанных данных нет ничего, что говорило бы: «эта транзакция для мейннета Ethereum». Так что подпись авторизует действие на любой цепочке, которая распознаёт ваш адрес.

одна подписанная транзакция, без привязки к цепи подписанная tx v = 27 / 28 Цепь A · ETH ✓ принято Цепь B · ETC ✓ повтор прошёл! те же подписанные байты валидны на любой цепи с вашим адресом
Подпись до EIP-155 покрывает действие, но не сеть. Поскольку ваш ключ даёт вам один и тот же адрес на каждой цепочке, идентичные подписанные байты валидны на всех них — так что транзакцию, разосланную в одной цепочке, можно скопировать и воспроизвести в другой.

Это атака воспроизведения Повторная рассылка валидно подписанной транзакции в другой цепочке (или после форка), где подписант никогда её не предполагал. Поскольку подпись всё ещё валидна, транзакция выполняется снова, перемещая средства, которые пользователь никогда не собирался туда перемещать. (replay attack). После хардфорка DAO у ETH и ETC была идентичная история вплоть до раскола, так что транзакция, сделанная вами в одной цепочке, мгновенно становилась воспроизводимой в другой — опустошая или дублируя переводы без какого-либо дополнительного подписания.

→ Шаг 2: делаем цепочку частью того, что вы подписываете.

2 шаг Подписываем и chainId

Вплетаем chainId в подписанные данные

Дайте каждой сети публичный номер — chainId Уникальное целое число, идентифицирующее цепочку: 1 для мейннета Ethereum, 11155111 для Sepolia, 61 для Ethereum Classic и так далее. Зарегистрировано, чтобы кошельки и узлы соглашались, для какой сети они подписывают. (мейннет — это 1, Ethereum Classic — 61). Теперь, вместо хеширования всего шести полей, хешируем список с добавленным chainId плюс две нулевые заглушки: [nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]. Именно весь этот список keccak-хешируется и подписывается. Поскольку chainId теперь внутри хеша, подписываемый дайджест отличается на каждой цепочке — подпись, вычисленная для chainId 1, просто не является подписью над дайджестом, который вычислил бы chainId 61.

chain id — в подписываемые данные rlp[nonce, gasPrice, gas, to, value, data] до — валидно на любой цепи rlp[ … , chainId, 0, 0] после — привязан к одному chainId keccak теперь свой на каждой цепи
Добавьте chainId (и две нулевые заглушки) к полям перед хешированием. chainId теперь часть подписываемого дайджеста, так что одна и та же транзакция даёт разный хеш — и, значит, требует разной подписи — на каждой цепочке.

Два хвостовых нуля — изящный трюк: они делают подписываемую структуру похожей на девятиэлементную транзакцию, чьи слоты (v, r, s) равны (chainId, 0, 0), переиспользуя уже существующую форму кодирования вместо изобретения новой.

→ Шаг 3: протаскиваем chainId внутрь v.

3 шаг Несём его в v

Кодируем chainId в v — с обратной совместимостью

Для нового поля нет места, так что переиспользуем v. Переопределим его как v = chainId·2 + 35 + parity, где parity — это бит восстановления 0/1. На мейннете (chainId 1) это даёт v = 37 или 38; получатель читает v, извлекает из него chainId и точно знает, какой дайджест восстановить перед восстановлением подписанта. И, что критично, любое унаследованное значение ниже этого диапазона: v = 27/28 по-прежнему означает «без защиты, без chainId», так что старые транзакции остаются валидными навсегда — они просто воспроизводимы, по собственному выбору.

почему это обратно совместимо: диапазоны v не пересекаются chainId·2 + 35 + parity → 37 or 38 27283738 legacy незащищённая 29–36 · пустой промежуток chainId 1 защищено chainId·2+35 всегда больше 28 — по v видна схема
chainId едет внутри v как chainId·2 + 35 + parity (так что chainId 1 → v равен 37 или 38). Получатель восстанавливает chainId из v; унаследованные транзакции с v = 27/28 по-прежнему валидируются, просто без защиты от воспроизведения.
Формула
v = chainId 1 · 2 + 35 2 + parity 3
  1. 1 идентификатор сети — 1 для мейннета, 61 для Ethereum Classic
  2. 2 смещение EIP-155; сохраняет валидность унаследованных v = 27/28
  3. 3 бит восстановления подписи, 0 или 1
Цепочка вварена в v, так что подпись восстанавливает верного подписанта только на своей собственной цепочке.

→ Шаг 4: смотрим, как не та цепочка её отклоняет.

4 шаг Не та цепочка — отклонено

Награда: воспроизведение проваливает восстановление подписанта

Теперь проследите за атакующим, копирующим вашу транзакцию с chainId-1 в цепочку с chainId 61. Эта цепочка валидирует, восстанавливая подписанный дайджест со своим собственным chainId, 61 — не 1. Хеш, который она строит, отличается от того, который вы действительно подписали, так что когда она запускает ECDSA-восстановление над вашими (r, s), выскакивающий открытый ключ — не ваш адрес. Транзакция выглядит подписанной каким-то случайным аккаунтом без средств, и она отклоняется. Подпись была криптографически вварена в цепочку 1; она не может путешествовать.

повторить в чужой цепи tx подписана для chainId 1 повтор → цепь · chainId 61 пересчитать хеш с 61 подписант ≠ отправитель ✗ отклонено цепь 61 пересчитывает хеш со своим id → recovery не удаётся → отклонено
При воспроизведении в chainId 61 цепочка перестраивает дайджест со своим собственным id, так что восстановление подписанта возвращает неверный адрес — не отправителя. Подпись совпадает только с той цепочкой, для которой она сделана, так что воспроизведение отклоняется.
04 Глубже Куда двигаться дальше