From e57daadc9eeb90c0146502c01ec7c0cfc0ca84dc Mon Sep 17 00:00:00 2001 From: Charlie <5764343+charlielye@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:36:30 +0000 Subject: [PATCH] test(labs): isolate a test by naming it *.isolate.test.ts A test that binds a port has to run in its own container, and which tests those were was a list of package prefixes in labs' test_cmds. A list only works if whoever adds a test in a new package knows to extend it, and an unisolated test that spawns anvil passes until the day it races another one. Two were already in that position. cli/src/cmds/l1/attester_exit, added this month, fails with "Address already in use (os error 98)" before reaching an assertion when it meets another anvil in the same window. It failed the x-full run on #25362, a bb and bb.js migration it has nothing to do with; the two anvils it lost to were ethereum/blob_kzg_warmup, which is isolated because ethereum is listed, and epoch-cache/epoch_cache.integration, which is not listed and has been unisolated since April. The suffix says it where someone writes the test rather than somewhere else that has to be remembered. The package list stays, so nothing loses the isolation it has today. Rides as a labs patch; upstream to aztec-node separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NuUzj3qpkpJor6GMpWB4T4 --- ...a-test-by-naming-it-.isolate.test.ts.patch | 471 ++++++++++++++++++ 1 file changed, 471 insertions(+) create mode 100644 labs-patches/0009-test-isolate-a-test-by-naming-it-.isolate.test.ts.patch diff --git a/labs-patches/0009-test-isolate-a-test-by-naming-it-.isolate.test.ts.patch b/labs-patches/0009-test-isolate-a-test-by-naming-it-.isolate.test.ts.patch new file mode 100644 index 00000000000..467c23616b3 --- /dev/null +++ b/labs-patches/0009-test-isolate-a-test-by-naming-it-.isolate.test.ts.patch @@ -0,0 +1,471 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Charlie <5764343+charlielye@users.noreply.github.com> +Date: Wed, 30 Sep 2026 09:35:47 +0000 +Subject: [PATCH] test: isolate a test by naming it *.isolate.test.ts + +A test that binds a port has to run in its own container, and which tests those are was +a list of package prefixes in test_cmds. A list only works if whoever adds a test in a +new package knows to extend it, and an unisolated test that spawns anvil passes until +the day it races another one. + +Two were already in that position. cli/src/cmds/l1/attester_exit, added this month, +fails intermittently with "Address already in use" before reaching an assertion, when +it meets another anvil in the same window; it was found failing a bb PR it has nothing +to do with. epoch-cache/src/epoch_cache.integration has been unisolated since April +and has only been luckier. + +The suffix says it at the point someone writes the test, rather than somewhere else +that has to be remembered. The package list stays, so nothing loses the isolation it +has today; prefer the suffix for anything new, and an entry can leave the list by +renaming its tests. + +Verified: the condition isolates both renamed tests, leaves ordinary ones shared, and +keeps every package currently listed; the glob still enumerates both, 649 tests in all. + +diff --git a/yarn-project/bootstrap.sh b/yarn-project/bootstrap.sh +index 35f7cf288f5d070fc6ce6cd9e5fd00e874a89d3d..a523ca15d2ff59bd00b0a6b15c6c511b1daf3684 100755 +--- a/yarn-project/bootstrap.sh ++++ b/yarn-project/bootstrap.sh +@@ -392,8 +392,16 @@ function test_cmds { + local prefix=$(dep_hash "$test") + local cmd_env="" + +- # These need isolation due to network stack usage (p2p, anvil, etc). +- if [[ "$test" =~ ^(prover-node|p2p|ethereum|aztec|prover-client/src/test|stdlib/src/l1-contracts|ivc-integration/src/chonk_browser|blob-client/src/server) ]]; then ++ # A test that uses the network stack — anvil, a p2p listener, anything binding a port — must run ++ # in its own container, because these fan out onto one host and a fixed port is a fixed port. ++ # ++ # Name it *.isolate.test.ts and it gets that, wherever it lives. The package list below predates ++ # the suffix and is kept so existing tests keep their isolation; prefer the suffix for anything ++ # new, and migrate an entry out of the list by renaming its tests. A list has to be remembered by ++ # whoever adds a test in a new package, and a test that spawns anvil from a package nobody ++ # thought to list will pass until the day it races something. ++ if [[ "$test" =~ \.isolate\.test\.ts$ ]] || ++ [[ "$test" =~ ^(prover-node|p2p|ethereum|aztec|prover-client/src/test|stdlib/src/l1-contracts|ivc-integration/src/chonk_browser|blob-client/src/server) ]]; then + prefix+=":ISOLATE=1:NAME=$test" + fi + +diff --git a/yarn-project/cli/src/cmds/l1/attester_exit.isolate.test.ts b/yarn-project/cli/src/cmds/l1/attester_exit.isolate.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..d3181a049e98b29e66a1857dc6ab9f46ca3e3cf9 +--- /dev/null ++++ b/yarn-project/cli/src/cmds/l1/attester_exit.isolate.test.ts +@@ -0,0 +1,207 @@ ++import { GovernanceAbi, RollupAbi, TestERC20Abi } from '@aztec-foundation/l1-artifacts'; ++ ++import { createExtendedL1Client } from '@aztec-labs/ethereum/client'; ++import { DefaultL1ContractsConfig } from '@aztec-labs/ethereum/config'; ++import { AttesterStatus, RollupContract } from '@aztec-labs/ethereum/contracts'; ++import { deployAztecL1Contracts } from '@aztec-labs/ethereum/deploy-aztec-l1-contracts'; ++import { type Anvil, EthCheatCodes, startAnvil } from '@aztec-labs/ethereum/test'; ++import { SecretValue } from '@aztec-labs/foundation/config'; ++import { Fr } from '@aztec-labs/foundation/curves/bn254'; ++import { EthAddress } from '@aztec-labs/foundation/eth-address'; ++import { Signature } from '@aztec-labs/foundation/eth-signature'; ++import { createLogger } from '@aztec-labs/foundation/log'; ++import { DateProvider } from '@aztec-labs/foundation/timer'; ++import { Command } from 'commander'; ++import { mkdtemp, rm, writeFile } from 'node:fs/promises'; ++import { tmpdir } from 'node:os'; ++import { join } from 'node:path'; ++import { getContract } from 'viem'; ++import { mnemonicToAccount } from 'viem/accounts'; ++import { foundry } from 'viem/chains'; ++ ++import { injectCommands } from './index.js'; ++import { initiateWithdrawByAttester, readAttesterExitAuthorizations } from './update_l1_validators.js'; ++ ++const mnemonic = 'test test test test test test test test test test test junk'; ++const attester = mnemonicToAccount(mnemonic); ++const withdrawer = mnemonicToAccount(mnemonic, { addressIndex: 1 }); ++const logger = createLogger('cli:test:attester-exit'); ++ ++describe('initiate-withdraw-by-attester command', () => { ++ it('requires a rollup and attester and accepts no recipient', () => { ++ const program = new Command(); ++ injectCommands(program, () => {}, logger); ++ const command = program.commands.find(command => command.name() === 'initiate-withdraw-by-attester'); ++ expect(command).toBeDefined(); ++ expect(command?.options.find(option => option.long === '--rollup')?.mandatory).toBe(true); ++ expect(command?.options.find(option => option.long === '--attester')?.mandatory).toBe(true); ++ expect(command?.options.some(option => /recipient|withdrawer/.test(option.long ?? ''))).toBe(false); ++ }); ++ ++ it('rejects a signer that does not match the attester before contacting L1', async () => { ++ await expect( ++ initiateWithdrawByAttester({ ++ rpcUrls: ['http://127.0.0.1:1'], ++ chainId: foundry.id, ++ mnemonic, ++ attesterAddress: EthAddress.fromString(withdrawer.address), ++ rollupAddress: EthAddress.ZERO, ++ log: () => {}, ++ debugLogger: logger, ++ }), ++ ).rejects.toThrow('The transaction signer must match the attester address'); ++ }); ++}); ++ ++describe('initiate-withdraw-by-attester-batch command', () => { ++ it('requires a rollup and authorization file and exposes the up-to-limit mode', () => { ++ const program = new Command(); ++ injectCommands(program, () => {}, logger); ++ const command = program.commands.find(command => command.name() === 'initiate-withdraw-by-attester-batch'); ++ expect(command).toBeDefined(); ++ expect(command?.options.find(option => option.long === '--rollup')?.mandatory).toBe(true); ++ expect(command?.options.find(option => option.long === '--authorizations')?.mandatory).toBe(true); ++ expect(command?.options.find(option => option.long === '--up-to-limit')).toBeDefined(); ++ }); ++ ++ it('reads the JSON authorization format used by the batch command', async () => { ++ const directory = await mkdtemp(join(tmpdir(), 'attester-exit-')); ++ const path = join(directory, 'authorizations.json'); ++ const signature = Signature.random(); ++ await writeFile( ++ path, ++ JSON.stringify([{ attester: attester.address, deadline: '123456789', signature: signature.toString() }]), ++ ); ++ ++ try { ++ await expect(readAttesterExitAuthorizations(path)).resolves.toEqual([ ++ { ++ attester: EthAddress.fromString(attester.address), ++ deadline: 123456789n, ++ signature: signature.toViemSignature(), ++ }, ++ ]); ++ } finally { ++ await rm(directory, { recursive: true }); ++ } ++ }); ++}); ++ ++describe('attester exit through the client and CLI', () => { ++ let anvil: Anvil; ++ let rpcUrl: string; ++ ++ afterAll(async () => { ++ await anvil?.stop(); ++ }); ++ ++ it('deploys, exits as the attester, and pays only the withdrawer-selected recipient after both delays', async () => { ++ ({ anvil, rpcUrl } = await startAnvil()); ++ const { l1ContractAddresses } = await deployAztecL1Contracts( ++ rpcUrl, ++ '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba', ++ foundry.id, ++ { ++ ...DefaultL1ContractsConfig, ++ vkTreeRoot: Fr.random(), ++ protocolContractsHash: Fr.random(), ++ genesisArchiveRoot: Fr.random(), ++ realVerifier: false, ++ aztecTargetCommitteeSize: 4, ++ entryQueueBootstrapValidatorSetSize: 21, ++ entryQueueBootstrapFlushSize: 21, ++ entryQueueMaxFlushSize: 21, ++ initialValidators: Array.from({ length: 21 }, (_, index) => ({ ++ attester: ++ index === 0 ++ ? EthAddress.fromString(attester.address) ++ : EthAddress.fromString(`0x${(1000 + index).toString(16).padStart(40, '0')}`), ++ withdrawer: EthAddress.fromString(withdrawer.address), ++ bn254SecretKey: new SecretValue(BigInt(index + 1)), ++ })), ++ }, ++ ); ++ const attesterClient = createExtendedL1Client([rpcUrl], attester, foundry); ++ const withdrawerClient = createExtendedL1Client([rpcUrl], withdrawer, foundry); ++ const rollup = new RollupContract(attesterClient, l1ContractAddresses.rollupAddress); ++ const target = EthAddress.fromString(attester.address); ++ const recipient = mnemonicToAccount(mnemonic, { addressIndex: 2 }).address; ++ const token = getContract({ ++ address: (await rollup.getStakingAsset()).toString(), ++ abi: TestERC20Abi, ++ client: attesterClient, ++ }); ++ const governance = getContract({ ++ address: l1ContractAddresses.governanceAddress.toString(), ++ abi: GovernanceAbi, ++ client: attesterClient, ++ }); ++ const ownerRollup = getContract({ address: rollup.address, abi: RollupAbi, client: withdrawerClient }); ++ const original = await rollup.getAttesterView(target); ++ const recipientBalance = await token.read.balanceOf([recipient]); ++ ++ const before = await rollup.getAttesterExitLimitState(); ++ expect(before).toMatchObject({ validatorCount: 21n, committeeSize: 4n, used: 0n, allowance: 1n, canExit: true }); ++ expect(await rollup.getAttesterExitWindow()).toBe(before.window); ++ ++ const receipt = await initiateWithdrawByAttester({ ++ rpcUrls: [rpcUrl], ++ chainId: foundry.id, ++ mnemonic, ++ attesterAddress: target, ++ rollupAddress: l1ContractAddresses.rollupAddress, ++ log: () => {}, ++ debugLogger: logger, ++ }); ++ expect(receipt.status).toBe('success'); ++ const pending = await rollup.getAttesterView(target); ++ expect(pending.status).toBe(AttesterStatus.ZOMBIE); ++ expect(pending.exit.exists).toBe(true); ++ expect(pending.exit.isRecipient).toBe(false); ++ expect(pending.exit.recipientOrWithdrawer).toEqual(EthAddress.fromString(withdrawer.address)); ++ expect(pending.exit.amount).toBe(original.effectiveBalance); ++ expect(await token.read.balanceOf([recipient])).toBe(recipientBalance); ++ expect(await rollup.getAttesterExitLimitState()).toMatchObject({ ++ validatorCount: 20n, ++ used: 1n, ++ allowance: 0n, ++ canExit: false, ++ }); ++ const withdrawal = await governance.read.getWithdrawal([pending.exit.withdrawalId]); ++ ++ await expect( ++ attesterClient.simulateContract({ ++ address: rollup.address, ++ abi: RollupAbi, ++ functionName: 'initiateWithdraw', ++ args: [attester.address, recipient], ++ }), ++ ).rejects.toThrow(); ++ ++ const selection = await ownerRollup.write.initiateWithdraw([attester.address, recipient]); ++ expect((await withdrawerClient.waitForTransactionReceipt({ hash: selection })).status).toBe('success'); ++ const selected = await rollup.getAttesterView(target); ++ expect(selected.exit.withdrawalId).toBe(pending.exit.withdrawalId); ++ expect(selected.exit.exitableAt).toBe(pending.exit.exitableAt); ++ expect(selected.exit.recipientOrWithdrawer).toEqual(EthAddress.fromString(recipient)); ++ expect(selected.exit.isRecipient).toBe(true); ++ expect(await governance.read.getWithdrawal([pending.exit.withdrawalId])).toEqual(withdrawal); ++ ++ const unlock = pending.exit.exitableAt > withdrawal.unlocksAt ? pending.exit.exitableAt : withdrawal.unlocksAt; ++ const cheatCodes = new EthCheatCodes([rpcUrl], new DateProvider()); ++ await cheatCodes.warp(unlock - 1n); ++ await expect( ++ attesterClient.simulateContract({ ++ address: rollup.address, ++ abi: RollupAbi, ++ functionName: 'finalizeWithdraw', ++ args: [attester.address], ++ }), ++ ).rejects.toThrow(); ++ await cheatCodes.warp(unlock); ++ const finalized = await ownerRollup.write.finalizeWithdraw([attester.address]); ++ expect((await withdrawerClient.waitForTransactionReceipt({ hash: finalized })).status).toBe('success'); ++ expect(await token.read.balanceOf([recipient])).toBe(recipientBalance + pending.exit.amount); ++ expect((await rollup.getAttesterView(target)).exit.exists).toBe(false); ++ }, 240_000); ++}); +diff --git a/yarn-project/epoch-cache/src/epoch_cache.integration.isolate.test.ts b/yarn-project/epoch-cache/src/epoch_cache.integration.isolate.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..ea0c771941ca88a324e83c946834608f516b6c5a +--- /dev/null ++++ b/yarn-project/epoch-cache/src/epoch_cache.integration.isolate.test.ts +@@ -0,0 +1,205 @@ ++import { getPublicClient } from '@aztec-labs/ethereum/client'; ++import { DefaultL1ContractsConfig } from '@aztec-labs/ethereum/config'; ++import { RollupContract } from '@aztec-labs/ethereum/contracts'; ++import { deployAztecL1Contracts } from '@aztec-labs/ethereum/deploy-aztec-l1-contracts'; ++import { EthCheatCodes, RollupCheatCodes, startAnvil } from '@aztec-labs/ethereum/test'; ++import type { Anvil } from '@aztec-labs/ethereum/test'; ++import { EpochNumber, SlotNumber } from '@aztec-labs/foundation/branded-types'; ++import { SecretValue } from '@aztec-labs/foundation/config'; ++import { Fr } from '@aztec-labs/foundation/curves/bn254'; ++import { EthAddress } from '@aztec-labs/foundation/eth-address'; ++import { createLogger } from '@aztec-labs/foundation/log'; ++import { TestDateProvider } from '@aztec-labs/foundation/timer'; ++import type { Hex } from 'viem'; ++import { privateKeyToAccount } from 'viem/accounts'; ++import { foundry } from 'viem/chains'; ++ ++import { EpochCache } from './epoch_cache.js'; ++ ++const NUM_VALIDATORS = 4; ++ ++/** ++ * Integration tests for the EpochCache against a real Anvil instance. ++ * ++ * These tests deploy L1 contracts with validators, then exercise the epoch cache's ++ * caching, TTL, finalization, and reorg-resilience behaviour. ++ */ ++describe('EpochCache integration', () => { ++ let anvil: Anvil; ++ let rpcUrl: string; ++ let dateProvider: TestDateProvider; ++ let cheatCodes: EthCheatCodes; ++ let rollupCheatCodes: RollupCheatCodes; ++ let rollup: RollupContract; ++ let epochCache: EpochCache; ++ ++ const deployerPrivateKey: Hex = '0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba'; ++ ++ const validatorKeys = [ ++ '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d', ++ '0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a', ++ '0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6', ++ '0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a', ++ ] as const; ++ ++ const validatorAddresses = validatorKeys.map(k => EthAddress.fromString(privateKeyToAccount(k).address)); ++ ++ beforeAll(async () => { ++ dateProvider = new TestDateProvider(); ++ ++ // Start Anvil with slotsInAnEpoch=8 so finalized = latest - 16 blocks. ++ // This keeps most epoch data non-finalized (TTL-cached) after warps. ++ ({ anvil, rpcUrl } = await startAnvil({ ++ l1BlockTime: 1, ++ slotsInAnEpoch: 8, ++ dateProvider, ++ })); ++ ++ const deployed = await deployAztecL1Contracts(rpcUrl, deployerPrivateKey, foundry.id, { ++ ...DefaultL1ContractsConfig, ++ // Short L2 epochs: 4 slots * 12s = 48s per epoch ++ aztecSlotDuration: 12, ++ aztecEpochDuration: 4, ++ aztecTargetCommitteeSize: NUM_VALIDATORS, ++ vkTreeRoot: Fr.random(), ++ protocolContractsHash: Fr.random(), ++ genesisArchiveRoot: Fr.random(), ++ realVerifier: false, ++ initialValidators: validatorAddresses.map(attester => ({ ++ attester, ++ withdrawer: attester, ++ bn254SecretKey: new SecretValue(Fr.random().toBigInt()), ++ })), ++ }); ++ ++ cheatCodes = new EthCheatCodes([rpcUrl], dateProvider); ++ rollupCheatCodes = new RollupCheatCodes(cheatCodes, deployed.l1ContractAddresses); ++ ++ const publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: foundry.id }); ++ rollup = new RollupContract(publicClient, deployed.l1ContractAddresses.rollupAddress!); ++ ++ epochCache = new EpochCache( ++ rollup, ++ { ++ l1StartBlock: await rollup.getL1StartBlock(), ++ l1GenesisTime: await rollup.getL1GenesisTime(), ++ slotDuration: await rollup.getSlotDuration(), ++ epochDuration: await rollup.getEpochDuration(), ++ ethereumSlotDuration: DefaultL1ContractsConfig.ethereumSlotDuration, ++ proofSubmissionEpochs: await rollup.getProofSubmissionEpochs(), ++ targetCommitteeSize: await rollup.getTargetCommitteeSize(), ++ rollupManaLimit: Number(await rollup.getManaLimit()), ++ lagInEpochsForValidatorSet: await rollup.getLagInEpochsForValidatorSet(), ++ lagInEpochsForRandao: await rollup.getLagInEpochsForRandao(), ++ }, ++ dateProvider, ++ ); ++ }); ++ ++ afterAll(async () => { ++ await cheatCodes?.setIntervalMining(0); ++ await anvil?.stop().catch(err => createLogger('cleanup').error(err)); ++ }); ++ ++ it('returns committee for a finalized epoch and marks it as finalized', async () => { ++ const lagInEpochsForRandao = await rollup.getLagInEpochsForRandao(); ++ const epochDuration = await rollup.getEpochDuration(); ++ ++ // Advance well past the lag so the epoch's sampling data is finalized. ++ const targetEpoch = EpochNumber(lagInEpochsForRandao + 2); ++ await rollupCheatCodes.advanceToEpoch(targetEpoch); ++ await rollupCheatCodes.setupEpoch(); ++ // Mine enough blocks so finalized catches up (finalized = latest - 16 with slotsInAnEpoch=8). ++ // With 12s timestamps per block, we need enough blocks so that the finalized block's ++ // timestamp covers the sampling timestamp for the target epoch. ++ await cheatCodes.mine(30); ++ ++ // Query the specific target epoch rather than 'now', because auto-mining may have ++ // advanced the dateProvider well past the target epoch. ++ const slotForEpoch = SlotNumber(Number(targetEpoch) * Number(epochDuration)); ++ const { committee, seed, epoch } = await epochCache.getCommittee(slotForEpoch); ++ ++ expect(committee).toBeDefined(); ++ expect(committee!.length).toBe(NUM_VALIDATORS); ++ expect(seed).not.toBe(0n); ++ expect(epoch).toBe(targetEpoch); ++ expect(epochCache.isFinalized(targetEpoch)).toBe(true); ++ }); ++ ++ it('refreshes non-finalized data after TTL expires', async () => { ++ // Warp far ahead with a single block, creating a gap between latest and finalized. ++ const currentTs = (await rollup.client.getBlock()).timestamp; ++ await cheatCodes.warp(Number(currentTs) + 600); ++ ++ // First fetch — works but may not be finalized (warp only mines one block). ++ const result1 = await epochCache.getCommittee('now'); ++ expect(result1.committee).toBeDefined(); ++ ++ // Mine some blocks to advance finalized, then advance time past TTL. ++ await cheatCodes.mine(5); ++ dateProvider.advanceTime(DefaultL1ContractsConfig.ethereumSlotDuration); ++ ++ // Re-query: cache entry is stale, so it should re-fetch from L1. ++ const result2 = await epochCache.getCommittee('now'); ++ expect(result2.committee).toBeDefined(); ++ // The entry was successfully re-fetched (we got a result without throwing). ++ }); ++ ++ it('picks up changed seed after an L1 rollback once the cache TTL expires', async () => { ++ const epochDuration = await rollup.getEpochDuration(); ++ ++ // Disable interval mining BEFORE the warp to avoid race conditions where ++ // Anvil auto-mines blocks between the warp and the disable call. Enable ++ // automine so transactions are still mined immediately. ++ await cheatCodes.setIntervalMining(0); ++ await cheatCodes.setAutomine(true); ++ ++ // Advance to a known point. The warp creates a gap between latest and finalized, ++ // keeping the epoch's sampling data non-finalized (TTL-cached, not permanent). ++ const currentTs = (await rollup.client.getBlock()).timestamp; ++ await cheatCodes.warp(Number(currentTs) + 600); ++ await cheatCodes.mine(3); ++ ++ // Call setupEpoch so the contract captures prevrandao for the current epoch. ++ await rollupCheatCodes.setupEpoch(); ++ ++ // Record the epoch and populate the cache. ++ const epochBefore = epochCache.getEpochNow(); ++ const before = await epochCache.getCommittee('now'); ++ expect(before.committee).toBeDefined(); ++ expect(epochCache.isFinalized(epochBefore)).toBe(false); ++ ++ // Record the L1 timestamp at which the cache entry was last refreshed. ++ const l1TsBefore = epochCache.getCachedLastRefreshL1Timestamp(epochBefore); ++ expect(l1TsBefore).toBeDefined(); ++ ++ // Rollback past the setupEpoch block and mine new blocks so the chain tip changes. ++ await cheatCodes.reorg(2); ++ await cheatCodes.mine(5); ++ await rollupCheatCodes.setupEpoch(); ++ ++ // Sync the dateProvider to the latest L1 block time since interval mining is off ++ // and the stdout-based sync may not update the dateProvider for automined blocks. ++ const latestBlock = await rollup.client.getBlock(); ++ dateProvider.setTime(Number(latestBlock.timestamp) * 1000); ++ ++ // Advance time well past TTL so the cached entry is stale. ++ // The TTL is one Ethereum slot (12s). We advance by 2x to ensure staleness ++ // regardless of small timestamp differences between the dateProvider and the ++ // L1 block timestamp stored in the cache entry. ++ dateProvider.advanceTime(DefaultL1ContractsConfig.ethereumSlotDuration * 2); ++ ++ // Re-query the SAME epoch by passing an explicit slot number, ++ // so we don't accidentally query a different epoch if time advanced. ++ const slotForEpoch = SlotNumber(Number(epochBefore) * Number(epochDuration)); ++ const after = await epochCache.getCommittee(slotForEpoch); ++ expect(after.committee).toBeDefined(); ++ expect(after.epoch).toBe(before.epoch); ++ ++ // The cache was refreshed: the L1 timestamp must have advanced because ++ // the chain was reorged and new blocks were mined. ++ const l1TsAfter = epochCache.getCachedLastRefreshL1Timestamp(epochBefore); ++ expect(l1TsAfter).toBeDefined(); ++ expect(l1TsAfter).not.toBe(l1TsBefore); ++ }); ++});