ethereum-optimism / ethereum-optimism/optimism
alloy-op-evm: Canyon create2-deployer activation check hardcodes a 2-second block time
- Dominant language
- Go
- Stars
- 6.5k
- Forks
- 4k
- Avg merge
- 2d 15h
- Merged PRs (30d)
- 145
Description
## Summary
[`ensure_create2_deployer`](https://github.com/ethereum-optimism/optimism/blob/68b009443f67cf830990e913bcbde8536cb15327/rust/alloy-op-evm/src/block/canyon.rs#L18-L36) detects the Canyon activation block — the block whose irregular state transition force-deploys the create2 deployer — with a hardcoded 2-second block time:
```rust
// If the canyon hardfork is active at the current timestamp, and it was not active at the
// previous block timestamp (heuristically, block time is not perfectly constant at 2s), and the
// chain is an optimism chain, then we need to force-deploy the create2 deployer contract.
if chain_spec.is_canyon_active_at_timestamp(timestamp) &&
!chain_spec.is_canyon_active_at_timestamp(timestamp.saturating_sub(2))
```
The comment's premise is wrong for OP: L2 block timestamps are exactly `genesis_l2_time + n * block_time`, so block time *is* constant — it is just not always 2. `block_time` is a per-chain rollup-config value and chains run at 1, 2, 5 and 10 seconds today.
The same crate already has the correct primitive one module over: [`OpHardforks::is_no_user_tx_activation_block(parent_timestamp, block_timestamp)`](https://github.com/ethereum-optimism/optimism/blob/68b009443f67cf830990e913bcbde8536cb15327/rust/alloy-op-hardforks/src/lib.rs#L237-L247).
## Precise analysis
Let `T_c` = `canyon_time`, `b` = block time, `ts` = block timestamp, parent timestamp = `ts - b`.
* The activation block is the first block with `ts >= T_c`, so its timestamp lies in `[T_c, T_c + b)`.
* **Current predicate** fires iff `ts ∈ [T_c, T_c + 2)`.
* **Correct (parent-relative) predicate** fires iff `ts ∈ [T_c, T_c + b)` — exactly the activation block, for any `b`.
| block time | behaviour today |
| --- | --- |
| `b == 2` | identical to the parent-relative form. No change. |
| `b == 1` | fires on the activation block **and the one after it**. The force-deploy is idempotent (fixed code + codehash on a non-empty account), so there is no state-root divergence — just a redundant account touch and changeset entry. |
| `b > 2` | **can miss the activation block entirely.** |
The `b > 2` miss is real, and it is worth being precise about when: it needs the activation block's timestamp to land at or after `T_c + 2`, which happens iff `T_c` is *not* aligned to the chain's block grid. Concretely with `b = 4`, blocks at `…, 998, 1002, …` and `T_c = 1000`: the activation block is `1002`, and `1002 - 2 = 1000 >= T_c`, so Canyon reads as *already active* at the probe point and the predicate is false. The create2 deployer is never force-deployed — a silent divergence from the specified irregular state transition.
Conversely, when `T_c` **is** grid-aligned the activation block's timestamp equals `T_c` exactly, which is inside `[T_c, T_c + 2)` for every `b`, and the current predicate is accidentally right. That is why nothing is broken today.
## Live-chain status: latent, not active
From `superchain-registry` @ `08d6a44910d75e28a5ee1c7c047d3bdd0bbdbdd2`:
* `block_time` distribution across chain configs: **43×2, 8×1, 1×5** (`mainnet/swan.toml`), **1×10** (`sepolia/cyber.toml`).
* Every chain with `block_time != 2` has `canyon_time <= genesis.l2_time` — Canyon is active from genesis, the deployer is in the genesis alloc, and the predicate never fires either way.
* Every chain with a post-genesis `canyon_time` (op, zora, mode, lyra, orderly, lisk-sep, metal-sep, …) has `block_time = 2` **and** `(canyon_time - l2_time) % 2 == 0`.
So no registry chain is affected. The exposure is new or custom chains, devnets and test harnesses that pick a non-2s block time with a post-genesis Canyon — and the crate is also published standalone as `alloy-op-evm`.
## Go side (mentioned, not to be fixed here)
op-geth's [`EnsureCreate2Deployer`](https://github.com/ethereum-optimism/op-geth/blob/v1.101702.3-rc.6/consensus/misc/create2deployer.go#L34-L39) (the version pinned by `go.mod`'s `replace` at `v1.101702.3-rc.6`) uses **exact equality**:
```go
if !c.IsOptimism() || c.CanyonTime == nil || *c.CanyonTime != timestamp {
return
}
```
That is block-time-independent but has the mirror-image bug: for an unaligned `canyon_time`, op-geth never force-deploys at all — while alloy-op-evm does, if the activation block happens to land in `[T_c, T_c+2)`. So the Go/Rust divergence for that config already exists today, in the opposite direction.
`op-node` does not execute blocks, and `op-core` carries no create2-deployer logic — only the predeploy address at [`op-core/predeploys/addresses.go#L37`](https://github.com/ethereum-optimism/optimism/blob/68b009443f67cf830990e913bcbde8536cb15327/op-core/predeploys/addresses.go#L37). op-geth is the only Go site.
## Fix
Key the check off the fork timestamp itself — `op_fork_activation(Canyon).as_timestamp() == Some(timestamp)` — which is exactly what op-geth does. That removes the block-time assumption, needs no parent header, and keeps every execution path (block building, `engine_newPayload`, sync re-execution, kona's proof executor) on one rule.
The parent-relative form is the *spec-level* definition of "activation block", but adopting it unilaterally in Rust would be the wrong move: on a chain with an unaligned `canyon_time` op-geth never force-deploys, so the canonical chain state has no create2 deployer, and deploying it in Rust would produce a state root the real chain never had. The residual hole — Canyon's consensus rules activate but the create2 deployer never lands — is shared by Go and Rust and would have to be closed on both sides together, if at all. It is unreachable on every registered chain.
Fixed in [#22691](https://github.com/ethereum-optimism/optimism/pull/22691).
🤖 *Co-created with Claude Fable 5*
Contributor guide
Research direction
Start in rust/alloy-op-evm/src/block/canyon.rs and review the existing ensure_create2_deployer check alongside alloy-op-hardforks/src/lib.rs. Compare the Rust behavior with op-geth’s EnsureCreate2Deployer in the pinned Go dependency, then review PR #22691; done means the activation check is consistent across the execution paths described in the issue.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go, rust
- Domain
- blockchain
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 20/100