Skip to content

docs(l2): run the release upgrade test with a real prover, and fix the verification-key instructions - #7203

Open
ilitteri wants to merge 4 commits into
mainfrom
docs/l2-upgrade-test-on-gpu
Open

docs(l2): run the release upgrade test with a real prover, and fix the verification-key instructions#7203
ilitteri wants to merge 4 commits into
mainfrom
docs/l2-upgrade-test-on-gpu

Conversation

@ilitteri

Copy link
Copy Markdown
Collaborator

Motivation

The release checklist's L2 upgrade test ran locally with --backend exec. That backend produces no proof, so it never consults a verification key — and the verification key is precisely what an ethrex upgrade changes. The test could not observe the main way an upgrade breaks.

Running it with a real SP1 prover for the first time (v24.0.0 → v25.0.0) surfaced a requirement that has applied since v9.0.0 and whose written procedure has been unusable for just as long. Two changes landed together in that release: per-commit-hash verification keys, and the Timelock becoming the OnChainProposer's owner.

Each batch is committed under keccak of the git sha of the binary that committed it, and commitBatch rejects a commit hash it holds no key for. So after upgrading, a deployment that verifies real proofs commits nothing until the new build's key is registered — every commit reverts with MissingVerificationKeyForCommit() (0xf6b9798e) while the L2 keeps producing blocks, which reads as a stuck committer rather than a missing key.

docs/l2/fundamentals/upgrades.md has documented the mechanism since it landed, but both of its instructions stopped working in that same release:

The doc says Measured against a live deployment
hash the "reduced" git commit, e.g. 9219410b9105485… that keccak is 4732af28…, so the example does not reproduce. The value is the full 40-char sha as ASCII — hashing v24.0.0's full sha yields exactly the key its own deployer registered
send upgradeSP1VerificationKey from the OnChainProposer owner account reverts with OwnableUnauthorizedAccount (0x118cdaa7); the Timelock has owned the contract since v9.0.0, so the call has to be routed through it

Description

Move the release upgrade test onto the GPU server with --backend sp1, and document the step it was missing.

  • docs/developers/release-process.md — the checklist item now names l2-gpu and SP1, explains why exec hides this failure class, and notes both L2 checks share ports and datadirs so they run sequentially. Keeps the fresh-deploy SP1 test as well: a release can deploy cleanly and still fail to upgrade, and the reverse.
  • docs/developers/l2/upgrade-test.md — deploy VERSION_FROM with --sp1 true so proofs are actually required, use the -gpu asset (the others have no CUDA, so --backend sp1 silently falls back to CPU and a batch looks like a hang), both provers on --backend sp1, a fourth stated goal covering on-L1 verification, and a new Step 3.6 that registers VERSION_TO's key.
  • docs/l2/deployment/upgrades.md — a standing per-release entry, since this applies to any real-proof deployment and not only to the release test.
  • docs/l2/fundamentals/upgrades.md — corrected. This page stays the single canonical explanation and the other two link to it; two divergent copies are what let the stale version go unnoticed.

