From c856c4f0999d28b2434a5d6a6957d3f9106fac33 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Thu, 24 Sep 2026 19:38:22 +0000 Subject: [PATCH 1/3] feat(l1): derive rollup version on-chain from chain id and address The rollup version was a constructor argument, computed by the deploy script as a hash of the config and genesis state. Two rollups deployed on the same chain with the same config therefore shared a version, and any deployer could pass an arbitrary one. RollupCore now computes it in the constructor as the first 4 bytes of keccak256(abi.encode("aztec_rollup_version", block.chainid, address(this))), and `version` is removed from RollupConfigInput. The version already feeds the tx context, Inbox/Outbox message scoping, the Registry key and the epoch proof public inputs, so each of those becomes deployment-specific. --- .../script/deploy/RollupConfiguration.sol | 19 -------- l1-contracts/src/core/RollupCore.sol | 14 ++++-- l1-contracts/src/core/interfaces/IRollup.sol | 1 - l1-contracts/src/governance/Registry.sol | 4 +- l1-contracts/test/RollupVersion.t.sol | 45 +++++++++++++++++++ l1-contracts/test/builder/RollupBuilder.sol | 1 - l1-contracts/test/harnesses/TestConstants.sol | 7 --- .../test/script/DeployRollupForUpgrade.t.sol | 3 -- .../staking/initiateWithdrawByAttester.t.sol | 5 +-- 9 files changed, 59 insertions(+), 40 deletions(-) create mode 100644 l1-contracts/test/RollupVersion.t.sol diff --git a/l1-contracts/script/deploy/RollupConfiguration.sol b/l1-contracts/script/deploy/RollupConfiguration.sol index 9d6f4cc38190..a2ffb3a02823 100644 --- a/l1-contracts/script/deploy/RollupConfiguration.sol +++ b/l1-contracts/script/deploy/RollupConfiguration.sol @@ -127,9 +127,6 @@ contract RollupConfiguration is IRollupConfiguration, Test { config.rewardBoostConfig = this.getRewardBoostConfiguration(); config.stakingQueueConfig = this.getStakingQueueConfiguration(); - // Compute version as first 4 bytes of hash(abi.encode(config, genesisState)) - config.version = _computeConfigVersion(config, this.getGenesisState()); - return config; } @@ -156,7 +153,6 @@ contract RollupConfiguration is IRollupConfiguration, Test { config.slashingDisableDuration = vm.envUint("AZTEC_SLASHING_DISABLE_DURATION"); config.manaTarget = vm.envUint("AZTEC_MANA_TARGET"); config.exitDelaySeconds = vm.envUint("AZTEC_EXIT_DELAY_SECONDS"); - config.version = 0; // Computed below config.provingCostPerMana = EthValue.wrap(vm.envUint("AZTEC_PROVING_COST_PER_MANA")); config.initialEthPerFeeAsset = EthPerFeeAssetE12.wrap(vm.envUint("AZTEC_INITIAL_ETH_PER_FEE_ASSET")); config.registryRewardOverrides[0] = _getRegistryRewardOverride("AZTEC_REGISTRY_REWARD_OVERRIDE_0"); @@ -179,21 +175,6 @@ contract RollupConfiguration is IRollupConfiguration, Test { registryRewardOverride.sequencerReward = vm.parseUint(fields[1]).toUint96(); } - /// @notice Compute rollup config version by hashing config + genesis state - /// @dev Version is the first 4 bytes (uint32) of keccak256(abi.encode(rollupConfig, genesisState)) - /// This DOES NOT match the TS implementation: keccak256(jsonStringify({rollupConfigArgs, genesisStateArgs})) - function _computeConfigVersion(RollupConfigInput memory _config, GenesisState memory _genesisState) - private - pure - returns (uint32) - { - bytes32 hash = keccak256(abi.encode(_config, _genesisState)); - // Extract first 4 bytes as uint32 (big-endian) - // Casting to bytes4 is intentional because the config version is defined as the first 4 bytes of the hash. - // forge-lint: disable-next-line(unsafe-typecast) - return uint32(bytes4(hash)); - } - function _getSlashingOffset() private view returns (uint256) { return vm.envUint("AZTEC_SLASHING_OFFSET_IN_ROUNDS"); } diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index c49b562f39be..93cc58ce503a 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -294,13 +294,21 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali // factored out into a helper the way the slasher and reward setup are. VK_TREE_ROOT = _genesisState.vkTreeRoot; PROTOCOL_CONTRACTS_HASH = _genesisState.protocolContractsHash; - VERSION = _config.version; + // The version identifies this rollup instance: it keys the Registry, scopes Inbox/Outbox messages, + // is part of every tx's signed context and is a public input of the epoch proof. It is derived from + // this contract's address so that two deployments never share it by accident and a deployer cannot + // set it directly. The hash is truncated to 32 bits, so a deployer willing to grind CREATE2 salts can + // still reach another rollup's version; it is a separator, not a collision-resistant identity. + // Casting to bytes4 is intentional because the version is defined as the first 4 bytes of the hash. + // forge-lint: disable-next-line(unsafe-typecast) + uint32 version = uint32(bytes4(keccak256(abi.encode(bytes("aztec_rollup_version"), block.chainid, address(this))))); + VERSION = version; FEE_ASSET = _feeAsset; EPOCH_PROOF_VERIFIER = _epochProofVerifier; - IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, _config.version, INBOX_BUCKET_RING_SIZE))); + IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, version, INBOX_BUCKET_RING_SIZE))); INBOX = inbox; - OUTBOX = IOutbox(address(new Outbox(address(this), _config.version))); + OUTBOX = IOutbox(address(new Outbox(address(this), version))); FEE_ASSET_PORTAL = IFeeJuicePortal(inbox.getFeeAssetPortal()); STFLib.initialize(_genesisState); diff --git a/l1-contracts/src/core/interfaces/IRollup.sol b/l1-contracts/src/core/interfaces/IRollup.sol index 1c5b7114d8d3..ca821656df2e 100644 --- a/l1-contracts/src/core/interfaces/IRollup.sol +++ b/l1-contracts/src/core/interfaces/IRollup.sol @@ -113,7 +113,6 @@ struct RollupConfigInput { uint256 slashingDisableDuration; uint256 manaTarget; uint256 exitDelaySeconds; - uint32 version; EthValue provingCostPerMana; EthPerFeeAssetE12 initialEthPerFeeAsset; RewardConfig rewardConfig; diff --git a/l1-contracts/src/governance/Registry.sol b/l1-contracts/src/governance/Registry.sol index cc14ac4e8965..2c60832e5fdf 100644 --- a/l1-contracts/src/governance/Registry.sol +++ b/l1-contracts/src/governance/Registry.sol @@ -11,8 +11,8 @@ import {RewardDistributor, IRewardDistributor} from "./RewardDistributor.sol"; struct RegistryStorage { /** * @notice Mapping from version to rollup instance - * @dev As implemented today, the version is a truncated hash of identifiers of the rollup instance - * See RollupCore.sol for the implementation. + * @dev The version is a truncated hash of the chain id and the rollup's address, computed by the rollup + * itself at construction. See RollupCore.sol for the implementation. * @dev updated when a new rollup instance is added, which becomes the new canonical rollup */ mapping(uint256 version => IHaveVersion rollup) versionToRollup; diff --git a/l1-contracts/test/RollupVersion.t.sol b/l1-contracts/test/RollupVersion.t.sol new file mode 100644 index 000000000000..8ea3081d7273 --- /dev/null +++ b/l1-contracts/test/RollupVersion.t.sol @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2024 Aztec Labs. +// solhint-disable func-name-mixedcase +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {RollupBuilder} from "./builder/RollupBuilder.sol"; +import {Rollup} from "@aztec/core/Rollup.sol"; +import {Inbox} from "@aztec/core/messagebridge/Inbox.sol"; +import {Outbox} from "@aztec/core/messagebridge/Outbox.sol"; +import {Registry} from "@aztec/governance/Registry.sol"; +import {IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol"; + +contract RollupVersionTest is Test { + function test_versionIsDerivedFromChainIdAndAddress() external { + RollupBuilder builder = new RollupBuilder(address(this)).deploy(); + Rollup rollup = Rollup(address(builder.getConfig().rollup)); + + uint256 expected = + uint32(bytes4(keccak256(abi.encode(bytes("aztec_rollup_version"), block.chainid, address(rollup))))); + assertEq(rollup.getVersion(), expected, "version"); + assertEq(Inbox(address(rollup.getInbox())).VERSION(), expected, "inbox version"); + assertEq(Outbox(address(rollup.getOutbox())).VERSION(), expected, "outbox version"); + } + + function test_identicallyConfiguredRollupsHaveDistinctVersions() external { + RollupBuilder first = new RollupBuilder(address(this)).deploy(); + Registry registry = first.getConfig().registry; + Rollup firstRollup = Rollup(address(first.getConfig().rollup)); + + // Same registry, GSE, assets, genesis state and rollup config as the first rollup. + RollupBuilder second = new RollupBuilder(address(this)).setGSE(first.getConfig().gse) + .setTestERC20(first.getConfig().testERC20).setRegistry(registry).setMakeCanonical(false).setMakeGovernance(false) + .setUpdateOwnerships(false).deploy(); + Rollup secondRollup = Rollup(address(second.getConfig().rollup)); + + assertNotEq(firstRollup.getVersion(), secondRollup.getVersion(), "versions collide"); + + // Both can be registered side by side, since the Registry is keyed by version. + vm.prank(registry.owner()); + registry.addRollup(IHaveVersion(address(secondRollup))); + assertEq(address(registry.getRollup(firstRollup.getVersion())), address(firstRollup), "first rollup"); + assertEq(address(registry.getRollup(secondRollup.getVersion())), address(secondRollup), "second rollup"); + } +} diff --git a/l1-contracts/test/builder/RollupBuilder.sol b/l1-contracts/test/builder/RollupBuilder.sol index 7ed6cfc15e17..92890810ee43 100644 --- a/l1-contracts/test/builder/RollupBuilder.sol +++ b/l1-contracts/test/builder/RollupBuilder.sol @@ -124,7 +124,6 @@ contract RollupBuilder is Test { target.slashingDisableDuration = _source.slashingDisableDuration; target.manaTarget = _source.manaTarget; target.exitDelaySeconds = _source.exitDelaySeconds; - target.version = _source.version; target.provingCostPerMana = _source.provingCostPerMana; target.initialEthPerFeeAsset = _source.initialEthPerFeeAsset; target.rewardConfig = _source.rewardConfig; diff --git a/l1-contracts/test/harnesses/TestConstants.sol b/l1-contracts/test/harnesses/TestConstants.sol index 881dfa54743f..059598566752 100644 --- a/l1-contracts/test/harnesses/TestConstants.sol +++ b/l1-contracts/test/harnesses/TestConstants.sol @@ -125,7 +125,6 @@ library TestConstants { config.exitDelaySeconds = AZTEC_EXIT_DELAY_SECONDS; config.provingCostPerMana = AZTEC_PROVING_COST_PER_MANA; config.initialEthPerFeeAsset = AZTEC_INITIAL_ETH_PER_FEE_ASSET; - config.version = 0; config.rewardConfig = rewardConfig; config.rewardBoostConfig = rewardBoostConfig; config.stakingQueueConfig = stakingQueueConfig; @@ -134,12 +133,6 @@ library TestConstants { config.localEjectionThreshold = 0; config.ethereumSlotDuration = ETHEREUM_SLOT_DURATION; - // For the version we derive it based on the config (with a 0 version) - // TODO(https://linear.app/aztec-labs/issue/TMNT-139/version-at-deployment) - uint32 version = - uint32(uint256(keccak256(abi.encode(bytes("aztec_rollup"), block.chainid, getGenesisState(), config)))); - config.version = version; - return config; } } diff --git a/l1-contracts/test/script/DeployRollupForUpgrade.t.sol b/l1-contracts/test/script/DeployRollupForUpgrade.t.sol index 5d17d25e8293..9ec8c248e53e 100644 --- a/l1-contracts/test/script/DeployRollupForUpgrade.t.sol +++ b/l1-contracts/test/script/DeployRollupForUpgrade.t.sol @@ -117,9 +117,6 @@ contract DeployRollupForUpgradeTest is Test { // ============ STEP 2: Deploy Rollup Upgrade ============ vm.setEnv("REGISTRY_ADDRESS", vm.toString(address(registry))); - // Set a different genesis archive root to get a different version - // This mirrors the TS test: genesisArchiveRoot: Fr.random() - vm.setEnv("GENESIS_ARCHIVE_ROOT", vm.toString(uint256(keccak256("different_genesis")))); DeployRollupForUpgrade upgradeDeploy = new DeployRollupForUpgrade(); upgradeDeploy.run(); diff --git a/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol b/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol index ec40bafad4c5..fa7d46a9a60c 100644 --- a/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol +++ b/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol @@ -3,7 +3,6 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {RollupBuilder} from "../builder/RollupBuilder.sol"; -import {RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; import {IStaking, IStakingCore, Exit, Status, AttesterExitLimitState} from "@aztec/core/interfaces/IStaking.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; @@ -96,9 +95,7 @@ contract InitiateWithdrawByAttesterTest is StakingBase { function _activateNewRollup() internal returns (IStaking nextRollup) { RollupBuilder builder = new RollupBuilder(address(this)).setGSE(gse).setTestERC20(stakingAsset) .setRegistry(registry).setMakeCanonical(false).setMakeGovernance(false).setUpdateOwnerships(false); - RollupConfigInput memory config = builder.getConfig().rollupConfigInput; - config.version = uint32(IHaveVersion(address(staking)).getVersion() + 1); - builder.setRollupConfigInput(config).deploy(); + builder.deploy(); nextRollup = IStaking(address(builder.getConfig().rollup)); AttesterExitLimitState memory initialState = nextRollup.getAttesterExitLimitState(); From 464f4ec8abefaa924f720936f3308176896b4ba9 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Thu, 24 Sep 2026 20:22:27 +0000 Subject: [PATCH 2/3] fix(l1): keep config and genesis state in the rollup version preimage The version now hashes the chain id, the rollup address, the config and the genesis state, computed at the top of the constructor so it covers the config as supplied. --- l1-contracts/src/core/RollupCore.sol | 20 +++++++++----------- l1-contracts/src/governance/Registry.sol | 4 ++-- l1-contracts/test/RollupVersion.t.sol | 13 ++++++++++--- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/l1-contracts/src/core/RollupCore.sol b/l1-contracts/src/core/RollupCore.sol index 93cc58ce503a..6fd823d7cdaf 100644 --- a/l1-contracts/src/core/RollupCore.sol +++ b/l1-contracts/src/core/RollupCore.sol @@ -242,6 +242,13 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali GenesisState memory _genesisState, RollupConfigInput memory _config ) Ownable(_governance) { + // Hashed before anything below can modify `_config` in memory. Including this contract's address means the + // deployer cannot choose the version and two deployments do not share one. The 32-bit truncation is still + // reachable by grinding CREATE2 salts, so it separates deployments rather than identifying them. + // Casting to bytes4 is intentional because the version is defined as the first 4 bytes of the hash. + // forge-lint: disable-next-line(unsafe-typecast) + VERSION = uint32(bytes4(keccak256(abi.encode(block.chainid, address(this), _config, _genesisState)))); + StakingLib.assertValidQueueConfig(_config.stakingQueueConfig); // queueSetSlasher schedules the replacement slasher to land at `block.timestamp + @@ -294,21 +301,12 @@ contract RollupCore is EIP712("Aztec Rollup", "1"), Ownable, IStakingCore, IVali // factored out into a helper the way the slasher and reward setup are. VK_TREE_ROOT = _genesisState.vkTreeRoot; PROTOCOL_CONTRACTS_HASH = _genesisState.protocolContractsHash; - // The version identifies this rollup instance: it keys the Registry, scopes Inbox/Outbox messages, - // is part of every tx's signed context and is a public input of the epoch proof. It is derived from - // this contract's address so that two deployments never share it by accident and a deployer cannot - // set it directly. The hash is truncated to 32 bits, so a deployer willing to grind CREATE2 salts can - // still reach another rollup's version; it is a separator, not a collision-resistant identity. - // Casting to bytes4 is intentional because the version is defined as the first 4 bytes of the hash. - // forge-lint: disable-next-line(unsafe-typecast) - uint32 version = uint32(bytes4(keccak256(abi.encode(bytes("aztec_rollup_version"), block.chainid, address(this))))); - VERSION = version; FEE_ASSET = _feeAsset; EPOCH_PROOF_VERIFIER = _epochProofVerifier; - IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, version, INBOX_BUCKET_RING_SIZE))); + IInbox inbox = IInbox(address(new Inbox(address(this), _feeAsset, VERSION, INBOX_BUCKET_RING_SIZE))); INBOX = inbox; - OUTBOX = IOutbox(address(new Outbox(address(this), version))); + OUTBOX = IOutbox(address(new Outbox(address(this), VERSION))); FEE_ASSET_PORTAL = IFeeJuicePortal(inbox.getFeeAssetPortal()); STFLib.initialize(_genesisState); diff --git a/l1-contracts/src/governance/Registry.sol b/l1-contracts/src/governance/Registry.sol index 2c60832e5fdf..90b6bc1577c7 100644 --- a/l1-contracts/src/governance/Registry.sol +++ b/l1-contracts/src/governance/Registry.sol @@ -11,8 +11,8 @@ import {RewardDistributor, IRewardDistributor} from "./RewardDistributor.sol"; struct RegistryStorage { /** * @notice Mapping from version to rollup instance - * @dev The version is a truncated hash of the chain id and the rollup's address, computed by the rollup - * itself at construction. See RollupCore.sol for the implementation. + * @dev The version is a truncated hash of the chain id, the rollup's address, its configuration and its + * genesis state, computed by the rollup itself at construction. See RollupCore.sol for the implementation. * @dev updated when a new rollup instance is added, which becomes the new canonical rollup */ mapping(uint256 version => IHaveVersion rollup) versionToRollup; diff --git a/l1-contracts/test/RollupVersion.t.sol b/l1-contracts/test/RollupVersion.t.sol index 8ea3081d7273..3adf4dd953bb 100644 --- a/l1-contracts/test/RollupVersion.t.sol +++ b/l1-contracts/test/RollupVersion.t.sol @@ -12,12 +12,19 @@ import {Registry} from "@aztec/governance/Registry.sol"; import {IHaveVersion} from "@aztec/governance/interfaces/IRegistry.sol"; contract RollupVersionTest is Test { - function test_versionIsDerivedFromChainIdAndAddress() external { + function test_versionIsDerivedFromChainIdAddressConfigAndGenesis() external { RollupBuilder builder = new RollupBuilder(address(this)).deploy(); Rollup rollup = Rollup(address(builder.getConfig().rollup)); - uint256 expected = - uint32(bytes4(keccak256(abi.encode(bytes("aztec_rollup_version"), block.chainid, address(rollup))))); + uint256 expected = uint32( + bytes4( + keccak256( + abi.encode( + block.chainid, address(rollup), builder.getConfig().rollupConfigInput, builder.getConfig().genesisState + ) + ) + ) + ); assertEq(rollup.getVersion(), expected, "version"); assertEq(Inbox(address(rollup.getInbox())).VERSION(), expected, "inbox version"); assertEq(Outbox(address(rollup.getOutbox())).VERSION(), expected, "outbox version"); From 07f7fd339d54c288297426f6363972fafa0013bf Mon Sep 17 00:00:00 2001 From: AztecBot Date: Thu, 24 Sep 2026 20:41:56 +0000 Subject: [PATCH 3/3] test(l1): keep version-unrelated test setup as on next --- l1-contracts/test/script/DeployRollupForUpgrade.t.sol | 3 +++ l1-contracts/test/staking/initiateWithdrawByAttester.t.sol | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/l1-contracts/test/script/DeployRollupForUpgrade.t.sol b/l1-contracts/test/script/DeployRollupForUpgrade.t.sol index 9ec8c248e53e..5d17d25e8293 100644 --- a/l1-contracts/test/script/DeployRollupForUpgrade.t.sol +++ b/l1-contracts/test/script/DeployRollupForUpgrade.t.sol @@ -117,6 +117,9 @@ contract DeployRollupForUpgradeTest is Test { // ============ STEP 2: Deploy Rollup Upgrade ============ vm.setEnv("REGISTRY_ADDRESS", vm.toString(address(registry))); + // Set a different genesis archive root to get a different version + // This mirrors the TS test: genesisArchiveRoot: Fr.random() + vm.setEnv("GENESIS_ARCHIVE_ROOT", vm.toString(uint256(keccak256("different_genesis")))); DeployRollupForUpgrade upgradeDeploy = new DeployRollupForUpgrade(); upgradeDeploy.run(); diff --git a/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol b/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol index fa7d46a9a60c..8b6ad55cc2a6 100644 --- a/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol +++ b/l1-contracts/test/staking/initiateWithdrawByAttester.t.sol @@ -3,6 +3,7 @@ pragma solidity >=0.8.27; import {StakingBase} from "./base.t.sol"; import {RollupBuilder} from "../builder/RollupBuilder.sol"; +import {RollupConfigInput} from "@aztec/core/interfaces/IRollup.sol"; import {IStaking, IStakingCore, Exit, Status, AttesterExitLimitState} from "@aztec/core/interfaces/IStaking.sol"; import {Errors} from "@aztec/core/libraries/Errors.sol"; import {StakingQueueConfig} from "@aztec/core/libraries/compressed-data/StakingQueueConfig.sol"; @@ -95,7 +96,8 @@ contract InitiateWithdrawByAttesterTest is StakingBase { function _activateNewRollup() internal returns (IStaking nextRollup) { RollupBuilder builder = new RollupBuilder(address(this)).setGSE(gse).setTestERC20(stakingAsset) .setRegistry(registry).setMakeCanonical(false).setMakeGovernance(false).setUpdateOwnerships(false); - builder.deploy(); + RollupConfigInput memory config = builder.getConfig().rollupConfigInput; + builder.setRollupConfigInput(config).deploy(); nextRollup = IStaking(address(builder.getConfig().rollup)); AttesterExitLimitState memory initialState = nextRollup.getAttesterExitLimitState();