Skip to content

feat(validations): detect validation file drift - #822

Open
jackchuma wants to merge 8 commits into
mainfrom
toshi/chain-5953-validation-file-drift-check
Open

jackchuma wants to merge 8 commits into
mainfrom
toshi/chain-5953-validation-file-drift-check

Conversation

@jackchuma

@jackchuma jackchuma commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

What changed? Why?

Signer validation files are generated by simulating a task's script against an internal RPC, then committed. When a script, task .env, or addresses.json changes later, nothing notices that the committed validation file no longer matches what signers will be asked to sign. This adds two checks, split by whether internal RPC access is needed:

  1. Offline fingerprint check (public CI). GEN_VALIDATION now stamps each validation file with a taskInputs fingerprint: the sha256 of the task-owned inputs that shape the transaction: the task Makefile; every file under the task's script/; the Forge script named in the validation file's cmd (which may be a shared script/common/... script); repo Solidity those scripts import transitively (bare package imports such as solady/ and anything under lib/ are dependencies pinned by BASE_CONTRACTS_COMMIT, so they are not hashed); every file git would commit under config/<network>/ except validations/, README.md, and prestate.json (a post-execution baseline that does not feed the signed call), so .env, addresses.json, and JSON inputs such as funding.json; and the root config/<network>.env. python3 scripts/validation-drift.py check scans active/evm/tasks/* and standby/evm/tasks/*, and for every network that is not EXECUTED/CANCELED it requires:

    • every validation file the task's Makefile declares (the output name of each GEN_VALIDATION call) to be present, with no undeclared .json in validations/ (the signer UI would list it);
    • each file to carry taskInputs, and the recomputed fingerprint to match. On mismatch it names the input files that changed, appeared, or disappeared.

    It runs in the new Validate validation files workflow on every PR and on main, with no secrets or network access.

  2. Regenerate and diff (internal machine). make TASK_NETWORK=<network> check-validations, run from an active task or standby operation directory, re-runs the task's own gen-validation-* targets into a temp directory and semantically compares each result with the committed file (validation-drift.py compare). Missing or extra files and any difference in cmd, rpcUrl, ledger id, expected hashes, overrides, state changes, balance changes, or taskInputs fail. Fields a facilitator edits by hand are ignored: name, description, and allowDifference on each entry (plus the values of entries the committed file marks allowDifference: true), and taskOriginConfig (it records the generating machine's identity). Committed files are never touched. It needs internal *.cbhq.net RPCs, so it runs on an internal machine, before collecting signatures and after any change to scripts or .env.

README and AGENTS.md document where each check runs and how to adopt the fingerprint (regenerate with the task's gen-validation-* targets; do not hand-edit values or cmd).

Notes to reviewers

  • Coverage boundary. The fingerprint does not cover shared Make includes (root Makefile, Multisig.mk, scripts/Standby.mk), the signer tool pin, or active/evm/foundry.toml. Hashing them would flag every pending invocation on each shared-tooling change, so drift from those files is caught only by check-validations. The README says so.
  • Executed networks are skipped. Shared files in a multi-network task change for later networks (e.g. a new network's script edits the task Makefile), which would otherwise flag networks that already ran. Validation files only matter while signatures are pending.
  • Existing files have no fingerprint. Both active tasks on main are executed on Zeronet, so CI passes on main today. Any pending network whose validation files predate this change fails the check until its files are regenerated (which stamps them). That is intentional: an unstamped file has never been checked against its inputs.
  • Signer tool compatibility. The signer tool's TaskConfigSchema is a non-strict zod object, so the extra taskInputs key is ignored when signing. The stamp is written at generation time, before task-origin signing tarballs config/<network>/. GEN_VALIDATION's --forge-cmd is unchanged, so the generated cmd is byte-identical.
  • Review-round decision: after four review rounds one finding remained (Forge auto-remapped imports such as solady/ were resolved via the gitignored lib/ directory, which public CI does not install). The decision was to fix it by treating unresolved bare imports as dependencies, add a regression test that runs with no lib/ present, and run one final review round.
  • Forge project resolution. The script entry point is resolved under a task-local foundry.toml project first and falls back to active/evm, because some tasks keep their own foundry.toml for build or deploy profiles while GEN_VALIDATION still runs Forge from active/evm.
  • Pending networks must declare validations. check fails a pending network whose task Makefile has no GEN_VALIDATION call (for example a fresh scaffold), the same as check-validations. The scaffolded facilitator guide now lists check-validations.
  • Mainnet origin opt-out stays strict. Offline, the checker can't tell whether a mainnet task goes through the ProxyAdmin owner, so a hand-added skipTaskOriginValidation on mainnet fails check-validations. A mainnet task that legitimately needs it sets it in the gen-validation recipe via $(VALIDATIONS_DIR), which the README now documents.
  • Relation to open PRs:

How has it been tested?

  • python3 -B -m unittest scripts.tests.test_validation_drift -v (tests over temp fixture repos: each input change named, missing/undeclared/unstamped files, executed and canonical standby skips, stamp round trip and formatting, semantic compare cases, GEN_VALIDATION stamping without changing cmd, check-validations isolation for active and both standby operations including -j2).
  • python3 -B -m unittest scripts.tests.test_standby_lifecycle -v (existing, passes).
  • python3 scripts/validation-drift.py check on main's tasks: passes, both Zeronet invocations reported as skipped.
  • make -n TASK_NETWORK=zeronet check-validations in active/evm/tasks/2026-10-15-upgrade-l2-predeploys.
  • Real RPC-backed make TASK_NETWORK=sepolia check-validations on the pending Denim Sepolia invocation from an internal machine, using that branch's content with this change merged locally: passed, No meaningful differences for base-signer.json and security-council-signer.json (all state overrides, state changes and expected domain/message hashes match the committed, hand-edited files). For that run the fingerprint was stamped onto local copies of the committed files, and the L1/L2 RPCs were set to the public endpoints those files were generated with (see follow-up below).

Follow-up found by this check (not changed here)

Running check-validations against the Denim Sepolia invocation in its own branch with its configured RPCs flags real drift: its two Sepolia validation files were generated with public RPCs (rpcUrl https://ethereum-sepolia-rpc.publicnode.com, and L2_RPC_URL=https://sepolia.base.org in cmd) instead of the internal RPCs in config/sepolia.env and the task config, and they carry no taskInputs fingerprint. Simulated content otherwise matches. Once both changes are on main, those files should be regenerated on an internal machine (which also stamps them), or the offline check will fail for that network. That branch is intentionally untouched here.

Generated with Toshi

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@cb-heimdall

cb-heimdall commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

🟡 Heimdall Review Status

Requirement Status More Info
Reviews 🟡 0/2
Denominator calculation
Show calculation
1 if user is bot 0
1 if user is external 0
2 if repo is sensitive 0
From .codeflow.yml 2
Additional review requirements
Show calculation
Max 0
0
From CODEOWNERS 0
Global minimum 0
Max 2
2
1 if commit is unverified 0
Sum 2

@linear

linear Bot commented Oct 17, 2026

Copy link
Copy Markdown

CHAIN-5953

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@jackchuma

Copy link
Copy Markdown
Contributor Author

Review round 1

Reviewers: anthropic/claude-opus-5-5, cbhq-openai/gpt-6.1-sol

  • Fixed: [P1] Standby signer fixtures did not copy validation-drift.py after stamping became mandatory — scripts/tests/test_standby_lifecycle.py (5f843e1)
  • Fixed: [P2] Common scripts invoked directly and JSON inputs under config/<network>/ were not fingerprinted — scripts/validation-drift.py (5f843e1)
  • Fixed: [P2] A deleted network .env silently disabled checks for that network — scripts/validation-drift.py (5f843e1)
  • Fixed: [P2] Stamping and check broke the dispatcher task layout (Task.mk, no task Makefile); both now skip unrecognized layouts with a notice, and check-validations fails loudly when it finds no generation targets — Multisig.mk, scripts/validation-drift.py (5f843e1)
    Synced with main: 796a45c (already current)
    Validation: drift suite 26 passed, standby lifecycle 19 passed, run-signer-standby.sh passed (devbox), validation-drift.py check passes on main's tasks

🤖 Reply generated with Toshi

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@jackchuma

Copy link
Copy Markdown
Contributor Author

Review round 2

Reviewers: anthropic/claude-opus-5-5, cbhq-openai/gpt-6.1-sol

  • Fixed: [P2] prestate.json (a refreshable post-execution baseline, not a signed-call input) was fingerprinted, so refreshing it after signing would fail the check — scripts/validation-drift.py (4578b9b)
  • Fixed: [P2] The documented non-mainnet skipTaskOriginValidation edit failed check-validations; compare --network now ignores the origin opt-out fields outside mainnet and compares them strictly on mainnet — scripts/validation-drift.py, Multisig.mk (4578b9b)
    Synced with main: 796a45c (already current)
    Validation: drift suite 30 passed, standby lifecycle 19 passed, validation-drift.py check passes on main's tasks

🤖 Reply generated with Toshi

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@jackchuma

Copy link
Copy Markdown
Contributor Author

Review round 3

Reviewers: anthropic/claude-opus-5-5, cbhq-openai/gpt-6.1-sol

  • Fixed: [P2] Git-ignored local files (e.g. .DS_Store) were fingerprinted, so CI could fail with no fixable commit; directory scans now use git's tracked and non-ignored file list — scripts/validation-drift.py (cd7ec2f)
  • Fixed: [P2] A README with any historical Status: EXECUTED line was skipped while pending; skipping now requires exactly one finalized status, matching the archive guard — scripts/validation-drift.py (cd7ec2f)
  • Fixed: [P3] Imports Forge resolves from libs without an explicit remapping (e.g. solady/) failed stamping; they are now treated as dependencies — scripts/validation-drift.py (cd7ec2f)
    Synced with main: 796a45c (already current)
    Validation: drift suite 35 passed, standby lifecycle 19 passed, validation-drift.py check passes on main's tasks

🤖 Reply generated with Toshi

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@jackchuma

Copy link
Copy Markdown
Contributor Author

Review round 4

Reviewers: anthropic/claude-opus-5-5, cbhq-openai/gpt-6.1-sol

  • Fixed: [P2] Forge auto-remapped imports (solady/, @solady/) were only treated as dependencies when the gitignored lib/ directory was installed, so a correctly stamped file could fail public CI; unresolved bare imports are now dependencies, with a regression test that runs without lib/ (both reviewers) — scripts/validation-drift.py (558ac9a)
    Synced with main: 796a45c (already current)
    Validation: drift suite 35 passed, standby lifecycle 19 passed, validation-drift.py check passes on main's tasks

🤖 Reply generated with Toshi

@jackchuma
jackchuma marked this pull request as ready for review October 17, 2026 21:00
raise ValueError("invalid network identifier")
project = task if (task / "foundry.toml").is_file() else repo / "active/evm"
config = task / "config" / network
paths = {task / "Makefile", config / ".env", repo / "config" / f"{network}.env"}

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.

P3: the offline fingerprint does not cover the shared make includes, so the "every input that shapes the transaction" claim is broader than the code.

Issue: fingerprint() hashes the task Makefile, config/<network>/ files, script/, the Forge entry point and its imports, and config/<network>.env. Every task Makefile also does include ../../Makefile and include ../../Multisig.mk (standby tasks use scripts/Standby.mk, which sets RPC_URL, PROJECT_DIR and NETWORK_ENV), and the root Makefile pins SIGNER_TOOL_COMMIT. None of these are in the path set. On a stamped copy, appending a line to Multisig.mk, to the root Makefile or to scripts/Standby.mk still printed "Validation input fingerprints match."

Impact: a change to a shared include that alters what is generated (for example RPC_URL, PROJECT_DIR, the signer tool commit) keeps public CI green. check-validations would catch it, but it needs RPC access and is not the check that runs on every PR. The PR body's claim and README line 208 read as complete coverage.

Requested change: either add Multisig.mk, the root Makefile and (for standby tasks) scripts/Standby.mk to the path set, accepting that editing them flags every pending network, or add one README sentence that shared includes and files outside config/<network>/ and script/ are covered only by check-validations, and correct the PR-body wording.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2127788 with the docs option. Hashing the root Makefile, Multisig.mk and scripts/Standby.mk would flag every pending invocation in the repo on each shared-tooling edit (this PR itself edits Multisig.mk), and check-validations already catches drift from them. The README now states that shared Make includes, the signer tool pin and foundry.toml are outside the fingerprint and covered only by check-validations, and lists changes to them as a reason to run it. The PR description now says "task-owned inputs" and has a coverage-boundary note.

🤖 Reply generated with Toshi

…edback)

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@jackchuma
jackchuma requested a review from 0xth4nh October 19, 2026 18:45

@0xth4nh 0xth4nh left a comment

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.

Looks Good to Thanh's Toshi

0xth4nh
0xth4nh previously approved these changes Oct 19, 2026
xenoliss
xenoliss previously approved these changes Oct 19, 2026
…dation-file-drift-check

Co-authored-by: Toshi <toshi-noreply@coinbase.com>

# Conflicts:
#	scripts/tests/test_standby_lifecycle.py

@mw2000 mw2000 left a comment

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.

Reviewed the drift checker and Make integration — nice test coverage overall. Left a few comments on cases that came up with the recent task-scaffolder changes (#821) and open task PRs (#824, #825).

Also worth noting for merge ordering: open PRs #824 and #825 currently have unstamped READY TO SIGN validation files on Sepolia (and #823 updates config/sepolia.env), so they'll need a re-stamp/regeneration once this lands.

One broader design thought: most of the complexity in validation-drift.py is the Solidity/TOML/remapping/import-graph parser (without_comments, remappings, script_entrypoint, and the import walker). Since the only repo-owned Solidity outside <task>/script/ is active/evm/script/common/, hashing git ls-files over <task>/Makefile, <task>/script/, active/evm/script/common/, <task>/config/<network>/ (minus validations/, README.md, prestate.json), and config/<network>.env would give the same drift coverage without needing to parse foundry.toml, remappings.txt, or cmd.

Comment thread scripts/validation-drift.py Outdated
def fingerprint(repo, task, network, cmd):
if not re.fullmatch(r"[a-z0-9-]+", network):
raise ValueError("invalid network identifier")
project = task if (task / "foundry.toml").is_file() else repo / "active/evm"

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.

Breaks on active tasks with a task-local foundry.toml (e.g. #825)

In #825 (active/evm/tasks/2026-10-20-upgrade-remaining-l2-predeploys), the task has its own foundry.toml for custom compiler profiles during build/deploy/simulate-l2, while GEN_VALIDATION still runs with PROJECT_DIR := $(abspath ../..) (active/evm) to match signer-tool. As a result, cmd in base-signer.json contains tasks/2026-10-20-upgrade-remaining-l2-predeploys/script/UpgradeL2Predeploys.s.sol:UpgradeL2Predeploys.

Because (task / "foundry.toml").is_file() is true here, script_entrypoint(project, cmd) resolves <task>/tasks/<task>/script/... and fails with FileNotFoundError when stamping or checking.

Consider falling back to repo / "active/evm" in script_entrypoint (or when selecting project for script resolution) if the path doesn't exist under task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1fc535c. The fingerprint now resolves the forge script entry point under the task's own Forge project first and falls back to active/evm when it is not there, so a task-local foundry.toml used only for build or deploy profiles no longer breaks stamping or checking. Remappings follow the project the entry point resolved in. Added a regression test.

🤖 Reply generated with Toshi

if len(args) < 4 or not re.fullmatch(r"[A-Za-z0-9_.-]+\.json", args[3]):
raise ValueError(f"{task / 'Makefile'}: GEN_VALIDATION needs a literal signer JSON filename as its fourth argument")
files.add(args[3])
return files

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.

Silently passes when all GEN_VALIDATION calls are commented out

When make new-task (from #821) scaffolds a task, the $(call GEN_VALIDATION,...) line in Makefile starts commented out while config/<network>/README.md is initialized to Status: READY TO SIGN. declared_files(task) returns set(), so if validations/ is also empty (present == set()), declared - present, present - declared, and declared & present are all empty and check exits 0 with zero validation files.

By contrast, check-validations in Multisig.mk errors when no gen-validation* targets exist. Should declared_files (or check for a pending network) fail when files is empty?

Relatedly, new-task.py and standby/evm/tasks/*/FACILITATOR.md from #821 could also include make TASK_NETWORK=<network> check-validations in the facilitator instructions alongside make check-task-status.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1fc535c. check now fails a pending network (has .env, not EXECUTED or CANCELED) when the task Makefile declares no GEN_VALIDATION files, matching check-validations. A freshly scaffolded task stays red in CI until its gen-validation target is written, which fits its READY TO SIGN status. The new-task.py facilitator guide now lists make TASK_NETWORK=<network> check-validations after gen-validation. I left the canonical standby FACILITATOR.md files alone, since editing them triggers a re-review of those operations; the README already covers the check for standby invocations.

