Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / README.zh-CN.md
README.zh-CN.md319 lines · 39428 bytes

Upgrade Airlock 升级气闸

每一次代理合约升级都要过两道门:一道是冷静期,一道是 Chainlink CRE 的放行,而只有扛过攻击模拟的代码才能拿到放行。

English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Español · Français · Deutsch · Português (Brasil) · Русский · Tiếng Việt

Source: GitLab License: MIT Live demo Demo video Ethereum Sepolia Chainlink CRE

亮点 · 工作原理 · 规则 · Chainlink CRE · Sepolia 链上运行 · 快速开始 · 安全模型

概览

Upgrade Airlock 为每一次合约升级设置两道门。外门是按区块计算的冷静期;内门只有在链上记录了放行之后才会打开。自动放行的条件是:Chainlink CRE 工作流已用模拟器对新实现发起攻击并查询了四家独立的 RPC 服务商,至少两家返回结果且所有结果一致,工作流的共识步骤(consensusIdenticalAggregation)已就判决达成一致,并且一份带签名的放行(Clear)报告已经上链。CRE 完全无法给出判决时,approver(审批人)私钥可以附上记录在案的证据手动放行;只要该提案仍在最新的 20 个 id 之内,定时扫描照样会补判,CRE 或 guardian 的否决优先。被攻击搜索覆盖到的恶意实现拿到的则是一份带签名的否决(Veto),即使攻击者握有升级私钥,它也不会被执行;有边界的搜索覆盖不到的部分,由严格模式再加一道人工代码审查。目前的判决来自服务器上运行本工作流的官方 CRE CLI(cre workflow simulate --broadcast);合约和工作流是为 DON 编写的,迁移到 DON 需要在 Chainlink 的生产环境 forwarder 上重新部署。本项目为 ETH Shenzhen 2026 打造,部署在 Ethereum Sepolia 上。

[!TIP] 在线演示:upgrade-airlock.pages.dev · 演示视频:upgrade-airlock.pages.dev/video(简体中文与英文)

战情室直接在浏览器里读取 Ethereum Sepolia,只读不写。每个提案都显示为一座气闸:外门记录提案,气闸舱为冷静期倒计时,内门始终锁住,直到放行上链(Chainlink CRE 的放行,或 approver 的人工放行,战情室会标明是人工放行)。一旦被否决,提案会被弹出舱外,同时列出对应的规则、证据哈希和交易。首次打开时是攻击选择器:每张卡(六种攻击和正常升级 v2)回放同类在 Sepolia 上最新的一笔真实提案,并标明是回放。页面默认显示简体中文:?lang=en 切换为英文,?lang=zh-TW 切换为繁体中文。页面背后的交易(提案 #1–#12)都收录在线上运行证据中。

亮点

  • 从设计上就是 fail-closed(出错即拒绝)。 只有链上已有放行,升级才能执行。漏掉事件日志、RPC 宕机、服务商结果不一致或运行失败,都只会让提案保持未放行或被否决:最多推迟升级,绝不会让升级通过。否决永远优先于放行。
  • 真刀真枪的攻击,而不是检查清单。 工作流(部署到 DON 时在每个节点上)在四家 RPC 服务商上用 eth_simulateV1,基于代理合约的真实状态执行这次升级。一个持有资金的陌生地址调用新字节码中的每一个选择器,并用填入持有者地址的参数逐一调用每个新函数,再对比调用前后的总供应量、所有权和余额。至少要有两家服务商返回结果,且所有结果必须一致。
  • 判决与提案绑定。 工作流的一致共识步骤就 {verdict, rule} 达成一致。报告附带一份 EIP-712 签名证明,绑定提案的确切内容(proposalDigest)、链、接收合约和截止区块,另附一个任何人都能用链上数据重新算出的证据哈希。
  • 漏掉的日志会被补判。 cron 定时扫描每 60 秒检查最新的 20 个提案,补判 log trigger 漏掉的所有提案,包括 approver(审批人)手动放行的升级;它的否决优先。被挤出最新 20 个 id 之外的提案不再被判定:除非 approver 已手动放行,否则它保持未放行(见扫描窗口)。
  • 已在 Ethereum Sepolia 上线运行。 官方 CRE CLI 在服务器上运行这个工作流,在链上对每一个提案都作出了判决(截至 2026-10-25 共十二个):一次正常升级被放行并执行,十一次攻击被否决(R1、R2、R3、R3B、R7、R9)。所有合约均已在 Sourcify 上完成验证。
  • 端到端测试。 CI 中运行 117 个 Foundry 测试、118 个工作流单元测试,以及 394 项战情室自检中的 387 项(其余 7 项需要 forge 编译产物和 cast,CI 任务里没有);另有一个分叉链端到端脚本,通过 CRE 模拟器跑通完整闭环。

要解决的问题

可升级的代理合约把整个协议的安危都压在一把升级私钥上。私钥一旦泄露,攻击路径非常短:部署恶意实现,把代理合约指向它,然后增发或掏空资金。这样的事已经发生过很多次:

  • PAID Network,2021 年 3 月。 代理合约所有者的私钥被用来把经过审计的代币代码替换为带有销毁和铸造函数的版本,攻击者铸出约 5950 万枚 PAID。(CertiK 事后分析)
  • Ankr,2022 年 12 月。 攻击者利用泄露的部署者私钥,把 aBNBc 的实现替换为不做权限检查就能铸币的版本,铸出 60 万亿枚 aBNBc。(Ankr 官方报告)
  • Radiant Capital,2024 年 10 月。 攻击者控制了 11 个多签签名者中的 3 个,接管借贷池的 provider 合约并升级借贷池的实现,卷走 5300 多万美元。(rekt.news)

时间锁只是让攻击者多等一会儿,而等待期间没有人有义务去检查;延迟拉得太长,又会挡住紧急修复。Upgrade Airlock 让这段等待真正发挥作用:升级必须先通过一项独立的检查(部署到 CRE DON 后即为去中心化)才能执行,而这项检查会在每一个提案上自动运行。

工作原理

sequenceDiagram
    actor Owner as owner 私钥(假设会泄露)
    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 个区块的冷静期,在 Sepolia 上约 5 分钟
    Admin-->>CRE: Proposed 事件日志(EVM log trigger),或每 60 秒一次的 cron 定时扫描
    CRE->>Admin: 在提案区块执行 EVM 读取
    loop 每个节点上(模拟器中为一个,部署后为每个 DON 节点),逐一请求每家服务商
        CRE->>RPC: eth_getCode,然后用 eth_simulateV1 做攻击模拟
        RPC-->>CRE: 判决和规则
    end
    Note over CRE: Quorum:至少 2 个结果且全部一致,否则以 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)
  1. 提案。 AirlockAdmin 是 OpenZeppelin ProxyAdmin 的所有者,也是代币 Ownable 的所有者。owner 私钥只能提案:它永远不会是 guardian(守护者)、approver(审批人)或放行预言机。每个提案(升级,或对气闸本身的修改:guardian、冷静期、紧急暂停目标)都会记录 readyBlock = block.number + delayBlocks。升级目标和紧急暂停目标都必须是普通的合约代码:EIP-7702 委托账户的私钥可以在判决之后把它重新指向别处,因此一律拒绝(DelegatedAccount)。
  2. 判定。 CRE 的 log trigger 在 Proposed 事件发出时触发。对气闸本身的修改根据链上读取的数据判定(R7)。升级先过静态规则(R5、R8),再进入固定在提案区块上的攻击模拟:代理合约的 ERC-1967 实现槽被覆盖为新代码,一个全新的陌生地址(由提案区块哈希推导而来)像真实用户一样被注入资金,包括来自第二个持有者的代币和 ETH。先由持有者完成一笔转账,再由陌生地址在没有授权额度的情况下调用 transferFrom,然后用五种参数形式调用新字节码中找到的每一个选择器。凡是不属于标准代币接口的选择器,还会再用持有者参数调用一遍:持有资金的持有者依次填入前四个参数位置,测试金额填入另一个位置,其余位置填入陌生地址或代理合约地址。调用前后都会读取总供应量、owner 和每个受监控的余额,每次持有者参数调用之后立即读取持有者的余额,并检查每一次探测自身发出的事件(R1 至 R4、R3B)。最后扫描新字节码,找出在真实区块上与在模拟中行为不同的操作码(R9)。
  3. 达成一致。 每个节点(DON 部署中为每个 DON 节点;目前为 CLI 模拟器)都在四家服务商上运行模拟。至少要有两家返回结果,且所有结果必须是同一个判决;其他情况一律否决(R6)。随后共识步骤就完全相同的 {verdict, rule} 达成一致。
  4. 报告。 工作流对规范化的证据 JSON 计算哈希,用 attester 私钥(CRE secret)签署一份 EIP-712 判决,再通过 forwarder 写入报告。AirlockReceiver 检查发送方、时效、签名,以及判决是否绑定到这一个提案(proposalDigest),然后在 AirlockAdmin 上调用 clear,或调用 veto 和 attachReason。
  5. 执行。 冷静期结束后,任何人都可以调用 execute(id)。冷静期内会以 TooEarly revert,没有放行时以 NotCleared revert,被否决后以 ProposalVetoed revert;如果目标地址上的代码已不是当初提案并经过判定的那一份,则以 CodehashChanged revert。否决永远优先于放行。
  6. 定时扫描。 cron trigger 每 60 秒重新检查最新的提案,凡是仍待处理、CRE 尚未答复(oracleJudged)的提案都会被补判:log trigger 漏掉的事件、大到无法投递的 Proposed 事件,或 approver 手动放行的升级。它的否决优先于这次手动放行。

