From d913c0364c7aa0d35f3cfc9e757607a84d13f5f3 Mon Sep 17 00:00:00 2001 From: Markus Osterlund Date: Wed, 17 Jun 2026 11:57:14 -0500 Subject: [PATCH 1/4] feat: defer + register AggregateVerifier for runtime config_hash (PRIV-1997) A dev multiproof devnet mints a fresh chain on every boot, so its config_hash (which commits to genesis.l1.hash, genesis.l2.hash, l2_time) is not known until after L2 genesis is generated and the EL initializes. AggregateVerifier.CONFIG_HASH is an immutable in a solady CWIA clone, so it cannot be set up front in that scenario. - _deferAggregateVerifierRegistration: dev-multiproof only, opt-in via the MULTIPROOF_DEFER_REGISTRATION env var. When set, _deployMultiproofContracts deploys the registry + TEE/ZK verifiers but skips the AggregateVerifier + setImplementation. Production / precompute flows are unchanged. - registerAggregateVerifier(bytes32): re-entrant post-genesis entrypoint that reloads config params from cfg and deployed addresses from artifacts, reuses _newAggregateVerifier to build the verifier with the real hash, then setImplementation(gameType, impl). Broadcast by the factory owner (finalSystemOwner == deployer in devnet). Co-authored-by: Cursor --- scripts/deploy/SystemDeploy.s.sol | 106 ++++++++++++++++++++++++------ 1 file changed, 85 insertions(+), 21 deletions(-) diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index 716c0d45..7223b6fe 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -231,6 +231,50 @@ contract SystemDeploy is Script { }); } + /// @notice Deploys and registers the AggregateVerifier for a dev multiproof devnet after L2 + /// genesis, once the real config_hash is finally known. + /// @dev Pairs with MULTIPROOF_DEFER_REGISTRATION: the main deploy skips the AggregateVerifier, + /// then this entrypoint deploys it with `_multiproofConfigHash` and points the + /// DisputeGameFactory's game-type implementation at it. All other inputs are reloaded from + /// the deploy config (`cfg`) and the saved deployment artifacts, so the verifier is built + /// identically to the inline path. Must be broadcast by the DisputeGameFactory owner + /// (finalSystemOwner). Run with: + /// forge script ... --sig "registerAggregateVerifier(bytes32)" --broadcast + function registerAggregateVerifier(bytes32 _multiproofConfigHash) public { + require(_multiproofConfigHash != bytes32(0), "SystemDeploy: multiproofConfigHash not set"); + + GameType gameType = GameType.wrap(uint32(cfg.multiproofGameType())); + + // zkVerifier is the dev sentinel (0xdead); this entrypoint is dev-multiproof only. + IVerifier aggregateVerifier = _newAggregateVerifier( + AggregateVerifierInput({ + multiproofGameType: gameType, + anchorStateRegistry: IAnchorStateRegistry(artifacts.mustGetAddress("AnchorStateRegistryProxy")), + delayedWETH: IDelayedWETH(artifacts.mustGetAddress("DelayedWETHProxy")), + teeVerifier: IVerifier(artifacts.mustGetAddress("TEEVerifier")), + zkVerifier: IVerifier(address(0xdead)), + teeImageHash: cfg.teeImageHash(), + zkRangeHash: cfg.zkRangeHash(), + zkAggregationHash: cfg.zkAggregationHash(), + multiproofConfigHash: _multiproofConfigHash, + l2ChainId: cfg.l2ChainId(), + multiproofBlockInterval: cfg.multiproofBlockInterval(), + multiproofIntermediateBlockInterval: cfg.multiproofIntermediateBlockInterval(), + slowFinalizationDelay: cfg.slowFinalizationDelay(), + fastFinalizationDelay: cfg.fastFinalizationDelay() + }) + ); + + IDisputeGameFactory disputeGameFactory = + IDisputeGameFactory(artifacts.mustGetAddress("DisputeGameFactoryProxy")); + vm.broadcast(msg.sender); + disputeGameFactory.setImplementation(gameType, IDisputeGame(address(aggregateVerifier))); + + artifacts.save("AggregateVerifier", address(aggregateVerifier)); + vm.label(address(aggregateVerifier), "AggregateVerifier"); + console.log("Registered AggregateVerifier at:", address(aggregateVerifier)); + } + function _runConfigured() internal returns (DeployOutput memory output_) { output_ = deploy(_deployInput()); @@ -1023,33 +1067,43 @@ contract SystemDeploy is Script { IVerifier(address(new ZKVerifier(_input.sp1Verifier, _output.anchorStateRegistryProxy))); } - output_.aggregateVerifier = _newAggregateVerifier( - AggregateVerifierInput({ - multiproofGameType: gameType, - anchorStateRegistry: _output.anchorStateRegistryProxy, - delayedWETH: _output.delayedWETHProxy, - teeVerifier: output_.teeVerifier, - zkVerifier: output_.zkVerifier, - teeImageHash: _input.teeImageHash, - zkRangeHash: _input.zkRangeHash, - zkAggregationHash: _input.zkAggregationHash, - multiproofConfigHash: _input.multiproofConfigHash, - l2ChainId: _opChainInput.l2ChainId, - multiproofBlockInterval: _input.multiproofBlockInterval, - multiproofIntermediateBlockInterval: _input.multiproofIntermediateBlockInterval, - slowFinalizationDelay: _input.slowFinalizationDelay, - fastFinalizationDelay: _input.fastFinalizationDelay - }) - ); + if (_deferAggregateVerifierRegistration(_input)) { + // The multiproof config_hash commits to the L2 genesis block hash, which is only known + // after the L2 execution client initializes from the generated genesis. Defer + // AggregateVerifier deployment + registration to `registerAggregateVerifier(bytes32)`, + // invoked post-genesis with the real hash. output_.aggregateVerifier stays unset here. + console.log("Deferring AggregateVerifier: run registerAggregateVerifier(bytes32) after L2 genesis"); + } else { + output_.aggregateVerifier = _newAggregateVerifier( + AggregateVerifierInput({ + multiproofGameType: gameType, + anchorStateRegistry: _output.anchorStateRegistryProxy, + delayedWETH: _output.delayedWETHProxy, + teeVerifier: output_.teeVerifier, + zkVerifier: output_.zkVerifier, + teeImageHash: _input.teeImageHash, + zkRangeHash: _input.zkRangeHash, + zkAggregationHash: _input.zkAggregationHash, + multiproofConfigHash: _input.multiproofConfigHash, + l2ChainId: _opChainInput.l2ChainId, + multiproofBlockInterval: _input.multiproofBlockInterval, + multiproofIntermediateBlockInterval: _input.multiproofIntermediateBlockInterval, + slowFinalizationDelay: _input.slowFinalizationDelay, + fastFinalizationDelay: _input.fastFinalizationDelay + }) + ); - vm.broadcast(msg.sender); - _output.disputeGameFactoryProxy.setImplementation(gameType, IDisputeGame(address(output_.aggregateVerifier))); + vm.broadcast(msg.sender); + _output.disputeGameFactoryProxy.setImplementation( + gameType, IDisputeGame(address(output_.aggregateVerifier)) + ); + vm.label(address(output_.aggregateVerifier), "AggregateVerifier"); + } vm.label(address(output_.teeProverRegistryImpl), "TEEProverRegistryImpl"); vm.label(address(output_.teeProverRegistryProxy), "TEEProverRegistryProxy"); vm.label(address(output_.teeVerifier), "TEEVerifier"); vm.label(address(output_.zkVerifier), "ZKVerifier"); - vm.label(address(output_.aggregateVerifier), "AggregateVerifier"); } function _newAggregateVerifier(AggregateVerifierInput memory _input) internal returns (IVerifier) { @@ -1109,6 +1163,16 @@ contract SystemDeploy is Script { return _multiproofEnabled(_input) && _input.nitroEnclaveVerifier == address(0); } + /// @notice Whether AggregateVerifier deployment + registration should be deferred to a + /// post-genesis step instead of happening inline during the main deploy. + /// @dev Only meaningful for dev multiproof (devnet), where the config_hash commits to the L2 + /// genesis hash and is therefore unknown at L1 deploy time. Opt-in via the + /// MULTIPROOF_DEFER_REGISTRATION env var so other dev flows that precompute the hash keep + /// deploying the verifier inline. + function _deferAggregateVerifierRegistration(ImplementationInput memory _input) internal view returns (bool) { + return _isDevMultiproof(_input) && vm.envOr("MULTIPROOF_DEFER_REGISTRATION", false); + } + function _assertValidMultiproofInput(ImplementationInput memory _input) internal view { require(_input.multiproofConfigHash != bytes32(0), "SystemDeploy: multiproofConfigHash not set"); require(_input.multiproofGameType != 0, "SystemDeploy: multiproofGameType not set"); From 9fab13f0295ce9e4d95f2662a299362d31f9e75c Mon Sep 17 00:00:00 2001 From: Markus Osterlund Date: Wed, 17 Jun 2026 11:59:08 -0500 Subject: [PATCH 2/4] forge fmt --- scripts/deploy/SystemDeploy.s.sol | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index 7223b6fe..f9482f38 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -1094,9 +1094,8 @@ contract SystemDeploy is Script { ); vm.broadcast(msg.sender); - _output.disputeGameFactoryProxy.setImplementation( - gameType, IDisputeGame(address(output_.aggregateVerifier)) - ); + _output.disputeGameFactoryProxy + .setImplementation(gameType, IDisputeGame(address(output_.aggregateVerifier))); vm.label(address(output_.aggregateVerifier), "AggregateVerifier"); } From 9dd4d1cb1695db71d3f705ea44dddf8d8bba7bfd Mon Sep 17 00:00:00 2001 From: Markus Osterlund Date: Wed, 17 Jun 2026 12:11:28 -0500 Subject: [PATCH 3/4] feat: guard registerAggregateVerifier to dev-multiproof only (PRIV-1997) registerAggregateVerifier hardcodes the dev ZK sentinel (0xdead), so it must never run against a production (Base<>Ethereum) config. Require the configured input to be dev multiproof (no real nitroEnclaveVerifier) and run _assertValidMultiproofInput, which also blocks production chain IDs. Co-authored-by: Cursor --- scripts/deploy/SystemDeploy.s.sol | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index f9482f38..342bc666 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -243,6 +243,14 @@ contract SystemDeploy is Script { function registerAggregateVerifier(bytes32 _multiproofConfigHash) public { require(_multiproofConfigHash != bytes32(0), "SystemDeploy: multiproofConfigHash not set"); + // This entrypoint hardcodes the dev ZK sentinel (0xdead) and exists only for dev multiproof + // (devnet) deployments, where config_hash is unknown until after genesis. Reject any config + // that wires a real nitroEnclaveVerifier (production). _assertValidMultiproofInput also + // blocks production chain IDs and validates the multiproof parameters. + ImplementationInput memory implInput = _configuredImplementationsInput(); + require(_isDevMultiproof(implInput), "SystemDeploy: registerAggregateVerifier is dev-multiproof only"); + _assertValidMultiproofInput(implInput); + GameType gameType = GameType.wrap(uint32(cfg.multiproofGameType())); // zkVerifier is the dev sentinel (0xdead); this entrypoint is dev-multiproof only. From 65a997428730279a6dc6246c10483e8d0c023d51 Mon Sep 17 00:00:00 2001 From: Markus Osterlund Date: Wed, 17 Jun 2026 12:41:47 -0500 Subject: [PATCH 4/4] fix: reload deployment artifacts in re-entrant registerAggregateVerifier (PRIV-1997) registerAggregateVerifier runs in a fresh forge process (the deferred post-genesis one-shot), where Artifacts._namedDeployments is empty because setUp() only seeds predeploys and the map is otherwise populated by save() within the same process. As a result mustGetAddress() reverted, and even a successful save() would clobber the deploy outfile (whole-file writeJson with only the AggregateVerifier key). Add Artifacts.load() to read the existing outfile back into _namedDeployments and re-seed forge's stateful JSON object so a later save() appends instead of overwriting, and call it at the top of registerAggregateVerifier. Add a unit test covering the reload + append-without-clobber behavior. Co-authored-by: Cursor --- scripts/Artifacts.s.sol | 18 ++++++++++++ scripts/deploy/SystemDeploy.s.sol | 4 +++ test/scripts/Artifacts.t.sol | 48 +++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 test/scripts/Artifacts.t.sol diff --git a/scripts/Artifacts.s.sol b/scripts/Artifacts.s.sol index a84afb3b..24abd748 100644 --- a/scripts/Artifacts.s.sol +++ b/scripts/Artifacts.s.sol @@ -50,6 +50,24 @@ contract Artifacts { _predeploys[keccak256("EAS")] = payable(Predeploys.EAS); } + /// @notice Loads previously-saved deployments from the outfile back into memory. + /// @dev `setUp()` only seeds predeploys; named deployments are otherwise only populated by + /// `save()` within the same process. A re-entrant script run (e.g. + /// `registerAggregateVerifier` after L2 genesis) is a fresh process, so it must call + /// this to read addresses from an earlier deploy. Re-seeding forge's stateful JSON + /// object also ensures a later `save()` appends to — rather than clobbers — the file. + function load() public { + if (!vm.exists(deploymentOutfile)) return; + string memory json = vm.readFile(deploymentOutfile); + if (bytes(json).length == 0) return; + string[] memory keys = vm.parseJsonKeys(json, "$"); + for (uint256 i = 0; i < keys.length; i++) { + address payable addr = payable(vm.parseJsonAddress(json, string.concat(".", keys[i]))); + _namedDeployments[keys[i]] = addr; + stdJson.serialize("", keys[i], addr); + } + } + /// @notice Returns the address of a deployment. Also handles the predeploys. /// @param _name The name of the deployment. /// @return The address of the deployment. May be `address(0)` if the deployment does not diff --git a/scripts/deploy/SystemDeploy.s.sol b/scripts/deploy/SystemDeploy.s.sol index 342bc666..f440e67e 100644 --- a/scripts/deploy/SystemDeploy.s.sol +++ b/scripts/deploy/SystemDeploy.s.sol @@ -243,6 +243,10 @@ contract SystemDeploy is Script { function registerAggregateVerifier(bytes32 _multiproofConfigHash) public { require(_multiproofConfigHash != bytes32(0), "SystemDeploy: multiproofConfigHash not set"); + // Re-entrant run in a fresh process: reload addresses from the prior deploy's outfile so + // mustGetAddress resolves them and the subsequent save() appends instead of clobbering. + artifacts.load(); + // This entrypoint hardcodes the dev ZK sentinel (0xdead) and exists only for dev multiproof // (devnet) deployments, where config_hash is unknown until after genesis. Reject any config // that wires a real nitroEnclaveVerifier (production). _assertValidMultiproofInput also diff --git a/test/scripts/Artifacts.t.sol b/test/scripts/Artifacts.t.sol new file mode 100644 index 00000000..bca6e824 --- /dev/null +++ b/test/scripts/Artifacts.t.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import { Test } from "lib/forge-std/src/Test.sol"; +import { Artifacts } from "scripts/Artifacts.s.sol"; + +/// @title Artifacts_load_Test +/// @notice Covers the re-entrant (fresh-process) path: a script run such as +/// `registerAggregateVerifier` after L2 genesis must reload addresses written by an +/// earlier deploy, and a subsequent `save()` must append to rather than clobber the file. +contract Artifacts_load_Test is Test { + Artifacts internal artifacts; + string internal outfile; + + address internal constant FOO = address(0x1111111111111111111111111111111111111111); + address internal constant BAR = address(0x2222222222222222222222222222222222222222); + address internal constant BAZ = address(0x3333333333333333333333333333333333333333); + + function setUp() public { + outfile = string.concat(vm.projectRoot(), "/deployments/artifacts-load-test.json"); + try vm.removeFile(outfile) { } catch { } + vm.setEnv("DEPLOYMENT_OUTFILE", outfile); + artifacts = new Artifacts(); + artifacts.setUp(); + } + + /// @dev Without load(), getAddress only knows predeploys, so reading a prior deployment would + /// revert; and save() does a whole-file writeJson, so it must not clobber existing keys. + function test_load_reloadsThenAppends_succeeds() public { + // Simulate an earlier deploy process having written the outfile. + vm.writeFile(outfile, string.concat('{"Foo":"', vm.toString(FOO), '","Bar":"', vm.toString(BAR), '"}')); + + artifacts.load(); + + assertEq(artifacts.mustGetAddress("Foo"), payable(FOO)); + assertEq(artifacts.mustGetAddress("Bar"), payable(BAR)); + + artifacts.save("Baz", BAZ); + + string memory json = vm.readFile(outfile); + assertEq(vm.parseJsonAddress(json, ".Foo"), FOO); + assertEq(vm.parseJsonAddress(json, ".Bar"), BAR); + assertEq(vm.parseJsonAddress(json, ".Baz"), BAZ); + assertEq(vm.parseJsonKeys(json, "$").length, 3); + + vm.removeFile(outfile); + } +}