diff --git a/packages/contracts-bedrock/book/src/dispute/tee/TEE_DISPUTE_GAME_SPEC.md b/packages/contracts-bedrock/book/src/dispute/tee/TEE_DISPUTE_GAME_SPEC.md new file mode 100644 index 0000000000000..b3c141ffd2617 --- /dev/null +++ b/packages/contracts-bedrock/book/src/dispute/tee/TEE_DISPUTE_GAME_SPEC.md @@ -0,0 +1,620 @@ +# TEE Dispute Game 规格说明 + +## 1. 概述 + +TeeDisputeGame 是 OP Stack 的争议游戏合约,用 **TEE(可信执行环境)ECDSA 签名验证** 替代交互式二分法(FaultDisputeGame)和 ZK 证明验证(OPSuccinctFaultDisputeGame),实现批量状态转移证明。 + +**目标**:利用 AWS Nitro Enclave 远程证明,实现更快、更低成本的争议解决。TEE 执行器在 enclave 内运行状态转移,用注册的 enclave 密钥签署结果,链上合约验证 ECDSA 签名。 + +**在 OP Stack 中的定位**: +- 通过标准 `DisputeGameFactory` 创建游戏(Clone 模式) +- 与 `AnchorStateRegistry` 集成,管理锚定状态、最终化和有效性检查 +- 使用共享 Types 库中的 `BondDistributionMode`(NORMAL/REFUND) +- 实现 `IDisputeGame` 接口,兼容 OP Stack 标准争议游戏框架(TZ 不使用 OptimismPortal,见 Section 12) +- 游戏类型常量:`1960` + +--- + +## 2. 架构 + +### 合约关系图 + +``` + +---------------------------+ + | DisputeGameFactory | + | (创建 Clone 代理) | + +-----+----------+----------+ + | | + create() | | gameAtIndex() + v v + +--------------------------+ + | TeeDisputeGame | + | (Clone 代理实例) | + +----+-------+-------+-----+ + | | | + +----------+ +----+----+ +----------+ + v v v v + +----------------+ +---------+ +------------------+ +---------------+ + | PROPOSER / | | Anchor | | TeeProofVerifier | | IDisputeGame | + | CHALLENGER | | State | | (enclave ECDSA | | (接口) | + | (不可变地址) | | Registry| | 签名验证) | | | + +----------------+ +---------+ +-------+----------+ +---------------+ + v + +------------------+ + | IRiscZeroVerifier| + | (仅用于 enclave | + | 注册时的 ZK 验证)| + +------------------+ +``` + +### 不可变量(constructor 设置,所有 Clone 共享) + +| 不可变量 | 类型 | 说明 | +|--------------------------|-------------------------|------------------------------------------| +| `GAME_TYPE` | `GameType` | 固定为 `GameType.wrap(1960)` | +| `MAX_CHALLENGE_DURATION` | `Duration` | 挑战者提交挑战的时间窗口 | +| `MAX_PROVE_DURATION` | `Duration` | 挑战后证明者提交证明的时间窗口 | +| `DISPUTE_GAME_FACTORY` | `IDisputeGameFactory` | 创建此游戏的工厂合约 | +| `TEE_PROOF_VERIFIER` | `ITeeProofVerifier` | TEE 签名验证合约 | +| `CHALLENGER_BOND` | `uint256` | 挑战所需的固定保证金金额 | +| `ANCHOR_STATE_REGISTRY` | `IAnchorStateRegistry` | 锚定状态管理合约 | +| `PROPOSER` | `address` | 唯一允许的提议者地址 | +| `CHALLENGER` | `address` | 唯一允许的挑战者地址 | + +### rootClaim 格式 + +``` +rootClaim = keccak256(abi.encode(blockHash, stateHash)) +``` + +blockHash 和 stateHash 通过 extraData 传入。这与 FaultDisputeGame 不同——FDG 的 rootClaim 直接是 output root hash。 + +### Clone 不可变参数布局(CWIA) + +Factory 通过 `create()` 创建 Clone 时,将以下字段追加到 proxy bytecode 末尾(Clone With Immutable Args 模式)。所有字段在创建后不可变。 + +| 偏移量 | 字段 | 类型 | 大小 | 说明 | +|--------|------|------|------|------| +| `0x00` | `gameCreator` | `address` | 20 bytes | 调用 Factory.create() 的地址 | +| `0x14` | `rootClaim` | `Claim` (bytes32) | 32 bytes | 提议的状态声明 | +| `0x34` | `l1Head` | `Hash` (bytes32) | 32 bytes | 创建时的 L1 区块哈希 | +| `0x54` | `l2SequenceNumber` | `uint256` | 32 bytes | 声明对应的 L2 区块号 | +| `0x74` | `parentIndex` | `uint32` | 4 bytes | 父游戏在 Factory 中的索引(`0xFFFFFFFF` = 无父游戏) | +| `0x78` | `blockHash` | `bytes32` | 32 bytes | L2 区块哈希(用于构造 rootClaim) | +| `0x98` | `stateHash` | `bytes32` | 32 bytes | L2 状态哈希(用于构造 rootClaim) | + +`extraData()` 返回偏移量 `0x54` 起的 100 bytes,即 `l2SequenceNumber + parentIndex + blockHash + stateHash`。 + +### 关键设计说明 + +- `wasRespectedGameTypeWhenCreated`:仅为兼容 `IDisputeGame` 接口保留,TZ 不使用 OptimismPortal,此字段无实际消费方(见 Section 12) +- 每个游戏实例使用单个 `ClaimData` 结构体(非数组),区别于 FDG 的追加式 DAG + +--- + +## 3. 游戏生命周期 + +`DisputeGameFactory.create()` 通过 Clone 模式创建游戏实例后立即调用 `initialize()`,进入状态机。 + +### 状态机 + +``` + initialize() + | + v + +---------------+ + | Unchallenged | <-- deadline = now + MAX_CHALLENGE_DURATION + +-------+-------+ + | + +------------------+------------------+ + | | + challenge() deadline 过期 + | | + v v + +-------------+ resolve() -> DEFENDER_WINS + | Challenged | <-- deadline = now + MAX_PROVE_DURATION + +------+------+ + | + +----------+----------+ + | | + prove() deadline 过期 + | | + v v ++---------------------------+ resolve() -> CHALLENGER_WINS +| ChallengedAndValid | +| ProofProvided | ++----------+---------------+ + | + v + resolve() -> DEFENDER_WINS +``` + +Unchallenged 状态下提前 prove 的路径: + +``` + Unchallenged --> prove() --> UnchallengedAndValidProofProvided --> resolve() --> DEFENDER_WINS +``` + +**重要约束**:`prove()` 内部检查 `gameOver()`,如果 deadline 已过期,prove() 会 revert `GameOver()`。因此 Unchallenged 状态下 prove 只能在 challenge deadline 过期之前调用。 + +### ProposalStatus 转移表 + +| 起始状态 | 动作 | 目标状态 | +|--------------------------------------|-------------|---------------------------------------| +| `Unchallenged` | `challenge()`| `Challenged` | +| `Unchallenged` | `prove()` | `UnchallengedAndValidProofProvided` | +| `Challenged` | `prove()` | `ChallengedAndValidProofProvided` | +| 任意非 Resolved | `resolve()` | `Resolved` | + +以上是全部合法转移路径,其他任何转移都不应发生。 + +### GameStatus 转移表 + +| 条件 | 结果 | +|-----------------------------------------------|------------------| +| 父游戏 resolve 为 CHALLENGER_WINS | CHALLENGER_WINS | +| Unchallenged + deadline 过期 | DEFENDER_WINS | +| Challenged + deadline 过期(无证明) | CHALLENGER_WINS | +| UnchallengedAndValidProofProvided | DEFENDER_WINS | +| ChallengedAndValidProofProvided | DEFENDER_WINS | + +### gameOver 条件 + +当 deadline 过期(严格小于 `block.timestamp`)或有效证明已提交时,游戏"结束"——不再接受 challenge 或 prove。 + +--- + +## 4. 挑战-证明模型 + +### 与 FaultDisputeGame 的关键差异 + +| 维度 | TeeDisputeGame | FaultDisputeGame | +|------|---------------|------------------| +| 证明机制 | TEE ECDSA 签名(单轮) | 交互式二分法 + VM step(多轮) | +| 解决复杂度 | O(1) | O(n) | +| 保证金托管 | 原生 ETH(直接持有) | DelayedWETH(7 天延迟 + 紧急恢复) | +| 保证金模型 | 固定 CHALLENGER_BOND | 基于位置的 bond 曲线 | +| 时间模型 | 固定 deadline | 棋钟 + 延长 | +| 访问控制 | 白名单 proposer + challenger | 无权限(permissionless) | +| 父子链 | 显式 parentIndex | 无(仅 ASR 锚定) | +| Claim 结构 | 单个 ClaimData | 追加式 ClaimData[] DAG | + +### challenge() + +仅白名单 CHALLENGER 可调用。提交固定金额保证金,将游戏从 Unchallenged 转为 Challenged,并重置 deadline 为 prove 窗口。 + +**前置条件**(任一不满足则 revert): + +| 检查 | revert | 说明 | +|------|--------|------| +| `claimData.status == Unchallenged` | `ClaimAlreadyChallenged` | 每个游戏最多一次挑战 | +| `msg.sender == CHALLENGER` | `BadAuth` | 白名单访问控制 | +| `gameOver() == false` | `GameOver` | deadline 未过期且无有效证明 | +| `msg.value == CHALLENGER_BOND` | `IncorrectBondAmount` | 保证金金额精确匹配 | + +**后置条件**: +- `claimData.counteredBy = msg.sender` +- `claimData.status = Challenged` +- `claimData.deadline = block.timestamp + MAX_PROVE_DURATION`(重置为 prove 窗口) +- `refundModeCredit[msg.sender] += msg.value` + +### prove() + +仅 proposer 可调用——防止第三方抢先提交观察到的证明数据窃取 prover 身份。 + +**前置条件**(任一不满足则 revert): + +| 检查 | revert | 说明 | +|------|--------|------| +| `msg.sender == proposer` | `BadAuth` | 只有创建游戏的 proposer 能证明 | +| `status == IN_PROGRESS` | `ClaimAlreadyResolved` | 游戏未被 resolve | +| `gameOver() == false` | `GameOver` | deadline 未过期且无有效证明 | +| `proofs.length > 0` | `EmptyBatchProofs` | 至少一个 batch | +| batch chain 验证通过 | 各专用 error | 见 Section 5 批量证明验证概述 | + +**后置条件**: +- `claimData.prover = msg.sender` +- 状态转移:`Unchallenged → UnchallengedAndValidProofProvided` 或 `Challenged → ChallengedAndValidProofProvided` +- `gameOver()` 立即返回 true(证明即终局) + +**关键设计决策**: +- **提前证明**:prove() 可在 Unchallenged 状态下调用(无需等待挑战),因为 TEE 被信任,有效证明即意味着 claim 正确 +- **证明即终局**:一旦证明成功,gameOver() 立即为 true,阻止后续 challenge()——这是有意设计,不是 bug +- **无需保证金**:证明者不需要质押,激励及时响应挑战 + +### resolve() + +任何人可调用。根据当前状态和父游戏结果确定最终胜负,分配 normalModeCredit。 + +**前置条件**(任一不满足则 revert): + +| 检查 | revert | 说明 | +|------|--------|------| +| `status == IN_PROGRESS` | `ClaimAlreadyResolved` | 只能 resolve 一次 | +| `parentGameStatus != IN_PROGRESS` | `ParentGameNotResolved` | 父游戏必须先 resolve | +| `gameOver() == true`(当父游戏非 CHALLENGER_WINS 时) | `GameNotOver` | deadline 已过或有效证明已提交 | + +**后置条件**: +- `status` 设为 `DEFENDER_WINS` 或 `CHALLENGER_WINS`(不可逆) +- `claimData.status = Resolved` +- `resolvedAt = block.timestamp` +- 恰好一个地址的 `normalModeCredit` 被设为 `address(this).balance`(见 Section 6 保证金分配表) + +--- + +## 5. TEE 证明安全模型 + +### 信任链 + +TEE 证明本质上是 **Owner 控制的备案制**: + +``` +Owner + └─ register() ──→ TeeProofVerifier 备案 enclave EOA + └─ verifyBatch() ──→ 检查签名者是否已备案 + └─ TeeDisputeGame.prove() 接受 +``` + +**核心信任假设**:合约无条件信任 Owner 注册的 TEE EOA 签署的状态转移。ZK 证明(RISC Zero)仅用于注册环节验证 TEE attestation 的合法性,不参与运行时的 batch 验证。 + +**信任边界**: +- Owner 有权注册恶意 enclave +- 已注册 enclave 签署的任何 batch digest 都会被接受 +- 链上不验证状态转移的正确性,只验证"签名者是否在备案名单中" + +### Enclave 生命周期 + +| 阶段 | 动作 | 控制方 | +|------|------|--------| +| 注册 | `register(seal, attestationData)` — ZK 证明验证 Nitro attestation 后备案 EOA | Owner | +| 运行 | `verifyBatch(digest, signature)` — ecrecover + 检查备案状态 | 任何人(view) | +| 单个撤销 | `revoke(address)` — 移除单个备案 | Owner | +| 批量撤销 | `revokeAll()` — 递增 generation,O(1) 撤销所有备案 | Owner | + +### 批量证明验证概述 + +`prove()` 接受 `BatchProof[]` 数组,验证从 `startingOutputRoot` 到 `rootClaim` 的完整状态转移链: + +1. 首个 batch 的起点必须等于锚定状态 +2. 相邻 batch 首尾相连(链式连续性) +3. L2 区块号严格单调递增 +4. 每个 batch 的 EIP-712 签名由已备案 enclave 签署 +5. 末尾 batch 的终点必须等于 rootClaim,区块号等于 l2SequenceNumber + +### EIP-712 签名方案 + +batchDigest 使用 EIP-712 typed data hash,domainSeparator 包含 `block.chainid` + `TEE_PROOF_VERIFIER` 地址,提供跨链和跨部署的 replay 保护。`verifyingContract` 使用 `TEE_PROOF_VERIFIER` 而非游戏实例地址,因为 verifier 是签名验证端点且每条链部署唯一。 + +--- + +## 6. 保证金经济学 + +### 保证金流向 + +| 角色 | 时机 | 金额 | 计入 | +|-----------|-------------------|---------------------|----------------------------------| +| Proposer | `initialize()` | `msg.value`(任意,无最低限额) | `refundModeCredit[proposer]` | +| Challenger| `challenge()` | `CHALLENGER_BOND` | `refundModeCredit[challenger]` | + +### resolve() 时的保证金分配 + +| ProposalStatus | 赢家 | 分配方式 | +|-----------------------------------------------|------------------|------------------------------------------------------| +| Unchallenged(deadline 过期) | Proposer (DEFENDER_WINS) | `normalModeCredit[proposer] = balance` | +| Challenged(deadline 过期,无证明) | Challenger (CHALLENGER_WINS) | `normalModeCredit[challenger] = balance` | +| UnchallengedAndValidProofProvided | Proposer (DEFENDER_WINS) | `normalModeCredit[proposer] = balance` | +| ChallengedAndValidProofProvided | Proposer (DEFENDER_WINS) | `normalModeCredit[proposer] = balance`(proposer 获得全部保证金,因为只有 proposer 能 prove)| +| 父游戏 CHALLENGER_WINS(子游戏已被挑战) | Challenger (CHALLENGER_WINS) | `normalModeCredit[challenger] = balance` | +| 父游戏 CHALLENGER_WINS(子游戏未被挑战) | Proposer 退款 (CHALLENGER_WINS) | `normalModeCredit[proposer] = balance` | + +### closeGame() + +领取保证金前必须先 close 游戏。`closeGame()` 根据 ASR 状态决定分配模式。幂等——已决定模式后直接返回。 + +**前置条件**(任一不满足则 revert): + +| 检查 | revert | 说明 | +|------|--------|------| +| `bondDistributionMode == UNDECIDED` | —(幂等返回) | 已决定模式则跳过 | +| `ANCHOR_STATE_REGISTRY.paused() == false` | `GamePaused` | 暂停期间不决定模式,防止临时暂停永久推入 REFUND | +| `ANCHOR_STATE_REGISTRY.isGameFinalized(this) == true` | `GameNotFinalized` | finality delay 必须已过 | + +**执行逻辑**: +1. 尝试调用 `ANCHOR_STATE_REGISTRY.setAnchorState(this)`(try/catch,失败不阻塞)——如果游戏是有效的最新状态,推进 anchor state +2. 调用 `ANCHOR_STATE_REGISTRY.isGameProper(this)` 判定分配模式: + - **NORMAL 模式**:游戏为 proper(已注册、未黑名单、未退休、未暂停)→ 赢家获得全部保证金 + - **REFUND 模式**:游戏非 proper → 各方退还原始存入金额(安全兜底) +3. `bondDistributionMode` 一旦从 UNDECIDED 变为 NORMAL 或 REFUND,不可再变更 + +### claimCredit() + +任何人可代为领取指定地址的保证金。 + +**执行逻辑**: +1. 调用 `closeGame()`(如已 close 则幂等返回) +2. 根据 `bondDistributionMode` 读取对应 credit:REFUND 模式读 `refundModeCredit`,NORMAL 模式读 `normalModeCredit` +3. 将两个 credit mapping 归零(防重入) +4. 通过 `call{value}` 转账原生 ETH 给 recipient + +**与 FaultDisputeGame 的关键区别**:FDG 使用 `DelayedWETH`(deposit → unlock → withdraw 两阶段),owner 有 `hold()` 紧急恢复函数。TeeDisputeGame 直接从合约余额一步转账原生 ETH。TZ 的 Proposer 和 Challenger 均为特权白名单地址(非 permissionless),不需要 DelayedWETH 的额外延迟和紧急恢复机制。ASR 的 finality delay + REFUND 模式已提供足够的安全兜底。 + +--- + +## 7. 父子链式关联 + +### 设计概述 + +游戏通过 `parentIndex` 引用父游戏(`0xFFFFFFFF` 表示无父游戏,使用 ASR 锚定状态)。子游戏的 `startingOutputRoot` 继承自父游戏的 `rootClaim`。 + +### 创建时父游戏验证(initialize) + +当 `parentIndex != type(uint32).max` 时,`initialize()` 对父游戏执行以下前置检查(任一失败则 revert `InvalidParentGame`): + +| # | 检查项 | 说明 | +|---|--------|------| +| 1 | GameType 一致 | 父游戏的 GameType 必须等于当前游戏的 `GAME_TYPE`。TEE 游戏只能链接到其他 TEE 游戏,防止被攻破的其他类型游戏被用作 TEE 链的起点 | +| 2 | ASR respected | `ANCHOR_STATE_REGISTRY.isGameRespected(parent)` 必须为 true | +| 3 | 未被 blacklist | `ANCHOR_STATE_REGISTRY.isGameBlacklisted(parent)` 必须为 false | +| 4 | 未被 retire | `ANCHOR_STATE_REGISTRY.isGameRetired(parent)` 必须为 false | +| 5 | 未被挑战者赢 | `parent.status() != GameStatus.CHALLENGER_WINS` | + +当 `parentIndex == type(uint32).max` 时,`startingOutputRoot` 直接从 `ANCHOR_STATE_REGISTRY.getAnchorRoot()` 获取。 + +### L2 区块号排序 + +无论是否有父游戏,`initialize()` 都强制要求: + +``` +l2SequenceNumber > startingOutputRoot.l2SequenceNumber +``` + +- 有父游戏时:`startingOutputRoot.l2SequenceNumber` 来自父游戏 +- 无父游戏时:来自 ASR anchor state + +这确保游戏链中 L2 区块号严格单调递增,防止重复或回退的状态声明。 + +### resolve 时父游戏验证 + +- 父游戏未 resolve 时,子游戏不能 resolve(revert `ParentGameNotResolved`,阻塞等待) +- 父游戏 resolve 为 CHALLENGER_WINS → 子游戏自动 CHALLENGER_WINS + - 子游戏已被挑战:challenger 获得全部保证金 + - 子游戏未被挑战:proposer 保证金被退还(不惩罚无辜 proposer) +- 父游戏 resolve 为 DEFENDER_WINS(或无父游戏)→ 正常解决逻辑 + +### Guardian 对子游戏的 blacklist 责任 + +创建时的父游戏验证(Section 7.2)只能拦截创建瞬间已知的无效父游戏。如果父游戏在子游戏创建**之后**才被 blacklist 或 retire,子游戏不会自动失效。 + +Guardian **必须**逐个 blacklist/retire 受影响的子游戏,使其在 `closeGame()` 时进入 REFUND 模式。 + +--- + +## 8. 访问控制 + +### 角色总览 + +| 角色 | 合约 | 能力 | 说明 | +|------|------|------|------| +| PROPOSER | TeeDisputeGame(immutable) | 创建游戏(tx.origin)、调用 prove() | 单地址,不可变 | +| CHALLENGER | TeeDisputeGame(immutable) | 调用 challenge() | 单地址,不可变 | +| Owner | TeeProofVerifier(Ownable) | 注册/撤销 enclave | 信任根,详见 Section 5 | +| Guardian | ASR(来自 SystemConfig) | pause/blacklist/retire 游戏 | 间接影响 bond 分配,详见 Section 10 | + +**设计说明**: +- Proposer 使用 `tx.origin` 检查(与 PermissionedDisputeGame 一致),Challenger 使用 `msg.sender` 检查 +- 两个地址均在 constructor 设置,所有 Clone 实例共享,更改需部署新 implementation +- TeeProofVerifier 的 Owner 可注册任意 Enclave,只要是 AWS Enclave 就可以注册,是整个系统的信任根 + +--- + +## 9. 全系统不变量 + +以下不变量必须在所有状态下成立。审计和测试应围绕证伪这些性质展开。 + +### 资金安全 + +**INV-1**: 合约持有的 ETH ≥ sum(normalModeCredit) + sum(refundModeCredit) +— 任何时刻,合约余额不低于所有未领取 credit 之和 + +**INV-2**: claimCredit() 转出的 ETH 总量 ≤ initialize() 和 challenge() 收到的 ETH 总量 +— 合约不会凭空多发 ETH + +**INV-3**: REFUND 模式下,每个参与者领取的金额 == 其原始存入金额 +— refundModeCredit 精确等于存入时的 msg.value + +### 状态机完整性 + +**INV-4**: ProposalStatus 转移只有以下合法路径: +- Unchallenged → Challenged(仅 challenge()) +- Unchallenged → UnchallengedAndValidProofProvided(仅 prove()) +- Challenged → ChallengedAndValidProofProvided(仅 prove()) +- 任意非 Resolved → Resolved(仅 resolve()) + +其他任何转移都不应发生。 + +**INV-5**: GameStatus 一旦从 IN_PROGRESS 变为 DEFENDER_WINS 或 CHALLENGER_WINS,不可逆转 +— status 只能被 resolve() 修改一次 + +**INV-6**: resolve() 之后,claimData.prover / claimData.counteredBy 不可再变更 + +### 访问控制 + +**INV-7**: 只有 PROPOSER(tx.origin)能通过 Factory 创建游戏 + +**INV-8**: 只有 CHALLENGER(msg.sender)能调用 challenge() + +**INV-9**: 只有 proposer(msg.sender,即 initialize 时记录的地址)能调用 prove() + +### 证明完整性 + +**INV-10**: prove() 成功 ⟹ 存在从 startingOutputRoot 到 rootClaim 的完整、连续、单调递增的 batch proof 链,且每个 batch 由当前 generation 的注册 enclave 签名 + +**INV-11**: 已 resolve 为 DEFENDER_WINS 的游戏必须满足以下之一: +- (a) 未被挑战且 challenge deadline 已过 +- (b) 提供了有效 TEE 证明(prover != address(0)) + +### Bond 分配 + +**INV-12**: NORMAL 模式下,恰好一个地址的 normalModeCredit == address(this).balance(resolve 时刻),其余为 0 + +**INV-13**: bondDistributionMode 一旦从 UNDECIDED 变为 NORMAL 或 REFUND,不可再变更 + +--- + +## 10. 外部依赖与信任假设 + +### 10.1 DisputeGameFactory + +**信任级别**:高度信任 + +**假设**: +- Factory 忠实地调用 initialize(),不会注入恶意 calldata +- Factory 的 gameAtIndex() 返回正确的游戏记录 +- Factory 是可升级合约(由 L1 admin 控制),如果被恶意升级: + - 可伪造 parentIndex 对应的游戏记录 + - 可创建任意 rootClaim 的游戏实例 + +**风险缓解**:Factory 的升级由 L1 multisig + timelock 控制 + +### 10.2 AnchorStateRegistry (ASR) + +**信任级别**:高度信任 + +**间接依赖链**:ASR 并非独立合约,其关键能力来自 SystemConfig / SuperchainConfig: + +``` +ASR.paused() → SystemConfig.paused() → SuperchainConfig.paused() +ASR._assertOnlyGuardian() → SystemConfig.guardian() +ASR.initialize() → ProxyAdminOwnedBase(需要 ProxyAdmin 授权) +ASR 升级 → ProxyAdmin.upgrade() +``` + +**假设**: +- ASR 的 guardian(来自 SystemConfig)可以 pause / unpause / blacklist / retire 游戏 +- ASR pause 期间,closeGame() 会 revert(`TeeDisputeGame.sol:431`),意味着所有进行中游戏的 bond 领取被暂停 +- ASR 的 isGameProper() 判定直接决定 NORMAL vs REFUND 模式 +- 如果 ASR guardian 被恶意控制: + - 可通过 blacklist 强制所有游戏进入 REFUND 模式 + - 可通过 pause 永久冻结所有 bond(但不能直接盗取) + +**风险缓解**:guardian 由 multisig 控制;REFUND 模式是安全兜底 + +#### ⏳ 待决策:TZ 的 SystemConfig / SuperchainConfig 部署方案 + +TZ 自身不使用 SystemConfig 和 SuperchainConfig,但 ASR 依赖它们。两个候选方案: + +| 维度 | 方案 A:统一管理(复用 XL) | 方案 B:最小化 Stub | +|------|-----------------------------------|-------------------| +| **方案描述** | TZ 的 ASR `systemConfig` 指向 XL 已部署的 SystemConfig | TZ 部署仅实现 `paused()` + `guardian()` 的轻量合约 | +| **部署成本** | 零额外合约 | 需部署 + 测试一个 stub 合约 | +| **运维成本** | 零(XL 团队统一运维) | 低(功能极简,但需关注上游兼容性) | +| **操作独立性** | ✗ — XL pause = TZ pause;XL guardian 控制 TZ 游戏 | ✓ — TZ 有独立的 pause 开关和 guardian | +| **安全隔离** | ✗ — XL guardian 被攻破时 TZ 同时受影响 | ✓ — TZ 和 XL 完全隔离 | +| **风险耦合** | 高 — XL 因自身原因 pause 时,TZ bond 领取也被冻结 | 无 | +| **上游兼容性** | ✓ — 使用标准 SystemConfig,上游升级无影响 | ⚠️ — 上游 ASR 若调用更多 SystemConfig 函数,stub 可能不兼容 | +| **适用前提** | TZ 和 XL 同一团队运营 | TZ 需要独立控制权,但不想 fork ASR | + +### 10.3 IRiscZeroVerifier + +**信任级别**:信任其正确性 + +**假设**: +- verify(seal, imageId, journalDigest) 正确验证 RISC Zero Groth16 证明 +- 如果 verifier 有 bug:可注册非法 enclave 地址 → 伪造状态转移 + +**风险缓解**: +- `riscZeroVerifier` 为 immutable,部署后不可替换。如需更换 verifier 需部署新的 TeeProofVerifier 合约 +- `imageId` 为 immutable,部署后不可更改。如需更换 guest image 需部署新合约 + +### 10.4 AWS Nitro Root Key + +**信任级别**:信任 AWS 硬件安全 + +**假设**: +- expectedRootKey 是 AWS Nitro 的官方 P384 公钥 +- AWS 可能轮换 root key(历史上未发生,但理论上可能) + +**生命周期管理**: +- `expectedRootKey` 在构造器中设置,部署后不可更改(无 setter 函数) +- 如果 AWS 轮换 root key:需部署新的 TeeProofVerifier → 部署新的 TeeDisputeGame implementation 指向新 verifier +- 此设计牺牲了运行时灵活性,换取了更小的 Owner 攻击面(Owner 无法在运行时替换 verifier/imageId/rootKey) + +### 10.5 TEE Enclave 硬件 + +**信任级别**:信任 enclave 在未被攻破时正确执行 + +**假设**: +- 注册的 enclave 忠实执行状态转移并签名 +- 如果 enclave 被攻破(旁路攻击、供应链攻击等): + - 可签署任意虚假状态转移 + - 需要 Owner 通过 revoke() 或 revokeAll() 撤销 + - 在撤销前,已签名的虚假 proof 可能已被提交 + +--- + +## 11. 应急机制与升级路径 + +### 11.1 Enclave 撤销机制 + +**单个撤销**: +- `TeeProofVerifier.revoke(address)` — Owner 移除单个 enclave + +**批量撤销(Generation 机制)**: +- `TeeProofVerifier.revokeAll()` — Owner 递增 enclaveGeneration +- 效果:所有当前 generation 的 enclave 立即失效(O(1)) +- ⚠️ 已提交的 proof 不受影响 — prove() 在调用时验证签名,如果 enclave 在 prove() 调用之前被撤销,该 proof 将失败 +- ⚠️ 已 resolve 的游戏不受影响 — 即使事后发现 enclave 被攻破,已完成的游戏状态不可逆转。应通过 ASR blacklist 处理 + +### 11.2 ASR Pause 对进行中游戏的影响 + +| 游戏阶段 | Pause 影响 | +|----------|-----------| +| Unchallenged(等待挑战) | challenge() 不受影响(无 pause 检查)| +| Challenged(等待证明) | prove() 不受影响(无 pause 检查)| +| 等待 resolve | resolve() 不受影响(无 pause 检查)| +| 已 resolve,等待 closeGame | closeGame() 被阻塞 → bond 无法领取 | +| 已 close,等待 claimCredit | claimCredit() 不受影响(close 是幂等的)| + +**关键结论**:pause 只影响 bond 领取,不影响游戏逻辑本身。长时间 pause 不会导致资金丢失,但会延迟资金释放。 + +### 11.3 升级路径 + +**TeeDisputeGame**: +- Clone proxy 模式,implementation 不可升级 +- 如需修复漏洞:部署新 implementation → Factory 注册新 gameType → ASR retire 旧 gameType +- 已创建的旧游戏继续运行,但无法作为新游戏的 parent(因为 parentGameType != 新 GAME_TYPE) + +**TeeProofVerifier**: +- 非 proxy,不可升级 +- riscZeroVerifier / imageId / expectedRootKey 均为不可变参数,部署后无法更改 +- Owner 仅保留 enclave 注册/撤销权限 +- 如需更换 verifier / imageId / rootKey:部署新的 TeeProofVerifier → 部署新的 TeeDisputeGame implementation 指向新 verifier + +### 11.4 应急 SOP(建议) + +如果发现 TEE enclave 被攻破: +1. Owner 调用 `revokeAll()` 撤销所有 enclave +2. Guardian 通过 ASR blacklist 被攻破 enclave 签名的游戏 +3. 排查受影响游戏,blacklist 后这些游戏进入 REFUND 模式 +4. 重新注册可信 enclave +5. 新游戏从 anchor state 继续 + +--- + +## 12. 超出范围的威胁 + +以下威胁被认为超出本合约系统的防御范围: + +1. **TEE 硬件级攻击**:旁路攻击、电压故障注入等物理攻击。缓解依赖 AWS Nitro 硬件安全保证。 + +2. **L1 Reorg**:深度 L1 重组可能导致已 resolve 的游戏状态回滚。这是 L1 共识层风险,非合约层可防御。 + +3. **Owner / Guardian 密钥泄露**:如果 Owner multisig 被完全攻破,攻击者可注册恶意 enclave(但无法替换 verifier、imageId 或 rootKey,因为这些是不可变参数)。缓解依赖密钥管理实践和 multisig/timelock 配置。 + +4. **L1 Gas Price 攻击**:攻击者通过操纵 L1 gas price 阻止 challenger/prover 在 deadline 内提交交易。缓解依赖合理设置 MAX_CHALLENGE_DURATION 和 MAX_PROVE_DURATION。 + +5. **跨链 MEV**:利用 L1/L2 之间的信息不对称进行的套利。不在合约层面防御。 + +6. **DisputeGameFactory 升级攻击**:Factory 由 L1 governance 控制,恶意升级可绕过所有游戏安全假设。依赖治理安全。 + +7. **OptimismPortal 提款证明**:TZ 不使用 OptimismPortal 进行 L1↔L2 提款。TZ 的 dispute game 仅用于将 state root 和 TEE proof 公布在 L1 上,跨链桥和提款机制不依赖游戏结果。因此 OptimismPortal 相关的安全假设(`wasRespectedGameTypeWhenCreated`、withdrawal finality 等)不在 TZ 的审计范围内。 diff --git a/packages/contracts-bedrock/book/src/dispute/tee/tee-local-integration-guide.md b/packages/contracts-bedrock/book/src/dispute/tee/tee-local-integration-guide.md new file mode 100644 index 0000000000000..523985310fa8f --- /dev/null +++ b/packages/contracts-bedrock/book/src/dispute/tee/tee-local-integration-guide.md @@ -0,0 +1,734 @@ +# TEE Dispute Game 本地部署联调指南 + +> 供 TEE ZK Prover 对接联调使用。全部通过 forge script / cast 命令行操作。 + +## 目录 + +- [架构概览](#架构概览) +- [真实 vs Mock 对照](#真实-vs-mock-对照) +- [脚本一览](#脚本一览) +- [前置条件](#前置条件) +- [Mock 模式(快速验证)](#mock-模式快速验证) + - [Step 1: 启动 Anvil](#step-1-启动-anvil) + - [Step 2: 部署合约](#step-2-部署合约) + - [Step 3: 运行 E2E](#step-3-运行-e2e) + - [Step 4: 领取 Bond](#step-4-领取-bond) +- [Fork 模式(真实 ZK Proof 验证)](#fork-模式真实-zk-proof-验证) + - [概述](#概述) + - [Step 1: Fork 主网启动 Anvil](#step-1-fork-主网启动-anvil) + - [Step 2: 部署合约(真实 RiscZero Verifier)](#step-2-部署合约真实-risczero-verifier) + - [Step 3: 运行 E2E(真实 seal + 外部签名)](#step-3-运行-e2e真实-seal--外部签名) + - [Journal 字段解析](#journal-字段解析) +- [Prover 对接核心概念](#prover-对接核心概念) + - [注册 Enclave](#注册-enclave) + - [prove() 输入格式](#prove-输入格式) + - [从外部传入 prove 输入](#从外部传入-prove-输入) + - [EIP-712 签名规范](#eip-712-签名规范) + - [多 Batch 链式证明](#多-batch-链式证明) +- [单步 cast 调用参考](#单步-cast-调用参考) +- [数据结构参考](#数据结构参考) +- [常见问题排查](#常见问题排查) + +--- + +## 架构概览 + +``` ++-------------------------------------------------------------+ +| TEE ZK Prover (你的服务) | +| | +| 1. 生成 Nitro Attestation (mock) | +| 2. 生成 ZK Proof of Attestation (mock -> 空 seal) | +| 3. 调用 register() 注册 enclave | +| 4. 用 enclave 私钥对 batch 数据做 EIP-712 ECDSA 签名 | +| 5. 调用 prove() 提交 batch proof | ++-------------+--------------------------------+---------------+ + | | + v v ++------------------------+ +----------------------------+ +| TeeProofVerifier | | TeeDisputeGame | +| | | | +| register(seal, att) |<-----| prove(batchProofs) | +| -> ZK 验证 (mock) | | -> verifyBatch(digest, | +| -> 存储 enclave | | signature) | +| | | | +| verifyBatch(digest, |<-----| (ECDSA recover -> | +| signature) | | 检查是否已注册) | ++----------+-------------+ +----------------------------+ + | + v ++------------------------+ +| MockRiscZeroVerifier | +| (verify -> 直接通过) | ++------------------------+ +``` + +## 真实 vs Mock 对照 + +**合约层面:** + +| 合约 | 真实 / Mock | 说明 | +|---|---|---| +| `MockRiscZeroVerifier` | **Mock** | `verify()` 直接通过,接受任意 seal | +| `TeeProofVerifier` | **真实** | 真实的 enclave 注册 + ECDSA batch 验证逻辑 | +| `DisputeGameFactory` | **真实** | 通过 Proxy 部署,创建 game 实例 | +| `AnchorStateRegistry` | **真实** | 通过 Proxy 部署,管理 anchor state | +| `TeeDisputeGame` | **真实** | 完整 game 逻辑:initialize, challenge, prove, resolve | +| `MockSystemConfig` | **Mock** | 返回 guardian 地址和 pause 状态 | + +**`prove()` 流程中的各部分:** + +| 部分 | 真实 / Mock | 生产环境对应 | +|---|---|---| +| `startBlockHash/stateHash` | Mock 数据(可外部传入) | TEE prover 从 L2 链上读取 | +| `endBlockHash/stateHash` | Mock 数据(可外部传入) | TEE prover 执行后计算得到 | +| `l2Block` | Mock 数据(可外部传入) | 真实 L2 区块号 | +| EIP-712 digest 计算 | **真实** | 链上合约用相同逻辑重算 | +| ECDSA 签名 | **真实** | enclave 私钥签署 EIP-712 digest | +| `verifyBatch()` ecrecover | **真实** | 恢复 signer 地址,检查注册状态 | + +整个 prove 流程中唯一 mock 的是**被签名的数据**(block/state hash 默认是假值,但可以通过环境变量替换为真实数据)。签名的生成和验证链路与生产环境完全一致。 + +## 脚本一览 + +| 脚本 | 用途 | RiscZero Verifier | +|---|---|---| +| `scripts/tee/DeployTeeMock.s.sol` | Mock 模式部署 | MockRiscZeroVerifier(任意 seal 通过) | +| `scripts/tee/TeeProveE2E.s.sol` | Mock E2E(本地签名) | 同上 | +| `scripts/tee/DeployTeeFork.s.sol` | Fork 模式部署 | 主网真实 RiscZeroVerifierRouter | +| `scripts/tee/TeeProveE2EFork.s.sol` | Fork E2E(真实 seal + 外部签名) | 同上 | + +--- + +## 前置条件 + +- 已安装 [Foundry](https://book.getfoundry.sh/getting-started/installation)(`forge`、`cast`、`anvil`) +- 已 clone 仓库并安装依赖 + +## Mock 模式(快速验证) + +### 快速开始 + +```bash +# Terminal 1: 启动 Anvil +anvil --block-time 1 + +# Terminal 2: 部署全部合约 +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ +CHALLENGER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \ +forge script scripts/tee/DeployTeeMock.s.sol \ + --rpc-url http://localhost:8545 --broadcast + +# 从输出中复制 TEE_PROOF_VERIFIER 和 DISPUTE_GAME_FACTORY 地址,然后运行 E2E: +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \ +CHALLENGER_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a \ +ENCLAVE_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 \ +TEE_PROOF_VERIFIER=<部署输出的地址> \ +DISPUTE_GAME_FACTORY=<部署输出的地址> \ +forge script scripts/tee/TeeProveE2E.s.sol \ + --rpc-url http://localhost:8545 --broadcast +``` + +预期输出: + +``` +=== Step 1: Register Enclave (mock attestation + mock ZK proof) === + Enclave registered: 0x90F79bf6EB2c4f870365E785982E1f101E93b906 + +=== Step 2: Create Game (proposer) === + Game created: 0xd8058efe0198ae9dD7D563e1b4938Dcbc86A1F81 + l2SequenceNumber: 100 + proposer: 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 + +=== Step 3: Challenge (challenger) === + Game challenged by: 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC + +=== Step 4: Prove - single batch (proposer submits, enclave signs) === + Domain separator from game: 0x7d2b73... + Batch signed, signature length: 65 + Proof submitted successfully! + +=== Step 5: Resolve === + Game resolved: DEFENDER_WINS + +=== E2E Complete (steps 1-5 passed) === +``` + +### Step 1: 启动 Anvil + +```bash +anvil --block-time 1 +``` + +默认账户(每个预充 10000 ETH): + +| 角色 | 私钥 | 地址 | +|---|---|---| +| Deployer / Owner | `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80` | `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` | +| Proposer | `0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d` | `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` | +| Challenger | `0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a` | `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` | +| Enclave (TEE Prover) | `0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6` | `0x90F79bf6EB2c4f870365E785982E1f101E93b906` | + +### Step 2: 部署合约 + +脚本:`scripts/tee/DeployTeeMock.s.sol` + +部署内容: +1. `MockRiscZeroVerifier` -- `verify()` 直接通过 +2. `TeeProofVerifier` -- 使用 mock verifier,但注册和 ECDSA 验证逻辑是真实的 +3. `DisputeGameFactory` -- 通过 Proxy 部署 +4. `AnchorStateRegistry` -- 通过 Proxy 部署,finality delay = 0 +5. `TeeDisputeGame` 实现合约 -- 注册为 game type 1960 + +测试用配置(脚本内硬编码): + +| 参数 | 值 | +|---|---| +| `DEFENDER_BOND` | 0.1 ETH | +| `CHALLENGER_BOND` | 0.2 ETH | +| `MAX_CHALLENGE_DURATION` | 300 秒(5 分钟) | +| `MAX_PROVE_DURATION` | 300 秒(5 分钟) | +| `TEE_GAME_TYPE` | 1960 | +| `ANCHOR_L2_BLOCK` | 0 | + +```bash +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ +CHALLENGER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \ +forge script scripts/tee/DeployTeeMock.s.sol \ + --rpc-url http://localhost:8545 --broadcast +``` + +保存输出的地址,下一步需要用到: + +``` +=== Deployed Addresses === +MockRiscZeroVerifier : 0x5FbDB2315678afecb367f032d93F642f64180aa3 +TeeProofVerifier : 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 <-- 需要 +DisputeGameFactory : 0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 <-- 需要 +AnchorStateRegistry : 0xa513E6E4b8f2a923D98304ec87F64353C4D5C853 +TeeDisputeGame impl : 0x8A791620dd6260079BF849Dc5567aDC3F2FdC318 +``` + +### Step 3: 运行 E2E + +脚本:`scripts/tee/TeeProveE2E.s.sol` + +依次执行 5 个步骤: + +1. **注册 Enclave** -- `register("", attestationData)`,seal 传空字节(mock ZK proof) +2. **创建 Game** -- `factory.create()`,proposer 存入 defender bond +3. **挑战** -- `game.challenge()`,challenger 存入 challenger bond +4. **提交证明** -- 构造 EIP-712 digest,用 enclave 私钥签名,调用 `game.prove()` +5. **解决** -- `game.resolve()` 返回 `DEFENDER_WINS` + +```bash +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \ +CHALLENGER_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a \ +ENCLAVE_KEY=0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6 \ +TEE_PROOF_VERIFIER=0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 \ +DISPUTE_GAME_FACTORY=0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9 \ +forge script scripts/tee/TeeProveE2E.s.sol \ + --rpc-url http://localhost:8545 --broadcast +``` + +### Step 4: 领取 Bond + +`claimCredit` 需要满足 `resolvedAt + finalityDelay < block.timestamp`。由于 forge script 中所有交易在同一个区块执行,必须单独调用。等待至少 1 秒后: + +```bash +# 将 替换为 Step 3 输出的 Game created 地址 +cast send 'claimCredit(address)' \ + 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ + --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \ + --rpc-url http://localhost:8545 +``` + +--- + +## Fork 模式(真实 ZK Proof 验证) + +### 概述 + +Fork 模式通过 `anvil --fork-url` fork 以太坊主网,使用链上已部署的 **RiscZeroVerifierRouter** (`0x8EaB2D97Dfce405A1692a21b3ff3A172d593D319`) 进行真实的 Groth16 ZK proof 验证。 + +与 mock 模式的区别: + +| | Mock 模式 | Fork 模式 | +|---|---|---| +| RiscZero Verifier | MockRiscZeroVerifier(任意 seal 通过) | 主网真实 RiscZeroVerifierRouter | +| `register()` seal | 空字节 `0x` | 真实 Groth16 seal(如 Boundless 返回的) | +| imageId | 假值 | 真实 guest image ID | +| expectedRootKey | 假值 | 真实 AWS Nitro P384 root key(96 字节) | +| prove() 签名 | ENCLAVE_KEY 本地签名 | 外部传入 `BATCH_SIGNATURE` | + +### Step 1: Fork 主网启动 Anvil + +```bash +# 需要以太坊主网 RPC(Alchemy / Infura / 自建节点) +anvil --fork-url $ETH_RPC_URL --block-time 1 +``` + +> 注意:fork 主网后 chainId 为 1(非 mock 模式的 31337)。Anvil 默认账户同样预充 10000 ETH。prover 构造 EIP-712 签名时 chainId 必须使用 1。 + +### Step 2: 部署合约(真实 RiscZero Verifier) + +脚本:`scripts/tee/DeployTeeFork.s.sol` + +```bash +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ +CHALLENGER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \ +RISC_ZERO_VERIFIER=0x8EaB2D97Dfce405A1692a21b3ff3A172d593D319 \ +RISC_ZERO_IMAGE_ID=0x \ +NITRO_ROOT_KEY=0x<96 字节 AWS Nitro P384 root key hex> \ +forge script scripts/tee/DeployTeeFork.s.sol \ + --rpc-url http://localhost:8545 --broadcast +``` + +**环境变量说明:** + +| 变量 | 说明 | +|---|---| +| `RISC_ZERO_VERIFIER` | 主网 RiscZeroVerifierRouter 地址 | +| `RISC_ZERO_IMAGE_ID` | RISC Zero guest program 的 image ID(ELF hash) | +| `NITRO_ROOT_KEY` | AWS Nitro Enclave P384 root public key,96 字节(不含 0x04 前缀) | + +保存输出的 `TeeProofVerifier` 和 `DisputeGameFactory` 地址。 + +### Step 3: 运行 E2E(真实 seal + 外部签名) + +脚本:`scripts/tee/TeeProveE2EFork.s.sol` + +```bash +PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +PROPOSER_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d \ +CHALLENGER_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a \ +TEE_PROOF_VERIFIER=<部署输出的地址> \ +DISPUTE_GAME_FACTORY=<部署输出的地址> \ +SEAL=0x \ +ATT_TIMESTAMP_MS= \ +ATT_PCR_HASH=0x \ +ATT_PUBLIC_KEY=0x<65 字节未压缩公钥 hex> \ +ATT_USER_DATA=0x \ +BATCH_SIGNATURE=0x \ +END_BLOCK_HASH=0x<终态 block hash> \ +END_STATE_HASH=0x<终态 state hash> \ +L2_SEQUENCE_NUMBER= \ +forge script scripts/tee/TeeProveE2EFork.s.sol \ + --rpc-url http://localhost:8545 --broadcast +``` + +**环境变量说明:** + +| 变量 | 说明 | +|---|---| +| `SEAL` | Boundless 返回的 Groth16 proof seal(hex 编码) | +| `ATT_TIMESTAMP_MS` | attestation 中的 Unix 时间戳(毫秒) | +| `ATT_PCR_HASH` | PCR0 hash(bytes32) | +| `ATT_PUBLIC_KEY` | enclave 的 65 字节未压缩 secp256k1 公钥 | +| `ATT_USER_DATA` | attestation 中的附加数据(可为空 `0x`) | +| `BATCH_SIGNATURE` | TEE prover 对 EIP-712 batch digest 的签名(65 字节) | + +### Journal 字段解析 + +`register()` 时合约会用 `attestationData` + `expectedRootKey` 重建 journal,然后计算 `journalDigest = SHA256(journal)` 交给 RiscZero verifier 验证。 + +如果你有 Boundless 返回的原始 journal(hex),按以下顺序拆解为环境变量: + +``` +journal = timestampMs (8 bytes) --> ATT_TIMESTAMP_MS(转为 uint64 十进制) + || pcrHash (32 bytes) --> ATT_PCR_HASH(0x 前缀 hex) + || rootKey (96 bytes) --> 跳过(合约从 expectedRootKey 取,部署时通过 NITRO_ROOT_KEY 设置) + || pubKeyLen (1 byte = 0x41) --> 跳过 + || publicKey (65 bytes) --> ATT_PUBLIC_KEY(0x 前缀 hex) + || userDataLen (2 bytes) --> 跳过 + || userData (variable) --> ATT_USER_DATA(0x 前缀 hex,可为空 0x) +``` + +**示例:用 cast 从 journal hex 中提取字段** + +```bash +JOURNAL=0x<完整 journal hex> + +# timestampMs: 前 8 字节 = 16 hex chars +ATT_TIMESTAMP_MS=$(cast to-dec $(echo $JOURNAL | cut -c3-18)) + +# pcrHash: 接下来 32 字节 = 64 hex chars (offset 18) +ATT_PCR_HASH=0x$(echo $JOURNAL | cut -c19-82) + +# 跳过 rootKey: 96 字节 = 192 hex chars (offset 82) +# pubKeyLen: 1 字节 = 2 hex chars (offset 274), 值应为 0x41 = 65 +# publicKey: 65 字节 = 130 hex chars (offset 276) +ATT_PUBLIC_KEY=0x$(echo $JOURNAL | cut -c277-406) + +# userDataLen: 2 字节 = 4 hex chars (offset 406) +USER_DATA_LEN=$(cast to-dec 0x$(echo $JOURNAL | cut -c407-410)) +# userData: 从 offset 410 开始 +if [ "$USER_DATA_LEN" -gt 0 ]; then + ATT_USER_DATA=0x$(echo $JOURNAL | cut -c411-$((410 + USER_DATA_LEN * 2))) +else + ATT_USER_DATA=0x +fi +``` + +--- + +## Prover 对接核心概念 + +### 注册 Enclave + +```solidity +function register(bytes calldata seal, AttestationData calldata attestationData) external onlyOwner +``` + +Mock 模式下使用 `MockRiscZeroVerifier`,ZK proof 验证被跳过。只需提供: + +- `seal`:空字节 `0x` +- `attestationData.publicKey`:**65 字节 secp256k1 未压缩公钥**(`0x04` + 32 字节 x + 32 字节 y) +- `attestationData.timestampMs`:任意 uint64 +- `attestationData.pcrHash`:任意 bytes32 +- `attestationData.userData`:可为空 + +合约通过 `keccak256(x || y)` 从公钥中提取 Ethereum 地址。后续 `verifyBatch()` 会通过 ECDSA recover 得到 signer 地址,与这个注册地址进行比对。 + +**关键**:用于签名 batch 的私钥必须与注册时提供的公钥是同一对密钥。 + +Fork 模式下需要提供真实的 seal 和 attestation data,详见 [Fork 模式](#fork-模式真实-zk-proof-验证)。 + +### prove() 输入格式 + +```solidity +function prove(bytes calldata proofBytes) external returns (ProposalStatus) +``` + +`proofBytes` = `abi.encode(BatchProof[])`: + +```solidity +struct BatchProof { + bytes32 startBlockHash; + bytes32 startStateHash; + bytes32 endBlockHash; + bytes32 endStateHash; + uint256 l2Block; + bytes signature; // 65 字节:r(32) + s(32) + v(1) +} +``` + +链上对每个 batch 的验证逻辑: + +1. `keccak256(abi.encode(proofs[0].startBlockHash, startStateHash))` == `startingOutputRoot.root`(起始状态匹配 anchor) +2. `proofs[i].end == proofs[i+1].start`(链式连续性) +3. `proofs[i].l2Block < proofs[i+1].l2Block`(单调递增) +4. 链上重算 EIP-712 digest + signature,通过 `TeeProofVerifier.verifyBatch()` 验证 +5. `keccak256(abi.encode(proofs[last].endBlockHash, endStateHash))` == `rootClaim`(终态匹配 rootClaim) +6. `proofs[last].l2Block` == `l2SequenceNumber`(最终区块号匹配) + +### 从外部传入 prove 输入 + +两套 E2E 脚本均支持通过环境变量覆盖 batch 数据(不设时使用 mock 默认值): + +| 变量 | 默认值 | 说明 | +|---|---|---| +| `START_BLOCK_HASH` | `keccak256("genesis-block")` | batch 起始 block hash,必须匹配 anchor | +| `START_STATE_HASH` | `keccak256("genesis-state")` | batch 起始 state hash,必须匹配 anchor | +| `END_BLOCK_HASH` | `keccak256("end-block-100")` | batch 终态 block hash | +| `END_STATE_HASH` | `keccak256("end-state-100")` | batch 终态 state hash | +| `L2_SEQUENCE_NUMBER` | `100` | L2 区块号 | + +**签名来源的区别:** + +| 脚本 | 签名方式 | 说明 | +|---|---|---| +| `TeeProveE2E.s.sol` | `ENCLAVE_KEY` 本地签名 | mock 模式,私钥在本地 | +| `TeeProveE2EFork.s.sol` | `BATCH_SIGNATURE` 外部传入 | fork 模式,私钥留在 TEE 内 | + +**查询当前 anchor state(用于确定 START_BLOCK_HASH / START_STATE_HASH):** + +```bash +cast call $ANCHOR_STATE_REGISTRY 'getAnchorRoot()(bytes32,uint256)' \ + --rpc-url http://localhost:8545 +``` + +默认部署的 anchor root = `keccak256(abi.encode(keccak256("genesis-block"), keccak256("genesis-state")))`,l2SequenceNumber = 0。 + +**注意事项:** +- `START_BLOCK_HASH` / `START_STATE_HASH` 必须满足 `keccak256(abi.encode(startBlockHash, startStateHash))` 等于链上 anchor root。 +- `END_BLOCK_HASH` / `END_STATE_HASH` 的 `keccak256(abi.encode(...))` 会作为 `rootClaim` 写入 game。 +- `L2_SEQUENCE_NUMBER` 必须大于 anchor 的 l2SequenceNumber。 +- `BATCH_SIGNATURE`(仅 fork 模式)必须是对正确 EIP-712 digest 的签名,签名者必须是已注册的 enclave,格式:`abi.encodePacked(r, s, v)` = 65 字节。 + +### EIP-712 签名规范 + +这是 prover 对接最关键的部分。domain、types、字段顺序有任何偏差都会导致 `verifyBatch()` revert。 + +**Domain:** + +``` +name: "TeeDisputeGame" +version: "1" +chainId: <当前链 ID> (mock 模式 = 31337, fork 主网 = 1) +verifyingContract: (注意:不是 game 地址!) +``` + +**Type:** + +``` +BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block) +``` + +**Domain separator(链上计算方式):** + +``` +domainSeparator = keccak256(abi.encode( + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak256("TeeDisputeGame"), + keccak256("1"), + chainId, + address(TeeProofVerifier) +)) +``` + +**Struct hash:** + +``` +structHash = keccak256(abi.encode( + keccak256("BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)"), + startBlockHash, + startStateHash, + endBlockHash, + endStateHash, + l2Block +)) +``` + +**最终 digest:** + +``` +digest = keccak256(abi.encodePacked("\x19\x01", domainSeparator, structHash)) +``` + +**签名格式:** `abi.encodePacked(r, s, v)` = 32 + 32 + 1 = **65 字节** + +可以通过调用 `game.domainSeparator()` 读取链上的 domain separator,与链下计算结果进行比对验证: + +```bash +cast call 'domainSeparator()(bytes32)' --rpc-url http://localhost:8545 +``` + +### 多 Batch 链式证明 + +多个 batch 覆盖不同的子范围,适用于不同的 TEE executor 处理不同的 L2 区块范围: + +``` +batch[0]: anchor -> mid1 (l2Block = 50) +batch[1]: mid1 -> mid2 (l2Block = 80) +batch[2]: mid2 -> endState (l2Block = 100) +``` + +规则: +- `batch[i].startBlockHash == batch[i-1].endBlockHash` 且 `batch[i].startStateHash == batch[i-1].endStateHash` +- `batch[i].l2Block > batch[i-1].l2Block` +- 每个 batch 可以由**不同的**已注册 enclave 签名 +- `batch[0].start` 必须匹配 anchor state +- `batch[last].end` 必须匹配 `rootClaim`,`batch[last].l2Block` 必须等于 `l2SequenceNumber` + +> 注意:当前 `TeeProveE2E.s.sol` 仅支持单 batch。如需测试多 batch,可参考 `test/dispute/tee/TeeDisputeGameIntegration.t.sol` 中的多 batch 测试用例自行扩展。 + +--- + +## 单步 cast 调用参考 + +如果需要脱离 E2E 脚本,单独调用各步骤(例如只测 prove 对接),可参考以下 cast 命令。 + +### 查询 enclave 注册状态 + +```bash +cast call $TEE_PROOF_VERIFIER \ + 'isRegistered(address)(bool)' $ENCLAVE_ADDR \ + --rpc-url http://localhost:8545 +``` + +### 查询 anchor state + +```bash +cast call $ANCHOR_STATE_REGISTRY \ + 'getAnchorRoot()(bytes32,uint256)' \ + --rpc-url http://localhost:8545 +``` + +### 查询 game 状态 + +```bash +# game status: 0=IN_PROGRESS, 1=CHALLENGER_WINS, 2=DEFENDER_WINS +cast call 'status()(uint8)' --rpc-url http://localhost:8545 + +# domain separator(用于验证链下 EIP-712 计算是否正确) +cast call 'domainSeparator()(bytes32)' --rpc-url http://localhost:8545 + +# l2SequenceNumber +cast call 'l2SequenceNumber()(uint256)' --rpc-url http://localhost:8545 + +# rootClaim +cast call 'rootClaim()(bytes32)' --rpc-url http://localhost:8545 + +# proposer +cast call 'proposer()(address)' --rpc-url http://localhost:8545 +``` + +### 领取 bond + +```bash +# 等 resolve 后至少 1 秒 +cast send 'claimCredit(address)' \ + --private-key \ + --rpc-url http://localhost:8545 +``` + +--- + +## 数据结构参考 + +### AttestationData(注册用) + +```solidity +struct AttestationData { + uint64 timestampMs; // Unix 时间戳(毫秒) + bytes32 pcrHash; // PCR hash(mock 时可填任意值) + bytes publicKey; // 65 字节:0x04 + x(32) + y(32) + bytes userData; // 附加数据(可为空) +} +``` + +### Game ExtraData(创建 game 用) + +``` +extraData = abi.encodePacked( + uint256 l2SequenceNumber, // L2 区块号 + uint32 parentIndex, // 无父 game = 0xFFFFFFFF + bytes32 endBlockHash, // 终态 block hash + bytes32 endStateHash // 终态 state hash +) +``` + +### Root Claim + +``` +rootClaim = keccak256(abi.encode(endBlockHash, endStateHash)) +``` + +### Game 生命周期 + +``` + create(proposer 存入 DEFENDER_BOND) + | + v + +---------- IN_PROGRESS ----------+ + | | + | challenge(可选) | prove(可选,EIP-712 签名的 batch) + | challenger 存入 | proposer 提交 + | CHALLENGER_BOND | enclave 签名的 proof + | | + +--------+----------------+------+ + | | + v v + deadline 过期 proof 已提交 + | | + v v + resolve() resolve() + | | + +------+------+ DEFENDER_WINS + | | (proposer 获得全部 bond) + v v + 无 proof 已 prove + | | + v v +CHALLENGER DEFENDER + _WINS _WINS +(challenger (proposer + 获得全部 获得全部 + bond) bond) +``` + +--- + +## 常见问题排查 + +### `register()` 报 `InvalidProof` 错误 + +**Mock 模式**:确认部署的是 `MockRiscZeroVerifier` 并传给了 `TeeProofVerifier` 构造函数。mock 的 `shouldRevert` 默认为 `false`。 + +**Fork 模式**:说明 seal 或 attestation data 与链上验证不匹配。检查: +1. `RISC_ZERO_IMAGE_ID` 是否与生成 seal 时使用的 guest program 一致 +2. `NITRO_ROOT_KEY` 是否与 attestation 中的 root key 一致(96 字节,P384) +3. `ATT_*` 字段是否与 journal 中的值完全对应(参考 [Journal 字段解析](#journal-字段解析)) +4. `SEAL` 是否完整、未被截断 + +### `verifyBatch()` 报 `EnclaveNotRegistered` 错误 + +1. 确认 `register()` 已成功执行:`cast call $TEE_PROOF_VERIFIER 'isRegistered(address)(bool)' $ENCLAVE_ADDR` +2. 确认签名用的私钥与注册时的公钥是同一对 +3. 确认没有调用过 `revokeAll()`(会使所有注册失效) + +### `prove()` 报 `InvalidSignature` 错误 + +说明 ecrecover 恢复出的地址与预期不一致,检查以下几点: + +1. EIP-712 domain 中的 **`verifyingContract`** 必须是 `TeeProofVerifier` 地址(不是 game 地址) +2. **`chainId`** 必须匹配当前链(mock 模式 = 31337,fork 主网 = 1) +3. **签名格式**必须是 `r(32) + s(32) + v(1)` = 65 字节,用 `abi.encodePacked(r, s, v)` 打包 +4. 读取 `game.domainSeparator()` 与你的链下计算结果对比 + +### `prove()` 报 `StartHashMismatch` 错误 + +`batch[0].startBlockHash/startStateHash` 的组合 hash 必须等于 anchor state: + +``` +keccak256(abi.encode(startBlockHash, startStateHash)) == startingOutputRoot.root +``` + +对于首个 game(无父 game),anchor 来自 `AnchorStateRegistry`,可查询: + +```bash +cast call $ANCHOR_STATE_REGISTRY 'getAnchorRoot()(bytes32,uint256)' --rpc-url http://localhost:8545 +``` + +### `prove()` 报 `FinalHashMismatch` 或 `FinalBlockMismatch` 错误 + +- 最后一个 batch 的 `endBlockHash/endStateHash` 必须满足:`keccak256(abi.encode(endBlockHash, endStateHash)) == rootClaim` +- 最后一个 batch 的 `l2Block` 必须等于 `game.l2SequenceNumber()` + +### `prove()` 报 `BatchChainBreak(i)` 错误 + +`batch[i].startBlockHash != batch[i-1].endBlockHash` 或 `startStateHash != endStateHash`。每个 batch 必须从上一个 batch 的终态开始。 + +### `prove()` 报 `BadAuth` 错误 + +`prove()` 只能由 proposer 调用(创建 game 时的 `tx.origin`)。 + +### `claimCredit()` 报 `GameNotFinalized` 错误 + +game 必须已 resolve 且 finality delay 已过:`resolvedAt + finalityDelay < block.timestamp`。mock 环境下 finality delay 为 0,但仍需等待至少 1 秒。在 forge script 中所有交易在同一个区块执行,所以需要单独用 `cast send` 调用。 + +### 如何获取 enclave 的未压缩公钥? + +**Foundry (Solidity 中):** +```solidity +Vm.Wallet memory wallet = vm.createWallet(privateKey, "label"); +bytes memory pubKey = abi.encodePacked( + bytes1(0x04), + bytes32(wallet.publicKeyX), + bytes32(wallet.publicKeyY) +); +``` + +**cast 命令行:** +```bash +# 获取地址 +cast wallet address $ENCLAVE_KEY +``` + +> 注意:`cast` 目前不直接输出未压缩公钥。E2E 脚本 (`TeeProveE2E.s.sol`) 内部通过 `vm.createWallet()` 自动处理了公钥的构造和注册。 diff --git a/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol new file mode 100644 index 0000000000000..433d4d4c4ebdd --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/IRiscZeroVerifier.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/// @title IRiscZeroVerifier +/// @notice Minimal interface for the RISC Zero Groth16 verifier. +interface IRiscZeroVerifier { + /// @notice Verify a RISC Zero proof. + /// @param seal The proof seal (Groth16). + /// @param imageId The guest image ID. + /// @param journalDigest The SHA-256 digest of the journal. + function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external view; +} diff --git a/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol b/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol new file mode 100644 index 0000000000000..a7498f2059761 --- /dev/null +++ b/packages/contracts-bedrock/interfaces/dispute/ITeeProofVerifier.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +/// @title ITeeProofVerifier +/// @notice Interface for the TEE Proof Verifier contract. +interface ITeeProofVerifier { + /// @notice Verify a batch state transition signed by a registered TEE enclave. + /// @param digest The hash of the batch data. + /// @param signature ECDSA signature (65 bytes: r + s + v). + /// @return signer The address of the verified enclave. + function verifyBatch(bytes32 digest, bytes calldata signature) external view returns (address signer); + + /// @notice Check if an address is a registered enclave. + /// @param enclaveAddress The address to check. + /// @return True if the address is registered. + function isRegistered(address enclaveAddress) external view returns (bool); +} diff --git a/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol b/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol new file mode 100644 index 0000000000000..3d991382817be --- /dev/null +++ b/packages/contracts-bedrock/scripts/deploy/DeployTee.s.sol @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console2} from "forge-std/Script.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {Duration} from "src/dispute/lib/Types.sol"; +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {TeeDisputeGame} from "src/dispute/tee/TeeDisputeGame.sol"; +import {TeeProofVerifier} from "src/dispute/tee/TeeProofVerifier.sol"; + +contract Deploy is Script { + struct DeployConfig { + uint256 deployerKey; + address deployer; + IRiscZeroVerifier riscZeroVerifier; + bytes32 imageId; + bytes nitroRootKey; + IDisputeGameFactory disputeGameFactory; + IAnchorStateRegistry anchorStateRegistry; + uint64 maxChallengeDuration; + uint64 maxProveDuration; + uint256 challengerBond; + address proofVerifierOwner; + address proposer; + address challenger; + } + + function run() external returns (TeeProofVerifier teeProofVerifier, TeeDisputeGame teeDisputeGame) { + DeployConfig memory cfg = _readConfig(); + + vm.startBroadcast(cfg.deployerKey); + + teeProofVerifier = new TeeProofVerifier(cfg.riscZeroVerifier, cfg.imageId, cfg.nitroRootKey); + if (cfg.proofVerifierOwner != cfg.deployer) { + teeProofVerifier.transferOwnership(cfg.proofVerifierOwner); + } + + teeDisputeGame = new TeeDisputeGame( + Duration.wrap(cfg.maxChallengeDuration), + Duration.wrap(cfg.maxProveDuration), + cfg.disputeGameFactory, + ITeeProofVerifier(address(teeProofVerifier)), + cfg.challengerBond, + cfg.anchorStateRegistry, + cfg.proposer, + cfg.challenger + ); + + vm.stopBroadcast(); + + console2.log("deployer", cfg.deployer); + console2.log("teeProofVerifier", address(teeProofVerifier)); + console2.log("teeDisputeGame", address(teeDisputeGame)); + } + + function _readConfig() internal view returns (DeployConfig memory cfg) { + cfg.deployerKey = vm.envUint("PRIVATE_KEY"); + cfg.deployer = vm.addr(cfg.deployerKey); + cfg.riscZeroVerifier = IRiscZeroVerifier(vm.envAddress("RISC_ZERO_VERIFIER")); + cfg.imageId = vm.envBytes32("RISC_ZERO_IMAGE_ID"); + cfg.nitroRootKey = vm.envBytes("NITRO_ROOT_KEY"); + cfg.disputeGameFactory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + cfg.anchorStateRegistry = IAnchorStateRegistry(vm.envAddress("ANCHOR_STATE_REGISTRY")); + cfg.maxChallengeDuration = uint64(vm.envUint("MAX_CHALLENGE_DURATION")); + cfg.maxProveDuration = uint64(vm.envUint("MAX_PROVE_DURATION")); + cfg.challengerBond = vm.envUint("CHALLENGER_BOND"); + cfg.proofVerifierOwner = vm.envOr("PROOF_VERIFIER_OWNER", cfg.deployer); + cfg.proposer = vm.envAddress("PROPOSER"); + cfg.challenger = vm.envAddress("CHALLENGER"); + } +} diff --git a/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol b/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol new file mode 100644 index 0000000000000..27b6a1df96c37 --- /dev/null +++ b/packages/contracts-bedrock/scripts/deploy/RegisterTeeGame.s.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Script, console2} from "forge-std/Script.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {GameType} from "src/dispute/lib/Types.sol"; + +contract RegisterTeeGame is Script { + uint32 internal constant TEE_GAME_TYPE = 1960; + string internal constant GAME_ARGS_UNSUPPORTED = + "RegisterTeeGame: GAME_ARGS is unsupported for gameType=1960"; + + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + IDisputeGameFactory disputeGameFactory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + IDisputeGame teeDisputeGame = IDisputeGame(vm.envAddress("TEE_DISPUTE_GAME_IMPL")); + uint256 initBond = vm.envUint("INIT_BOND"); + bytes memory gameArgs = vm.envOr("GAME_ARGS", bytes("")); + bool setRespectedGameType = vm.envOr("SET_RESPECTED_GAME_TYPE", false); + + require(gameArgs.length == 0, GAME_ARGS_UNSUPPORTED); + + vm.startBroadcast(deployerKey); + + disputeGameFactory.setImplementation(GameType.wrap(TEE_GAME_TYPE), teeDisputeGame); + disputeGameFactory.setInitBond(GameType.wrap(TEE_GAME_TYPE), initBond); + + if (setRespectedGameType) { + IAnchorStateRegistry anchorStateRegistry = IAnchorStateRegistry(vm.envAddress("ANCHOR_STATE_REGISTRY")); + anchorStateRegistry.setRespectedGameType(GameType.wrap(TEE_GAME_TYPE)); + console2.log("anchorStateRegistry respected game type set", TEE_GAME_TYPE); + } + + vm.stopBroadcast(); + + console2.log("registered gameType", TEE_GAME_TYPE); + console2.log("teeDisputeGame", address(teeDisputeGame)); + console2.log("initBond", initBond); + } +} diff --git a/packages/contracts-bedrock/scripts/tee/DeployTeeFork.s.sol b/packages/contracts-bedrock/scripts/tee/DeployTeeFork.s.sol new file mode 100644 index 0000000000000..4883f1e5463b0 --- /dev/null +++ b/packages/contracts-bedrock/scripts/tee/DeployTeeFork.s.sol @@ -0,0 +1,129 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Proxy } from "src/universal/Proxy.sol"; +import { DisputeGameFactory } from "src/dispute/DisputeGameFactory.sol"; +import { AnchorStateRegistry } from "src/dispute/AnchorStateRegistry.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; +import { IRiscZeroVerifier } from "interfaces/dispute/IRiscZeroVerifier.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { MockSystemConfig } from "test/dispute/tee/mocks/MockSystemConfig.sol"; +import { Duration, GameType, Hash, Proposal } from "src/dispute/lib/Types.sol"; + +/// @title DeployTeeFork +/// @notice Deploys TEE dispute game stack on a forked mainnet, using the real +/// RiscZeroVerifierRouter for ZK proof verification during enclave registration. +/// +/// @dev Usage: +/// anvil --fork-url $ETH_RPC_URL --block-time 1 +/// +/// PRIVATE_KEY=0xac09...ff80 \ +/// PROPOSER=0x7099...79C8 \ +/// CHALLENGER=0x3C44...93BC \ +/// RISC_ZERO_VERIFIER=0x8EaB2D97Dfce405A1692a21b3ff3A172d593D319 \ +/// RISC_ZERO_IMAGE_ID=0x \ +/// NITRO_ROOT_KEY=0x<96 bytes P384 root key hex> \ +/// forge script scripts/tee/DeployTeeFork.s.sol --rpc-url http://localhost:8545 --broadcast +contract DeployTeeFork is Script { + uint256 internal constant DEFENDER_BOND = 0.1 ether; + uint256 internal constant CHALLENGER_BOND = 0.2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 300; + uint64 internal constant MAX_PROVE_DURATION = 300; + + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("genesis-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("genesis-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 0; + + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + address proposer_ = vm.envAddress("PROPOSER"); + address challenger_ = vm.envAddress("CHALLENGER"); + + vm.startBroadcast(deployerKey); + + TeeProofVerifier teeProofVerifier = _deployVerifier(); + DisputeGameFactory factory = _deployFactory(deployer); + AnchorStateRegistry asr = _deployASR(deployer, factory); + TeeDisputeGame impl = _deployGame(factory, teeProofVerifier, asr, proposer_, challenger_); + + vm.stopBroadcast(); + + console2.log("=== Deployed (fork mode) ==="); + console2.log("TeeProofVerifier :", address(teeProofVerifier)); + console2.log("DisputeGameFactory :", address(factory)); + console2.log("AnchorStateRegistry :", address(asr)); + console2.log("TeeDisputeGame impl :", address(impl)); + } + + function _deployVerifier() internal returns (TeeProofVerifier) { + IRiscZeroVerifier rv = IRiscZeroVerifier(vm.envAddress("RISC_ZERO_VERIFIER")); + bytes32 imageId = vm.envBytes32("RISC_ZERO_IMAGE_ID"); + bytes memory rootKey = vm.envBytes("NITRO_ROOT_KEY"); + console2.log("RiscZeroVerifier :", address(rv)); + console2.log("imageId :", vm.toString(imageId)); + return new TeeProofVerifier(rv, imageId, rootKey); + } + + function _deployFactory(address deployer) internal returns (DisputeGameFactory) { + DisputeGameFactory factoryImpl = new DisputeGameFactory(); + Proxy p = new Proxy(deployer); + p.upgradeToAndCall(address(factoryImpl), abi.encodeCall(factoryImpl.initialize, (deployer))); + return DisputeGameFactory(address(p)); + } + + function _deployASR(address deployer, DisputeGameFactory factory) internal returns (AnchorStateRegistry) { + MockSystemConfig sc = new MockSystemConfig(deployer); + AnchorStateRegistry asrImpl = new AnchorStateRegistry(0); + Proxy p = new Proxy(deployer); + p.upgradeToAndCall( + address(asrImpl), + abi.encodeCall( + asrImpl.initialize, + ( + ISystemConfig(address(sc)), + IDisputeGameFactory(address(factory)), + Proposal({ + root: Hash.wrap(keccak256(abi.encode(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH))), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + TEE_GAME_TYPE + ) + ) + ); + return AnchorStateRegistry(address(p)); + } + + function _deployGame( + DisputeGameFactory factory, + TeeProofVerifier verifier, + AnchorStateRegistry asr, + address proposer_, + address challenger_ + ) + internal + returns (TeeDisputeGame) + { + TeeDisputeGame impl = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(verifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(asr)), + proposer_, + challenger_ + ); + factory.setImplementation(TEE_GAME_TYPE, IDisputeGame(address(impl)), bytes("")); + factory.setInitBond(TEE_GAME_TYPE, DEFENDER_BOND); + return impl; + } +} diff --git a/packages/contracts-bedrock/scripts/tee/DeployTeeMock.s.sol b/packages/contracts-bedrock/scripts/tee/DeployTeeMock.s.sol new file mode 100644 index 0000000000000..656405b1294d8 --- /dev/null +++ b/packages/contracts-bedrock/scripts/tee/DeployTeeMock.s.sol @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Proxy } from "src/universal/Proxy.sol"; +import { DisputeGameFactory } from "src/dispute/DisputeGameFactory.sol"; +import { AnchorStateRegistry } from "src/dispute/AnchorStateRegistry.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { MockRiscZeroVerifier } from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import { MockSystemConfig } from "test/dispute/tee/mocks/MockSystemConfig.sol"; +import { Duration, GameType, Hash, Proposal } from "src/dispute/lib/Types.sol"; + +/// @title DeployTeeMock +/// @notice Deploys the full TEE dispute game stack with MockRiscZeroVerifier for local testing. +/// register() accepts empty seal in this mode. +/// +/// @dev Usage: +/// anvil --block-time 1 +/// +/// PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \ +/// PROPOSER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8 \ +/// CHALLENGER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC \ +/// forge script scripts/tee/DeployTeeMock.s.sol --rpc-url http://localhost:8545 --broadcast +contract DeployTeeMock is Script { + uint256 internal constant DEFENDER_BOND = 0.1 ether; + uint256 internal constant CHALLENGER_BOND = 0.2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 300; // 5 min + uint64 internal constant MAX_PROVE_DURATION = 300; // 5 min + + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("genesis-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("genesis-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 0; + + function run() external { + uint256 deployerKey = vm.envUint("PRIVATE_KEY"); + address deployer = vm.addr(deployerKey); + address proposer_ = vm.envAddress("PROPOSER"); + address challenger_ = vm.envAddress("CHALLENGER"); + + vm.startBroadcast(deployerKey); + + // 1. MockRiscZeroVerifier -- verify() is a no-op + MockRiscZeroVerifier mockRiscZero = new MockRiscZeroVerifier(); + + // 2. TeeProofVerifier with mock verifier + dummy imageId/rootKey + bytes32 imageId = keccak256("mock-image-id"); + bytes memory rootKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), bytes32(uint256(3))); + TeeProofVerifier teeProofVerifier = new TeeProofVerifier(mockRiscZero, imageId, rootKey); + + // 3. DisputeGameFactory (via Proxy) + DisputeGameFactory factory = _deployFactory(deployer); + + // 4. AnchorStateRegistry (via Proxy) + AnchorStateRegistry anchorStateRegistry = _deployAnchorStateRegistry(deployer, factory); + + // 5. TeeDisputeGame implementation + register in factory + TeeDisputeGame teeDisputeGame = + _deployAndRegisterGame(factory, teeProofVerifier, anchorStateRegistry, proposer_, challenger_); + + vm.stopBroadcast(); + + console2.log("=== Deployed Addresses ==="); + console2.log("MockRiscZeroVerifier :", address(mockRiscZero)); + console2.log("TeeProofVerifier :", address(teeProofVerifier)); + console2.log("DisputeGameFactory :", address(factory)); + console2.log("AnchorStateRegistry :", address(anchorStateRegistry)); + console2.log("TeeDisputeGame impl :", address(teeDisputeGame)); + console2.log(""); + console2.log("=== Config ==="); + console2.log("PROPOSER :", proposer_); + console2.log("CHALLENGER :", challenger_); + console2.log("DEFENDER_BOND :", DEFENDER_BOND); + console2.log("CHALLENGER_BOND :", CHALLENGER_BOND); + console2.log("TEE_GAME_TYPE :", TEE_DISPUTE_GAME_TYPE); + console2.log("ANCHOR_L2_BLOCK :", ANCHOR_L2_BLOCK); + } + + function _deployFactory(address deployer) internal returns (DisputeGameFactory) { + DisputeGameFactory factoryImpl = new DisputeGameFactory(); + Proxy factoryProxy = new Proxy(deployer); + factoryProxy.upgradeToAndCall(address(factoryImpl), abi.encodeCall(factoryImpl.initialize, (deployer))); + return DisputeGameFactory(address(factoryProxy)); + } + + function _deployAnchorStateRegistry( + address deployer, + DisputeGameFactory factory + ) + internal + returns (AnchorStateRegistry) + { + MockSystemConfig systemConfig = new MockSystemConfig(deployer); + AnchorStateRegistry asrImpl = new AnchorStateRegistry(0); + Proxy asrProxy = new Proxy(deployer); + asrProxy.upgradeToAndCall( + address(asrImpl), + abi.encodeCall( + asrImpl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(factory)), + Proposal({ + root: Hash.wrap(keccak256(abi.encode(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH))), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + TEE_GAME_TYPE + ) + ) + ); + return AnchorStateRegistry(address(asrProxy)); + } + + function _deployAndRegisterGame( + DisputeGameFactory factory, + TeeProofVerifier teeProofVerifier, + AnchorStateRegistry anchorStateRegistry, + address proposer_, + address challenger_ + ) + internal + returns (TeeDisputeGame) + { + TeeDisputeGame teeDisputeGame = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(anchorStateRegistry)), + proposer_, + challenger_ + ); + + factory.setImplementation(TEE_GAME_TYPE, IDisputeGame(address(teeDisputeGame)), bytes("")); + factory.setInitBond(TEE_GAME_TYPE, DEFENDER_BOND); + + return teeDisputeGame; + } +} diff --git a/packages/contracts-bedrock/scripts/tee/TeeProveE2E.s.sol b/packages/contracts-bedrock/scripts/tee/TeeProveE2E.s.sol new file mode 100644 index 0000000000000..b2965de7c6ec6 --- /dev/null +++ b/packages/contracts-bedrock/scripts/tee/TeeProveE2E.s.sol @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { Claim, GameType, GameStatus } from "src/dispute/lib/Types.sol"; + +/// @title TeeProveE2E +/// @notice Mock E2E: register enclave (empty seal), create game, challenge, prove, resolve. +/// All signing done locally with ENCLAVE_KEY. For real ZK proof testing, use TeeProveE2EFork. +/// +/// @dev Usage: +/// PRIVATE_KEY=0xac09...ff80 PROPOSER_KEY=0x59c6...690d CHALLENGER_KEY=0x5de4...365a \ +/// ENCLAVE_KEY=0x7c85...07a6 TEE_PROOF_VERIFIER= DISPUTE_GAME_FACTORY= \ +/// forge script scripts/tee/TeeProveE2E.s.sol --rpc-url http://localhost:8545 --broadcast +contract TeeProveE2E is Script { + bytes32 private constant BATCH_PROOF_TYPEHASH = keccak256( + "BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)" + ); + + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + TeeProofVerifier internal teeProofVerifier; + IDisputeGameFactory internal factory; + TeeDisputeGame internal game; + + bytes32 internal startBlockHash; + bytes32 internal startStateHash; + bytes32 internal endBlockHash; + bytes32 internal endStateHash; + uint256 internal l2SeqNum; + + function run() external { + uint256 ownerKey = vm.envUint("PRIVATE_KEY"); + uint256 proposerKey = vm.envUint("PROPOSER_KEY"); + uint256 challengerKey = vm.envUint("CHALLENGER_KEY"); + uint256 enclaveKey = vm.envUint("ENCLAVE_KEY"); + + teeProofVerifier = TeeProofVerifier(vm.envAddress("TEE_PROOF_VERIFIER")); + factory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + + startBlockHash = vm.envOr("START_BLOCK_HASH", keccak256("genesis-block")); + startStateHash = vm.envOr("START_STATE_HASH", keccak256("genesis-state")); + endBlockHash = vm.envOr("END_BLOCK_HASH", keccak256("end-block-100")); + endStateHash = vm.envOr("END_STATE_HASH", keccak256("end-state-100")); + l2SeqNum = vm.envOr("L2_SEQUENCE_NUMBER", uint256(100)); + + console2.log("=== Step 1: Register Enclave (mock) ==="); + _registerEnclave(ownerKey, enclaveKey); + + console2.log(""); + console2.log("=== Step 2: Create Game ==="); + _createGame(proposerKey); + + console2.log(""); + console2.log("=== Step 3: Challenge ==="); + _challenge(challengerKey); + + console2.log(""); + console2.log("=== Step 4: Prove (enclave key signs locally) ==="); + _prove(proposerKey, enclaveKey); + + console2.log(""); + console2.log("=== Step 5: Resolve ==="); + _resolve(proposerKey); + + console2.log(""); + console2.log("=== E2E Complete ==="); + } + + function _registerEnclave(uint256 ownerKey, uint256 enclaveKey) internal { + Vm.Wallet memory w = vm.createWallet(enclaveKey, "enclave"); + if (teeProofVerifier.isRegistered(w.addr)) { + console2.log("Already registered:", w.addr); + return; + } + bytes memory pubKey = abi.encodePacked(bytes1(0x04), bytes32(w.publicKeyX), bytes32(w.publicKeyY)); + TeeProofVerifier.AttestationData memory att = TeeProofVerifier.AttestationData({ + timestampMs: uint64(block.timestamp * 1000), + pcrHash: keccak256("mock-pcr-hash"), + publicKey: pubKey, + userData: "" + }); + vm.broadcast(ownerKey); + teeProofVerifier.register("", att); + console2.log("Enclave registered:", w.addr); + } + + function _createGame(uint256 proposerKey) internal { + uint256 bond = factory.initBonds(TEE_GAME_TYPE); + bytes memory extra = abi.encodePacked(l2SeqNum, type(uint32).max, endBlockHash, endStateHash); + Claim root = Claim.wrap(keccak256(abi.encode(endBlockHash, endStateHash))); + vm.broadcast(proposerKey); + game = TeeDisputeGame(payable(address(factory.create{ value: bond }(TEE_GAME_TYPE, root, extra)))); + console2.log("Game created:", address(game)); + console2.log(" l2SequenceNumber:", l2SeqNum); + } + + function _challenge(uint256 challengerKey) internal { + uint256 bond = vm.envOr("CHALLENGER_BOND", uint256(0.2 ether)); + vm.broadcast(challengerKey); + game.challenge{ value: bond }(); + console2.log("Challenged by:", vm.addr(challengerKey)); + } + + function _prove(uint256 proposerKey, uint256 enclaveKey) internal { + bytes32 domainSep = game.domainSeparator(); + bytes32 structHash = keccak256( + abi.encode(BATCH_PROOF_TYPEHASH, startBlockHash, startStateHash, endBlockHash, endStateHash, l2SeqNum) + ); + bytes32 digest = keccak256(abi.encodePacked("\x19\x01", domainSep, structHash)); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(enclaveKey, digest); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = TeeDisputeGame.BatchProof({ + startBlockHash: startBlockHash, + startStateHash: startStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: l2SeqNum, + signature: abi.encodePacked(r, s, v) + }); + vm.broadcast(proposerKey); + game.prove(abi.encode(proofs)); + console2.log("Proof submitted!"); + } + + function _resolve(uint256 callerKey) internal { + vm.broadcast(callerKey); + GameStatus result = game.resolve(); + if (result == GameStatus.DEFENDER_WINS) console2.log("DEFENDER_WINS"); + else if (result == GameStatus.CHALLENGER_WINS) console2.log("CHALLENGER_WINS"); + else console2.log("IN_PROGRESS"); + } +} diff --git a/packages/contracts-bedrock/scripts/tee/TeeProveE2EFork.s.sol b/packages/contracts-bedrock/scripts/tee/TeeProveE2EFork.s.sol new file mode 100644 index 0000000000000..9e43d56d810cb --- /dev/null +++ b/packages/contracts-bedrock/scripts/tee/TeeProveE2EFork.s.sol @@ -0,0 +1,209 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Script, console2 } from "forge-std/Script.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { Claim, GameType, GameStatus } from "src/dispute/lib/Types.sol"; + +/// @title TeeProveE2EFork +/// @notice E2E on forked mainnet: register enclave with real ZK proof, then +/// create game, challenge, prove (external signature), resolve. +/// +/// @dev Usage: +/// # 1. Deploy first: +/// forge script scripts/tee/DeployTeeFork.s.sol --rpc-url http://localhost:8545 --broadcast +/// +/// # 2. Run E2E: +/// PRIVATE_KEY= \ +/// PROPOSER_KEY= \ +/// CHALLENGER_KEY= \ +/// TEE_PROOF_VERIFIER= \ +/// DISPUTE_GAME_FACTORY= \ +/// SEAL=0x \ +/// ATT_TIMESTAMP_MS= \ +/// ATT_PCR_HASH=0x \ +/// ATT_PUBLIC_KEY=0x<65 bytes> \ +/// ATT_USER_DATA=0x \ +/// BATCH_SIGNATURE=0x<65 bytes r+s+v> \ +/// END_BLOCK_HASH=0x... \ +/// END_STATE_HASH=0x... \ +/// L2_SEQUENCE_NUMBER=100 \ +/// forge script scripts/tee/TeeProveE2EFork.s.sol --rpc-url http://localhost:8545 --broadcast +contract TeeProveE2EFork is Script { + bytes32 private constant BATCH_PROOF_TYPEHASH = keccak256( + "BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)" + ); + + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + TeeProofVerifier internal teeProofVerifier; + IDisputeGameFactory internal factory; + TeeDisputeGame internal game; + + bytes32 internal startBlockHash; + bytes32 internal startStateHash; + bytes32 internal endBlockHash; + bytes32 internal endStateHash; + uint256 internal l2SeqNum; + + function run() external { + uint256 ownerKey = vm.envUint("PRIVATE_KEY"); + uint256 proposerKey = vm.envUint("PROPOSER_KEY"); + uint256 challengerKey = vm.envUint("CHALLENGER_KEY"); + + teeProofVerifier = TeeProofVerifier(vm.envAddress("TEE_PROOF_VERIFIER")); + factory = IDisputeGameFactory(vm.envAddress("DISPUTE_GAME_FACTORY")); + + startBlockHash = vm.envOr("START_BLOCK_HASH", keccak256("genesis-block")); + startStateHash = vm.envOr("START_STATE_HASH", keccak256("genesis-state")); + endBlockHash = vm.envOr("END_BLOCK_HASH", keccak256("end-block-100")); + endStateHash = vm.envOr("END_STATE_HASH", keccak256("end-state-100")); + l2SeqNum = vm.envOr("L2_SEQUENCE_NUMBER", uint256(100)); + + bytes memory batchSig = vm.envBytes("BATCH_SIGNATURE"); + + // ---- Step 1: Register enclave with real ZK proof ---- + console2.log("=== Step 1: Register Enclave (real ZK proof) ==="); + _registerEnclave(ownerKey); + + // ---- Step 2: Create game ---- + console2.log(""); + console2.log("=== Step 2: Create Game ==="); + _createGame(proposerKey); + + // ---- Step 3: Challenge ---- + console2.log(""); + console2.log("=== Step 3: Challenge ==="); + _challenge(challengerKey); + + // ---- Step 4: Prove with external signature ---- + console2.log(""); + console2.log("=== Step 4: Prove (external signature) ==="); + _prove(proposerKey, batchSig); + + // ---- Step 5: Resolve ---- + console2.log(""); + console2.log("=== Step 5: Resolve ==="); + _resolve(proposerKey); + + console2.log(""); + console2.log("=== E2E Complete (fork mode) ==="); + } + + // ---------------------------------------------------------------- + // Step 1: Register enclave with real seal from Boundless + // ---------------------------------------------------------------- + + function _registerEnclave(uint256 ownerKey) internal { + bytes memory seal = vm.envBytes("SEAL"); + uint64 timestampMs = uint64(vm.envUint("ATT_TIMESTAMP_MS")); + bytes32 pcrHash = vm.envBytes32("ATT_PCR_HASH"); + bytes memory publicKey = vm.envBytes("ATT_PUBLIC_KEY"); + bytes memory userData = vm.envOr("ATT_USER_DATA", bytes("")); + + require(publicKey.length == 65, "ATT_PUBLIC_KEY must be 65 bytes"); + + // Derive enclave address from public key + address enclaveAddr = _pubKeyToAddr(publicKey); + + if (teeProofVerifier.isRegistered(enclaveAddr)) { + console2.log("Already registered:", enclaveAddr); + return; + } + + TeeProofVerifier.AttestationData memory att = TeeProofVerifier.AttestationData({ + timestampMs: timestampMs, + pcrHash: pcrHash, + publicKey: publicKey, + userData: userData + }); + + console2.log("Registering with real seal..."); + console2.log(" seal length:", seal.length); + console2.log(" enclave address:", enclaveAddr); + + vm.broadcast(ownerKey); + teeProofVerifier.register(seal, att); + + require(teeProofVerifier.isRegistered(enclaveAddr), "register failed"); + console2.log("Enclave registered:", enclaveAddr); + } + + // ---------------------------------------------------------------- + // Step 2: Create game + // ---------------------------------------------------------------- + + function _createGame(uint256 proposerKey) internal { + uint256 bond = factory.initBonds(TEE_GAME_TYPE); + bytes memory extra = abi.encodePacked(l2SeqNum, type(uint32).max, endBlockHash, endStateHash); + Claim root = Claim.wrap(keccak256(abi.encode(endBlockHash, endStateHash))); + + vm.broadcast(proposerKey); + game = TeeDisputeGame(payable(address(factory.create{ value: bond }(TEE_GAME_TYPE, root, extra)))); + + console2.log("Game created:", address(game)); + console2.log(" l2SequenceNumber:", l2SeqNum); + console2.log(" domainSeparator:", vm.toString(game.domainSeparator())); + } + + // ---------------------------------------------------------------- + // Step 3: Challenge + // ---------------------------------------------------------------- + + function _challenge(uint256 challengerKey) internal { + uint256 bond = vm.envOr("CHALLENGER_BOND", uint256(0.2 ether)); + vm.broadcast(challengerKey); + game.challenge{ value: bond }(); + console2.log("Challenged by:", vm.addr(challengerKey)); + } + + // ---------------------------------------------------------------- + // Step 4: Prove with external signature from TEE prover + // ---------------------------------------------------------------- + + function _prove(uint256 proposerKey, bytes memory signature) internal { + require(signature.length == 65, "BATCH_SIGNATURE must be 65 bytes"); + console2.log("Using external signature, length:", signature.length); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = TeeDisputeGame.BatchProof({ + startBlockHash: startBlockHash, + startStateHash: startStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: l2SeqNum, + signature: signature + }); + + vm.broadcast(proposerKey); + game.prove(abi.encode(proofs)); + + console2.log("Proof submitted!"); + } + + // ---------------------------------------------------------------- + // Step 5: Resolve + // ---------------------------------------------------------------- + + function _resolve(uint256 callerKey) internal { + vm.broadcast(callerKey); + GameStatus result = game.resolve(); + if (result == GameStatus.DEFENDER_WINS) console2.log("DEFENDER_WINS"); + else if (result == GameStatus.CHALLENGER_WINS) console2.log("CHALLENGER_WINS"); + else console2.log("IN_PROGRESS"); + } + + // ---------------------------------------------------------------- + // Helpers + // ---------------------------------------------------------------- + + function _pubKeyToAddr(bytes memory publicKey) internal pure returns (address) { + bytes memory coords = new bytes(64); + for (uint256 i = 0; i < 64; i++) { + coords[i] = publicKey[i + 1]; + } + return address(uint160(uint256(keccak256(coords)))); + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol new file mode 100644 index 0000000000000..686c273c2b002 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/TeeDisputeGame.sol @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// Libraries +import { Clone } from "@solady/utils/Clone.sol"; +import { + BondDistributionMode, + Claim, + Duration, + GameStatus, + GameType, + Hash, + Proposal, + Timestamp +} from "src/dispute/lib/Types.sol"; +import { + AlreadyInitialized, + BadAuth, + BondTransferFailed, + ClaimAlreadyResolved, + GameNotFinalized, + GamePaused, + IncorrectBondAmount, + InvalidBondDistributionMode, + NoCreditToClaim, + UnexpectedRootClaim +} from "src/dispute/lib/Errors.sol"; +import "src/dispute/tee/lib/Errors.sol"; + +// Interfaces +import { ISemver } from "interfaces/universal/ISemver.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; + +/// @dev Game type constant for TEE Dispute Game. +uint32 constant TEE_DISPUTE_GAME_TYPE = 1960; + +/// @title TeeDisputeGame +/// @notice A dispute game that uses TEE (AWS Nitro Enclave) ECDSA signatures +/// instead of SP1 ZK proofs for batch state transition verification. +/// @dev Mirrors OPSuccinctFaultDisputeGame architecture but replaces +/// SP1_VERIFIER.verifyProof() with TEE_PROOF_VERIFIER.verifyBatch(). +/// Uses the same DisputeGameFactory and AnchorStateRegistry +/// infrastructure from OP Stack. +/// +/// prove() accepts multiple chained batch proofs to support the scenario +/// where different TEE executors handle different sub-ranges within a single game. +/// Each batch carries (startBlockHash, startStateHash, endBlockHash, endStateHash, l2Block). +/// batchDigest is computed on-chain as an EIP-712 typed data hash +/// (domain: name="TeeDisputeGame", version="1", chainId, verifyingContract=TEE_PROOF_VERIFIER) +/// and verified via TEE ECDSA signature. +/// +/// rootClaim = keccak256(abi.encode(blockHash, stateHash)) where blockHash and stateHash +/// are passed via extraData. The anchor state stores this combined hash. +contract TeeDisputeGame is Clone, ISemver, IDisputeGame { + //////////////////////////////////////////////////////////////// + // Enums // + //////////////////////////////////////////////////////////////// + + enum ProposalStatus { + Unchallenged, + Challenged, + UnchallengedAndValidProofProvided, + ChallengedAndValidProofProvided, + Resolved + } + + //////////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////////// + + struct ClaimData { + uint32 parentIndex; + address counteredBy; + address prover; + Claim claim; + ProposalStatus status; + Timestamp deadline; + } + + /// @notice A single batch proof segment within a chained prove() call. + /// @dev Multiple BatchProofs can be submitted to cover a game's full range, + /// e.g. when different TEE executors handle sub-ranges. + struct BatchProof { + bytes32 startBlockHash; + bytes32 startStateHash; + bytes32 endBlockHash; + bytes32 endStateHash; + uint256 l2Block; + bytes signature; // 65 bytes ECDSA (r + s + v) + } + + //////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////// + + event Challenged(address indexed challenger); + event Proved(address indexed prover); + event GameClosed(BondDistributionMode bondDistributionMode); + + error EmptyBatchProofs(); + error StartHashMismatch(bytes32 expectedCombined, bytes32 actualCombined); + error BatchChainBreak(uint256 index); + error BatchBlockNotIncreasing(uint256 index, uint256 prevBlock, uint256 curBlock); + error FinalHashMismatch(bytes32 expectedCombined, bytes32 actualCombined); + error FinalBlockMismatch(uint256 expectedBlock, uint256 actualBlock); + error RootClaimMismatch(bytes32 expectedRootClaim, bytes32 actualRootClaim); + + //////////////////////////////////////////////////////////////// + // EIP-712 Constants // + //////////////////////////////////////////////////////////////// + + bytes32 private constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant DOMAIN_NAME_HASH = keccak256("TeeDisputeGame"); + bytes32 private constant DOMAIN_VERSION_HASH = keccak256("1"); + bytes32 private constant BATCH_PROOF_TYPEHASH = keccak256( + "BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)" + ); + + //////////////////////////////////////////////////////////////// + // Immutables // + //////////////////////////////////////////////////////////////// + + GameType internal constant GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + Duration internal immutable MAX_CHALLENGE_DURATION; + Duration internal immutable MAX_PROVE_DURATION; + IDisputeGameFactory internal immutable DISPUTE_GAME_FACTORY; + ITeeProofVerifier internal immutable TEE_PROOF_VERIFIER; + uint256 internal immutable CHALLENGER_BOND; + IAnchorStateRegistry internal immutable ANCHOR_STATE_REGISTRY; + address internal immutable PROPOSER; + address internal immutable CHALLENGER; + + //////////////////////////////////////////////////////////////// + // State Vars // + //////////////////////////////////////////////////////////////// + + /// @custom:semver 1.0.0 + string public constant version = "1.0.0"; + + Timestamp public createdAt; + Timestamp public resolvedAt; + GameStatus public status; + /// @notice The proposer EOA captured during initialization, aligned with OP permissioned games. + address public proposer; + bool internal initialized; + ClaimData public claimData; + + mapping(address => uint256) public normalModeCredit; + mapping(address => uint256) public refundModeCredit; + + Proposal public startingOutputRoot; + bool public wasRespectedGameTypeWhenCreated; + BondDistributionMode public bondDistributionMode; + + //////////////////////////////////////////////////////////////// + // Constructor // + //////////////////////////////////////////////////////////////// + + constructor( + Duration _maxChallengeDuration, + Duration _maxProveDuration, + IDisputeGameFactory _disputeGameFactory, + ITeeProofVerifier _teeProofVerifier, + uint256 _challengerBond, + IAnchorStateRegistry _anchorStateRegistry, + address _proposer, + address _challenger + ) { + MAX_CHALLENGE_DURATION = _maxChallengeDuration; + MAX_PROVE_DURATION = _maxProveDuration; + DISPUTE_GAME_FACTORY = _disputeGameFactory; + TEE_PROOF_VERIFIER = _teeProofVerifier; + CHALLENGER_BOND = _challengerBond; + ANCHOR_STATE_REGISTRY = _anchorStateRegistry; + PROPOSER = _proposer; + CHALLENGER = _challenger; + } + + //////////////////////////////////////////////////////////////// + // Initialize // + //////////////////////////////////////////////////////////////// + + function initialize() external payable virtual { + if (initialized) revert AlreadyInitialized(); + if (address(DISPUTE_GAME_FACTORY) != msg.sender) revert IncorrectDisputeGameFactory(); + if (tx.origin != PROPOSER) revert BadAuth(); + + assembly { + if iszero(eq(calldatasize(), 0xBE)) { + mstore(0x00, 0x9824bdab) + revert(0x1C, 0x04) + } + } + + // Verify rootClaim == keccak256(abi.encode(blockHash, stateHash)) + bytes32 expectedRootClaim = keccak256(abi.encode(blockHash(), stateHash())); + if (expectedRootClaim != rootClaim().raw()) { + revert RootClaimMismatch(expectedRootClaim, rootClaim().raw()); + } + + if (parentIndex() != type(uint32).max) { + (GameType parentGameType,, IDisputeGame proxy) = DISPUTE_GAME_FACTORY.gameAtIndex(parentIndex()); + if (GameType.unwrap(parentGameType) != GameType.unwrap(GAME_TYPE)) revert InvalidParentGame(); + + if ( + !ANCHOR_STATE_REGISTRY.isGameRespected(proxy) || ANCHOR_STATE_REGISTRY.isGameBlacklisted(proxy) + || ANCHOR_STATE_REGISTRY.isGameRetired(proxy) + ) { + revert InvalidParentGame(); + } + + startingOutputRoot = Proposal({ + l2SequenceNumber: TeeDisputeGame(address(proxy)).l2SequenceNumber(), + root: Hash.wrap(TeeDisputeGame(address(proxy)).rootClaim().raw()) + }); + + if (proxy.status() == GameStatus.CHALLENGER_WINS) revert InvalidParentGame(); + } else { + (startingOutputRoot.root, startingOutputRoot.l2SequenceNumber) = ANCHOR_STATE_REGISTRY.getAnchorRoot(); + } + + if (l2SequenceNumber() <= startingOutputRoot.l2SequenceNumber) { + revert UnexpectedRootClaim(rootClaim()); + } + + claimData = ClaimData({ + parentIndex: parentIndex(), + counteredBy: address(0), + prover: address(0), + claim: rootClaim(), + status: ProposalStatus.Unchallenged, + deadline: Timestamp.wrap(uint64(block.timestamp + MAX_CHALLENGE_DURATION.raw())) + }); + + initialized = true; + proposer = tx.origin; + refundModeCredit[proposer] += msg.value; + createdAt = Timestamp.wrap(uint64(block.timestamp)); + wasRespectedGameTypeWhenCreated = + GameType.unwrap(ANCHOR_STATE_REGISTRY.respectedGameType()) == GameType.unwrap(GAME_TYPE); + } + + //////////////////////////////////////////////////////////////// + // Core Game Logic // + //////////////////////////////////////////////////////////////// + + function challenge() external payable returns (ProposalStatus) { + if (claimData.status != ProposalStatus.Unchallenged) revert ClaimAlreadyChallenged(); + if (msg.sender != CHALLENGER) revert BadAuth(); + if (gameOver()) revert GameOver(); + if (msg.value != CHALLENGER_BOND) revert IncorrectBondAmount(); + + claimData.counteredBy = msg.sender; + claimData.status = ProposalStatus.Challenged; + claimData.deadline = Timestamp.wrap(uint64(block.timestamp + MAX_PROVE_DURATION.raw())); + refundModeCredit[msg.sender] += msg.value; + + emit Challenged(claimData.counteredBy); + return claimData.status; + } + + /// @notice Submit chained batch proofs to verify the full state transition. + /// @dev Can be called before or after challenge(). Early proving (before any challenge) is + /// intentional — TEE enclaves are trusted, so a valid proof means the claim is correct. + /// Once proved, gameOver() returns true, which blocks further challenges. The challenge + /// mechanism is an economic incentive for the TEE to prove on demand, not a fraud-proof + /// security layer. If the TEE is compromised, the system's security relies on enclave + /// revocation via TeeProofVerifier.revoke(), not on the challenge window. + /// + /// Each BatchProof covers a sub-range with (startBlockHash, startStateHash, endBlockHash, endStateHash). + /// The contract verifies: + /// 1. keccak256(proofs[0].startBlockHash, startStateHash) == startingOutputRoot.root + /// 2. proofs[i].end{Block,State}Hash == proofs[i+1].start{Block,State}Hash (chain continuity) + /// 3. proofs[i].l2Block < proofs[i+1].l2Block (monotonically increasing) + /// 4. keccak256(proofs[last].endBlockHash, endStateHash) == rootClaim + /// 5. proofs[last].l2Block == l2SequenceNumber + /// 6. Each batch's EIP-712 typed digest + TEE signature is valid (via TEE_PROOF_VERIFIER) + /// @param proofBytes ABI-encoded BatchProof[] array + function prove(bytes calldata proofBytes) external returns (ProposalStatus) { + if (msg.sender != proposer) revert BadAuth(); + if (status != GameStatus.IN_PROGRESS) revert ClaimAlreadyResolved(); + if (gameOver()) revert GameOver(); + + BatchProof[] memory proofs = abi.decode(proofBytes, (BatchProof[])); + if (proofs.length == 0) revert EmptyBatchProofs(); + + // Verify first proof starts from the starting output root + { + bytes32 startCombined = keccak256(abi.encode(proofs[0].startBlockHash, proofs[0].startStateHash)); + bytes32 expectedStart = Hash.unwrap(startingOutputRoot.root); + if (startCombined != expectedStart) { + revert StartHashMismatch(expectedStart, startCombined); + } + } + + uint256 prevBlock = startingOutputRoot.l2SequenceNumber; + + for (uint256 i = 0; i < proofs.length; i++) { + // Chain continuity: each batch starts where the previous ended + if (i > 0) { + if ( + proofs[i].startBlockHash != proofs[i - 1].endBlockHash + || proofs[i].startStateHash != proofs[i - 1].endStateHash + ) { + revert BatchChainBreak(i); + } + } + + // L2 block must be monotonically increasing + if (proofs[i].l2Block <= prevBlock) { + revert BatchBlockNotIncreasing(i, prevBlock, proofs[i].l2Block); + } + + // Compute EIP-712 typed batchDigest on-chain and verify TEE signature + bytes32 structHash = keccak256( + abi.encode( + BATCH_PROOF_TYPEHASH, + proofs[i].startBlockHash, + proofs[i].startStateHash, + proofs[i].endBlockHash, + proofs[i].endStateHash, + proofs[i].l2Block + ) + ); + bytes32 batchDigest = keccak256(abi.encodePacked("\x19\x01", _domainSeparator(), structHash)); + TEE_PROOF_VERIFIER.verifyBatch(batchDigest, proofs[i].signature); + + prevBlock = proofs[i].l2Block; + } + + // Final endHash must match rootClaim (which is keccak256(blockHash, stateHash)) + { + uint256 last = proofs.length - 1; + bytes32 endCombined = keccak256(abi.encode(proofs[last].endBlockHash, proofs[last].endStateHash)); + if (endCombined != rootClaim().raw()) { + revert FinalHashMismatch(rootClaim().raw(), endCombined); + } + } + + // Final l2Block must match game's l2SequenceNumber + if (prevBlock != l2SequenceNumber()) { + revert FinalBlockMismatch(l2SequenceNumber(), prevBlock); + } + + claimData.prover = msg.sender; + + if (claimData.counteredBy == address(0)) { + claimData.status = ProposalStatus.UnchallengedAndValidProofProvided; + } else { + claimData.status = ProposalStatus.ChallengedAndValidProofProvided; + } + + emit Proved(claimData.prover); + return claimData.status; + } + + function resolve() external returns (GameStatus) { + if (status != GameStatus.IN_PROGRESS) revert ClaimAlreadyResolved(); + + GameStatus parentGameStatus = _getParentGameStatus(); + if (parentGameStatus == GameStatus.IN_PROGRESS) revert ParentGameNotResolved(); + + if (parentGameStatus == GameStatus.CHALLENGER_WINS) { + status = GameStatus.CHALLENGER_WINS; + // If the child was challenged, the challenger gets the bonds. + // If the child was never challenged (counteredBy == address(0)), + // refund the proposer — they should not lose their bond due to parent invalidation. + address recipient = claimData.counteredBy != address(0) ? claimData.counteredBy : proposer; + normalModeCredit[recipient] = address(this).balance; + } else { + if (!gameOver()) revert GameNotOver(); + + if (claimData.status == ProposalStatus.Unchallenged) { + status = GameStatus.DEFENDER_WINS; + normalModeCredit[proposer] = address(this).balance; + } else if (claimData.status == ProposalStatus.Challenged) { + status = GameStatus.CHALLENGER_WINS; + normalModeCredit[claimData.counteredBy] = address(this).balance; + } else if (claimData.status == ProposalStatus.UnchallengedAndValidProofProvided) { + status = GameStatus.DEFENDER_WINS; + normalModeCredit[proposer] = address(this).balance; + } else if (claimData.status == ProposalStatus.ChallengedAndValidProofProvided) { + status = GameStatus.DEFENDER_WINS; + normalModeCredit[proposer] = address(this).balance; + } else { + revert InvalidProposalStatus(); + } + } + + claimData.status = ProposalStatus.Resolved; + resolvedAt = Timestamp.wrap(uint64(block.timestamp)); + emit Resolved(status); + + return status; + } + + function claimCredit(address _recipient) external { + closeGame(); + + uint256 recipientCredit; + if (bondDistributionMode == BondDistributionMode.REFUND) { + recipientCredit = refundModeCredit[_recipient]; + } else if (bondDistributionMode == BondDistributionMode.NORMAL) { + recipientCredit = normalModeCredit[_recipient]; + } else { + revert InvalidBondDistributionMode(); + } + + if (recipientCredit == 0) revert NoCreditToClaim(); + + refundModeCredit[_recipient] = 0; + normalModeCredit[_recipient] = 0; + + (bool success,) = _recipient.call{ value: recipientCredit }(hex""); + if (!success) revert BondTransferFailed(); + } + + function closeGame() public { + if (bondDistributionMode == BondDistributionMode.REFUND || bondDistributionMode == BondDistributionMode.NORMAL) + { + return; + } else if (bondDistributionMode != BondDistributionMode.UNDECIDED) { + revert InvalidBondDistributionMode(); + } + + if (ANCHOR_STATE_REGISTRY.paused()) revert GamePaused(); + + bool finalized = ANCHOR_STATE_REGISTRY.isGameFinalized(IDisputeGame(address(this))); + if (!finalized) { + revert GameNotFinalized(); + } + + try ANCHOR_STATE_REGISTRY.setAnchorState(IDisputeGame(address(this))) { } catch { } + + bool properGame = ANCHOR_STATE_REGISTRY.isGameProper(IDisputeGame(address(this))); + + if (properGame) { + bondDistributionMode = BondDistributionMode.NORMAL; + } else { + bondDistributionMode = BondDistributionMode.REFUND; + } + + emit GameClosed(bondDistributionMode); + } + + //////////////////////////////////////////////////////////////// + // View Functions // + //////////////////////////////////////////////////////////////// + + function gameOver() public view returns (bool gameOver_) { + gameOver_ = claimData.deadline.raw() < uint64(block.timestamp) || claimData.prover != address(0); + } + + function credit(address _recipient) external view returns (uint256 credit_) { + if (bondDistributionMode == BondDistributionMode.REFUND) { + credit_ = refundModeCredit[_recipient]; + } else { + credit_ = normalModeCredit[_recipient]; + } + } + + //////////////////////////////////////////////////////////////// + // IDisputeGame Impl // + //////////////////////////////////////////////////////////////// + + function gameType() public pure returns (GameType gameType_) { + gameType_ = GAME_TYPE; + } + + function gameCreator() public pure returns (address creator_) { + creator_ = _getArgAddress(0x00); + } + + function rootClaim() public pure returns (Claim rootClaim_) { + rootClaim_ = Claim.wrap(_getArgBytes32(0x14)); + } + + function l1Head() public pure returns (Hash l1Head_) { + l1Head_ = Hash.wrap(_getArgBytes32(0x34)); + } + + function l2SequenceNumber() public pure returns (uint256 l2SequenceNumber_) { + l2SequenceNumber_ = _getArgUint256(0x54); + } + + function l2BlockNumber() public pure returns (uint256 l2BlockNumber_) { + l2BlockNumber_ = l2SequenceNumber(); + } + + function parentIndex() public pure returns (uint32 parentIndex_) { + parentIndex_ = _getArgUint32(0x74); + } + + function blockHash() public pure returns (bytes32 blockHash_) { + blockHash_ = _getArgBytes32(0x78); + } + + function stateHash() public pure returns (bytes32 stateHash_) { + stateHash_ = _getArgBytes32(0x98); + } + + function startingBlockNumber() external view returns (uint256) { + return startingOutputRoot.l2SequenceNumber; + } + + function startingRootHash() external view returns (Hash) { + return startingOutputRoot.root; + } + + function extraData() public pure returns (bytes memory extraData_) { + extraData_ = _getArgBytes(0x54, 0x64); + } + + function gameData() external pure returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + gameType_ = gameType(); + rootClaim_ = rootClaim(); + extraData_ = extraData(); + } + + //////////////////////////////////////////////////////////////// + // Immutable Getters // + //////////////////////////////////////////////////////////////// + + function domainSeparator() external view returns (bytes32) { + return _domainSeparator(); + } + + function batchProofTypehash() external pure returns (bytes32) { + return BATCH_PROOF_TYPEHASH; + } + + function maxChallengeDuration() external view returns (Duration) { + return MAX_CHALLENGE_DURATION; + } + + function maxProveDuration() external view returns (Duration) { + return MAX_PROVE_DURATION; + } + + function disputeGameFactory() external view returns (IDisputeGameFactory) { + return DISPUTE_GAME_FACTORY; + } + + function teeProofVerifier() external view returns (ITeeProofVerifier) { + return TEE_PROOF_VERIFIER; + } + + function challengerBond() external view returns (uint256) { + return CHALLENGER_BOND; + } + + function anchorStateRegistry() external view returns (IAnchorStateRegistry) { + return ANCHOR_STATE_REGISTRY; + } + + function proposer_() external view returns (address) { + return PROPOSER; + } + + function challenger_() external view returns (address) { + return CHALLENGER; + } + + //////////////////////////////////////////////////////////////// + // Internal Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Computes the EIP-712 domain separator. + /// @dev Computed dynamically to support chain ID changes (e.g., hard forks). + /// Uses TEE_PROOF_VERIFIER as verifyingContract since it is the signature + /// verification endpoint and is unique per chain deployment. + function _domainSeparator() private view returns (bytes32) { + return keccak256( + abi.encode( + DOMAIN_TYPEHASH, DOMAIN_NAME_HASH, DOMAIN_VERSION_HASH, block.chainid, address(TEE_PROOF_VERIFIER) + ) + ); + } + + function _getParentGameStatus() private view returns (GameStatus) { + if (parentIndex() != type(uint32).max) { + (,, IDisputeGame parentGame) = DISPUTE_GAME_FACTORY.gameAtIndex(parentIndex()); + return parentGame.status(); + } else { + return GameStatus.DEFENDER_WINS; + } + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol b/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol new file mode 100644 index 0000000000000..17c00d503a4e8 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/TeeProofVerifier.sol @@ -0,0 +1,206 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +// Libraries +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +// Interfaces +import { IRiscZeroVerifier } from "interfaces/dispute/IRiscZeroVerifier.sol"; + +/// @title TEE Proof Verifier for OP Stack DisputeGame +/// @notice Verifies TEE enclave identity via ZK proof (owner-gated registration) and +/// batch state transitions via ECDSA signature (permissionless verification). +/// @dev Two core responsibilities: +/// 1. register(): Owner verifies ZK proof of Nitro attestation, binds EOA on-chain +/// 2. verifyBatch(): ecrecover signature, check signer is a registered enclave +/// +/// Uses generation-based revocation: incrementing enclaveGeneration invalidates +/// all previously registered enclaves in O(1). +/// +/// Journal is reconstructed on-chain from AttestationData + expectedRootKey, +/// so rootKey mismatch causes ZK verify failure without explicit comparison. +contract TeeProofVerifier is Ownable { + //////////////////////////////////////////////////////////////// + // Structs // + //////////////////////////////////////////////////////////////// + + /// @notice Attestation data from the RISC Zero guest program + struct AttestationData { + uint64 timestampMs; + bytes32 pcrHash; + bytes publicKey; // 65 bytes secp256k1 uncompressed (0x04 + x + y) + bytes userData; + } + + //////////////////////////////////////////////////////////////// + // State Vars // + //////////////////////////////////////////////////////////////// + + /// @notice RISC Zero Groth16 verifier (only called during registration, immutable after deployment) + IRiscZeroVerifier public immutable riscZeroVerifier; + + /// @notice RISC Zero guest image ID (hash of the attestation verification guest ELF, immutable after deployment) + bytes32 public immutable imageId; + + /// @notice Expected AWS Nitro root public key (96 bytes, P384 without 0x04 prefix). + /// Set in constructor and never changed. Cannot use `immutable` keyword because Solidity + /// does not support immutable for dynamic `bytes` type. + bytes public expectedRootKey; + + /// @notice Current enclave generation (starts at 1, increments on bulk revocation) + uint256 public enclaveGeneration; + + /// @notice Generation at which each enclave was registered + mapping(address => uint256) public enclaveRegisteredGeneration; + + /// @notice PCR hash recorded for each enclave (on-chain record only, not validated) + mapping(address => bytes32) public enclavePcrHash; + + //////////////////////////////////////////////////////////////// + // Events // + //////////////////////////////////////////////////////////////// + + event EnclaveRegistered(address indexed enclaveAddress, bytes32 indexed pcrHash, uint64 timestampMs); + event EnclaveRevoked(address indexed enclaveAddress); + event AllEnclavesRevoked(uint256 previousGeneration, uint256 newGeneration); + + //////////////////////////////////////////////////////////////// + // Errors // + //////////////////////////////////////////////////////////////// + + error InvalidProof(); + error InvalidPublicKey(); + error EnclaveAlreadyRegistered(); + error EnclaveNotRegistered(); + error InvalidSignature(); + + //////////////////////////////////////////////////////////////// + // Constructor // + //////////////////////////////////////////////////////////////// + + /// @param _riscZeroVerifier RISC Zero verifier contract (Groth16 or mock) + /// @param _imageId RISC Zero guest image ID + /// @param _rootKey Expected AWS Nitro root public key (96 bytes) + constructor(IRiscZeroVerifier _riscZeroVerifier, bytes32 _imageId, bytes memory _rootKey) { + riscZeroVerifier = _riscZeroVerifier; + imageId = _imageId; + expectedRootKey = _rootKey; + enclaveGeneration = 1; + } + + //////////////////////////////////////////////////////////////// + // Registration (Owner Only) // + //////////////////////////////////////////////////////////////// + + /// @notice Register a TEE enclave by verifying its ZK attestation proof. + /// @dev Only callable by the owner. The journal is reconstructed on-chain from + /// attestationData + expectedRootKey. If the rootKey in the original attestation + /// differs from expectedRootKey, the reconstructed digest won't match and + /// the ZK proof verification will fail. + /// @param seal The RISC Zero proof seal (Groth16) + /// @param attestationData Attestation fields from the guest program + function register(bytes calldata seal, AttestationData calldata attestationData) external onlyOwner { + // 1. Validate public key length + if (attestationData.publicKey.length != 65) { + revert InvalidPublicKey(); + } + + // 2. Extract EOA address from secp256k1 public key + address enclaveAddress = _extractAddress(attestationData.publicKey); + + // 3. Check not already registered in current generation + if (enclaveRegisteredGeneration[enclaveAddress] == enclaveGeneration) { + revert EnclaveAlreadyRegistered(); + } + + // 4. Reconstruct journal digest (rootKey baked in from chain state) + bytes32 journalDigest = sha256( + abi.encodePacked( + attestationData.timestampMs, + attestationData.pcrHash, + expectedRootKey, + uint8(attestationData.publicKey.length), + attestationData.publicKey, + uint16(attestationData.userData.length), + attestationData.userData + ) + ); + + // 5. Verify ZK proof + try riscZeroVerifier.verify(seal, imageId, journalDigest) { } + catch { + revert InvalidProof(); + } + + // 6. Store registration + enclaveRegisteredGeneration[enclaveAddress] = enclaveGeneration; + enclavePcrHash[enclaveAddress] = attestationData.pcrHash; + + emit EnclaveRegistered(enclaveAddress, attestationData.pcrHash, attestationData.timestampMs); + } + + //////////////////////////////////////////////////////////////// + // Batch Verification (Permissionless) // + //////////////////////////////////////////////////////////////// + + /// @notice Verify a batch state transition signed by a registered TEE enclave. + /// @param digest The hash of the batch data (pre_batch, txs, post_batch, etc.) + /// @param signature ECDSA signature (65 bytes: r + s + v) + /// @return signer The address of the verified enclave that signed the batch + function verifyBatch(bytes32 digest, bytes calldata signature) external view returns (address signer) { + (address recovered, ECDSA.RecoverError err) = ECDSA.tryRecover(digest, signature); + if (err != ECDSA.RecoverError.NoError || recovered == address(0)) { + revert InvalidSignature(); + } + + if (enclaveRegisteredGeneration[recovered] != enclaveGeneration) { + revert EnclaveNotRegistered(); + } + + return recovered; + } + + //////////////////////////////////////////////////////////////// + // Query Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Check if an address is a registered enclave + function isRegistered(address enclaveAddress) external view returns (bool) { + return enclaveRegisteredGeneration[enclaveAddress] == enclaveGeneration; + } + + //////////////////////////////////////////////////////////////// + // Admin Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Revoke a single registered enclave + function revoke(address enclaveAddress) external onlyOwner { + if (enclaveRegisteredGeneration[enclaveAddress] != enclaveGeneration) { + revert EnclaveNotRegistered(); + } + enclaveRegisteredGeneration[enclaveAddress] = 0; + emit EnclaveRevoked(enclaveAddress); + } + + /// @notice Revoke all registered enclaves by incrementing the generation counter + function revokeAll() external onlyOwner { + uint256 previousGeneration = enclaveGeneration; + enclaveGeneration = previousGeneration + 1; + emit AllEnclavesRevoked(previousGeneration, enclaveGeneration); + } + + //////////////////////////////////////////////////////////////// + // Internal Functions // + //////////////////////////////////////////////////////////////// + + /// @notice Extract Ethereum address from secp256k1 uncompressed public key + /// @param publicKey 65 bytes: 0x04 prefix + 32-byte x + 32-byte y + function _extractAddress(bytes memory publicKey) internal pure returns (address) { + bytes memory coordinates = new bytes(64); + for (uint256 i = 0; i < 64; i++) { + coordinates[i] = publicKey[i + 1]; + } + return address(uint160(uint256(keccak256(coordinates)))); + } +} diff --git a/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol b/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol new file mode 100644 index 0000000000000..d0a0224ebbc31 --- /dev/null +++ b/packages/contracts-bedrock/src/dispute/tee/lib/Errors.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +//////////////////////////////////////////////////////////////// +// `TeeDisputeGame` Errors // +//////////////////////////////////////////////////////////////// + +/// @notice Thrown when the claim has already been challenged. +error ClaimAlreadyChallenged(); + +/// @notice Thrown when the game type of the parent game does not match the current game. +error UnexpectedGameType(); + +/// @notice Thrown when the parent game is invalid. +error InvalidParentGame(); + +/// @notice Thrown when the parent game is not resolved. +error ParentGameNotResolved(); + +/// @notice Thrown when the game is over. +error GameOver(); + +/// @notice Thrown when the game is not over. +error GameNotOver(); + +/// @notice Thrown when the proposal status is invalid. +error InvalidProposalStatus(); + +/// @notice Thrown when the game is initialized by an incorrect factory. +error IncorrectDisputeGameFactory(); + +/// @notice Thrown when prove() is called but the claim has not been challenged. +error ClaimNotChallenged(); diff --git a/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol b/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol new file mode 100644 index 0000000000000..e4424c19f4d51 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/AnchorStateRegistryCompatibility.t.sol @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {Proxy} from "src/universal/Proxy.sol"; +import {AnchorStateRegistry} from "src/dispute/AnchorStateRegistry.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {ITeeProofVerifier} from "interfaces/dispute/ITeeProofVerifier.sol"; +import {TeeDisputeGame, TEE_DISPUTE_GAME_TYPE} from "src/dispute/tee/TeeDisputeGame.sol"; +import {Claim, Duration, GameType, Hash, Proposal} from "src/dispute/lib/Types.sol"; +import {MockDisputeGameFactory} from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import {MockSystemConfig} from "test/dispute/tee/mocks/MockSystemConfig.sol"; +import {MockTeeProofVerifier} from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import {TeeTestUtils} from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract AnchorStateRegistryCompatibilityTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + MockDisputeGameFactory internal factory; + MockSystemConfig internal systemConfig; + MockTeeProofVerifier internal teeProofVerifier; + TeeDisputeGame internal implementation; + IAnchorStateRegistry internal anchorStateRegistry; + + address internal proposer; + address internal challenger; + address internal executor; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + factory = new MockDisputeGameFactory(); + systemConfig = new MockSystemConfig(address(this)); + teeProofVerifier = new MockTeeProofVerifier(); + + AnchorStateRegistry anchorStateRegistryImpl = new AnchorStateRegistry(0); + Proxy anchorStateRegistryProxy = new Proxy(address(this)); + anchorStateRegistryProxy.upgradeToAndCall( + address(anchorStateRegistryImpl), + abi.encodeCall( + anchorStateRegistryImpl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(factory)), + Proposal({ + root: Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + GameType.wrap(TEE_DISPUTE_GAME_TYPE) + ) + ) + ); + anchorStateRegistry = IAnchorStateRegistry(address(anchorStateRegistryProxy)); + + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + anchorStateRegistry, + proposer, + challenger + ); + + factory.setImplementation(GameType.wrap(TEE_DISPUTE_GAME_TYPE), implementation); + factory.setInitBond(GameType.wrap(TEE_DISPUTE_GAME_TYPE), DEFENDER_BOND); + } + + function test_anchorStateRegistry_acceptsTeeDisputeGame() public { + vm.warp(block.timestamp + 1); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + assertTrue(anchorStateRegistry.isGameRegistered(game)); + assertTrue(anchorStateRegistry.isGameProper(game)); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("end-block"), + endStateHash: keccak256("end-state"), + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + game.prove(abi.encode(proofs)); + game.resolve(); + + vm.warp(block.timestamp + 1); + AnchorStateRegistry(address(anchorStateRegistry)).setAnchorState(game); + + assertEq(address(AnchorStateRegistry(address(anchorStateRegistry)).anchorGame()), address(game)); + } + + function _createGame( + address creator, + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + returns (TeeDisputeGame game, bytes memory extraData, Claim rootClaim) + { + extraData = buildExtraData(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + rootClaim = computeRootClaim(blockHash_, stateHash_); + + vm.startPrank(creator, creator); + game = TeeDisputeGame( + payable(address(factory.create{value: DEFENDER_BOND}(GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData))) + ); + vm.stopPrank(); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol new file mode 100644 index 0000000000000..541f01b4fcdd9 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGame.t.sol @@ -0,0 +1,986 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { BadAuth, GameNotFinalized, IncorrectBondAmount, UnexpectedRootClaim } from "src/dispute/lib/Errors.sol"; +import { + ClaimAlreadyChallenged, + InvalidParentGame, + ParentGameNotResolved, + GameOver, + GameNotOver +} from "src/dispute/tee/lib/Errors.sol"; +import { ClaimAlreadyResolved } from "src/dispute/lib/Errors.sol"; +import { BondDistributionMode, Duration, GameType, Claim, Hash, GameStatus } from "src/dispute/lib/Types.sol"; +import { MockAnchorStateRegistry } from "test/dispute/tee/mocks/MockAnchorStateRegistry.sol"; +import { MockDisputeGameFactory } from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import { MockStatusDisputeGame } from "test/dispute/tee/mocks/MockStatusDisputeGame.sol"; +import { MockTeeProofVerifier } from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import { TeeTestUtils } from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract TeeDisputeGameTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + MockDisputeGameFactory internal factory; + MockAnchorStateRegistry internal anchorStateRegistry; + MockTeeProofVerifier internal teeProofVerifier; + TeeDisputeGame internal implementation; + + address internal proposer; + address internal challenger; + address internal executor; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + factory = new MockDisputeGameFactory(); + anchorStateRegistry = new MockAnchorStateRegistry(); + teeProofVerifier = new MockTeeProofVerifier(); + + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(anchorStateRegistry)), + proposer, + challenger + ); + + factory.setImplementation(GameType.wrap(TEE_DISPUTE_GAME_TYPE), implementation); + factory.setInitBond(GameType.wrap(TEE_DISPUTE_GAME_TYPE), DEFENDER_BOND); + + anchorStateRegistry.setAnchor( + Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), ANCHOR_L2_BLOCK + ); + anchorStateRegistry.setRespectedGameType(GameType.wrap(TEE_DISPUTE_GAME_TYPE)); + } + + function test_initialize_usesAnchorStateForRootGame() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + (Hash startingRoot, uint256 startingBlockNumber) = game.startingOutputRoot(); + assertEq(startingRoot.raw(), computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()); + assertEq(startingBlockNumber, ANCHOR_L2_BLOCK); + assertEq(game.proposer(), proposer); + assertEq(game.refundModeCredit(proposer), DEFENDER_BOND); + assertTrue(game.wasRespectedGameTypeWhenCreated()); + } + + function test_initialize_usesParentGameOutput() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + (Hash startingRoot, uint256 startingBlockNumber) = game.startingOutputRoot(); + assertEq(startingRoot.raw(), parent.rootClaim().raw()); + assertEq(startingBlockNumber, parent.l2SequenceNumber()); + } + + function test_initialize_revertUnauthorizedProposer() public { + address unauthorized = makeAddr("unauthorized"); + vm.deal(unauthorized, DEFENDER_BOND); + + vm.expectRevert(BadAuth.selector); + _createGame(unauthorized, ANCHOR_L2_BLOCK + 1, type(uint32).max, keccak256("block"), keccak256("state")); + } + + function test_initialize_revertRootClaimMismatch() public { + bytes memory extraData = + buildExtraData(ANCHOR_L2_BLOCK + 1, type(uint32).max, keccak256("block"), keccak256("state")); + Claim wrongRootClaim = Claim.wrap(keccak256("wrong-root-claim")); + Claim expectedRootClaim = computeRootClaim(keccak256("block"), keccak256("state")); + + vm.startPrank(proposer, proposer); + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.RootClaimMismatch.selector, expectedRootClaim.raw(), wrongRootClaim.raw() + ) + ); + factory.create{ value: DEFENDER_BOND }(GameType.wrap(TEE_DISPUTE_GAME_TYPE), wrongRootClaim, extraData); + vm.stopPrank(); + } + + function test_initialize_revertWhenL2SequenceNumberDoesNotAdvance() public { + vm.expectRevert( + abi.encodeWithSelector( + UnexpectedRootClaim.selector, computeRootClaim(keccak256("block"), keccak256("state")) + ) + ); + _createGame(proposer, ANCHOR_L2_BLOCK, type(uint32).max, keccak256("block"), keccak256("state")); + } + + function test_initialize_revertInvalidParentGame() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.CHALLENGER_WINS, + uint64(block.timestamp), + uint64(block.timestamp), + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + vm.expectRevert(InvalidParentGame.selector); + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + } + + function test_challenge_updatesState() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + TeeDisputeGame.ProposalStatus proposalStatus = game.challenge{ value: CHALLENGER_BOND }(); + + (, address counteredBy,,, TeeDisputeGame.ProposalStatus storedStatus,) = game.claimData(); + assertEq(counteredBy, challenger); + assertEq(uint8(proposalStatus), uint8(TeeDisputeGame.ProposalStatus.Challenged)); + assertEq(uint8(storedStatus), uint8(TeeDisputeGame.ProposalStatus.Challenged)); + assertEq(game.refundModeCredit(challenger), CHALLENGER_BOND); + } + + function test_challenge_revertIncorrectBond() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + vm.expectRevert(IncorrectBondAmount.selector); + game.challenge{ value: CHALLENGER_BOND - 1 }(); + } + + function test_challenge_revertWhenAlreadyChallenged() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + vm.prank(challenger); + vm.expectRevert(ClaimAlreadyChallenged.selector); + game.challenge{ value: CHALLENGER_BOND }(); + } + + function test_prove_succeedsWithSingleBatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + TeeDisputeGame.ProposalStatus status = game.prove(abi.encode(proofs)); + (,, address prover,, TeeDisputeGame.ProposalStatus storedStatus,) = game.claimData(); + assertEq(prover, proposer); + assertEq(uint8(status), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + assertEq(uint8(storedStatus), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + } + + function test_prove_succeedsWithChainedBatches() public { + bytes32 middleBlockHash = keccak256("middle-block"); + bytes32 middleStateHash = keccak256("middle-state"); + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + + bytes32 domainSep = game.domainSeparator(); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: middleBlockHash, + endStateHash: middleStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY, + domainSep + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: middleBlockHash, + startStateHash: middleStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + domainSep + ); + + vm.prank(proposer); + TeeDisputeGame.ProposalStatus status = game.prove(abi.encode(proofs)); + assertEq(uint8(status), uint8(TeeDisputeGame.ProposalStatus.UnchallengedAndValidProofProvided)); + } + + function test_prove_revertEmptyBatchProofs() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(proposer); + vm.expectRevert(TeeDisputeGame.EmptyBatchProofs.selector); + game.prove(abi.encode(new TeeDisputeGame.BatchProof[](0))); + } + + function test_prove_revertStartHashMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: keccak256("wrong-start-block"), + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.StartHashMismatch.selector, + computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw(), + keccak256(abi.encode(keccak256("wrong-start-block"), ANCHOR_STATE_HASH)) + ) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertBatchChainBreak() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("middle-block"), + endStateHash: keccak256("middle-state"), + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: keccak256("different-block"), + startStateHash: keccak256("different-state"), + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert(abi.encodeWithSelector(TeeDisputeGame.BatchChainBreak.selector, 1)); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertBatchBlockNotIncreasing() public { + bytes32 middleBlockHash = keccak256("middle-block"); + bytes32 middleStateHash = keccak256("middle-state"); + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 8, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](2); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: middleBlockHash, + endStateHash: middleStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + proofs[1] = buildBatchProof( + BatchInput({ + startBlockHash: middleBlockHash, + startStateHash: middleStateHash, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: ANCHOR_L2_BLOCK + 4 + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.BatchBlockNotIncreasing.selector, 1, ANCHOR_L2_BLOCK + 4, ANCHOR_L2_BLOCK + 4 + ) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertFinalHashMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: keccak256("wrong-end-block"), + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.FinalHashMismatch.selector, + computeRootClaim(endBlockHash, endStateHash).raw(), + keccak256(abi.encode(keccak256("wrong-end-block"), endStateHash)) + ) + ); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertFinalBlockMismatch() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() - 1 + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.expectRevert( + abi.encodeWithSelector( + TeeDisputeGame.FinalBlockMismatch.selector, game.l2SequenceNumber(), game.l2SequenceNumber() - 1 + ) + ); + vm.prank(proposer); + game.prove(abi.encode(proofs)); + } + + function test_prove_revertWhenVerifierRejectsSignature() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert(MockTeeProofVerifier.EnclaveNotRegistered.selector); + game.prove(abi.encode(proofs)); + } + + function test_resolve_revertWhenParentInProgress() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + (TeeDisputeGame child,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + // Wait for child's challenge window to expire so gameOver() passes + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + + // Child still cannot resolve because parent is IN_PROGRESS + vm.expectRevert(ParentGameNotResolved.selector); + child.resolve(); + } + + /// @notice When parent resolves as CHALLENGER_WINS, child short-circuits to CHALLENGER_WINS. + /// @dev Realistic timing: child is created while parent is IN_PROGRESS (required by initialize), + /// then parent later resolves as CHALLENGER_WINS, then child resolve short-circuits. + function test_resolve_parentChallengerWinsShortCircuits() public { + MockStatusDisputeGame parent = new MockStatusDisputeGame( + proposer, + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + computeRootClaim(keccak256("parent-block"), keccak256("parent-state")), + ANCHOR_L2_BLOCK + 3, + bytes("parent"), + GameStatus.IN_PROGRESS, + uint64(block.timestamp), + 0, + true, + IAnchorStateRegistry(address(anchorStateRegistry)) + ); + factory.pushGame( + GameType.wrap(TEE_DISPUTE_GAME_TYPE), + uint64(block.timestamp), + IDisputeGame(address(parent)), + parent.rootClaim(), + bytes("parent") + ); + anchorStateRegistry.setGameFlags(IDisputeGame(address(parent)), true, true, false, false, false, true, false); + + // Child created while parent is still IN_PROGRESS + (TeeDisputeGame child,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 7, 0, keccak256("child-block"), keccak256("child-state")); + + // Challenger challenges the child + vm.prank(challenger); + child.challenge{ value: CHALLENGER_BOND }(); + + // Time passes: parent is challenged and times out → CHALLENGER_WINS + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + parent.setStatus(GameStatus.CHALLENGER_WINS); + parent.setResolvedAt(uint64(block.timestamp)); + + // Child resolve short-circuits because parent lost + GameStatus status = child.resolve(); + assertEq(uint8(status), uint8(GameStatus.CHALLENGER_WINS)); + assertEq(child.normalModeCredit(challenger), DEFENDER_BOND + CHALLENGER_BOND); + } + + function test_prove_revertUnauthorizedProver() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + address unauthorized = makeAddr("unauthorized"); + vm.prank(unauthorized); + vm.expectRevert(BadAuth.selector); + game.prove(abi.encode(proofs)); + } + + /// @notice CHALLENGER_WINS in NORMAL mode: challenger takes all bonds, proposer gets nothing. + /// @dev A CHALLENGER_WINS game is still "proper" in real ASR (registered, not blacklisted, + /// not retired, not paused), so closeGame → NORMAL mode → normalModeCredit only. + function test_claimCredit_challengerWinsNormalMode() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Timeout without proof → CHALLENGER_WINS + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + assertEq(uint8(game.resolve()), uint8(GameStatus.CHALLENGER_WINS)); + + // CHALLENGER_WINS game is still proper → NORMAL mode + // (registered, respected, not blacklisted, not retired, finalized) + anchorStateRegistry.setGameFlags(game, true, true, false, false, true, true, false); + + // Challenger takes all bonds + uint256 challengerBalanceBefore = challenger.balance; + game.claimCredit(challenger); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(challenger.balance, challengerBalanceBefore + DEFENDER_BOND + CHALLENGER_BOND); + // Proposer has zero credit — lost their bond + assertEq(game.normalModeCredit(proposer), 0); + } + + /// @notice REFUND mode: only triggered by guardian blacklisting (not by CHALLENGER_WINS). + /// @dev In real ASR, isGameProper returns false only when the game is blacklisted, retired, + /// or the system is paused. Here we simulate a blacklisted DEFENDER_WINS game. + function test_claimCredit_refundModeWhenBlacklisted() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Proposer proves — game would normally be DEFENDER_WINS + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + + // Guardian blacklists the game (e.g. discovered exploit) + // → isGameProper returns false → REFUND mode + anchorStateRegistry.setGameFlags(game, true, true, true, false, true, false, false); + + uint256 proposerBalanceBefore = proposer.balance; + uint256 challengerBalanceBefore = challenger.balance; + game.claimCredit(proposer); + game.claimCredit(challenger); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.REFUND)); + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + assertEq(challenger.balance, challengerBalanceBefore + CHALLENGER_BOND); + } + + function test_closeGame_revertWhenNotFinalized() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + vm.expectRevert(GameNotFinalized.selector); + game.closeGame(); + } + + function test_resolve_revertWhenGameNotOver() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.expectRevert(GameNotOver.selector); + game.resolve(); + } + + //////////////////////////////////////////////////////////////// + // Audit: Boundary & Invariant Tests // + //////////////////////////////////////////////////////////////// + + /// @notice INV-6: prove() should revert after resolve (claimData immutable) + function test_prove_revertAfterResolve() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + // Wait for challenge deadline to expire, then resolve + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + // prove after resolve should revert + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + vm.expectRevert(ClaimAlreadyResolved.selector); + game.prove(abi.encode(proofs)); + } + + /// @notice INV-6: challenge() should revert after resolve (claimData immutable) + function test_challenge_revertAfterResolve() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + vm.prank(challenger); + vm.expectRevert(ClaimAlreadyChallenged.selector); + game.challenge{ value: CHALLENGER_BOND }(); + } + + /// @notice Double prove: second prove() should revert with GameOver + function test_prove_revertWhenAlreadyProved() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + // Second prove should revert (gameOver = true because prover != address(0)) + vm.prank(proposer); + vm.expectRevert(GameOver.selector); + game.prove(abi.encode(proofs)); + } + + /// @notice challenge should revert after prove (gameOver blocks further challenges) + function test_challenge_revertAfterProve() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + // After prove, status is UnchallengedAndValidProofProvided. + // challenge() checks status != Unchallenged first → revert ClaimAlreadyChallenged + vm.prank(challenger); + vm.expectRevert(ClaimAlreadyChallenged.selector); + game.challenge{ value: CHALLENGER_BOND }(); + } + + /// @notice challenge should revert after deadline expires + function test_challenge_revertAfterDeadline() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + + vm.prank(challenger); + vm.expectRevert(GameOver.selector); + game.challenge{ value: CHALLENGER_BOND }(); + } + + /// @notice Double resolve should revert + function test_resolve_revertWhenAlreadyResolved() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + vm.expectRevert(ClaimAlreadyResolved.selector); + game.resolve(); + } + + /// @notice INV-6: claimData.prover and claimData.counteredBy cannot change after resolve + function test_invariant6_claimDataImmutableAfterResolve() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + // Challenge + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Prove + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + // resolve + game.resolve(); + + // Snapshot claimData after resolve + (, address counteredBy, address prover,, TeeDisputeGame.ProposalStatus postStatus,) = game.claimData(); + assertEq(counteredBy, challenger); + assertEq(prover, proposer); + assertEq(uint8(postStatus), uint8(TeeDisputeGame.ProposalStatus.Resolved)); + + // Confirm prove / challenge cannot modify claimData + vm.prank(proposer); + vm.expectRevert(ClaimAlreadyResolved.selector); + game.prove(abi.encode(proofs)); + + vm.prank(challenger); + vm.expectRevert(ClaimAlreadyChallenged.selector); + game.challenge{ value: CHALLENGER_BOND }(); + + // claimData remains unchanged + (, address counteredBy2, address prover2,, TeeDisputeGame.ProposalStatus postStatus2,) = game.claimData(); + assertEq(counteredBy2, challenger); + assertEq(prover2, proposer); + assertEq(uint8(postStatus2), uint8(TeeDisputeGame.ProposalStatus.Resolved)); + } + + /// @notice INV-1: contract balance >= active mode credit sum after resolve + /// @dev Both normalModeCredit and refundModeCredit coexist in storage, but claimCredit + /// only reads one mode. Correct invariant: balance >= max(sum(normal), sum(refund)). + function test_invariant1_balanceCoversCredits_defenderWins() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + teeProofVerifier.setRegistered(executor, true); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + vm.prank(proposer); + game.prove(abi.encode(proofs)); + game.resolve(); + + // INV-1: balance ≥ max(sum(normalModeCredit), sum(refundModeCredit)) + uint256 totalNormal = game.normalModeCredit(proposer) + game.normalModeCredit(challenger); + uint256 totalRefund = game.refundModeCredit(proposer) + game.refundModeCredit(challenger); + assertGe(address(game).balance, totalNormal, "INV-1: balance < sum(normalModeCredit)"); + assertGe(address(game).balance, totalRefund, "INV-1: balance < sum(refundModeCredit)"); + + // INV-12: In NORMAL mode, exactly one address has normalModeCredit (proposer wins) + assertGt(game.normalModeCredit(proposer), 0); + assertEq(game.normalModeCredit(challenger), 0); + } + + /// @notice INV-1 + INV-12: balance covers credit when CHALLENGER_WINS + function test_invariant1_balanceCoversCredits_challengerWins() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + game.resolve(); + + // INV-1: balance ≥ max(sum(normalModeCredit), sum(refundModeCredit)) + uint256 totalNormal = game.normalModeCredit(proposer) + game.normalModeCredit(challenger); + uint256 totalRefund = game.refundModeCredit(proposer) + game.refundModeCredit(challenger); + assertGe(address(game).balance, totalNormal, "INV-1: balance < sum(normalModeCredit)"); + assertGe(address(game).balance, totalRefund, "INV-1: balance < sum(refundModeCredit)"); + + // INV-12: when challenger wins, only challenger has credit + assertEq(game.normalModeCredit(proposer), 0); + assertGt(game.normalModeCredit(challenger), 0); + } + + /// @notice INV-5: GameStatus is irreversible — status unchanged after resolve + function test_invariant5_gameStatusIrreversible() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + GameStatus statusAfterResolve = game.status(); + assertEq(uint8(statusAfterResolve), uint8(GameStatus.DEFENDER_WINS)); + + // Second resolve should revert + vm.expectRevert(ClaimAlreadyResolved.selector); + game.resolve(); + + // Status unchanged + assertEq(uint8(game.status()), uint8(statusAfterResolve)); + } + + /// @notice INV-13: bondDistributionMode is irreversible once set + function test_invariant13_bondDistributionModeIrreversible() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + game.resolve(); + + // Set ASR flags so closeGame can execute + anchorStateRegistry.setGameFlags(game, true, true, false, false, true, true, false); + game.closeGame(); + + BondDistributionMode modeAfterClose = game.bondDistributionMode(); + assertEq(uint8(modeAfterClose), uint8(BondDistributionMode.NORMAL)); + + // closeGame is idempotent, mode unchanged + game.closeGame(); + assertEq(uint8(game.bondDistributionMode()), uint8(modeAfterClose)); + } + + function _createGame( + address creator, + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + returns (TeeDisputeGame game, bytes memory extraData, Claim rootClaim) + { + extraData = buildExtraData(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + rootClaim = computeRootClaim(blockHash_, stateHash_); + + vm.startPrank(creator, creator); + game = TeeDisputeGame( + payable( + address( + factory.create{ value: DEFENDER_BOND }(GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData) + ) + ) + ); + vm.stopPrank(); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameIntegration.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameIntegration.t.sol new file mode 100644 index 0000000000000..b4500d0ebcf9a --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameIntegration.t.sol @@ -0,0 +1,644 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Vm } from "forge-std/Vm.sol"; +import { Proxy } from "src/universal/Proxy.sol"; +import { AnchorStateRegistry } from "src/dispute/AnchorStateRegistry.sol"; +import { DisputeGameFactory } from "src/dispute/DisputeGameFactory.sol"; +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ISystemConfig } from "interfaces/L1/ISystemConfig.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { BondDistributionMode, Claim, Duration, GameStatus, GameType, Hash, Proposal } from "src/dispute/lib/Types.sol"; +import { GameNotFinalized } from "src/dispute/lib/Errors.sol"; +import { ParentGameNotResolved, InvalidParentGame } from "src/dispute/tee/lib/Errors.sol"; +import { TeeTestUtils } from "test/dispute/tee/helpers/TeeTestUtils.sol"; +import { MockRiscZeroVerifier } from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import { MockSystemConfig } from "test/dispute/tee/mocks/MockSystemConfig.sol"; + +/// @title TeeDisputeGameIntegrationTest +/// @notice Integration tests for the full TEE dispute game lifecycle using real contracts. +/// Only MockRiscZeroVerifier and MockSystemConfig are mocked; all core contracts +/// (DisputeGameFactory, AnchorStateRegistry, TeeProofVerifier) are real. +contract TeeDisputeGameIntegrationTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + bytes32 internal constant IMAGE_ID = keccak256("integration-tee-image"); + bytes32 internal constant PCR_HASH = keccak256("integration-pcr-hash"); + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + GameType internal constant TEE_GAME_TYPE = GameType.wrap(TEE_DISPUTE_GAME_TYPE); + + DisputeGameFactory internal factory; + AnchorStateRegistry internal anchorStateRegistry; + TeeProofVerifier internal teeProofVerifier; + TeeDisputeGame internal implementation; + + address internal proposer; + address internal challenger; + address internal executor; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + // --- Deploy real DisputeGameFactory via Proxy --- + factory = _deployFactory(); + + // --- Deploy real AnchorStateRegistry via Proxy --- + anchorStateRegistry = _deployAnchorStateRegistry(factory); + + // --- Deploy real TeeProofVerifier (with MockRiscZeroVerifier) --- + teeProofVerifier = _deployTeeProofVerifier(); + + // --- Deploy TeeDisputeGame implementation --- + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(anchorStateRegistry)), + proposer, + challenger + ); + + factory.setImplementation(TEE_GAME_TYPE, IDisputeGame(address(implementation)), bytes("")); + factory.setInitBond(TEE_GAME_TYPE, DEFENDER_BOND); + + // Warp past the retirement timestamp so games are not retired. + vm.warp(block.timestamp + 1); + } + + //////////////////////////////////////////////////////////////// + // Test 1: Unchallenged DEFENDER_WINS // + //////////////////////////////////////////////////////////////// + + /// @notice create → (no challenge) → timeout → resolve → closeGame → claimCredit + function test_lifecycle_unchallenged_defenderWins() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + // Wait for challenge window to expire + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + + // Resolve — unchallenged, so DEFENDER_WINS + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + + // closeGame not yet callable — need finality delay + vm.expectRevert(GameNotFinalized.selector); + game.closeGame(); + + // Wait for finality delay (DISPUTE_GAME_FINALITY_DELAY_SECONDS = 0 in our ASR) + vm.warp(block.timestamp + 1); + assertTrue(anchorStateRegistry.isGameFinalized(game)); + + // claimCredit triggers closeGame → setAnchorState → NORMAL mode + uint256 proposerBalanceBefore = proposer.balance; + game.claimCredit(proposer); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + assertEq(address(anchorStateRegistry.anchorGame()), address(game)); + } + + //////////////////////////////////////////////////////////////// + // Test 2: Challenged + Proposer Proves → DEFENDER_WINS // + //////////////////////////////////////////////////////////////// + + /// @notice create → challenge → proposer proves → resolve → claimCredit + function test_lifecycle_challenged_proveByProposer_defenderWins() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + // Challenger challenges + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Proposer proves with real TeeProofVerifier + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + // Resolve — challenged + proved by proposer → DEFENDER_WINS, proposer gets all + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + assertEq(game.normalModeCredit(proposer), DEFENDER_BOND + CHALLENGER_BOND); + + // Wait for finality + vm.warp(block.timestamp + 1); + + uint256 proposerBalanceBefore = proposer.balance; + game.claimCredit(proposer); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND + CHALLENGER_BOND); + assertEq(address(anchorStateRegistry.anchorGame()), address(game)); + } + + //////////////////////////////////////////////////////////////// + // Test 3: Challenged + Timeout → CHALLENGER_WINS → NORMAL // + //////////////////////////////////////////////////////////////// + + /// @notice create → challenge → (no prove) → timeout → resolve → NORMAL → challenger takes all + /// @dev A CHALLENGER_WINS game is still "proper" per ASR (registered, not blacklisted, + /// not retired, not paused), so closeGame → NORMAL mode. The challenger wins all bonds. + function test_lifecycle_challenged_timeout_challengerWins() public { + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + + // Challenger challenges + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Nobody proves — wait for prove deadline + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + + // Resolve — challenged + no proof → CHALLENGER_WINS + assertEq(uint8(game.resolve()), uint8(GameStatus.CHALLENGER_WINS)); + assertEq(game.normalModeCredit(challenger), DEFENDER_BOND + CHALLENGER_BOND); + + // Wait for finality + vm.warp(block.timestamp + 1); + assertTrue(anchorStateRegistry.isGameFinalized(game)); + + // Anchor should NOT update (setAnchorState requires DEFENDER_WINS) + address anchorBefore = address(anchorStateRegistry.anchorGame()); + + // Challenger claims all bonds + uint256 challengerBalanceBefore = challenger.balance; + game.claimCredit(challenger); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(challenger.balance, challengerBalanceBefore + DEFENDER_BOND + CHALLENGER_BOND); + + // Proposer has no credit — lost their bond + assertEq(game.normalModeCredit(proposer), 0); + + // Anchor state did NOT change + assertEq(address(anchorStateRegistry.anchorGame()), anchorBefore); + } + + //////////////////////////////////////////////////////////////// + // Test 4b: Blacklisted Game → REFUND // + //////////////////////////////////////////////////////////////// + + /// @notice Guardian blacklists a game → closeGame → REFUND → each gets deposit back + function test_lifecycle_blacklisted_refund() public { + bytes32 endBlockHash = keccak256("end-block"); + bytes32 endStateHash = keccak256("end-state"); + (TeeDisputeGame game,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + // Challenger challenges + vm.prank(challenger); + game.challenge{ value: CHALLENGER_BOND }(); + + // Proposer proves + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: ANCHOR_BLOCK_HASH, + startStateHash: ANCHOR_STATE_HASH, + endBlockHash: endBlockHash, + endStateHash: endStateHash, + l2Block: game.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + game.domainSeparator() + ); + vm.prank(proposer); + game.prove(abi.encode(proofs)); + + // Resolve — DEFENDER_WINS + assertEq(uint8(game.resolve()), uint8(GameStatus.DEFENDER_WINS)); + + // Guardian blacklists the game before finalization + // (address(this) is the guardian via MockSystemConfig) + anchorStateRegistry.blacklistDisputeGame(game); + + // Wait for finality + vm.warp(block.timestamp + 1); + assertTrue(anchorStateRegistry.isGameFinalized(game)); + assertFalse(anchorStateRegistry.isGameProper(game)); + + // claimCredit → closeGame → isGameProper = false → REFUND mode + uint256 proposerBalanceBefore = proposer.balance; + uint256 challengerBalanceBefore = challenger.balance; + game.claimCredit(proposer); + game.claimCredit(challenger); + + assertEq(uint8(game.bondDistributionMode()), uint8(BondDistributionMode.REFUND)); + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + assertEq(challenger.balance, challengerBalanceBefore + CHALLENGER_BOND); + } + + //////////////////////////////////////////////////////////////// + // Test 5: Parent-Child Chain → DEFENDER_WINS // + //////////////////////////////////////////////////////////////// + + /// @notice create parent → resolve parent → create child (parentIndex=0) → resolve child + function test_lifecycle_parentChildChain_defenderWins() public { + bytes32 parentEndBlockHash = keccak256("parent-end-block"); + bytes32 parentEndStateHash = keccak256("parent-end-state"); + + // Create parent game (root game, parentIndex = max) + (TeeDisputeGame parent,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, parentEndBlockHash, parentEndStateHash); + + // Wait for challenge window and resolve parent + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + parent.resolve(); + + // Wait for parent finality so it can become anchor + vm.warp(block.timestamp + 1); + parent.claimCredit(proposer); + + // Create child game referencing parent (parentIndex = 0) + bytes32 childEndBlockHash = keccak256("child-end-block"); + bytes32 childEndStateHash = keccak256("child-end-state"); + (TeeDisputeGame child,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 10, 0, childEndBlockHash, childEndStateHash); + + // Verify child's startingOutputRoot comes from parent + (Hash childStartRoot, uint256 childStartBlock) = child.startingOutputRoot(); + assertEq(childStartRoot.raw(), computeRootClaim(parentEndBlockHash, parentEndStateHash).raw()); + assertEq(childStartBlock, ANCHOR_L2_BLOCK + 5); + + // Prove and resolve child + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + proofs[0] = buildBatchProof( + BatchInput({ + startBlockHash: parentEndBlockHash, + startStateHash: parentEndStateHash, + endBlockHash: childEndBlockHash, + endStateHash: childEndStateHash, + l2Block: child.l2SequenceNumber() + }), + DEFAULT_EXECUTOR_KEY, + child.domainSeparator() + ); + + vm.prank(proposer); + child.prove(abi.encode(proofs)); + + assertEq(uint8(child.resolve()), uint8(GameStatus.DEFENDER_WINS)); + + // Wait for finality and claim + vm.warp(block.timestamp + 1); + child.claimCredit(proposer); + + assertEq(uint8(child.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + // Child should be the new anchor (higher l2SequenceNumber) + assertEq(address(anchorStateRegistry.anchorGame()), address(child)); + } + + //////////////////////////////////////////////////////////////// + // Test 6: Parent CHALLENGER_WINS → Child Short-Circuits // + //////////////////////////////////////////////////////////////// + + /// @notice parent CHALLENGER_WINS → child resolve short-circuits to CHALLENGER_WINS + /// @dev Child must be created while parent is still IN_PROGRESS (initialize rejects + /// a CHALLENGER_WINS parent). The short-circuit happens at resolve() time. + function test_lifecycle_parentChallengerWins_childShortCircuits() public { + bytes32 parentEndBlockHash = keccak256("parent-end-block"); + bytes32 parentEndStateHash = keccak256("parent-end-state"); + + // Create parent (still IN_PROGRESS) + (TeeDisputeGame parent,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, parentEndBlockHash, parentEndStateHash); + + // Create child BEFORE parent resolves (parentIndex = 0) + bytes32 childEndBlockHash = keccak256("child-end-block"); + bytes32 childEndStateHash = keccak256("child-end-state"); + (TeeDisputeGame child,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 10, 0, childEndBlockHash, childEndStateHash); + + // Challenge child so there's a challenger to receive bonds + vm.prank(challenger); + child.challenge{ value: CHALLENGER_BOND }(); + + // Now challenge parent and let it timeout → CHALLENGER_WINS + vm.prank(challenger); + parent.challenge{ value: CHALLENGER_BOND }(); + + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + parent.resolve(); + assertEq(uint8(parent.status()), uint8(GameStatus.CHALLENGER_WINS)); + + // Child resolve short-circuits to CHALLENGER_WINS because parent lost + assertEq(uint8(child.resolve()), uint8(GameStatus.CHALLENGER_WINS)); + + // Challenger gets all child bonds + assertEq(child.normalModeCredit(challenger), DEFENDER_BOND + CHALLENGER_BOND); + + // Wait for finality and claim + vm.warp(block.timestamp + 1); + uint256 challengerBalanceBefore = challenger.balance; + child.claimCredit(challenger); + + assertEq(uint8(child.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(challenger.balance, challengerBalanceBefore + DEFENDER_BOND + CHALLENGER_BOND); + } + + //////////////////////////////////////////////////////////////// + // Test 6b: Parent Loses, Child Unchallenged → Proposer Refund // + //////////////////////////////////////////////////////////////// + + /// @notice When parent loses and child was never challenged, proposer should get their bond back + /// (regression test for C-02: resolve() previously credited address(0)) + function test_lifecycle_parentChallengerWins_childUnchallenged_proposerRefunded() public { + bytes32 parentEndBlockHash = keccak256("parent-end-block"); + bytes32 parentEndStateHash = keccak256("parent-end-state"); + + // Create parent (still IN_PROGRESS) + (TeeDisputeGame parent,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, parentEndBlockHash, parentEndStateHash); + + // Create child BEFORE parent resolves — child is NOT challenged + bytes32 childEndBlockHash = keccak256("child-end-block"); + bytes32 childEndStateHash = keccak256("child-end-state"); + (TeeDisputeGame child,,) = _createGame(proposer, ANCHOR_L2_BLOCK + 10, 0, childEndBlockHash, childEndStateHash); + + // Challenge parent and let it timeout → CHALLENGER_WINS + vm.prank(challenger); + parent.challenge{ value: CHALLENGER_BOND }(); + + vm.warp(block.timestamp + MAX_PROVE_DURATION + 1); + parent.resolve(); + assertEq(uint8(parent.status()), uint8(GameStatus.CHALLENGER_WINS)); + + // Child resolve short-circuits to CHALLENGER_WINS because parent lost + assertEq(uint8(child.resolve()), uint8(GameStatus.CHALLENGER_WINS)); + + // Proposer should get their bond back (not burned to address(0)) + assertEq(child.normalModeCredit(proposer), DEFENDER_BOND); + assertEq(child.normalModeCredit(address(0)), 0); + + // Wait for finality and claim + vm.warp(block.timestamp + 1); + uint256 proposerBalanceBefore = proposer.balance; + child.claimCredit(proposer); + + assertEq(uint8(child.bondDistributionMode()), uint8(BondDistributionMode.NORMAL)); + assertEq(proposer.balance, proposerBalanceBefore + DEFENDER_BOND); + } + + //////////////////////////////////////////////////////////////// + // Test 7: Child Cannot Resolve Before Parent // + //////////////////////////////////////////////////////////////// + + /// @notice child.resolve() reverts with ParentGameNotResolved when parent is IN_PROGRESS + function test_lifecycle_childCannotResolveBeforeParent() public { + // Create parent (unchallenged, still in progress) + (TeeDisputeGame parent,,) = _createGame( + proposer, + ANCHOR_L2_BLOCK + 5, + type(uint32).max, + keccak256("parent-end-block"), + keccak256("parent-end-state") + ); + + // Create child referencing parent + (TeeDisputeGame child,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 10, 0, keccak256("child-end-block"), keccak256("child-end-state")); + + // Fast forward past child's challenge window + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + + // Child cannot resolve because parent is still IN_PROGRESS + vm.expectRevert(ParentGameNotResolved.selector); + child.resolve(); + + // Now resolve parent first + parent.resolve(); + + // Now child can resolve + assertEq(uint8(child.resolve()), uint8(GameStatus.DEFENDER_WINS)); + } + + //////////////////////////////////////////////////////////////// + // Test 8: Cross-Chain — Parent Game Wrong GameType // + //////////////////////////////////////////////////////////////// + + /// @notice Creating a TZ game with a parent of a different GameType reverts + function test_initialize_revertParentGameWrongGameType() public { + // Register a second game type (XL = GameType 1) using the same implementation + GameType XL_GAME_TYPE = GameType.wrap(1); + factory.setImplementation(XL_GAME_TYPE, IDisputeGame(address(implementation)), bytes("")); + factory.setInitBond(XL_GAME_TYPE, DEFENDER_BOND); + + // Create an XL game (index 0) — factory records it as GameType 1 + bytes memory xlExtraData = + buildExtraData(ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("xl-block"), keccak256("xl-state")); + Claim xlRootClaim = computeRootClaim(keccak256("xl-block"), keccak256("xl-state")); + + vm.startPrank(proposer, proposer); + factory.create{ value: DEFENDER_BOND }(XL_GAME_TYPE, xlRootClaim, xlExtraData); + vm.stopPrank(); + + // Try to create a TZ game (GameType 1960) with parentIndex=0 (the XL game) + // This should revert because parent's GameType (1) != child's GAME_TYPE (1960) + bytes memory tzExtraData = buildExtraData(ANCHOR_L2_BLOCK + 10, 0, keccak256("tz-block"), keccak256("tz-state")); + Claim tzRootClaim = computeRootClaim(keccak256("tz-block"), keccak256("tz-state")); + + vm.startPrank(proposer, proposer); + vm.expectRevert(InvalidParentGame.selector); + factory.create{ value: DEFENDER_BOND }(TEE_GAME_TYPE, tzRootClaim, tzExtraData); + vm.stopPrank(); + } + + //////////////////////////////////////////////////////////////// + // Test 9: Cross-Chain — Anchor Isolation // + //////////////////////////////////////////////////////////////// + + /// @notice A resolved TZ game cannot update XL's AnchorStateRegistry + function test_crossChain_anchorIsolation() public { + // Deploy a second ASR for the "XL" chain with its own respectedGameType + GameType XL_GAME_TYPE = GameType.wrap(1); + AnchorStateRegistry xlAnchorStateRegistry = _deployAnchorStateRegistryForType(factory, XL_GAME_TYPE); + + // Create and resolve a TZ game (DEFENDER_WINS) + bytes32 endBlockHash = keccak256("tz-end-block"); + bytes32 endStateHash = keccak256("tz-end-state"); + (TeeDisputeGame tzGame,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, endBlockHash, endStateHash); + + vm.warp(block.timestamp + MAX_CHALLENGE_DURATION + 1); + tzGame.resolve(); + vm.warp(block.timestamp + 1); + + // TZ game CAN update TZ's ASR (via claimCredit → closeGame → setAnchorState) + tzGame.claimCredit(proposer); + assertEq(address(anchorStateRegistry.anchorGame()), address(tzGame)); + + // TZ game CANNOT update XL's ASR — isGameRegistered fails because + // the game was created by a factory that XL's ASR recognizes, but + // setAnchorState checks respectedGameType which is XL_GAME_TYPE (1), not 1960 + vm.expectRevert(); + xlAnchorStateRegistry.setAnchorState(IDisputeGame(address(tzGame))); + } + + //////////////////////////////////////////////////////////////// + // Test 10: Cross-Chain — Parent Chain Isolation // + //////////////////////////////////////////////////////////////// + + /// @notice In a shared Factory, a child game can reference a same-type parent + /// but NOT a different-type parent + function test_crossChain_parentChainIsolation() public { + // Register XL game type in the same factory + GameType XL_GAME_TYPE = GameType.wrap(1); + factory.setImplementation(XL_GAME_TYPE, IDisputeGame(address(implementation)), bytes("")); + factory.setInitBond(XL_GAME_TYPE, DEFENDER_BOND); + + // Create XL game (index 0) + bytes memory xlExtraData = + buildExtraData(ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("xl-block"), keccak256("xl-state")); + Claim xlRootClaim = computeRootClaim(keccak256("xl-block"), keccak256("xl-state")); + + vm.startPrank(proposer, proposer); + factory.create{ value: DEFENDER_BOND }(XL_GAME_TYPE, xlRootClaim, xlExtraData); + vm.stopPrank(); + + // Create TZ game (index 1) + (TeeDisputeGame tzParent,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("tz-block"), keccak256("tz-state")); + + // TZ child referencing TZ parent (index 1, same type) — should succeed + (TeeDisputeGame tzChild,,) = + _createGame(proposer, ANCHOR_L2_BLOCK + 10, 1, keccak256("tz-child-block"), keccak256("tz-child-state")); + assertEq(uint8(tzChild.status()), uint8(GameStatus.IN_PROGRESS)); + + // TZ child referencing XL parent (index 0, wrong type) — should revert + bytes memory badExtraData = + buildExtraData(ANCHOR_L2_BLOCK + 15, 0, keccak256("bad-block"), keccak256("bad-state")); + Claim badRootClaim = computeRootClaim(keccak256("bad-block"), keccak256("bad-state")); + + vm.startPrank(proposer, proposer); + vm.expectRevert(InvalidParentGame.selector); + factory.create{ value: DEFENDER_BOND }(TEE_GAME_TYPE, badRootClaim, badExtraData); + vm.stopPrank(); + } + + //////////////////////////////////////////////////////////////// + // Infrastructure Helpers // + //////////////////////////////////////////////////////////////// + + function _deployFactory() internal returns (DisputeGameFactory) { + DisputeGameFactory impl = new DisputeGameFactory(); + Proxy proxy = new Proxy(address(this)); + proxy.upgradeToAndCall(address(impl), abi.encodeCall(impl.initialize, (address(this)))); + return DisputeGameFactory(address(proxy)); + } + + function _deployAnchorStateRegistry(DisputeGameFactory _factory) internal returns (AnchorStateRegistry) { + MockSystemConfig systemConfig = new MockSystemConfig(address(this)); + AnchorStateRegistry impl = new AnchorStateRegistry(0); + Proxy proxy = new Proxy(address(this)); + proxy.upgradeToAndCall( + address(impl), + abi.encodeCall( + impl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(_factory)), + Proposal({ + root: Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + TEE_GAME_TYPE + ) + ) + ); + return AnchorStateRegistry(address(proxy)); + } + + function _deployTeeProofVerifier() internal returns (TeeProofVerifier) { + MockRiscZeroVerifier riscZeroVerifier = new MockRiscZeroVerifier(); + bytes memory expectedRootKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), bytes32(uint256(3))); + TeeProofVerifier verifier = new TeeProofVerifier(riscZeroVerifier, IMAGE_ID, expectedRootKey); + + // Register the executor enclave via real register() flow + Vm.Wallet memory enclaveWallet = makeWallet(DEFAULT_EXECUTOR_KEY, "integration-enclave"); + TeeProofVerifier.AttestationData memory data = TeeProofVerifier.AttestationData({ + timestampMs: 1234, + pcrHash: PCR_HASH, + publicKey: uncompressedPublicKey(enclaveWallet), + userData: "" + }); + verifier.register("", data); + + return verifier; + } + + function _deployAnchorStateRegistryForType( + DisputeGameFactory _factory, + GameType _gameType + ) + internal + returns (AnchorStateRegistry) + { + MockSystemConfig systemConfig = new MockSystemConfig(address(this)); + AnchorStateRegistry impl = new AnchorStateRegistry(0); + Proxy proxy = new Proxy(address(this)); + proxy.upgradeToAndCall( + address(impl), + abi.encodeCall( + impl.initialize, + ( + ISystemConfig(address(systemConfig)), + IDisputeGameFactory(address(_factory)), + Proposal({ + root: Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), + l2SequenceNumber: ANCHOR_L2_BLOCK + }), + _gameType + ) + ) + ); + return AnchorStateRegistry(address(proxy)); + } + + function _createGame( + address creator, + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + returns (TeeDisputeGame game, bytes memory extraData, Claim rootClaim) + { + extraData = buildExtraData(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + rootClaim = computeRootClaim(blockHash_, stateHash_); + + vm.startPrank(creator, creator); + game = TeeDisputeGame( + payable(address(factory.create{ value: DEFENDER_BOND }(TEE_GAME_TYPE, rootClaim, extraData))) + ); + vm.stopPrank(); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameInvariant.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameInvariant.t.sol new file mode 100644 index 0000000000000..2a3dbeba96085 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeDisputeGameInvariant.t.sol @@ -0,0 +1,278 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { IDisputeGameFactory } from "interfaces/dispute/IDisputeGameFactory.sol"; +import { IDisputeGame } from "interfaces/dispute/IDisputeGame.sol"; +import { IAnchorStateRegistry } from "interfaces/dispute/IAnchorStateRegistry.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; +import { TeeDisputeGame, TEE_DISPUTE_GAME_TYPE } from "src/dispute/tee/TeeDisputeGame.sol"; +import { BondDistributionMode, Duration, GameType, Claim, Hash, GameStatus } from "src/dispute/lib/Types.sol"; +import { MockAnchorStateRegistry } from "test/dispute/tee/mocks/MockAnchorStateRegistry.sol"; +import { MockDisputeGameFactory } from "test/dispute/tee/mocks/MockDisputeGameFactory.sol"; +import { MockTeeProofVerifier } from "test/dispute/tee/mocks/MockTeeProofVerifier.sol"; +import { TeeTestUtils } from "test/dispute/tee/helpers/TeeTestUtils.sol"; +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; + +/// @title TeeDisputeGameHandler +/// @notice Foundry invariant test handler that simulates random proposer/challenger +/// action sequences against a single game instance. +contract TeeDisputeGameHandler is Test { + TeeDisputeGame public game; + address public proposer; + address public challenger; + address public executor; + MockTeeProofVerifier public verifier; + MockAnchorStateRegistry public anchorStateRegistry; + uint256 public executorKey; + + // Track ProposalStatus transitions for monotonicity checks + uint8 public lastProposalStatus; + // Track whether GameStatus has changed from IN_PROGRESS + bool public gameStatusChanged; + GameStatus public recordedStatus; + + bytes32 private constant BATCH_PROOF_TYPEHASH = keccak256( + "BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)" + ); + + constructor( + TeeDisputeGame _game, + address _proposer, + address _challenger, + address _executor, + uint256 _executorKey, + MockTeeProofVerifier _verifier, + MockAnchorStateRegistry _anchorStateRegistry + ) { + game = _game; + proposer = _proposer; + challenger = _challenger; + executor = _executor; + executorKey = _executorKey; + verifier = _verifier; + anchorStateRegistry = _anchorStateRegistry; + lastProposalStatus = uint8(TeeDisputeGame.ProposalStatus.Unchallenged); + } + + /// @notice Randomly attempt challenge + function challenge() external { + vm.deal(challenger, 100 ether); + vm.prank(challenger); + try game.challenge{ value: 2 ether }() { + _recordProposalStatus(); + } catch { } + _recordGameStatus(); + } + + /// @notice Randomly attempt prove (with a valid signature) + function prove() external { + verifier.setRegistered(executor, true); + + // Build a batch proof (may not match startingOutputRoot, hence try/catch) + (Hash startRoot, uint256 startBlock) = game.startingOutputRoot(); + TeeDisputeGame.BatchProof[] memory proofs = new TeeDisputeGame.BatchProof[](1); + + bytes32 structHash = keccak256( + abi.encode( + BATCH_PROOF_TYPEHASH, + game.blockHash(), + bytes32(startRoot.raw()), + game.blockHash(), + game.stateHash(), + game.l2SequenceNumber() + ) + ); + + // Placeholder proof — will likely fail hash checks but exercises the path + proofs[0] = TeeDisputeGame.BatchProof({ + startBlockHash: bytes32(0), + startStateHash: bytes32(0), + endBlockHash: game.blockHash(), + endStateHash: game.stateHash(), + l2Block: game.l2SequenceNumber(), + signature: _sign(keccak256("placeholder")) + }); + + vm.prank(proposer); + try game.prove(abi.encode(proofs)) { + _recordProposalStatus(); + } catch { } + _recordGameStatus(); + } + + /// @notice Randomly warp time forward + function warpForward(uint256 _seconds) external { + _seconds = bound(_seconds, 0, 2 days); + vm.warp(block.timestamp + _seconds); + } + + /// @notice Randomly attempt resolve + function resolve() external { + try game.resolve() { + _recordProposalStatus(); + _recordGameStatus(); + } catch { } + } + + function _recordProposalStatus() internal { + (,,,, TeeDisputeGame.ProposalStatus s,) = game.claimData(); + lastProposalStatus = uint8(s); + } + + function _recordGameStatus() internal { + GameStatus s = game.status(); + if (s != GameStatus.IN_PROGRESS && !gameStatusChanged) { + gameStatusChanged = true; + recordedStatus = s; + } + } + + function _sign(bytes32 digest) internal returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(executorKey, digest); + return abi.encodePacked(r, s, v); + } +} + +/// @title TeeDisputeGameInvariantTest +/// @notice Foundry invariant tests that verify key invariants from the audit report: +/// - INV-1: contract balance >= active mode credit sum +/// - INV-4: ProposalStatus monotonically increasing (no rollback) +/// - INV-5: GameStatus irreversible once changed +/// - INV-12: In NORMAL mode, at most one address has normalModeCredit +/// - INV-13: bondDistributionMode irreversible once set +contract TeeDisputeGameInvariantTest is TeeTestUtils { + uint256 internal constant DEFENDER_BOND = 1 ether; + uint256 internal constant CHALLENGER_BOND = 2 ether; + uint64 internal constant MAX_CHALLENGE_DURATION = 1 days; + uint64 internal constant MAX_PROVE_DURATION = 12 hours; + + bytes32 internal constant ANCHOR_BLOCK_HASH = keccak256("anchor-block"); + bytes32 internal constant ANCHOR_STATE_HASH = keccak256("anchor-state"); + uint256 internal constant ANCHOR_L2_BLOCK = 10; + + MockDisputeGameFactory internal factory; + MockAnchorStateRegistry internal anchorStateRegistry; + MockTeeProofVerifier internal teeProofVerifier; + TeeDisputeGame internal implementation; + TeeDisputeGame internal game; + TeeDisputeGameHandler internal handler; + + address internal proposer; + address internal challenger; + address internal executor; + + function setUp() public { + proposer = makeWallet(DEFAULT_PROPOSER_KEY, "proposer").addr; + challenger = makeWallet(DEFAULT_CHALLENGER_KEY, "challenger").addr; + executor = makeWallet(DEFAULT_EXECUTOR_KEY, "executor").addr; + + vm.deal(proposer, 100 ether); + vm.deal(challenger, 100 ether); + + factory = new MockDisputeGameFactory(); + anchorStateRegistry = new MockAnchorStateRegistry(); + teeProofVerifier = new MockTeeProofVerifier(); + + implementation = new TeeDisputeGame( + Duration.wrap(MAX_CHALLENGE_DURATION), + Duration.wrap(MAX_PROVE_DURATION), + IDisputeGameFactory(address(factory)), + ITeeProofVerifier(address(teeProofVerifier)), + CHALLENGER_BOND, + IAnchorStateRegistry(address(anchorStateRegistry)), + proposer, + challenger + ); + + factory.setImplementation(GameType.wrap(TEE_DISPUTE_GAME_TYPE), implementation); + factory.setInitBond(GameType.wrap(TEE_DISPUTE_GAME_TYPE), DEFENDER_BOND); + + anchorStateRegistry.setAnchor( + Hash.wrap(computeRootClaim(ANCHOR_BLOCK_HASH, ANCHOR_STATE_HASH).raw()), ANCHOR_L2_BLOCK + ); + anchorStateRegistry.setRespectedGameType(GameType.wrap(TEE_DISPUTE_GAME_TYPE)); + + // Create a game instance for the handler to operate on + bytes memory extraData = + buildExtraData(ANCHOR_L2_BLOCK + 5, type(uint32).max, keccak256("end-block"), keccak256("end-state")); + Claim rootClaim = computeRootClaim(keccak256("end-block"), keccak256("end-state")); + + vm.startPrank(proposer, proposer); + game = TeeDisputeGame( + payable( + address( + factory.create{ value: DEFENDER_BOND }(GameType.wrap(TEE_DISPUTE_GAME_TYPE), rootClaim, extraData) + ) + ) + ); + vm.stopPrank(); + + handler = new TeeDisputeGameHandler( + game, proposer, challenger, executor, DEFAULT_EXECUTOR_KEY, teeProofVerifier, anchorStateRegistry + ); + + // Only fuzz calls against the handler + targetContract(address(handler)); + } + + /// @notice INV-1: contract balance >= active mode credit sum + /// @dev Both normalModeCredit and refundModeCredit coexist in storage, but claimCredit + /// only reads one mode. Correct invariant: balance >= max(sum(normal), sum(refund)). + function invariant_balanceCoversAllCredits() public view { + uint256 totalNormal = game.normalModeCredit(proposer) + game.normalModeCredit(challenger); + uint256 totalRefund = game.refundModeCredit(proposer) + game.refundModeCredit(challenger); + assertGe(address(game).balance, totalNormal, "INV-1: balance < sum(normalModeCredit)"); + assertGe(address(game).balance, totalRefund, "INV-1: balance < sum(refundModeCredit)"); + } + + /// @notice INV-4: ProposalStatus can only transition forward; Resolved is terminal + function invariant_proposalStatusMonotonic() public view { + (,,,, TeeDisputeGame.ProposalStatus currentStatus,) = game.claimData(); + // Resolved (4) is the terminal state + if (handler.lastProposalStatus() == uint8(TeeDisputeGame.ProposalStatus.Resolved)) { + assertEq( + uint8(currentStatus), + uint8(TeeDisputeGame.ProposalStatus.Resolved), + "INV-4: left Resolved state" + ); + } + } + + /// @notice INV-5: GameStatus is irreversible once changed from IN_PROGRESS + function invariant_gameStatusIrreversible() public view { + if (handler.gameStatusChanged()) { + GameStatus current = game.status(); + // If previously resolved, current status must match the recorded one + assertTrue( + current == handler.recordedStatus() || current == GameStatus.IN_PROGRESS, + "INV-5: GameStatus reversed" + ); + } + } + + /// @notice INV-12: In NORMAL mode, at most one address has normalModeCredit > 0 + function invariant_normalModeAtMostOneRecipient() public view { + uint256 proposerCredit = game.normalModeCredit(proposer); + uint256 challengerCredit = game.normalModeCredit(challenger); + // Both cannot be > 0 simultaneously (winner takes all in NORMAL mode) + assertTrue( + proposerCredit == 0 || challengerCredit == 0, + "INV-12: both proposer and challenger have normalModeCredit" + ); + } + + /// @notice INV-13: bondDistributionMode is irreversible once set + /// @dev Since handler does not call closeGame, this invariant verifies + /// UNDECIDED stability within handler's operation scope. + function invariant_bondDistributionModeStable() public view { + BondDistributionMode mode = game.bondDistributionMode(); + // Within handler scope (no closeGame), mode should stay UNDECIDED. + // If mode changed via another path, it must not revert to UNDECIDED. + assertTrue( + mode == BondDistributionMode.UNDECIDED || mode == BondDistributionMode.NORMAL + || mode == BondDistributionMode.REFUND, + "INV-13: invalid bondDistributionMode" + ); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol b/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol new file mode 100644 index 0000000000000..9fd384e48059d --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/TeeProofVerifier.t.sol @@ -0,0 +1,247 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Vm } from "forge-std/Vm.sol"; +import { TeeProofVerifier } from "src/dispute/tee/TeeProofVerifier.sol"; +import { MockRiscZeroVerifier } from "test/dispute/tee/mocks/MockRiscZeroVerifier.sol"; +import { TeeTestUtils } from "test/dispute/tee/helpers/TeeTestUtils.sol"; + +contract TeeProofVerifierTest is TeeTestUtils { + MockRiscZeroVerifier internal riscZeroVerifier; + TeeProofVerifier internal verifier; + + Vm.Wallet internal enclaveWallet; + bytes32 internal constant IMAGE_ID = keccak256("tee-image"); + bytes32 internal constant PCR_HASH = keccak256("pcr-hash"); + bytes internal expectedRootKey; + + function setUp() public { + riscZeroVerifier = new MockRiscZeroVerifier(); + expectedRootKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2)), bytes32(uint256(3))); + verifier = new TeeProofVerifier(riscZeroVerifier, IMAGE_ID, expectedRootKey); + enclaveWallet = makeWallet(DEFAULT_EXECUTOR_KEY, "enclave"); + } + + // ============ Helper ============ + + function _buildAttestationData( + uint64 timestampMs, + bytes32 pcrHash, + bytes memory publicKey, + bytes memory userData + ) + internal + pure + returns (TeeProofVerifier.AttestationData memory) + { + return TeeProofVerifier.AttestationData({ + timestampMs: timestampMs, + pcrHash: pcrHash, + publicKey: publicKey, + userData: userData + }); + } + + function _registerEnclave() internal { + TeeProofVerifier.AttestationData memory data = + _buildAttestationData(1234, PCR_HASH, uncompressedPublicKey(enclaveWallet), "data"); + verifier.register(hex"1234", data); + } + + // ============ Register Tests ============ + + function test_register_succeeds() public { + TeeProofVerifier.AttestationData memory data = + _buildAttestationData(1234, PCR_HASH, uncompressedPublicKey(enclaveWallet), "data"); + + verifier.register(hex"1234", data); + + assertEq(verifier.enclavePcrHash(enclaveWallet.addr), PCR_HASH); + assertEq(verifier.enclaveRegisteredGeneration(enclaveWallet.addr), verifier.enclaveGeneration()); + assertTrue(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_register_revertUnauthorizedCaller() public { + TeeProofVerifier.AttestationData memory data = + _buildAttestationData(1234, PCR_HASH, uncompressedPublicKey(enclaveWallet), ""); + + vm.prank(makeAddr("attacker")); + vm.expectRevert("Ownable: caller is not the owner"); + verifier.register(hex"1234", data); + } + + function test_register_revertInvalidProof() public { + riscZeroVerifier.setShouldRevert(true); + TeeProofVerifier.AttestationData memory data = + _buildAttestationData(1234, PCR_HASH, uncompressedPublicKey(enclaveWallet), ""); + + vm.expectRevert(TeeProofVerifier.InvalidProof.selector); + verifier.register(hex"1234", data); + } + + function test_register_revertInvalidPublicKey() public { + bytes memory shortPublicKey = abi.encodePacked(bytes32(uint256(1)), bytes32(uint256(2))); + TeeProofVerifier.AttestationData memory data = _buildAttestationData(1234, PCR_HASH, shortPublicKey, ""); + + vm.expectRevert(TeeProofVerifier.InvalidPublicKey.selector); + verifier.register(hex"1234", data); + } + + function test_register_revertDuplicateEnclave() public { + _registerEnclave(); + + TeeProofVerifier.AttestationData memory data = + _buildAttestationData(1234, PCR_HASH, uncompressedPublicKey(enclaveWallet), "data"); + + vm.expectRevert(TeeProofVerifier.EnclaveAlreadyRegistered.selector); + verifier.register(hex"1234", data); + } + + // ============ VerifyBatch Tests ============ + + function test_verifyBatch_succeedsForRegisteredEnclave() public { + _registerEnclave(); + + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + + assertEq(verifier.verifyBatch(digest, signature), enclaveWallet.addr); + } + + function test_verifyBatch_revertForUnregisteredSigner() public { + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.verifyBatch(digest, signature); + } + + function test_verifyBatch_revertForInvalidSignature() public { + _registerEnclave(); + + vm.expectRevert(TeeProofVerifier.InvalidSignature.selector); + verifier.verifyBatch(keccak256("batch"), hex"1234"); + } + + // ============ Revoke Tests ============ + + function test_revoke_succeeds() public { + _registerEnclave(); + + verifier.revoke(enclaveWallet.addr); + + assertFalse(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_revoke_revertWhenEnclaveMissing() public { + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.revoke(enclaveWallet.addr); + } + + function test_revoke_revertNonOwner() public { + _registerEnclave(); + vm.prank(makeAddr("attacker")); + vm.expectRevert("Ownable: caller is not the owner"); + verifier.revoke(enclaveWallet.addr); + } + + function test_revoke_verifyBatchFailsAfterRevoke() public { + _registerEnclave(); + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + assertEq(verifier.verifyBatch(digest, signature), enclaveWallet.addr); + + verifier.revoke(enclaveWallet.addr); + + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.verifyBatch(digest, signature); + } + + function test_revoke_doubleRevokeReverts() public { + _registerEnclave(); + verifier.revoke(enclaveWallet.addr); + + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.revoke(enclaveWallet.addr); + } + + function test_revoke_canReRegisterAfterRevoke() public { + _registerEnclave(); + verifier.revoke(enclaveWallet.addr); + assertFalse(verifier.isRegistered(enclaveWallet.addr)); + + _registerEnclave(); + assertTrue(verifier.isRegistered(enclaveWallet.addr)); + } + + // ============ RevokeAll Tests ============ + + function test_revokeAll_invalidatesAllEnclaves() public { + _registerEnclave(); + assertTrue(verifier.isRegistered(enclaveWallet.addr)); + + uint256 oldGen = verifier.enclaveGeneration(); + verifier.revokeAll(); + + assertEq(verifier.enclaveGeneration(), oldGen + 1); + assertFalse(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_revokeAll_enclaveCanReRegister() public { + _registerEnclave(); + verifier.revokeAll(); + assertFalse(verifier.isRegistered(enclaveWallet.addr)); + + // Re-register after generation bump + _registerEnclave(); + assertTrue(verifier.isRegistered(enclaveWallet.addr)); + } + + function test_revokeAll_revertNonOwner() public { + vm.prank(makeAddr("attacker")); + vm.expectRevert("Ownable: caller is not the owner"); + verifier.revokeAll(); + } + + function test_revokeAll_verifyBatchFailsAfterRevoke() public { + _registerEnclave(); + bytes32 digest = keccak256("batch"); + bytes memory signature = signDigest(enclaveWallet.privateKey, digest); + + // Works before revokeAll + assertEq(verifier.verifyBatch(digest, signature), enclaveWallet.addr); + + verifier.revokeAll(); + + // Fails after revokeAll + vm.expectRevert(TeeProofVerifier.EnclaveNotRegistered.selector); + verifier.verifyBatch(digest, signature); + } + + // ============ Immutability Tests ============ + + function test_riscZeroVerifier_isImmutable() public view { + assertEq(address(verifier.riscZeroVerifier()), address(riscZeroVerifier)); + } + + function test_imageId_isImmutable() public view { + assertEq(verifier.imageId(), IMAGE_ID); + } + + function test_expectedRootKey_isSetInConstructor() public view { + assertEq(keccak256(verifier.expectedRootKey()), keccak256(expectedRootKey)); + } + + // ============ Ownership Tests ============ + + function test_transferOwnership_updatesOwner() public { + address newOwner = makeAddr("newOwner"); + verifier.transferOwnership(newOwner); + assertEq(verifier.owner(), newOwner); + } + + function test_transferOwnership_revertZeroAddress() public { + vm.expectRevert("Ownable: new owner is the zero address"); + verifier.transferOwnership(address(0)); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol b/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol new file mode 100644 index 0000000000000..39e66995deb0e --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/helpers/TeeTestUtils.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { Test } from "forge-std/Test.sol"; +import { Vm } from "forge-std/Vm.sol"; +import { Claim } from "src/dispute/lib/Types.sol"; +import { TeeDisputeGame } from "src/dispute/tee/TeeDisputeGame.sol"; + +abstract contract TeeTestUtils is Test { + uint256 internal constant DEFAULT_PROPOSER_KEY = 0xA11CE; + uint256 internal constant DEFAULT_CHALLENGER_KEY = 0xB0B; + uint256 internal constant DEFAULT_EXECUTOR_KEY = 0xC0DE; + uint256 internal constant DEFAULT_THIRD_PARTY_PROVER_KEY = 0xD00D; + + struct BatchInput { + bytes32 startBlockHash; + bytes32 startStateHash; + bytes32 endBlockHash; + bytes32 endStateHash; + uint256 l2Block; + } + + function buildExtraData( + uint256 l2SequenceNumber, + uint32 parentIndex, + bytes32 blockHash_, + bytes32 stateHash_ + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked(l2SequenceNumber, parentIndex, blockHash_, stateHash_); + } + + function computeRootClaim(bytes32 blockHash_, bytes32 stateHash_) internal pure returns (Claim) { + return Claim.wrap(keccak256(abi.encode(blockHash_, stateHash_))); + } + + bytes32 private constant BATCH_PROOF_TYPEHASH = keccak256( + "BatchProof(bytes32 startBlockHash,bytes32 startStateHash,bytes32 endBlockHash,bytes32 endStateHash,uint256 l2Block)" + ); + + bytes32 private constant DOMAIN_TYPEHASH = + keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"); + bytes32 private constant DOMAIN_NAME_HASH = keccak256("TeeDisputeGame"); + bytes32 private constant DOMAIN_VERSION_HASH = keccak256("1"); + + function computeBatchStructHash(BatchInput memory batch) internal pure returns (bytes32) { + return keccak256( + abi.encode( + BATCH_PROOF_TYPEHASH, + batch.startBlockHash, + batch.startStateHash, + batch.endBlockHash, + batch.endStateHash, + batch.l2Block + ) + ); + } + + function computeDomainSeparator(address verifier) internal view returns (bytes32) { + return keccak256(abi.encode(DOMAIN_TYPEHASH, DOMAIN_NAME_HASH, DOMAIN_VERSION_HASH, block.chainid, verifier)); + } + + function computeEIP712Digest(BatchInput memory batch, bytes32 domainSeparator) internal pure returns (bytes32) { + return keccak256(abi.encodePacked("\x19\x01", domainSeparator, computeBatchStructHash(batch))); + } + + function signDigest(uint256 privateKey, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(privateKey, digest); + return abi.encodePacked(r, s, v); + } + + function buildBatchProof( + BatchInput memory batch, + uint256 privateKey, + bytes32 domainSeparator + ) + internal + returns (TeeDisputeGame.BatchProof memory) + { + return TeeDisputeGame.BatchProof({ + startBlockHash: batch.startBlockHash, + startStateHash: batch.startStateHash, + endBlockHash: batch.endBlockHash, + endStateHash: batch.endStateHash, + l2Block: batch.l2Block, + signature: signDigest(privateKey, computeEIP712Digest(batch, domainSeparator)) + }); + } + + function buildBatchProofWithSignature( + BatchInput memory batch, + bytes memory signature + ) + internal + pure + returns (TeeDisputeGame.BatchProof memory) + { + return TeeDisputeGame.BatchProof({ + startBlockHash: batch.startBlockHash, + startStateHash: batch.startStateHash, + endBlockHash: batch.endBlockHash, + endStateHash: batch.endStateHash, + l2Block: batch.l2Block, + signature: signature + }); + } + + function makeWallet(uint256 privateKey, string memory label) internal returns (Vm.Wallet memory wallet) { + wallet = vm.createWallet(privateKey, label); + } + + function uncompressedPublicKey(Vm.Wallet memory wallet) internal pure returns (bytes memory) { + return abi.encodePacked(bytes1(0x04), bytes32(wallet.publicKeyX), bytes32(wallet.publicKeyY)); + } + + function buildJournal( + uint64 timestampMs, + bytes32 pcrHash, + bytes memory rootKey, + bytes memory publicKey, + bytes memory userData + ) + internal + pure + returns (bytes memory) + { + return abi.encodePacked( + bytes8(timestampMs), + pcrHash, + rootKey, + bytes1(uint8(publicKey.length)), + publicKey, + bytes2(uint16(userData.length)), + userData + ); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol new file mode 100644 index 0000000000000..c0e7a59c8b055 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockAnchorStateRegistry.sol @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {IDisputeGameFactory} from "interfaces/dispute/IDisputeGameFactory.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {IFaultDisputeGame} from "interfaces/dispute/IFaultDisputeGame.sol"; +import {ISystemConfig} from "interfaces/L1/ISystemConfig.sol"; +import {ISuperchainConfig} from "interfaces/L1/ISuperchainConfig.sol"; +import {IProxyAdmin} from "interfaces/universal/IProxyAdmin.sol"; +import {GameType, Hash, Proposal} from "src/dispute/lib/Types.sol"; + +contract MockAnchorStateRegistry is IAnchorStateRegistry { + struct Flags { + bool registered; + bool respected; + bool blacklisted; + bool retired; + bool finalized; + bool proper; + bool claimValid; + } + + uint8 public initVersion = 1; + ISystemConfig public systemConfig; + IDisputeGameFactory public disputeGameFactory; + IFaultDisputeGame public anchorGame; + Proposal internal anchorRoot; + mapping(IDisputeGame => bool) public disputeGameBlacklist; + mapping(address => Flags) internal flags; + GameType public respectedGameType; + uint64 public retirementTimestamp; + bool public paused; + bool public revertOnSetAnchorState; + IDisputeGame public lastSetAnchorState; + + function initialize( + ISystemConfig _systemConfig, + IDisputeGameFactory _disputeGameFactory, + Proposal memory _startingAnchorRoot, + GameType _startingRespectedGameType + ) + external + { + systemConfig = _systemConfig; + disputeGameFactory = _disputeGameFactory; + anchorRoot = _startingAnchorRoot; + respectedGameType = _startingRespectedGameType; + } + + function anchors(GameType) external view returns (Hash, uint256) { + return (anchorRoot.root, anchorRoot.l2SequenceNumber); + } + + function setAnchor(Hash root_, uint256 l2SequenceNumber_) external { + anchorRoot = Proposal({root: root_, l2SequenceNumber: l2SequenceNumber_}); + } + + function setRespectedGameType(GameType _gameType) external { + respectedGameType = _gameType; + } + + function updateRetirementTimestamp() external { + retirementTimestamp = uint64(block.timestamp); + } + + function blacklistDisputeGame(IDisputeGame game) external { + disputeGameBlacklist[game] = true; + flags[address(game)].blacklisted = true; + } + + function setPaused(bool value) external { + paused = value; + } + + function setRevertOnSetAnchorState(bool value) external { + revertOnSetAnchorState = value; + } + + function setGameFlags( + IDisputeGame game, + bool registered_, + bool respected_, + bool blacklisted_, + bool retired_, + bool finalized_, + bool proper_, + bool claimValid_ + ) + external + { + flags[address(game)] = Flags({ + registered: registered_, + respected: respected_, + blacklisted: blacklisted_, + retired: retired_, + finalized: finalized_, + proper: proper_, + claimValid: claimValid_ + }); + disputeGameBlacklist[game] = blacklisted_; + } + + function isGameBlacklisted(IDisputeGame game) external view returns (bool) { + return flags[address(game)].blacklisted; + } + + function isGameProper(IDisputeGame game) external view returns (bool) { + return flags[address(game)].proper; + } + + function isGameRegistered(IDisputeGame game) external view returns (bool) { + return flags[address(game)].registered; + } + + function isGameResolved(IDisputeGame) external pure returns (bool) { + return false; + } + + function isGameRespected(IDisputeGame game) external view returns (bool) { + return flags[address(game)].respected; + } + + function isGameRetired(IDisputeGame game) external view returns (bool) { + return flags[address(game)].retired; + } + + function isGameFinalized(IDisputeGame game) external view returns (bool) { + return flags[address(game)].finalized; + } + + function isGameClaimValid(IDisputeGame game) external view returns (bool) { + return flags[address(game)].claimValid; + } + + function setAnchorState(IDisputeGame game) external { + if (revertOnSetAnchorState) revert AnchorStateRegistry_InvalidAnchorGame(); + anchorGame = IFaultDisputeGame(address(game)); + lastSetAnchorState = game; + } + + function getAnchorRoot() external view returns (Hash, uint256) { + return (anchorRoot.root, anchorRoot.l2SequenceNumber); + } + + function disputeGameFinalityDelaySeconds() external pure returns (uint256) { + return 0; + } + + function superchainConfig() external view returns (ISuperchainConfig) { + return systemConfig.superchainConfig(); + } + + function version() external pure returns (string memory) { + return "mock"; + } + + function proxyAdmin() external pure returns (IProxyAdmin) { + return IProxyAdmin(address(0)); + } + + function proxyAdminOwner() external pure returns (address) { + return address(0); + } + + function __constructor__(uint256) external { } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol new file mode 100644 index 0000000000000..fe78a49f89831 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockDisputeGameFactory.sol @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {LibClone} from "@solady/utils/LibClone.sol"; +import {IDisputeGame} from "interfaces/dispute/IDisputeGame.sol"; +import {GameType, Claim, Timestamp} from "src/dispute/lib/Types.sol"; + +contract MockDisputeGameFactory { + using LibClone for address; + + struct StoredGame { + GameType gameType; + Timestamp timestamp; + IDisputeGame proxy; + Claim rootClaim; + bytes extraData; + } + + struct Lookup { + IDisputeGame proxy; + Timestamp timestamp; + } + + error IncorrectBondAmount(); + error NoImplementation(GameType gameType); + + address public owner; + + mapping(GameType => IDisputeGame) public gameImpls; + mapping(GameType => uint256) public initBonds; + mapping(GameType => bytes) public gameArgs; + + StoredGame[] internal storedGames; + mapping(bytes32 => Lookup) internal storedLookups; + + modifier onlyOwner() { + require(msg.sender == owner, "MockDisputeGameFactory: not owner"); + _; + } + + constructor() { + owner = msg.sender; + } + + function create(GameType _gameType, Claim _rootClaim, bytes calldata _extraData) + external + payable + returns (IDisputeGame proxy_) + { + IDisputeGame impl = gameImpls[_gameType]; + if (address(impl) == address(0)) revert NoImplementation(_gameType); + if (msg.value != initBonds[_gameType]) revert IncorrectBondAmount(); + + bytes32 parentHash = blockhash(block.number - 1); + if (gameArgs[_gameType].length == 0) { + proxy_ = IDisputeGame( + address(impl).clone(abi.encodePacked(msg.sender, _rootClaim, parentHash, _extraData)) + ); + } else { + proxy_ = IDisputeGame( + address(impl).clone( + abi.encodePacked(msg.sender, _rootClaim, parentHash, _gameType, _extraData, gameArgs[_gameType]) + ) + ); + } + + proxy_.initialize{value: msg.value}(); + _storeGame(_gameType, _rootClaim, _extraData, proxy_, uint64(block.timestamp)); + } + + function pushGame( + GameType _gameType, + uint64 _timestamp, + IDisputeGame _proxy, + Claim _rootClaim, + bytes memory _extraData + ) + external + { + _storeGame(_gameType, _rootClaim, _extraData, _proxy, _timestamp); + } + + function setImplementation(GameType _gameType, IDisputeGame _impl) external onlyOwner { + gameImpls[_gameType] = _impl; + } + + function setImplementation(GameType _gameType, IDisputeGame _impl, bytes calldata _args) external onlyOwner { + gameImpls[_gameType] = _impl; + gameArgs[_gameType] = _args; + } + + function setInitBond(GameType _gameType, uint256 _initBond) external onlyOwner { + initBonds[_gameType] = _initBond; + } + + function games(GameType _gameType, Claim _rootClaim, bytes calldata _extraData) + external + view + returns (IDisputeGame proxy_, Timestamp timestamp_) + { + Lookup memory lookup = storedLookups[_uuid(_gameType, _rootClaim, _extraData)]; + return (lookup.proxy, lookup.timestamp); + } + + function findLatestGames(GameType, uint256, uint256) external pure returns (bytes memory) { + revert("MockDisputeGameFactory: not implemented"); + } + + function gameAtIndex(uint256 _index) + external + view + returns (GameType gameType_, Timestamp timestamp_, IDisputeGame proxy_) + { + StoredGame storage game = storedGames[_index]; + return (game.gameType, game.timestamp, game.proxy); + } + + function gameCount() external view returns (uint256) { + return storedGames.length; + } + + function transferOwnership(address newOwner) external onlyOwner { + owner = newOwner; + } + + function initialize(address newOwner) external { + owner = newOwner; + } + + function proxyAdmin() external pure returns (address) { + return address(0); + } + + function proxyAdminOwner() external pure returns (address) { + return address(0); + } + + function initVersion() external pure returns (uint8) { + return 1; + } + + function renounceOwnership() external onlyOwner { + owner = address(0); + } + + function version() external pure returns (string memory) { + return "mock"; + } + + function __constructor__() external { } + + function _storeGame( + GameType _gameType, + Claim _rootClaim, + bytes memory _extraData, + IDisputeGame _proxy, + uint64 _timestamp + ) + internal + { + Timestamp timestamp = Timestamp.wrap(_timestamp); + storedGames.push( + StoredGame({ + gameType: _gameType, + timestamp: timestamp, + proxy: _proxy, + rootClaim: _rootClaim, + extraData: _extraData + }) + ); + storedLookups[_uuid(_gameType, _rootClaim, _extraData)] = Lookup({proxy: _proxy, timestamp: timestamp}); + } + + function _uuid(GameType _gameType, Claim _rootClaim, bytes memory _extraData) internal pure returns (bytes32) { + return keccak256(abi.encode(_gameType, _rootClaim, _extraData)); + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol new file mode 100644 index 0000000000000..ab897314e8fd5 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockRiscZeroVerifier.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IRiscZeroVerifier} from "interfaces/dispute/IRiscZeroVerifier.sol"; + +contract MockRiscZeroVerifier is IRiscZeroVerifier { + bool public shouldRevert; + bytes public lastSeal; + bytes32 public lastImageId; + bytes32 public lastJournalDigest; + + function setShouldRevert(bool value) external { + shouldRevert = value; + } + + function verify(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external view { + if (shouldRevert) revert("MockRiscZeroVerifier: invalid proof"); + seal; + imageId; + journalDigest; + } + + function verifyAndRecord(bytes calldata seal, bytes32 imageId, bytes32 journalDigest) external { + if (shouldRevert) revert("MockRiscZeroVerifier: invalid proof"); + lastSeal = seal; + lastImageId = imageId; + lastJournalDigest = journalDigest; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol new file mode 100644 index 0000000000000..3398bdbbebc46 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockStatusDisputeGame.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {IAnchorStateRegistry} from "interfaces/dispute/IAnchorStateRegistry.sol"; +import {Timestamp, GameStatus, GameType, Claim, Hash} from "src/dispute/lib/Types.sol"; + +contract MockStatusDisputeGame { + Timestamp public createdAt; + Timestamp public resolvedAt; + GameStatus public status; + GameType internal _gameType; + Claim internal _rootClaim; + Hash internal _l1Head; + uint256 internal _l2SequenceNumber; + bytes internal _extraData; + address internal _gameCreator; + bool public wasRespectedGameTypeWhenCreated; + IAnchorStateRegistry public anchorStateRegistry; + + constructor( + address creator_, + GameType gameType_, + Claim rootClaim_, + uint256 l2SequenceNumber_, + bytes memory extraData_, + GameStatus status_, + uint64 createdAt_, + uint64 resolvedAt_, + bool respected_, + IAnchorStateRegistry anchorStateRegistry_ + ) { + _gameCreator = creator_; + _gameType = gameType_; + _rootClaim = rootClaim_; + _l2SequenceNumber = l2SequenceNumber_; + _extraData = extraData_; + status = status_; + createdAt = Timestamp.wrap(createdAt_); + resolvedAt = Timestamp.wrap(resolvedAt_); + wasRespectedGameTypeWhenCreated = respected_; + anchorStateRegistry = anchorStateRegistry_; + } + + function initialize() external payable { } + + function resolve() external view returns (GameStatus status_) { + return status; + } + + function setStatus(GameStatus status_) external { + status = status_; + } + + function setResolvedAt(uint64 resolvedAt_) external { + resolvedAt = Timestamp.wrap(resolvedAt_); + } + + function gameData() external view returns (GameType gameType_, Claim rootClaim_, bytes memory extraData_) { + return (_gameType, _rootClaim, _extraData); + } + + function gameType() external view returns (GameType gameType_) { + return _gameType; + } + + function rootClaim() external view returns (Claim rootClaim_) { + return _rootClaim; + } + + function l1Head() external view returns (Hash l1Head_) { + return _l1Head; + } + + function l2SequenceNumber() external view returns (uint256 l2SequenceNumber_) { + return _l2SequenceNumber; + } + + function extraData() external view returns (bytes memory extraData_) { + return _extraData; + } + + function gameCreator() external view returns (address creator_) { + return _gameCreator; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol new file mode 100644 index 0000000000000..b318e6c19ef08 --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockSystemConfig.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import {ISuperchainConfig} from "interfaces/L1/ISuperchainConfig.sol"; + +contract MockSystemConfig { + bool public paused; + address public guardian; + ISuperchainConfig public superchainConfig; + + constructor(address guardian_) { + guardian = guardian_; + } + + function setPaused(bool value) external { + paused = value; + } + + function setGuardian(address value) external { + guardian = value; + } + + function setSuperchainConfig(ISuperchainConfig value) external { + superchainConfig = value; + } +} diff --git a/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol b/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol new file mode 100644 index 0000000000000..7632d51228a5b --- /dev/null +++ b/packages/contracts-bedrock/test/dispute/tee/mocks/MockTeeProofVerifier.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.15; + +import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { ITeeProofVerifier } from "interfaces/dispute/ITeeProofVerifier.sol"; + +contract MockTeeProofVerifier is ITeeProofVerifier { + error EnclaveNotRegistered(); + error InvalidSignature(); + + mapping(address => bool) public registered; + bytes32 public lastDigest; + bytes public lastSignature; + + function setRegistered(address enclave, bool value) external { + registered[enclave] = value; + } + + function verifyBatch(bytes32 digest, bytes calldata signature) external view returns (address signer) { + (address recovered, ECDSA.RecoverError err) = ECDSA.tryRecover(digest, signature); + if (err != ECDSA.RecoverError.NoError || recovered == address(0)) revert InvalidSignature(); + if (!registered[recovered]) revert EnclaveNotRegistered(); + return recovered; + } + + function verifyBatchAndRecord(bytes32 digest, bytes calldata signature) external returns (address signer) { + lastDigest = digest; + lastSignature = signature; + return this.verifyBatch(digest, signature); + } + + function isRegistered(address enclaveAddress) external view returns (bool) { + return registered[enclaveAddress]; + } +}