所有失败都指向同一个方向。 漏掉事件日志、RPC 宕机、服务商结果不一致或运行失败,结果都是提案得不到放行或被否决:只会推迟升级,绝不会让升级通过。当 CRE 完全无法给出结果时,approver 私钥可以附上记录在案的证据手动放行;CRE 恢复后,定时扫描仍会判定这个升级,CRE 或 guardian 的否决依然优先。breakGlass() 允许 owner 或任一 guardian 立即把代理合约切换到事先登记的暂停实现;退出暂停状态则是一次升级,同样要过两道门;已放行的提案永不过期,因此较早放行的升级也能结束暂停(见已知限制)。

更多细节见 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,或者它的某次探测转走了别人的代币、铸币、销毁、转移所有权,或使持有者的余额减少;即使没有发出事件,或之后的调用又把变化撤销,也同样否决 攻击模拟
R4 TRANSFER_LIVENESS 持有者的普通转账失败 攻击模拟
R5 STRICT_CODEHASH 已开启严格模式,且 approver 尚未审批该 codehash 链上读取
R6 SIM_FAILURE 模拟无法得出结论:服务商结果不一致、返回结果太少、代码不匹配、view 调用或陌生地址的注资发生 revert,或探测超出了配置的请求容量 模拟与 quorum
R7 CONFIG_GUARD 修改 guardian、缩短冷静期或设置新的紧急暂停目标,却没有 approver 的事先审批 链上读取
R8 INIT_DATA 升级附带初始化数据,而 approver 没有连同 codehash 一起审批这些数据 链上读取
R9 NEW_OPCODES 新代码包含配置中固定允许清单(默认为空)未覆盖的区块或交易上下文操作码(BASEFEE、TIMESTAMP、NUMBER、ORIGIN、GAS 等)、外部调用、create 或 selfdestruct,除非 approver 审批的正是这个 codehash 字节码

R9 堵住的是模拟规避。 EvilBaseFeeImpl 在 block.basefee == 0 时(这正是 eth_simulateV1 调用看到的情况)表现得和 v1 一模一样,到了真实区块上却允许任何人铸币。模拟中的所有不变量检查都能通过,但它的 BASEFEE 操作码让它被否决。允许清单从不依赖代理合约当前运行的代码,因此一次审批只覆盖一个 codehash,不会延伸到之后的任何升级。只有当编译器的 metadata 尾段是格式完全符合 solc 规范的 CBOR map,且没有任何指令能执行到它时,扫描才会跳过这一段;其他情况一律当作代码扫描。审批某个 codehash 只会让它免于 R9,并容许探测被截断(R6),仅此而已:其他规则照常执行。

R3B 堵住的是无声掏空。 EvilDrainImpl 就是 v1 加上一个没有任何权限控制的 rescue(from, to, amount)。它的总供应量、owner、转账和操作码都与 v1 相同,所以 R1 至 R4 和 R9 全部通过。rescue 不是标准代币函数,因此陌生地址还会用持有者参数调用它;把持有者作为 from 的那次调用转走了持有者的代币,R3B 随即将其否决。即便掏空函数只对“持有代币或 ETH 的调用者”开放,也同样逃不过,因为陌生地址两者都有。

自动模式与严格模式

自动模式为默认模式:CRE 通过有边界的攻击搜索,自主放行或否决每一次升级,搜索范围包括一个持有资金的外部调用者、新代码中的每一个选择器,以及把持有者依次填入每个新函数前四个参数位置的调用。严格模式(R5)再加一道人工审查:每个新实现的 codehash 都必须先经 approver 私钥审批,攻击模拟照常运行。自动模式适合希望常规升级在几分钟内获得放行的代币;严格模式则把代码审查纳入放行流程(详见两种模式)。

