Skip to content

feat(deployer): add verify-proposal and auto-generate upgrade proposals #4618

Merged
sveitser merged 13 commits into
mainfrom
ma/deploy-verify-bytecode
Jul 7, 2026
Merged

feat(deployer): add verify-proposal and auto-generate upgrade proposals #4618
sveitser merged 13 commits into
mainfrom
ma/deploy-verify-bytecode

Conversation

@sveitser

@sveitser sveitser commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator
  • Add deploy verify-proposal --contract <kind> --input schedule.json --input execute.json: independently verify a timelock (schedule+execute) upgrade
    proposal against on-chain state and shipped bytecode, without trusting
    Etherscan or the JSON description
  • Bytecode check: compare the on-chain impl to the contract binding's
    DEPLOYED_BYTECODE, normalizing trailing solc CBOR metadata and the UUPS
    __self immutable; classify FullMatch / CodeMatchMetaDiffers / Mismatch
  • Governance checks: proxy owner()/currentAdmin() == mapped timelock, delay >=
    getMinDelay, value/predecessor zero, init selector, version prerequisite;
    assert schedule and execute share inner payload, salt, value, predecessor
  • Compute Safe EIP-712 domain/message/tx hashes so signers can match their Ledger
  • deploy writes proposals into contracts/deployments/proposals//-/
    (network from chain id; --network/--proposal-slug/--proposals-root overrides):
    schedule.json, execute.json, a contract kind file, and a templated README
  • Auto-fill the README Safe hashes: resolve the signing Safe from the
    deployment-info toml and read its nonce on-chain; skip with a warning when the
    signer set is ambiguous or lookup fails
  • Add a CI job to verify committed proposals under contracts/deployments/proposals/**

- Add `deploy verify-proposal --contract <kind> --input schedule.json --input
  execute.json`: independently verify a timelock (schedule+execute) upgrade
  proposal against on-chain state and shipped bytecode, without trusting
  Etherscan or the JSON description
- Bytecode check: compare the on-chain impl to the contract binding's
  DEPLOYED_BYTECODE, normalizing trailing solc CBOR metadata and the UUPS
  __self immutable; classify FullMatch / CodeMatchMetaDiffers / Mismatch
- Governance checks: proxy owner()/currentAdmin() == mapped timelock, delay >=
  getMinDelay, value/predecessor zero, init selector, version prerequisite;
  assert schedule and execute share inner payload, salt, value, predecessor
- Compute Safe EIP-712 domain/message/tx hashes so signers can match their Ledger
- deploy writes proposals into contracts/deployments/proposals/<network>/<date>-<slug>/
  (network from chain id; --network/--proposal-slug/--proposals-root overrides):
  schedule.json, execute.json, a contract kind file, and a templated README
- Auto-fill the README Safe hashes: resolve the signing Safe from the
  deployment-info toml and read its nonce on-chain; skip with a warning when the
  signer set is ambiguous or lookup fails
- Add a CI job to verify committed proposals under contracts/deployments/proposals/**
@gemini-code-assist

Copy link
Copy Markdown
Contributor
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

@claude

claude Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @sveitser's task in 3m 37s —— View job


Code Review: feat(deployer): add verify-proposal and auto-generate upgrade proposals

  • Read repo guidelines and existing review comments
  • Analyze the full PR diff
  • Review core verification logic (verify.rs)
  • Review proposal writing logic (write.rs)
  • Review supporting modules (safe_hash.rs, deployment_info.rs, timelock.rs)
  • Review CLI integration and builder changes
  • Review CI workflow and documentation
  • Post detailed findings

This is a well-structured addition that builds a trust-minimizing verification pipeline for timelock upgrade proposals. The architecture is solid — bytecode normalization (CBOR stripping + UUPS immutable masking), governance wiring checks, and EIP-712 Safe hash computation are all implemented correctly and thoroughly tested with known-vector assertions.

What works well

  • Comprehensive test coverage: 20+ unit tests covering round-trip decoding, bytecode matching (full match, metadata diff, core mismatch), CBOR stripping across solc versions, immutable masking, governance checks, Safe hash vectors, and path validation. The test_verify_kind_by_bytecode_ok test using real StakeTableV3::DEPLOYED_BYTECODE with injected addresses is particularly good.
  • Clean separation of concerns: Pure functions (compare_normalized, strip_cbor_metadata, mask_immutables, safe_tx_hashes) are isolated from I/O and easy to test independently.
  • Defense in depth: Path traversal validation, existing-directory guards, library placeholder rejection, and immutable count bounds all prevent subtle misuse.
  • Good error messages: Verification failures include enough context (addresses, values, selectors) to diagnose issues without re-running with debug flags.

Inline comments posted

  1. CI workflow JSON globbing (verify-proposals.yml:69): ls "$dir"/*.json picks up all JSON files — extra files cause a confusing "expected 2 input files" error. Consider enumerating schedule.json/execute.json explicitly.

  2. Silent pass on unexpected init data (verify.rs:877-887): When expected_init_selector is None but init data is non-empty, the check passes. For a trust-minimizing verifier, this is worth flagging as a failure or at least a prominent warning.

  3. Unreachable path validation branches (write.rs:37-43): Minor — starts_with("../") and ends_with("/..") are unreachable since / is already rejected.

Other observations (no inline comments)

  • Naming: write_stake_table_v3_proposal_dir and render_stake_table_v3_readme are hardcoded to StakeTableV3 naming, but the verification side (ContractKindArg) supports 5 contract types. When extending proposal writing to other contracts, these will need generalization. Fine for now.
  • Safe nonce assumption: Both write.rs:298 and verify.rs:846 assume execute_nonce = schedule_nonce + 1 when the same Safe signs both phases. The README template appropriately warns signers to recompute if additional transactions have been queued.
  • EIP-712 encoding: The safe_tx_hashes implementation in safe_hash.rs correctly implements Safe v1.4.1's domain separator and SafeTx typehash. The known-vector test against a real on-chain domain separator provides strong confidence.
  • fetch_proxy_owner / fetch_proxy_major_version use StakeTableV3 binding for all OwnerAccessor::Owner contracts. This works because owner() and getVersion() have the same ABI selector across OpenZeppelin contracts, but it's worth knowing if a future contract breaks this assumption.

Overall this is solid, well-tested code. The inline items are the main action items — the CI globbing being the most impactful for day-to-day reliability.

Comment thread .github/workflows/verify-proposals.yml Outdated
esac
echo "Verifying $dir (contract=$contract, network=$network) ..."
# Build --input flags for each JSON file in the dir.
input_flags=$(ls "$dir"/*.json | sort | sed 's/^/--input /' | tr '\n' ' ')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wildcard glob "$dir"/*.json will pick up any .json file in the proposal directory, not just schedule.json/execute.json. If someone adds a metadata.json or notes.json, the verification fails with a confusing "expected 2 input files" error rather than running correctly.

Consider enumerating the expected files explicitly:

Suggested change
input_flags=$(ls "$dir"/*.json | sort | sed 's/^/--input /' | tr '\n' ' ')
input_flags="--input $dir/schedule.json --input $dir/execute.json"

Or at least document in the contracts/deployments/proposals/README.md that proposal directories must contain exactly schedule.json and execute.json (no other .json files).

Comment on lines +877 to +887
None => pass(
"init-selector",
if init_data.is_empty() {
"empty (expected for patch/no-reinitializer)".to_owned()
} else {
format!(
"non-empty init data but no selector expected; selector=0x{}",
alloy::hex::encode(&init_data[..4.min(init_data.len())])
)
},
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When expected_init_selector is None (e.g. FeeContract, RewardClaim) but the proposal contains non-empty init data, this silently passes. For a trust-minimizing verifier, unexpected init data in a contract that isn't supposed to have a reinitializer could indicate a malformed or malicious proposal.

Consider emitting a WARN-level row (still passing, but with a prominent detail) or even failing when init data is present but no selector is expected — matching the defense-in-depth tone of the rest of the checks.

Suggested change
None => pass(
"init-selector",
if init_data.is_empty() {
"empty (expected for patch/no-reinitializer)".to_owned()
} else {
format!(
"non-empty init data but no selector expected; selector=0x{}",
alloy::hex::encode(&init_data[..4.min(init_data.len())])
)
},
),
None => {
if init_data.is_empty() {
pass(
"init-selector",
"empty (expected for patch/no-reinitializer)".to_owned(),
)
} else {
fail(
"init-selector",
format!(
"unexpected non-empty init data for contract with no expected selector; selector=0x{}",
alloy::hex::encode(&init_data[..4.min(init_data.len())])
),
)
}
},

Comment on lines +37 to +43
if value.contains('/') || value.contains('\\') {
anyhow::bail!("{label} must not contain '/' or '\\': {value:?}");
}
if value == ".." || value.starts_with("../") || value.ends_with("/..") {
anyhow::bail!("{label} must not be '..': {value:?}");
}
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the starts_with("../") and ends_with("/..") checks on lines 40-41 are unreachable — any string containing / is already rejected by the check on line 37. The only reachable case is value == "..".

Not a bug (it's defense-in-depth), just noting it could be simplified to if value == ".." without changing behavior.

- Replace the two --input files with a positional proposal directory; read
  schedule.json, execute.json, and the contract kind from <dir>/contract
  (--contract now optional, cross-checked against the file). Fixes the broken
  num_args=2 usage that rejected two separate --input flags
- Simplify the CI job to verify-proposal "$dir"; update README template
  (PROPOSAL_DIR token), docs, and the helper script comment to the dir form
- Remove unused test imports (OpsTimelock, ext::AnvilApi) that failed clippy
  --all-targets -D warnings; apply nightly rustfmt import grouping
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Nextest failures (2) in this run

Test Attempts Time (s) Main history
hotshot-testing::tests_2::tests_2::catchup::test_all_restart::testtypes_::pushcdnimpl_::test_all_restart 1 23.65 passing
hotshot-testing::tests_2::tests_2::catchup::test_all_restart::testtypesrandomizedleader_::pushcdnimpl_::test_all_restart 1 23.55 passing

See the step summary for flaky tests and slowest tests.

sveitser added 9 commits July 3, 2026 08:25
…al README

- Proposal PRs add only data: schedule.json, execute.json, and proposal.toml
  (contract kind, per-phase safe/nonce, all six Safe hashes, and metadata). The
  author is untrusted; correctness is established by verify-proposal, not by text
- verify-proposal recomputes proxy/impl/salt/delay/predecessor/hashes from the
  JSONs + on-chain + shipped bytecode and asserts they match proposal.toml; the
  per-phase safe is cross-checked against deployment-info; nonce drift warns
- Drop --safe/--nonce from verify (read from the toml); deploy writes the toml
  (auto-resolves signers, --safe override for ambiguous networks)
- Remove per-proposal README generation and the contract file; the only README is
  the static, trusted contracts/deployments/proposals/README.md describing the flow
- verify-proposal reads chain_id from proposal.toml and defaults --rpc-url to the
  network's public node (1 -> mainnet, 11155111 -> sepolia publicnode); --rpc-url overrides
- Run verify read-only via its own provider, dispatched before the wallet provider
  is built, so it needs no mnemonic and no localhost node
- build with rustup/mold/protoc like build.yml instead of the nix dev shell
- run ./target/debug/deploy; --profile test outputs to target/debug, so the
  old ./target/test/deploy path failed every run
- fetch-depth: 0 so git diff against the base ref resolves
- verify a proposal dir when any file in it changes; the .json-only grep
  skipped proposal.toml tampering and broke under pipefail on no match
- drop the per-network RPC case; the binary resolves the public node itself
- add Swatinem/rust-cache and raise the timeout to 30 minutes
- require exactly one transaction per Safe batch; extra txs escaped checks
- fail non-empty init data for kinds without an expected init selector
- embed deployment-info TOMLs via include_str!; the CWD-relative path let
  checks silently skip outside the repo root
- always emit timelock-addr-match and proxy-addr-match from deployment-info;
  previously skipped without contract-address env vars, letting a look-alike
  proxy owned by an attacker timelock pass
- hard-fail Safe proposer/executor checks on unknown networks and cover the
  SafeExit timelock instead of skipping it
- pin network name to chain id (mainnet/decaf/hoodi); the default RPC was
  chosen from toml.chain_id and compared against itself
- honor ESPRESSO_L1_PROVIDER on the verify-proposal subcommand
- strip CBOR metadata only for a bounded tail ending in the canonical solc
  entry; the old scan never matched real bindings and allowed oversized
  attacker tails
- mask immutables at per-kind artifact offsets instead of a substring scan;
  offsets kept in sync with bindings by tests over all five contract kinds
- capture each proposal dir's verify output and PASS/FAIL status into a
  markdown file, posted via marocchino/sticky-pull-request-comment like
  test.yml
- four-backtick fences since proposal descriptions are untrusted
- stale comment is deleted when nothing was verified
- job gets pull-requests: write; contents stays read-only
- build or setup failures previously posted nothing and deleted the
  previous sticky comment
- write a fallback FAIL comment with the run link when the job fails
  without producing pr-comment.md
@sveitser

sveitser commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

@claude review again

@claude

claude Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 1m 34s —— View job


Code Review: feat(deployer): add verify-proposal and auto-generate upgrade proposals

  • Read repo guidelines (CLAUDE.md, doc/agents)
  • Review the full PR diff against origin/main
  • Check if previous review comments were addressed
  • Review verification logic (verify.rs)
  • Review proposal writing logic (write.rs)
  • Review supporting modules (safe_hash.rs, deployment_info.rs, timelock.rs, proposal_toml.rs)
  • Review CLI integration and builder changes
  • Review CI workflow
  • Post findings

Comment thread contracts/rust/deployer/src/proposals/deployment_info.rs
Comment thread contracts/rust/deployer/src/proposals/verify.rs Outdated
sveitser added 2 commits July 6, 2026 14:16
- compare bytecode by explaining every difference: accept only 20-byte
  windows holding the impl address over reference zeros (UUPS __self
  slots), require the exact per-contract window count, and report the
  offset plus both values on mismatch
- delete the per-contract immutable offset tables; pin each binding's
  zero runs to the expected slot count in tests instead
- accept empty init data only when the proxy already reached the
  reinitializer's target major version; fail closed if the version
  query fails
@sveitser
sveitser merged commit f0d6d97 into main Jul 7, 2026
145 of 147 checks passed
@sveitser
sveitser deleted the ma/deploy-verify-bytecode branch July 7, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants