Upgrade Airlock
Каждое обновление прокси проходит через две двери: период ожидания и допуск Chainlink CRE, который может получить только код, выдержавший симуляцию атаки.
English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Español · Français · Deutsch · Português (Brasil) · Русский · Tiếng Việt
Ключевые особенности · Как это работает · Правила · Chainlink CRE · В сети Sepolia · Быстрый старт · Модель безопасности
Обзор
Upgrade Airlock ставит перед каждым обновлением контракта две двери. Внешняя дверь — период ожидания, отсчитываемый в блоках. Внутренняя дверь открывается только по допуску, записанному в блокчейне. Автоматически это значит, что воркфлоу Chainlink CRE атаковал новую реализацию в симуляторе, опросив четыре независимых RPC-провайдера, ответили как минимум два и все ответы совпали, шаг консенсуса воркфлоу (consensusIdenticalAggregation) согласовал вердикт, а подписанный отчёт Clear оказался в блокчейне. Если CRE вообще не может ответить, ключ утверждающего (approver) может выдать допуск вручную с записанными доказательствами; обходчик всё равно оценивает такое предложение, пока оно входит в 20 последних id, а вето CRE или стража имеет приоритет. Вредоносная реализация, до которой доходит поиск атак, вместо этого получает подписанное Veto и не выполняется, даже если у атакующего есть ключ обновления; то, до чего ограниченный поиск не дотягивается, закрывает строгий режим с ручным ревью кода. Сегодня вердикты выносит официальный CRE CLI (cre workflow simulate --broadcast), который запускает воркфлоу на сервере; контракты и воркфлоу написаны в расчёте на DON, а для перехода туда потребуется новое развёртывание с продакшн-форвардером Chainlink. Проект создан для ETH Shenzhen 2026 на базе Ethereum Sepolia.
[!TIP] Онлайн-демо: upgrade-airlock.pages.dev · Демо-видео: upgrade-airlock.pages.dev/video (на упрощённом китайском и английском)
Ситуационный центр (war room) читает Ethereum Sepolia прямо из браузера, в режиме только для чтения. Каждое предложение изображено в виде шлюза: внешняя дверь фиксирует предложение, шлюзовая камера отсчитывает период ожидания, а внутренняя дверь остаётся запертой, пока в блокчейне не окажется допуск (Clear от Chainlink CRE или ручной допуск утверждающего, который ситуационный центр так и помечает). Veto выбрасывает предложение из шлюза — с указанием правила, хеша доказательств и транзакции. При первом посещении открывается выбор атаки: каждая карточка (шесть атак и честное обновление v2) воспроизводит самое свежее реальное предложение своего вида в Sepolia и помечена как повтор. Страница открывается на упрощённом китайском:
?lang=enпереключает её на английский, а?lang=zh-TW— на традиционный китайский. Транзакции, которые она показывает (предложения #1–#12), перечислены в доказательствах реального прогона.
Ключевые особенности
- Fail-closed по построению. Обновление выполняется только при наличии допуска в блокчейне. Пропущенный лог, сбой RPC, расхождение провайдеров или неудачный прогон оставляют предложение без допуска или под вето: обновление может задержаться, но никогда не пройдёт без проверки. Вето всегда имеет приоритет над допуском.
- Атака, а не чек-лист. Воркфлоу (при развёртывании в DON — на каждом узле) прогоняет обновление на реальном состоянии прокси через
eth_simulateV1у четырёх RPC-провайдеров. Посторонний адрес с пополненным балансом вызывает каждый селектор из нового байткода, аргументы с подстановкой держателя подаются в каждую новую функцию, а эмиссия, владение и балансы сравниваются до и после. Ответить должны как минимум два провайдера, и все ответы должны совпасть. - Вердикты привязаны к предложению. Шаг идентичного консенсуса воркфлоу согласует
{verdict, rule}. Отчёт содержит аттестацию EIP-712, привязанную к точному содержимому предложения (proposalDigest), к сети, к получателю и к предельному блоку, а также хеш доказательств, который любой может пересчитать по данным блокчейна. - Пропущенные логи подбираются. Cron-обходчик (sweeper) каждые 60 секунд проверяет 20 последних предложений и выносит вердикт по всем, которые пропустил log trigger, включая обновления, которым утверждающий (approver) выдал допуск вручную; его вето имеет приоритет. Предложение, вытесненное за пределы 20 последних id, больше не оценивается: оно остаётся без допуска, если только утверждающий не выдал его вручную (окно обхода).
- Работает в Ethereum Sepolia. Официальный CRE CLI выполнял воркфлоу на сервере и ответил в блокчейне на каждое предложение (двенадцать на 2026-10-25): одно честное обновление получило допуск и было выполнено, одиннадцать атак получили вето (R1, R2, R3, R3B, R7, R9). Все контракты верифицированы на Sourcify.
- Проверено сквозными тестами. В CI запускаются 117 тестов Foundry, 118 юнит-тестов воркфлоу и 387 из 394 самопроверок ситуационного центра (остальным 7 нужны результаты сборки forge и cast, которых в CI-задаче нет); e2e-скрипт на форке прогоняет весь цикл через симулятор CRE.
Проблема
Обновляемый прокси ставит весь протокол в зависимость от ключа обновления. Как только этот ключ утекает, атака занимает всего несколько шагов: развернуть вредоносную реализацию, перенаправить на неё прокси, выпустить токены или вывести средства. Такое происходило уже не раз:
- PAID Network, март 2021. С помощью ключей владельца прокси проверенный аудиторами код токена был заменён версией с функциями burn и mint, и атакующий выпустил около 59,5 млн PAID. (разбор инцидента CertiK)
- Ankr, декабрь 2022. С помощью скомпрометированного ключа деплоера атакующий заменил реализацию aBNBc на версию, которая выпускала токены без проверки авторизации: 60 трлн aBNBc. (отчёт Ankr)
- Radiant Capital, октябрь 2024. Атакующий, контролировавший 3 из 11 подписантов мультисига, захватил провайдер пулов кредитования и обновил реализацию пулов, выведя более $53 млн. (rekt.news)
Таймлок лишь заставляет атакующего подождать, и никто не обязан что-либо проверять, пока идёт ожидание. К тому же долгие задержки блокируют экстренные исправления. Upgrade Airlock заставляет время ожидания работать: обновление не может выполниться, пока его не пропустит независимая проверка (децентрализованная после развёртывания в CRE DON), и эта проверка автоматически запускается для каждого предложения.
Как это работает
sequenceDiagram
actor Owner as Ключ владельца (считаем, что он утёк)
participant Admin as AirlockAdmin
participant CRE as Воркфлоу Chainlink CRE
participant RPC as 4 RPC-провайдера
participant Fwd as KeystoneForwarder
participant Recv as AirlockReceiver
actor Anyone as Кто угодно
Owner->>Admin: proposeUpgrade(newImpl, data)
Note over Admin: Внешняя дверь: период ожидания 25 блоков, около 5 минут в Sepolia
Admin-->>CRE: Лог Proposed (EVM log trigger) или cron-обходчик каждые 60 с
CRE->>Admin: Чтения EVM на блоке предложения
loop На каждом узле (в симуляторе один, при развёртывании каждый узел DON), для каждого провайдера
CRE->>RPC: eth_getCode, затем симуляция атаки через eth_simulateV1
RPC-->>CRE: вердикт и правило
end
Note over CRE: Кворум: не менее 2 ответов, все совпадают, иначе Veto R6<br/>идентичный консенсус по вердикту и правилу<br/>хеш доказательств и аттестация EIP-712
CRE->>Fwd: writeReport
Fwd->>Recv: onReport(metadata, report)
alt Все проверки пройдены
Recv->>Admin: clear(id, evidenceHash)
else Нарушено правило
Recv->>Admin: veto(id, ruleHash) и attachReason(id, evidenceHash)
end
Anyone->>Admin: execute(id)
Note over Admin: Внутренняя дверь: ожидание истекло, допуск есть, вето нет, codehash не изменился
Admin->>Admin: ProxyAdmin.upgradeAndCall(proxy, newImpl, data)
- Предложение.
AirlockAdminявляется владельцемProxyAdminиз OpenZeppelin иOwnableтокена. Ключ владельца может только предлагать: он никогда не бывает стражем (guardian), утверждающим (approver) или оракулом допуска. Каждое предложение (обновление или изменение самого шлюза: стражи, задержка, цель break-glass) получаетreadyBlock = block.number + delayBlocks. Целью обновления или break-glass может быть только обычный код контракта: делегированный аккаунт EIP-7702, ключ которого мог бы перенаправить его после вердикта, отклоняется (DelegatedAccount). - Оценка. Log trigger CRE срабатывает на событие
Proposed. Изменение самого шлюза оценивается по данным, прочитанным из блокчейна (R7). Обновление сначала проходит статические правила (R5, R8), затем симуляцию атаки, привязанную к блоку предложения: слот реализации ERC-1967 прокси подменяется новым кодом, а новый посторонний адрес (выводится из хеша блока предложения) получает средства, как настоящий пользователь: токены от второго держателя и ETH. Держатель делает перевод, посторонний вызываетtransferFromбез allowance, а затем — каждый селектор, найденный в новом байткоде, с пятью вариантами аргументов. Каждый селектор вне стандартного интерфейса токена дополнительно вызывается с подстановкой держателя: держатель с балансом — в каждом из первых четырёх слотов аргументов, тестовая сумма — в другом слоте, посторонний адрес или прокси — в остальных. Эмиссия, владелец и каждый отслеживаемый баланс считываются до и после, баланс держателя — сразу после каждого вызова с подстановкой держателя; кроме того, проверяются собственные события каждого пробного вызова (с R1 по R4, R3B). Наконец, новый байткод проверяется на опкоды, которые в реальном блоке ведут себя иначе, чем в симуляции (R9). - Согласование. Каждый узел (в развёртывании DON — каждый узел DON; сегодня — симулятор CLI) запускает симуляцию у четырёх провайдеров. Ответить должны минимум два, и все ответы должны содержать один и тот же вердикт; в любом другом случае — вето (R6). Затем шаг консенсуса согласует идентичный
{verdict, rule}. - Отчёт. Воркфлоу хеширует канонический JSON доказательств, подписывает вердикт EIP-712 своим ключом аттестатора (секрет CRE) и записывает отчёт через форвардер.
AirlockReceiverпроверяет отправителя, актуальность, подпись и то, что вердикт привязан именно к этому предложению (proposalDigest), после чего вызывает вAirlockAdminclearлибоvetoиattachReason. - Выполнение. Кто угодно может вызвать
execute(id)после окончания периода ожидания. Вызов откатывается с ошибкойTooEarlyво время ожидания, сNotClearedбез допуска, сProposalVetoedпосле вето и сCodehashChanged, если по целевому адресу больше нет кода, который был предложен и проверен. Вето всегда имеет приоритет над допуском. - Обход. Cron trigger каждые 60 секунд перепроверяет последние предложения и выносит вердикт по каждому ожидающему предложению, на которое CRE ещё не ответил (
oracleJudged): лог, пропущенный триггером, событиеProposed, слишком большое для доставки, или обновление, которому утверждающий выдал допуск вручную. Его вето имеет приоритет над таким допуском.
Все сбои ведут в одну сторону. Пропущенный лог, сбой RPC, расхождение провайдеров или неудачный прогон оставляют предложение без допуска или накладывают на него вето, то есть задерживают обновление и никогда его не пропускают. Если CRE не может ответить вовсе, ключ утверждающего может вручную выдать обновлению допуск с зафиксированными доказательствами; обходчик всё равно оценит это обновление, как только CRE снова начнёт отвечать, а вето CRE или стража по-прежнему имеет приоритет. breakGlass() позволяет владельцу или любому стражу мгновенно переключить прокси на заранее зарегистрированную реализацию в режиме паузы. Выход из режима паузы — обновление через обе двери; допуск предложения не истекает, поэтому завершить паузу может и ранее допущенное обновление (известные ограничения).
Подробнее: docs/design.md (модель угроз, конечный автомат допуска, привязка отчёта, бюджеты) и cre/README.md (воркфлоу).
Правила
ruleHash = keccak256("GUARD/<ID>"), записывается в блокчейн при каждом вето.
| Правило | Вето, если | Чем определяется |
|---|---|---|
R1 TOTAL_SUPPLY |
вызов постороннего изменяет totalSupply |
симуляция атаки |
R2 OWNER |
вызов постороннего изменяет owner() |
симуляция атаки |
R3 TRANSFER_CONSERVATION |
перевод держателем суммы X перемещает не ровно X или затрагивает чей-то ещё баланс | симуляция атаки |
R3B THIRD_PARTY_FUNDS |
посторонний перемещает токены любого держателя: transferFrom пополненного постороннего адреса без allowance проходит успешно, либо один из его пробных вызовов перемещает чужие токены, выпускает или сжигает токены, передаёт владение или оставляет баланс держателя ниже прежнего — даже без события и даже если последующий вызов это отменяет |
симуляция атаки |
R4 TRANSFER_LIVENESS |
обычный перевод держателя не проходит | симуляция атаки |
R5 STRICT_CODEHASH |
включён строгий режим, а утверждающий не одобрил codehash | чтение из блокчейна |
R6 SIM_FAILURE |
симуляция не может вынести решение: провайдеры расходятся, отвечает слишком мало провайдеров, код не совпадает, откатывается view-вызов или пополнение постороннего адреса, либо пробные вызовы не умещаются в заданное конфигурацией число запросов | симуляция и кворум |
R7 CONFIG_GUARD |
смена стража, сокращение задержки или новая цель break-glass без предварительного одобрения утверждающего | чтение из блокчейна |
R8 INIT_DATA |
обновление содержит init data, которые утверждающий не одобрил вместе с codehash | чтение из блокчейна |
R9 NEW_OPCODES |
новый код содержит опкод контекста блока или транзакции (BASEFEE, TIMESTAMP, NUMBER, ORIGIN, GAS и другие), внешний вызов, create или selfdestruct, не покрытые фиксированным allowlist конфигурации (по умолчанию пустым), если только утверждающий не одобрил именно этот codehash | байткод |
R9 закрывает обход симуляции. EvilBaseFeeImpl ведёт себя как v1, пока block.basefee == 0 — а именно это видит вызов eth_simulateV1, — и позволяет кому угодно выпускать токены в реальном блоке. Все инварианты в симуляции выполняются; вето ему приносит опкод BASEFEE. Allowlist никогда не зависит от кода, который прокси исполняет сейчас, поэтому одобрение распространяется на один codehash и ни на одно последующее обновление. Сканер пропускает хвост метаданных компилятора, только если это точная CBOR-карта solc, недостижимая ни для одной инструкции; всё остальное сканируется как код. Одобрение codehash освобождает его от R9 и допускает усечение пробных вызовов (R6) — и только: все остальные правила продолжают действовать.
R3B закрывает тихий вывод средств. EvilDrainImpl — это v1 плюс rescue(from, to, amount) без контроля доступа. Эмиссия, владелец, переводы и опкоды у неё такие же, как у v1, поэтому правила с R1 по R4 и R9 проходят. rescue — не стандартная функция токена, поэтому посторонний вызывает её ещё и с подстановкой держателя; вызов с держателем в качестве from перемещает токены держателя, и R3B накладывает вето. Вывод средств, спрятанный за условием «только для вызывающих, у которых есть токены или ETH», тоже обнаруживается, потому что у постороннего есть и то и другое.
Автоматический и строгий режимы
Автоматический режим (по умолчанию): CRE самостоятельно выдаёт допуск или вето каждому обновлению по итогам ограниченного поиска атак — внешний вызывающий с пополненным балансом, каждый селектор нового кода и держатель в каждом из первых четырёх слотов аргументов каждой новой функции. Строгий режим (R5) добавляет проверку человеком: каждый новый codehash реализации сначала должен одобрить ключ утверждающего, а симуляция атаки всё равно выполняется. Автоматический режим подходит токену, которому нужен допуск рутинных обновлений за считанные минуты; строгий режим делает код-ревью частью допуска (два режима).
Chainlink CRE
CRE — оркестрирующий слой Upgrade Airlock: воркфлоу — единственный автоматический путь через внутреннюю дверь, и он ведёт каждый шаг от ончейн-триггера до ончейн-вердикта. Воркфлоу написан на CRE TypeScript SDK (@chainlink/cre-sdk 1.23.0) и скомпилирован в WASM с помощью CRE CLI v1.37.0.
| Технология Chainlink | Где используется | Роль в проекте |
|---|---|---|
| Раннер воркфлоу CRE | cre/veto/main.ts#L1-L9, cre/veto/workflow.yaml, cre/project.yaml |
Два обработчика, цели sepolia и sepolia-fork |
| EVM log trigger | cre/veto/workflow.ts#L202-L211, #L193-L198, #L54-L65 |
AirlockAdmin.Proposed на LATEST: предложение оценивается сразу после появления, за целый период ожидания до того, как его можно будет выполнить |
| Cron trigger | cre/veto/workflow.ts#L200, #L209, cre/veto/airlock.ts#L323-L389 |
Обходчик: 20 последних id, каждую минуту |
Чтение EVM (callContract) |
cre/veto/workflow.ts#L114-L122, #L137-L142, cre/veto/chain.ts |
Предложение, допуск, oracleJudged, одобрения и балансы — пакетами через Multicall3 на блоке предложения или на LATEST |
Чтение EVM (headerByNumber) |
cre/veto/workflow.ts#L125-L136 |
Последний блок и хеш блока предложения, из которого выводится посторонний адрес для симуляции |
| HTTP capability в режиме узла (node mode) | cre/veto/workflow.ts#L167-L189, #L80-L94, cre/veto/judge.ts#L157-L220 |
Каждый узел отправляет каждому провайдеру eth_getCode, затем один или два запроса eth_simulateV1 (каждый — в пределах лимита запроса 120 kb) |
consensusIdenticalAggregation |
cre/veto/workflow.ts#L184-L188, кворум в cre/veto/judge.ts#L244-L277 |
Идентичный консенсус по {verdict, rule} (не более 52 байт; при развёртывании — каждый узел DON) |
| Секреты (Secrets) | cre/veto/workflow.ts#L160-L166, cre/secrets.yaml |
Ключ аттестатора EIP-712, загружается один раз за прогон |
Отчёт + writeReport |
cre/veto/workflow.ts#L143-L149, cre/veto/airlock.ts#L229-L282, cre/veto/report.ts |
runtime.report (ECDSA, keccak256) и evm.writeReport, затем чтение предложения для подтверждения результата |
Форвардер + IReceiver |
src/cre/AirlockReceiver.sol#L88-L120, #L156-L169, src/cre/IReceiver.sol, script/DeployEth.s.sol#L142 |
onReport принимает отчёты только от настроенного форвардера и, если они заданы, проверяет workflow id и владельца воркфлоу |
| Селекторы сетей (chain selectors) | cre/veto/workflow.ts#L100-L104 |
ethereum-testnet-sepolia |
| CRE CLI | cre/setup.sh, cre/scripts/fork-e2e.sh, scripts/sepolia/airlock.sh, scripts/sepolia/sweep.sh |
cre workflow build, cre workflow simulate --broadcast |
Как воркфлоу работает сегодня. Воркфлоу запускается командой CRE CLI cre workflow simulate --broadcast в сети Ethereum Sepolia. Слушатель (scripts/sepolia/airlock.sh) держит log trigger в режиме прослушивания, обходчик (scripts/sepolia/sweep.sh) каждую минуту запускает cron-обработчик, а каждый Clear и Veto — реальная транзакция в Sepolia через MockKeystoneForwarder, развёрнутый Chainlink в Sepolia, 0x15fC6ae953E024d975e77382eEeC56A9101f9F88. AirlockReceiver в текущем виде готов к развёртыванию воркфлоу в DON: достаточно развернуть его с FORWARDER, указывающим на продакшн-KeystoneForwarder, и с заданными WORKFLOW_ID / WORKFLOW_OWNER — тогда onReport дополнительно проверяет подписанные DON метаданные воркфлоу. DeployEth отказывается использовать permissionless MockKeystoneForwarder, если только не заданы оба значения или явно не указан ALLOW_MOCK_FORWARDER=1 (script/DeployEth.s.sol#L96-L104).
Спроектирован под лимиты CRE на один прогон. Не более 15 чтений из блокчейна (пакеты Multicall3 укладываются в лимит payload 5 kb), 12 из 15 HTTP-вызовов при четырёх провайдерах, одна симуляция атаки за прогон и значение консенсуса не более 52 байт. Бюджеты описаны в docs/design.md.
В сети Sepolia
Ethereum Sepolia, chain id 11155111. Развёртывание выполнено скриптом script/DeployEth.s.sol в блоках 11950684–11950699 (13 транзакций; AirlockAdmin в блоке 11950697); все контракты верифицированы на Sourcify. Владелец, утверждающий, аварийный страж и аттестатор — четыре разных ключа. Период ожидания — 25 блоков.
| Контракт | Адрес |
|---|---|
| AirlockAdmin | 0x28e7aAC40560804F480e454bF9C12F50C83b700B |
| AirlockReceiver | 0xE40D463116F4Aa24d5E79776a65ED0f1b088c670 |
| Прокси DemoBTC (dBTC) | 0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71 |
| ProxyAdmin | 0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E |
| DemoBTC v1 | 0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108 |
| DemoBTC v2 (честное обновление) | 0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3 |
| EvilMintImpl | 0x01502c4af90c27f53B2E1293941670C6B761CAAA |
| EvilOwnerImpl | 0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488 |
| EvilFeeImpl | 0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5 |
| EvilBaseFeeImpl | 0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310 |
| EvilDrainImpl | 0x1d98D64BC5204AE139B5A0747855841Fa5333b6d |
| PausedImpl (цель break-glass) | 0xb6573006fB607E96966d0F578B96AAB25413F5BE |
| Chainlink MockKeystoneForwarder | 0x15fC6ae953E024d975e77382eEeC56A9101f9F88 |
Первый прогон: семь предложений — семь вердиктов в блокчейне (последующие #8–#12 перечислены в docs/evidence/sepolia-live.md). Блок в каждой ссылке — это блок, в который попала транзакция; (+n) показывает, через сколько блоков после предложения пришёл вердикт CRE.
| Карточка | Предложение | Вердикт CRE | Tx предложения | Tx отчёта | Затем |
|---|---|---|---|---|---|
v2: честное обновление |
#4 | Clear | 11950745 | 11950747 (+2) | execute 11950772, version() = "2" |
evil-mint: выпускать токены может кто угодно |
#1 | Veto R1 | 11950735 | 11950736 (+1) | execute откатывается с ProposalVetoed |
evil-owner: кто угодно может забрать владение |
#6 | Veto R2 | 11950788 | 11950790 (+2) | execute откатывается с ProposalVetoed |
evil-fee: с каждого перевода удерживается 10% |
#7 | Veto R3 | 11950792 | 11950794 (+2) | execute откатывается с ProposalVetoed |
evil-basefee: выпуск токенов открывается в реальном блоке |
#3 | Veto R9 | 11950742 | 11950743 (+1) | execute откатывается с ProposalVetoed |
evil-drain: кто угодно перемещает токены держателя |
#2 | Veto R3B | 11950737 | 11950740 (+3) | execute откатывается с ProposalVetoed |
remove-receiver: убрать CRE из стражей |
#5 | Veto R7 | 11950749 | 11950750 (+1) | execute откатывается с ProposalVetoed |
Все вердикты выше вынес официальный CRE CLI, выполнявший этот воркфлоу на сервере (слушатель log trigger плюс 60-секундный обходчик, scripts/sepolia/systemd/); собственные строки лога воркфлоу приведены в docs/evidence/sepolia-live.md.
Быстрый старт
Требования
- Foundry (
forge,cast,anvil),jqи Node 22. - Аккаунт CRE для симулятора (
cre login); он нужен для сквозного прогона на форке и для операторского набора. cre/setup.shустанавливает CRE CLI v1.37.0 (с зафиксированным SHA-256) и bun 1.4.2 вcre/.tools.
Сборка и тесты
git clone https://gitlab.com/jiapunk/upgrade-airlock.git # или https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
cd upgrade-airlock
# контракты (OpenZeppelin и forge-std уже включены в lib/)
forge build # однократно загружает зафиксированную версию solc 0.8.37
forge test --offline # 117 тестов
# воркфлоу CRE
cre/setup.sh
export PATH="$PWD/cre/.tools/bin:$PWD/cre/.tools/node_modules/.bin:$PATH"
(cd cre/veto && bun test && bun run typecheck) # 118 тестов: правила, пробные вызовы, байткод, отчёты, бюджеты, обработчики
# ситуационный центр
(cd web && npm ci && npm run selftest) # 394 проверки: режим реального времени и выбор атаки, офлайн-воспроизведение, API оператора
Сквозной прогон на форке Sepolia
cre login # один раз; симулятору нужен аккаунт CRE
cre/scripts/fork-e2e.sh # локальный anvil-форк Sepolia: в реальную сеть ничего не отправляется
cre/scripts/fork-e2e.sh разворачивает стек на форке, запускает воркфлоу в симуляторе CRE с --broadcast и проверяет каждый результат в блокчейне:
- Log trigger: EvilMint получает вето R1, EvilOwner — R2, EvilFee — R3, EvilBaseFee — R9 (с указанием
BASEFEE), EvilDrain — R3B (с указаниемrescue, при том что все остальные правила проходят); ещё семь вариантов вывода средств (test/fixtures/DrainVariants.sol) получают вето только по R3B: переставленные аргументы, проверка адреса токена, вывод, доступный лишь вызывающим с токенами или ETH, вариант без событияTransferиtransferFrom, который для держателей токенов пропускает проверку allowance; честный v2 получает допуск и выполняется после периода ожидания; удаление получателя из стражей получает вето R7; сокращение задержки, заранее одобренное утверждающим, получает допуск. - Обходчик: обновление, по которому никто не вынес вердикт, после периода ожидания откатывается с
NotCleared, один прогон обходчика выдаёт ему допуск, и оно выполняется; два предложения, которым утверждающий выдал допуск вручную, оцениваются следующим прогоном: EvilDrain получает вето R3B поверх допуска, а увеличение задержки CRE тоже допускает; обновление с 6 KB init data получает вето R8 на последующем прогоне. - Доказательства: каждое предложение под вето откатывается с
ProposalVetoed, а хеш доказательств каждого вердикта пересчитывается по данным блокчейна (cre/veto/tools/check-evidence.ts).
Операторский набор для Sepolia
scripts/sepolia/ — скрипты, на которых работает онлайн-демо. Ключи хранятся вне репозитория, по одному hex-ключу на файл в KEY_DIR (owner.key, attester.key, sender.key, approver.key, emergency.key, а также необязательный sweeper.key); скрипты никогда их не выводят, а deploy.sh отказывается работать, если владелец, утверждающий, аварийный страж и аттестатор — не четыре разных ключа. Каждый скрипт можно запустить и на локальном форке с FORK=<port>, так что демо можно отрепетировать без каких-либо затрат.
scripts/sepolia/deploy.sh # 13 транзакций, записывает deployments/sepolia.json (FORCE=1 заменяет существующий)
bun scripts/sepolia/make-config.mjs # конфигурация воркфлоу, 4 провайдера, кворум 2
scripts/sepolia/fund-sender.sh 0.05 # ETH для транзакций с отчётами
scripts/sepolia/airlock.sh # терминал A: слушатель CRE (log trigger)
scripts/sepolia/propose.sh v2 # терминал B: затем evil-mint, evil-owner, evil-fee, evil-basefee, evil-drain, remove-receiver
scripts/sepolia/execute.sh <id> # выполняет допущенное предложение после периода ожидания, иначе показывает откат
scripts/sepolia/status.sh # все предложения: вердикт, оценено ли CRE, правило, доказательства, транзакции
sweep.sh однократно запускает cron-обходчик, clear.sh — ручной допуск утверждающего, fork.sh запускает, сохраняет и восстанавливает форк Sepolia, probe-rpcs.ts проверяет каждого провайдера собственным кодом оценки воркфлоу, а systemd/ запускает слушатель и обходчик на Linux-сервере.
Ситуационный центр
cd web
npm ci
npm run dev # http://127.0.0.1:5173/
npm run build:airlock # синхронизирует deployments/sepolia.json в страницу, собирает web/dist-airlock
Страница читает Sepolia напрямую, не хранит ключей и никогда ничего не подписывает и не отправляет. Она показывает каждое предложение в виде шлюза: период ожидания, допуск CRE, вето с правилом и доказательствами. Посетитель выбирает одну из семи карточек (шесть атак и честное обновление v2) и смотрит помеченный повтор самого свежего реального предложения этого вида в Sepolia (или живое предложение, пока оно в периоде ожидания) — см. выбор атаки. Для докладчика есть режим оператора (web/operator/, web/functions/): на dev-сервере он принимает только loopback-подключения, на Cloudflare Pages требует токен и выключен, пока не заданы оба секрета OWNER_KEY и OPERATOR_TOKEN. Он отправляет одно из тех же семи фиксированных предложений через POST /api/operator/propose, подписывая его ключом владельца на стороне сервера (дизайн и так исходит из того, что этот ключ утечёт: он может только предлагать); страница обычного посетителя его никогда не вызывает — см. режим оператора.
| Параметр URL | Действие |
|---|---|
?mode=airlock |
По умолчанию. Просмотр Ethereum Sepolia в реальном времени, только чтение |
?mode=offline |
Воспроизводит записанный прогон вообще без сетевого подключения |
?lang=en, ?lang=zh-TW, ?lang=zh-CN |
Язык интерфейса (по умолчанию zh-CN; клавиша L переключает их по кругу) |
?rpc=http://127.0.0.1:<port> |
Читает вместо этого локальный форк Sepolia — для репетиций |
?op=1 |
Режим оператора: запрашивает токен оператора (только для докладчика) |
В режиме реального времени нажмите P, чтобы воспроизвести последние предложения по их событиям в блокчейне, и 1–7, чтобы воспроизвести карточку из выбора атаки.
Структура репозитория
src/AirlockAdmin.sol две двери: предложения, период ожидания, вето стража, допуск, execute, breakGlass
src/cre/AirlockReceiver.sol получатель отчётов CRE: форвардер, метаданные воркфлоу, аттестация EIP-712, привязка digest
src/cre/IReceiver.sol интерфейс получателя Chainlink CRE
src/DemoBTC.sol, DemoBTCV2.sol демо-токен (dBTC) v1 и честный v2
src/PausedImpl.sol цель break-glass: любое изменение баланса откатывается
src/attack/ EvilMintImpl, EvilOwnerImpl, EvilFeeImpl, EvilBaseFeeImpl, EvilDrainImpl
script/DeployEth.s.sol развёртывание (13 транзакций) с проверками ролей, форвардера и связей после деплоя
test/ тесты Foundry; fixtures/DrainVariants.sol содержит семь вариантов вывода средств
cre/veto/ воркфлоу CRE: workflow.ts (capabilities), airlock.ts (обработчики), judge.ts (правила,
кворум), simulate.ts (eth_simulateV1), bytecode.ts (селекторы, R9), report.ts
(отчёт, EIP-712, доказательства), chain.ts (бюджет чтений), keys.ts, rules.ts, config.ts
cre/veto/test/ юнит-тесты Bun
cre/veto/tools/ check-evidence.ts пересчитывает каждый ончейн-хеш доказательств
cre/scripts/fork-e2e.sh сквозной прогон на anvil-форке Sepolia
cre/setup.sh зафиксированные версии CRE CLI и bun
scripts/sepolia/ операторский набор для онлайн-демо, с unit-файлами systemd для сервера
web/ ситуационный центр (Vite, TypeScript, viem)
web/operator/, web/functions/ режим оператора: семь фиксированных предложений, подписанных на сервере (dev-сервер, Pages Functions)
deployments/sepolia.json действующее развёртывание
docs/design.md модель угроз, конечный автомат допуска, привязка отчёта, бюджеты
docs/evidence/ запись реального прогона
.github/workflows/ci.yml проверки контрактов, воркфлоу и ситуационного центра
Модель безопасности
Upgrade Airlock исходит из того, что ключ владельца утечёт, и построен так, чтобы одного ключа владельца никогда не хватало для выполнения обновления.
| Роль | Кто | Что может |
|---|---|---|
| Владелец (owner) | один ключ | предлагать обновления и изменения самого шлюза (стражи, задержка, цель break-glass); breakGlass |
| Утверждающий (approver) | отдельный ключ | вручную выдавать допуск, когда CRE не может ответить; одобрять codehash, init data и изменения шлюза (approveCodehash) |
| Аварийный страж (emergency guardian) | отдельный ключ | налагать veto на ожидающее предложение; breakGlass |
| Аттестатор (attester) | секрет CRE, отдельный ключ | подписывать вердикты EIP-712, которые принимает AirlockReceiver |
| AirlockReceiver | получатель отчётов CRE | оракул допуска (clear) и страж (veto, attachReason) |
| Кто угодно | вызывать execute для предложения, прошедшего обе двери |
Роли никогда не пересекаются. Конструктор AirlockAdmin отклоняет владельца, который одновременно является утверждающим, стражем или оракулом допуска (RoleOverlap), а DeployEth выполняет развёртывание только тогда, когда владелец, утверждающий, аварийный страж и аттестатор — четыре разных адреса.
Утёкший ключ владельца может предлагать. Каждое предложение у всех на виду проходит период ожидания, пока CRE его оценивает, и требует допуска, который владелец не может выдать себе сам. Владелец также может задействовать аварийный тормоз (breakGlass), который лишь переключает прокси на заранее зарегистрированную реализацию в режиме паузы; выход из этого режима — обновление через обе двери, и завершить его может также ранее допущенное, но так и не выполненное обновление.
Тот же ключ не может:
- выполнить обновление без допуска: у
AirlockAdminнет универсального пути вызова, и единственный способ сменить реализацию —executeдопущенного обновления (илиbreakGlassна реализацию в режиме паузы); - сначала ослабить шлюз: удаление получателя CRE, сокращение задержки или замена цели break-glass — это тоже предложения, и без предварительного одобрения утверждающего R7 накладывает на них вето;
- подменить код после вердикта:
executeперепроверяет codehash цели (CodehashChanged), а делегированные аккаунты EIP-7702 отклоняются (DelegatedAccount); - подделать или повторно использовать вердикт:
AirlockReceiverпринимает отчёт только от настроенного форвардера, только подписанный аттестатором, не позднее егоvalidUntilBlockи привязанный кproposalDigestпредложения в этой сети и для этого получателя.
Ручной допуск утверждающего — это ручное переопределение на случай, когда CRE не может ответить: обходчик всё равно оценивает такое предложение, пока оно входит в 20 последних id, а вето CRE или стража имеет приоритет. Полная модель угроз — в docs/design.md.
Это демонстрационные контракты для тестовой сети с известными ограничениями, каждое из которых закреплено проходящим тестом: допуски не истекают, ключ владельца нельзя сменить (украденный ключ может снова ставить токен на паузу после каждого снятия паузы), а один недобросовестный страж может держать токен на паузе. Список — в docs/design.md.
Технологии
- Solidity 0.8.37 и Foundry, с OpenZeppelin Contracts 5.7.0 (
ProxyAdmin,TransparentUpgradeableProxy,EIP712,ECDSA) - Chainlink CRE: TypeScript SDK (
@chainlink/cre-sdk1.23.0) и CRE CLI v1.37.0 на Bun 1.4.2 - viem для ABI-кодирования и чтения блокчейна — в воркфлоу и в ситуационном центре
- Vite и TypeScript для ситуационного центра
Инструменты ИИ
Upgrade Airlock создан под руководством автора с помощью Claude Code: с ним написаны и проверены контракты, воркфлоу CRE, тесты, скрипты, ситуационный центр и эта документация.
Лицензия
MIT © 2026 jiapunk
Source
| 1 | <div align="center"> |
| 2 | |
| 3 | # Upgrade Airlock |
| 4 | |
| 5 | **Каждое обновление прокси проходит через две двери: период ожидания и допуск Chainlink CRE, который может получить только код, выдержавший симуляцию атаки.** |
| 6 | |
| 7 | [English](README.md) · [简体中文](README.zh-CN.md) · [繁體中文](README.zh-TW.md) · [日本語](README.ja.md) · [한국어](README.ko.md) · [Español](README.es.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [Português (Brasil)](README.pt-BR.md) · **Русский** · [Tiếng Việt](README.vi.md) |
| 8 | |
| 9 | [](https://gitlab.com/jiapunk/upgrade-airlock) |
| 10 | [](LICENSE) |
| 11 | [](https://upgrade-airlock.pages.dev) |
| 12 | [](https://upgrade-airlock.pages.dev/video/) |
| 13 | [](https://sepolia.etherscan.io/address/0x28e7aAC40560804F480e454bF9C12F50C83b700B) |
| 14 | [](#chainlink-cre) |
| 15 | |
| 16 | [Ключевые особенности](#highlights) · [Как это работает](#how-it-works) · [Правила](#rules) · [Chainlink CRE](#chainlink-cre) · [В сети Sepolia](#live-on-sepolia) · [Быстрый старт](#quick-start) · [Модель безопасности](#security-model) |
| 17 | |
| 18 | </div> |
| 19 | |
| 20 | ## Обзор |
| 21 | |
| 22 | Upgrade Airlock ставит перед каждым обновлением контракта две двери. **Внешняя дверь** — период ожидания, отсчитываемый в блоках. **Внутренняя дверь** открывается только по допуску, записанному в блокчейне. Автоматически это значит, что воркфлоу Chainlink CRE атаковал новую реализацию в симуляторе, опросив четыре независимых RPC-провайдера, ответили как минимум два и все ответы совпали, шаг консенсуса воркфлоу (`consensusIdenticalAggregation`) согласовал вердикт, а подписанный отчёт **Clear** оказался в блокчейне. Если CRE вообще не может ответить, ключ утверждающего (approver) может выдать допуск вручную с записанными доказательствами; обходчик всё равно оценивает такое предложение, пока оно входит в 20 последних id, а вето CRE или стража имеет приоритет. Вредоносная реализация, до которой доходит поиск атак, вместо этого получает подписанное **Veto** и не выполняется, даже если у атакующего есть ключ обновления; то, до чего ограниченный поиск не дотягивается, закрывает строгий режим с ручным ревью кода. Сегодня вердикты выносит официальный CRE CLI (`cre workflow simulate --broadcast`), который запускает воркфлоу на сервере; контракты и воркфлоу написаны в расчёте на DON, а для перехода туда потребуется новое развёртывание с продакшн-форвардером Chainlink. Проект создан для ETH Shenzhen 2026 на базе Ethereum Sepolia. |
| 23 | |
| 24 | > [!TIP] |
| 25 | > **Онлайн-демо: [upgrade-airlock.pages.dev](https://upgrade-airlock.pages.dev)** · **Демо-видео: [upgrade-airlock.pages.dev/video](https://upgrade-airlock.pages.dev/video/)** (на упрощённом китайском и английском) |
| 26 | > |
| 27 | > Ситуационный центр (war room) читает Ethereum Sepolia прямо из браузера, в режиме только для чтения. Каждое предложение изображено в виде шлюза: **внешняя дверь** фиксирует предложение, **шлюзовая камера** отсчитывает период ожидания, а **внутренняя дверь** остаётся запертой, пока в блокчейне не окажется допуск (Clear от Chainlink CRE или ручной допуск утверждающего, который ситуационный центр так и помечает). Veto выбрасывает предложение из шлюза — с указанием правила, хеша доказательств и транзакции. При первом посещении открывается выбор атаки: каждая карточка (шесть атак и честное обновление v2) воспроизводит самое свежее реальное предложение своего вида в Sepolia и помечена как повтор. Страница открывается на упрощённом китайском: [`?lang=en`](https://upgrade-airlock.pages.dev/?lang=en) переключает её на английский, а [`?lang=zh-TW`](https://upgrade-airlock.pages.dev/?lang=zh-TW) — на традиционный китайский. Транзакции, которые она показывает (предложения #1–#12), перечислены в [доказательствах реального прогона](docs/evidence/sepolia-live.md). |
| 28 | |
| 29 | ## <a id="highlights"></a>Ключевые особенности |
| 30 | |
| 31 | - **Fail-closed по построению.** Обновление выполняется только при наличии допуска в блокчейне. Пропущенный лог, сбой RPC, расхождение провайдеров или неудачный прогон оставляют предложение без допуска или под вето: обновление может задержаться, но никогда не пройдёт без проверки. Вето всегда имеет приоритет над допуском. |
| 32 | - **Атака, а не чек-лист.** Воркфлоу (при развёртывании в DON — на каждом узле) прогоняет обновление на реальном состоянии прокси через `eth_simulateV1` у четырёх RPC-провайдеров. Посторонний адрес с пополненным балансом вызывает каждый селектор из нового байткода, аргументы с подстановкой держателя подаются в каждую новую функцию, а эмиссия, владение и балансы сравниваются до и после. Ответить должны как минимум два провайдера, и все ответы должны совпасть. |
| 33 | - **Вердикты привязаны к предложению.** Шаг идентичного консенсуса воркфлоу согласует `{verdict, rule}`. Отчёт содержит аттестацию EIP-712, привязанную к точному содержимому предложения (`proposalDigest`), к сети, к получателю и к предельному блоку, а также хеш доказательств, который любой может пересчитать по данным блокчейна. |
| 34 | - **Пропущенные логи подбираются.** Cron-обходчик (sweeper) каждые 60 секунд проверяет 20 последних предложений и выносит вердикт по всем, которые пропустил log trigger, включая обновления, которым утверждающий (approver) выдал допуск вручную; его вето имеет приоритет. Предложение, вытесненное за пределы 20 последних id, больше не оценивается: оно остаётся без допуска, если только утверждающий не выдал его вручную ([окно обхода](docs/design.md#the-sweep-window)). |
| 35 | - **Работает в Ethereum Sepolia.** Официальный CRE CLI выполнял воркфлоу на сервере и ответил в блокчейне на каждое предложение (двенадцать на 2026-10-25): одно честное обновление получило допуск и было выполнено, одиннадцать атак получили вето (R1, R2, R3, R3B, R7, R9). Все контракты верифицированы на Sourcify. |
| 36 | - **Проверено сквозными тестами.** В [CI](.github/workflows/ci.yml) запускаются 117 тестов Foundry, 118 юнит-тестов воркфлоу и 387 из 394 самопроверок ситуационного центра (остальным 7 нужны результаты сборки forge и cast, которых в CI-задаче нет); e2e-скрипт на форке прогоняет весь цикл через симулятор CRE. |
| 37 | |
| 38 | ## Проблема |
| 39 | |
| 40 | Обновляемый прокси ставит весь протокол в зависимость от ключа обновления. Как только этот ключ утекает, атака занимает всего несколько шагов: развернуть вредоносную реализацию, перенаправить на неё прокси, выпустить токены или вывести средства. Такое происходило уже не раз: |
| 41 | |
| 42 | - **PAID Network, март 2021.** С помощью ключей владельца прокси проверенный аудиторами код токена был заменён версией с функциями burn и mint, и атакующий выпустил около 59,5 млн PAID. ([разбор инцидента CertiK](https://www.certik.com/blog/paid-network-post-mortem)) |
| 43 | - **Ankr, декабрь 2022.** С помощью скомпрометированного ключа деплоера атакующий заменил реализацию aBNBc на версию, которая выпускала токены без проверки авторизации: 60 трлн aBNBc. ([отчёт Ankr](https://www.ankr.com/blog/the-abnbc-token-report/)) |
| 44 | - **Radiant Capital, октябрь 2024.** Атакующий, контролировавший 3 из 11 подписантов мультисига, захватил провайдер пулов кредитования и обновил реализацию пулов, выведя более $53 млн. ([rekt.news](https://rekt.news/radiant-capital-rekt2)) |
| 45 | |
| 46 | Таймлок лишь заставляет атакующего подождать, и никто не обязан что-либо проверять, пока идёт ожидание. К тому же долгие задержки блокируют экстренные исправления. Upgrade Airlock заставляет время ожидания работать: обновление не может выполниться, пока его не пропустит независимая проверка (децентрализованная после развёртывания в CRE DON), и эта проверка автоматически запускается для каждого предложения. |
| 47 | |
| 48 | ## <a id="how-it-works"></a>Как это работает |
| 49 | |
| 50 | ```mermaid |
| 51 | sequenceDiagram |
| 52 | actor Owner as Ключ владельца (считаем, что он утёк) |
| 53 | participant Admin as AirlockAdmin |
| 54 | participant CRE as Воркфлоу Chainlink CRE |
| 55 | participant RPC as 4 RPC-провайдера |
| 56 | participant Fwd as KeystoneForwarder |
| 57 | participant Recv as AirlockReceiver |
| 58 | actor Anyone as Кто угодно |
| 59 | |
| 60 | Owner->>Admin: proposeUpgrade(newImpl, data) |
| 61 | Note over Admin: Внешняя дверь: период ожидания 25 блоков, около 5 минут в Sepolia |
| 62 | Admin-->>CRE: Лог Proposed (EVM log trigger) или cron-обходчик каждые 60 с |
| 63 | CRE->>Admin: Чтения EVM на блоке предложения |
| 64 | loop На каждом узле (в симуляторе один, при развёртывании каждый узел DON), для каждого провайдера |
| 65 | CRE->>RPC: eth_getCode, затем симуляция атаки через eth_simulateV1 |
| 66 | RPC-->>CRE: вердикт и правило |
| 67 | end |
| 68 | Note over CRE: Кворум: не менее 2 ответов, все совпадают, иначе Veto R6<br/>идентичный консенсус по вердикту и правилу<br/>хеш доказательств и аттестация EIP-712 |
| 69 | CRE->>Fwd: writeReport |
| 70 | Fwd->>Recv: onReport(metadata, report) |
| 71 | alt Все проверки пройдены |
| 72 | Recv->>Admin: clear(id, evidenceHash) |
| 73 | else Нарушено правило |
| 74 | Recv->>Admin: veto(id, ruleHash) и attachReason(id, evidenceHash) |
| 75 | end |
| 76 | Anyone->>Admin: execute(id) |
| 77 | Note over Admin: Внутренняя дверь: ожидание истекло, допуск есть, вето нет, codehash не изменился |
| 78 | Admin->>Admin: ProxyAdmin.upgradeAndCall(proxy, newImpl, data) |
| 79 | ``` |
| 80 | |
| 81 | 1. **Предложение.** `AirlockAdmin` является владельцем `ProxyAdmin` из OpenZeppelin и `Ownable` токена. Ключ владельца может только предлагать: он никогда не бывает стражем (guardian), утверждающим (approver) или оракулом допуска. Каждое предложение (обновление или изменение самого шлюза: стражи, задержка, цель break-glass) получает `readyBlock = block.number + delayBlocks`. Целью обновления или break-glass может быть только обычный код контракта: делегированный аккаунт EIP-7702, ключ которого мог бы перенаправить его после вердикта, отклоняется (`DelegatedAccount`). |
| 82 | 2. **Оценка.** Log trigger CRE срабатывает на событие `Proposed`. Изменение самого шлюза оценивается по данным, прочитанным из блокчейна (R7). Обновление сначала проходит статические правила (R5, R8), затем симуляцию атаки, привязанную к блоку предложения: слот реализации ERC-1967 прокси подменяется новым кодом, а новый посторонний адрес (выводится из хеша блока предложения) получает средства, как настоящий пользователь: токены от второго держателя и ETH. Держатель делает перевод, посторонний вызывает `transferFrom` без allowance, а затем — каждый селектор, найденный в новом байткоде, с пятью вариантами аргументов. Каждый селектор вне стандартного интерфейса токена дополнительно вызывается с подстановкой держателя: держатель с балансом — в каждом из первых четырёх слотов аргументов, тестовая сумма — в другом слоте, посторонний адрес или прокси — в остальных. Эмиссия, владелец и каждый отслеживаемый баланс считываются до и после, баланс держателя — сразу после каждого вызова с подстановкой держателя; кроме того, проверяются собственные события каждого пробного вызова (с R1 по R4, R3B). Наконец, новый байткод проверяется на опкоды, которые в реальном блоке ведут себя иначе, чем в симуляции (R9). |
| 83 | 3. **Согласование.** Каждый узел (в развёртывании DON — каждый узел DON; сегодня — симулятор CLI) запускает симуляцию у четырёх провайдеров. Ответить должны минимум два, и все ответы должны содержать один и тот же вердикт; в любом другом случае — вето (R6). Затем шаг консенсуса согласует идентичный `{verdict, rule}`. |
| 84 | 4. **Отчёт.** Воркфлоу хеширует канонический JSON доказательств, подписывает вердикт EIP-712 своим ключом аттестатора (секрет CRE) и записывает отчёт через форвардер. `AirlockReceiver` проверяет отправителя, актуальность, подпись и то, что вердикт привязан именно к этому предложению (`proposalDigest`), после чего вызывает в `AirlockAdmin` `clear` либо `veto` и `attachReason`. |
| 85 | 5. **Выполнение.** Кто угодно может вызвать `execute(id)` после окончания периода ожидания. Вызов откатывается с ошибкой `TooEarly` во время ожидания, с `NotCleared` без допуска, с `ProposalVetoed` после вето и с `CodehashChanged`, если по целевому адресу больше нет кода, который был предложен и проверен. Вето всегда имеет приоритет над допуском. |
| 86 | 6. **Обход.** Cron trigger каждые 60 секунд перепроверяет последние предложения и выносит вердикт по каждому ожидающему предложению, на которое CRE ещё не ответил (`oracleJudged`): лог, пропущенный триггером, событие `Proposed`, слишком большое для доставки, или обновление, которому утверждающий выдал допуск вручную. Его вето имеет приоритет над таким допуском. |
| 87 | |
| 88 | **Все сбои ведут в одну сторону.** Пропущенный лог, сбой RPC, расхождение провайдеров или неудачный прогон оставляют предложение без допуска или накладывают на него вето, то есть задерживают обновление и никогда его не пропускают. Если CRE не может ответить вовсе, ключ утверждающего может вручную выдать обновлению допуск с зафиксированными доказательствами; обходчик всё равно оценит это обновление, как только CRE снова начнёт отвечать, а вето CRE или стража по-прежнему имеет приоритет. `breakGlass()` позволяет владельцу или любому стражу мгновенно переключить прокси на заранее зарегистрированную реализацию в режиме паузы. Выход из режима паузы — обновление через обе двери; допуск предложения не истекает, поэтому завершить паузу может и ранее допущенное обновление ([известные ограничения](docs/design.md#known-limitations)). |
| 89 | |
| 90 | Подробнее: [docs/design.md](docs/design.md) (модель угроз, конечный автомат допуска, привязка отчёта, бюджеты) и [cre/README.md](cre/README.md) (воркфлоу). |
| 91 | |
| 92 | ## <a id="rules"></a>Правила |
| 93 | |
| 94 | `ruleHash = keccak256("GUARD/<ID>")`, записывается в блокчейн при каждом вето. |
| 95 | |
| 96 | | Правило | Вето, если | Чем определяется | |
| 97 | |---|---|---| |
| 98 | | R1 `TOTAL_SUPPLY` | вызов постороннего изменяет `totalSupply` | симуляция атаки | |
| 99 | | R2 `OWNER` | вызов постороннего изменяет `owner()` | симуляция атаки | |
| 100 | | R3 `TRANSFER_CONSERVATION` | перевод держателем суммы X перемещает не ровно X или затрагивает чей-то ещё баланс | симуляция атаки | |
| 101 | | R3B `THIRD_PARTY_FUNDS` | посторонний перемещает токены любого держателя: `transferFrom` пополненного постороннего адреса без allowance проходит успешно, либо один из его пробных вызовов перемещает чужие токены, выпускает или сжигает токены, передаёт владение или оставляет баланс держателя ниже прежнего — даже без события и даже если последующий вызов это отменяет | симуляция атаки | |
| 102 | | R4 `TRANSFER_LIVENESS` | обычный перевод держателя не проходит | симуляция атаки | |
| 103 | | R5 `STRICT_CODEHASH` | включён строгий режим, а утверждающий не одобрил codehash | чтение из блокчейна | |
| 104 | | R6 `SIM_FAILURE` | симуляция не может вынести решение: провайдеры расходятся, отвечает слишком мало провайдеров, код не совпадает, откатывается view-вызов или пополнение постороннего адреса, либо пробные вызовы не умещаются в заданное конфигурацией число запросов | симуляция и кворум | |
| 105 | | R7 `CONFIG_GUARD` | смена стража, сокращение задержки или новая цель break-glass без предварительного одобрения утверждающего | чтение из блокчейна | |
| 106 | | R8 `INIT_DATA` | обновление содержит init data, которые утверждающий не одобрил вместе с codehash | чтение из блокчейна | |
| 107 | | R9 `NEW_OPCODES` | новый код содержит опкод контекста блока или транзакции (BASEFEE, TIMESTAMP, NUMBER, ORIGIN, GAS и другие), внешний вызов, create или selfdestruct, не покрытые фиксированным allowlist конфигурации (по умолчанию пустым), если только утверждающий не одобрил именно этот codehash | байткод | |
| 108 | |
| 109 | **R9 закрывает обход симуляции.** `EvilBaseFeeImpl` ведёт себя как v1, пока `block.basefee == 0` — а именно это видит вызов `eth_simulateV1`, — и позволяет кому угодно выпускать токены в реальном блоке. Все инварианты в симуляции выполняются; вето ему приносит опкод `BASEFEE`. Allowlist никогда не зависит от кода, который прокси исполняет сейчас, поэтому одобрение распространяется на один codehash и ни на одно последующее обновление. Сканер пропускает хвост метаданных компилятора, только если это точная CBOR-карта solc, недостижимая ни для одной инструкции; всё остальное сканируется как код. Одобрение codehash освобождает его от R9 и допускает усечение пробных вызовов (R6) — и только: все остальные правила продолжают действовать. |
| 110 | |
| 111 | **R3B закрывает тихий вывод средств.** `EvilDrainImpl` — это v1 плюс `rescue(from, to, amount)` без контроля доступа. Эмиссия, владелец, переводы и опкоды у неё такие же, как у v1, поэтому правила с R1 по R4 и R9 проходят. `rescue` — не стандартная функция токена, поэтому посторонний вызывает её ещё и с подстановкой держателя; вызов с держателем в качестве `from` перемещает токены держателя, и R3B накладывает вето. Вывод средств, спрятанный за условием «только для вызывающих, у которых есть токены или ETH», тоже обнаруживается, потому что у постороннего есть и то и другое. |
| 112 | |
| 113 | ### Автоматический и строгий режимы |
| 114 | |
| 115 | **Автоматический режим** (по умолчанию): CRE самостоятельно выдаёт допуск или вето каждому обновлению по итогам ограниченного поиска атак — внешний вызывающий с пополненным балансом, каждый селектор нового кода и держатель в каждом из первых четырёх слотов аргументов каждой новой функции. **Строгий режим** (R5) добавляет проверку человеком: каждый новый codehash реализации сначала должен одобрить ключ утверждающего, а симуляция атаки всё равно выполняется. Автоматический режим подходит токену, которому нужен допуск рутинных обновлений за считанные минуты; строгий режим делает код-ревью частью допуска ([два режима](docs/design.md#two-modes-automatic-and-strict)). |
| 116 | |
| 117 | ## Chainlink CRE |
| 118 | |
| 119 | CRE — оркестрирующий слой Upgrade Airlock: воркфлоу — единственный автоматический путь через внутреннюю дверь, и он ведёт каждый шаг от ончейн-триггера до ончейн-вердикта. Воркфлоу написан на CRE TypeScript SDK (`@chainlink/cre-sdk` 1.23.0) и скомпилирован в WASM с помощью CRE CLI v1.37.0. |
| 120 | |
| 121 | | Технология Chainlink | Где используется | Роль в проекте | |
| 122 | |---|---|---| |
| 123 | | Раннер воркфлоу CRE | [`cre/veto/main.ts#L1-L9`](cre/veto/main.ts#L1-L9), [`cre/veto/workflow.yaml`](cre/veto/workflow.yaml), [`cre/project.yaml`](cre/project.yaml) | Два обработчика, цели `sepolia` и `sepolia-fork` | |
| 124 | | EVM log trigger | [`cre/veto/workflow.ts#L202-L211`](cre/veto/workflow.ts#L202-L211), [`#L193-L198`](cre/veto/workflow.ts#L193-L198), [`#L54-L65`](cre/veto/workflow.ts#L54-L65) | `AirlockAdmin.Proposed` на `LATEST`: предложение оценивается сразу после появления, за целый период ожидания до того, как его можно будет выполнить | |
| 125 | | Cron trigger | [`cre/veto/workflow.ts#L200`](cre/veto/workflow.ts#L200), [`#L209`](cre/veto/workflow.ts#L209), [`cre/veto/airlock.ts#L323-L389`](cre/veto/airlock.ts#L323-L389) | Обходчик: 20 последних id, каждую минуту | |
| 126 | | Чтение EVM (`callContract`) | [`cre/veto/workflow.ts#L114-L122`](cre/veto/workflow.ts#L114-L122), [`#L137-L142`](cre/veto/workflow.ts#L137-L142), [`cre/veto/chain.ts`](cre/veto/chain.ts) | Предложение, допуск, `oracleJudged`, одобрения и балансы — пакетами через Multicall3 на блоке предложения или на `LATEST` | |
| 127 | | Чтение EVM (`headerByNumber`) | [`cre/veto/workflow.ts#L125-L136`](cre/veto/workflow.ts#L125-L136) | Последний блок и хеш блока предложения, из которого выводится посторонний адрес для симуляции | |
| 128 | | HTTP capability в режиме узла (node mode) | [`cre/veto/workflow.ts#L167-L189`](cre/veto/workflow.ts#L167-L189), [`#L80-L94`](cre/veto/workflow.ts#L80-L94), [`cre/veto/judge.ts#L157-L220`](cre/veto/judge.ts#L157-L220) | Каждый узел отправляет каждому провайдеру `eth_getCode`, затем один или два запроса `eth_simulateV1` (каждый — в пределах лимита запроса 120 kb) | |
| 129 | | `consensusIdenticalAggregation` | [`cre/veto/workflow.ts#L184-L188`](cre/veto/workflow.ts#L184-L188), кворум в [`cre/veto/judge.ts#L244-L277`](cre/veto/judge.ts#L244-L277) | Идентичный консенсус по `{verdict, rule}` (не более 52 байт; при развёртывании — каждый узел DON) | |
| 130 | | Секреты (Secrets) | [`cre/veto/workflow.ts#L160-L166`](cre/veto/workflow.ts#L160-L166), [`cre/secrets.yaml`](cre/secrets.yaml) | Ключ аттестатора EIP-712, загружается один раз за прогон | |
| 131 | | Отчёт + `writeReport` | [`cre/veto/workflow.ts#L143-L149`](cre/veto/workflow.ts#L143-L149), [`cre/veto/airlock.ts#L229-L282`](cre/veto/airlock.ts#L229-L282), [`cre/veto/report.ts`](cre/veto/report.ts) | `runtime.report` (ECDSA, keccak256) и `evm.writeReport`, затем чтение предложения для подтверждения результата | |
| 132 | | Форвардер + `IReceiver` | [`src/cre/AirlockReceiver.sol#L88-L120`](src/cre/AirlockReceiver.sol#L88-L120), [`#L156-L169`](src/cre/AirlockReceiver.sol#L156-L169), [`src/cre/IReceiver.sol`](src/cre/IReceiver.sol), [`script/DeployEth.s.sol#L142`](script/DeployEth.s.sol#L142) | `onReport` принимает отчёты только от настроенного форвардера и, если они заданы, проверяет workflow id и владельца воркфлоу | |
| 133 | | Селекторы сетей (chain selectors) | [`cre/veto/workflow.ts#L100-L104`](cre/veto/workflow.ts#L100-L104) | `ethereum-testnet-sepolia` | |
| 134 | | CRE CLI | [`cre/setup.sh`](cre/setup.sh), [`cre/scripts/fork-e2e.sh`](cre/scripts/fork-e2e.sh), [`scripts/sepolia/airlock.sh`](scripts/sepolia/airlock.sh), [`scripts/sepolia/sweep.sh`](scripts/sepolia/sweep.sh) | `cre workflow build`, `cre workflow simulate --broadcast` | |
| 135 | |
| 136 | **Как воркфлоу работает сегодня.** Воркфлоу запускается командой CRE CLI `cre workflow simulate --broadcast` в сети Ethereum Sepolia. Слушатель ([`scripts/sepolia/airlock.sh`](scripts/sepolia/airlock.sh)) держит log trigger в режиме прослушивания, обходчик ([`scripts/sepolia/sweep.sh`](scripts/sepolia/sweep.sh)) каждую минуту запускает cron-обработчик, а каждый Clear и Veto — реальная транзакция в Sepolia через MockKeystoneForwarder, развёрнутый Chainlink в Sepolia, [`0x15fC6ae953E024d975e77382eEeC56A9101f9F88`](https://docs.chain.link/cre/guides/workflow/using-evm-client/forwarder-directory-ts). `AirlockReceiver` в текущем виде готов к развёртыванию воркфлоу в DON: достаточно развернуть его с `FORWARDER`, указывающим на продакшн-KeystoneForwarder, и с заданными `WORKFLOW_ID` / `WORKFLOW_OWNER` — тогда `onReport` дополнительно проверяет подписанные DON метаданные воркфлоу. `DeployEth` отказывается использовать permissionless MockKeystoneForwarder, если только не заданы оба значения или явно не указан `ALLOW_MOCK_FORWARDER=1` ([`script/DeployEth.s.sol#L96-L104`](script/DeployEth.s.sol#L96-L104)). |
| 137 | |
| 138 | **Спроектирован под лимиты CRE на один прогон.** Не более 15 чтений из блокчейна (пакеты Multicall3 укладываются в лимит payload 5 kb), 12 из 15 HTTP-вызовов при четырёх провайдерах, одна симуляция атаки за прогон и значение консенсуса не более 52 байт. Бюджеты описаны в [docs/design.md](docs/design.md#budgets). |
| 139 | |
| 140 | ## <a id="live-on-sepolia"></a>В сети Sepolia |
| 141 | |
| 142 | Ethereum Sepolia, chain id 11155111. Развёртывание выполнено скриптом [`script/DeployEth.s.sol`](script/DeployEth.s.sol) в блоках 11950684–11950699 (13 транзакций; AirlockAdmin в блоке 11950697); все контракты верифицированы на Sourcify. Владелец, утверждающий, аварийный страж и аттестатор — четыре разных ключа. Период ожидания — 25 блоков. |
| 143 | |
| 144 | | Контракт | Адрес | |
| 145 | |---|---| |
| 146 | | AirlockAdmin | [`0x28e7aAC40560804F480e454bF9C12F50C83b700B`](https://sepolia.etherscan.io/address/0x28e7aAC40560804F480e454bF9C12F50C83b700B) | |
| 147 | | AirlockReceiver | [`0xE40D463116F4Aa24d5E79776a65ED0f1b088c670`](https://sepolia.etherscan.io/address/0xE40D463116F4Aa24d5E79776a65ED0f1b088c670) | |
| 148 | | Прокси DemoBTC (dBTC) | [`0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71`](https://sepolia.etherscan.io/address/0x88Cc569265faDAEAc2a16B69f35E10B2158e4b71) | |
| 149 | | ProxyAdmin | [`0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E`](https://sepolia.etherscan.io/address/0x51a3fE6AD22BF7753574B7b7FD56aE9a65bd593E) | |
| 150 | | DemoBTC v1 | [`0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108`](https://sepolia.etherscan.io/address/0x8f7d6453d77B3f3b008CBE4A144d82c5742eB108) | |
| 151 | | DemoBTC v2 (честное обновление) | [`0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3`](https://sepolia.etherscan.io/address/0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3) | |
| 152 | | EvilMintImpl | [`0x01502c4af90c27f53B2E1293941670C6B761CAAA`](https://sepolia.etherscan.io/address/0x01502c4af90c27f53B2E1293941670C6B761CAAA) | |
| 153 | | EvilOwnerImpl | [`0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488`](https://sepolia.etherscan.io/address/0xBe849DE8e0a4D9b0010ba1468f94Eeb24A42d488) | |
| 154 | | EvilFeeImpl | [`0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5`](https://sepolia.etherscan.io/address/0xB95Ffa7b970B1a73488e2fC32B787fA536F12AF5) | |
| 155 | | EvilBaseFeeImpl | [`0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310`](https://sepolia.etherscan.io/address/0xE1AAa4E88DB9cc7ceCca14722eE2874D834e0310) | |
| 156 | | EvilDrainImpl | [`0x1d98D64BC5204AE139B5A0747855841Fa5333b6d`](https://sepolia.etherscan.io/address/0x1d98D64BC5204AE139B5A0747855841Fa5333b6d) | |
| 157 | | PausedImpl (цель break-glass) | [`0xb6573006fB607E96966d0F578B96AAB25413F5BE`](https://sepolia.etherscan.io/address/0xb6573006fB607E96966d0F578B96AAB25413F5BE) | |
| 158 | | Chainlink MockKeystoneForwarder | [`0x15fC6ae953E024d975e77382eEeC56A9101f9F88`](https://sepolia.etherscan.io/address/0x15fC6ae953E024d975e77382eEeC56A9101f9F88) | |
| 159 | |
| 160 | Первый прогон: семь предложений — семь вердиктов в блокчейне (последующие #8–#12 перечислены в [docs/evidence/sepolia-live.md](docs/evidence/sepolia-live.md)). Блок в каждой ссылке — это блок, в который попала транзакция; `(+n)` показывает, через сколько блоков после предложения пришёл вердикт CRE. |
| 161 | |
| 162 | | Карточка | Предложение | Вердикт CRE | Tx предложения | Tx отчёта | Затем | |
| 163 | |---|---|---|---|---|---| |
| 164 | | `v2`: честное обновление | #4 | Clear | [11950745](https://sepolia.etherscan.io/tx/0x3cd96ac220391e2b404d35591d1a96808b75256e7c685a414e2ca5a63bc54145) | [11950747 (+2)](https://sepolia.etherscan.io/tx/0x2c02d2a23016a72509900eb7ce92dabc06b0dd93a2394d076a62d687ce467efb) | `execute` [11950772](https://sepolia.etherscan.io/tx/0x01913c1854b9862763af74ddacd7be23a7f55232af7334714aa8213aa7021890), `version()` = "2" | |
| 165 | | `evil-mint`: выпускать токены может кто угодно | #1 | Veto R1 | [11950735](https://sepolia.etherscan.io/tx/0x862c5fdf826a8fef09fe2ff278d3e58191f93c267dbdd38ad9aaa270f39937b0) | [11950736 (+1)](https://sepolia.etherscan.io/tx/0xb0d74e25e2528fb9aad7d21de202dac288cd256048f2986b5214280fda4b84f6) | `execute` откатывается с `ProposalVetoed` | |
| 166 | | `evil-owner`: кто угодно может забрать владение | #6 | Veto R2 | [11950788](https://sepolia.etherscan.io/tx/0x32eb22039bd43602e0406b1a8f9a02c5cbcdb5cf2ef1c389919a45d05820da25) | [11950790 (+2)](https://sepolia.etherscan.io/tx/0x49451bee7a2f3351b18992d94b5f89b9ff89cf53be65a937400ab95fb79be551) | `execute` откатывается с `ProposalVetoed` | |
| 167 | | `evil-fee`: с каждого перевода удерживается 10% | #7 | Veto R3 | [11950792](https://sepolia.etherscan.io/tx/0xe06924c2e3961ab27d2803c0654b783fc06e52107345bda9bfeff391053fcad3) | [11950794 (+2)](https://sepolia.etherscan.io/tx/0x01f6055a683e0dabc8f34ae094e5daa5a81ddb11c687aa9c5ac6e30c7a7f3d3b) | `execute` откатывается с `ProposalVetoed` | |
| 168 | | `evil-basefee`: выпуск токенов открывается в реальном блоке | #3 | Veto R9 | [11950742](https://sepolia.etherscan.io/tx/0x3c29c118ca395ca92500abcaae6126244924fbd8e14f846af57b70f113cad6e1) | [11950743 (+1)](https://sepolia.etherscan.io/tx/0xb6db2d832112384287e5a7ccdd086127931360900d09c80645c21eac2bc165db) | `execute` откатывается с `ProposalVetoed` | |
| 169 | | `evil-drain`: кто угодно перемещает токены держателя | #2 | Veto R3B | [11950737](https://sepolia.etherscan.io/tx/0x0e52369921040eb0c901ac3a077aff762d370b4efd9503a52c91360d56366b16) | [11950740 (+3)](https://sepolia.etherscan.io/tx/0x40bbe374a8cee47a94da977f973cf66a67573c12bc84117483fcbd11430f90cb) | `execute` откатывается с `ProposalVetoed` | |
| 170 | | `remove-receiver`: убрать CRE из стражей | #5 | Veto R7 | [11950749](https://sepolia.etherscan.io/tx/0xda2bd1c9dc5f7770da3926358b8da3ffe5a1cd3154b499cafae440387eebb934) | [11950750 (+1)](https://sepolia.etherscan.io/tx/0xaa2f598bdcc7f46727e5456528521e50ac5c1d8e423805042cbf2d10a4c4cdac) | `execute` откатывается с `ProposalVetoed` | |
| 171 | |
| 172 | Все вердикты выше вынес официальный CRE CLI, выполнявший этот воркфлоу на сервере (слушатель log trigger плюс 60-секундный обходчик, [`scripts/sepolia/systemd/`](scripts/sepolia/systemd)); собственные строки лога воркфлоу приведены в [`docs/evidence/sepolia-live.md`](docs/evidence/sepolia-live.md). |
| 173 | |
| 174 | ## <a id="quick-start"></a>Быстрый старт |
| 175 | |
| 176 | ### Требования |
| 177 | |
| 178 | - [Foundry](https://getfoundry.sh) (`forge`, `cast`, `anvil`), `jq` и Node 22. |
| 179 | - Аккаунт CRE для симулятора (`cre login`); он нужен для сквозного прогона на форке и для операторского набора. |
| 180 | - [`cre/setup.sh`](cre/setup.sh) устанавливает CRE CLI v1.37.0 (с зафиксированным SHA-256) и bun 1.4.2 в `cre/.tools`. |
| 181 | |
| 182 | ### Сборка и тесты |
| 183 | |
| 184 | ```bash |
| 185 | git clone https://gitlab.com/jiapunk/upgrade-airlock.git # или https://upgrade-airlock.pages.dev/source/upgrade-airlock.git |
| 186 | cd upgrade-airlock |
| 187 | |
| 188 | # контракты (OpenZeppelin и forge-std уже включены в lib/) |
| 189 | forge build # однократно загружает зафиксированную версию solc 0.8.37 |
| 190 | forge test --offline # 117 тестов |
| 191 | |
| 192 | # воркфлоу CRE |
| 193 | cre/setup.sh |
| 194 | export PATH="$PWD/cre/.tools/bin:$PWD/cre/.tools/node_modules/.bin:$PATH" |
| 195 | (cd cre/veto && bun test && bun run typecheck) # 118 тестов: правила, пробные вызовы, байткод, отчёты, бюджеты, обработчики |
| 196 | |
| 197 | # ситуационный центр |
| 198 | (cd web && npm ci && npm run selftest) # 394 проверки: режим реального времени и выбор атаки, офлайн-воспроизведение, API оператора |
| 199 | ``` |
| 200 | |
| 201 | ### Сквозной прогон на форке Sepolia |
| 202 | |
| 203 | ```bash |
| 204 | cre login # один раз; симулятору нужен аккаунт CRE |
| 205 | cre/scripts/fork-e2e.sh # локальный anvil-форк Sepolia: в реальную сеть ничего не отправляется |
| 206 | ``` |
| 207 | |
| 208 | [`cre/scripts/fork-e2e.sh`](cre/scripts/fork-e2e.sh) разворачивает стек на форке, запускает воркфлоу в симуляторе CRE с `--broadcast` и проверяет каждый результат в блокчейне: |
| 209 | |
| 210 | - **Log trigger:** EvilMint получает вето R1, EvilOwner — R2, EvilFee — R3, EvilBaseFee — R9 (с указанием `BASEFEE`), EvilDrain — R3B (с указанием `rescue`, при том что все остальные правила проходят); ещё семь вариантов вывода средств ([`test/fixtures/DrainVariants.sol`](test/fixtures/DrainVariants.sol)) получают вето только по R3B: переставленные аргументы, проверка адреса токена, вывод, доступный лишь вызывающим с токенами или ETH, вариант без события `Transfer` и `transferFrom`, который для держателей токенов пропускает проверку allowance; честный v2 получает допуск и выполняется после периода ожидания; удаление получателя из стражей получает вето R7; сокращение задержки, заранее одобренное утверждающим, получает допуск. |
| 211 | - **Обходчик:** обновление, по которому никто не вынес вердикт, после периода ожидания откатывается с `NotCleared`, один прогон обходчика выдаёт ему допуск, и оно выполняется; два предложения, которым утверждающий выдал допуск вручную, оцениваются следующим прогоном: EvilDrain получает вето R3B поверх допуска, а увеличение задержки CRE тоже допускает; обновление с 6 KB init data получает вето R8 на последующем прогоне. |
| 212 | - **Доказательства:** каждое предложение под вето откатывается с `ProposalVetoed`, а хеш доказательств каждого вердикта пересчитывается по данным блокчейна ([`cre/veto/tools/check-evidence.ts`](cre/veto/tools/check-evidence.ts)). |
| 213 | |
| 214 | ### Операторский набор для Sepolia |
| 215 | |
| 216 | [`scripts/sepolia/`](scripts/sepolia/README.md) — скрипты, на которых работает онлайн-демо. Ключи хранятся вне репозитория, по одному hex-ключу на файл в `KEY_DIR` (`owner.key`, `attester.key`, `sender.key`, `approver.key`, `emergency.key`, а также необязательный `sweeper.key`); скрипты никогда их не выводят, а `deploy.sh` отказывается работать, если владелец, утверждающий, аварийный страж и аттестатор — не четыре разных ключа. Каждый скрипт можно запустить и на локальном форке с `FORK=<port>`, так что демо можно отрепетировать без каких-либо затрат. |
| 217 | |
| 218 | ```bash |
| 219 | scripts/sepolia/deploy.sh # 13 транзакций, записывает deployments/sepolia.json (FORCE=1 заменяет существующий) |
| 220 | bun scripts/sepolia/make-config.mjs # конфигурация воркфлоу, 4 провайдера, кворум 2 |
| 221 | scripts/sepolia/fund-sender.sh 0.05 # ETH для транзакций с отчётами |
| 222 | scripts/sepolia/airlock.sh # терминал A: слушатель CRE (log trigger) |
| 223 | scripts/sepolia/propose.sh v2 # терминал B: затем evil-mint, evil-owner, evil-fee, evil-basefee, evil-drain, remove-receiver |
| 224 | scripts/sepolia/execute.sh <id> # выполняет допущенное предложение после периода ожидания, иначе показывает откат |
| 225 | scripts/sepolia/status.sh # все предложения: вердикт, оценено ли CRE, правило, доказательства, транзакции |
| 226 | ``` |
| 227 | |
| 228 | `sweep.sh` однократно запускает cron-обходчик, `clear.sh` — ручной допуск утверждающего, `fork.sh` запускает, сохраняет и восстанавливает форк Sepolia, `probe-rpcs.ts` проверяет каждого провайдера собственным кодом оценки воркфлоу, а `systemd/` запускает слушатель и обходчик на Linux-сервере. |
| 229 | |
| 230 | ### Ситуационный центр |
| 231 | |
| 232 | ```bash |
| 233 | cd web |
| 234 | npm ci |
| 235 | npm run dev # http://127.0.0.1:5173/ |
| 236 | npm run build:airlock # синхронизирует deployments/sepolia.json в страницу, собирает web/dist-airlock |
| 237 | ``` |
| 238 | |
| 239 | Страница читает Sepolia напрямую, не хранит ключей и никогда ничего не подписывает и не отправляет. Она показывает каждое предложение в виде шлюза: период ожидания, допуск CRE, вето с правилом и доказательствами. Посетитель выбирает одну из семи карточек (шесть атак и честное обновление v2) и смотрит помеченный повтор самого свежего реального предложения этого вида в Sepolia (или живое предложение, пока оно в периоде ожидания) — см. [выбор атаки](web/README.md#attack-picker-what-a-visitor-sees). Для докладчика есть режим оператора (`web/operator/`, `web/functions/`): на dev-сервере он принимает только loopback-подключения, на Cloudflare Pages требует токен и выключен, пока не заданы оба секрета `OWNER_KEY` и `OPERATOR_TOKEN`. Он отправляет одно из тех же семи фиксированных предложений через `POST /api/operator/propose`, подписывая его ключом владельца на стороне сервера (дизайн и так исходит из того, что этот ключ утечёт: он может только предлагать); страница обычного посетителя его никогда не вызывает — см. [режим оператора](web/README.md#operator-mode-real-attacks-on-sepolia). |
| 240 | |
| 241 | | Параметр URL | Действие | |
| 242 | |---|---| |
| 243 | | `?mode=airlock` | По умолчанию. Просмотр Ethereum Sepolia в реальном времени, только чтение | |
| 244 | | `?mode=offline` | Воспроизводит записанный прогон вообще без сетевого подключения | |
| 245 | | `?lang=en`, `?lang=zh-TW`, `?lang=zh-CN` | Язык интерфейса (по умолчанию `zh-CN`; клавиша `L` переключает их по кругу) | |
| 246 | | `?rpc=http://127.0.0.1:<port>` | Читает вместо этого локальный форк Sepolia — для репетиций | |
| 247 | | `?op=1` | Режим оператора: запрашивает токен оператора (только для докладчика) | |
| 248 | |
| 249 | В режиме реального времени нажмите `P`, чтобы воспроизвести последние предложения по их событиям в блокчейне, и `1`–`7`, чтобы воспроизвести карточку из выбора атаки. |
| 250 | |
| 251 | ## Структура репозитория |
| 252 | |
| 253 | ``` |
| 254 | src/AirlockAdmin.sol две двери: предложения, период ожидания, вето стража, допуск, execute, breakGlass |
| 255 | src/cre/AirlockReceiver.sol получатель отчётов CRE: форвардер, метаданные воркфлоу, аттестация EIP-712, привязка digest |
| 256 | src/cre/IReceiver.sol интерфейс получателя Chainlink CRE |
| 257 | src/DemoBTC.sol, DemoBTCV2.sol демо-токен (dBTC) v1 и честный v2 |
| 258 | src/PausedImpl.sol цель break-glass: любое изменение баланса откатывается |
| 259 | src/attack/ EvilMintImpl, EvilOwnerImpl, EvilFeeImpl, EvilBaseFeeImpl, EvilDrainImpl |
| 260 | script/DeployEth.s.sol развёртывание (13 транзакций) с проверками ролей, форвардера и связей после деплоя |
| 261 | test/ тесты Foundry; fixtures/DrainVariants.sol содержит семь вариантов вывода средств |
| 262 | cre/veto/ воркфлоу CRE: workflow.ts (capabilities), airlock.ts (обработчики), judge.ts (правила, |
| 263 | кворум), simulate.ts (eth_simulateV1), bytecode.ts (селекторы, R9), report.ts |
| 264 | (отчёт, EIP-712, доказательства), chain.ts (бюджет чтений), keys.ts, rules.ts, config.ts |
| 265 | cre/veto/test/ юнит-тесты Bun |
| 266 | cre/veto/tools/ check-evidence.ts пересчитывает каждый ончейн-хеш доказательств |
| 267 | cre/scripts/fork-e2e.sh сквозной прогон на anvil-форке Sepolia |
| 268 | cre/setup.sh зафиксированные версии CRE CLI и bun |
| 269 | scripts/sepolia/ операторский набор для онлайн-демо, с unit-файлами systemd для сервера |
| 270 | web/ ситуационный центр (Vite, TypeScript, viem) |
| 271 | web/operator/, web/functions/ режим оператора: семь фиксированных предложений, подписанных на сервере (dev-сервер, Pages Functions) |
| 272 | deployments/sepolia.json действующее развёртывание |
| 273 | docs/design.md модель угроз, конечный автомат допуска, привязка отчёта, бюджеты |
| 274 | docs/evidence/ запись реального прогона |
| 275 | .github/workflows/ci.yml проверки контрактов, воркфлоу и ситуационного центра |
| 276 | ``` |
| 277 | |
| 278 | ## <a id="security-model"></a>Модель безопасности |
| 279 | |
| 280 | Upgrade Airlock исходит из того, что ключ владельца утечёт, и построен так, чтобы одного ключа владельца никогда не хватало для выполнения обновления. |
| 281 | |
| 282 | | Роль | Кто | Что может | |
| 283 | |---|---|---| |
| 284 | | Владелец (owner) | один ключ | предлагать обновления и изменения самого шлюза (стражи, задержка, цель break-glass); `breakGlass` | |
| 285 | | Утверждающий (approver) | отдельный ключ | вручную выдавать допуск, когда CRE не может ответить; одобрять codehash, init data и изменения шлюза (`approveCodehash`) | |
| 286 | | Аварийный страж (emergency guardian) | отдельный ключ | налагать `veto` на ожидающее предложение; `breakGlass` | |
| 287 | | Аттестатор (attester) | секрет CRE, отдельный ключ | подписывать вердикты EIP-712, которые принимает `AirlockReceiver` | |
| 288 | | AirlockReceiver | получатель отчётов CRE | оракул допуска (`clear`) и страж (`veto`, `attachReason`) | |
| 289 | | Кто угодно | | вызывать `execute` для предложения, прошедшего обе двери | |
| 290 | |
| 291 | Роли никогда не пересекаются. Конструктор `AirlockAdmin` отклоняет владельца, который одновременно является утверждающим, стражем или оракулом допуска (`RoleOverlap`), а `DeployEth` выполняет развёртывание только тогда, когда владелец, утверждающий, аварийный страж и аттестатор — четыре разных адреса. |
| 292 | |
| 293 | **Утёкший ключ владельца может предлагать.** Каждое предложение у всех на виду проходит период ожидания, пока CRE его оценивает, и требует допуска, который владелец не может выдать себе сам. Владелец также может задействовать аварийный тормоз (`breakGlass`), который лишь переключает прокси на заранее зарегистрированную реализацию в режиме паузы; выход из этого режима — обновление через обе двери, и завершить его может также ранее допущенное, но так и не выполненное обновление. |
| 294 | |
| 295 | **Тот же ключ не может:** |
| 296 | |
| 297 | - выполнить обновление без допуска: у `AirlockAdmin` нет универсального пути вызова, и единственный способ сменить реализацию — `execute` допущенного обновления (или `breakGlass` на реализацию в режиме паузы); |
| 298 | - сначала ослабить шлюз: удаление получателя CRE, сокращение задержки или замена цели break-glass — это тоже предложения, и без предварительного одобрения утверждающего R7 накладывает на них вето; |
| 299 | - подменить код после вердикта: `execute` перепроверяет codehash цели (`CodehashChanged`), а делегированные аккаунты EIP-7702 отклоняются (`DelegatedAccount`); |
| 300 | - подделать или повторно использовать вердикт: `AirlockReceiver` принимает отчёт только от настроенного форвардера, только подписанный аттестатором, не позднее его `validUntilBlock` и привязанный к `proposalDigest` предложения в этой сети и для этого получателя. |
| 301 | |
| 302 | Ручной допуск утверждающего — это ручное переопределение на случай, когда CRE не может ответить: обходчик всё равно оценивает такое предложение, пока оно входит в 20 последних id, а вето CRE или стража имеет приоритет. Полная модель угроз — в [docs/design.md](docs/design.md#threat-model). |
| 303 | |
| 304 | Это демонстрационные контракты для тестовой сети с известными ограничениями, каждое из которых закреплено проходящим тестом: допуски не истекают, ключ владельца нельзя сменить (украденный ключ может снова ставить токен на паузу после каждого снятия паузы), а один недобросовестный страж может держать токен на паузе. Список — в [docs/design.md](docs/design.md#known-limitations). |
| 305 | |
| 306 | ## Технологии |
| 307 | |
| 308 | - **Solidity 0.8.37** и **Foundry**, с OpenZeppelin Contracts 5.7.0 (`ProxyAdmin`, `TransparentUpgradeableProxy`, `EIP712`, `ECDSA`) |
| 309 | - **Chainlink CRE**: TypeScript SDK (`@chainlink/cre-sdk` 1.23.0) и CRE CLI v1.37.0 на Bun 1.4.2 |
| 310 | - **viem** для ABI-кодирования и чтения блокчейна — в воркфлоу и в ситуационном центре |
| 311 | - **Vite** и TypeScript для ситуационного центра |
| 312 | |
| 313 | ## Инструменты ИИ |
| 314 | |
| 315 | Upgrade Airlock создан под руководством автора с помощью [Claude Code](https://claude.com/claude-code): с ним написаны и проверены контракты, воркфлоу CRE, тесты, скрипты, ситуационный центр и эта документация. |
| 316 | |
| 317 | ## Лицензия |
| 318 | |
| 319 | [MIT](LICENSE) © 2026 jiapunk |