CRE 是 Upgrade Airlock 的编排层:工作流是穿过内门的唯一自动化通道,从链上触发到链上判决的每一步都由它完成。工作流使用 CRE TypeScript SDK(@chainlink/cre-sdk 1.23.0)编写,并用 CRE CLI v1.37.0 编译为 WASM。

Chainlink 技术 位置 在本项目中的作用
CRE workflow runner cre/veto/main.ts#L1-L9、cre/veto/workflow.yaml、cre/project.yaml 两个 handler,target 为 sepolia 和 sepolia-fork
EVM log trigger cre/veto/workflow.ts#L202-L211、#L193-L198、#L54-L65 以 LATEST 置信度监听 AirlockAdmin.Proposed:提案一出现就开始判定,比它最早可执行的时间早了整整一个冷静期
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 最新区块,以及用于推导模拟陌生地址的提案区块哈希
node mode 下的 HTTP capability 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,quorum 逻辑见 cre/veto/judge.ts#L244-L277 就 {verdict, rule} 达成一致共识(最多 52 字节;部署后为每个 DON 节点)
Secrets cre/veto/workflow.ts#L160-L166、cre/secrets.yaml EIP-712 attester 私钥,每次运行只获取一次
Report + writeReport cre/veto/workflow.ts#L143-L149、cre/veto/airlock.ts#L229-L282、cre/veto/report.ts runtime.report(ECDSA、keccak256)和 evm.writeReport,随后读回提案以确认结果
Forwarder + IReceiver src/cre/AirlockReceiver.sol#L88-L120、#L156-L169、src/cre/IReceiver.sol、script/DeployEth.s.sol#L142 onReport 只接受配置的 forwarder,并在已配置时校验 workflow id 和 owner
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 handler;每一次放行和否决都是一笔真实的 Sepolia 交易,经由 Chainlink 在 Sepolia 上的 MockKeystoneForwarder 0x15fC6ae953E024d975e77382eEeC56A9101f9F88 上链。AirlockReceiver 无需任何改动即可用于 DON 部署:部署时把 FORWARDER 设为生产环境的 KeystoneForwarder,并设置 WORKFLOW_ID / WORKFLOW_OWNER,onReport 就会同时校验 DON 签名的工作流 metadata。除非这两项都已设置,或通过 ALLOW_MOCK_FORWARDER=1 明确允许,否则 DeployEth 会拒绝使用无需许可的 MockKeystoneForwarder(script/DeployEth.s.sol#L96-L104)。

按 CRE 单次运行限额设计。 最多 15 次链上读取(Multicall3 批量读取,每批都低于 5 kb 的 payload 上限)、四家服务商占用 15 次 HTTP 调用中的 12 次、每次运行一次攻击模拟、共识值最多 52 字节。完整预算见 docs/design.md。

Sepolia 链上运行

Ethereum Sepolia,chain id 11155111。合约通过 script/DeployEth.s.sol 在区块 11950684–11950699 部署(13 笔交易;AirlockAdmin 位于区块 11950697),均已在 Sourcify 上完成验证。owner、approver、紧急 guardian 和 attester 是四把不同的私钥。冷静期为 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(紧急暂停目标) 0xb6573006fB607E96966d0F578B96AAB25413F5BE
Chainlink MockKeystoneForwarder 0x15fC6ae953E024d975e77382eEeC56A9101f9F88

第一轮运行:七个提案,七个链上判决(之后的 #8–#12 列在 docs/evidence/sepolia-live.md 中)。每个链接上的区块号是该交易所在的区块;(+n) 表示 CRE 判决在提案之后第几个区块到达。

卡片 提案 CRE 判决 提案交易 报告交易 后续
v2:正常升级 #4 放行 11950745 11950747 (+2) execute 11950772,version() = "2"
evil-mint:任何人都能铸币 #1 否决 R1 11950735 11950736 (+1) execute 以 ProposalVetoed revert
evil-owner:任何人都能夺取所有权 #6 否决 R2 11950788 11950790 (+2) execute 以 ProposalVetoed revert
evil-fee:转账暗扣 10% #7 否决 R3 11950792 11950794 (+2) execute 以 ProposalVetoed revert
evil-basefee:到了真实区块才开放铸币 #3 否决 R9 11950742 11950743 (+1) execute 以 ProposalVetoed revert
evil-drain:任何人都能转走持有者的代币 #2 否决 R3B 11950737 11950740 (+3) execute 以 ProposalVetoed revert
remove-receiver:把 CRE 从 guardian 中移除 #5 否决 R7 11950749 11950750 (+1) execute 以 ProposalVetoed revert

上表中的每一个判决,都来自官方 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 个测试:规则、探测、字节码、报告、预算、handler

# 战情室
(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;正常的 v2 被放行,并在冷静期后执行;把 receiver 从 guardian 中移除的提案被 R7 否决;经 approver 事先审批的缩短冷静期提案被放行。
  • 定时扫描: 无人判定过的升级在冷静期后执行会以 NotCleared revert,一次扫描将其放行后即可执行;approver 手动放行的两个提案由下一次扫描判定:EvilDrain 被 R3B 否决(否决压过手动放行),延长冷静期的提案也再次由 CRE 放行;带 6 KB 初始化数据的升级在再下一次扫描中被 R8 否决。
  • 证据: 每个被否决的提案执行时都以 ProposalVetoed revert,每个判决的证据哈希都会用链上数据重新计算核对(cre/veto/tools/check-evidence.ts)。

Sepolia 运维工具包

scripts/sepolia/ 用于运行线上演示。私钥存放在仓库之外:KEY_DIR 中每个文件存一把十六进制私钥(owner.key、attester.key、sender.key、approver.key、emergency.key,另可选 sweeper.key);脚本从不打印私钥,而且只有当 owner、approver、紧急 guardian 和 attester 是四把不同的私钥时,deploy.sh 才会执行部署。每个脚本也都能通过 FORK=<port> 在本地分叉链上运行,整套演示可以零成本彩排。

scripts/sepolia/deploy.sh                    # 13 笔交易,写出 deployments/sepolia.json(FORCE=1 时替换已有部署)
bun scripts/sepolia/make-config.mjs          # 生成工作流配置:4 家服务商,quorum 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>              # 冷静期后执行已放行的提案,否则显示 revert 原因
scripts/sepolia/status.sh                    # 列出每个提案:判决、CRE 是否已判定、规则、证据、交易

sweep.sh 运行一次 cron 定时扫描,clear.sh 是 approver 的手动放行,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/):在开发服务器上只接受本机连接;在 Cloudflare Pages 上要凭口令,而且只有 OWNER_KEY 和 OPERATOR_TOKEN 两个 secret 都存在时才会开启。它通过 POST /api/operator/propose 发出同样七种固定提案之一,由服务器端用 owner 私钥签名(设计本来就假设这把私钥会泄露:它只能提案);公众访客的页面从不调用它,见操作员模式。

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            两道门:提案、冷静期、guardian 否决、放行、执行、breakGlass
src/cre/AirlockReceiver.sol     CRE 报告接收合约:forwarder、工作流 metadata、EIP-712 签名证明、digest 绑定
src/cre/IReceiver.sol           Chainlink CRE 接收接口
src/DemoBTC.sol, DemoBTCV2.sol  演示代币(dBTC)v1 和正常的 v2
src/PausedImpl.sol              紧急暂停目标:任何余额变动都会 revert
src/attack/                     EvilMintImpl、EvilOwnerImpl、EvilFeeImpl、EvilBaseFeeImpl、EvilDrainImpl
script/DeployEth.s.sol          部署脚本(13 笔交易),检查角色、forwarder 和部署后的接线
test/                           Foundry 测试;fixtures/DrainVariants.sol 收录七种掏空变体
cre/veto/                       CRE 工作流:workflow.ts(capability)、airlock.ts(handler)、judge.ts(规则、
                                quorum)、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/                线上演示的运维工具包,附带服务器用的 systemd 单元
web/                            战情室(Vite、TypeScript、viem)
web/operator/, web/functions/   操作员模式:服务器端签名的七种固定提案(开发服务器、Pages Functions)
deployments/sepolia.json        线上部署记录
docs/design.md                  威胁模型、放行状态机、报告绑定、预算
docs/evidence/                  录制的线上运行记录
.github/workflows/ci.yml        合约、工作流和战情室的检查

安全模型

Upgrade Airlock 假定 owner 私钥会泄露,并据此设计:单凭 owner 私钥永远无法执行升级。

角色 持有方 权限
Owner(所有者) 一把私钥 提议升级以及对气闸本身的修改(guardian、冷静期、紧急暂停目标);breakGlass
Approver(审批人) 另一把独立私钥 CRE 无法给出结果时手动放行;审批 codehash、初始化数据和气闸修改(approveCodehash)
紧急 guardian(守护者) 另一把独立私钥 对待定提案执行 veto;breakGlass
Attester(签名者) CRE secret,独立的私钥 签署 AirlockReceiver 认可的 EIP-712 判决
AirlockReceiver CRE 报告接收合约 放行预言机(clear)和 guardian(veto、attachReason)
任何人 对已通过两道门的提案调用 execute

各角色之间绝不重叠。如果 owner 同时是 approver、guardian 或放行预言机,AirlockAdmin 的构造函数会直接拒绝(RoleOverlap);DeployEth 也只有在 owner、approver、紧急 guardian 和 attester 是四个不同地址时才会部署。

泄露的 owner 私钥可以提案。 但每个提案都要在公开状态下等完冷静期,同时接受 CRE 的判定,还需要一份 owner 无法自己给出的放行。owner 也可以拉下紧急制动(breakGlass),但这只会把代理合约切换到事先登记的暂停实现;要退出暂停状态,就得走一次过两道门的升级,而较早放行却从未执行的升级也能结束暂停。

同一把私钥无法:

  • 在没有放行的情况下执行:AirlockAdmin 没有通用的调用路径,更换实现的唯一途径是 execute 一个已放行的升级(或者用 breakGlass 切换到暂停实现);
  • 先削弱气闸:移除 CRE receiver、缩短冷静期或更换紧急暂停目标同样都是提案,没有 approver 的事先审批就会被 R7 否决;
  • 在判决之后偷换代码:execute 会重新检查目标的 codehash(CodehashChanged),EIP-7702 委托账户一律拒绝(DelegatedAccount);
  • 伪造或重放判决:AirlockReceiver 只接受满足以下条件的报告:来自配置的 forwarder、由 attester 签名、不晚于其 validUntilBlock,并且绑定到本链、本接收合约上该提案的 proposalDigest。

approver 的手动放行是 CRE 无法给出结果时的人工兜底:只要该提案仍在最新的 20 个 id 之内,定时扫描照样会判定它,而且 CRE 或 guardian 的否决优先。完整的威胁模型见 docs/design.md。

这些是测试网演示合约,存在已知限制,每一项都有一个通过的测试固定下来:放行永不过期;owner 私钥无法轮换(被盗的私钥可以在每次解除暂停后再次暂停代币);一个恶意 guardian 就能让代币一直保持暂停。完整列表见 docs/design.md。

技术栈

  • Solidity 0.8.37 和 Foundry,使用 OpenZeppelin Contracts 5.7.0(ProxyAdmin、TransparentUpgradeableProxy、EIP712、ECDSA)
  • Chainlink CRE:TypeScript SDK(@chainlink/cre-sdk 1.23.0)和 CRE CLI v1.37.0,运行在 Bun 1.4.2 上
  • viem:用于工作流和战情室中的 ABI 编码与链上读取
  • Vite 和 TypeScript:用于战情室

使用的 AI 工具

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-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) · [Русский](README.ru.md) · [Tiếng Việt](README.vi.md)
8
9[![Source: GitLab](https://img.shields.io/badge/source-GitLab-FC6D26?logo=gitlab&logoColor=white)](https://gitlab.com/jiapunk/upgrade-airlock)
10[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11[![Live demo](https://img.shields.io/badge/live%20demo-upgrade--airlock.pages.dev-2ea44f)](https://upgrade-airlock.pages.dev)
12[![Demo video](https://img.shields.io/badge/demo%20video-watch-red)](https://upgrade-airlock.pages.dev/video/)
13[![Ethereum Sepolia](https://img.shields.io/badge/Ethereum-Sepolia-627EEA?logo=ethereum&logoColor=white)](https://sepolia.etherscan.io/address/0x28e7aAC40560804F480e454bF9C12F50C83b700B)
14[![Chainlink CRE](https://img.shields.io/badge/Chainlink-CRE-375BD2?logo=chainlink&logoColor=white)](#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
22Upgrade Airlock 为每一次合约升级设置两道门。**外门**是按区块计算的冷静期;**内门**只有在链上记录了放行之后才会打开。自动放行的条件是:Chainlink CRE 工作流已用模拟器对新实现发起攻击并查询了四家独立的 RPC 服务商,至少两家返回结果且所有结果一致,工作流的共识步骤(`consensusIdenticalAggregation`)已就判决达成一致,并且一份带签名的**放行(Clear)**报告已经上链。CRE 完全无法给出判决时,approver(审批人)私钥可以附上记录在案的证据手动放行;只要该提案仍在最新的 20 个 id 之内,定时扫描照样会补判,CRE 或 guardian 的否决优先。被攻击搜索覆盖到的恶意实现拿到的则是一份带签名的**否决(Veto)**,即使攻击者握有升级私钥,它也不会被执行;有边界的搜索覆盖不到的部分,由严格模式再加一道人工代码审查。目前的判决来自服务器上运行本工作流的官方 CRE CLI(`cre workflow simulate --broadcast`);合约和工作流是为 DON 编写的,迁移到 DON 需要在 Chainlink 的生产环境 forwarder 上重新部署。本项目为 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> 战情室直接在浏览器里读取 Ethereum Sepolia,只读不写。每个提案都显示为一座气闸:**外门**记录提案,**气闸舱**为冷静期倒计时,**内门**始终锁住,直到放行上链(Chainlink CRE 的放行,或 approver 的人工放行,战情室会标明是人工放行)。一旦被否决,提案会被弹出舱外,同时列出对应的规则、证据哈希和交易。首次打开时是攻击选择器:每张卡(六种攻击和正常升级 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 时在每个节点上)在四家 RPC 服务商上用 `eth_simulateV1`,基于代理合约的真实状态执行这次升级。一个持有资金的陌生地址调用新字节码中的每一个选择器,并用填入持有者地址的参数逐一调用每个新函数,再对比调用前后的总供应量、所有权和余额。至少要有两家服务商返回结果,且所有结果必须一致。
33- **判决与提案绑定。** 工作流的一致共识步骤就 `{verdict, rule}` 达成一致。报告附带一份 EIP-712 签名证明,绑定提案的确切内容(`proposalDigest`)、链、接收合约和截止区块,另附一个任何人都能用链上数据重新算出的证据哈希。
34- **漏掉的日志会被补判。** cron 定时扫描每 60 秒检查最新的 20 个提案,补判 log trigger 漏掉的所有提案,包括 approver(审批人)手动放行的升级;它的否决优先。被挤出最新 20 个 id 之外的提案不再被判定:除非 approver 已手动放行,否则它保持未放行(见[扫描窗口](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 个工作流单元测试,以及 394 项战情室自检中的 387 项(其余 7 项需要 forge 编译产物和 cast,CI 任务里没有);另有一个分叉链端到端脚本,通过 CRE 模拟器跑通完整闭环。
37
38## 要解决的问题
39
40可升级的代理合约把整个协议的安危都压在一把升级私钥上。私钥一旦泄露,攻击路径非常短:部署恶意实现,把代理合约指向它,然后增发或掏空资金。这样的事已经发生过很多次:
41
42- **PAID Network,2021 年 3 月。** 代理合约所有者的私钥被用来把经过审计的代币代码替换为带有销毁和铸造函数的版本,攻击者铸出约 5950 万枚 PAID。([CertiK 事后分析](https://www.certik.com/blog/paid-network-post-mortem))
43- **Ankr,2022 年 12 月。** 攻击者利用泄露的部署者私钥,把 aBNBc 的实现替换为不做权限检查就能铸币的版本,铸出 60 万亿枚 aBNBc。([Ankr 官方报告](https://www.ankr.com/blog/the-abnbc-token-report/))
44- **Radiant Capital,2024 年 10 月。** 攻击者控制了 11 个多签签名者中的 3 个,接管借贷池的 provider 合约并升级借贷池的实现,卷走 5300 多万美元。([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
51sequenceDiagram
52 actor Owner as owner 私钥(假设会泄露)
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 个区块的冷静期,在 Sepolia 上约 5 分钟
62 Admin-->>CRE: Proposed 事件日志(EVM log trigger),或每 60 秒一次的 cron 定时扫描
63 CRE->>Admin: 在提案区块执行 EVM 读取
64 loop 每个节点上(模拟器中为一个,部署后为每个 DON 节点),逐一请求每家服务商
65 CRE->>RPC: eth_getCode,然后用 eth_simulateV1 做攻击模拟
66 RPC-->>CRE: 判决和规则
67 end
68 Note over CRE: Quorum:至少 2 个结果且全部一致,否则以 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
811. **提案。** `AirlockAdmin` 是 OpenZeppelin `ProxyAdmin` 的所有者,也是代币 `Ownable` 的所有者。owner 私钥只能提案:它永远不会是 guardian(守护者)、approver(审批人)或放行预言机。每个提案(升级,或对气闸本身的修改:guardian、冷静期、紧急暂停目标)都会记录 `readyBlock = block.number + delayBlocks`。升级目标和紧急暂停目标都必须是普通的合约代码:EIP-7702 委托账户的私钥可以在判决之后把它重新指向别处,因此一律拒绝(`DelegatedAccount`)。
822. **判定。** CRE 的 log trigger 在 `Proposed` 事件发出时触发。对气闸本身的修改根据链上读取的数据判定(R7)。升级先过静态规则(R5、R8),再进入固定在提案区块上的攻击模拟:代理合约的 ERC-1967 实现槽被覆盖为新代码,一个全新的陌生地址(由提案区块哈希推导而来)像真实用户一样被注入资金,包括来自第二个持有者的代币和 ETH。先由持有者完成一笔转账,再由陌生地址在没有授权额度的情况下调用 `transferFrom`,然后用五种参数形式调用新字节码中找到的每一个选择器。凡是不属于标准代币接口的选择器,还会再用持有者参数调用一遍:持有资金的持有者依次填入前四个参数位置,测试金额填入另一个位置,其余位置填入陌生地址或代理合约地址。调用前后都会读取总供应量、owner 和每个受监控的余额,每次持有者参数调用之后立即读取持有者的余额,并检查每一次探测自身发出的事件(R1 至 R4、R3B)。最后扫描新字节码,找出在真实区块上与在模拟中行为不同的操作码(R9)。
833. **达成一致。** 每个节点(DON 部署中为每个 DON 节点;目前为 CLI 模拟器)都在四家服务商上运行模拟。至少要有两家返回结果,且所有结果必须是同一个判决;其他情况一律否决(R6)。随后共识步骤就完全相同的 `{verdict, rule}` 达成一致。
844. **报告。** 工作流对规范化的证据 JSON 计算哈希,用 attester 私钥(CRE secret)签署一份 EIP-712 判决,再通过 forwarder 写入报告。`AirlockReceiver` 检查发送方、时效、签名,以及判决是否绑定到这一个提案(`proposalDigest`),然后在 `AirlockAdmin` 上调用 `clear`,或调用 `veto` 和 `attachReason`。
855. **执行。** 冷静期结束后,任何人都可以调用 `execute(id)`。冷静期内会以 `TooEarly` revert,没有放行时以 `NotCleared` revert,被否决后以 `ProposalVetoed` revert;如果目标地址上的代码已不是当初提案并经过判定的那一份,则以 `CodehashChanged` revert。否决永远优先于放行。
866. **定时扫描。** cron trigger 每 60 秒重新检查最新的提案,凡是仍待处理、CRE 尚未答复(`oracleJudged`)的提案都会被补判:log trigger 漏掉的事件、大到无法投递的 `Proposed` 事件,或 approver 手动放行的升级。它的否决优先于这次手动放行。
87
88**所有失败都指向同一个方向。** 漏掉事件日志、RPC 宕机、服务商结果不一致或运行失败,结果都是提案得不到放行或被否决:只会推迟升级,绝不会让升级通过。当 CRE 完全无法给出结果时,approver 私钥可以附上记录在案的证据手动放行;CRE 恢复后,定时扫描仍会判定这个升级,CRE 或 guardian 的否决依然优先。`breakGlass()` 允许 owner 或任一 guardian 立即把代理合约切换到事先登记的暂停实现;退出暂停状态则是一次升级,同样要过两道门;已放行的提案永不过期,因此较早放行的升级也能结束暂停(见[已知限制](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`,或者它的某次探测转走了别人的代币、铸币、销毁、转移所有权,或使持有者的余额减少;即使没有发出事件,或之后的调用又把变化撤销,也同样否决 | 攻击模拟 |
102| R4 `TRANSFER_LIVENESS` | 持有者的普通转账失败 | 攻击模拟 |
103| R5 `STRICT_CODEHASH` | 已开启严格模式,且 approver 尚未审批该 codehash | 链上读取 |
104| R6 `SIM_FAILURE` | 模拟无法得出结论:服务商结果不一致、返回结果太少、代码不匹配、view 调用或陌生地址的注资发生 revert,或探测超出了配置的请求容量 | 模拟与 quorum |
105| R7 `CONFIG_GUARD` | 修改 guardian、缩短冷静期或设置新的紧急暂停目标,却没有 approver 的事先审批 | 链上读取 |
106| R8 `INIT_DATA` | 升级附带初始化数据,而 approver 没有连同 codehash 一起审批这些数据 | 链上读取 |
107| R9 `NEW_OPCODES` | 新代码包含配置中固定允许清单(默认为空)未覆盖的区块或交易上下文操作码(BASEFEE、TIMESTAMP、NUMBER、ORIGIN、GAS 等)、外部调用、create 或 selfdestruct,除非 approver 审批的正是这个 codehash | 字节码 |
108
109**R9 堵住的是模拟规避。** `EvilBaseFeeImpl` 在 `block.basefee == 0` 时(这正是 `eth_simulateV1` 调用看到的情况)表现得和 v1 一模一样,到了真实区块上却允许任何人铸币。模拟中的所有不变量检查都能通过,但它的 `BASEFEE` 操作码让它被否决。允许清单从不依赖代理合约当前运行的代码,因此一次审批只覆盖一个 codehash,不会延伸到之后的任何升级。只有当编译器的 metadata 尾段是格式完全符合 solc 规范的 CBOR map,且没有任何指令能执行到它时,扫描才会跳过这一段;其他情况一律当作代码扫描。审批某个 codehash 只会让它免于 R9,并容许探测被截断(R6),仅此而已:其他规则照常执行。
110
111**R3B 堵住的是无声掏空。** `EvilDrainImpl` 就是 v1 加上一个没有任何权限控制的 `rescue(from, to, amount)`。它的总供应量、owner、转账和操作码都与 v1 相同,所以 R1 至 R4 和 R9 全部通过。`rescue` 不是标准代币函数,因此陌生地址还会用持有者参数调用它;把持有者作为 `from` 的那次调用转走了持有者的代币,R3B 随即将其否决。即便掏空函数只对“持有代币或 ETH 的调用者”开放,也同样逃不过,因为陌生地址两者都有。
112
113### 自动模式与严格模式
114
115**自动模式**为默认模式:CRE 通过有边界的攻击搜索,自主放行或否决每一次升级,搜索范围包括一个持有资金的外部调用者、新代码中的每一个选择器,以及把持有者依次填入每个新函数前四个参数位置的调用。**严格模式**(R5)再加一道人工审查:每个新实现的 codehash 都必须先经 approver 私钥审批,攻击模拟照常运行。自动模式适合希望常规升级在几分钟内获得放行的代币;严格模式则把代码审查纳入放行流程(详见[两种模式](docs/design.md#two-modes-automatic-and-strict))。
116
117## Chainlink CRE
118
119CRE 是 Upgrade Airlock 的编排层:工作流是穿过内门的唯一自动化通道,从链上触发到链上判决的每一步都由它完成。工作流使用 CRE TypeScript SDK(`@chainlink/cre-sdk` 1.23.0)编写,并用 CRE CLI v1.37.0 编译为 WASM。
120
121| Chainlink 技术 | 位置 | 在本项目中的作用 |
122|---|---|---|
123| CRE workflow runner | [`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) | 两个 handler,target 为 `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) | 以 `LATEST` 置信度监听 `AirlockAdmin.Proposed`:提案一出现就开始判定,比它最早可执行的时间早了整整一个冷静期 |
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| node mode 下的 HTTP capability | [`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),quorum 逻辑见 [`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 attester 私钥,每次运行只获取一次 |
131| Report + `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| Forwarder + `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` 只接受配置的 forwarder,并在已配置时校验 workflow id 和 owner |
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 handler;每一次放行和否决都是一笔真实的 Sepolia 交易,经由 Chainlink 在 Sepolia 上的 MockKeystoneForwarder [`0x15fC6ae953E024d975e77382eEeC56A9101f9F88`](https://docs.chain.link/cre/guides/workflow/using-evm-client/forwarder-directory-ts) 上链。`AirlockReceiver` 无需任何改动即可用于 DON 部署:部署时把 `FORWARDER` 设为生产环境的 KeystoneForwarder,并设置 `WORKFLOW_ID` / `WORKFLOW_OWNER`,`onReport` 就会同时校验 DON 签名的工作流 metadata。除非这两项都已设置,或通过 `ALLOW_MOCK_FORWARDER=1` 明确允许,否则 `DeployEth` 会拒绝使用无需许可的 MockKeystoneForwarder([`script/DeployEth.s.sol#L96-L104`](script/DeployEth.s.sol#L96-L104))。
137
138**按 CRE 单次运行限额设计。** 最多 15 次链上读取(Multicall3 批量读取,每批都低于 5 kb 的 payload 上限)、四家服务商占用 15 次 HTTP 调用中的 12 次、每次运行一次攻击模拟、共识值最多 52 字节。完整预算见 [docs/design.md](docs/design.md#budgets)。
139
140## <a id="live-on-sepolia"></a>Sepolia 链上运行
141
142Ethereum Sepolia,chain id 11155111。合约通过 [`script/DeployEth.s.sol`](script/DeployEth.s.sol) 在区块 11950684–11950699 部署(13 笔交易;AirlockAdmin 位于区块 11950697),均已在 Sourcify 上完成验证。owner、approver、紧急 guardian 和 attester 是四把不同的私钥。冷静期为 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(紧急暂停目标) | [`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 判决 | 提案交易 | 报告交易 | 后续 |
163|---|---|---|---|---|---|
164| `v2`:正常升级 | #4 | 放行 | [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 | 否决 R1 | [11950735](https://sepolia.etherscan.io/tx/0x862c5fdf826a8fef09fe2ff278d3e58191f93c267dbdd38ad9aaa270f39937b0) | [11950736 (+1)](https://sepolia.etherscan.io/tx/0xb0d74e25e2528fb9aad7d21de202dac288cd256048f2986b5214280fda4b84f6) | `execute` 以 `ProposalVetoed` revert |
166| `evil-owner`:任何人都能夺取所有权 | #6 | 否决 R2 | [11950788](https://sepolia.etherscan.io/tx/0x32eb22039bd43602e0406b1a8f9a02c5cbcdb5cf2ef1c389919a45d05820da25) | [11950790 (+2)](https://sepolia.etherscan.io/tx/0x49451bee7a2f3351b18992d94b5f89b9ff89cf53be65a937400ab95fb79be551) | `execute` 以 `ProposalVetoed` revert |
167| `evil-fee`:转账暗扣 10% | #7 | 否决 R3 | [11950792](https://sepolia.etherscan.io/tx/0xe06924c2e3961ab27d2803c0654b783fc06e52107345bda9bfeff391053fcad3) | [11950794 (+2)](https://sepolia.etherscan.io/tx/0x01f6055a683e0dabc8f34ae094e5daa5a81ddb11c687aa9c5ac6e30c7a7f3d3b) | `execute` 以 `ProposalVetoed` revert |
168| `evil-basefee`:到了真实区块才开放铸币 | #3 | 否决 R9 | [11950742](https://sepolia.etherscan.io/tx/0x3c29c118ca395ca92500abcaae6126244924fbd8e14f846af57b70f113cad6e1) | [11950743 (+1)](https://sepolia.etherscan.io/tx/0xb6db2d832112384287e5a7ccdd086127931360900d09c80645c21eac2bc165db) | `execute` 以 `ProposalVetoed` revert |
169| `evil-drain`:任何人都能转走持有者的代币 | #2 | 否决 R3B | [11950737](https://sepolia.etherscan.io/tx/0x0e52369921040eb0c901ac3a077aff762d370b4efd9503a52c91360d56366b16) | [11950740 (+3)](https://sepolia.etherscan.io/tx/0x40bbe374a8cee47a94da977f973cf66a67573c12bc84117483fcbd11430f90cb) | `execute` 以 `ProposalVetoed` revert |
170| `remove-receiver`:把 CRE 从 guardian 中移除 | #5 | 否决 R7 | [11950749](https://sepolia.etherscan.io/tx/0xda2bd1c9dc5f7770da3926358b8da3ffe5a1cd3154b499cafae440387eebb934) | [11950750 (+1)](https://sepolia.etherscan.io/tx/0xaa2f598bdcc7f46727e5456528521e50ac5c1d8e423805042cbf2d10a4c4cdac) | `execute` 以 `ProposalVetoed` revert |
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
185git clone https://gitlab.com/jiapunk/upgrade-airlock.git # 或 https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
186cd upgrade-airlock
187
188# 合约(OpenZeppelin 和 forge-std 已随仓库放在 lib/ 中)
189forge build # 首次运行时下载固定版本的 solc 0.8.37
190forge test --offline # 117 个测试
191
192# CRE 工作流
193cre/setup.sh
194export PATH="$PWD/cre/.tools/bin:$PWD/cre/.tools/node_modules/.bin:$PATH"
195(cd cre/veto && bun test && bun run typecheck) # 118 个测试:规则、探测、字节码、报告、预算、handler
196
197# 战情室
198(cd web && npm ci && npm run selftest) # 394 项检查:实时视图与攻击选择器、离线回放、操作员 API
199```
200
201### 在 Sepolia 分叉链上端到端运行
202
203```bash
204cre login # 只需一次;模拟器需要 CRE 账号
205cre/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`;正常的 v2 被放行,并在冷静期后执行;把 receiver 从 guardian 中移除的提案被 R7 否决;经 approver 事先审批的缩短冷静期提案被放行。
211- **定时扫描:** 无人判定过的升级在冷静期后执行会以 `NotCleared` revert,一次扫描将其放行后即可执行;approver 手动放行的两个提案由下一次扫描判定:EvilDrain 被 R3B 否决(否决压过手动放行),延长冷静期的提案也再次由 CRE 放行;带 6 KB 初始化数据的升级在再下一次扫描中被 R8 否决。
212- **证据:** 每个被否决的提案执行时都以 `ProposalVetoed` revert,每个判决的证据哈希都会用链上数据重新计算核对([`cre/veto/tools/check-evidence.ts`](cre/veto/tools/check-evidence.ts))。
213
214### Sepolia 运维工具包
215
216[`scripts/sepolia/`](scripts/sepolia/README.md) 用于运行线上演示。私钥存放在仓库之外:`KEY_DIR` 中每个文件存一把十六进制私钥(`owner.key`、`attester.key`、`sender.key`、`approver.key`、`emergency.key`,另可选 `sweeper.key`);脚本从不打印私钥,而且只有当 owner、approver、紧急 guardian 和 attester 是四把不同的私钥时,`deploy.sh` 才会执行部署。每个脚本也都能通过 `FORK=<port>` 在本地分叉链上运行,整套演示可以零成本彩排。
217
218```bash
219scripts/sepolia/deploy.sh # 13 笔交易,写出 deployments/sepolia.json(FORCE=1 时替换已有部署)
220bun scripts/sepolia/make-config.mjs # 生成工作流配置:4 家服务商,quorum 2
221scripts/sepolia/fund-sender.sh 0.05 # 为报告交易充值 ETH
222scripts/sepolia/airlock.sh # 终端 A:CRE 监听器(log trigger)
223scripts/sepolia/propose.sh v2 # 终端 B:随后依次提交 evil-mint、evil-owner、evil-fee、evil-basefee、evil-drain、remove-receiver
224scripts/sepolia/execute.sh <id> # 冷静期后执行已放行的提案,否则显示 revert 原因
225scripts/sepolia/status.sh # 列出每个提案:判决、CRE 是否已判定、规则、证据、交易
226```
227
228`sweep.sh` 运行一次 cron 定时扫描,`clear.sh` 是 approver 的手动放行,`fork.sh` 用于启动、保存和恢复 Sepolia 分叉链,`probe-rpcs.ts` 用工作流自身的判定代码检查每家服务商,`systemd/` 则在 Linux 服务器上运行监听器和定时扫描。
229
230### 战情室
231
232```bash
233cd web
234npm ci
235npm run dev # http://127.0.0.1:5173/
236npm 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/`):在开发服务器上只接受本机连接;在 Cloudflare Pages 上要凭口令,而且只有 `OWNER_KEY` 和 `OPERATOR_TOKEN` 两个 secret 都存在时才会开启。它通过 `POST /api/operator/propose` 发出同样七种固定提案之一,由服务器端用 owner 私钥签名(设计本来就假设这把私钥会泄露:它只能提案);公众访客的页面从不调用它,见[操作员模式](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```
254src/AirlockAdmin.sol 两道门:提案、冷静期、guardian 否决、放行、执行、breakGlass
255src/cre/AirlockReceiver.sol CRE 报告接收合约:forwarder、工作流 metadata、EIP-712 签名证明、digest 绑定
256src/cre/IReceiver.sol Chainlink CRE 接收接口
257src/DemoBTC.sol, DemoBTCV2.sol 演示代币(dBTC)v1 和正常的 v2
258src/PausedImpl.sol 紧急暂停目标:任何余额变动都会 revert
259src/attack/ EvilMintImpl、EvilOwnerImpl、EvilFeeImpl、EvilBaseFeeImpl、EvilDrainImpl
260script/DeployEth.s.sol 部署脚本(13 笔交易),检查角色、forwarder 和部署后的接线
261test/ Foundry 测试;fixtures/DrainVariants.sol 收录七种掏空变体
262cre/veto/ CRE 工作流:workflow.ts(capability)、airlock.ts(handler)、judge.ts(规则、
263 quorum)、simulate.ts(eth_simulateV1)、bytecode.ts(选择器、R9)、report.ts
264 (报告、EIP-712、证据)、chain.ts(读取预算)、keys.ts、rules.ts、config.ts
265cre/veto/test/ Bun 单元测试
266cre/veto/tools/ check-evidence.ts:重新计算每个链上证据哈希
267cre/scripts/fork-e2e.sh 在 anvil 分叉的 Sepolia 上端到端运行
268cre/setup.sh 固定版本的 CRE CLI 和 bun
269scripts/sepolia/ 线上演示的运维工具包,附带服务器用的 systemd 单元
270web/ 战情室(Vite、TypeScript、viem)
271web/operator/, web/functions/ 操作员模式:服务器端签名的七种固定提案(开发服务器、Pages Functions)
272deployments/sepolia.json 线上部署记录
273docs/design.md 威胁模型、放行状态机、报告绑定、预算
274docs/evidence/ 录制的线上运行记录
275.github/workflows/ci.yml 合约、工作流和战情室的检查
276```
277
278## <a id="security-model"></a>安全模型
279
280Upgrade Airlock 假定 owner 私钥会泄露,并据此设计:单凭 owner 私钥永远无法执行升级。
281
282| 角色 | 持有方 | 权限 |
283|---|---|---|
284| Owner(所有者) | 一把私钥 | 提议升级以及对气闸本身的修改(guardian、冷静期、紧急暂停目标);`breakGlass` |
285| Approver(审批人) | 另一把独立私钥 | CRE 无法给出结果时手动放行;审批 codehash、初始化数据和气闸修改(`approveCodehash`) |
286| 紧急 guardian(守护者) | 另一把独立私钥 | 对待定提案执行 `veto`;`breakGlass` |
287| Attester(签名者) | CRE secret,独立的私钥 | 签署 `AirlockReceiver` 认可的 EIP-712 判决 |
288| AirlockReceiver | CRE 报告接收合约 | 放行预言机(`clear`)和 guardian(`veto`、`attachReason`) |
289| 任何人 | | 对已通过两道门的提案调用 `execute` |
290
291各角色之间绝不重叠。如果 owner 同时是 approver、guardian 或放行预言机,`AirlockAdmin` 的构造函数会直接拒绝(`RoleOverlap`);`DeployEth` 也只有在 owner、approver、紧急 guardian 和 attester 是四个不同地址时才会部署。
292
293**泄露的 owner 私钥可以提案。** 但每个提案都要在公开状态下等完冷静期,同时接受 CRE 的判定,还需要一份 owner 无法自己给出的放行。owner 也可以拉下紧急制动(`breakGlass`),但这只会把代理合约切换到事先登记的暂停实现;要退出暂停状态,就得走一次过两道门的升级,而较早放行却从未执行的升级也能结束暂停。
294
295**同一把私钥无法:**
296
297- 在没有放行的情况下执行:`AirlockAdmin` 没有通用的调用路径,更换实现的唯一途径是 `execute` 一个已放行的升级(或者用 `breakGlass` 切换到暂停实现);
298- 先削弱气闸:移除 CRE receiver、缩短冷静期或更换紧急暂停目标同样都是提案,没有 approver 的事先审批就会被 R7 否决;
299- 在判决之后偷换代码:`execute` 会重新检查目标的 codehash(`CodehashChanged`),EIP-7702 委托账户一律拒绝(`DelegatedAccount`);
300- 伪造或重放判决:`AirlockReceiver` 只接受满足以下条件的报告:来自配置的 forwarder、由 attester 签名、不晚于其 `validUntilBlock`,并且绑定到本链、本接收合约上该提案的 `proposalDigest`。
301
302approver 的手动放行是 CRE 无法给出结果时的人工兜底:只要该提案仍在最新的 20 个 id 之内,定时扫描照样会判定它,而且 CRE 或 guardian 的否决优先。完整的威胁模型见 [docs/design.md](docs/design.md#threat-model)。
303
304这些是测试网演示合约,存在已知限制,每一项都有一个通过的测试固定下来:放行永不过期;owner 私钥无法轮换(被盗的私钥可以在每次解除暂停后再次暂停代币);一个恶意 guardian 就能让代币一直保持暂停。完整列表见 [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## 使用的 AI 工具
314
315Upgrade Airlock 在作者的主导下使用 [Claude Code](https://claude.com/claude-code) 构建:合约、CRE 工作流、测试、脚本、战情室以及这些文档,都是借助它编写并检查的。
316
317## 许可证
318
319[MIT](LICENSE) © 2026 jiapunk