diff --git a/l1-contracts/src/periphery/DateGatedRelayer.sol b/l1-contracts/src/periphery/DateGatedRelayer.sol new file mode 100644 index 000000000000..6569824b2350 --- /dev/null +++ b/l1-contracts/src/periphery/DateGatedRelayer.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Aztec Labs. +pragma solidity >=0.8.27; + +import {Ownable} from "@oz/access/Ownable.sol"; +import {Address} from "@oz/utils/Address.sol"; +import {IDateGatedRelayer} from "./interfaces/IDateGatedRelayer.sol"; + +contract DateGatedRelayer is Ownable, IDateGatedRelayer { + uint256 public immutable GATED_UNTIL; + + error GateIsClosed(); + + constructor(address owner, uint256 _gatedUntil) Ownable(owner) { + GATED_UNTIL = _gatedUntil; + } + + function relay(address target, bytes calldata data) + external + override(IDateGatedRelayer) + onlyOwner + returns (bytes memory) + { + require(block.timestamp >= GATED_UNTIL, GateIsClosed()); + return Address.functionCall(target, data); + } +} diff --git a/l1-contracts/src/periphery/interfaces/IDateGatedRelayer.sol b/l1-contracts/src/periphery/interfaces/IDateGatedRelayer.sol new file mode 100644 index 000000000000..27aba13fe98a --- /dev/null +++ b/l1-contracts/src/periphery/interfaces/IDateGatedRelayer.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2025 Aztec Labs. +pragma solidity >=0.8.27; + +interface IDateGatedRelayer { + function relay(address target, bytes calldata data) external returns (bytes memory); +} diff --git a/l1-contracts/test/DateGatedRelayer.t.sol b/l1-contracts/test/DateGatedRelayer.t.sol new file mode 100644 index 000000000000..5aa179ac9a98 --- /dev/null +++ b/l1-contracts/test/DateGatedRelayer.t.sol @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity >=0.8.27; + +import {Test} from "forge-std/Test.sol"; +import {DateGatedRelayer} from "../src/periphery/DateGatedRelayer.sol"; +import {Ownable} from "@oz/access/Ownable.sol"; +import {TestERC20} from "@aztec/mock/TestERC20.sol"; +import {CoinIssuer} from "@aztec/governance/CoinIssuer.sol"; + +contract DateGatedRelayerTest is Test { + function test_notOwner(address _owner, address _caller) public { + vm.assume(_owner != address(0)); + vm.assume(_caller != address(0)); + vm.assume(_caller != _owner); + + DateGatedRelayer dateGatedRelayer = new DateGatedRelayer(_owner, block.timestamp + 100); + + vm.prank(_caller); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, _caller)); + dateGatedRelayer.relay(address(0), ""); + } + + function test_GateIsClosed(uint256 _gatedUntil, uint256 _warp) public { + uint256 gatedUntil = bound(_gatedUntil, block.timestamp + 1, type(uint32).max); + DateGatedRelayer dateGatedRelayer = new DateGatedRelayer(address(this), gatedUntil); + + uint256 warp = bound(_warp, 0, gatedUntil - 1); + + vm.warp(warp); + vm.expectRevert(abi.encodeWithSelector(DateGatedRelayer.GateIsClosed.selector)); + dateGatedRelayer.relay(address(0), ""); + } + + function test_GateIsOpen(uint256 _gatedUntil, uint256 _warp) public { + uint256 gatedUntil = bound(_gatedUntil, block.timestamp + 1, type(uint32).max); + + TestERC20 testERC20 = new TestERC20("test", "TEST", address(this)); + CoinIssuer coinIssuer = new CoinIssuer(testERC20, 100, address(this)); + testERC20.transferOwnership(address(coinIssuer)); + coinIssuer.acceptTokenOwnership(); + + DateGatedRelayer dateGatedRelayer = new DateGatedRelayer(address(this), gatedUntil); + coinIssuer.transferOwnership(address(dateGatedRelayer)); + + uint256 warp = bound(_warp, gatedUntil, type(uint32).max); + + vm.expectRevert(); + coinIssuer.mint(address(this), 100); + + vm.warp(warp); + dateGatedRelayer.relay(address(coinIssuer), abi.encodeWithSelector(CoinIssuer.mint.selector, address(this), 100)); + + assertEq(testERC20.balanceOf(address(this)), 100); + } +} diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks_proof.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks_proof.test.ts new file mode 100644 index 000000000000..e7366a302f9d --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks_proof.test.ts @@ -0,0 +1,45 @@ +import { type Logger, sleep } from '@aztec/aztec.js'; +import { RollupContract } from '@aztec/ethereum/contracts'; +import { ChainMonitor } from '@aztec/ethereum/test'; + +import { jest } from '@jest/globals'; + +import type { EndToEndContext } from '../fixtures/utils.js'; +import { EpochsTestContext } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 15); + +describe('e2e_epochs/epochs_empty_blocks_proof', () => { + let context: EndToEndContext; + let rollup: RollupContract; + let logger: Logger; + let monitor: ChainMonitor; + + let L1_BLOCK_TIME_IN_S: number; + + let test: EpochsTestContext; + + beforeEach(async () => { + test = await EpochsTestContext.setup(); + ({ context, rollup, logger, monitor, L1_BLOCK_TIME_IN_S } = test); + }); + + afterEach(async () => { + jest.restoreAllMocks(); + await test.teardown(); + }); + + it('submits proof even if there are no txs to build a block', async () => { + context.sequencer?.updateConfig({ minTxsPerBlock: 1 }); + await test.waitUntilEpochStarts(1); + + // Sleep to make sure any pending blocks are published + await sleep(L1_BLOCK_TIME_IN_S * 1000); + const blockNumberAtEndOfEpoch0 = Number(await rollup.getBlockNumber()); + logger.info(`Starting epoch 1 after L2 block ${blockNumberAtEndOfEpoch0}`); + + await test.waitUntilProvenL2BlockNumber(blockNumberAtEndOfEpoch0, 240); + expect(monitor.l2BlockNumber).toEqual(blockNumberAtEndOfEpoch0); + logger.info(`Test succeeded`); + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_multiple.test.ts similarity index 73% rename from yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks.test.ts rename to yarn-project/end-to-end/src/e2e_epochs/epochs_multiple.test.ts index 252614c10b3c..a3d2ba12a150 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_empty_blocks.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_multiple.test.ts @@ -1,6 +1,5 @@ -import { type Logger, sleep } from '@aztec/aztec.js'; +import type { Logger } from '@aztec/aztec.js'; import { RollupContract } from '@aztec/ethereum/contracts'; -import { ChainMonitor } from '@aztec/ethereum/test'; import { jest } from '@jest/globals'; @@ -9,19 +8,16 @@ import { EpochsTestContext, WORLD_STATE_BLOCK_HISTORY } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 15); -describe('e2e_epochs/epochs_empty_blocks', () => { +describe('e2e_epochs/epochs_multiple', () => { let context: EndToEndContext; let rollup: RollupContract; let logger: Logger; - let monitor: ChainMonitor; - - let L1_BLOCK_TIME_IN_S: number; let test: EpochsTestContext; beforeEach(async () => { test = await EpochsTestContext.setup(); - ({ context, rollup, logger, monitor, L1_BLOCK_TIME_IN_S } = test); + ({ context, rollup, logger } = test); }); afterEach(async () => { @@ -29,26 +25,13 @@ describe('e2e_epochs/epochs_empty_blocks', () => { await test.teardown(); }); - it('submits proof even if there are no txs to build a block', async () => { - context.sequencer?.updateConfig({ minTxsPerBlock: 1 }); - await test.waitUntilEpochStarts(1); - - // Sleep to make sure any pending blocks are published - await sleep(L1_BLOCK_TIME_IN_S * 1000); - const blockNumberAtEndOfEpoch0 = Number(await rollup.getBlockNumber()); - logger.info(`Starting epoch 1 after L2 block ${blockNumberAtEndOfEpoch0}`); - - await test.waitUntilProvenL2BlockNumber(blockNumberAtEndOfEpoch0, 240); - expect(monitor.l2BlockNumber).toEqual(blockNumberAtEndOfEpoch0); - logger.info(`Test succeeded`); - }); - it('successfully proves multiple epochs', async () => { const targetProvenEpochs = process.env.TARGET_PROVEN_EPOCHS ? parseInt(process.env.TARGET_PROVEN_EPOCHS) : 3; const targetProvenBlockNumber = targetProvenEpochs * test.epochDuration; let provenBlockNumber = 0; let epochNumber = 0; + logger.info(`Waiting for ${targetProvenEpochs} epochs to be proven at ${targetProvenBlockNumber} L2 blocks`); while (provenBlockNumber < targetProvenBlockNumber) { logger.info(`Waiting for the end of epoch ${epochNumber}`); await test.waitUntilEpochStarts(epochNumber + 1); @@ -71,7 +54,7 @@ describe('e2e_epochs/epochs_empty_blocks', () => { await test.waitForNodeToSync(provenBlockNumber, 'proven'); await test.verifyHistoricBlock(provenBlockNumber, true); - // right now finalisation means a block is two L2 epochs deep. If this rule changes then we need this test needs to be updated + // right now finalization means a block is two L2 epochs deep. If this rule changes then we need this test needs to be updated const finalizedBlockNumber = Math.max(provenBlockNumber - context.config.aztecEpochDuration * 2, 0); const expectedOldestHistoricBlock = Math.max(finalizedBlockNumber - WORLD_STATE_BLOCK_HISTORY + 1, 1); const expectedBlockRemoved = expectedOldestHistoricBlock - 1; diff --git a/yarn-project/ethereum/src/contracts/registry.test.ts b/yarn-project/ethereum/src/contracts/registry.test.ts index 9a16f00a5a53..53136c5f1a3a 100644 --- a/yarn-project/ethereum/src/contracts/registry.test.ts +++ b/yarn-project/ethereum/src/contracts/registry.test.ts @@ -63,6 +63,7 @@ describe('Registry', () => { 'feeAssetHandlerAddress', 'stakingAssetHandlerAddress', 'zkPassportVerifierAddress', + 'dateGatedRelayerAddress', ); registry = new RegistryContract(l1Client, deployedAddresses.registryAddress); diff --git a/yarn-project/ethereum/src/deploy_l1_contracts.test.ts b/yarn-project/ethereum/src/deploy_l1_contracts.test.ts index ba94d0b1b9a3..2437ad532ff9 100644 --- a/yarn-project/ethereum/src/deploy_l1_contracts.test.ts +++ b/yarn-project/ethereum/src/deploy_l1_contracts.test.ts @@ -300,6 +300,7 @@ describe('deploy_l1_contracts', () => { const registry = new RegistryContract(client, deployment.l1ContractAddresses.registryAddress); const rollup = new RollupContract(client, deployment.l1ContractAddresses.rollupAddress); const gse = new GSEContract(client, await rollup.getGSE()); + const dateGatedRelayerAddress = deployment.l1ContractAddresses.dateGatedRelayerAddress!; // Checking the shared expect(await registry.getOwner()).toEqual(governance.address); @@ -308,7 +309,10 @@ describe('deploy_l1_contracts', () => { expect(await getOwner(deployment.l1ContractAddresses.rewardDistributorAddress, 'REGISTRY')).toEqual( registry.address, ); - expect(await getOwner(deployment.l1ContractAddresses.coinIssuerAddress)).toEqual(governance.address); + + // The coin issuer should be owned by governance, but indirectly through the date gated relayer + expect(await getOwner(deployment.l1ContractAddresses.coinIssuerAddress)).toEqual(dateGatedRelayerAddress); + expect(await getOwner(dateGatedRelayerAddress)).toEqual(governance.address); expect(await getOwner(deployment.l1ContractAddresses.feeJuiceAddress)).toEqual( deployment.l1ContractAddresses.coinIssuerAddress, diff --git a/yarn-project/ethereum/src/deploy_l1_contracts.ts b/yarn-project/ethereum/src/deploy_l1_contracts.ts index fe678a6e1cb9..75785965b526 100644 --- a/yarn-project/ethereum/src/deploy_l1_contracts.ts +++ b/yarn-project/ethereum/src/deploy_l1_contracts.ts @@ -45,6 +45,7 @@ import { RegistryContract } from './contracts/registry.js'; import { RollupContract, SlashingProposerType } from './contracts/rollup.js'; import { CoinIssuerArtifact, + DateGatedRelayerArtifact, FeeAssetArtifact, FeeAssetHandlerArtifact, GSEArtifact, @@ -935,21 +936,24 @@ export const handoverToGovernance = async ( logger.verbose('Skipping fee asset ownership transfer due to external token usage'); } + // Either deploy or at least predict the address of the date gated relayer + const dateGatedRelayer = await deployer.deploy(DateGatedRelayerArtifact, [ + governanceAddress.toString(), + 1798761600n, // 2027-01-01 00:00:00 UTC + ]); + // If the owner is not the Governance contract, transfer ownership to the Governance contract - if ( - acceleratedTestDeployments || - (await coinIssuerContract.read.owner()) !== getAddress(governanceAddress.toString()) - ) { + if (acceleratedTestDeployments || (await coinIssuerContract.read.owner()) === deployer.client.account.address) { const { txHash: transferOwnershipTxHash } = await deployer.sendTransaction({ to: coinIssuerContract.address, data: encodeFunctionData({ abi: CoinIssuerArtifact.contractAbi, functionName: 'transferOwnership', - args: [getAddress(governanceAddress.toString())], + args: [getAddress(dateGatedRelayer.address.toString())], }), }); logger.verbose( - `Transferring the ownership of the coin issuer contract at ${coinIssuerAddress} to the Governance ${governanceAddress} in tx ${transferOwnershipTxHash}`, + `Transferring the ownership of the coin issuer contract at ${coinIssuerAddress} to the DateGatedRelayer ${dateGatedRelayer.address} in tx ${transferOwnershipTxHash}`, ); txHashes.push(transferOwnershipTxHash); } @@ -957,6 +961,8 @@ export const handoverToGovernance = async ( // Wait for all actions to be mined await deployer.waitForDeployments(); await Promise.all(txHashes.map(txHash => extendedClient.waitForTransactionReceipt({ hash: txHash }))); + + return { dateGatedRelayerAddress: dateGatedRelayer.address }; }; /* @@ -1249,7 +1255,7 @@ export const deployL1Contracts = async ( await deployer.waitForDeployments(); // Now that the rollup has been deployed and added to the registry, transfer ownership to governance - await handoverToGovernance( + const { dateGatedRelayerAddress } = await handoverToGovernance( l1Client, deployer, registryAddress, @@ -1444,6 +1450,7 @@ export const deployL1Contracts = async ( stakingAssetHandlerAddress, zkPassportVerifierAddress, coinIssuerAddress, + dateGatedRelayerAddress, }, }; }; diff --git a/yarn-project/ethereum/src/l1_artifacts.ts b/yarn-project/ethereum/src/l1_artifacts.ts index 33dbac6861f1..5c91a155dfea 100644 --- a/yarn-project/ethereum/src/l1_artifacts.ts +++ b/yarn-project/ethereum/src/l1_artifacts.ts @@ -1,6 +1,8 @@ import { CoinIssuerAbi, CoinIssuerBytecode, + DateGatedRelayerAbi, + DateGatedRelayerBytecode, EmpireSlasherDeploymentExtLibAbi, EmpireSlasherDeploymentExtLibBytecode, EmpireSlashingProposerAbi, @@ -149,6 +151,12 @@ export const CoinIssuerArtifact = { contractBytecode: CoinIssuerBytecode as Hex, }; +export const DateGatedRelayerArtifact = { + name: 'DateGatedRelayer', + contractAbi: DateGatedRelayerAbi, + contractBytecode: DateGatedRelayerBytecode as Hex, +}; + export const GovernanceProposerArtifact = { name: 'GovernanceProposer', contractAbi: GovernanceProposerAbi, diff --git a/yarn-project/ethereum/src/l1_contract_addresses.ts b/yarn-project/ethereum/src/l1_contract_addresses.ts index 87a8c440c06c..c9f21956b718 100644 --- a/yarn-project/ethereum/src/l1_contract_addresses.ts +++ b/yarn-project/ethereum/src/l1_contract_addresses.ts @@ -32,6 +32,7 @@ export type L1ContractAddresses = { stakingAssetHandlerAddress?: EthAddress | undefined; zkPassportVerifierAddress?: EthAddress | undefined; gseAddress?: EthAddress | undefined; + dateGatedRelayerAddress?: EthAddress | undefined; }; export const L1ContractAddressesSchema = z.object({ @@ -51,12 +52,13 @@ export const L1ContractAddressesSchema = z.object({ stakingAssetHandlerAddress: schemas.EthAddress.optional(), zkPassportVerifierAddress: schemas.EthAddress.optional(), gseAddress: schemas.EthAddress.optional(), + dateGatedRelayerAddress: schemas.EthAddress.optional(), }) satisfies ZodFor; const parseEnv = (val: string) => EthAddress.fromString(val); export const l1ContractAddressesMapping: ConfigMappingsType< - Omit + Omit > = { registryAddress: { env: 'REGISTRY_CONTRACT_ADDRESS', diff --git a/yarn-project/l1-artifacts/scripts/generate-artifacts.sh b/yarn-project/l1-artifacts/scripts/generate-artifacts.sh index c0d461040c70..4cb81f474765 100755 --- a/yarn-project/l1-artifacts/scripts/generate-artifacts.sh +++ b/yarn-project/l1-artifacts/scripts/generate-artifacts.sh @@ -45,6 +45,7 @@ contracts=( "MultiAdder" "GSE" "MockZKPassportVerifier" + "DateGatedRelayer" ) # Combine error ABIs once, removing duplicates by {type, name}.