🤖 Reply generated with Toshi


def compare(args):
committed, regenerated = read_json(args.committed), read_json(args.regenerated)
if args.network != "mainnet":

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.

Mainnet tasks that don't go through ProxyAdmin owner

Per AGENTS.md, task origin validation is only needed for mainnet scripts that go through the ProxyAdmin owner. For a mainnet task owned directly by CB_MULTISIG (like 2026-10-08-denim-system-config), if a facilitator adds "skipTaskOriginValidation": true by hand to the committed JSON rather than via a Makefile recipe step, make TASK_NETWORK=mainnet check-validations will fail with $.skipTaskOriginValidation: removed true because genValidationFile.ts doesn't emit that field.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This one is intentional, so I kept the strict mainnet comparison. Offline, the checker can't tell whether a mainnet task goes through the ProxyAdmin owner, and a hand-added opt-out on one that does would silently disable task-origin validation. The supported path is now documented in README and AGENTS.md (1fc535c): set the opt-out in the gen-validation recipe after GEN_VALIDATION, writing to $(VALIDATIONS_DIR)/<file>. check-validations points VALIDATIONS_DIR at its temp directory, so regeneration reproduces the field, and reviewers see the opt-out in the Makefile.

🤖 Reply generated with Toshi

…alidations

Fall back to active/evm when a task-local foundry.toml does not contain the
script entry point, fail pending networks with no GEN_VALIDATION files, document
recipe-based mainnet origin opt-outs, and add check-validations to the
scaffolded facilitator guide.

Co-authored-by: Toshi <toshi-noreply@coinbase.com>
@cb-heimdall
cb-heimdall dismissed 0xth4nh’s stale review October 21, 2026 19:56

Approved review 5576401451 from 0xth4nh is now dismissed due to new commit. Re-request for approval.

@jackchuma
jackchuma requested a review from mw2000 October 21, 2026 19:56
@cb-heimdall
cb-heimdall dismissed xenoliss’s stale review October 21, 2026 19:56

Approved review 5577317423 from xenoliss is now dismissed due to new commit. Re-request for approval.

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.

5 participants