All four now point at rex l2 register-vk (lambdaclass/rex#249), which derives the commit hash, routes through the Timelock and reads the key back, rather than a hand-assembled cast/rex send call.

Validation

Every claim here was measured on a v24.0.0 → v25.0.0 upgrade on l2-gpu, not derived from reading the contracts:

  • the two releases ship different verification keys, so registration is genuinely required
  • keccak(full ascii sha) reproduces the key the VERSION_FROM deployer registered for itself — confirming the derivation
  • v24.0.0 with --sp1 true: batches verified with real GPU proofs
  • v25.0.0 before registration: could not commit at all, reverting with 0xf6b9798e
  • after registering through Timelock.emergencyExecute: the committer's own retry recommitted the pending batch, and the batch carrying the v24.0.0-produced blocks verified under v25.0.0's key
  • the integration suite then passed against the upgraded stack, 1 passed; 0 failed

Checklist

  • Updated STORE_SCHEMA_VERSION (crates/storage/lib.rs) if the PR includes breaking changes to the Store requiring a re-sync.

…ackend

The upgrade test ran locally with `--backend exec`, which produces no proof and
therefore never reads a verification key — and the verification key is precisely
what a release bump changes. Batches are committed under
`keccak(VERGEN_GIT_SHA)`, baked into the binary at build time, and the
OnChainProposer resolves `verificationKeys[commitHash][SP1_VERIFIER_ID]` when
verifying, so a new release's batches commit fine and then never verify until
its key is registered. Under `exec` the test passes while the real upgrade
stalls at the first batch.

Move the test to `l2-gpu` with `--backend sp1` and the `-gpu` asset, deploy
`VERSION_FROM` with `--sp1 true` so proofs are actually required, and add the
step that registers `VERSION_TO`'s key through the Timelock.

The same requirement applies to any deployment verifying real proofs, not just
to the release test, so it is also documented as a standing step in the L2
upgrade guide — which had no mention of verification keys at all.
The derivation was checked against a live deployment: the deployer registers
`VERSION_FROM`'s key under `keccak(ascii git sha)`, and reading the public
`verificationKeys` mapping back returns exactly the key shipped in that
release's contracts tarball. Include both the `emergencyExecute` call and the
read that confirms it, so the commit hash can be sanity-checked against the key
the deployment already holds instead of being taken on faith.
Running the upgrade test on a GPU with a real prover showed the previous
description was wrong. The key is not checked at verification time but inside
`commitBatch`, which reverts with `MissingVerificationKeyForCommit()` (selector
`0xf6b9798e`) before storing anything. An upgraded sequencer therefore commits
nothing at all, rather than committing batches that later fail to verify:
`lastCommittedBatch` and `lastVerifiedBatch` both stand still while the L2 keeps
producing blocks, which reads as a stuck committer.

Observed on a v24.0.0 -> v25.0.0 upgrade: commits reverted with that selector
until the key was registered through `Timelock.emergencyExecute`, after which
the committer's own retry recommitted the pending batch and it verified with a
real SP1 proof. Nothing is lost by hitting this, which is worth saying so
operators do not go looking for damage.
… for it

`docs/l2/fundamentals/upgrades.md` has documented this since the mechanism
landed, but both of its instructions stopped working in the same release that
introduced it:

- It says to hash the "reduced" git commit, with a worked example that does not
  reproduce: keccak of that short string is not the hash shown. Measured against
  a live deployment, the value is the full 40-char sha as ASCII — hashing v24's
  full sha yields exactly the key its own deployer registered.
- It says to send the call from the OnChainProposer owner account, which the
  Timelock became in v9.0.0, the same release as per-commit-hash keys. A direct
  EOA call now reverts with `OwnableUnauthorizedAccount` (`0x118cdaa7`),
  reproduced here.

Point all three docs at `rex l2 register-vk`, which derives the hash, routes
through the Timelock and reads the key back, and keep one canonical explanation
in the fundamentals page rather than a second copy in the deployment guide —
two divergent copies are what let this go unnoticed.
@ilitteri
ilitteri requested a review from a team as a code owner August 24, 2026 16:15
@github-actions github-actions Bot added the L2 Rollup client label Aug 24, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. docs/developers/l2/upgrade-test.md, docs/l2/deployment/upgrades.md, docs/l2/fundamentals/upgrades.md: the new instructions hard-code HEAD- when extracting the git SHA from ethrex --version. That is not stable for release artifacts: the client version format is name/v<version>-<channel>-<sha>/..., and promoted release images explicitly report -stable-, not HEAD-. In those cases the sample command hashes the whole version string instead of the SHA, so register-vk writes the key under the wrong commit hash and commitBatch will keep reverting with MissingVerificationKeyForCommit(). The extraction needs to be channel-agnostic.

  2. docs/developers/l2/upgrade-test.md: TO_VK=0x$(tr -d '\n' < ethrex-riscv32im-succinct-zkvm-vk-bn254 ...) reads from the current directory, but this guide never cds into "$WORK/contracts-$VERSION_TO". In the normal flow that file lives under "$WORK/contracts-$VERSION_TO/...", so the command will fail with “No such file or directory” unless the operator happens to be in the extracted contracts directory already.

Aside from those two doc regressions, the contract-side rationale looks correct: commitBatch does reject missing verification keys, and upgradeSP1VerificationKey is owner-gated as described.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

ilitteri added a commit to lambdaclass/rex that referenced this pull request Aug 26, 2026
… key (#249)

**Motivation**

Every ethrex upgrade of an L2 that verifies real proofs needs the new
build's verification key registered against the commit hash it commits
batches under. This has been true since ethrex v9.0.0, when verification
keys became per-commit-hash and the Timelock became the
OnChainProposer's owner.

It is not optional. `commitBatch` rejects a commit hash the deployment
holds no key for, so an upgraded sequencer commits **nothing** until the
key is registered — every commit reverts with
`MissingVerificationKeyForCommit()` (`0xf6b9798e`) while the L2 keeps
producing blocks. The symptom looks like a stuck committer, not a
missing key.

Doing it by hand is easy to get wrong in two ways, and both failures
look the same from the outside:

- **The hashed value is the full git sha the binary reports, as ASCII**
— the segment after `HEAD-` in `ethrex --version`. Hashing an
abbreviated sha silently produces a different key, and the only symptom
is that commits keep reverting. The ethrex docs carried a worked example
using a shortened sha that does not reproduce; that is fixed in
lambdaclass/ethrex#7203.
- **`upgradeSP1VerificationKey` is `onlyOwner`, and that owner is the
Timelock**, so the call has to be routed through `emergencyExecute`
(Security Council) or Governance `schedule` + `execute`. Sending it
straight to the OnChainProposer from an EOA reverts with
`OwnableUnauthorizedAccount` (`0x118cdaa7`), which surfaces as an opaque
abi-decode failure.

**Description**

Adds `rex l2 register-vk` (alias `vk`) and the SDK functions behind it.

```
rex l2 register-vk --commit <GIT_SHA> --vk <VERIFICATION_KEY> \
  --on-chain-proposer <OCP> --timelock <TIMELOCK> --private-key <SECURITY_COUNCIL_PK>
```

- `--commit` takes the git sha and hashes it, or accepts an
already-hashed 32-byte value, so the caller never has to know which form
the contract wants.
- `--timelock` routes through `emergencyExecute`. Omit it only when an
EOA still owns the contract. If the direct call is rejected as
not-owner, the revert is translated into a message saying to pass the
flag rather than an abi-decode error.
- `--prover risc0` targets `upgradeRISC0VerificationKey`; the default is
SP1.
- `--dry-run` prints the derived commit hash and the key currently on
chain without sending.
- The key is read back after the transaction is mined, because a call
routed to the wrong owner can be mined without taking effect. A re-run
when the key already matches is a no-op.

New: `sdk/src/l2/verification_key.rs` (`commit_hash_from_git_sha`,
`get_verification_key`, `register_verification_key`, `Prover`), plus the
four contract signatures in `sdk/src/l2/constants.rs`.

**How to test**

Against a dev L2 deployed with `--sp1 true` (this is the flow
lambdaclass/ethrex#7203 documents as Step 3.6 of the release upgrade
test):

```bash
# what would happen, nothing sent
rex l2 register-vk --commit "$(./ethrex --version | sed 's|.*HEAD-||; s|/.*||')" \
  --vk 0x<key from the release's ethrex-contracts.tar.gz> \
  --on-chain-proposer "$OCP" --timelock "$TIMELOCK" --private-key "$PK" --dry-run

# register, then re-run: the second call reports nothing to do
rex l2 register-vk --commit ... --vk ... --on-chain-proposer "$OCP" --timelock "$TIMELOCK" --private-key "$PK"
```

Verified end to end against a live v24.0.0 → v25.0.0 upgrade: the dry
run reproduced a hand-computed commit hash, the registration went
through the Timelock and read back correctly, a repeat run reported
nothing to do, and omitting `--timelock` produced the translated error.
`cargo check --all-targets`, `clippy` and `fmt` are clean.
AnkushinDaniil pushed a commit to AnkushinDaniil/ethrex that referenced this pull request Aug 30, 2026
**Motivation**

Merges the `release/v25.0.0` branch back into `main` now that
[v25.0.0](https://github.com/lambdaclass/ethrex/releases/tag/v25.0.0) is
published, per the last step of the [release
process](https://github.com/lambdaclass/ethrex/blob/main/docs/developers/release-process.md).

The second commit matters on its own: **`main`'s release workflow is
currently broken**, and this is what fixes it. Any tag cut from `main`
before this merges would build every artifact and then fail to publish a
release.

**Description**

Two commits, no conflicts with `main`:

- `chore(l1,l2): bump version to 25.0.0` — the 25 internal version pins
across 9 `Cargo.toml` files, the regenerated lockfiles, and both
`--builder.extra-data` defaults in `docs/CLI.md`.

Includes the four
`crates/guest-program/stateless-validator/**/Cargo.lock` files, which
are each their own workspace and reach the ethrex crates through path
dependencies, so they pin the workspace version and go stale on a bump.
`make update-cargo-lock` does not cover them while `check-cargo-lock`
does, so they were refreshed by hand — worth closing that asymmetry in a
follow-up, since every future bump hits it.

- `ci(l1,l2): stop the release asset download from matching docker build
records` — `finalize-release` downloaded artifacts with `pattern:
"*ethrex*"`, which also matches the build records the docker jobs upload
as `<org>~ethrex~<id>.dockerbuild`. Those are not zip archives, so
`download-artifact` exhausted its retries trying to extract them and the
job died before publishing anything.

This is a regression from lambdaclass#7115, which widened the pattern to pick up
the new `stateless-validator-ethrex-*` artifacts; the previous anchored
pattern never matched the docker records. v25.0.0-rc.1 hit it — all 13
build jobs green, no release created. The dry-run rehearsal could not
have caught it either: that job only waits on the guest builds, so it
runs before any docker record exists.

Fixed with two anchored patterns, `ethrex*` and
`stateless-validator-ethrex-*`, verified against the run's real 17
artifacts to select exactly the 12 release assets. rc.2 published all 22
assets correctly.

**Validation**

v25.0.0 went through the full release checklist: three mainnet canaries
paired with Prysm/Teku/Grandine, an eth-docker mainnet node, 13
consecutive successful multisync runs across hoodi/sepolia/mainnet, the
SP1 GPU integration suite, and the L2 upgrade test — the last of which
ran with a real SP1 GPU prover for the first time (see lambdaclass#7203).

**Checklist**

- [ ] Updated `STORE_SCHEMA_VERSION` (crates/storage/lib.rs) if the PR
includes breaking changes to the `Store` requiring a re-sync.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L2 Rollup client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

1 participant