From 717a90c4527b25f4431261e1cb9860abaeb8e2bc Mon Sep 17 00:00:00 2001 From: spypsy <6403450+spypsy@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:32:42 +0000 Subject: [PATCH 01/55] feat(p2p): detect and track announce IP changes at runtime ## Summary - Keep `getPublicIp()` at startup so the ENR always has a valid IP from the start - Enable discv5 `enrUpdate` with `addrVotesToUpdateEnr: 1` and faster pings (10s) when `queryForIp` is enabled, so PONG votes can correct the IP at runtime if it changes (e.g. residential ISP, Cloud NAT rotation) - Bridge discv5 IP changes to libp2p's AddressManager so peers see updated addresses - Have the bootnode explicitly `addEnr()` on discovery to fix routing table gaps where nodes were never inserted - Improve P2P observability: log KAD table state in peer manager heartbeats, log ENR additions with multiaddrs, log config at startup - Small change to deploy scripts that allows us to define a full aztec image to deploy on a network rather than just `aztecprotcool/aztec:` Fixes [A-310](https://linear.app/aztec-labs/issue/A-310/p2p-query-for-ip-should-detect-ip-changes) Co-authored-by: Alex Gherghisan Co-authored-by: danielntmd <162406516+danielntmd@users.noreply.github.com> --- .github/workflows/deploy-network.yml | 65 +++++++---- .github/workflows/deploy-next-net.yml | 4 +- spartan/environments/next-net.env | 2 + yarn-project/p2p/src/bootstrap/bootstrap.ts | 8 ++ .../p2p/src/services/discv5/discV5_service.ts | 43 ++++++- .../services/discv5/discv5_service.test.ts | 39 +++++++ .../services/libp2p/libp2p_service.test.ts | 110 +++++++++++++++++- .../p2p/src/services/libp2p/libp2p_service.ts | 53 ++++++++- .../peer-manager/peer_manager.test.ts | 1 + .../src/services/peer-manager/peer_manager.ts | 1 + yarn-project/p2p/src/services/service.ts | 3 + .../p2p/src/test-helpers/mock-pubsub.ts | 7 ++ .../p2p/src/test-helpers/reqresp-nodes.ts | 4 + yarn-project/p2p/src/util.ts | 51 +++++--- 14 files changed, 336 insertions(+), 55 deletions(-) diff --git a/.github/workflows/deploy-network.yml b/.github/workflows/deploy-network.yml index 046c44b425ef..60aa8a20d411 100644 --- a/.github/workflows/deploy-network.yml +++ b/.github/workflows/deploy-network.yml @@ -10,11 +10,11 @@ on: required: true type: string semver: - description: "Semver version (e.g., 2.3.4)" - required: true + description: "Semver version (e.g., 2.3.4). Used to construct docker image if aztec_docker_image is not set." + required: false type: string - docker_image_tag: - description: "Full docker image tag (optional, defaults to semver)" + aztec_docker_image: + description: "Full Aztec docker image (e.g., aztecprotocol/aztec:2.3.4). If not set, constructed from semver." required: false type: string ref: @@ -50,11 +50,11 @@ on: - testnet - mainnet semver: - description: "Semver version (e.g., 2.3.4)" - required: true + description: "Semver version (e.g., 2.3.4). Used to construct docker image if aztec_docker_image is not set." + required: false type: string - docker_image_tag: - description: "Full docker image tag (optional, defaults to semver)" + aztec_docker_image: + description: "Full Aztec docker image (e.g., aztecprotocol/aztec:2.3.4). If not set, constructed from semver." required: false type: string namespace: @@ -76,7 +76,7 @@ on: type: string concurrency: - group: deploy-network-${{ inputs.network }}-${{ inputs.namespace || inputs.network }}-${{ inputs.semver }}-${{ github.ref || github.ref_name }} + group: deploy-network-${{ inputs.network }}-${{ inputs.namespace || inputs.network }}-${{ inputs.aztec_docker_image || inputs.semver }}-${{ github.ref || github.ref_name }} cancel-in-progress: true jobs: @@ -120,16 +120,33 @@ jobs: exit 1 fi - # Validate semver format - if ! echo "${{ inputs.semver }}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$'; then - echo "Error: Invalid semver format '${{ inputs.semver }}'. Expected format: X.Y.Z or X.Y.Z-suffix" + # Require at least one of aztec_docker_image or semver + if [[ -z "${{ inputs.aztec_docker_image }}" && -z "${{ inputs.semver }}" ]]; then + echo "Error: Either 'aztec_docker_image' or 'semver' must be provided" exit 1 fi - # Extract major version for v2 check - major_version="${{ inputs.semver }}" - major_version="${major_version%%.*}" - echo "MAJOR_VERSION=$major_version" >> $GITHUB_ENV + # Validate semver format if provided + if [[ -n "${{ inputs.semver }}" ]]; then + if ! echo "${{ inputs.semver }}" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-.*)?$'; then + echo "Error: Invalid semver format '${{ inputs.semver }}'. Expected format: X.Y.Z or X.Y.Z-suffix" + exit 1 + fi + fi + + # Resolve the docker image + if [[ -n "${{ inputs.aztec_docker_image }}" ]]; then + AZTEC_DOCKER_IMAGE="${{ inputs.aztec_docker_image }}" + else + AZTEC_DOCKER_IMAGE="aztecprotocol/aztec:${{ inputs.semver }}" + fi + echo "AZTEC_DOCKER_IMAGE=$AZTEC_DOCKER_IMAGE" >> $GITHUB_ENV + + # Only use the separate prover-agent image for official semver builds; + # for custom images, let the deploy script fall back to AZTEC_DOCKER_IMAGE + if [[ -n "${{ inputs.semver }}" ]]; then + echo "PROVER_AGENT_DOCKER_IMAGE=aztecprotocol/aztec-prover-agent:${{ inputs.semver }}" >> $GITHUB_ENV + fi - name: Store the GCP key in a file env: @@ -174,12 +191,12 @@ jobs: RUN_ID: ${{ github.run_id }} SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} GOOGLE_APPLICATION_CREDENTIALS: ${{ env.GOOGLE_APPLICATION_CREDENTIALS }} - REF_NAME: "v${{ inputs.semver }}" + REF_NAME: ${{ inputs.semver && format('v{0}', inputs.semver) || '' }} GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }} NAMESPACE: ${{ inputs.namespace }} - AZTEC_DOCKER_IMAGE: "aztecprotocol/aztec:${{ inputs.docker_image_tag || inputs.semver }}" + AZTEC_DOCKER_IMAGE: ${{ env.AZTEC_DOCKER_IMAGE }} CREATE_ROLLUP_CONTRACTS: ${{ inputs.deploy_contracts == true && 'true' || '' }} - PROVER_AGENT_DOCKER_IMAGE: "aztecprotocol/aztec-prover-agent:${{ inputs.docker_image_tag || inputs.semver }}" + PROVER_AGENT_DOCKER_IMAGE: ${{ env.PROVER_AGENT_DOCKER_IMAGE || env.AZTEC_DOCKER_IMAGE }} VALIDATOR_HA_DOCKER_IMAGE: ${{ inputs.ha_docker_image || '' }} run: | echo "Deploying network: ${{ inputs.network }}" @@ -209,7 +226,7 @@ jobs: echo "| Item | Value |" echo "|------|-------|" echo "| Network | \`${{ inputs.network }}\` |" - echo "| Semver | \`${{ inputs.semver }}\` |" + echo "| Docker Image | \`${{ env.AZTEC_DOCKER_IMAGE }}\` |" echo "| Ref | \`${{ steps.checkout-ref.outputs.ref }}\` |" if [[ -n "${{ inputs.source_tag }}" ]]; then echo "| Source Tag | [\`${{ inputs.source_tag }}\`](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.source_tag }}) |" @@ -229,7 +246,7 @@ jobs: CHANNEL="#alerts-${{ inputs.network }}" RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - TEXT="Deploy Network workflow FAILED for *${{ inputs.network }}* (version ${{ inputs.semver }}): <${RUN_URL}|View Run> (🤖)" + TEXT="Deploy Network workflow FAILED for *${{ inputs.network }}* (image ${{ env.AZTEC_DOCKER_IMAGE }}): <${RUN_URL}|View Run> (🤖)" # Post to Slack and capture timestamp for permalink RESP=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ @@ -247,11 +264,11 @@ jobs: fi # Dispatch ClaudeBox to investigate the failure - PROMPT="Deployment of ${{ inputs.network }} (version ${{ inputs.semver }}) failed. \ + PROMPT="Deployment of ${{ inputs.network }} (image ${{ env.AZTEC_DOCKER_IMAGE }}) failed. \ Follow .claude/claudebox/deploy-investigation.md to investigate. \ GitHub Actions run: ${RUN_URL}. \ - Network: ${{ inputs.network }}. Version: ${{ inputs.semver }}. \ - Docker image: ${{ inputs.docker_image_tag || inputs.semver }}. \ + Network: ${{ inputs.network }}. \ + Docker image: ${{ env.AZTEC_DOCKER_IMAGE }}. \ Git ref: ${{ steps.checkout-ref.outputs.ref }}. \ Namespace: ${{ inputs.namespace || inputs.network }}. \ Deploy contracts: ${{ inputs.deploy_contracts }}." diff --git a/.github/workflows/deploy-next-net.yml b/.github/workflows/deploy-next-net.yml index 60a7329b4ade..9cdacbc36f84 100644 --- a/.github/workflows/deploy-next-net.yml +++ b/.github/workflows/deploy-next-net.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: inputs: image_tag: - description: 'Docker image tag (e.g., 2.3.4, 3.0.0-nightly.20251004-amd64, or leave empty for latest nightly)' + description: "Docker image tag (e.g., 2.3.4, 3.0.0-nightly.20251004-amd64, or leave empty for latest nightly)" required: false type: string @@ -67,6 +67,6 @@ jobs: with: network: next-net semver: ${{ needs.get-image-tag.outputs.semver }} - docker_image_tag: ${{ needs.get-image-tag.outputs.tag }} + docker_image: "aztecprotocol/aztec:${{ needs.get-image-tag.outputs.tag }}" ref: ${{ github.ref }} secrets: inherit diff --git a/spartan/environments/next-net.env b/spartan/environments/next-net.env index c4b5167788bc..7ecf1b28b7e6 100644 --- a/spartan/environments/next-net.env +++ b/spartan/environments/next-net.env @@ -29,6 +29,8 @@ L1_TX_FAILED_STORE=gs://aztec-develop/next-net/failed-l1-txs TEST_ACCOUNTS=true SPONSORED_FPC=true +LOG_LEVEL=debug + SEQ_ENABLE_PROPOSER_PIPELINING=true SEQ_MIN_TX_PER_BLOCK=1 SEQ_MAX_TX_PER_CHECKPOINT=12 diff --git a/yarn-project/p2p/src/bootstrap/bootstrap.ts b/yarn-project/p2p/src/bootstrap/bootstrap.ts index f0e78d14e9d7..86ed966e6544 100644 --- a/yarn-project/p2p/src/bootstrap/bootstrap.ts +++ b/yarn-project/p2p/src/bootstrap/bootstrap.ts @@ -86,6 +86,14 @@ export class BootstrapNode implements P2PBootstrapApi { this.node.on('discovered', async (enr: SignableENR) => { const addr = await enr.getFullMultiaddr('udp'); this.logger.verbose(`Discovered new peer`, { enr: enr.encodeTxt(), addr: addr?.toString() }); + // discv5's discovered() only updates routing table entries that already exist. Nodes that + // established a session with an empty-IP ENR are never inserted, so even after their ENR + // gains a valid socket address the routing table stays empty and FINDNODE always returns 0 + // peers. Calling addEnr() here does an insertOrUpdate regardless of prior state, fixing + // the routing table so these nodes become discoverable to other peers. + if (addr) { + this.node.addEnr(enr); + } }); try { diff --git a/yarn-project/p2p/src/services/discv5/discV5_service.ts b/yarn-project/p2p/src/services/discv5/discV5_service.ts index de288a63092a..5e1f3e0912d0 100644 --- a/yarn-project/p2p/src/services/discv5/discV5_service.ts +++ b/yarn-project/p2p/src/services/discv5/discV5_service.ts @@ -38,6 +38,8 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService private startTime = 0; + private currentIp: string | undefined; + private handlers = { onMultiaddrUpdated: this.onMultiaddrUpdated.bind(this), onDiscovered: this.onDiscovered.bind(this), @@ -53,8 +55,10 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService configOverrides: Partial = {}, ) { super(); + const { p2pIp, p2pPort, p2pBroadcastPort, bootstrapNodes, trustedPeers, privatePeers } = config; + this.currentIp = p2pIp; this.bootstrapNodeEnrs = bootstrapNodes.map(x => ENR.decodeTxt(x)); const privatePeerEnrs = new Set(privatePeers); this.trustedPeerEnrs = trustedPeers.filter(x => !privatePeerEnrs.has(x)).map(x => ENR.decodeTxt(x)); @@ -96,7 +100,8 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService lookupTimeout: 2000, requestTimeout: 2000, allowUnverifiedSessions: true, - enrUpdate: !p2pIp ? true : false, // If no p2p IP is set, enrUpdate can automatically resolve it + enrUpdate: config.queryForIp || !p2pIp, + pingInterval: config.queryForIp ? 10_000 : 300_000, ...configOverrides.config, }, metricsRegistry, @@ -127,11 +132,34 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService } private onMultiaddrUpdated(m: Multiaddr) { - // We want to update our tcp port to match the udp port - // p2pBroadcastPort is optional on config, however it is set to default within the p2p client factory - const multiAddrTcp = multiaddr(convertToMultiaddr(m.nodeAddress().address, this.config.p2pBroadcastPort!, 'tcp')); + const newIp = m.nodeAddress().address; + const previousIp = this.currentIp; + + if (newIp === previousIp) { + this.logger.debug('Discv5 confirmed current IP (no change)', { ip: newIp }); + return; + } + + const multiAddrTcp = multiaddr(convertToMultiaddr(newIp, this.config.p2pBroadcastPort!, 'tcp')); this.enr.setLocationMultiaddr(multiAddrTcp); - this.logger.info('Multiaddr updated', { multiaddr: multiAddrTcp.toString() }); + this.currentIp = newIp; + + if (previousIp) { + this.logger.info('IP address changed, ENR updated', { + previousIp, + newIp, + multiaddr: multiAddrTcp.toString(), + enr: this.enr.encodeTxt(), + }); + } else { + this.logger.info('Initial IP discovered via discv5, ENR updated', { + ip: newIp, + multiaddr: multiAddrTcp.toString(), + enr: this.enr.encodeTxt(), + }); + } + + this.emit('ip:changed', newIp); } public async start(): Promise { @@ -142,12 +170,17 @@ export class DiscV5Service extends EventEmitter implements PeerDiscoveryService await this.discv5.start(); this.startTime = Date.now(); + const enrUpdateEnabled = this.config.queryForIp || !this.config.p2pIp; this.logger.info(`DiscV5 service started`, { nodeId: this.enr.nodeId, peerId: this.peerId, enrUdp: await this.enr.getFullMultiaddr('udp'), enrTcp: await this.enr.getFullMultiaddr('tcp'), versions: this.versions, + enrUpdateEnabled, + queryForIp: this.config.queryForIp, + configuredIp: this.config.p2pIp ?? 'none', + pingIntervalMs: this.config.queryForIp ? 10_000 : 300_000, }); this.currentState = PeerDiscoveryState.RUNNING; diff --git a/yarn-project/p2p/src/services/discv5/discv5_service.test.ts b/yarn-project/p2p/src/services/discv5/discv5_service.test.ts index fe4528c27ec2..f3fec17d4fe0 100644 --- a/yarn-project/p2p/src/services/discv5/discv5_service.test.ts +++ b/yarn-project/p2p/src/services/discv5/discv5_service.test.ts @@ -144,6 +144,45 @@ describe('Discv5Service', () => { await stopNodes(...nodes); }); + it('should correct a wrong initial IP via PONG votes and emit ip:changed', async () => { + const extraNodes = 3; + const nodes: DiscV5Service[] = []; + + // Simulate the scenario where getPublicIp() returned a wrong IP at startup (e.g. NAT egress IP). + // With enrUpdate forced on, PONG votes from peers should correct the ENR to 127.0.0.1. + const node = await createNode({ + p2pIp: '1.2.3.4', + config: { enrUpdate: true, addrVotesToUpdateEnr: 1, pingInterval: 200 }, + }); + await node.start(); + nodes.push(node); + + // Track ip:changed events (these are what libp2p_service bridges to its AddressManager) + const ipChanges: string[] = []; + node.on('ip:changed', (ip: string) => ipChanges.push(ip)); + + expect(node.getEnr().ip).toEqual('1.2.3.4'); + + for (let i = 1; i < extraNodes; i++) { + const n = await createNode({ config: { pingInterval: 200 } }); + await n.start(); + nodes.push(n); + } + + // Wait for the ENR IP to be corrected by PONG votes + await runDiscoveryUntil(nodes, () => node.getEnr().ip !== '1.2.3.4'); + + // ENR should now reflect the real IP (127.0.0.1) as reported by peers + expect(node.getEnr().ip).toEqual('127.0.0.1'); + expect(node.getEnr().tcp).toEqual(node.getEnr().udp); + + // ip:changed should have fired with the corrected IP + expect(ipChanges.length).toBeGreaterThanOrEqual(1); + expect(ipChanges[ipChanges.length - 1]).toEqual('127.0.0.1'); + + await stopNodes(...nodes); + }); + it('should refuse to connect to a bootstrap node with wrong chain id', async () => { const node1 = await createNode({ l1ChainId: 13, bootstrapNodeEnrVersionCheck: true }); const node2 = await createNode({ l1ChainId: 14, bootstrapNodeEnrVersionCheck: false }); diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index 4d5ee8f0957a..7c9cd70d4781 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -24,6 +24,8 @@ import { ServerWorldStateSynchronizer } from '@aztec/world-state'; import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals'; import type { Message, PeerId } from '@libp2p/interface'; import { TopicValidatorResult } from '@libp2p/interface'; +import { multiaddr } from '@multiformats/multiaddr'; +import EventEmitter from 'events'; import { type MockProxy, mock } from 'jest-mock-extended'; import { type P2PConfig, p2pConfigMappings } from '../../config.js'; @@ -991,6 +993,87 @@ describe('LibP2PService', () => { expect(allNodesCheckpointReceivedCallback).toHaveBeenCalledWith(expect.any(Object), expect.anything()); }); }); + + describe('ip:changed bridge to AddressManager', () => { + let peerDiscoveryEmitter: PeerDiscoveryService; + let addObservedAddr: jest.Mock; + let confirmObservedAddr: jest.Mock; + let removeObservedAddr: jest.Mock; + let ipNode: MockProxy; + + beforeEach(() => { + peerDiscoveryEmitter = new EventEmitter() as PeerDiscoveryService; + peerDiscoveryEmitter.start = jest.fn<() => Promise>().mockResolvedValue(undefined); + peerDiscoveryEmitter.stop = jest.fn<() => Promise>().mockResolvedValue(undefined); + peerDiscoveryEmitter.getKadValues = jest.fn<() => any[]>().mockReturnValue([]); + peerDiscoveryEmitter.runRandomNodesQuery = jest.fn<() => Promise>().mockResolvedValue(undefined); + peerDiscoveryEmitter.isBootstrapPeer = jest.fn<() => boolean>().mockReturnValue(false); + peerDiscoveryEmitter.getStatus = jest.fn().mockReturnValue('running') as any; + peerDiscoveryEmitter.getEnr = jest.fn().mockReturnValue(undefined) as any; + peerDiscoveryEmitter.bootstrapNodeEnrs = []; + + addObservedAddr = jest.fn(); + confirmObservedAddr = jest.fn(); + removeObservedAddr = jest.fn(); + + ipNode = mock(); + ipNode.services = { + pubsub: { + reportMessageValidationResult: jest.fn(), + subscribe: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + getTopics: jest.fn().mockReturnValue([]), + }, + components: { + addressManager: { addObservedAddr, confirmObservedAddr, removeObservedAddr }, + }, + } as any; + ipNode.start = jest.fn<() => Promise>().mockResolvedValue(undefined) as any; + }); + + it('should update libp2p AddressManager when discv5 emits ip:changed', async () => { + const ipService = createTestLibP2PService({ + peerManager: mock(), + node: ipNode, + configOverrides: { queryForIp: true, p2pIp: '10.0.0.1', p2pPort: 40400 }, + peerDiscoveryService: peerDiscoveryEmitter, + }); + + await ipService.start(); + + // Emit first IP change — should remove the initial config IP (10.0.0.1) + peerDiscoveryEmitter.emit('ip:changed', '1.2.3.4'); + expect(addObservedAddr).toHaveBeenCalledTimes(1); + expect(confirmObservedAddr).toHaveBeenCalledTimes(1); + expect(removeObservedAddr).toHaveBeenCalledTimes(1); + expect(removeObservedAddr).toHaveBeenCalledWith(multiaddr('/ip4/10.0.0.1/tcp/40400')); + + // Emit second IP change — should remove the previous discovered IP (1.2.3.4) + peerDiscoveryEmitter.emit('ip:changed', '5.6.7.8'); + expect(addObservedAddr).toHaveBeenCalledTimes(2); + expect(confirmObservedAddr).toHaveBeenCalledTimes(2); + expect(removeObservedAddr).toHaveBeenCalledTimes(2); + + await ipService.stop(); + }); + + it('should not wire the bridge when queryForIp is false', async () => { + const ipService = createTestLibP2PService({ + peerManager: mock(), + node: ipNode, + configOverrides: { queryForIp: false, p2pIp: '10.0.0.1', p2pPort: 40400 }, + peerDiscoveryService: peerDiscoveryEmitter, + }); + + await ipService.start(); + + peerDiscoveryEmitter.emit('ip:changed', '1.2.3.4'); + expect(addObservedAddr).not.toHaveBeenCalled(); + + await ipService.stop(); + }); + }); }); /** Mock type for tx objects used in block txs validation tests. */ @@ -1014,6 +1097,8 @@ interface CreateTestLibP2PServiceOptions { attestationPool?: AttestationPool; txPool?: MockProxy; epochCache?: MockProxy; + configOverrides?: Partial; + peerDiscoveryService?: PeerDiscoveryService; } /** @@ -1042,6 +1127,8 @@ class TestLibP2PService extends LibP2PService { /** Exposed epoch cache for test configuration. */ public testEpochCache: MockProxy; + public mockPeerDiscoveryService: PeerDiscoveryService; + constructor( node: PubSubLibp2p, peerManager: PeerManagerInterface, @@ -1050,6 +1137,8 @@ class TestLibP2PService extends LibP2PService { epochCache: MockProxy, telemetry: TelemetryClient, logger: Logger, + configOverrides?: Partial, + peerDiscoveryService?: PeerDiscoveryService, ) { // Create minimal mock dependencies for the base class const mockConfig: P2PConfig = { @@ -1062,9 +1151,10 @@ class TestLibP2PService extends LibP2PService { l1Contracts: { rollupAddress: EthAddress.random(), }, + ...configOverrides, }; - const mockPeerDiscoveryService = mock(); + const resolvedPeerDiscoveryService = peerDiscoveryService ?? mock(); const mockReqResp = mock(); const mockWorldStateSynchronizer = mock(); const mockProofVerifier = mock({ @@ -1074,7 +1164,7 @@ class TestLibP2PService extends LibP2PService { super( mockConfig, node, - mockPeerDiscoveryService, + resolvedPeerDiscoveryService, mockReqResp, peerManager, mempools, @@ -1086,6 +1176,8 @@ class TestLibP2PService extends LibP2PService { logger, ); + this.mockPeerDiscoveryService = resolvedPeerDiscoveryService; + this.testEpochCache = epochCache; this.validateRequestedTxMock = jest.fn(() => Promise.resolve()); this.stubValidator = { @@ -1178,6 +1270,8 @@ function createTestLibP2PService(options: CreateTestLibP2PServiceOptions): TestL attestationPool = new AttestationPool(openTmpStore(true)), txPool = mock(), epochCache = mock(), + configOverrides, + peerDiscoveryService, } = options; epochCache.getL1Constants.mockReturnValue({ @@ -1191,7 +1285,17 @@ function createTestLibP2PService(options: CreateTestLibP2PServiceOptions): TestL const telemetry = getTelemetryClient(); const logger = createLogger('p2p:test'); - return new TestLibP2PService(node, peerManager, mempools, archiver, epochCache, telemetry, logger); + return new TestLibP2PService( + node, + peerManager, + mempools, + archiver, + epochCache, + telemetry, + logger, + configOverrides, + peerDiscoveryService, + ); } /** Creates a TestLibP2PService instance with real attestation pool and mocked tx pool. */ diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index e95ccbd05b4f..ed327c651a73 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -50,9 +50,10 @@ import { yamux } from '@chainsafe/libp2p-yamux'; import { bootstrap } from '@libp2p/bootstrap'; import { identify } from '@libp2p/identify'; import { type Message, type MultiaddrConnection, type PeerId, TopicValidatorResult } from '@libp2p/interface'; -import type { ConnectionManager } from '@libp2p/interface-internal'; +import type { AddressManager, ConnectionManager } from '@libp2p/interface-internal'; import { mplex } from '@libp2p/mplex'; import { tcp } from '@libp2p/tcp'; +import { multiaddr } from '@multiformats/multiaddr'; import { ENR } from '@nethermindeth/enr'; import { createLibp2p } from 'libp2p'; @@ -180,6 +181,9 @@ export class LibP2PService extends WithTracer implements P2PService { private gossipSubEventHandler: (e: CustomEvent) => void; + private ipChangedHandler?: (ip: string) => void; + private discoveredP2pIp?: string; + private instrumentation: P2PInstrumentation; private telemetry: TelemetryClient; @@ -457,8 +461,9 @@ export class LibP2PService extends WithTracer implements P2PService { topics: topicScoreParams, }), }) as (components: GossipSubComponents) => GossipSub, - components: (components: { connectionManager: ConnectionManager }) => ({ + components: (components: { connectionManager: ConnectionManager; addressManager: AddressManager }) => ({ connectionManager: components.connectionManager, + addressManager: components.addressManager, }), }, logger: createLibp2pComponentLogger(logger.module, logger.getBindings()), @@ -518,12 +523,11 @@ export class LibP2PService extends WithTracer implements P2PService { throw new Error('P2P service already started'); } - // Get listen & announce addresses for logging const { p2pIp, p2pPort } = this.config; - if (!p2pIp) { - throw new Error('Announce address not provided.'); + if (!p2pIp && !this.config.queryForIp) { + throw new Error('Announce address not provided and queryForIp is not enabled.'); } - const announceTcpMultiaddr = convertToMultiaddr(p2pIp, p2pPort, 'tcp'); + const announceTcpMultiaddr = p2pIp ? convertToMultiaddr(p2pIp, p2pPort, 'tcp') : undefined; // Create request response protocol handlers const txHandler = reqRespTxHandler(this.mempools); @@ -574,6 +578,38 @@ export class LibP2PService extends WithTracer implements P2PService { if (!this.config.p2pDiscoveryDisabled) { await this.peerDiscoveryService.start(); } + + // Bridge discv5 IP changes to libp2p's AddressManager so peers see the updated address + if (this.config.queryForIp) { + this.discoveredP2pIp = this.config.p2pIp; + this.logger.info('IP change tracking enabled, bridging discv5 IP updates to libp2p AddressManager'); + this.ipChangedHandler = (ip: string) => { + const addressManager = this.node.services.components.addressManager; + const newAddr = multiaddr(convertToMultiaddr(ip, this.config.p2pPort, 'tcp')); + const previousIp = this.discoveredP2pIp; + + if (previousIp) { + const oldAddr = multiaddr(convertToMultiaddr(previousIp, this.config.p2pPort, 'tcp')); + addressManager.removeObservedAddr(oldAddr); + this.logger.info('Libp2p announce address updated due to IP change', { + previousIp, + newIp: ip, + newMultiaddr: newAddr.toString(), + }); + } else { + this.logger.info('Libp2p announce address set from initial discv5 IP discovery', { + ip, + multiaddr: newAddr.toString(), + }); + } + + addressManager.addObservedAddr(newAddr); + addressManager.confirmObservedAddr(newAddr); + this.discoveredP2pIp = ip; + }; + this.peerDiscoveryService.on('ip:changed', this.ipChangedHandler); + } + this.discoveryRunningPromise = new RunningPromise( async () => { await this.peerManager.heartbeat(); @@ -599,6 +635,11 @@ export class LibP2PService extends WithTracer implements P2PService { // Remove gossip sub listener this.node.services.pubsub.removeEventListener(GossipSubEvent.MESSAGE, this.gossipSubEventHandler); + if (this.ipChangedHandler) { + this.peerDiscoveryService.removeListener('ip:changed', this.ipChangedHandler); + this.ipChangedHandler = undefined; + } + // Stop peer manager this.logger.debug('Stopping peer manager...'); await this.peerManager.stop(); diff --git a/yarn-project/p2p/src/services/peer-manager/peer_manager.test.ts b/yarn-project/p2p/src/services/peer-manager/peer_manager.test.ts index c8feb0f1135a..3bd1e140582c 100644 --- a/yarn-project/p2p/src/services/peer-manager/peer_manager.test.ts +++ b/yarn-project/p2p/src/services/peer-manager/peer_manager.test.ts @@ -43,6 +43,7 @@ describe('PeerManager', () => { off: jest.fn(), isBootstrapPeer: jest.fn().mockReturnValue(false), runRandomNodesQuery: jest.fn(), + getKadValues: jest.fn().mockReturnValue([]), }; const mockEpochCache = mock(); diff --git a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts index c47c5468e926..68ddf5eee868 100644 --- a/yarn-project/p2p/src/services/peer-manager/peer_manager.ts +++ b/yarn-project/p2p/src/services/peer-manager/peer_manager.ts @@ -527,6 +527,7 @@ export class PeerManager implements PeerManagerInterface { protectedConnections: protectedPeerCount, maxPeerCount: this.config.maxPeerCount, cachedPeers: this.cachedPeers.size, + discv5KadPeers: this.peerDiscoveryService.getKadValues().length, ...this.peerScoring.getStats(), }); diff --git a/yarn-project/p2p/src/services/service.ts b/yarn-project/p2p/src/services/service.ts index 3151973b31a0..f7f5e09267d2 100644 --- a/yarn-project/p2p/src/services/service.ts +++ b/yarn-project/p2p/src/services/service.ts @@ -201,6 +201,9 @@ export interface PeerDiscoveryService extends EventEmitter { on(event: 'peer:discovered', listener: (enr: ENR) => void): this; emit(event: 'peer:discovered', enr: ENR): boolean; + on(event: 'ip:changed', listener: (ip: string) => void): this; + emit(event: 'ip:changed', ip: string): boolean; + getStatus(): PeerDiscoveryState; getEnr(): ENR | undefined; diff --git a/yarn-project/p2p/src/test-helpers/mock-pubsub.ts b/yarn-project/p2p/src/test-helpers/mock-pubsub.ts index c6ec55b7b8b6..4e5f234ca440 100644 --- a/yarn-project/p2p/src/test-helpers/mock-pubsub.ts +++ b/yarn-project/p2p/src/test-helpers/mock-pubsub.ts @@ -224,6 +224,13 @@ export class MockPubSub implements PubSubLibp2p { get services() { return { pubsub: this.gossipSub, + components: { + addressManager: { + addObservedAddr: () => {}, + confirmObservedAddr: () => {}, + removeObservedAddr: () => {}, + }, + }, }; } diff --git a/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts b/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts index 624123df0bc6..74ea535ebe1a 100644 --- a/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts +++ b/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts @@ -76,6 +76,10 @@ export async function createLibp2pNode( identify: identify({ protocolPrefix: 'aztec', }), + components: (components: { connectionManager: any; addressManager: any }) => ({ + connectionManager: components.connectionManager, + addressManager: components.addressManager, + }), }, }; diff --git a/yarn-project/p2p/src/util.ts b/yarn-project/p2p/src/util.ts index 37bba2f5f0b9..af5f3907a699 100644 --- a/yarn-project/p2p/src/util.ts +++ b/yarn-project/p2p/src/util.ts @@ -1,5 +1,5 @@ import { SecretValue } from '@aztec/foundation/config'; -import type { Logger } from '@aztec/foundation/log'; +import { type Logger, createLogger } from '@aztec/foundation/log'; import type { AztecAsyncKVStore, AztecAsyncSingleton } from '@aztec/kv-store'; import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; @@ -7,7 +7,7 @@ import type { GossipSub } from '@chainsafe/libp2p-gossipsub'; import { generateKeyPair, marshalPrivateKey, unmarshalPrivateKey } from '@libp2p/crypto/keys'; import type { Identify } from '@libp2p/identify'; import type { PeerId, PrivateKey } from '@libp2p/interface'; -import type { ConnectionManager } from '@libp2p/interface-internal'; +import type { AddressManager, ConnectionManager } from '@libp2p/interface-internal'; import { createFromPrivKey } from '@libp2p/peer-id-factory'; import { resolve } from 'dns/promises'; import { promises as fs } from 'fs'; @@ -19,6 +19,13 @@ import type { P2PConfig } from './config.js'; const PEER_ID_DATA_DIR_FILE = 'p2p-private-key'; +const PUBLIC_IP_SERVICES = [ + 'https://api.ipify.org/', + 'https://checkip.amazonaws.com/', + 'https://ifconfig.me/ip', + 'https://icanhazip.com/', +]; + export interface PubSubLibp2p extends Pick { services: { pubsub: Pick< @@ -31,6 +38,9 @@ export interface PubSubLibp2p extends Pick & { score: Pick }; + components: { + addressManager: Pick; + }; }; } @@ -39,6 +49,7 @@ export type FullLibp2p = Libp2p<{ pubsub: GossipSub; components: { connectionManager: ConnectionManager; + addressManager: AddressManager; }; }>; @@ -60,16 +71,24 @@ export function convertToMultiaddr(address: string, port: number, protocol: 'tcp } /** - * Queries the public IP address of the machine. + * Queries the public IP address of the machine, trying multiple services in order. */ export async function getPublicIp(): Promise { - const resp = await fetch('https://checkip.amazonaws.com/'); - const text = await resp.text(); - const address = text.trim(); - if (!isValidIpAddress(address)) { - throw new Error(`Received invalid IP address from checkip service: ${address}`); + const errors: string[] = []; + for (const url of PUBLIC_IP_SERVICES) { + try { + const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); + const text = await resp.text(); + const address = text.trim(); + if (isValidIpAddress(address)) { + return address; + } + errors.push(`${url}: invalid IP "${address}"`); + } catch (err: any) { + errors.push(`${url}: ${err.message ?? err}`); + } } - return address; + throw new Error(`Failed to determine public IP from all services:\n${errors.join('\n')}`); } export function isValidIpAddress(address: string): boolean { @@ -107,18 +126,20 @@ export async function configureP2PClientAddresses( ): Promise { const config = { ..._config }; const { p2pIp, queryForIp, p2pBroadcastPort, p2pPort } = config; + const logger = createLogger('p2p:config'); // If no broadcast port is provided, use the given p2p port as the broadcast port if (!p2pBroadcastPort) { config.p2pBroadcastPort = p2pPort; } - // check if no announce IP was provided - if (!p2pIp) { - if (queryForIp) { - const publicIp = await getPublicIp(); - config.p2pIp = publicIp; - } + // Resolve the initial public IP so the ENR and announce address are set at startup. + // If queryForIp is enabled, discv5 will also track IP changes at runtime via enrUpdate. + if (!p2pIp && queryForIp) { + config.p2pIp = await getPublicIp(); + logger.info('Resolved initial public IP for P2P', { ip: config.p2pIp, queryForIp }); + } else if (p2pIp) { + logger.info('Using configured static P2P IP', { ip: p2pIp, queryForIp }); } // TODO(md): guard against setting a local ip address as the announce ip From 74535621345ead23ed58c80d1a2a305a974fdaba Mon Sep 17 00:00:00 2001 From: spypsy Date: Mon, 20 Apr 2026 12:57:18 +0000 Subject: [PATCH 02/55] public IP services as config --- yarn-project/foundation/src/config/env_var.ts | 1 + yarn-project/p2p/src/bootstrap/bootstrap.ts | 2 +- yarn-project/p2p/src/config.test.ts | 10 +++++++ yarn-project/p2p/src/config.ts | 26 +++++++++++++++++++ .../services/discv5/discv5_service.test.ts | 3 ++- .../p2p/src/test-helpers/reqresp-nodes.ts | 3 ++- yarn-project/p2p/src/util.ts | 16 ++++-------- 7 files changed, 47 insertions(+), 14 deletions(-) diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index ec423746dcab..09231836b5bd 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -135,6 +135,7 @@ export type EnvVar = | 'P2P_PEER_FAILED_BAN_TIME_MS' | 'P2P_PEER_PENALTY_VALUES' | 'P2P_QUERY_FOR_IP' + | 'P2P_PUBLIC_IP_SERVICES' | 'P2P_REQRESP_INDIVIDUAL_REQUEST_TIMEOUT_MS' | 'P2P_REQRESP_DIAL_TIMEOUT_MS' | 'P2P_REQRESP_OVERALL_REQUEST_TIMEOUT_MS' diff --git a/yarn-project/p2p/src/bootstrap/bootstrap.ts b/yarn-project/p2p/src/bootstrap/bootstrap.ts index 86ed966e6544..4f5ad00dd770 100644 --- a/yarn-project/p2p/src/bootstrap/bootstrap.ts +++ b/yarn-project/p2p/src/bootstrap/bootstrap.ts @@ -40,7 +40,7 @@ export class BootstrapNode implements P2PBootstrapApi { if (!p2pIp) { if (queryForIp) { this.logger.info('Querying for public IP address...'); - const publicIp = await getPublicIp(); + const publicIp = await getPublicIp(config.publicIpServices); p2pIp = publicIp; this.logger.info(`Found public IP address: ${publicIp}`); } diff --git a/yarn-project/p2p/src/config.test.ts b/yarn-project/p2p/src/config.test.ts index 02185e3322c2..ca751857a05a 100644 --- a/yarn-project/p2p/src/config.test.ts +++ b/yarn-project/p2p/src/config.test.ts @@ -83,4 +83,14 @@ describe('config', () => { const config = getP2PDefaultConfig(); expect(config.txCollectionMissingTxsCollectorType).toBe('new'); }); + + it('defaults public IP service URLs', () => { + const config = getP2PDefaultConfig(); + expect(config.publicIpServices).toEqual([ + 'https://api.ipify.org/', + 'https://checkip.amazonaws.com/', + 'https://ifconfig.me/ip', + 'https://icanhazip.com/', + ]); + }); }); diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index 8850545616fd..b8d1b6efea4b 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -116,6 +116,11 @@ export interface P2PConfig /** If announceUdpAddress or announceTcpAddress are not provided, query for the IP address of the machine. Default is false. */ queryForIp: boolean; + /** + * HTTPS URLs that return plain-text public IPv4, tried in order when resolving the announce IP (e.g. when `queryForIp` is true and `p2pIp` is unset). + */ + publicIpServices: string[]; + /** The interval of the gossipsub heartbeat to perform maintenance tasks. */ gossipsubInterval: number; @@ -220,6 +225,14 @@ export interface P2PConfig export const DEFAULT_P2P_PORT = 40400; +/** Default endpoints used to discover this machine's public IPv4 when `queryForIp` is enabled. */ +export const DEFAULT_PUBLIC_IP_SERVICES: string[] = [ + 'https://api.ipify.org/', + 'https://checkip.amazonaws.com/', + 'https://ifconfig.me/ip', + 'https://icanhazip.com/', +]; + export const p2pConfigMappings: ConfigMappingsType = { validateMaxTxsPerBlock: { env: 'VALIDATOR_MAX_TX_PER_BLOCK', @@ -338,6 +351,17 @@ export const p2pConfigMappings: ConfigMappingsType = { 'If announceUdpAddress or announceTcpAddress are not provided, query for the IP address of the machine. Default is false.', ...booleanConfigHelper(), }, + publicIpServices: { + env: 'P2P_PUBLIC_IP_SERVICES', + parseEnv: (val: string) => + val + .split(',') + .map(s => s.trim()) + .filter(Boolean), + description: + 'Comma-separated HTTPS URLs that return plain-text public IPv4. Used when P2P_QUERY_FOR_IP is true and P2P_IP is unset. Tried in order until one succeeds.', + defaultValue: DEFAULT_PUBLIC_IP_SERVICES, + }, gossipsubInterval: { env: 'P2P_GOSSIPSUB_INTERVAL_MS', description: 'The interval of the gossipsub heartbeat to perform maintenance tasks.', @@ -562,6 +586,7 @@ export type BootnodeConfig = Pick< | 'bootstrapNodes' | 'listenAddress' | 'queryForIp' + | 'publicIpServices' > & Required> & Pick & @@ -579,6 +604,7 @@ const bootnodeConfigKeys: (keyof BootnodeConfig)[] = [ 'bootstrapNodes', 'l1ChainId', 'queryForIp', + 'publicIpServices', ]; export const bootnodeConfigMappings = pickConfigMappings( diff --git a/yarn-project/p2p/src/services/discv5/discv5_service.test.ts b/yarn-project/p2p/src/services/discv5/discv5_service.test.ts index f3fec17d4fe0..8dc4d876883d 100644 --- a/yarn-project/p2p/src/services/discv5/discv5_service.test.ts +++ b/yarn-project/p2p/src/services/discv5/discv5_service.test.ts @@ -11,7 +11,7 @@ import { createSecp256k1PeerId } from '@libp2p/peer-id-factory'; import type { IDiscv5CreateOptions } from '@nethermindeth/discv5'; import { BootstrapNode } from '../../bootstrap/bootstrap.js'; -import { type BootnodeConfig, type P2PConfig, getP2PDefaultConfig } from '../../config.js'; +import { type BootnodeConfig, DEFAULT_PUBLIC_IP_SERVICES, type P2PConfig, getP2PDefaultConfig } from '../../config.js'; import { AZTEC_ENR_CLIENT_VERSION_KEY } from '../../types/index.js'; import { PeerDiscoveryState } from '../service.js'; import { DiscV5Service } from './discV5_service.js'; @@ -48,6 +48,7 @@ describe('Discv5Service', () => { dataStoreMapSizeKb: 0, bootstrapNodes: [], queryForIp: false, + publicIpServices: DEFAULT_PUBLIC_IP_SERVICES, ...emptyChainConfig, }; diff --git a/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts b/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts index 74ea535ebe1a..b26d3e3f6e61 100644 --- a/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts +++ b/yarn-project/p2p/src/test-helpers/reqresp-nodes.ts @@ -30,7 +30,7 @@ import getPort from 'get-port'; import { type Libp2p, type Libp2pOptions, createLibp2p } from 'libp2p'; import { BootstrapNode } from '../bootstrap/bootstrap.js'; -import type { BootnodeConfig, P2PConfig } from '../config.js'; +import { type BootnodeConfig, DEFAULT_PUBLIC_IP_SERVICES, type P2PConfig } from '../config.js'; import type { MemPools } from '../mem_pools/interface.js'; import { DiscV5Service } from '../services/discv5/discV5_service.js'; import { APP_SPECIFIC_WEIGHT } from '../services/gossipsub/scoring.js'; @@ -294,6 +294,7 @@ export function createBootstrapNodeConfig(privateKey: string, port: number, chai bootstrapNodes: [], listenAddress: '127.0.0.1', queryForIp: false, + publicIpServices: DEFAULT_PUBLIC_IP_SERVICES, }; } diff --git a/yarn-project/p2p/src/util.ts b/yarn-project/p2p/src/util.ts index af5f3907a699..1f8d5973984b 100644 --- a/yarn-project/p2p/src/util.ts +++ b/yarn-project/p2p/src/util.ts @@ -15,17 +15,10 @@ import type { Libp2p } from 'libp2p'; import net from 'net'; import path from 'path'; -import type { P2PConfig } from './config.js'; +import { DEFAULT_PUBLIC_IP_SERVICES, type P2PConfig } from './config.js'; const PEER_ID_DATA_DIR_FILE = 'p2p-private-key'; -const PUBLIC_IP_SERVICES = [ - 'https://api.ipify.org/', - 'https://checkip.amazonaws.com/', - 'https://ifconfig.me/ip', - 'https://icanhazip.com/', -]; - export interface PubSubLibp2p extends Pick { services: { pubsub: Pick< @@ -72,10 +65,11 @@ export function convertToMultiaddr(address: string, port: number, protocol: 'tcp /** * Queries the public IP address of the machine, trying multiple services in order. + * @param services - HTTPS URLs to try; defaults to {@link DEFAULT_PUBLIC_IP_SERVICES}. */ -export async function getPublicIp(): Promise { +export async function getPublicIp(services: string[] = DEFAULT_PUBLIC_IP_SERVICES): Promise { const errors: string[] = []; - for (const url of PUBLIC_IP_SERVICES) { + for (const url of services) { try { const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }); const text = await resp.text(); @@ -136,7 +130,7 @@ export async function configureP2PClientAddresses( // Resolve the initial public IP so the ENR and announce address are set at startup. // If queryForIp is enabled, discv5 will also track IP changes at runtime via enrUpdate. if (!p2pIp && queryForIp) { - config.p2pIp = await getPublicIp(); + config.p2pIp = await getPublicIp(config.publicIpServices); logger.info('Resolved initial public IP for P2P', { ip: config.p2pIp, queryForIp }); } else if (p2pIp) { logger.info('Using configured static P2P IP', { ip: p2pIp, queryForIp }); From ed3d5773b0ef6c272ca76b69e99656b2a2becc8b Mon Sep 17 00:00:00 2001 From: spypsy Date: Wed, 29 Apr 2026 13:47:17 +0000 Subject: [PATCH 03/55] fix deploy-next-net --- .github/workflows/deploy-next-net.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-next-net.yml b/.github/workflows/deploy-next-net.yml index 9cdacbc36f84..0cb509920302 100644 --- a/.github/workflows/deploy-next-net.yml +++ b/.github/workflows/deploy-next-net.yml @@ -67,6 +67,6 @@ jobs: with: network: next-net semver: ${{ needs.get-image-tag.outputs.semver }} - docker_image: "aztecprotocol/aztec:${{ needs.get-image-tag.outputs.tag }}" + aztec_docker_image: "aztecprotocol/aztec:${{ needs.get-image-tag.outputs.tag }}" ref: ${{ github.ref }} secrets: inherit From 9930ab15461ddf6264d92af688ccc7e965c4648b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 6 May 2026 04:54:00 -0300 Subject: [PATCH 04/55] fix(test): warp L1 forward when proposer scan hits EpochNotStable (#22967) ## Motivation The `e2e_epochs/epochs_missed_l1_publish` test fails intermittently when its proposer-discovery scan looks too far into the future. The L1 rollup contract reverts with `ValidatorSelection__EpochNotStable` for any epoch whose randao sample timestamp is still ahead of `block.timestamp`, and the test was scanning up to 60 slots (~15 epochs at the test's epoch duration) ahead, well past the queryable horizon. ## Approach Wrap the proposer scan in a retry loop that catches `EpochNotStable`, warps L1 forward by one epoch, and re-queries the same candidate. After each warp the scan also re-anchors the candidate to keep the +4 slot margin from the new "now", so subsequent steps (the warp to `slotZero` and sequencer start-up) still have headroom. ## Changes - **end-to-end (tests)**: Replace the bounded `for` loop in `epochs_missed_l1_publish.test.ts` with a try/catch retry that warps L1 on `EpochNotStable`. --- .../epochs_missed_l1_publish.test.ts | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts index 9ab521186a71..99507d921656 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts @@ -107,29 +107,51 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { // Find slotOne (>=4 ahead) such that proposers for slotOne, slotTwo, slotThree are three // distinct validators. The +4 margin (vs +2 in equivocation) gives the warp+sequencer-start // path enough headroom to reach the build window for slotZero even if node creation jitters. - const { slot: currentSlot } = test.epochCache.getEpochAndSlotNow(); - const scanStart = currentSlot + 4; - const scanEnd = currentSlot + 60; + // + // The L1 rollup contract only exposes proposers for epochs whose randao seed is "stable" + // (i.e. queryable on L1 right now). When we look too far into the future the contract + // reverts with `ValidatorSelection__EpochNotStable`. We handle this by warping L1 forward + // one epoch at a time and retrying — after each warp the previously-unstable epoch becomes + // queryable, and we bump the candidate to keep the +4 slot margin from the new "now". let slotOne: SlotNumber | undefined; let proposerOne: EthAddress | undefined; let proposerTwo: EthAddress | undefined; let proposerThree: EthAddress | undefined; - for (let candidate = scanStart; candidate <= scanEnd; candidate++) { - const [p1, p2, p3] = await Promise.all([ - test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate)), - test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 1)), - test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 2)), - ]); - if (p1 && p2 && p3 && !p1.equals(p2) && !p1.equals(p3) && !p2.equals(p3)) { - slotOne = SlotNumber(candidate); - proposerOne = p1; - proposerTwo = p2; - proposerThree = p3; - break; + let candidate = Number(test.epochCache.getEpochAndSlotNow().slot) + 4; + const maxAttempts = 200; + for (let attempt = 0; attempt < maxAttempts && slotOne === undefined; attempt++) { + try { + const [p1, p2, p3] = await Promise.all([ + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate)), + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 1)), + test.epochCache.getProposerAttesterAddressInSlot(SlotNumber(candidate + 2)), + ]); + if (p1 && p2 && p3 && !p1.equals(p2) && !p1.equals(p3) && !p2.equals(p3)) { + slotOne = SlotNumber(candidate); + proposerOne = p1; + proposerTwo = p2; + proposerThree = p3; + break; + } + candidate++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('EpochNotStable')) { + throw err; + } + const block = await test.l1Client.getBlock({ includeTransactions: false }); + const warpBy = test.epochDuration * test.L2_SLOT_DURATION_IN_S; + const newTs = Number(block.timestamp) + warpBy; + logger.warn(`Hit EpochNotStable at candidate ${candidate}, warping L1 forward by ${warpBy}s to ${newTs}`); + await test.context.cheatCodes.eth.warp(newTs, { resetBlockInterval: true }); + const newCurrentSlot = Number(test.epochCache.getEpochAndSlotNow().slot); + if (candidate < newCurrentSlot + 4) { + candidate = newCurrentSlot + 4; + } } } if (slotOne === undefined || !proposerOne || !proposerTwo || !proposerThree) { - throw new Error(`Could not find a slot in [${scanStart}, ${scanEnd}] with three distinct consecutive proposers`); + throw new Error(`Could not find a slot with three distinct consecutive proposers after ${maxAttempts} attempts`); } const slotZero = SlotNumber(slotOne - 1); From 355db4349c15223a16d5a47c97a7e1991bba76c4 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 6 May 2026 04:54:51 -0300 Subject: [PATCH 05/55] test(e2e): fail epochs tests on proposer-rollup-check-failed (#22965) These sequencer errors were ignored in some tests. Removing that since this error should not happen. If it does, it's cause for analysis. --- .../epochs_high_tps_block_building.test.ts | 15 ++++----------- .../e2e_epochs/epochs_missed_l1_publish.test.ts | 3 --- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts index 303436897d52..ba1983f0fe93 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts @@ -212,17 +212,10 @@ describe('e2e_epochs/epochs_high_tps_block_building', () => { expect(Math.max(...blocks.map(b => b.body!.txEffects.length))).toEqual(TXS_PER_BLOCK); expect(Math.max(...checkpoints.map(c => c.length))).toEqual(BLOCKS_PER_CHECKPOINT); - // Expect no failures from sequencers during block building. Filter out the self-proposal 'Rollup contract - // check failed' spam: when a validator proposes two consecutive checkpoints, the archiver's sequentiality - // guard rejects persisting the second proposed checkpoint until the first is confirmed on L1, so the next - // pipelining cycle falls through without simulation overrides and canProposeAt reverts until state catches - // up. Tracked in A-910. - const significantFailEvents = failEvents.filter( - e => !(e.type === 'proposer-rollup-check-failed' && e.reason === 'Rollup contract check failed'), - ); - if (significantFailEvents.length > 0) { - logger.error(`Failed events from sequencers`, significantFailEvents); + // Expect no failures from sequencers during block building + if (failEvents.length > 0) { + logger.error(`Failed events from sequencers`, failEvents); } - expect(significantFailEvents).toEqual([]); + expect(failEvents).toEqual([]); }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts index 99507d921656..073e9b03afef 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts @@ -374,9 +374,6 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { ) { return false; } - if (e.type === 'proposer-rollup-check-failed') { - return false; - } return true; }); if (unexpectedFailEvents.length > 0) { From 4a996382a5052d86790debf9ff0133dc3856c970 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Wed, 6 May 2026 05:08:24 -0400 Subject: [PATCH 06/55] fix: grafana switch to aztec_status="proposed" (#22978) --- spartan/metrics/grafana/alerts/rules.yaml | 2 +- spartan/metrics/grafana/dashboards/aztec_network.json | 2 +- spartan/metrics/grafana/dashboards/fisherman.json | 2 +- spartan/metrics/grafana/dashboards/network-tps.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spartan/metrics/grafana/alerts/rules.yaml b/spartan/metrics/grafana/alerts/rules.yaml index 553cac34419b..308def0aedaf 100644 --- a/spartan/metrics/grafana/alerts/rules.yaml +++ b/spartan/metrics/grafana/alerts/rules.yaml @@ -443,7 +443,7 @@ groups: datasourceUid: spartan-metrics-prometheus model: editorMode: code - expr: max by (k8s_namespace_name) (increase(aztec_archiver_block_height{k8s_namespace_name!="",aztec_status=""}[10m])) + expr: max by (k8s_namespace_name) (increase(aztec_archiver_block_height{k8s_namespace_name!="",aztec_status="proposed"}[10m])) instant: true intervalMs: 60000 legendFormat: __auto diff --git a/spartan/metrics/grafana/dashboards/aztec_network.json b/spartan/metrics/grafana/dashboards/aztec_network.json index 69603af7cfc3..e6fae556a5e7 100644 --- a/spartan/metrics/grafana/dashboards/aztec_network.json +++ b/spartan/metrics/grafana/dashboards/aztec_network.json @@ -2593,7 +2593,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "max by(service_name, aztec_status) (label_replace(aztec_archiver_block_height{service_namespace=\"$namespace\"}, \"aztec_status\", \"pending\", \"aztec_status\", \"^$\"))", + "expr": "max by(service_name, aztec_status) (aztec_archiver_block_height{service_namespace=\"$namespace\", aztec_status=~\"proposed|proven\"})", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, diff --git a/spartan/metrics/grafana/dashboards/fisherman.json b/spartan/metrics/grafana/dashboards/fisherman.json index 07cb45e62fec..feb40670a20f 100644 --- a/spartan/metrics/grafana/dashboards/fisherman.json +++ b/spartan/metrics/grafana/dashboards/fisherman.json @@ -3161,7 +3161,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "max by(service_name, aztec_status) (label_replace(aztec_archiver_block_height{service_namespace=\"$namespace\"}, \"aztec_status\", \"pending\", \"aztec_status\", \"^$\"))", + "expr": "max by(service_name, aztec_status) (aztec_archiver_block_height{service_namespace=\"$namespace\", aztec_status=~\"proposed|proven\"})", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, diff --git a/spartan/metrics/grafana/dashboards/network-tps.json b/spartan/metrics/grafana/dashboards/network-tps.json index fb2276e7944a..be0a3087d8a1 100644 --- a/spartan/metrics/grafana/dashboards/network-tps.json +++ b/spartan/metrics/grafana/dashboards/network-tps.json @@ -834,7 +834,7 @@ }, "disableTextWrap": false, "editorMode": "builder", - "expr": "max by(service_name, aztec_status) (label_replace(aztec_archiver_block_height{service_namespace=\"$namespace\"}, \"aztec_status\", \"pending\", \"aztec_status\", \"^$\"))", + "expr": "max by(service_name, aztec_status) (aztec_archiver_block_height{service_namespace=\"$namespace\", aztec_status=~\"proposed|proven\"})", "fullMetaSearch": false, "includeNullMetadata": true, "instant": false, From 3601722242b4d6592b70105c3e5d2efc5d4bf0b7 Mon Sep 17 00:00:00 2001 From: Alex Gherghisan Date: Wed, 6 May 2026 11:00:27 +0100 Subject: [PATCH 07/55] chore: update benchmark scraper (#22984) . --- .../bench_10tps/bench_output.schema.json | 12 +- spartan/scripts/bench_10tps/bench_scrape.ts | 256 ++++++++++++++---- 2 files changed, 211 insertions(+), 57 deletions(-) diff --git a/spartan/scripts/bench_10tps/bench_output.schema.json b/spartan/scripts/bench_10tps/bench_output.schema.json index 8820573083b2..a6c57437fd06 100644 --- a/spartan/scripts/bench_10tps/bench_output.schema.json +++ b/spartan/scripts/bench_10tps/bench_output.schema.json @@ -218,7 +218,7 @@ "targetTps": { "type": "number" }, "inclusionTpsMean": { "type": ["number", "null"], - "description": "Exact block-log inclusion throughput over the observed inclusion window: totalTxsMined / (inclusionEndedAt - startedAt)." + "description": "Inclusion throughput over the observed inclusion window. Uses exact block-log throughput when block records are available, otherwise falls back to the Prometheus inclusionTps mean." }, "inclusionTpsPeak": { "type": ["number", "null"], @@ -231,8 +231,14 @@ "blockBuildDurationP95Ms": { "type": ["number", "null"] }, "publicProcessorTxDurationP50Ms": { "type": ["number", "null"] }, "publicProcessorTxDurationP95Ms": { "type": ["number", "null"] }, - "totalTxsMined": { "type": ["integer", "null"] }, - "totalTxsFailed": { "type": ["integer", "null"] }, + "totalTxsMined": { + "type": ["integer", "null"], + "description": "Exact sum from per-block logs. Null when block logs were unavailable and inclusionTpsMean came from Prometheus." + }, + "totalTxsFailed": { + "type": ["integer", "null"], + "description": "Exact sum from per-block logs. Null when block logs were unavailable." + }, "totalSilentSkipCount": { "type": ["integer", "null"], "description": "Sum of per-block silentlySkippedCount. > 0 means the post-process blob-field revert path fired during the run." diff --git a/spartan/scripts/bench_10tps/bench_scrape.ts b/spartan/scripts/bench_10tps/bench_scrape.ts index 69c7f920e88a..3f819585bcfd 100755 --- a/spartan/scripts/bench_10tps/bench_scrape.ts +++ b/spartan/scripts/bench_10tps/bench_scrape.ts @@ -42,6 +42,7 @@ const STEP_SECONDS = 15; const DRAIN_BUFFER_SECONDS = 90; // OTel batch push 60s + one Prom scrape 15s + slack const PENDING_POLL_SECONDS = 30; const DEFAULT_MAX_PENDING_WAIT_SECONDS = 60 * 60; +const GCLOUD_LOG_FRESHNESS = env.BENCH_SCRAPE_GCLOUD_LOG_FRESHNESS ?? "2d"; // --- CLI --- @@ -485,7 +486,7 @@ async function gcloudRead(filter: string): Promise { GCP_PROJECT_ID, "--format=json", "--order=asc", - "--freshness=24h", + `--freshness=${GCLOUD_LOG_FRESHNESS}`, "--limit=50000", ], { stdio: ["ignore", "pipe", "pipe"] }, @@ -755,67 +756,203 @@ async function scrapeBlocks( startedAt: string, endedAt: string, ): Promise { - const filter = [ + const canonicalFilter = [ + `resource.labels.namespace_name="${NAMESPACE}"`, + `resource.labels.pod_name=~"${NAMESPACE}-(validator|rpc).*"`, + `jsonPayload.eventName="l2-block-handled"`, + timeFilter(startedAt, endedAt), + ].join(" AND "); + const builtFilter = [ + `resource.labels.namespace_name="${NAMESPACE}"`, + `resource.labels.pod_name=~"${NAMESPACE}-(validator|rpc).*"`, + `jsonPayload.eventName="l2-block-built"`, + timeFilter(startedAt, endedAt), + ].join(" AND "); + const processorFilter = [ `resource.labels.namespace_name="${NAMESPACE}"`, `resource.labels.pod_name=~"${NAMESPACE}-(validator|rpc).*"`, `jsonPayload.message=~"^Processed [0-9]+ successful txs and"`, timeFilter(startedAt, endedAt), ].join(" AND "); - const entries = await gcloudRead(filter); - // Each block is logged once per pod that processed it (validators + - // RPC-colocated full node sync). Dedupe by blockNumber, keep the earliest - // timestamp — that's most likely the proposer who built the block. - const byBlock = new Map(); - for (const entry of entries) { - const p = entry.jsonPayload; - if (!p) { - continue; - } - const blockNumber = - typeof p.blockNumber === "number" - ? p.blockNumber - : typeof p.blockNumber === "string" - ? Number(p.blockNumber) - : NaN; - if (!Number.isFinite(blockNumber)) { - continue; - } - const t = Date.parse(entry.timestamp); - const prev = byBlock.get(blockNumber); - if (!prev || t < prev.time) { - byBlock.set(blockNumber, { entry, time: t }); - } - } + const [canonicalEntries, builtEntries, processorEntries] = await Promise.all([ + gcloudRead(canonicalFilter), + gcloudRead(builtFilter).catch((err) => { + log("built-block log scrape failed, continuing without build durations", { + err: err instanceof Error ? err.message : String(err), + }); + return [] as GcloudEntry[]; + }), + gcloudRead(processorFilter).catch((err) => { + log( + "public-processor log scrape failed, continuing without gas/silent-skip fields", + { + err: err instanceof Error ? err.message : String(err), + }, + ); + return [] as GcloudEntry[]; + }), + ]); - if (byBlock.size === 0) { + const canonicalByBlock = dedupeBlockEntries(canonicalEntries, (entry) => ({ + blockNumber: numberPayloadField(entry.jsonPayload ?? {}, "blockNumber"), + txCount: numberPayloadField(entry.jsonPayload ?? {}, "txCount"), + time: Date.parse(entry.timestamp), + })); + const builtByBlock = entriesByBlock(builtEntries); + const processorByBlock = entriesByBlock(processorEntries); + + if (canonicalByBlock.size === 0) { return []; } - const blockNumbers = [...byBlock.keys()].sort((a, b) => a - b); + const blockNumbers = [...canonicalByBlock.keys()].sort((a, b) => a - b); const first = blockNumbers[0]; return blockNumbers.map((bn) => { - const { entry } = byBlock.get(bn)!; - const p = entry.jsonPayload!; + const canonical = canonicalByBlock.get(bn)!; + const p = canonical.jsonPayload!; + const txCount = finiteOrZero(numberPayloadField(p, "txCount")); + const built = chooseBestMatchingEntry( + builtByBlock.get(bn) ?? [], + txCount, + "txCount", + ); + const processor = chooseBestMatchingEntry( + processorByBlock.get(bn) ?? [], + txCount, + "successfulCount", + ); + const processorPayload = processor?.jsonPayload; return { blockNumber: bn, blockNumberInTest: bn - first, - minedAt: entry.timestamp, - successfulCount: Number(p.successfulCount ?? 0), - failedCount: Number(p.failedCount ?? 0), - silentlySkippedCount: Number(p.silentlySkippedCount ?? 0), - silentlySkippedDurationMs: Number(p.silentlySkippedDurationMs ?? 0), - buildDurationSeconds: Number(p.duration ?? 0), - totalPublicGas: p.totalPublicGas as + minedAt: canonical.timestamp, + successfulCount: txCount, + failedCount: finiteOrZero( + numberPayloadField(processorPayload ?? {}, "failedCount"), + ), + silentlySkippedCount: finiteOrZero( + numberPayloadField(processorPayload ?? {}, "silentlySkippedCount"), + ), + silentlySkippedDurationMs: finiteOrZero( + numberPayloadField(processorPayload ?? {}, "silentlySkippedDurationMs"), + ), + buildDurationSeconds: + built?.jsonPayload === undefined + ? finiteOrZero(numberPayloadField(processorPayload ?? {}, "duration")) + : finiteOrZero(numberPayloadField(built.jsonPayload, "duration")) / + 1000, + totalPublicGas: processorPayload?.totalPublicGas as | { daGas: number; l2Gas: number } | undefined, totalSizeInBytes: - typeof p.totalSizeInBytes === "number" ? p.totalSizeInBytes : undefined, + typeof processorPayload?.totalSizeInBytes === "number" + ? processorPayload.totalSizeInBytes + : undefined, source: "log", }; }); } +type BlockEntryProjection = { + blockNumber: number; + txCount: number; + time: number; +}; + +function dedupeBlockEntries( + entries: GcloudEntry[], + project: (entry: GcloudEntry) => BlockEntryProjection, +): Map { + const byBlock = new Map< + number, + { entry: GcloudEntry; projection: BlockEntryProjection } + >(); + for (const entry of entries) { + const projection = project(entry); + if ( + !Number.isFinite(projection.blockNumber) || + !Number.isFinite(projection.txCount) || + !Number.isFinite(projection.time) + ) { + continue; + } + const prev = byBlock.get(projection.blockNumber); + if (!prev || isBetterCanonicalBlockEntry(projection, prev.projection)) { + byBlock.set(projection.blockNumber, { entry, projection }); + } + } + return new Map( + [...byBlock.entries()].map(([blockNumber, value]) => [ + blockNumber, + value.entry, + ]), + ); +} + +function isBetterCanonicalBlockEntry( + candidate: BlockEntryProjection, + previous: BlockEntryProjection, +): boolean { + // Same tx count usually means the same block observed by another pod; keep + // the earliest timestamp. Different tx count implies a distinct block at the + // same height, so prefer the later observation as the best final-chain proxy. + if (candidate.txCount !== previous.txCount) { + return candidate.time > previous.time; + } + return candidate.time < previous.time; +} + +function entriesByBlock(entries: GcloudEntry[]): Map { + const out = new Map(); + for (const entry of entries) { + const blockNumber = numberPayloadField( + entry.jsonPayload ?? {}, + "blockNumber", + ); + if (!Number.isFinite(blockNumber)) { + continue; + } + const bucket = out.get(blockNumber) ?? []; + bucket.push(entry); + out.set(blockNumber, bucket); + } + return out; +} + +function chooseBestMatchingEntry( + entries: GcloudEntry[], + txCount: number, + txCountField: string, +): GcloudEntry | undefined { + const candidates = entries.filter( + (entry) => + numberPayloadField(entry.jsonPayload ?? {}, txCountField) === txCount, + ); + const source = candidates.length > 0 ? candidates : entries; + return source + .filter((entry) => Number.isFinite(Date.parse(entry.timestamp))) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp))[0]; +} + +function numberPayloadField( + payload: Record, + key: string, +): number { + const value = payload[key]; + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + return Number(value); + } + return NaN; +} + +function finiteOrZero(value: number): number { + return Number.isFinite(value) ? value : 0; +} + type ChainPrunedEvent = { at: string; type: "chainPruned"; @@ -1289,16 +1426,27 @@ async function buildSummary(a: SummaryArgs): Promise> { minedAtEpoch <= a.inclusionEndedAtEpoch ); }); - const totalTxsMined = inclusionBlocks.reduce( - (s, b) => s + b.successfulCount, - 0, - ); + const hasInclusionBlockRecords = inclusionBlocks.length > 0; + const totalTxsMined = hasInclusionBlockRecords + ? inclusionBlocks.reduce((s, b) => s + b.successfulCount, 0) + : null; + const promInclusionTpsMean = meanNonNull(inclusionPoints); const inclusionTpsMean = - a.windowSec > 0 + totalTxsMined !== null && a.windowSec > 0 ? totalTxsMined / a.windowSec - : meanNonNull(inclusionPoints); + : promInclusionTpsMean; const inclusionTpsPeak = maxNonNull(inclusionPoints); + if (!hasInclusionBlockRecords && promInclusionTpsMean !== null) { + log( + "No block records found in inclusion window; using Prometheus inclusion TPS mean for summary", + { + promInclusionTpsMean, + inclusionPointCount: inclusionPoints.length, + }, + ); + } + const safeInstant = async (promql: string): Promise => { try { return await queryInstant(promql, a.endedAtEpoch); @@ -1379,15 +1527,15 @@ async function buildSummary(a: SummaryArgs): Promise> { publicProcessorTxDurationP50Ms: ppTxP50, publicProcessorTxDurationP95Ms: ppTxP95, totalTxsMined, - totalTxsFailed: inclusionBlocks.reduce((s, b) => s + b.failedCount, 0), - totalSilentSkipCount: inclusionBlocks.reduce( - (s, b) => s + b.silentlySkippedCount, - 0, - ), - totalSilentSkipDurationMs: inclusionBlocks.reduce( - (s, b) => s + b.silentlySkippedDurationMs, - 0, - ), + totalTxsFailed: hasInclusionBlockRecords + ? inclusionBlocks.reduce((s, b) => s + b.failedCount, 0) + : null, + totalSilentSkipCount: hasInclusionBlockRecords + ? inclusionBlocks.reduce((s, b) => s + b.silentlySkippedCount, 0) + : null, + totalSilentSkipDurationMs: hasInclusionBlockRecords + ? inclusionBlocks.reduce((s, b) => s + b.silentlySkippedDurationMs, 0) + : null, reorgCount: reorgs.length, deepestReorgBlocks: deepest, }; From 4b563a646a6b32d09c347f4588d615432d7a78cc Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 6 May 2026 09:20:25 -0300 Subject: [PATCH 08/55] test(e2e): migrate simple epoch tests to pipelining (#22973) Enable pipelining on `epochs_first_slot` and `simple_block_building` --- .../src/e2e_epochs/epochs_first_slot.test.ts | 33 +++++++++++-------- .../epochs_missed_l1_publish.test.ts | 4 +++ .../epochs_simple_block_building.test.ts | 15 ++++----- .../end-to-end/src/e2e_epochs/epochs_test.ts | 12 +++++++ .../src/sequencer/sequencer.ts | 16 ++++----- 5 files changed, 50 insertions(+), 30 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_first_slot.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_first_slot.test.ts index a6a79a2e58d1..d10cf312f787 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_first_slot.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_first_slot.test.ts @@ -29,8 +29,7 @@ jest.setTimeout(1000 * 60 * 10); const NODE_COUNT = 8; const COMMITTEE_SIZE = 3; -const TX_COUNT = 2; -const EPOCH = EpochNumber(4); +const TX_COUNT = 8; // Spawns NODE_COUNT validator nodes, connected via a mocked gossip sub network, but sets // committee size to 3. Warps to immediately before the beginning of an epoch, and checks @@ -54,6 +53,7 @@ describe('e2e_epochs/epochs_first_slot', () => { }); // Setup context with the given set of validators, no reorgs, mocked gossip sub network, and no anvil test watcher. + // We expect 4 blocks per checkpoint with this config test = await EpochsTestContext.setup({ numberOfAccounts: 0, initialValidators: validators, @@ -62,6 +62,8 @@ describe('e2e_epochs/epochs_first_slot', () => { aztecProofSubmissionEpochs: 1024, aztecEpochDuration: 32, aztecSlotDurationInL1Slots: 3, + ethereumSlotDuration: 12, + blockDurationMs: 6000, startProverNode: false, aztecTargetCommitteeSize: COMMITTEE_SIZE, enforceTimeTable: true, @@ -70,6 +72,8 @@ describe('e2e_epochs/epochs_first_slot', () => { attestationPropagationTime: 0.5, archiverPollingIntervalMS: 200, skipInitialSequencer: true, + enableProposerPipelining: true, + inboxLag: 2, }); ({ context, logger } = test); @@ -110,9 +114,18 @@ describe('e2e_epochs/epochs_first_slot', () => { const sequencers = nodes.map(node => node.getSequencer()!); const { failEvents } = test.watchSequencerEvents(sequencers, i => ({ validator: validators[i].attester })); - // Warp to before the first slot of an epoch, so that the sequencers are ready to build blocks. - const [epochStart] = getTimestampRangeForEpoch(EPOCH, test.constants); - await test.context.cheatCodes.eth.warp(Number(epochStart) - test.L1_BLOCK_TIME_IN_S, { + // Jump to the beginning of two epochs from now + const currentEpoch = (await test.monitor.run()).l2EpochNumber; + const epoch = EpochNumber(currentEpoch + 2); + + // Warp so that the next pipelined build cycle targets the first slot of the epoch. Under + // proposer pipelining the build window starts one L2 slot earlier than the target slot + // so we want wall-clock to enter `firstSlot - 1` (the last slot of the previous epoch) before + // the next L1 block. Subtracting `L2_SLOT_DURATION + L1_BLOCK_TIME` puts us one L1 block before that + // build slot starts, so the proposer for `firstSlot` gets the full build window available + // before the epoch boundary is crossed on L1. + const [epochStart] = getTimestampRangeForEpoch(epoch, test.constants); + await test.context.cheatCodes.eth.warp(Number(epochStart) - test.L2_SLOT_DURATION_IN_S - test.L1_BLOCK_TIME_IN_S, { resetBlockInterval: true, }); @@ -126,7 +139,7 @@ describe('e2e_epochs/epochs_first_slot', () => { logger.warn(`All txs have been mined`); // Check that the first two slots of the epoch have a block - const [firstSlot] = getSlotRangeForEpoch(EPOCH, test.constants); + const [firstSlot] = getSlotRangeForEpoch(epoch, test.constants); const secondSlot = SlotNumber(firstSlot + 1); logger.warn(`Waiting until blocks are synced for slots ${firstSlot} and ${secondSlot}`); await retryUntil( @@ -141,12 +154,6 @@ describe('e2e_epochs/epochs_first_slot', () => { 1, ); - // Expect no failures from sequencers during block building. - // The following error is marked as a flake on the test ignore patterns, - // so we can have this test run for a while before it breaks CI on a recoverable error. - if (failEvents.length > 0) { - logger.error(`Failed events from sequencers`, failEvents); - } - expect(failEvents).toEqual([]); + test.assertNoFailuresFromSequencers(failEvents); }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts index 073e9b03afef..49a99125455d 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts @@ -374,6 +374,10 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { ) { return false; } + // Expected + if (e.type === 'pipelined-checkpoint-discarded') { + return false; + } return true; }); if (unexpectedFailEvents.length > 0) { diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_simple_block_building.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_simple_block_building.test.ts index 3797e2fb2d60..25aae6ead749 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_simple_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_simple_block_building.test.ts @@ -23,7 +23,7 @@ import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 10); const NODE_COUNT = 3; -const TX_COUNT = 3; +const TX_COUNT = 8; // Sets up a lightweight RPC-only node without any account deployment, registers a test contract // locally, then spawns NODE_COUNT validator nodes connected via a mocked gossip sub network. @@ -53,9 +53,13 @@ describe('e2e_epochs/epochs_simple_block_building', () => { mockGossipSubNetwork: true, disableAnvilTestWatcher: true, aztecProofSubmissionEpochs: 1024, + aztecSlotDurationInL1Slots: 3, + ethereumSlotDuration: 12, + blockDurationMs: 6000, startProverNode: false, enforceTimeTable: true, skipInitialSequencer: true, + enableProposerPipelining: true, inboxLag: 2, }); @@ -100,12 +104,7 @@ describe('e2e_epochs/epochs_simple_block_building', () => { ); logger.warn(`All txs have been mined`); - // Expect no failures from sequencers during block building. - // The following error is marked as a flake on the test ignore patterns, - // so we can have this test run for a while before it breaks CI on a recoverable error. - if (failEvents.length > 0) { - logger.error(`Failed events from sequencers`, failEvents); - } - expect(failEvents).toEqual([]); + // Expect no failures from sequencers during block building + test.assertNoFailuresFromSequencers(failEvents); }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 7fd2f8c5aa7c..7bd24a0728da 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -497,6 +497,7 @@ export class EpochsTestContext { public watchSequencerEvents( sequencers: SequencerClient[], getMetadata: (i: number) => Record = () => ({}), + additionalFailEventKeys: (keyof SequencerEvents)[] = [], ) { const stateChanges: TrackedSequencerEvent[] = []; const failEvents: TrackedSequencerEvent[] = []; @@ -507,6 +508,10 @@ export class EpochsTestContext { 'block-build-failed', 'checkpoint-publish-failed', 'proposer-rollup-check-failed', + 'checkpoint-error', + 'checkpoint-publish-failed', + 'pipelined-checkpoint-discarded', + ...additionalFailEventKeys, ]; const makeEvent = ( @@ -545,4 +550,11 @@ export class EpochsTestContext { return { failEvents, stateChanges }; } + + public assertNoFailuresFromSequencers(failEvents: TrackedSequencerEvent[]) { + if (failEvents.length > 0) { + this.logger.error(`Failed events from sequencers`, failEvents); + } + expect(failEvents).toEqual([]); + } } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 0e7f3ab55c29..e1dff8c91f93 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -181,17 +181,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter Date: Wed, 6 May 2026 10:00:31 -0300 Subject: [PATCH 09/55] chore: remove top-level yarn.lock (#22987) Had been accidentally introduced in https://github.com/AztecProtocol/aztec-packages/pull/22759 --- yarn.lock | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index fb57ccd13afb..000000000000 --- a/yarn.lock +++ /dev/null @@ -1,4 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - From 7f384379d339956303f4d6cea4052ab26fe74b3b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 6 May 2026 12:12:10 -0300 Subject: [PATCH 10/55] refactor(archiver)!: unify L2BlockSource checkpoint lookups via query objects (#22933) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Clean up the checkpoint side of `L2BlockSource`. PR #22809 already collapsed the block-side API into 4 query-shaped methods over 2 return types; the checkpoint surface was left with the pre-refactor sprawl (9 narrow methods over 4 return shapes, parallel by-number / by-range / by-epoch entrypoints, and a wire-level alias that conflated proposed and confirmed checkpoints). This change applies the same simplification. Fixes A-979 ## Approach `L2BlockSource` checkpoint methods reduce to 4 query-shaped readers (`getCheckpoint`, `getCheckpoints`, `getCheckpointData`, `getCheckpointsData`) over 2 return shapes (`PublishedCheckpoint`, `CheckpointData`), plus a polymorphic `getProposedCheckpointData(query?)` for the proposed-only path. Three new query types live next to `BlockQuery`/`BlocksQuery`. On-disk format and `BlockStore` primitives are unchanged — the simplification is at the API boundary. The public RPC's `getCheckpoint` keeps the same wire signature but gains a confirmed→proposed fallback (for `{number}`/`{slot}`/`'proposed'` lookups) and `BadRequestError` guards for incompatible `include*` flags. ## API surface change ### Methods removed from `L2BlockSource` `getCheckpoints(from, limit)`, `getCheckpointData(n)`, `getCheckpointDataRange(from, limit)`, `getCheckpointsForEpoch(epoch)`, `getCheckpointsDataForEpoch(epoch)`, `getCheckpointNumberBySlot(slot)`, `getLastCheckpoint()`, `getLastProposedCheckpoint()`. Dead methods on `data_source_base` also removed: `getCheckpointHeader`, `getLastBlockNumberInCheckpoint`, `getSynchedCheckpointNumber`. ### Methods added to `L2BlockSource` ```ts getCheckpoint(query: CheckpointQuery): Promise getCheckpoints(query: CheckpointsQuery): Promise getCheckpointData(query: CheckpointQuery): Promise getCheckpointsData(query: CheckpointsQuery): Promise getProposedCheckpointData(query?: ProposedCheckpointQuery): Promise type CheckpointQuery = { number } | { slot } | { tag: 'checkpointed' | 'proven' | 'finalized' } type CheckpointsQuery = { from, limit } | { epoch } type ProposedCheckpointQuery = { number } | { slot } | { tag: 'proposed' } ``` ### Public RPC (`AztecNode`) wire-level changes - `getCheckpointsDataForEpoch(epoch)` removed; `getCheckpointsData(query: CheckpointsQuery)` added (range or epoch). - `'latest'` removed from `CheckpointParameter`. - `'proposed'` semantics changed: previously aliased to "latest L1-confirmed checkpoint" (a documented foot-gun); now `getCheckpoint('proposed')` strictly targets the proposed-checkpoint store, and `getCheckpointNumber('proposed')` returns the proposed-tip number with confirmed fallback. - `getCheckpoint({ number }) / ({ slot })` now check confirmed first then fall back to proposed; tag-based lookups (`'checkpointed'` / `'proven'` / `'finalized'`) do not fall back. - `getCheckpoint('proposed', { includeL1PublishInfo: true | includeAttestations: true })` and the same flags on a by-number/by-slot lookup that resolves to a proposed entry now throw `BadRequestError` (proposed checkpoints have no L1 publish info or attestations). ### Types kept `CheckpointData`, `CommonCheckpointData` (structural base of `CheckpointData` / `ProposedCheckpointInput`), `ProposedCheckpointData`, `ProposedCheckpointInput`, `PublishedCheckpoint`, `Checkpoint`. No structural-type deletions. Migration guidance for wallet/SDK consumers is in `docs/docs-developers/docs/resources/migration_notes.md`. ## Changes - **stdlib**: New query types (`CheckpointQuery`, `CheckpointsQuery`, `ProposedCheckpointQuery`) + Zod schemas in `block/l2_block_source.ts`. `'latest'` literal removed from `interfaces/checkpoint_parameter.ts`. `NormalizedCheckpointDispatch` type for the server's parameter normalizer. `ArchiverApiSchema` and `AztecNode` schema updated. `computeL2ToL1MembershipWitness` switched to the new query shape. - **archiver**: `data_source_base` adds `resolveCheckpointQuery` / `resolveCheckpointsQuery` mirroring the block-side helpers, implements the 4 confirmed methods plus the polymorphic proposed lookup. `BlockStore` adds `getProposedCheckpointBySlot(slot)`. `MockArchiver` and `mock_l2_block_source` updated to match the new interface. - **aztec-node**: `server.ts` adds the confirmed→proposed fallback flow with the two `BadRequestError` guards in `getCheckpoint`, sources all tips from a single `getL2Tips()` call in `getCheckpointNumber`, and routes the public RPC through the new internal methods. New pure-projection helper `projectProposedToCheckpointResponse` in `block_response_helpers.ts`. - **consumer migrations**: prover-node (collapses two checkpoint fetches into one `getCheckpoints({ epoch })`), world-state, slasher, sequencer (`checkpoint_proposal_job`, `sequencer`), validator (`proposal_handler`), `L2BlockStream`, pxe `block_stream_source`, telemetry wrapper, and 10 e2e files updated to the new query shapes. - **tests**: 48 new `it()` blocks covering each query discriminant, the throw guards, the confirmed→proposed fallback, the polymorphic `getProposedCheckpointData` dispatch, and `BlockStore.getProposedCheckpointBySlot`. - **docs**: `migration_notes.md` updated with the breaking changes for downstream wallet/SDK consumers. --- .../docs/resources/migration_notes.md | 57 ++- .../archiver/src/archiver-store.test.ts | 332 +++++++++++++++++- .../archiver/src/archiver-sync.test.ts | 66 ++-- yarn-project/archiver/src/archiver.ts | 2 +- .../archiver/src/modules/data_source_base.ts | 129 ++++--- .../archiver/src/store/block_store.test.ts | 59 ++++ .../archiver/src/store/block_store.ts | 15 + .../archiver/src/test/mock_l2_block_source.ts | 99 ++++-- .../src/aztec-node/block_response_helpers.ts | 47 ++- .../aztec-node/src/aztec-node/server.test.ts | 258 +++++++++++++- .../aztec-node/src/aztec-node/server.ts | 95 +++-- .../e2e_epochs/epochs_mbps.parallel.test.ts | 6 +- .../epochs_mbps.pipeline.parallel.test.ts | 4 +- .../epochs_mbps_redistribution.test.ts | 2 +- .../end-to-end/src/e2e_epochs/epochs_test.ts | 2 +- .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 8 +- .../e2e_multi_validator_node.test.ts | 4 +- .../fee_asset_price_oracle_gossip.test.ts | 2 +- .../src/e2e_p2p/gossip_network.test.ts | 2 +- .../e2e_p2p/gossip_network_no_cheat.test.ts | 2 +- .../e2e_p2p/preferred_gossip_network.test.ts | 2 +- .../prover-node/src/prover-node.test.ts | 10 +- yarn-project/prover-node/src/prover-node.ts | 17 +- .../block_synchronizer/block_stream_source.ts | 16 +- .../sequencer/checkpoint_proposal_job.test.ts | 12 +- .../checkpoint_proposal_job.timing.test.ts | 2 +- .../src/sequencer/checkpoint_proposal_job.ts | 2 +- .../src/sequencer/sequencer.test.ts | 9 +- .../src/sequencer/sequencer.ts | 2 +- .../src/watchers/epoch_prune_watcher.test.ts | 4 +- .../src/watchers/epoch_prune_watcher.ts | 2 +- .../stdlib/src/block/l2_block_source.ts | 90 +++-- .../l2_block_stream/l2_block_stream.test.ts | 40 ++- .../block/l2_block_stream/l2_block_stream.ts | 7 +- .../stdlib/src/interfaces/archiver.test.ts | 97 ++--- .../stdlib/src/interfaces/archiver.ts | 33 +- .../stdlib/src/interfaces/aztec-node.test.ts | 14 +- .../stdlib/src/interfaces/aztec-node.ts | 11 +- .../src/interfaces/checkpoint_parameter.ts | 6 +- .../src/messaging/l2_to_l1_membership.ts | 7 +- .../src/proposal_handler.test.ts | 2 +- .../validator-client/src/proposal_handler.ts | 4 +- .../validator-client/src/validator.test.ts | 2 +- .../server_world_state_synchronizer.ts | 10 +- 44 files changed, 1206 insertions(+), 386 deletions(-) diff --git a/docs/docs-developers/docs/resources/migration_notes.md b/docs/docs-developers/docs/resources/migration_notes.md index ef7febbaeef5..18581350adf8 100644 --- a/docs/docs-developers/docs/resources/migration_notes.md +++ b/docs/docs-developers/docs/resources/migration_notes.md @@ -50,7 +50,7 @@ The Aztec Node JSON-RPC surface for fetching blocks and checkpoints has been con | `getProvenBlockNumber()` | `getBlockNumber('proven')` | | `getCheckpointedBlockNumber()` | `getBlockNumber('checkpointed')` | -**Deprecated but still present** (scheduled for removal once internal consumers of the archiver shape are rewired): `getL2Tips` (use `getChainTips`), `getBlockHeader` (use `getBlock(param).then(r => r?.header)`), `getCheckpointedBlocks` (use `getBlocks(from, limit, { includeL1PublishInfo: true, includeAttestations: true })`), `getCheckpointsDataForEpoch` (use `getCheckpoints(from, limit)` over the epoch's checkpoint range). Do not adopt these in new code. +**Deprecated but still present** (scheduled for removal once internal consumers of the archiver shape are rewired): `getL2Tips` (use `getChainTips`), `getBlockHeader` (use `getBlock(param).then(r => r?.header)`), `getCheckpointedBlocks` (use `getBlocks(from, limit, { includeL1PublishInfo: true, includeAttestations: true })`). Do not adopt these in new code. (`getCheckpointsDataForEpoch` was previously listed here; see the dedicated checkpoint-API entry below for its removal.) **New response shapes:** `BlockResponse` always carries `header`, `archive`, `hash`, `number`, `checkpointNumber`, and `indexWithinCheckpoint`. `body`, `l1` (an `L1PublishInfo` discriminated union), and `attestations` are present only when the matching include option is set. `CheckpointResponse` mirrors this for checkpoints, with `blocks` gated on `includeBlocks`, and always carries `feeAssetPriceModifier` as a base field. The response types are generic over the options object, so passing a literal `{ includeTransactions: true }` narrows the return type and `response.body` becomes non-optional. @@ -79,12 +79,65 @@ The Aztec Node JSON-RPC surface for fetching blocks and checkpoints has been con `getBlockHeader`, `getCheckpointedBlocks`, `getCheckpointsDataForEpoch`, and `getL2Tips` continue to work in this release but are deprecated; migrate to the replacements above. -**Chain-tip selectors:** `getBlockNumber` and `getCheckpointNumber` now accept an optional `ChainTip` argument (`'proposed' | 'checkpointed' | 'proven' | 'finalized'`). Note the semantic difference: on the block side `'proposed'` means the latest proposed block (chain head), whereas on the checkpoint side `'proposed'` resolves to the latest L1-confirmed checkpoint. Pre-L1-confirmation checkpoints are not exposed over RPC. +**Chain-tip selectors:** `getBlockNumber` and `getCheckpointNumber` now accept an optional `ChainTip` argument (`'proposed' | 'checkpointed' | 'proven' | 'finalized'`). The `'proposed'` semantics are described in the dedicated checkpoint-API entry below — they were tightened in this release to mean "the proposed-tip checkpoint" rather than "the latest L1-confirmed checkpoint." **Block parameter variants:** `BlockParameter` now also accepts a block hash, an archive root, and chain-tip names. The existing `number | 'latest'` forms continue to work — `'latest'` is an alias for `'proposed'`. **Impact**: Source changes are required anywhere the removed methods are called. Type changes are required anywhere `L2Block` / `BlockHeader` / `CheckpointedL2Block` were consumed from the RPC — those call sites now receive `BlockResponse` / `CheckpointResponse` and must request the fields they need via `options`. Production nodes will reject JSON-RPC calls to the removed method names. +### [Aztec Node] Checkpoint RPC: `'proposed'` is now strictly proposed; `'latest'` removed; `getCheckpointsData` takes a query + +Follow-up to the unified-RPC change above. Tightens the checkpoint-side API surface: removes the old positional / per-shape entrypoints, drops the wire-level alias that conflated proposed and confirmed checkpoints, and replaces the deprecated epoch-only `getCheckpointsDataForEpoch` method with a unified query-shaped `getCheckpointsData` that mirrors the block-side API. + +**`getCheckpointsDataForEpoch(epoch)` removed.** The previously-deprecated method is gone. Use `getCheckpointsData({ epoch })` instead. The new `getCheckpointsData` also accepts a contiguous range: + +```diff +- const cps = await node.getCheckpointsDataForEpoch(epoch); ++ const cps = await node.getCheckpointsData({ epoch }); + + // New: contiguous range ++ const cps = await node.getCheckpointsData({ from: 1, limit: 5 }); +``` + +**`'latest'` removed from `CheckpointParameter`.** The `'latest'` literal previously accepted by `getCheckpoint('latest', options)` is no longer valid. Use `'checkpointed'` to address the latest confirmed checkpoint, or `'proposed'` for the proposed-tip semantics described below. (Block-side `'latest'` in `BlockParameter` is unaffected.) + +```diff +- await node.getCheckpoint('latest'); ++ await node.getCheckpoint('checkpointed'); +``` + +**`'proposed'` semantics changed.** Previously `'proposed'` on the checkpoint side aliased to "latest L1-confirmed checkpoint" — a documented foot-gun. After this release: + +- `getCheckpoint('proposed')` resolves to the proposed-tip checkpoint number and looks it up confirmed-first, then falls back to the proposed-checkpoint store. When a proposed entry exists at that number it is returned; when none exists, the proposed-tip falls back to the confirmed tip and the call returns the latest confirmed checkpoint. Returns `undefined` only when neither store has the resolved number. +- `getCheckpointNumber('proposed')` returns the proposed-tip checkpoint number, falling back to the latest confirmed checkpoint number when no proposed entry exists. Return type stays `Promise`. + +If you want the latest L1-confirmed checkpoint regardless of proposed state, switch the call to `'checkpointed'`: + +```diff +- const cp = await node.getCheckpoint('proposed'); +- const n = await node.getCheckpointNumber('proposed'); ++ const cp = await node.getCheckpoint('checkpointed'); ++ const n = await node.getCheckpointNumber('checkpointed'); +``` + +**By-number / by-slot lookups gain a confirmed→proposed fallback.** `getCheckpoint({ number: N })` and `getCheckpoint({ slot: S })` now check the confirmed store first, then fall back to the proposed store. Tag-based lookups (`'checkpointed'`, `'proven'`, `'finalized'`) do not fall back — those tags name confirmed-only positions. + +**Throws on a proposed match + L1/attestations.** Proposed checkpoints have no L1 publish info or committee attestations (those data points only exist after L1 confirmation). The throw fires only when the lookup actually lands on a proposed entry — i.e. the confirmed store missed and the proposed store hit. When the proposed-tip falls back to the confirmed tip (no proposed entry exists), `'proposed' + includeAttestations` returns the latest confirmed checkpoint with attestations rather than throwing: + +```ts +// Throws BadRequestError when a proposed entry exists at the resolved number: +await node.getCheckpoint('proposed', { includeAttestations: true }); +await node.getCheckpoint('proposed', { includeL1PublishInfo: true }); + +// And when a by-number / by-slot lookup falls back to a proposed entry: +await node.getCheckpoint({ number: N }, { includeAttestations: true }); +// → throws if N is matched only in the proposed store +``` + +If your code asks for `includeAttestations` / `includeL1PublishInfo` and might land on a proposed entry, gate the call on `getCheckpoint(param)` first, then re-issue with the include flags only after confirming the result is from the confirmed store (e.g. by checking that the tag-based equivalent returns the same checkpoint number). + +**Impact**: Wallet, indexer, and tooling code that called `node.getCheckpoint('proposed')` or `node.getCheckpoint('latest')` will need to update their tag. Any code relying on the old "proposed = latest confirmed" alias should switch to `'checkpointed'`. Code that combined `'proposed'` (or by-number/by-slot fallbacks) with `includeAttestations` / `includeL1PublishInfo` will now throw at runtime; gate those flags as described above. + ### [Aztec Node] `feeAssetPriceModifier` now correctly populated on confirmed checkpoints Confirmed checkpoints previously reported `feeAssetPriceModifier = 0n` regardless of the value observed on L1, because the archiver dropped the field on checkpoint confirmation. The field is now persisted and returned correctly on `CheckpointResponse`. Any wallet or indexer logic that special-cased `0n` as a sentinel for "no modifier" will need to be updated; it is now a valid value in its own right. diff --git a/yarn-project/archiver/src/archiver-store.test.ts b/yarn-project/archiver/src/archiver-store.test.ts index 2f1a0b770390..7033798c65d1 100644 --- a/yarn-project/archiver/src/archiver-store.test.ts +++ b/yarn-project/archiver/src/archiver-store.test.ts @@ -15,6 +15,7 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { L2Block } from '@aztec/stdlib/block'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { makeStateReference } from '@aztec/stdlib/testing'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { BlockHeader } from '@aztec/stdlib/tx'; @@ -132,7 +133,7 @@ describe('Archiver Store', () => { const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpoints(CheckpointNumber(1), 10); + const result = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 10 }); expect(result.length).toBe(3); expect(result.map(c => c.checkpoint.number)).toEqual([1, 2, 3]); @@ -148,7 +149,7 @@ describe('Archiver Store', () => { const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpoints(CheckpointNumber(1), 2); + const result = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 2 }); expect(result.length).toBe(2); expect(result.map(c => c.checkpoint.number)).toEqual([1, 2]); @@ -159,20 +160,20 @@ describe('Archiver Store', () => { const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const result = await archiver.getCheckpoints(CheckpointNumber(2), 10); + const result = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 10 }); expect(result.length).toBe(2); expect(result.map(c => c.checkpoint.number)).toEqual([2, 3]); }); it('returns empty array when no checkpoints exist', async () => { - const result = await archiver.getCheckpoints(CheckpointNumber(1), 10); + const result = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 10 }); expect(result).toEqual([]); }); }); - describe('getCheckpointsForEpoch', () => { + describe('getCheckpoints({ epoch })', () => { it('returns checkpoints for a specific epoch based on slot numbers', async () => { // l1Constants has epochDuration: 4, so epoch 0 has slots 0-3, epoch 1 has slots 4-7 const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); @@ -186,13 +187,13 @@ describe('Archiver Store', () => { }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const epoch0Checkpoints = await archiver.getCheckpointsForEpoch(EpochNumber(0)); + const epoch0Checkpoints = await archiver.getCheckpointsData({ epoch: EpochNumber(0) }); expect(epoch0Checkpoints.length).toBe(2); - expect(epoch0Checkpoints.map(c => c.number)).toEqual([1, 2]); + expect(epoch0Checkpoints.map(c => c.checkpointNumber)).toEqual([1, 2]); - const epoch1Checkpoints = await archiver.getCheckpointsForEpoch(EpochNumber(1)); + const epoch1Checkpoints = await archiver.getCheckpointsData({ epoch: EpochNumber(1) }); expect(epoch1Checkpoints.length).toBe(1); - expect(epoch1Checkpoints.map(c => c.number)).toEqual([3]); + expect(epoch1Checkpoints.map(c => c.checkpointNumber)).toEqual([3]); }); it('returns empty array for epoch with no checkpoints', async () => { @@ -203,7 +204,7 @@ describe('Archiver Store', () => { }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const epoch1Checkpoints = await archiver.getCheckpointsForEpoch(EpochNumber(1)); + const epoch1Checkpoints = await archiver.getCheckpointsData({ epoch: EpochNumber(1) }); expect(epoch1Checkpoints).toEqual([]); }); @@ -220,9 +221,306 @@ describe('Archiver Store', () => { }); await archiverStore.blocks.addCheckpoints(testCheckpoints); - const epoch0Checkpoints = await archiver.getCheckpointsForEpoch(EpochNumber(0)); + const epoch0Checkpoints = await archiver.getCheckpointsData({ epoch: EpochNumber(0) }); expect(epoch0Checkpoints.length).toBe(3); - expect(epoch0Checkpoints.map(c => c.number)).toEqual([1, 2, 3]); + expect(epoch0Checkpoints.map(c => c.checkpointNumber)).toEqual([1, 2, 3]); + }); + }); + + describe('getCheckpoint', () => { + it('returns checkpoint by number', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoint({ number: CheckpointNumber(2) }); + expect(result).toBeDefined(); + expect(result!.checkpoint.number).toBe(2); + }); + + it('returns undefined for unknown checkpoint number', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(2, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoint({ number: CheckpointNumber(99) }); + expect(result).toBeUndefined(); + }); + + it('returns checkpoint by slot', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const targetSlot = SlotNumber(7); + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + makeCheckpointOptions: cpNumber => ({ + slotNumber: cpNumber === CheckpointNumber(1) ? SlotNumber(3) : targetSlot, + }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoint({ slot: targetSlot }); + expect(result).toBeDefined(); + expect(result!.checkpoint.number).toBe(2); + }); + + it('returns undefined for unknown slot', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + makeCheckpointOptions: () => ({ slotNumber: SlotNumber(5) }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoint({ slot: SlotNumber(99) }); + expect(result).toBeUndefined(); + }); + + it('returns the latest checkpointed checkpoint for tag=checkpointed', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoint({ tag: 'checkpointed' }); + expect(result).toBeDefined(); + expect(result!.checkpoint.number).toBe(3); + }); + + it('returns the proven checkpoint for tag=proven when one exists', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(2)); + + const result = await archiver.getCheckpoint({ tag: 'proven' }); + expect(result).toBeDefined(); + expect(result!.checkpoint.number).toBe(2); + }); + + it('returns undefined for tag=proven when no checkpoints exist', async () => { + // proven tip is checkpoint 0 (genesis), getCheckpoint returns undefined for number=0 + const result = await archiver.getCheckpoint({ tag: 'proven' }); + expect(result).toBeUndefined(); + }); + + it('returns the finalized checkpoint for tag=finalized when one exists', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(3)); + await archiverStore.blocks.setFinalizedCheckpointNumber(CheckpointNumber(1)); + + const result = await archiver.getCheckpoint({ tag: 'finalized' }); + expect(result).toBeDefined(); + expect(result!.checkpoint.number).toBe(1); + }); + + it('returns undefined for tag=finalized when no checkpoints exist', async () => { + const result = await archiver.getCheckpoint({ tag: 'finalized' }); + expect(result).toBeUndefined(); + }); + }); + + describe('getCheckpointData', () => { + it('returns checkpoint data by number', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpointData({ number: CheckpointNumber(2) }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(2); + expect(result!.l1).toBeDefined(); + }); + + it('returns undefined for unknown checkpoint number', async () => { + const result = await archiver.getCheckpointData({ number: CheckpointNumber(99) }); + expect(result).toBeUndefined(); + }); + + it('returns checkpoint data by slot', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const targetSlot = SlotNumber(11); + const testCheckpoints = await makeChainedCheckpoints(2, { + previousArchive: genesisArchive, + makeCheckpointOptions: cpNumber => ({ + slotNumber: cpNumber === CheckpointNumber(1) ? SlotNumber(2) : targetSlot, + }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpointData({ slot: targetSlot }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(2); + }); + + it('returns undefined for unknown slot', async () => { + const result = await archiver.getCheckpointData({ slot: SlotNumber(999) }); + expect(result).toBeUndefined(); + }); + + it('returns the latest checkpointed data for tag=checkpointed', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpointData({ tag: 'checkpointed' }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(3); + }); + + it('returns the proven checkpoint data for tag=proven', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(2)); + + const result = await archiver.getCheckpointData({ tag: 'proven' }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(2); + }); + + it('returns the finalized checkpoint data for tag=finalized', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(3, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(3)); + await archiverStore.blocks.setFinalizedCheckpointNumber(CheckpointNumber(2)); + + const result = await archiver.getCheckpointData({ tag: 'finalized' }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(2); + }); + + it('returns undefined for tags when chain is empty', async () => { + expect(await archiver.getCheckpointData({ tag: 'proven' })).toBeUndefined(); + expect(await archiver.getCheckpointData({ tag: 'finalized' })).toBeUndefined(); + }); + }); + + describe('getCheckpoints / getCheckpointsData', () => { + it('getCheckpoints returns the right slice from from+limit', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(5, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 3 }); + expect(result.length).toBe(3); + expect(result.map(c => c.checkpoint.number)).toEqual([2, 3, 4]); + }); + + it('getCheckpointsData returns the right slice from from+limit', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const testCheckpoints = await makeChainedCheckpoints(5, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpointsData({ from: CheckpointNumber(3), limit: 2 }); + expect(result.length).toBe(2); + expect(result.map(c => c.checkpointNumber)).toEqual([3, 4]); + }); + + it('getCheckpoints returns [] for empty range', async () => { + const result = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 10 }); + expect(result).toEqual([]); + }); + + it('getCheckpointsData returns [] for unknown epoch', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + // checkpoint 1 in epoch 0 (slot 1, epochDuration=4) + const testCheckpoints = await makeChainedCheckpoints(1, { + previousArchive: genesisArchive, + makeCheckpointOptions: () => ({ slotNumber: SlotNumber(1) }), + }); + await archiverStore.blocks.addCheckpoints(testCheckpoints); + + const result = await archiver.getCheckpointsData({ epoch: EpochNumber(5) }); + expect(result).toEqual([]); + }); + }); + + describe('getProposedCheckpointData', () => { + async function addProposedCheckpoint( + checkpointNumber: CheckpointNumber, + slotNumber: SlotNumber, + startBlock: BlockNumber, + ) { + const block = await L2Block.random(startBlock, { + checkpointNumber, + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + await archiverStore.blocks.addProposedBlock(block, { force: true }); + await archiverStore.blocks.addProposedCheckpoint({ + checkpointNumber, + header: CheckpointHeader.random({ slotNumber }), + startBlock, + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 0n, + }); + } + + it('returns the latest proposed entry when called with no args', async () => { + await addProposedCheckpoint(CheckpointNumber(1), SlotNumber(3), BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData(); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(1); + }); + + it('returns undefined when no proposed entry exists (no args)', async () => { + const result = await archiver.getProposedCheckpointData(); + expect(result).toBeUndefined(); + }); + + it('returns the latest proposed entry for tag=proposed', async () => { + await addProposedCheckpoint(CheckpointNumber(1), SlotNumber(5), BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData({ tag: 'proposed' }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(1); + }); + + it('returns matching proposed entry by number', async () => { + await addProposedCheckpoint(CheckpointNumber(1), SlotNumber(2), BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData({ number: CheckpointNumber(1) }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(1); + }); + + it('returns undefined for number that has no proposed entry', async () => { + await addProposedCheckpoint(CheckpointNumber(1), SlotNumber(2), BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData({ number: CheckpointNumber(99) }); + expect(result).toBeUndefined(); + }); + + it('returns matching proposed entry by slot', async () => { + const targetSlot = SlotNumber(7); + await addProposedCheckpoint(CheckpointNumber(1), targetSlot, BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData({ slot: targetSlot }); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(1); + expect(result!.header.slotNumber).toBe(targetSlot); + }); + + it('returns undefined for slot that has no proposed entry', async () => { + await addProposedCheckpoint(CheckpointNumber(1), SlotNumber(3), BlockNumber(1)); + + const result = await archiver.getProposedCheckpointData({ slot: SlotNumber(999) }); + expect(result).toBeUndefined(); + }); + + it('never falls back to confirmed checkpoints', async () => { + const genesisArchive = new AppendOnlyTreeSnapshot(genesisArchiveRoot, 1); + const confirmedCheckpoints = await makeChainedCheckpoints(2, { previousArchive: genesisArchive }); + await archiverStore.blocks.addCheckpoints(confirmedCheckpoints); + + // No proposed checkpoint exists — all queries should return undefined + expect(await archiver.getProposedCheckpointData()).toBeUndefined(); + expect(await archiver.getProposedCheckpointData({ tag: 'proposed' })).toBeUndefined(); + expect(await archiver.getProposedCheckpointData({ number: CheckpointNumber(1) })).toBeUndefined(); + expect(await archiver.getProposedCheckpointData({ number: CheckpointNumber(2) })).toBeUndefined(); }); }); @@ -614,7 +912,7 @@ describe('Archiver Store', () => { // Block 3 is the last block of checkpoint 1 — should succeed await archiver.rollbackTo(BlockNumber(3)); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Verify sync points are set to checkpoint 1's L1 block number (10) const synchPoint = await getArchiverSynchPoint(archiverStore); @@ -661,7 +959,7 @@ describe('Archiver Store', () => { // Roll back to block 2 (end of checkpoint 1), which is before proven block 4 await archiver.rollbackTo(BlockNumber(2)); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); }); @@ -681,7 +979,7 @@ describe('Archiver Store', () => { // Roll back to block 4 (end of checkpoint 2), which is after proven block 2 await archiver.rollbackTo(BlockNumber(4)); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2)); expect(await archiver.getProvenCheckpointNumber()).toEqual(CheckpointNumber(1)); }); @@ -702,7 +1000,7 @@ describe('Archiver Store', () => { // Roll back to block 2 (end of checkpoint 1), which is before finalized block 4 await archiver.rollbackTo(BlockNumber(2)); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); }); @@ -723,7 +1021,7 @@ describe('Archiver Store', () => { // Roll back to block 4 (end of checkpoint 2), which is after finalized block 2 await archiver.rollbackTo(BlockNumber(4)); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2)); expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); }); }); diff --git a/yarn-project/archiver/src/archiver-sync.test.ts b/yarn-project/archiver/src/archiver-sync.test.ts index 81c820ddcb3b..f4898e6791f8 100644 --- a/yarn-project/archiver/src/archiver-sync.test.ts +++ b/yarn-project/archiver/src/archiver-sync.test.ts @@ -171,7 +171,7 @@ describe('Archiver Sync', () => { describe('basic sync', () => { it('syncs l1 to l2 messages and checkpoints', async () => { - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(0)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(0)); // Add first checkpoint (creates messages automatically, L1 block 98 for messages, 101 for checkpoint) const { checkpoint: cp1, messages: msgs1 } = await fake.addCheckpoint(CheckpointNumber(1), { @@ -200,7 +200,7 @@ describe('Archiver Sync', () => { logger.warn('Expecting checkpoint 1'); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Verify messages for checkpoint 1 expect(await archiver.getL1ToL2Messages(CheckpointNumber(1))).toEqual(msgs1); @@ -247,9 +247,9 @@ describe('Archiver Sync', () => { expect(await archiver.getProvenCheckpointNumber()).toBe(CheckpointNumber(1)); // Get published checkpoints - expect((await archiver.getCheckpoints(CheckpointNumber(1), 100)).map(b => b.checkpoint.number)).toEqual([ - 1, 2, 3, - ]); + expect( + (await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 100 })).map(b => b.checkpoint.number), + ).toEqual([1, 2, 3]); }, 30_000); it('ignores checkpoint 3 because it has been pruned', async () => { @@ -431,7 +431,7 @@ describe('Archiver Sync', () => { expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Verify the checkpoint was synced successfully - const syncedCheckpoints = await archiver.getCheckpoints(CheckpointNumber(1), 1); + const syncedCheckpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 1 }); expect(syncedCheckpoints).toBeDefined(); expect(syncedCheckpoints.length).toBeGreaterThan(0); expect(syncedCheckpoints[0]).toBeDefined(); @@ -484,7 +484,7 @@ describe('Archiver Sync', () => { await archiver.syncImmediate(); // Should have processed the checkpoint without attempting to fetch any messages - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); expect(inboxContract.getMessageSentEvents).not.toHaveBeenCalled(); }); }); @@ -823,7 +823,7 @@ describe('Archiver Sync', () => { expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(3)); // And checkpoint 2 should return the proper one - const [checkpoint2] = await archiver.getCheckpoints(CheckpointNumber(2), 1); + const [checkpoint2] = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 }); expect(checkpoint2.checkpoint.number).toEqual(2); expect(checkpoint2.checkpoint.archive.root.toString()).toEqual(goodCp2.archive.root.toString()); expect(checkpoint2.attestations.length).toEqual(3); @@ -892,7 +892,7 @@ describe('Archiver Sync', () => { // Verify data from checkpoint 2 is removed const txHash = cp2.blocks[0].body.txEffects[0].txHash; expect(await archiver.getTxEffect(txHash)).resolves.toBeUndefined; - expect(await archiver.getCheckpoints(CheckpointNumber(2), 1)).toEqual([]); + expect(await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 })).toEqual([]); expect((await archiver.getPublicLogs({ fromBlock: 2, toBlock: 3 })).logs).toEqual([]); expect((await archiver.getContractClassLogs({ fromBlock: 2, toBlock: 3 })).logs).toEqual([]); @@ -1164,8 +1164,8 @@ describe('Archiver Sync', () => { expect(tips.checkpointed.checkpoint.number).toEqual(CheckpointNumber(1)); // Data from checkpoints 2 and 3 should be removed - expect(await archiver.getCheckpoints(CheckpointNumber(2), 1)).toEqual([]); - expect(await archiver.getCheckpoints(CheckpointNumber(3), 1)).toEqual([]); + expect(await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 })).toEqual([]); + expect(await archiver.getCheckpoints({ from: CheckpointNumber(3), limit: 1 })).toEqual([]); archiver.events.off(L2BlockSourceEvents.L2PruneUnproven, pruneSpy); }, 15_000); @@ -1507,7 +1507,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(100n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const lastBlockInCheckpoint1 = cp1.blocks[cp1.blocks.length - 1].number; // Verify L2Tips after syncing checkpoint 1: proposed and checkpointed should both be at checkpoint 1 @@ -1531,7 +1531,7 @@ describe('Archiver Sync', () => { // Verify blocks are retrievable but not yet checkpointed const lastBlockInCheckpoint2 = cp2.blocks[cp2.blocks.length - 1].number; expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint2); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); expect((await archiver.getBlock({ number: cp2.blocks[0].number }))!.equals(cp2.blocks[0])).toBe(true); // Verify L2Tips after adding blocks: proposed advances but checkpointed stays at checkpoint 1 @@ -1562,7 +1562,7 @@ describe('Archiver Sync', () => { expect(pruneSpy).not.toHaveBeenCalled(); // Now the blocks should be checkpointed - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(2)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2)); // Verify L2Tips after syncing checkpoint 2: proposed and checkpointed should both be at checkpoint 2 const tipsAfterCheckpoint2 = await archiver.getL2Tips(); @@ -1591,7 +1591,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(100n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const blockAlreadySyncedFromCheckpoint = cp1.blocks[cp1.blocks.length - 1]; // Now try and add one of the blocks via the addProposedBlock method. It should throw @@ -1615,7 +1615,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(4n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Now advance L1 significantly to slot 10 (L1 block 20) // Current slot = floor(20 / 2) = 10 @@ -1645,7 +1645,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(100n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Create checkpoint 2 on L1 with 2 blocks (not yet visible) const { checkpoint: cp2 } = await fake.addCheckpoint(CheckpointNumber(2), { @@ -1676,7 +1676,7 @@ describe('Archiver Sync', () => { expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint2); // Verify we can retrieve both blocks - const syncedCheckpoints = await archiver.getCheckpoints(CheckpointNumber(2), 1); + const syncedCheckpoints = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 }); expect(syncedCheckpoints[0].checkpoint.blocks.length).toEqual(2); }, 15_000); @@ -1686,7 +1686,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(100n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Create checkpoint 2 on L1 (NOT visible yet - L1 block is far in the future) const { checkpoint: cp2 } = await fake.addCheckpoint(CheckpointNumber(2), { l1BlockNumber: 5000n }); @@ -1707,7 +1707,7 @@ describe('Archiver Sync', () => { expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint3); // Still at checkpoint 1 (checkpoints 2 and 3 not synced yet) - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Advance L1 to make BOTH checkpoints visible at once fake.setL1BlockNumber(5010n); @@ -1722,7 +1722,7 @@ describe('Archiver Sync', () => { expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint3); // Assert: Both checkpoints are synced - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(3)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(3)); }, 15_000); it('prunes and replaces local blocks when checkpoint has different blocks', async () => { @@ -1733,7 +1733,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(80n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Create blocks for checkpoint 2 locally (not yet on L1) const provisionalBlocks = await fake.makeBlocks(CheckpointNumber(2), { @@ -1749,7 +1749,7 @@ describe('Archiver Sync', () => { // Verify blocks are visible but not checkpointed const lastBlockInCheckpoint2 = provisionalBlocks[provisionalBlocks.length - 1].number; expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint2); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Now create a DIFFERENT checkpoint 2 on L1 (different blocks) const { checkpoint: differentCp2 } = await fake.addCheckpoint(CheckpointNumber(2), { @@ -1777,7 +1777,7 @@ describe('Archiver Sync', () => { ); // Verify blocks were replaced with L1 checkpoint's blocks - const syncedCheckpoints = await archiver.getCheckpoints(CheckpointNumber(2), 1); + const syncedCheckpoints = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 }); expect(syncedCheckpoints[0].checkpoint.archive.root.toString()).toEqual(differentCp2.archive.root.toString()); }, 15_000); @@ -1789,7 +1789,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(80n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Create 2 provisional blocks locally (not on L1) const provisionalBlocks = await fake.makeBlocks(CheckpointNumber(2), { @@ -1830,7 +1830,7 @@ describe('Archiver Sync', () => { ); // Verify only the L1 checkpoint's single block is now present - const syncedCheckpoints = await archiver.getCheckpoints(CheckpointNumber(2), 1); + const syncedCheckpoints = await archiver.getCheckpoints({ from: CheckpointNumber(2), limit: 1 }); expect(syncedCheckpoints[0].checkpoint.blocks.length).toEqual(1); expect(syncedCheckpoints[0].checkpoint.archive.root.toString()).toEqual(differentCp2.archive.root.toString()); }, 15_000); @@ -1850,7 +1850,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(1n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const lastBlockInCheckpoint1 = cp1.blocks[cp1.blocks.length - 1].number; @@ -1899,7 +1899,7 @@ describe('Archiver Sync', () => { // Block number should revert to last checkpointed block expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint1); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); }, 15_000); it('does nothing when slot ends without local blocks', async () => { @@ -1917,7 +1917,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(1n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const lastBlockInCheckpoint1 = cp1.blocks[cp1.blocks.length - 1].number; // Do NOT add any provisional blocks for slot 1 @@ -1932,7 +1932,7 @@ describe('Archiver Sync', () => { // Block number should remain at last checkpointed block expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint1); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); }, 15_000); it('does not prune blocks covered by a pending checkpoint in current slot', async () => { @@ -1950,7 +1950,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(1n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const lastBlockInCheckpoint1 = cp1.blocks[cp1.blocks.length - 1].number; // Create provisional blocks for slot 1 @@ -2011,7 +2011,7 @@ describe('Archiver Sync', () => { fake.setL1BlockNumber(1n); await archiver.syncImmediate(); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); const lastBlockInCheckpoint1 = cp1.blocks[cp1.blocks.length - 1].number; // Create provisional blocks for slot 1 @@ -2054,7 +2054,7 @@ describe('Archiver Sync', () => { // Block number should revert to last checkpointed block expect(await archiver.getBlockNumber()).toEqual(lastBlockInCheckpoint1); - expect(await archiver.getSynchedCheckpointNumber()).toEqual(CheckpointNumber(1)); + expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); // Proposed checkpoint should be cleared, so proposed tip falls back to checkpointed tip expect(await archiverStore.blocks.getLastProposedCheckpoint()).toBeUndefined(); diff --git a/yarn-project/archiver/src/archiver.ts b/yarn-project/archiver/src/archiver.ts index 23f417c088e3..9519e29012c0 100644 --- a/yarn-project/archiver/src/archiver.ts +++ b/yarn-project/archiver/src/archiver.ts @@ -208,7 +208,7 @@ export class Archiver extends ArchiverDataSourceBase implements L2BlockSink, Tra const { blocksSynchedTo = l1StartBlock, messagesSynchedTo = l1StartBlock } = await getArchiverSynchPoint( this.stores, ); - const currentL2Checkpoint = await this.getSynchedCheckpointNumber(); + const currentL2Checkpoint = await this.getCheckpointNumber(); this.log.info( `Starting archiver sync to rollup contract ${this.rollup.address} from L1 block ${blocksSynchedTo} and L2 checkpoint ${currentL2Checkpoint}`, { blocksSynchedTo, messagesSynchedTo, currentL2Checkpoint }, diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index ad051843475c..9e75f7e4b894 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -17,13 +17,15 @@ import { type BlockTag, type BlocksQuery, Body, + type CheckpointQuery, + type CheckpointsQuery, L2Block, type L2Tips, + type ProposedCheckpointQuery, } from '@aztec/stdlib/block'; import { Checkpoint, type CheckpointData, - type CommonCheckpointData, type ProposedCheckpointData, PublishedCheckpoint, } from '@aztec/stdlib/checkpoint'; @@ -33,7 +35,6 @@ import type { GetContractClassLogsResponse, GetPublicLogsResponse } from '@aztec import type { L2LogsSource } from '@aztec/stdlib/interfaces/server'; import type { LogFilter, SiloedTag, Tag, TxScopedL2Log } from '@aztec/stdlib/logs'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import type { CheckpointHeader } from '@aztec/stdlib/rollup'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import type { BlockHeader, IndexedTxEffect, TxHash, TxReceipt } from '@aztec/stdlib/tx'; import type { UInt64 } from '@aztec/stdlib/types'; @@ -146,10 +147,6 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getLatestCheckpointNumber(); } - public getSynchedCheckpointNumber(): Promise { - return this.stores.blocks.getLatestCheckpointNumber(); - } - public getProvenCheckpointNumber(): Promise { return this.stores.blocks.getProvenCheckpointNumber(); } @@ -182,38 +179,90 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getFinalizedL2BlockNumber(); } - public async getCheckpointHeader(number: CheckpointNumber | 'latest'): Promise { - if (number === 'latest') { - number = await this.stores.blocks.getLatestCheckpointNumber(); + /** + * Resolves a {@link CheckpointQuery} to a concrete `CheckpointNumber`, or undefined when the + * query refers to a position that has no checkpoint yet (e.g. `{ slot }` not found). + */ + private resolveCheckpointQuery(query: CheckpointQuery): Promise { + if ('number' in query) { + return Promise.resolve(query.number); } - if (number === 0) { - return undefined; + if ('slot' in query) { + return this.stores.blocks.getCheckpointNumberBySlot(query.slot); + } + // tag variant + switch (query.tag) { + case 'checkpointed': + return this.stores.blocks.getLatestCheckpointNumber(); + case 'proven': + return this.stores.blocks.getProvenCheckpointNumber(); + case 'finalized': + return this.stores.blocks.getFinalizedCheckpointNumber(); } - const checkpoint = await this.stores.blocks.getCheckpointData(number); - if (!checkpoint) { + } + + /** + * Resolves a {@link CheckpointsQuery} to a concrete `{from, limit}` pair used by BlockStore, + * or undefined when the epoch has no checkpoints. + */ + private async resolveCheckpointsQuery( + query: CheckpointsQuery, + ): Promise<{ from: CheckpointNumber; limit: number } | undefined> { + if ('from' in query) { + return query; + } + const numbers = await this.getCheckpointNumbersForEpoch(query.epoch); + if (numbers.length === 0) { return undefined; } - return checkpoint.header; + return { from: numbers[0], limit: numbers.length }; } - public async getLastBlockNumberInCheckpoint(checkpointNumber: CheckpointNumber): Promise { - const checkpointData = await this.stores.blocks.getCheckpointData(checkpointNumber); - if (!checkpointData) { + public async getCheckpoint(query: CheckpointQuery): Promise { + const number = await this.resolveCheckpointQuery(query); + if (number === undefined || number === 0) { return undefined; } - return BlockNumber(checkpointData.startBlock + checkpointData.blockCount - 1); + const data = await this.stores.blocks.getCheckpointData(number); + if (!data) { + return undefined; + } + return this.getPublishedCheckpointFromCheckpointData(data); + } + + public async getCheckpoints(query: CheckpointsQuery): Promise { + const resolved = await this.resolveCheckpointsQuery(query); + if (!resolved) { + return []; + } + const checkpoints = await this.stores.blocks.getRangeOfCheckpoints(resolved.from, resolved.limit); + return Promise.all(checkpoints.map(ch => this.getPublishedCheckpointFromCheckpointData(ch))); } - public getCheckpointData(checkpointNumber: CheckpointNumber): Promise { - return this.stores.blocks.getCheckpointData(checkpointNumber); + public async getCheckpointData(query: CheckpointQuery): Promise { + const number = await this.resolveCheckpointQuery(query); + if (number === undefined || number === 0) { + return undefined; + } + return this.stores.blocks.getCheckpointData(number); } - public getCheckpointDataRange(from: CheckpointNumber, limit: number): Promise { - return this.stores.blocks.getRangeOfCheckpoints(from, limit); + public async getCheckpointsData(query: CheckpointsQuery): Promise { + const resolved = await this.resolveCheckpointsQuery(query); + if (!resolved) { + return []; + } + return this.stores.blocks.getRangeOfCheckpoints(resolved.from, resolved.limit); } - public getCheckpointNumberBySlot(slot: SlotNumber): Promise { - return this.stores.blocks.getCheckpointNumberBySlot(slot); + public getProposedCheckpointData(query?: ProposedCheckpointQuery): Promise { + if (!query || 'tag' in query) { + return this.stores.blocks.getLastProposedCheckpoint(); + } + if ('number' in query) { + return this.stores.blocks.getProposedCheckpointByNumber(query.number); + } + return this.stores.blocks.getProposedCheckpointBySlot(query.slot); } public getTxEffect(txHash: TxHash): Promise { @@ -224,14 +273,6 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getSettledTxReceipt(txHash, this.l1Constants); } - public getLastCheckpoint(): Promise { - return this.stores.blocks.getLastCheckpoint(); - } - - public getLastProposedCheckpoint(): Promise { - return this.stores.blocks.getLastProposedCheckpoint(); - } - public isPendingChainInvalid(): Promise { return this.getPendingChainValidationStatus().then(status => !status.valid); } @@ -310,11 +351,6 @@ export abstract class ArchiverDataSourceBase return this.stores.messages.getL1ToL2MessageIndex(l1ToL2Message); } - public async getCheckpoints(checkpointNumber: CheckpointNumber, limit: number): Promise { - const checkpoints = await this.stores.blocks.getRangeOfCheckpoints(checkpointNumber, limit); - return Promise.all(checkpoints.map(ch => this.getPublishedCheckpointFromCheckpointData(ch))); - } - private async getPublishedCheckpointFromCheckpointData(checkpoint: CheckpointData): Promise { const blocksForCheckpoint = await this.stores.blocks.getBlocksForCheckpoint(checkpoint.checkpointNumber); if (!blocksForCheckpoint) { @@ -334,25 +370,8 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getBlocksForSlot(slotNumber); } - public async getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { - const checkpointsData = await this.getCheckpointsDataForEpoch(epochNumber); - return Promise.all( - checkpointsData.map(data => this.getPublishedCheckpointFromCheckpointData(data).then(p => p.checkpoint)), - ); - } - - /** Returns checkpoint data for all checkpoints whose slot falls within the given epoch. */ - public getCheckpointsDataForEpoch(epochNumber: EpochNumber): Promise { - if (!this.l1Constants) { - throw new Error('L1 constants not set'); - } - - const [start, end] = getSlotRangeForEpoch(epochNumber, this.l1Constants); - return this.stores.blocks.getCheckpointDataForSlotRange(start, end); - } - /** Returns just the checkpoint numbers for all checkpoints whose slot falls within the given epoch. */ - public getCheckpointNumbersForEpoch(epochNumber: EpochNumber): Promise { + private getCheckpointNumbersForEpoch(epochNumber: EpochNumber): Promise { if (!this.l1Constants) { throw new Error('L1 constants not set'); } diff --git a/yarn-project/archiver/src/store/block_store.test.ts b/yarn-project/archiver/src/store/block_store.test.ts index 08e72e029d08..29c6126a1ec0 100644 --- a/yarn-project/archiver/src/store/block_store.test.ts +++ b/yarn-project/archiver/src/store/block_store.test.ts @@ -2611,6 +2611,65 @@ describe('BlockStore', () => { }); }); + describe('getProposedCheckpointBySlot', () => { + async function addBlocksForProposed( + startBlock: number, + blockCount: number, + checkpointNumber: number, + previousArchive?: AppendOnlyTreeSnapshot, + ): Promise { + for (let i = 0; i < blockCount; i++) { + const opts: Parameters[1] = { + checkpointNumber: CheckpointNumber(checkpointNumber), + indexWithinCheckpoint: IndexWithinCheckpoint(i), + }; + if (i === 0 && previousArchive) { + (opts as any).lastArchive = previousArchive; + } + const block = await L2Block.random(BlockNumber(startBlock + i), opts); + await blockStore.addProposedBlock(block, { force: true }); + } + } + + it('returns the proposed entry whose header slot matches', async () => { + await addBlocksForProposed(1, 1, 1); + const targetSlot = SlotNumber(42); + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.random({ slotNumber: targetSlot }), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 0n, + }); + + const result = await blockStore.getProposedCheckpointBySlot(targetSlot); + expect(result).toBeDefined(); + expect(result!.checkpointNumber).toBe(1); + expect(result!.header.slotNumber).toBe(targetSlot); + }); + + it('returns undefined if no proposed entry has that slot', async () => { + await addBlocksForProposed(1, 1, 1); + await blockStore.addProposedCheckpoint({ + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.random({ slotNumber: SlotNumber(10) }), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 100n, + feeAssetPriceModifier: 0n, + }); + + const result = await blockStore.getProposedCheckpointBySlot(SlotNumber(999)); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no proposed checkpoints exist', async () => { + const result = await blockStore.getProposedCheckpointBySlot(SlotNumber(1)); + expect(result).toBeUndefined(); + }); + }); + describe('removeBlocksAfterBlock', () => { it('removes blocks with number > given blockNumber', async () => { // Create blocks for initial checkpoint diff --git a/yarn-project/archiver/src/store/block_store.ts b/yarn-project/archiver/src/store/block_store.ts index 6ec9d07f19b2..46490f35bd77 100644 --- a/yarn-project/archiver/src/store/block_store.ts +++ b/yarn-project/archiver/src/store/block_store.ts @@ -797,6 +797,21 @@ export class BlockStore { return stored ? this.convertToProposedCheckpointData(stored) : undefined; } + /** + * Returns the pending checkpoint whose header slot matches the given slot, or undefined if not found. + * Iterates `#proposedCheckpoints` rather than reading an index because the map carries 0–1 entries + * in normal operation (bounded by the proposer pipelining window). Returns the first match. + */ + async getProposedCheckpointBySlot(slot: SlotNumber): Promise { + for await (const [, stored] of this.#proposedCheckpoints.entriesAsync()) { + const header = CheckpointHeader.fromBuffer(stored.header); + if (header.slotNumber === slot) { + return this.convertToProposedCheckpointData(stored); + } + } + return undefined; + } + /** * Evicts all pending checkpoints with checkpoint number >= fromNumber. * Used for divergent-mined-checkpoint cleanup: when L1 mines checkpoint N with a different archive, diff --git a/yarn-project/archiver/src/test/mock_l2_block_source.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts index 0250dd7159ad..ad18bcf2b2c2 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -20,11 +20,14 @@ import { type BlockTag, type BlocksQuery, Body, + type CheckpointQuery, + type CheckpointsQuery, GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH, L2Block, type L2BlockSource, type L2Tips, + type ProposedCheckpointQuery, type ValidateCheckpointResult, } from '@aztec/stdlib/block'; import { @@ -273,8 +276,16 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(BlockNumber(this.proposedCheckpointBlockNumber)); } - public getCheckpoints(from: CheckpointNumber, limit: number) { - const checkpoints = this.checkpointList.slice(from - 1, from - 1 + limit); + public getCheckpoint(query: CheckpointQuery): Promise { + const checkpoint = this.resolveCheckpointQuery(query); + if (!checkpoint) { + return Promise.resolve(undefined); + } + return Promise.resolve(new PublishedCheckpoint(checkpoint, this.mockL1DataForCheckpoint(checkpoint), [])); + } + + public getCheckpoints(query: CheckpointsQuery): Promise { + const checkpoints = this.resolveCheckpointsQuery(query); return Promise.resolve( checkpoints.map(checkpoint => new PublishedCheckpoint(checkpoint, this.mockL1DataForCheckpoint(checkpoint), [])), ); @@ -285,41 +296,65 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(checkpoint); } - public getCheckpointData(_n: CheckpointNumber): Promise { - return Promise.resolve(undefined); + public getCheckpointData(query: CheckpointQuery): Promise { + const checkpoint = this.resolveCheckpointQuery(query); + if (!checkpoint) { + return Promise.resolve(undefined); + } + return Promise.resolve(this.checkpointToData(checkpoint)); } - public getCheckpointDataRange(_from: CheckpointNumber, _limit: number): Promise { - return Promise.resolve([]); + public getCheckpointsData(query: CheckpointsQuery): Promise { + const checkpoints = this.resolveCheckpointsQuery(query); + return Promise.resolve(checkpoints.map(c => this.checkpointToData(c))); } - public getCheckpointNumberBySlot(_slot: SlotNumber): Promise { - return Promise.resolve(undefined); + private checkpointToData(checkpoint: Checkpoint): CheckpointData { + return { + checkpointNumber: checkpoint.number, + header: checkpoint.header, + archive: checkpoint.archive, + checkpointOutHash: computeCheckpointOutHash( + checkpoint.blocks.map(b => b.body.txEffects.map(tx => tx.l2ToL1Msgs)), + ), + startBlock: checkpoint.blocks[0].number, + blockCount: checkpoint.blocks.length, + feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, + attestations: [], + l1: this.mockL1DataForCheckpoint(checkpoint), + }; } - getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { - return Promise.resolve(this.getCheckpointsInEpoch(epochNumber)); + private resolveCheckpointQuery(query: CheckpointQuery): Checkpoint | undefined { + if ('number' in query) { + return this.checkpointList[query.number - 1]; + } + if ('slot' in query) { + return this.checkpointList.find(c => c.header.slotNumber === query.slot); + } + switch (query.tag) { + case 'checkpointed': + return this.checkpointList[this.checkpointList.length - 1]; + case 'proven': { + const provenCheckpoint = this.checkpointList.filter(c => + c.blocks.some(b => b.number <= this.provenBlockNumber), + ); + return provenCheckpoint.at(-1); + } + case 'finalized': { + const finalizedCheckpoint = this.checkpointList.filter(c => + c.blocks.some(b => b.number <= this.finalizedBlockNumber), + ); + return finalizedCheckpoint.at(-1); + } + } } - getCheckpointsDataForEpoch(epochNumber: EpochNumber): Promise { - const checkpoints = this.getCheckpointsInEpoch(epochNumber); - return Promise.resolve( - checkpoints.map( - (checkpoint): CheckpointData => ({ - checkpointNumber: checkpoint.number, - header: checkpoint.header, - archive: checkpoint.archive, - checkpointOutHash: computeCheckpointOutHash( - checkpoint.blocks.map(b => b.body.txEffects.map(tx => tx.l2ToL1Msgs)), - ), - startBlock: checkpoint.blocks[0].number, - blockCount: checkpoint.blocks.length, - feeAssetPriceModifier: checkpoint.feeAssetPriceModifier, - attestations: [], - l1: this.mockL1DataForCheckpoint(checkpoint), - }), - ), - ); + private resolveCheckpointsQuery(query: CheckpointsQuery): Checkpoint[] { + if ('from' in query) { + return this.checkpointList.slice(query.from - 1, query.from - 1 + query.limit); + } + return this.getCheckpointsInEpoch(query.epoch); } getBlocksForSlot(slotNumber: SlotNumber): Promise { @@ -608,11 +643,7 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve({ valid: true }); } - getLastCheckpoint(): Promise { - return Promise.resolve(undefined); - } - - getLastProposedCheckpoint(): Promise { + getProposedCheckpointData(_query?: ProposedCheckpointQuery): Promise { return Promise.resolve(undefined); } diff --git a/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts b/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts index 672c031329a0..7c3e52c04273 100644 --- a/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts +++ b/yarn-project/aztec-node/src/aztec-node/block_response_helpers.ts @@ -1,6 +1,11 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { type BlockData, type CommitteeAttestation, L2Block } from '@aztec/stdlib/block'; -import type { CheckpointData, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; +import type { + CheckpointData, + L1PublishedData, + ProposedCheckpointData, + PublishedCheckpoint, +} from '@aztec/stdlib/checkpoint'; import { type BlockIncludeOptions, type BlockResponse, @@ -114,3 +119,43 @@ export function checkpointResponseFromCheckpointData( } return response; } + +/** + * Projects a {@link ProposedCheckpointData} into a {@link CheckpointResponse}. + * Pure projection — caller pre-fetches `blocks` via `blockSource.getBlocks(...)` when + * `options.includeBlocks` is true. Throws if `includeL1PublishInfo` or `includeAttestations` + * is requested (proposed checkpoints have no L1 publish info or attestations). + */ +export async function projectProposedToCheckpointResponse( + proposed: ProposedCheckpointData, + options: CheckpointIncludeOptions, + blocks?: L2Block[], +): Promise { + if (options.includeL1PublishInfo || options.includeAttestations) { + throw new Error('Proposed checkpoints have no L1 publish info or attestations'); + } + const response: CheckpointResponse = { + number: proposed.checkpointNumber, + header: proposed.header, + archive: proposed.archive, + checkpointOutHash: proposed.checkpointOutHash, + startBlock: proposed.startBlock, + blockCount: proposed.blockCount, + feeAssetPriceModifier: proposed.feeAssetPriceModifier, + }; + if (options.includeBlocks) { + if (!blocks) { + throw new Error('Blocks must be supplied when includeBlocks is true'); + } + (response as CheckpointResponse).blocks = await Promise.all( + blocks.map(block => + blockResponseFromL2Block(block, { + includeTransactions: options.includeTransactions, + includeL1PublishInfo: false, + includeAttestations: false, + }), + ), + ); + } + return response; +} diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index 21b5e558fa91..8c94969f3188 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -29,14 +29,22 @@ import { type BlockQuery, L2Block, type L2BlockSource, + type L2Tips, } from '@aztec/stdlib/block'; +import type { CheckpointData, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; import type { ContractDataSource } from '@aztec/stdlib/contract'; import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { L2LogsSource, MerkleTreeReadOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { mockTx } from '@aztec/stdlib/testing'; -import { MerkleTreeId, PublicDataTreeLeaf, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; +import { + AppendOnlyTreeSnapshot, + MerkleTreeId, + PublicDataTreeLeaf, + PublicDataTreeLeafPreimage, +} from '@aztec/stdlib/trees'; import type { FeeProvider } from '@aztec/stdlib/tx'; import { BlockHeader, @@ -1121,4 +1129,252 @@ describe('aztec node', () => { expect(result).toEqual([[[[msg1]]], [[[msg2]]]]); }); }); + + /** Builds an L2Tips stub with the given checkpoint numbers per tip. */ + function makeTips(args: { + proposedCheckpoint?: CheckpointNumber; + checkpointed?: CheckpointNumber; + proven?: CheckpointNumber; + finalized?: CheckpointNumber; + }): L2Tips { + const emptyBlockId = { number: BlockNumber(0), hash: '' }; + const makeTipId = (n: CheckpointNumber) => ({ block: emptyBlockId, checkpoint: { number: n, hash: '' } }); + return { + proposed: emptyBlockId, + checkpointed: makeTipId(args.checkpointed ?? CheckpointNumber(0)), + proposedCheckpoint: makeTipId(args.proposedCheckpoint ?? CheckpointNumber(0)), + proven: makeTipId(args.proven ?? CheckpointNumber(0)), + finalized: makeTipId(args.finalized ?? CheckpointNumber(0)), + }; + } + + describe('getCheckpoint', () => { + /** Builds a minimal ProposedCheckpointData stub. */ + function makeProposedCheckpointData( + checkpointNumber: CheckpointNumber, + slotNumber: SlotNumber, + ): ProposedCheckpointData { + return { + checkpointNumber, + header: CheckpointHeader.random({ slotNumber }), + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.ZERO, + startBlock: BlockNumber(Number(checkpointNumber)), + blockCount: 1, + totalManaUsed: 0n, + feeAssetPriceModifier: 0n, + }; + } + + /** Builds a minimal CheckpointData stub. */ + function makeCheckpointData(checkpointNumber: CheckpointNumber): CheckpointData { + return { + checkpointNumber, + header: CheckpointHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.ZERO, + startBlock: BlockNumber(Number(checkpointNumber)), + blockCount: 1, + feeAssetPriceModifier: 0n, + attestations: [], + l1: { blockNumber: 10n, blockTimestamp: 1000n, blockHash: '0x0000' } as any, + }; + } + + describe('throw guards', () => { + it('throws BadRequestError when "proposed" resolves to a proposed entry and includeL1PublishInfo is requested', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ proposedCheckpoint: CheckpointNumber(5) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue( + makeProposedCheckpointData(CheckpointNumber(5), SlotNumber(10)), + ); + + await expect(node.getCheckpoint('proposed', { includeL1PublishInfo: true })).rejects.toThrow(BadRequestError); + }); + + it('throws BadRequestError when "proposed" resolves to a proposed entry and includeAttestations is requested', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ proposedCheckpoint: CheckpointNumber(5) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue( + makeProposedCheckpointData(CheckpointNumber(5), SlotNumber(10)), + ); + + await expect(node.getCheckpoint('proposed', { includeAttestations: true })).rejects.toThrow(BadRequestError); + }); + + it('throws BadRequestError when number lookup resolves to a proposed entry and includeL1PublishInfo is requested', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue( + makeProposedCheckpointData(CheckpointNumber(5), SlotNumber(10)), + ); + + await expect( + node.getCheckpoint({ number: CheckpointNumber(5) }, { includeL1PublishInfo: true }), + ).rejects.toThrow(BadRequestError); + }); + + it('throws BadRequestError when slot lookup resolves to a proposed entry and includeAttestations is requested', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue( + makeProposedCheckpointData(CheckpointNumber(3), SlotNumber(7)), + ); + + await expect(node.getCheckpoint({ slot: SlotNumber(7) }, { includeAttestations: true })).rejects.toThrow( + BadRequestError, + ); + }); + }); + + describe('fallback semantics', () => { + it('getCheckpoint("proposed") returns the projected proposed entry when one exists at the proposed-tip number', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ proposedCheckpoint: CheckpointNumber(2) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + const proposed = makeProposedCheckpointData(CheckpointNumber(2), SlotNumber(5)); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(proposed); + + const result = await node.getCheckpoint('proposed'); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(2)); + }); + + it('getCheckpoint("proposed") returns the latest confirmed checkpoint when no proposed entry exists', async () => { + // When no proposed entry exists, the proposedCheckpoint tip falls back to the confirmed tip. + l2BlockSource.getL2Tips.mockResolvedValue( + makeTips({ proposedCheckpoint: CheckpointNumber(3), checkpointed: CheckpointNumber(3) }), + ); + const confirmed = makeCheckpointData(CheckpointNumber(3)); + l2BlockSource.getCheckpointData.mockResolvedValue(confirmed); + + const result = await node.getCheckpoint('proposed'); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(3)); + }); + + it('getCheckpoint({ number }) returns the confirmed entry when one exists', async () => { + const confirmed = makeCheckpointData(CheckpointNumber(3)); + l2BlockSource.getCheckpointData.mockResolvedValue(confirmed); + l2BlockSource.getProposedCheckpointData.mockResolvedValue( + makeProposedCheckpointData(CheckpointNumber(3), SlotNumber(99)), + ); + + const result = await node.getCheckpoint({ number: CheckpointNumber(3) }); + // The confirmed entry should be returned; proposed should not be reached + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(3)); + // l1 is not included by default — confirm no throw and correct shape + expect(result!.l1).toBeUndefined(); + }); + + it('getCheckpoint({ number }) falls back to proposed entry when no confirmed match', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + const proposed = makeProposedCheckpointData(CheckpointNumber(4), SlotNumber(8)); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(proposed); + + const result = await node.getCheckpoint({ number: CheckpointNumber(4) }); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(4)); + }); + + it('getCheckpoint({ slot }) falls back to proposed entry when no confirmed match', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + const proposed = makeProposedCheckpointData(CheckpointNumber(5), SlotNumber(11)); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(proposed); + + const result = await node.getCheckpoint({ slot: SlotNumber(11) }); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(5)); + }); + + it('getCheckpoint("checkpointed") returns the confirmed entry at the checkpointed tip', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ checkpointed: CheckpointNumber(2) })); + const confirmed = makeCheckpointData(CheckpointNumber(2)); + l2BlockSource.getCheckpointData.mockResolvedValue(confirmed); + + const result = await node.getCheckpoint('checkpointed'); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(2)); + }); + + it('getCheckpoint("checkpointed") returns undefined when neither store has the resolved number', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ checkpointed: CheckpointNumber(2) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(undefined); + + const result = await node.getCheckpoint('checkpointed'); + expect(result).toBeUndefined(); + }); + + it('getCheckpoint("proven") returns the confirmed entry at the proven tip', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ proven: CheckpointNumber(4) })); + const confirmed = makeCheckpointData(CheckpointNumber(4)); + l2BlockSource.getCheckpointData.mockResolvedValue(confirmed); + + const result = await node.getCheckpoint('proven'); + expect(result).toBeDefined(); + expect(result!.number).toEqual(CheckpointNumber(4)); + }); + + it('getCheckpoint("proven") returns undefined when neither store has the resolved number', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ proven: CheckpointNumber(4) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(undefined); + + const result = await node.getCheckpoint('proven'); + expect(result).toBeUndefined(); + }); + + it('getCheckpoint("finalized") returns undefined when neither store has the resolved number', async () => { + l2BlockSource.getL2Tips.mockResolvedValue(makeTips({ finalized: CheckpointNumber(6) })); + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(undefined); + + const result = await node.getCheckpoint('finalized'); + expect(result).toBeUndefined(); + }); + + it('getCheckpoint({ number }) returns undefined when neither confirmed nor proposed exist', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(undefined); + + const result = await node.getCheckpoint({ number: CheckpointNumber(99) }); + expect(result).toBeUndefined(); + }); + }); + + describe('includeBlocks on a proposed match', () => { + it('pre-fetches inner blocks and passes them into the projected response', async () => { + l2BlockSource.getCheckpointData.mockResolvedValue(undefined); + const proposed = makeProposedCheckpointData(CheckpointNumber(2), SlotNumber(5)); + l2BlockSource.getProposedCheckpointData.mockResolvedValue(proposed); + + const fakeBlock = L2Block.empty(); + l2BlockSource.getBlocks.mockResolvedValue([fakeBlock]); + + const result = await node.getCheckpoint({ number: CheckpointNumber(2) }, { includeBlocks: true }); + expect(result).toBeDefined(); + expect(result!.blocks).toBeDefined(); + expect(result!.blocks!.length).toBe(1); + }); + }); + }); + + describe('getCheckpointNumber', () => { + it('returns the proposed checkpoint number from proposedCheckpoint tip', async () => { + l2BlockSource.getL2Tips.mockResolvedValue( + makeTips({ proposedCheckpoint: CheckpointNumber(7), checkpointed: CheckpointNumber(5) }), + ); + + const result = await node.getCheckpointNumber('proposed'); + expect(result).toEqual(CheckpointNumber(7)); + }); + + it('returns the proposedCheckpoint tip number when it equals the confirmed checkpoint (fallback already baked in)', async () => { + l2BlockSource.getL2Tips.mockResolvedValue( + makeTips({ proposedCheckpoint: CheckpointNumber(5), checkpointed: CheckpointNumber(5) }), + ); + + const result = await node.getCheckpointNumber('proposed'); + expect(result).toEqual(CheckpointNumber(5)); + }); + }); }); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 975f9bfcce78..e8306dcf93e2 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -53,13 +53,14 @@ import { BlockHash, type BlockParameter, BlockTag, + type CheckpointsQuery, type CommitteeAttestation, type DataInBlock, type L2BlockSource, type NormalizedBlockParameter, inspectBlockParameter, } from '@aztec/stdlib/block'; -import { L1PublishedData } from '@aztec/stdlib/checkpoint'; +import { type CheckpointData, L1PublishedData, type PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, ContractDataSource, @@ -143,6 +144,7 @@ import { blockResponseFromL2Block, checkpointResponseFromCheckpointData, checkpointResponseFromPublishedCheckpoint, + projectProposedToCheckpointResponse, } from './block_response_helpers.js'; import { type AztecNodeConfig, createKeyStoreForValidator } from './config.js'; import { NodeMetrics } from './node_metrics.js'; @@ -240,8 +242,8 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb ); } - public getCheckpointsDataForEpoch(epoch: EpochNumber) { - return this.blockSource.getCheckpointsDataForEpoch(epoch); + public getCheckpointsData(query: CheckpointsQuery) { + return this.blockSource.getCheckpointsData(query); } public getBlockNumber(tip?: ChainTip): Promise { @@ -259,16 +261,17 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } public async getCheckpointNumber(tip?: ChainTip): Promise { + const tips = await this.blockSource.getL2Tips(); switch (tip) { case undefined: - case 'proposed': case 'checkpointed': - return await this.blockSource.getCheckpointNumber(); + return tips.checkpointed.checkpoint.number; + case 'proposed': + return tips.proposedCheckpoint.checkpoint.number; case 'proven': - case 'finalized': { - const tips = await this.blockSource.getL2Tips(); - return tip === 'proven' ? tips.proven.checkpoint.number : tips.finalized.checkpoint.number; - } + return tips.proven.checkpoint.number; + case 'finalized': + return tips.finalized.checkpoint.number; } } @@ -318,17 +321,32 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return BlockTag.includes(value as BlockTag); } + /** + * Resolves a {@link CheckpointParameter} into a concrete `{ number }` or `{ slot }` query. + * + * Tag-based parameters (`'proposed'`, `'checkpointed'`, `'proven'`, `'finalized'`) are + * translated up-front to the corresponding tip's checkpoint number via {@link L2BlockSource.getL2Tips}. + * After resolution the unified {@link getCheckpoint} flow can perform a single + * confirmed→proposed lookup against either store. + */ private async resolveCheckpointParameter( param: CheckpointParameter, - ): Promise<{ number?: CheckpointNumber; slot?: SlotNumber }> { + ): Promise<{ number: CheckpointNumber } | { slot: SlotNumber }> { if (typeof param === 'number') { return { number: param as CheckpointNumber }; } - if (param === 'latest') { - return { number: await this.blockSource.getCheckpointNumber() }; - } if (this.isChainTip(param)) { - return { number: await this.getCheckpointNumber(param) }; + const tips = await this.blockSource.getL2Tips(); + switch (param) { + case 'proposed': + return { number: tips.proposedCheckpoint.checkpoint.number }; + case 'checkpointed': + return { number: tips.checkpointed.checkpoint.number }; + case 'proven': + return { number: tips.proven.checkpoint.number }; + case 'finalized': + return { number: tips.finalized.checkpoint.number }; + } } if (typeof param === 'object' && param !== null) { if ('number' in param) { @@ -345,7 +363,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb async #getCheckpointContext( checkpointNumber: CheckpointNumber, ): Promise<{ l1?: L1PublishedData; attestations?: CommitteeAttestation[] } | undefined> { - const checkpoint = await this.blockSource.getCheckpointData(checkpointNumber); + const checkpoint = await this.blockSource.getCheckpointData({ number: checkpointNumber }); if (!checkpoint) { return undefined; } @@ -412,26 +430,33 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb param: CheckpointParameter, options: Opts = {} as Opts, ): Promise | undefined> { - const resolved = await this.resolveCheckpointParameter(param); - let checkpointNumber = resolved.number; - if (checkpointNumber === undefined && resolved.slot !== undefined) { - checkpointNumber = await this.blockSource.getCheckpointNumberBySlot(resolved.slot); - } - if (checkpointNumber === undefined) { - return undefined; - } - if (options.includeBlocks) { - const [checkpoint] = await this.blockSource.getCheckpoints(checkpointNumber, 1); - if (!checkpoint) { - return undefined; + const query = await this.resolveCheckpointParameter(param); + + // Try the confirmed store first. + const confirmed = options.includeBlocks + ? await this.blockSource.getCheckpoint(query) + : await this.blockSource.getCheckpointData(query); + if (confirmed) { + return (await (options.includeBlocks + ? checkpointResponseFromPublishedCheckpoint(confirmed as PublishedCheckpoint, options) + : checkpointResponseFromCheckpointData(confirmed as CheckpointData, options))) as CheckpointResponse; + } + + // Fall back to the proposed store. + const proposed = await this.blockSource.getProposedCheckpointData(query); + if (proposed) { + if (options.includeAttestations || options.includeL1PublishInfo) { + throw new BadRequestError( + `Options includeL1PublishInfo or includeAttestations cannot be satisfied for a proposed checkpoint`, + ); } - return (await checkpointResponseFromPublishedCheckpoint(checkpoint, options)) as CheckpointResponse; + const blocks = options.includeBlocks + ? await this.blockSource.getBlocks({ from: proposed.startBlock, limit: proposed.blockCount }) + : undefined; + return (await projectProposedToCheckpointResponse(proposed, options, blocks)) as CheckpointResponse; } - const data = await this.blockSource.getCheckpointData(checkpointNumber); - if (!data) { - return undefined; - } - return checkpointResponseFromCheckpointData(data, options) as CheckpointResponse; + + return undefined; } public async getCheckpoints( @@ -440,12 +465,12 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb options: Opts = {} as Opts, ): Promise[]> { if (options.includeBlocks) { - const checkpoints = await this.blockSource.getCheckpoints(from, limit); + const checkpoints = await this.blockSource.getCheckpoints({ from, limit }); return (await Promise.all( checkpoints.map(cp => checkpointResponseFromPublishedCheckpoint(cp, options)), )) as CheckpointResponse[]; } - const datas = await this.blockSource.getCheckpointDataRange(from, limit); + const datas = await this.blockSource.getCheckpointsData({ from, limit }); return datas.map(d => checkpointResponseFromCheckpointData(d, options)) as CheckpointResponse[]; } diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index ec13d41a8ed4..cb1b195dc488 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -145,7 +145,7 @@ describe('e2e_epochs/epochs_mbps', () => { const waitTimeout = test.L2_SLOT_DURATION_IN_S * 3; await retryUntil( async () => { - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); return checkpoints.some(pc => pc.checkpoint.blocks.length >= targetBlockCount) || undefined; }, `checkpoint with at least ${targetBlockCount} blocks`, @@ -153,7 +153,7 @@ describe('e2e_epochs/epochs_mbps', () => { 0.5, ); - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), }); @@ -308,7 +308,7 @@ describe('e2e_epochs/epochs_mbps', () => { const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); // Verify L2→L1 messages are in the blocks - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); const allBlocks = checkpoints.flatMap(pc => pc.checkpoint.blocks); const allL2ToL1Messages = allBlocks.flatMap(block => block.body.txEffects.flatMap(txEffect => txEffect.l2ToL1Msgs)); logger.warn(`Found ${allL2ToL1Messages.length} L2→L1 message(s) across all blocks`, { allL2ToL1Messages }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts index 543c5c4ca712..013324576541 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts @@ -115,7 +115,7 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { /** Retrieves all checkpoints from the archiver, checks that one has the target block count, and returns its number. */ async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger): Promise { - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), }); @@ -162,7 +162,7 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { blockProposedEvents: { blockNumber: BlockNumber; slot: SlotNumber; buildSlot: SlotNumber }[], logger: Logger, ) { - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); const allBlocks = checkpoints.flatMap(pc => pc.checkpoint.blocks); logger.warn(`assertProposerPipelining: ${allBlocks.length} blocks, ${blockProposedEvents.length} events`, { diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts index e6a0e0fbfade..ae015d03e5e3 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts @@ -311,7 +311,7 @@ describe('e2e_epochs/epochs_mbps_redistribution', () => { await retryUntil( async () => { - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); for (const pc of checkpoints) { if (pc.checkpoint.number <= lastSeenCheckpoint) { continue; diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 7bd24a0728da..4a0fa3336427 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -465,7 +465,7 @@ export class EpochsTestContext { /** Verifies at least one checkpoint has the target number of blocks (for MBPS validation). */ public async assertMultipleBlocksPerSlot(targetBlockCount: number) { const archiver = (this.context.aztecNode as AztecNodeService).getBlockSource() as Archiver; - const checkpoints = await archiver.getCheckpoints(CheckpointNumber(1), 50); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); this.logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index eb25a0beed48..84eca5503904 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -52,6 +52,7 @@ import { type BlockQuery, type BlocksQuery, Body, + type CheckpointsQuery, type CommitteeAttestation, CommitteeAttestationsAndSigners, L2Block, @@ -249,9 +250,10 @@ describe('L1Publisher integration', () => { } return Promise.resolve(undefined); }, - async getCheckpoints(checkpointNumber, _limit) { + async getCheckpoints(query: CheckpointsQuery) { // Test uses 1-block-per-checkpoint, so we find block by checkpoint number - const block = blocks.find(b => Number(b.number) === Number(checkpointNumber)); + const from = 'from' in query ? query.from : undefined; + const block = from !== undefined ? blocks.find(b => Number(b.number) === Number(from)) : undefined; if (!block) { return Promise.resolve([]); } @@ -259,7 +261,7 @@ describe('L1Publisher integration', () => { block.archive, CheckpointHeader.random({ lastArchiveRoot: block.header.lastArchive.root }), [block], - checkpointNumber, + from!, ); return [ new PublishedCheckpoint( diff --git a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts b/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts index 3d0481027068..13ea91f29bfc 100644 --- a/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts +++ b/yarn-project/end-to-end/src/e2e_multi_validator/e2e_multi_validator_node.test.ts @@ -124,7 +124,7 @@ describe('e2e_multi_validator_node', () => { const dataStore = (aztecNode as AztecNodeService).getBlockSource() as Archiver; const blockData = await dataStore.getBlockData({ number: BlockNumber(tx.blockNumber!) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); const signatureContext = { chainId: config.l1ChainId, rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress, @@ -186,7 +186,7 @@ describe('e2e_multi_validator_node', () => { const dataStore = (aztecNode as AztecNodeService).getBlockSource() as Archiver; const blockData = await dataStore.getBlockData({ number: BlockNumber(tx.blockNumber!) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); const signatureContext = { chainId: config.l1ChainId, rollupAddress: deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts index 626b9f80da92..04b436680edb 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts @@ -178,7 +178,7 @@ describe('e2e_p2p_network', () => { const blockNumbers = await Promise.all(nodes.map(node => node.getBlockNumber())); const checkpointNumber = (await t.monitor.run()).checkpointNumber; t.logger.info(`Current block numbers ${blockNumbers} (checkpoint number on L1 is ${checkpointNumber})`); - const [checkpoint] = await dataStore.getCheckpoints(CheckpointNumber(1), 1); + const [checkpoint] = await dataStore.getCheckpoints({ from: CheckpointNumber(1), limit: 1 }); return checkpoint; }, 'published checkpoint to be indexed', diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts index 7cedb449aa54..2ff865324b5f 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts @@ -188,7 +188,7 @@ describe('e2e_p2p_network', () => { const blockNumber = receipt.blockNumber!; const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts index 5dc5504822ca..242a2fef3e16 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts @@ -247,7 +247,7 @@ describe('e2e_p2p_network', () => { const blockNumber = await nodes[0].getTxReceipt(txsSentViaDifferentNodes[0][0]).then(r => r.blockNumber!); const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts index 7e13b565cf65..03bd23ebb57e 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts @@ -357,7 +357,7 @@ describe('e2e_p2p_preferred_network', () => { const blockNumber = receipts[0].blockNumber!; const dataStore = (nodes[0] as AztecNodeService).getBlockSource() as Archiver; const blockData = await dataStore.getBlockData({ number: BlockNumber(blockNumber) }); - const [publishedCheckpoint] = await dataStore.getCheckpoints(blockData!.checkpointNumber, 1); + const [publishedCheckpoint] = await dataStore.getCheckpoints({ from: blockData!.checkpointNumber, limit: 1 }); const signatureContext = { chainId: t.ctx.aztecNodeConfig.l1ChainId, rollupAddress: t.ctx.deployL1ContractsValues.l1ContractAddresses.rollupAddress, diff --git a/yarn-project/prover-node/src/prover-node.test.ts b/yarn-project/prover-node/src/prover-node.test.ts index 45b09dc511a0..68fdca4152a4 100644 --- a/yarn-project/prover-node/src/prover-node.test.ts +++ b/yarn-project/prover-node/src/prover-node.test.ts @@ -149,10 +149,14 @@ describe('prover-node', () => { attestations: [CommitteeAttestation.random()], } as PublishedCheckpoint; + const publishedCheckpoints: PublishedCheckpoint[] = [ + ...checkpoints.slice(0, -1).map(cp => ({ checkpoint: cp, attestations: [] }) as unknown as PublishedCheckpoint), + lastPublishedCheckpoint, + ]; + l1GenesisTime = Math.floor(Date.now() / 1000) - 3600; l2BlockSource.getL1Constants.mockResolvedValue({ ...EmptyL1RollupConstants, l1GenesisTime: BigInt(l1GenesisTime) }); - l2BlockSource.getCheckpointsForEpoch.mockResolvedValue(checkpoints); - l2BlockSource.getCheckpoints.mockResolvedValue([lastPublishedCheckpoint]); + l2BlockSource.getCheckpoints.mockResolvedValue(publishedCheckpoints); const latestBlockNumber = BlockNumber.fromCheckpointNumber(checkpoints.at(-1)!.number); const latestHash = checkpoints.at(-1)!.hash().toString(); const genesisTipId = { @@ -214,7 +218,7 @@ describe('prover-node', () => { }); it('does not start a proof if there are no checkpoints in the epoch', async () => { - l2BlockSource.getCheckpointsForEpoch.mockResolvedValue([]); + l2BlockSource.getCheckpoints.mockResolvedValue([]); await proverNode.handleEpochReadyToProve(EpochNumber.fromBigInt(10n)); expect(proverNode.totalJobCount).toEqual(0); }); diff --git a/yarn-project/prover-node/src/prover-node.ts b/yarn-project/prover-node/src/prover-node.ts index 9d7a4336054e..92db38ae492b 100644 --- a/yarn-project/prover-node/src/prover-node.ts +++ b/yarn-project/prover-node/src/prover-node.ts @@ -312,26 +312,21 @@ export class ProverNode implements EpochMonitorHandler, ProverNodeApi, Traceable @trackSpan('ProverNode.gatherEpochData', epochNumber => ({ [Attributes.EPOCH_NUMBER]: epochNumber })) private async gatherEpochData(epochNumber: EpochNumber): Promise { - const checkpoints = await this.gatherCheckpoints(epochNumber); + const publishedCheckpoints = await this.l2BlockSource.getCheckpoints({ epoch: epochNumber }); + if (publishedCheckpoints.length === 0) { + throw new EmptyEpochError(epochNumber); + } + const checkpoints = publishedCheckpoints.map(p => p.checkpoint); + const attestations = publishedCheckpoints.at(-1)?.attestations ?? []; const txArray = await this.gatherTxs(epochNumber, checkpoints); const txs = new Map(txArray.map(tx => [tx.getTxHash().toString(), tx])); const l1ToL2Messages = await this.gatherMessages(epochNumber, checkpoints); const [firstBlock] = checkpoints[0].blocks; const previousBlockHeader = await this.gatherPreviousBlockHeader(epochNumber, firstBlock.number - 1); - const [lastPublishedCheckpoint] = await this.l2BlockSource.getCheckpoints(checkpoints.at(-1)!.number, 1); - const attestations = lastPublishedCheckpoint?.attestations ?? []; return { checkpoints, txs, l1ToL2Messages, epochNumber, previousBlockHeader, attestations }; } - private async gatherCheckpoints(epochNumber: EpochNumber) { - const checkpoints = await this.l2BlockSource.getCheckpointsForEpoch(epochNumber); - if (checkpoints.length === 0) { - throw new EmptyEpochError(epochNumber); - } - return checkpoints; - } - private async gatherTxs(epochNumber: EpochNumber, checkpoints: Checkpoint[]) { const deadline = new Date(this.dateProvider.now() + this.config.txGatheringTimeoutMs); const txProvider = this.p2pClient.getTxProvider(); diff --git a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts index eb4b2ca4bfcc..82182ad8cac8 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts @@ -1,6 +1,12 @@ -import type { CheckpointNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { type BlockData, type BlockQuery, type BlocksQuery, L2Block, type L2BlockSource } from '@aztec/stdlib/block'; +import { + type BlockData, + type BlockQuery, + type BlocksQuery, + type CheckpointsQuery, + L2Block, + type L2BlockSource, +} from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; @@ -42,7 +48,11 @@ export function blockStreamSourceFromAztecNode( return responses.map(r => new L2Block(r.archive, r.header, r.body!, r.checkpointNumber, r.indexWithinCheckpoint)); }, - async getCheckpoints(from: CheckpointNumber, limit: number): Promise { + async getCheckpoints(query: CheckpointsQuery): Promise { + if (!('from' in query)) { + throw new Error('getCheckpoints with epoch query not supported via AztecNode RPC'); + } + const { from, limit } = query; const responses = await node.getCheckpoints(from, limit, { includeBlocks: true, includeTransactions: true, diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 059add1b4e0b..e95e55ed41b4 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -234,7 +234,7 @@ describe('CheckpointProposalJob', () => { l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue(Array(4).fill(Fr.ZERO)); l2BlockSource = mock(); - l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); + l2BlockSource.getCheckpointsData.mockResolvedValue([]); blockSink = mock(); blockSink.addBlock.mockResolvedValue(undefined); @@ -460,7 +460,7 @@ describe('CheckpointProposalJob', () => { ); // Mock l2BlockSource to return the previous checkpoints - l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue(previousCheckpointsData); + l2BlockSource.getCheckpointsData.mockResolvedValue(previousCheckpointsData); // Build block successfully const { txs, block } = await setupTxsAndBlock(p2p, globalVariables, 1, chainId); @@ -497,7 +497,7 @@ describe('CheckpointProposalJob', () => { ); // Mock l2BlockSource to return all three checkpoints as data - l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue([ + l2BlockSource.getCheckpointsData.mockResolvedValue([ toCheckpointData(previousCheckpoint), toCheckpointData(currentCheckpoint), toCheckpointData(futureCheckpoint), @@ -529,7 +529,7 @@ describe('CheckpointProposalJob', () => { checkpointNumber = CheckpointNumber(2); const previousCheckpoint = await Checkpoint.random(CheckpointNumber(1)); - l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue([toCheckpointData(previousCheckpoint)]); + l2BlockSource.getCheckpointsData.mockResolvedValue([toCheckpointData(previousCheckpoint)]); job = createCheckpointProposalJob({ slotNow, targetSlot, targetEpoch }); job.setTimetable( @@ -548,8 +548,8 @@ describe('CheckpointProposalJob', () => { await job.execute(); - // Verify getCheckpointsDataForEpoch was called with targetEpoch (1), not the wall-clock epoch (0) - expect(l2BlockSource.getCheckpointsDataForEpoch).toHaveBeenCalledWith(targetEpoch); + // Verify getCheckpointsData was called with targetEpoch (1), not the wall-clock epoch (0) + expect(l2BlockSource.getCheckpointsData).toHaveBeenCalledWith({ epoch: targetEpoch }); }); }); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts index 90f98e39cd02..de0d8138b7c2 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.timing.test.ts @@ -435,7 +435,7 @@ describe('CheckpointProposalJob Timing Tests', () => { l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue(Array(4).fill(Fr.ZERO)); l2BlockSource = mock(); - l2BlockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); + l2BlockSource.getCheckpointsData.mockResolvedValue([]); blockSink = mock(); blockSink.addBlock.mockResolvedValue(undefined); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 27f16178405d..586b3d544de5 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -561,7 +561,7 @@ export class CheckpointProposalJob implements Traceable { const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages); // Collect the out hashes of all the checkpoints before this one in the same epoch - const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(this.targetEpoch)) + const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsData({ epoch: this.targetEpoch })) .filter(c => c.checkpointNumber < this.checkpointNumber) .map(c => c.checkpointOutHash); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 4159fd2f11f9..033aad7108ed 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -317,10 +317,9 @@ describe('sequencer', () => { getL1Timestamp: mockFn().mockResolvedValue(1000n), isPendingChainInvalid: mockFn().mockResolvedValue(false), getPendingChainValidationStatus: mockFn().mockResolvedValue({ valid: true }), - getCheckpointsForEpoch: mockFn().mockResolvedValue([]), - getCheckpointsDataForEpoch: mockFn().mockResolvedValue([]), + getCheckpointsData: mockFn().mockResolvedValue([]), getSyncedL2SlotNumber: mockFn().mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)), - getLastCheckpoint: mockFn().mockResolvedValue(undefined), + getProposedCheckpointData: mockFn().mockResolvedValue(undefined), }); l1ToL2MessageSource = mock({ @@ -1075,7 +1074,7 @@ describe('sequencer', () => { checkpointNumber: CheckpointNumber(1), indexWithinCheckpoint: IndexWithinCheckpoint(0), } satisfies BlockData); - l2BlockSource.getLastCheckpoint.mockResolvedValue({ + l2BlockSource.getProposedCheckpointData.mockResolvedValue({ checkpointNumber: CheckpointNumber(1), } as any); @@ -1136,7 +1135,7 @@ describe('sequencer', () => { checkpointNumber: CheckpointNumber(3), indexWithinCheckpoint: IndexWithinCheckpoint(0), } satisfies BlockData); - l2BlockSource.getLastCheckpoint.mockResolvedValue({ + l2BlockSource.getProposedCheckpointData.mockResolvedValue({ checkpointNumber: CheckpointNumber(2), } as any); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index e1dff8c91f93..109e0ab467d5 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -605,7 +605,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter p2p.syncedToL2Block), this.l1ToL2MessageSource.getL2Tips().then(t => ({ proposed: t.proposed, checkpointed: t.checkpointed })), this.l2BlockSource.getPendingChainValidationStatus(), - this.l2BlockSource.getLastProposedCheckpoint(), + this.l2BlockSource.getProposedCheckpointData(), ] as const); const [worldState, l2Tips, p2p, l1ToL2MessageSourceTips, pendingChainValidationStatus, proposedCheckpointData] = diff --git a/yarn-project/slasher/src/watchers/epoch_prune_watcher.test.ts b/yarn-project/slasher/src/watchers/epoch_prune_watcher.test.ts index aa106b929d17..cca7caf88caa 100644 --- a/yarn-project/slasher/src/watchers/epoch_prune_watcher.test.ts +++ b/yarn-project/slasher/src/watchers/epoch_prune_watcher.test.ts @@ -253,8 +253,8 @@ describe('EpochPruneWatcher', () => { class MockL2BlockSource { public readonly events = new EventEmitter(); - public getCheckpointsForEpoch = () => []; - public getCheckpointsDataForEpoch = () => []; + public getCheckpoints = () => []; + public getCheckpointsData = () => []; constructor() {} } diff --git a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts index b0eeb2b80709..bfbed6d1f552 100644 --- a/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts +++ b/yarn-project/slasher/src/watchers/epoch_prune_watcher.ts @@ -132,7 +132,7 @@ export class EpochPruneWatcher extends (EventEmitter as new () => WatcherEmitter const blocksByCheckpoint = chunkBy(sortedBlocks, b => b.checkpointNumber); // Get prior checkpoints in the epoch (in case this was a partial prune) to extract the out hashes - const priorCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsDataForEpoch(epochNumber)) + const priorCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsData({ epoch: epochNumber })) .filter(c => c.checkpointNumber < sortedBlocks[0].checkpointNumber) .map(c => c.checkpointOutHash); let previousCheckpointOutHashes: Fr[] = [...priorCheckpointOutHashes]; diff --git a/yarn-project/stdlib/src/block/l2_block_source.ts b/yarn-project/stdlib/src/block/l2_block_source.ts index 7fd024f7e42a..7aa47bd16ac3 100644 --- a/yarn-project/stdlib/src/block/l2_block_source.ts +++ b/yarn-project/stdlib/src/block/l2_block_source.ts @@ -6,6 +6,7 @@ import { type EpochNumber, EpochNumberSchema, type SlotNumber, + SlotNumberSchema, } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { EthAddress } from '@aztec/foundation/eth-address'; @@ -14,8 +15,7 @@ import type { TypedEventEmitter } from '@aztec/foundation/types'; import { z } from 'zod'; -import type { Checkpoint } from '../checkpoint/checkpoint.js'; -import type { CheckpointData, CommonCheckpointData, ProposedCheckpointData } from '../checkpoint/checkpoint_data.js'; +import type { CheckpointData, ProposedCheckpointData } from '../checkpoint/checkpoint_data.js'; import type { PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; import type { L1RollupConstants } from '../epoch-helpers/index.js'; import { CheckpointHeader } from '../rollup/checkpoint_header.js'; @@ -56,6 +56,39 @@ export const BlocksQuerySchema: z.ZodType = z.object({ epoch: EpochNumberSchema, onlyCheckpointed: z.literal(true) }).strict(), ]); +/** Lookup a single confirmed checkpoint by checkpoint number, slot, or chain-tip tag. */ +export type CheckpointQuery = + | { number: CheckpointNumber } + | { slot: SlotNumber } + | { tag: 'checkpointed' | 'proven' | 'finalized' }; + +/** Query a range of confirmed checkpoints by start/limit or by epoch. */ +export type CheckpointsQuery = { from: CheckpointNumber; limit: number } | { epoch: EpochNumber }; + +/** + * Lookup a proposed (archiver-internal, not-yet-L1-confirmed) checkpoint. + * Distinct from CheckpointQuery because proposed checkpoints have a different shape + * (no l1, no attestations, but carries totalManaUsed) and live in their own LMDB map. + */ +export type ProposedCheckpointQuery = { number: CheckpointNumber } | { slot: SlotNumber } | { tag: 'proposed' }; + +export const CheckpointQuerySchema: z.ZodType = z.union([ + z.object({ number: CheckpointNumberSchema }).strict(), + z.object({ slot: SlotNumberSchema }).strict(), + z.object({ tag: z.union([z.literal('checkpointed'), z.literal('proven'), z.literal('finalized')]) }).strict(), +]); + +export const CheckpointsQuerySchema: z.ZodType = z.union([ + z.object({ from: CheckpointNumberSchema, limit: z.number().int().min(1) }).strict(), + z.object({ epoch: EpochNumberSchema }).strict(), +]); + +export const ProposedCheckpointQuerySchema: z.ZodType = z.union([ + z.object({ number: CheckpointNumberSchema }).strict(), + z.object({ slot: SlotNumberSchema }).strict(), + z.object({ tag: z.literal('proposed') }).strict(), +]); + /** * Interface of classes allowing for the retrieval of L2 blocks. */ @@ -112,47 +145,32 @@ export interface L2BlockSource { getFinalizedL2BlockNumber(): Promise; /** - * Retrieves a collection of checkpoints. - * @param checkpointNumber The first checkpoint to be retrieved. - * @param limit The number of checkpoints to be retrieved. - * @returns The collection of complete checkpoints. + * Gets a single confirmed checkpoint matching the given query. + * Heavy shape: includes nested full `L2Block`s with transaction bodies. + * @param query - Lookup by checkpoint number, slot, or chain-tip tag. */ - getCheckpoints(checkpointNumber: CheckpointNumber, limit: number): Promise; + getCheckpoint(query: CheckpointQuery): Promise; /** - * Gets the checkpoints for a given epoch - * @param epochNumber - Epoch for which we want checkpoint data + * Gets a collection of confirmed checkpoints matching the given query. + * Heavy shape: includes nested full `L2Block`s with transaction bodies. + * @param query - Range by start/limit or by epoch. */ - getCheckpointsForEpoch(epochNumber: EpochNumber): Promise; - - /** - * Gets lightweight checkpoint metadata for a given epoch, without fetching full block data. - * @param epochNumber - Epoch for which we want checkpoint data - */ - getCheckpointsDataForEpoch(epochNumber: EpochNumber): Promise; + getCheckpoints(query: CheckpointsQuery): Promise; /** * Gets lightweight checkpoint metadata for a single checkpoint. * Cheap passthrough for metadata-only queries (no block body reads). - * @param checkpointNumber - The checkpoint number to retrieve. - * @returns The requested checkpoint data (or undefined if not found). + * @param query - Lookup by checkpoint number, slot, or chain-tip tag. */ - getCheckpointData(checkpointNumber: CheckpointNumber): Promise; + getCheckpointData(query: CheckpointQuery): Promise; /** - * Gets up to `limit` amount of checkpoint metadata entries starting from `from`. + * Gets a collection of lightweight checkpoint metadata entries matching the given query. * Cheap passthrough for metadata-only queries (no block body reads). - * @param from - The first checkpoint number to return (inclusive). - * @param limit - The maximum number of checkpoints to return. - */ - getCheckpointDataRange(from: CheckpointNumber, limit: number): Promise; - - /** - * Looks up the checkpoint number that contains the given slot. - * @param slot - The slot number to look up. - * @returns The checkpoint number (or undefined if not found). + * @param query - Range by start/limit or by epoch. */ - getCheckpointNumberBySlot(slot: SlotNumber): Promise; + getCheckpointsData(query: CheckpointsQuery): Promise; /** * Gets a tx effect. @@ -222,11 +240,13 @@ export interface L2BlockSource { */ getPendingChainValidationStatus(): Promise; - /** Returns the checkpoint at the proposed chain tip. */ - getLastCheckpoint(): Promise; - - /** Returns proposed checkpoint, if set, undefined if not*/ - getLastProposedCheckpoint(): Promise; + /** + * Looks up a proposed (archiver-internal, not-yet-L1-confirmed) checkpoint. + * Returns the latest proposed entry when called with no args or `{ tag: 'proposed' }`. + * With `{ number }` or `{ slot }`, returns the matching entry or undefined. + * Never falls back to confirmed checkpoints. + */ + getProposedCheckpointData(query?: ProposedCheckpointQuery): Promise; /** Force a sync. */ syncImmediate(): Promise; diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts index 26216f7aff2b..7ab1b78ac414 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.test.ts @@ -134,8 +134,12 @@ describe('L2BlockStream', () => { // Returns published checkpoints - each checkpoint contains just the one block for simplicity // Respects the limit parameter and returns up to `limit` checkpoints - blockSource.getCheckpoints.mockImplementation((checkpointNumber: CheckpointNumber, limit: number) => - Promise.resolve( + blockSource.getCheckpoints.mockImplementation(query => { + if (!('from' in query)) { + return Promise.resolve([]); + } + const { from: checkpointNumber, limit } = query; + return Promise.resolve( compactArray( times(limit, i => { const cpNum = checkpointNumber + i; @@ -150,8 +154,8 @@ describe('L2BlockStream', () => { } as unknown as PublishedCheckpoint); }), ), - ), - ); + ); + }); }); describe('with mock local data provider', () => { @@ -492,7 +496,11 @@ describe('L2BlockStream', () => { }); // Returns published checkpoints with multiple blocks each, respecting the limit parameter - blockSource.getCheckpoints.mockImplementation((checkpointNumber: CheckpointNumber, limit: number) => { + blockSource.getCheckpoints.mockImplementation(query => { + if (!('from' in query)) { + return Promise.resolve([]); + } + const { from: checkpointNumber, limit } = query; const checkpoints: PublishedCheckpoint[] = []; for (let i = 0; i < limit; i++) { const cpNum = CheckpointNumber(checkpointNumber + i); @@ -891,9 +899,9 @@ describe('L2BlockStream', () => { // Should have fetched all 3 checkpoints in a single call (Loop 2 makes 1 call with limit 10) // Even though we requested 10, only 3 exist - verify we handle this correctly const calls = blockSource.getCheckpoints.mock.calls; - const loop2Calls = calls.filter(([_, limit]) => limit === 10); + const loop2Calls = calls.filter(([query]) => 'limit' in query && query.limit === 10); expect(loop2Calls.length).toBe(1); - expect(loop2Calls[0][0]).toBe(1); // Starting from checkpoint 1 + expect((loop2Calls[0][0] as { from: number }).from).toBe(1); // Starting from checkpoint 1 // All 3 checkpoints should be emitted correctly (not 10) const checkpointEvents = handler.events.filter(e => e.type === 'chain-checkpointed'); @@ -926,9 +934,9 @@ describe('L2BlockStream', () => { // Loop 2 should start fetching from checkpoint 3 (block 7 is in checkpoint 3) const calls = blockSource.getCheckpoints.mock.calls; - const loop2Calls = calls.filter(([_, limit]) => limit === 10); + const loop2Calls = calls.filter(([query]) => 'limit' in query && query.limit === 10); expect(loop2Calls.length).toBe(1); - expect(loop2Calls[0][0]).toBe(3); // Starting from checkpoint 3, not 1 + expect((loop2Calls[0][0] as { from: number }).from).toBe(3); // Starting from checkpoint 3, not 1 // Should only emit blocks 7-15 and checkpoints 3-5 (not 1-2, those are already local) expect(handler.events).toEqual([ @@ -960,9 +968,9 @@ describe('L2BlockStream', () => { // Should start prefetching from checkpoint 3 const calls = blockSource.getCheckpoints.mock.calls; - const loop2Calls = calls.filter(([_, limit]) => limit === 10); + const loop2Calls = calls.filter(([query]) => 'limit' in query && query.limit === 10); expect(loop2Calls.length).toBe(1); - expect(loop2Calls[0][0]).toBe(3); // Starting from checkpoint 3 + expect((loop2Calls[0][0] as { from: number }).from).toBe(3); // Starting from checkpoint 3 // Should emit only blocks 8-9 from checkpoint 3 (block 7 is already local) expect(handler.events).toEqual([ @@ -993,11 +1001,11 @@ describe('L2BlockStream', () => { // Should have made 3 calls with limit 2 to fetch 5 checkpoints const calls = blockSource.getCheckpoints.mock.calls; - const loop2Calls = calls.filter(([_, limit]) => limit === 2); + const loop2Calls = calls.filter(([query]) => 'limit' in query && query.limit === 2); expect(loop2Calls.length).toBe(3); // ceil(5/2) = 3 - expect(loop2Calls[0][0]).toBe(1); // First batch: checkpoints 1-2 - expect(loop2Calls[1][0]).toBe(3); // Second batch: checkpoints 3-4 - expect(loop2Calls[2][0]).toBe(5); // Third batch: checkpoint 5 + expect((loop2Calls[0][0] as { from: number }).from).toBe(1); // First batch: checkpoints 1-2 + expect((loop2Calls[1][0] as { from: number }).from).toBe(3); // Second batch: checkpoints 3-4 + expect((loop2Calls[2][0] as { from: number }).from).toBe(5); // Third batch: checkpoint 5 // All 5 checkpoints should be emitted const checkpointEvents = handler.events.filter(e => e.type === 'chain-checkpointed'); @@ -1017,7 +1025,7 @@ describe('L2BlockStream', () => { // Should have used default limit of 50 for Loop 2 calls const calls = blockSource.getCheckpoints.mock.calls; - const loop2Calls = calls.filter(([_, limit]) => limit === 50); + const loop2Calls = calls.filter(([query]) => 'limit' in query && query.limit === 50); expect(loop2Calls.length).toBeGreaterThanOrEqual(1); }); }); diff --git a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts index 85bf1961bb77..9a38e28d8d9c 100644 --- a/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts +++ b/yarn-project/stdlib/src/block/l2_block_stream/l2_block_stream.ts @@ -160,7 +160,7 @@ export class L2BlockStream { if (!this.opts.ignoreCheckpoints) { let loop1Iterations = 0; while (nextCheckpointToEmit <= sourceTips.checkpointed.checkpoint.number) { - const checkpoints = await this.l2BlockSource.getCheckpoints(nextCheckpointToEmit, 1); + const checkpoints = await this.l2BlockSource.getCheckpoints({ from: nextCheckpointToEmit, limit: 1 }); if (checkpoints.length === 0) { break; } @@ -205,7 +205,10 @@ export class L2BlockStream { // Refill the prefetch buffer when exhausted if (prefetchIdx >= prefetchedCheckpoints.length) { const prefetchLimit = this.opts.checkpointPrefetchLimit ?? CHECKPOINT_PREFETCH_LIMIT; - prefetchedCheckpoints = await this.l2BlockSource.getCheckpoints(nextCheckpointNumber, prefetchLimit); + prefetchedCheckpoints = await this.l2BlockSource.getCheckpoints({ + from: nextCheckpointNumber, + limit: prefetchLimit, + }); prefetchIdx = 0; if (prefetchedCheckpoints.length === 0) { break; diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index 16df1aa6f3e0..d59c2fe873a5 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -15,7 +15,10 @@ import { BlockQuerySchema, type BlocksQuery, BlocksQuerySchema, + type CheckpointQuery, + type CheckpointsQuery, type L2Tips, + type ProposedCheckpointQuery, } from '../block/l2_block_source.js'; import type { ValidateCheckpointResult } from '../block/validate_block_result.js'; import { Checkpoint } from '../checkpoint/checkpoint.js'; @@ -107,14 +110,30 @@ describe('ArchiverApiSchema', () => { expect(result).toEqual(BlockNumber(0)); }); + it('getCheckpoint', async () => { + const response = await context.client.getCheckpoint({ number: CheckpointNumber(1) }); + expect(response).toBeDefined(); + expect(response!.checkpoint.constructor.name).toEqual('Checkpoint'); + expect(response!.attestations[0]).toBeInstanceOf(CommitteeAttestation); + expect(response!.l1).toBeDefined(); + }); + it('getCheckpoints', async () => { - const response = await context.client.getCheckpoints(CheckpointNumber(1), BlockNumber(1)); + const response = await context.client.getCheckpoints({ from: CheckpointNumber(1), limit: 1 }); expect(response).toHaveLength(1); expect(response[0].checkpoint.constructor.name).toEqual('Checkpoint'); expect(response[0].attestations[0]).toBeInstanceOf(CommitteeAttestation); expect(response[0].l1).toBeDefined(); }); + it('getCheckpointsData', async () => { + const result = await context.client.getCheckpointsData({ epoch: EpochNumber(1) }); + expect(result).toHaveLength(1); + expect(result[0].checkpointNumber).toBeDefined(); + expect(result[0].checkpointOutHash).toBeDefined(); + expect(result[0].attestations[0]).toBeInstanceOf(CommitteeAttestation); + }); + it('getTxEffect', async () => { const result = await context.client.getTxEffect(TxHash.fromBuffer(Buffer.alloc(32, BlockNumber(1)))); expect(result!.data).toBeInstanceOf(TxEffect); @@ -135,19 +154,6 @@ describe('ArchiverApiSchema', () => { expect(result).toBe(EpochNumber(1)); }); - it('getCheckpointsForEpoch', async () => { - const result = await context.client.getCheckpointsForEpoch(EpochNumber(1)); - expect(result).toEqual([expect.any(Checkpoint)]); - }); - - it('getCheckpointsDataForEpoch', async () => { - const result = await context.client.getCheckpointsDataForEpoch(EpochNumber(1)); - expect(result).toHaveLength(1); - expect(result[0].checkpointNumber).toBeDefined(); - expect(result[0].checkpointOutHash).toBeDefined(); - expect(result[0].attestations[0]).toBeInstanceOf(CommitteeAttestation); - }); - it('getBlocksForSlot', async () => { const result = await context.client.getBlocksForSlot(SlotNumber(1)); expect(result).toEqual([expect.any(L2Block)]); @@ -269,22 +275,8 @@ describe('ArchiverApiSchema', () => { expect(result).toBe(1n); }); - it('getLastCheckpoint', async () => { - const result = await context.client.getLastCheckpoint(); - expect(result).toEqual({ - checkpointNumber: 1, - header: expect.any(CheckpointHeader), - archive: expect.any(AppendOnlyTreeSnapshot), - checkpointOutHash: expect.any(Fr), - blockCount: 1, - startBlock: 1, - totalManaUsed: 1n, - feeAssetPriceModifier: 1n, - }); - }); - - it('getLastProposedCheckpoint', async () => { - const result = await context.client.getLastProposedCheckpoint(); + it('getProposedCheckpointData', async () => { + const result = await context.client.getProposedCheckpointData(); expect(result).toEqual({ checkpointNumber: 1, header: expect.any(CheckpointHeader), @@ -308,17 +300,7 @@ describe('ArchiverApiSchema', () => { }); it('getCheckpointData', async () => { - const result = await context.client.getCheckpointData(CheckpointNumber(1)); - expect(result).toBeUndefined(); - }); - - it('getCheckpointDataRange', async () => { - const result = await context.client.getCheckpointDataRange(CheckpointNumber(1), 10); - expect(result).toEqual([]); - }); - - it('getCheckpointNumberBySlot', async () => { - const result = await context.client.getCheckpointNumberBySlot(SlotNumber(1)); + const result = await context.client.getCheckpointData({ number: CheckpointNumber(1) }); expect(result).toBeUndefined(); }); @@ -410,10 +392,7 @@ class MockArchiver implements ArchiverApi { getPendingChainValidationStatus(): Promise { return Promise.resolve({ valid: true }); } - getLastCheckpoint(): Promise { - return this.getLastProposedCheckpoint(); - } - getLastProposedCheckpoint(): Promise { + getProposedCheckpointData(_query?: ProposedCheckpointQuery): Promise { return Promise.resolve({ checkpointNumber: CheckpointNumber(1), header: CheckpointHeader.random(), @@ -463,10 +442,17 @@ class MockArchiver implements ArchiverApi { getBlocksData(_query: BlocksQuery): Promise { return Promise.resolve([]); } - async getCheckpoints(from: CheckpointNumber, _limit: number): Promise { + async getCheckpoint(_query: CheckpointQuery): Promise { + return PublishedCheckpoint.from({ + checkpoint: await Checkpoint.random(CheckpointNumber(1)), + attestations: [CommitteeAttestation.random()], + l1: new L1PublishedData(1n, 0n, `0x`), + }); + } + async getCheckpoints(_query: CheckpointsQuery): Promise { return [ PublishedCheckpoint.from({ - checkpoint: await Checkpoint.random(CheckpointNumber(from)), + checkpoint: await Checkpoint.random(CheckpointNumber(1)), attestations: [CommitteeAttestation.random()], l1: new L1PublishedData(1n, 0n, `0x`), }), @@ -491,12 +477,10 @@ class MockArchiver implements ArchiverApi { getSyncedL2EpochNumber(): Promise { return Promise.resolve(EpochNumber(1)); } - async getCheckpointsForEpoch(epochNumber: EpochNumber): Promise { - expect(epochNumber).toEqual(EpochNumber(1)); - return [await Checkpoint.random(CheckpointNumber(1))]; + getCheckpointData(_query: CheckpointQuery): Promise { + return Promise.resolve(undefined); } - async getCheckpointsDataForEpoch(epochNumber: EpochNumber): Promise { - expect(epochNumber).toEqual(EpochNumber(1)); + async getCheckpointsData(_query: CheckpointsQuery): Promise { const checkpoint = await Checkpoint.random(CheckpointNumber(1)); return [ { @@ -512,15 +496,6 @@ class MockArchiver implements ArchiverApi { }, ]; } - getCheckpointData(_n: CheckpointNumber): Promise { - return Promise.resolve(undefined); - } - getCheckpointDataRange(_from: CheckpointNumber, _limit: number): Promise { - return Promise.resolve([]); - } - getCheckpointNumberBySlot(_slot: SlotNumber): Promise { - return Promise.resolve(undefined); - } async getBlocksForSlot(slotNumber: SlotNumber): Promise { expect(slotNumber).toEqual(SlotNumber(1)); return [await L2Block.random(BlockNumber(Number(slotNumber)))]; diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 9809a2d1af15..fdba0a9c54e6 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -6,9 +6,16 @@ import { z } from 'zod'; import { BlockDataSchema } from '../block/block_data.js'; import { L2Block } from '../block/l2_block.js'; -import { BlockQuerySchema, BlocksQuerySchema, type L2BlockSource, L2TipsSchema } from '../block/l2_block_source.js'; +import { + BlockQuerySchema, + BlocksQuerySchema, + CheckpointQuerySchema, + CheckpointsQuerySchema, + type L2BlockSource, + L2TipsSchema, + ProposedCheckpointQuerySchema, +} from '../block/l2_block_source.js'; import { ValidateCheckpointResultSchema } from '../block/validate_block_result.js'; -import { Checkpoint } from '../checkpoint/checkpoint.js'; import { CheckpointDataSchema, ProposedCheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; import { PublishedCheckpoint } from '../checkpoint/published_checkpoint.js'; import { @@ -93,22 +100,14 @@ export const ArchiverApiSchema: ApiSchemaFor = { getCheckpointedL2BlockNumber: z.function().args().returns(BlockNumberSchema), getCheckpointNumber: z.function().args().returns(CheckpointNumberSchema), getFinalizedL2BlockNumber: z.function().args().returns(BlockNumberSchema), - getCheckpoints: z - .function() - .args(CheckpointNumberSchema, schemas.Integer) - .returns(z.array(PublishedCheckpoint.schema)), - getCheckpointData: z.function().args(CheckpointNumberSchema).returns(CheckpointDataSchema.optional()), - getCheckpointDataRange: z - .function() - .args(CheckpointNumberSchema, schemas.Integer) - .returns(z.array(CheckpointDataSchema)), - getCheckpointNumberBySlot: z.function().args(schemas.SlotNumber).returns(CheckpointNumberSchema.optional()), + getCheckpoint: z.function().args(CheckpointQuerySchema).returns(PublishedCheckpoint.schema.optional()), + getCheckpoints: z.function().args(CheckpointsQuerySchema).returns(z.array(PublishedCheckpoint.schema)), + getCheckpointData: z.function().args(CheckpointQuerySchema).returns(CheckpointDataSchema.optional()), + getCheckpointsData: z.function().args(CheckpointsQuerySchema).returns(z.array(CheckpointDataSchema)), getTxEffect: z.function().args(TxHash.schema).returns(indexedTxSchema().optional()), getSettledTxReceipt: z.function().args(TxHash.schema).returns(TxReceipt.schema.optional()), getSyncedL2SlotNumber: z.function().args().returns(schemas.SlotNumber.optional()), getSyncedL2EpochNumber: z.function().args().returns(EpochNumberSchema.optional()), - getCheckpointsForEpoch: z.function().args(EpochNumberSchema).returns(z.array(Checkpoint.schema)), - getCheckpointsDataForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointDataSchema)), getBlocksForSlot: z.function().args(schemas.SlotNumber).returns(z.array(L2Block.schema)), isEpochComplete: z.function().args(EpochNumberSchema).returns(z.boolean()), getL2Tips: z.function().args().returns(L2TipsSchema), @@ -139,8 +138,10 @@ export const ArchiverApiSchema: ApiSchemaFor = { .args() .returns(z.object({ genesisArchiveRoot: schemas.Fr })), getL1Timestamp: z.function().args().returns(schemas.BigInt.optional()), - getLastCheckpoint: z.function().args().returns(ProposedCheckpointDataSchema.optional()), - getLastProposedCheckpoint: z.function().args().returns(ProposedCheckpointDataSchema.optional()), + getProposedCheckpointData: z + .function() + .args(optional(ProposedCheckpointQuerySchema)) + .returns(ProposedCheckpointDataSchema.optional()), syncImmediate: z.function().args().returns(z.void()), isPendingChainInvalid: z.function().args().returns(z.boolean()), getPendingChainValidationStatus: z.function().args().returns(ValidateCheckpointResultSchema), diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index dd3cdc3fab00..3aa8d1be7caf 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -15,7 +15,8 @@ import type { ContractArtifact } from '../abi/abi.js'; import { AztecAddress } from '../aztec-address/index.js'; import type { DataInBlock } from '../block/in_block.js'; import { BlockHash, type BlockParameter } from '../block/index.js'; -import type { L2Tips } from '../block/l2_block_source.js'; +import type { CheckpointsQuery, L2Tips } from '../block/l2_block_source.js'; +import type { CheckpointData } from '../checkpoint/checkpoint_data.js'; import { type ContractClassPublic, type ContractInstanceWithAddress, @@ -253,8 +254,13 @@ describe('AztecNodeApiSchema', () => { expect(response).toEqual([]); }); - it('getCheckpointsDataForEpoch', async () => { - const response = await context.client.getCheckpointsDataForEpoch(EpochNumber(1)); + it('getCheckpointsData (epoch)', async () => { + const response = await context.client.getCheckpointsData({ epoch: EpochNumber(1) }); + expect(response).toEqual([]); + }); + + it('getCheckpointsData (range)', async () => { + const response = await context.client.getCheckpointsData({ from: CheckpointNumber(1), limit: 1 }); expect(response).toEqual([]); }); @@ -578,7 +584,7 @@ class MockAztecNode implements AztecNode { return Promise.resolve([]); } - getCheckpointsDataForEpoch(_epochNumber: EpochNumber) { + getCheckpointsData(_query: CheckpointsQuery): Promise { return Promise.resolve([]); } diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.ts b/yarn-project/stdlib/src/interfaces/aztec-node.ts index 4e71f392d04a..6263db6ac204 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.ts @@ -22,7 +22,7 @@ import type { AztecAddress } from '../aztec-address/index.js'; import { BlockHash } from '../block/block_hash.js'; import { type BlockParameter, BlockParameterSchema } from '../block/block_parameter.js'; import { type DataInBlock, dataInBlockSchemaFor } from '../block/in_block.js'; -import { type L2Tips, L2TipsSchema } from '../block/l2_block_source.js'; +import { type CheckpointsQuery, CheckpointsQuerySchema, type L2Tips, L2TipsSchema } from '../block/l2_block_source.js'; import { type CheckpointData, CheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; import { type ContractClassPublic, @@ -227,8 +227,11 @@ export interface AztecNode { getBlockHeader(number: BlockNumber | 'latest'): Promise; /** @deprecated Scheduled for removal; use `getBlocks(from, limit, { includeL1PublishInfo: true, includeAttestations: true })`. */ getCheckpointedBlocks(from: BlockNumber, limit: number): Promise; - /** @deprecated Scheduled for removal; use `getCheckpoints(from, limit)` over an explicit checkpoint range. */ - getCheckpointsDataForEpoch(epoch: EpochNumber): Promise; + /** + * Gets lightweight checkpoint metadata for a contiguous range or for an entire epoch. + * @param query - Either `{ from, limit }` or `{ epoch }`. + */ + getCheckpointsData(query: CheckpointsQuery): Promise; /** * Unified block fetch. Returns the block identified by `param`, with optional fields controlled @@ -569,7 +572,7 @@ export const AztecNodeApiSchema: ApiSchemaFor = { .args(BlockNumberPositiveSchema, z.number().gt(0).lte(MAX_RPC_BLOCKS_LEN)) .returns(z.array(BlockResponseSchema)), - getCheckpointsDataForEpoch: z.function().args(EpochNumberSchema).returns(z.array(CheckpointDataSchema)), + getCheckpointsData: z.function().args(CheckpointsQuerySchema).returns(z.array(CheckpointDataSchema)), getBlock: z .function() diff --git a/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts b/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts index 36725a304ecc..0c50a436d753 100644 --- a/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts +++ b/yarn-project/stdlib/src/interfaces/checkpoint_parameter.ts @@ -7,15 +7,13 @@ import { ChainTipSchema } from './chain_tips.js'; /** * Selector for a checkpoint in RPC calls. * - * Accepts a numeric checkpoint number (or `{ number }`), a slot number (`{ slot }`), a chain-tip - * name (e.g. `'proven'`), or `'latest'` (alias for `'proposed'` — on the checkpoint side, this - * means the most recent confirmed checkpoint). + * Accepts a numeric checkpoint number (or `{ number }`), a slot number (`{ slot }`), + * or a chain-tip name (e.g. `'proposed'`, `'proven'`). */ export const CheckpointParameterSchema = z.union([ z.object({ number: CheckpointNumberSchema }).strict(), z.object({ slot: SlotNumberSchema }).strict(), ChainTipSchema, - z.literal('latest'), CheckpointNumberSchema, ]); diff --git a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts index 7dbc9eb5c439..a34ca559a253 100644 --- a/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts +++ b/yarn-project/stdlib/src/messaging/l2_to_l1_membership.ts @@ -113,10 +113,7 @@ export type L2ToL1MembershipWitness = { * @returns The membership witness and epoch number, or undefined if the tx is not yet in a block/epoch. */ export async function computeL2ToL1MembershipWitness( - node: Pick< - AztecNode, - 'getL2ToL1Messages' | 'getTxReceipt' | 'getTxEffect' | 'getBlock' | 'getCheckpointsDataForEpoch' - >, + node: Pick, message: Fr, txHash: TxHash, messageIndexInTx?: number, @@ -130,7 +127,7 @@ export async function computeL2ToL1MembershipWitness( node.getL2ToL1Messages(epochNumber), node.getBlock(blockNumber), node.getTxEffect(txHash), - node.getCheckpointsDataForEpoch(epochNumber), + node.getCheckpointsData({ epoch: epochNumber }), ]); if (messagesInEpoch.length === 0 || !block || !txEffect) { diff --git a/yarn-project/validator-client/src/proposal_handler.test.ts b/yarn-project/validator-client/src/proposal_handler.test.ts index 44a28f3d0cb4..ab85a7287b47 100644 --- a/yarn-project/validator-client/src/proposal_handler.test.ts +++ b/yarn-project/validator-client/src/proposal_handler.test.ts @@ -55,7 +55,7 @@ describe('ProposalHandler checkpoint validation', () => { beforeEach(() => { blockSource = mock(); - blockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); + blockSource.getCheckpointsData.mockResolvedValue([]); blockSource.getBlocksForSlot.mockResolvedValue([]); blockSource.syncImmediate.mockResolvedValue(undefined); diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index cdf951a68b34..1a6d3215ac2a 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -349,7 +349,7 @@ export class ProposalHandler { // Collect the out hashes of all the checkpoints before this one in the same epoch const epoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants()); - const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch)) + const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsData({ epoch })) .filter(c => c.checkpointNumber < checkpointNumber) .map(c => c.checkpointOutHash); @@ -856,7 +856,7 @@ export class ProposalHandler { // Collect the out hashes of all the checkpoints before this one in the same epoch const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants()); - const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsDataForEpoch(epoch)) + const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsData({ epoch })) .filter(c => c.checkpointNumber < checkpointNumber) .map(c => c.checkpointOutHash); diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index d1286c4d086e..0e891cba35ef 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -152,7 +152,7 @@ describe('ValidatorClient', () => { blockSource = mock(); blockSource.getBlocks.mockResolvedValue([]); - blockSource.getCheckpointsDataForEpoch.mockResolvedValue([]); + blockSource.getCheckpointsData.mockResolvedValue([]); blockSource.getBlocksForSlot.mockResolvedValue([]); blockSource.getSyncedL2SlotNumber.mockResolvedValue(SlotNumber(Number.MAX_SAFE_INTEGER)); blockSource.syncImmediate.mockResolvedValue(undefined); diff --git a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts index d8e939fba8ae..f46fe2c1b584 100644 --- a/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts +++ b/yarn-project/world-state/src/synchronizer/server_world_state_synchronizer.ts @@ -422,15 +422,13 @@ export class ServerWorldStateSynchronizer return; } // Retrieve the historic checkpoint - const historicCheckpoints = await this.l2BlockSource.getCheckpoints( - CheckpointNumber(newHistoricCheckpointNumber), - 1, - ); - if (historicCheckpoints.length === 0 || historicCheckpoints[0] === undefined) { + const historicCheckpoint = await this.l2BlockSource.getCheckpoint({ + number: CheckpointNumber(newHistoricCheckpointNumber), + }); + if (!historicCheckpoint) { this.log.warn(`Failed to retrieve checkpoint number ${newHistoricCheckpointNumber} from Archiver`); return; } - const historicCheckpoint = historicCheckpoints[0]; if (historicCheckpoint.checkpoint.blocks.length === 0 || historicCheckpoint.checkpoint.blocks[0] === undefined) { this.log.warn(`Retrieved checkpoint number ${newHistoricCheckpointNumber} has no blocks!`); return; From 3b04a40c2f7b13a993d940c2568ea1e45591b734 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Wed, 6 May 2026 13:41:44 -0300 Subject: [PATCH 11/55] fix(sequencer): bounded sweep instead of event scan for governance proposal check (#22989) ## Motivation `hasPayloadBeenProposed` (now `hasActiveProposalWithPayload`) used `eth_getLogs` over the rollup's full L1 deployment range to find prior `PayloadSubmitted` events. On long-lived rollups that range exceeds typical RPC provider block-range caps and the call times out, silently breaking the sequencer's "stop signaling for an already-proposed payload" logic. The previous in-memory cache also permanently blacklisted any payload it saw as proposed once, which is wrong: each round on `EmpireBase` is independent and the same payload can legitimately be re-signaled and re-submitted after a prior proposal becomes Dropped/Rejected/Expired/Executed. ## Approach Replace the log scan with a bounded view-call sweep over `Governance.proposals`. The sweep walks newest -> oldest using `proposalCount`, unwraps each proposal's `GSEPayload` via `getOriginalPayload()`, and treats only `Pending`/`Active`/`Queued`/`Executable` as "in an active proposal" -- terminal states allow re-signaling. The descent has a hard early-stop on the protocol-wide proposal lifetime cap (`4 * ConfigurationLib.TIME_UPPER = 360 days`), which is safe regardless of per-proposal frozen configs because every config field is bounded by `TIME_UPPER` on-chain. Two in-memory caches absorb the per-call cost over time: terminal proposals (provably immutable on-chain) and wrapper -> original payload unwraps (immutable bytecode). ## Changes - **ethereum/contracts/governance**: New `hasActiveProposalWithPayload(payload)` and `getProposalCount()` on `ReadOnlyGovernanceContract`. Inlines a minimal `IProposerPayload` ABI (just `getOriginalPayload`) to avoid generating a full artifact. Handles `proposeWithLock`-style proposals (no GSEPayload wrapper) by catching the unwrap revert and skipping. - **ethereum/contracts/governance (types)**: Adds explicit types (`Proposal`, `ProposalConfiguration`, `GovernanceConfiguration`, `ProposeWithLockConfiguration`, `Ballot`) and maps the viem return shapes of `getProposal` / `getConfiguration` onto them. `Proposal` now carries both `cachedState` (raw stored) and `state` (live, time-derived from `getProposalState`); `getProposal` issues both reads in parallel so callers don't need a separate state RPC. - **ethereum/contracts/governance (caching)**: Adds two memoization layers on `ReadOnlyGovernanceContract`. Proposals are cached when `state` is in any of the four terminal phases (Executed/Rejected/Dropped/Expired) -- once terminal the entire struct is provably immutable on-chain. Wrapper unwraps are keyed by wrapper address and cached forever (deployed bytecode is immutable). `GovernanceProposerContract` already memoizes its `getGovernance()`, so the same `ReadOnlyGovernanceContract` instance (and its caches) is reused across slots in the sequencer publisher. - **ethereum/contracts/governance_proposer**: Drops the event-based `hasPayloadBeenProposed`. Adds a memoized `getGovernance()` accessor and a thin `hasActiveProposalWithPayload` delegate that resolves the Governance address via the on-chain registry lookup. - **ethereum/contracts/empire_base**: Removes `hasPayloadBeenProposed` from `IEmpireBase` -- it's a Governance concern, not a generic empire concern (slasher doesn't need it). - **sequencer-client/publisher**: Removes the permanent `payloadProposedCache` so the publisher re-checks every slot, allowing re-signaling once a prior proposal is terminal. Switches the failure mode from fail-closed to fail-open (a flaky L1 endpoint should not silence governance participation; a duplicate signal is harmless). Narrows the helper's `base` param from `IEmpireBase` to `GovernanceProposerContract` since this code path is governance-only. - **ethereum/contracts (tests)**: New `hasActiveProposalWithPayload` describe block hitting a real anvil-deployed Governance. Impersonates the `governanceProposer`, calls `Governance.propose` directly, and etches hand-rolled mock wrapper bytecode at chosen addresses to drive (wrapper, original) pairs. Covers: empty governance, live match, no match, terminal state via warp, reverting wrapper (proposeWithLock-style), descent past unrelated proposals, case-insensitive match, and the 360-day hard cutoff via warp. Also adds a sync-guard describe block that probes `Governance.updateConfiguration` via impersonated `eth_call` to assert each of `votingDelay`/`votingDuration`/`executionDelay`/`gracePeriod` accepts `TIME_UPPER` and rejects `TIME_UPPER + 1` -- if those caps change on-chain, this trips and `MAX_PROPOSAL_LIFETIME_SECONDS` must be revisited. - **sequencer-client/publisher (tests)**: Replaces the cache test with a "re-checks each call so re-signaling resumes after terminal" test. Updates the RPC-failure semantics test from fail-closed to fail-open. --- .../ethereum/src/contracts/empire_base.ts | 2 - .../ethereum/src/contracts/governance.test.ts | 320 ++++++++++++++++-- .../ethereum/src/contracts/governance.ts | 275 ++++++++++++++- .../src/contracts/governance_proposer.ts | 27 +- .../src/publisher/sequencer-publisher.test.ts | 71 ++-- .../src/publisher/sequencer-publisher.ts | 41 +-- 6 files changed, 626 insertions(+), 110 deletions(-) diff --git a/yarn-project/ethereum/src/contracts/empire_base.ts b/yarn-project/ethereum/src/contracts/empire_base.ts index 377c279b3a2d..470137b78604 100644 --- a/yarn-project/ethereum/src/contracts/empire_base.ts +++ b/yarn-project/ethereum/src/contracts/empire_base.ts @@ -22,8 +22,6 @@ export interface IEmpireBase { signerAddress: Hex, signer: (msg: TypedDataDefinition) => Promise, ): Promise; - /** Checks if a payload was ever submitted to governance via submitRoundWinner. */ - hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise; } export function encodeSignal(payload: Hex): Hex { diff --git a/yarn-project/ethereum/src/contracts/governance.test.ts b/yarn-project/ethereum/src/contracts/governance.test.ts index f65cf1f11fbb..cbdd8826cec9 100644 --- a/yarn-project/ethereum/src/contracts/governance.test.ts +++ b/yarn-project/ethereum/src/contracts/governance.test.ts @@ -1,16 +1,27 @@ import { createExtendedL1Client, getPublicClient } from '@aztec/ethereum/client'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; +import { TestDateProvider } from '@aztec/foundation/timer'; +import { GovernanceAbi } from '@aztec/l1-artifacts/GovernanceAbi'; +import { type Hex, encodeFunctionData, parseEventLogs } from 'viem'; import { type PrivateKeyAccount, privateKeyToAccount } from 'viem/accounts'; import { foundry } from 'viem/chains'; import { DefaultL1ContractsConfig } from '../config.js'; import { deployAztecL1Contracts } from '../deploy_aztec_l1_contracts.js'; +import { EthCheatCodes } from '../test/eth_cheat_codes.js'; import type { Anvil } from '../test/start_anvil.js'; import { startAnvil } from '../test/start_anvil.js'; import type { ExtendedViemWalletClient, ViemClient } from '../types.js'; -import { GovernanceContract, ReadOnlyGovernanceContract } from './governance.js'; +import { + type GovernanceConfiguration, + GovernanceContract, + MAX_PROPOSAL_LIFETIME_SECONDS, + ProposalState, + ReadOnlyGovernanceContract, +} from './governance.js'; describe('Governance', () => { let anvil: Anvil; @@ -18,10 +29,12 @@ describe('Governance', () => { let privateKey: PrivateKeyAccount; let publicClient: ViemClient; let walletClient: ExtendedViemWalletClient; + let cheatCodes: EthCheatCodes; let vkTreeRoot: Fr; let protocolContractsHash: Fr; let governanceAddress: `0x${string}`; + let governanceProposerAddress: `0x${string}`; beforeAll(async () => { // this is the 6th address that gets funded by the junk mnemonic @@ -34,6 +47,7 @@ describe('Governance', () => { walletClient = createExtendedL1Client([rpcUrl], privateKey, foundry); publicClient = getPublicClient({ l1RpcUrls: [rpcUrl], l1ChainId: 31337 }); + cheatCodes = new EthCheatCodes([rpcUrl], new TestDateProvider()); const deployed = await deployAztecL1Contracts(rpcUrl, privateKeyRaw, foundry.id, { ...DefaultL1ContractsConfig, @@ -44,6 +58,7 @@ describe('Governance', () => { }); governanceAddress = deployed.l1ContractAddresses.governanceAddress.toString() as `0x${string}`; + governanceProposerAddress = deployed.l1ContractAddresses.governanceProposerAddress.toString() as `0x${string}`; }); afterAll(async () => { @@ -51,30 +66,287 @@ describe('Governance', () => { }); describe('ReadOnlyGovernanceContract', () => { - let readOnlyGovernance: ReadOnlyGovernanceContract; + let governance: ReadOnlyGovernanceContract; beforeEach(() => { - readOnlyGovernance = new ReadOnlyGovernanceContract(governanceAddress, publicClient); + governance = new ReadOnlyGovernanceContract(governanceAddress, publicClient); }); it('can be instantiated with public client but not wallet methods', () => { - expect(readOnlyGovernance).toBeDefined(); - expect(readOnlyGovernance.client).toBe(publicClient); + expect(governance).toBeDefined(); + expect(governance.client).toBe(publicClient); // Verify wallet-specific methods are not available - expect(readOnlyGovernance).not.toHaveProperty('deposit'); - expect(readOnlyGovernance).not.toHaveProperty('proposeWithLock'); - expect(readOnlyGovernance).not.toHaveProperty('vote'); - expect(readOnlyGovernance).not.toHaveProperty('executeProposal'); + expect(governance).not.toHaveProperty('deposit'); + expect(governance).not.toHaveProperty('proposeWithLock'); + expect(governance).not.toHaveProperty('vote'); + expect(governance).not.toHaveProperty('executeProposal'); }); it('has all read-only methods', () => { - expect(readOnlyGovernance.getGovernanceProposerAddress).toBeDefined(); - expect(readOnlyGovernance.getConfiguration).toBeDefined(); - expect(readOnlyGovernance.getProposal).toBeDefined(); - expect(readOnlyGovernance.getProposalState).toBeDefined(); - expect(readOnlyGovernance.awaitProposalActive).toBeDefined(); - expect(readOnlyGovernance.awaitProposalExecutable).toBeDefined(); + expect(governance.getGovernanceProposerAddress).toBeDefined(); + expect(governance.getConfiguration).toBeDefined(); + expect(governance.getProposal).toBeDefined(); + expect(governance.getProposalState).toBeDefined(); + expect(governance.getProposalCount).toBeDefined(); + expect(governance.hasActiveProposalWithPayload).toBeDefined(); + expect(governance.awaitProposalActive).toBeDefined(); + expect(governance.awaitProposalExecutable).toBeDefined(); + }); + + it('gets configuration', async () => { + const config = await governance.getConfiguration(); + expect(config).toBeDefined(); + expect(config.proposeConfig.lockDelay).toBeGreaterThan(0n); + expect(config.proposeConfig.lockAmount).toBeGreaterThan(0n); + expect(config.votingDelay).toBeGreaterThan(0n); + expect(config.votingDuration).toBeGreaterThan(0n); + expect(config.executionDelay).toBeGreaterThan(0n); + expect(config.gracePeriod).toBeGreaterThan(0n); + expect(config.quorum).toBeGreaterThan(0n); + expect(config.requiredYeaMargin).toBeGreaterThan(0n); + expect(config.minimumVotes).toBeGreaterThan(0n); + }); + + describe('hasActiveProposalWithPayload', () => { + // Runtime bytecode for a contract whose only behavior is: ignore calldata, return `original` + // (zero-padded to 32 bytes). This is a stand-in for `IProposerPayload.getOriginalPayload()`. + // The point is to faithfully exercise the 'getOriginalPayload' path. + // + // PUSH20 // 0x73 <20 bytes> + // PUSH1 0x00 + // MSTORE // mem[0..31] = 000...000 + // PUSH1 0x20 + // PUSH1 0x00 + // RETURN // return mem[0..31] + const buildMockWrapperBytecode = (original: EthAddress): Hex => + `0x73${original.toString().toLowerCase().replace(/^0x/, '')}60005260206000f3`; + + // A wrapper that always reverts when called. Stands in for proposals created via + // `Governance.proposeWithLock`, which bypass GSEPayload entirely -- the payload stored on the + // proposal won't have a `getOriginalPayload()` selector and the call will revert. The sweep + // must treat this as "no match" and continue rather than aborting. + // PUSH1 0x00 + // PUSH1 0x00 + // REVERT + const REVERTING_WRAPPER_BYTECODE: Hex = '0x60006000fd'; + + // Etches `code` at a random address and returns the address as a Hex string + const etchCode = async (code: Hex) => { + const addr = EthAddress.random(); + await cheatCodes.etch(addr, code); + return addr.toString() as `0x${string}`; + }; + + // Calls `Governance.propose(wrapper)` from the impersonated `governanceProposer`. Returns the + // resulting proposal id by parsing the `Proposed` event from the receipt. Anvil accepts + // unsigned transactions from impersonated accounts via `eth_sendTransaction`, so we don't need + // a separate wallet client. + const proposeAsProposer = async (wrapperAddress: Hex): Promise => { + const data = encodeFunctionData({ + abi: GovernanceAbi, + functionName: 'propose', + args: [wrapperAddress], + }); + const txHash = (await cheatCodes.rpcCall('eth_sendTransaction', [ + { from: governanceProposerAddress, to: governanceAddress, data, gas: '0x100000' }, + ])) as Hex; + const receipt = await walletClient.waitForTransactionReceipt({ hash: txHash }); + expect(receipt.status).toBe('success'); + const [proposed] = parseEventLogs({ abi: GovernanceAbi, eventName: 'Proposed', logs: receipt.logs }); + if (!proposed) { + throw new Error('Proposed event not found in receipt'); + } + return proposed.args.proposalId; + }; + + // Returns the latest L1 block timestamp as a bigint + const nowOnChain = () => publicClient.getBlock({ includeTransactions: false }).then(b => b.timestamp); + + beforeAll(async () => { + await cheatCodes.startImpersonating(governanceProposerAddress); + }); + + afterAll(async () => { + await cheatCodes.stopImpersonating(governanceProposerAddress); + }); + + it('returns false on a fresh governance with no proposals', async () => { + const proposalCount = await governance.getProposalCount(); + expect(proposalCount).toBe(0n); + const arbitraryPayload = EthAddress.random().toString(); + await expect(governance.hasActiveProposalWithPayload(arbitraryPayload)).resolves.toBe(false); + }); + + it('returns true when a live proposal unwraps to the queried payload', async () => { + const original = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(original)); + + const proposalId = await proposeAsProposer(wrapper); + + // The proposal is freshly created, so it should be in `Pending` state + await expect(governance.getProposalState(proposalId)).resolves.toBe(ProposalState.Pending); + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(true); + }); + + it('returns false when no live proposal references the queried payload', async () => { + // Create a proposal for a different payload than the one we query. + const proposalOriginal = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(proposalOriginal)); + await proposeAsProposer(wrapper); + + const queriedPayload = EthAddress.random(); + await expect(governance.hasActiveProposalWithPayload(queriedPayload.toString())).resolves.toBe(false); + }); + + it('returns false once the matching proposal reaches a terminal state', async () => { + // No tokens were ever deposited, so no votes can be cast. Once the active phase ends with no + // yea votes the proposal transitions to `Rejected`, which is terminal -- and at that point + // re-signaling/re-proposing is allowed, so `hasActiveProposalWithPayload` must report false. + const original = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(original)); + const proposalId = await proposeAsProposer(wrapper); + + // Pending while the queried payload is in Pending. + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(true); + + // Warp past the active phase so the proposal becomes terminal. We use the proposal's own + // frozen config (creation + votingDelay + votingDuration + 1) rather than the live config, + // because each proposal stores its own snapshot. + const proposal = await governance.getProposal(proposalId); + const activeThrough = proposal.creation + proposal.config.votingDelay + proposal.config.votingDuration; + await cheatCodes.warp(Number(activeThrough + 1n)); + + await expect(governance.getProposalState(proposalId)).resolves.toBe(ProposalState.Rejected); + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(false); + }); + + it('skips proposals whose payload reverts on getOriginalPayload (proposeWithLock-style)', async () => { + // `Governance.proposeWithLock` bypasses GSEPayload, so the payload stored on such a proposal + // is the original IPayload contract directly -- it has no `getOriginalPayload()` selector and + // the unwrap call reverts. The sweep must treat that as "no match" and keep iterating instead + // of aborting the whole call. + const revertingWrapper = await etchCode(REVERTING_WRAPPER_BYTECODE); + await proposeAsProposer(revertingWrapper); + + const original = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(original)); + const proposalId = await proposeAsProposer(wrapper); + + // The proposal is freshly created, so it should be in `Pending` state + await expect(governance.getProposalState(proposalId)).resolves.toBe(ProposalState.Pending); + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(true); + }); + + it('finds a live proposal among multiple unrelated proposals', async () => { + // Create three proposals; the *middle* one is the one we want to find. This exercises the + // descent loop's matching logic and confirms that hitting non-matching proposals (newer ones + // here, since we walk newest -> oldest) doesn't short-circuit the search. + const irrelevantOriginal1 = EthAddress.random(); + const irrelevantOriginal2 = EthAddress.random(); + const targetOriginal = EthAddress.random(); + + const targetWrapper = await etchCode(buildMockWrapperBytecode(targetOriginal)); + await proposeAsProposer(targetWrapper); + + const noiseWrapper1 = await etchCode(buildMockWrapperBytecode(irrelevantOriginal1)); + await proposeAsProposer(noiseWrapper1); + + const noiseWrapper2 = await etchCode(buildMockWrapperBytecode(irrelevantOriginal2)); + await proposeAsProposer(noiseWrapper2); + + await expect(governance.hasActiveProposalWithPayload(targetOriginal.toString())).resolves.toBe(true); + }); + + it('matches case-insensitively against the original payload address', async () => { + // Ethereum addresses are case-insensitive (mixed case is only used for EIP-55 checksums). + // Callers may pass either form, so the comparison must lowercase both sides. + const original = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(original)); + await proposeAsProposer(wrapper); + + const upperHex = ('0x' + original.toString().slice(2).toUpperCase()) as Hex; + await expect(governance.hasActiveProposalWithPayload(upperHex)).resolves.toBe(true); + }); + + it('early-stops on the protocol-wide lifetime cap and returns false for old proposals', async () => { + // Even if a proposal is "live" in the sense that no terminal-state transition has been + // recorded, once its creation timestamp is more than 4 * TIME_UPPER (= 360 days) in the past + // it cannot possibly still be in Pending/Active/Queued/Executable, because each phase is + // capped at TIME_UPPER = 90 days. We don't even get as far as `getProposalState` for those + // -- the descent loop short-circuits on `proposal.creation < hardCutoff`. + const original = EthAddress.random(); + const wrapper = await etchCode(buildMockWrapperBytecode(original)); + await proposeAsProposer(wrapper); + + // Live initially. + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(true); + + // Warp beyond 4 * 90 days so the proposal's creation falls outside the hard cutoff. + const FOUR_TIME_UPPER = 4n * 90n * 24n * 3600n; + const target = (await nowOnChain()) + FOUR_TIME_UPPER + 60n; + await cheatCodes.warp(Number(target)); + + // We don't assert on getProposalState here; it would return `Rejected` (no votes cast in the + // active phase), but the early-stop in `hasActiveProposalWithPayload` is meant to fire even + // if it were stuck in some non-terminal state, so we test the boolean directly. + await expect(governance.hasActiveProposalWithPayload(original.toString())).resolves.toBe(false); + + // Sanity: the older live proposals from earlier tests are also past the cutoff now, so the + // sweep should report false for any payload, including the dummy one. + await expect(governance.hasActiveProposalWithPayload(EthAddress.random().toString())).resolves.toBe(false); + }); + }); + + describe('Configuration time bounds (sync check for MAX_PROPOSAL_LIFETIME_SECONDS)', () => { + // Mirrors `ConfigurationLib.TIME_UPPER` in `l1-contracts/src/governance/libraries/ConfigurationLib.sol` + // If any of the tests below fail due to a change of constants in the Governance contract, we must update + // the value of MAX_PROPOSAL_LIFETIME_SECONDS in the governance contract wrapper here in ts-land. + const TIME_UPPER = 90n * 24n * 3600n; + + let governance: ReadOnlyGovernanceContract; + let baselineConfig: GovernanceConfiguration; + + beforeAll(async () => { + governance = new ReadOnlyGovernanceContract(governanceAddress, publicClient); + baselineConfig = await governance.getConfiguration(); + }); + + // Probes `updateConfiguration` via `eth_call` impersonating the Governance contract itself. + // `updateConfiguration` is gated by an `onlySelf` modifier, so we set `from = governanceAddress`. + // `eth_call` does not commit state, so this leaves the deployment untouched between probes. + const probeUpdateConfiguration = async (config: GovernanceConfiguration) => { + const data = encodeFunctionData({ + abi: GovernanceAbi, + functionName: 'updateConfiguration', + args: [config], + }); + try { + await cheatCodes.rpcCall('eth_call', [{ from: governanceAddress, to: governanceAddress, data }, 'latest']); + return { ok: true as const }; + } catch (err) { + return { ok: false as const, err }; + } + }; + + // Iterate the four fields whose upper bound feeds MAX_PROPOSAL_LIFETIME_SECONDS. Each is named + // explicitly so the failure message points at the exact field that drifted from the assumption. + const TIME_BOUNDED_FIELDS = ['votingDelay', 'votingDuration', 'executionDelay', 'gracePeriod'] as const; + + it.each(TIME_BOUNDED_FIELDS)('accepts %s = TIME_UPPER', async field => { + const result = await probeUpdateConfiguration({ ...baselineConfig, [field]: TIME_UPPER }); + expect(result.ok).toBe(true); + }); + + it.each(TIME_BOUNDED_FIELDS)('rejects %s = TIME_UPPER + 1', async field => { + const result = await probeUpdateConfiguration({ ...baselineConfig, [field]: TIME_UPPER + 1n }); + expect(result.ok).toBe(false); + }); + + it('the ts hard cap matches the protocol-wide upper bound', () => { + const expected = 4n * TIME_UPPER; + expect(MAX_PROPOSAL_LIFETIME_SECONDS).toBe(expected); + }); }); }); @@ -101,6 +373,8 @@ describe('Governance', () => { expect(governance.getConfiguration).toBeDefined(); expect(governance.getProposal).toBeDefined(); expect(governance.getProposalState).toBeDefined(); + expect(governance.getProposalCount).toBeDefined(); + expect(governance.hasActiveProposalWithPayload).toBeDefined(); expect(governance.awaitProposalActive).toBeDefined(); expect(governance.awaitProposalExecutable).toBeDefined(); }); @@ -111,20 +385,4 @@ describe('Governance', () => { }).toThrow(); }); }); - - it('gets configuration', async () => { - const governance = new GovernanceContract(governanceAddress, walletClient); - expect(governance).toBeDefined(); - const config = await governance.getConfiguration(); - expect(config).toBeDefined(); - expect(config.proposeConfig.lockDelay).toBeGreaterThan(0n); - expect(config.proposeConfig.lockAmount).toBeGreaterThan(0n); - expect(config.votingDelay).toBeGreaterThan(0n); - expect(config.votingDuration).toBeGreaterThan(0n); - expect(config.executionDelay).toBeGreaterThan(0n); - expect(config.gracePeriod).toBeGreaterThan(0n); - expect(config.quorum).toBeGreaterThan(0n); - expect(config.requiredYeaMargin).toBeGreaterThan(0n); - expect(config.minimumVotes).toBeGreaterThan(0n); - }); }); diff --git a/yarn-project/ethereum/src/contracts/governance.ts b/yarn-project/ethereum/src/contracts/governance.ts index d4aa7b396e1e..61d82efe0a29 100644 --- a/yarn-project/ethereum/src/contracts/governance.ts +++ b/yarn-project/ethereum/src/contracts/governance.ts @@ -17,6 +17,21 @@ import type { L1ContractAddresses } from '../l1_contract_addresses.js'; import { createL1TxUtils } from '../l1_tx_utils/index.js'; import { type ExtendedViemWalletClient, type ViemClient, isExtendedClient } from '../types.js'; +// Minimal ABI for IProposerPayload (`l1-contracts/src/governance/interfaces/IProposerPayload.sol`). +// The GovernanceProposer wraps every original payload in a GSEPayload before submitting it to +// Governance, so `Proposal.payload` on the Governance contract is the wrapper address rather than +// the original. We only need `getOriginalPayload` to recover the underlying payload, so we inline +// the ABI here instead of generating a full artifact. +const ProposerPayloadAbi = [ + { + type: 'function', + name: 'getOriginalPayload', + inputs: [], + outputs: [{ type: 'address' }], + stateMutability: 'view', + }, +] as const; + export type L1GovernanceContractAddresses = Pick< L1ContractAddresses, 'governanceAddress' | 'rollupAddress' | 'registryAddress' | 'governanceProposerAddress' @@ -34,6 +49,98 @@ export enum ProposalState { Expired, } +/** Vote tallies on a single proposal. Both fields are mutated by `Governance.vote`. */ +export interface Ballot { + yea: bigint; + nay: bigint; +} + +/** + * Snapshot of the timing/quorum parameters that govern a single proposal's lifecycle. Each proposal + * stores its own copy at creation time (see `_propose` in Governance.sol), so this snapshot is + * immutable for the lifetime of the proposal even if the global `Configuration` changes later. + */ +export interface ProposalConfiguration { + votingDelay: bigint; + votingDuration: bigint; + executionDelay: bigint; + gracePeriod: bigint; + quorum: bigint; + requiredYeaMargin: bigint; + minimumVotes: bigint; +} + +/** Parameters for `Governance.proposeWithLock`. Stored only in the global Configuration, never on a proposal. */ +export interface ProposeWithLockConfiguration { + lockDelay: bigint; + lockAmount: bigint; +} + +/** + * Live, mutable governance configuration. `proposeConfig` is the lock configuration used by + * `proposeWithLock`; the remaining fields are the same shape as `ProposalConfiguration` and are + * snapshotted onto each new proposal at creation time. + */ +export interface GovernanceConfiguration extends ProposalConfiguration { + proposeConfig: ProposeWithLockConfiguration; +} + +/** + * A governance proposal augmented with its live (computed) state. + * + * Mutability: + * - `config`, `payload`, `proposer`, `creation` are immutable for the lifetime of the proposal. + * - `cachedState` is the raw value stored on-chain. It is only written when a proposal is explicitly + * executed or dropped, so for time-derived terminal states (Rejected, Expired) it remains at the + * value that was current when the proposal was created. + * - `state` is the computed state returned by `Governance.getProposalState`. This is the value + * callers almost always want -- it reflects time-derived transitions that `cachedState` does not. + * - `summedBallot` is mutated by every `Governance.vote` call while the proposal is Active. + * + * Once `state` is in a terminal phase (`Executed`/`Rejected`/`Dropped`/`Expired`) the entire struct + * is provably immutable on-chain (no further votes can be cast and no state-mutating call to + * `execute`/`dropProposal` can succeed), and the wrapper memoizes it. + */ +export interface Proposal { + config: ProposalConfiguration; + cachedState: ProposalState; + state: ProposalState; + payload: EthAddress; + proposer: EthAddress; + creation: bigint; + summedBallot: Ballot; +} + +/** Set of `ProposalState` values for which a proposal is fully immutable on-chain. */ +const TERMINAL_PROPOSAL_STATES: ReadonlySet = new Set([ + ProposalState.Executed, + ProposalState.Rejected, + ProposalState.Dropped, + ProposalState.Expired, +]); + +// Hard upper bound on the wall-clock lifetime of any Governance proposal, in seconds. +// Each proposal stores its own snapshot of `ProposalConfiguration` at creation time and progresses +// through Pending -> Active -> Queued -> Executable using those frozen durations +// (see `ProposalLib.{pendingThrough,activeThrough,queuedThrough,executableThrough}`). Each of those +// four durations is bounded by `ConfigurationLib.TIME_UPPER = 90 days` (validated in +// `ConfigurationLib.assertValid`), so no proposal can be live for more than 4 * 90 days regardless +// of what config it was created under. Once past this point, the proposal is guaranteed to be in a +// terminal state (Executed / Rejected / Dropped / Expired). +export const MAX_PROPOSAL_LIFETIME_SECONDS = 4n * 90n * 24n * 3600n; + +/** + * Validates a number returned by an on-chain `ProposalState` enum field and narrows it to the + * `ProposalState` enum. Throws if the value is out of range. + */ +function asProposalState(raw: number): ProposalState { + // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison + if (raw < 0 || raw > ProposalState.Expired) { + throw new Error(`Invalid proposal state: ${raw}`); + } + return raw as ProposalState; +} + export function extractProposalIdFromLogs(logs: Log[]): bigint { const parsedLogs = parseEventLogs({ abi: GovernanceAbi, @@ -50,6 +157,22 @@ export function extractProposalIdFromLogs(logs: Log[]): bigint { export class ReadOnlyGovernanceContract { protected readonly governanceContract: GetContractReturnType; + /** + * Cache of fully-resolved proposals keyed by id. We populate this lazily and only retain entries + * whose state is provably terminal -- once `cachedState` is `Executed` or `Dropped` the on-chain + * proposal struct is frozen and safe to memoize indefinitely. Other state transitions (e.g. + * Pending -> Active, or accumulating votes) leave the cache untouched and force a fresh fetch. + */ + private readonly proposalCache: Map = new Map(); + + /** + * Cache of `IProposerPayload.getOriginalPayload()` results keyed by wrapper address. The wrapper + * contract's bytecode is immutable, so this mapping never changes -- a value of `undefined` + * encodes a proposal whose payload doesn't implement `getOriginalPayload` (e.g. proposeWithLock + * proposals) and should be treated as "no original". + */ + private readonly originalPayloadCache: Map = new Map(); + constructor( address: Hex, public readonly client: ViemClient, @@ -65,21 +188,155 @@ export class ReadOnlyGovernanceContract { return EthAddress.fromString(await this.governanceContract.read.governanceProposer()); } - public getConfiguration() { - return this.governanceContract.read.getConfiguration(); + public async getConfiguration(): Promise { + const raw = await this.governanceContract.read.getConfiguration(); + return { + proposeConfig: { + lockDelay: raw.proposeConfig.lockDelay, + lockAmount: raw.proposeConfig.lockAmount, + }, + votingDelay: raw.votingDelay, + votingDuration: raw.votingDuration, + executionDelay: raw.executionDelay, + gracePeriod: raw.gracePeriod, + quorum: raw.quorum, + requiredYeaMargin: raw.requiredYeaMargin, + minimumVotes: raw.minimumVotes, + }; } - public getProposal(proposalId: bigint) { - return this.governanceContract.read.getProposal([proposalId]); + /** + * Fetches a proposal by id together with its live state, returning the mapped {@link Proposal} + * type. Issues `getProposal` and `getProposalState` in parallel so the result carries both the + * raw stored `cachedState` and the time-derived `state` -- callers can use whichever they need + * without an extra round-trip. + * + * Backed by an in-memory cache that retains entries only when `state` is in one of the four + * terminal phases (`Executed` / `Rejected` / `Dropped` / `Expired`). At that point the entire + * proposal struct is provably immutable on-chain (no further votes can be cast and no + * state-mutating call can succeed), so caching is safe forever. Non-terminal states force a + * fresh fetch on every call so callers always see fresh `state` and `summedBallot` values. + */ + public async getProposal(proposalId: bigint): Promise { + const cached = this.proposalCache.get(proposalId); + if (cached !== undefined) { + return cached; + } + const [raw, rawState] = await Promise.all([ + this.governanceContract.read.getProposal([proposalId]), + this.governanceContract.read.getProposalState([proposalId]), + ]); + const proposal: Proposal = { + config: { + votingDelay: raw.config.votingDelay, + votingDuration: raw.config.votingDuration, + executionDelay: raw.config.executionDelay, + gracePeriod: raw.config.gracePeriod, + quorum: raw.config.quorum, + requiredYeaMargin: raw.config.requiredYeaMargin, + minimumVotes: raw.config.minimumVotes, + }, + cachedState: asProposalState(raw.cachedState), + state: asProposalState(rawState), + payload: EthAddress.fromString(raw.payload), + proposer: EthAddress.fromString(raw.proposer), + creation: raw.creation, + summedBallot: { yea: raw.summedBallot.yea, nay: raw.summedBallot.nay }, + }; + if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) { + this.proposalCache.set(proposalId, proposal); + } + return proposal; } + /** + * Returns the live state of a proposal as computed by `Governance.getProposalState`. Prefer + * {@link getProposal} when you also need any other proposal data -- it returns this same value + * via {@link Proposal.state} alongside the rest of the struct in a single round-trip. + */ public async getProposalState(proposalId: bigint): Promise { - const state = await this.governanceContract.read.getProposalState([proposalId]); - // eslint-disable-next-line @typescript-eslint/no-unsafe-enum-comparison - if (state < 0 || state > ProposalState.Expired) { - throw new Error(`Invalid proposal state: ${state}`); + return asProposalState(await this.governanceContract.read.getProposalState([proposalId])); + } + + public getProposalCount(): Promise { + return this.governanceContract.read.proposalCount(); + } + + /** + * Checks whether the given original payload is currently the subject of a live (non-terminal) + * Governance proposal. Returns true only if a proposal references this payload and is still in + * Pending, Active, Queued, or Executable state. Terminal proposals (Executed, Rejected, Dropped, + * Expired) are ignored, because once a proposal reaches a terminal state the same original + * payload may legitimately be re-signaled and re-submitted via the GovernanceProposer (each round + * is independent and there is no payload-level uniqueness check on-chain). + * + * Implemented as a bounded view-call sweep over `Governance.proposals` rather than an event scan, + * because `eth_getLogs` over the full deployment history of a long-lived rollup exceeds typical + * RPC block-range caps. The number of proposals (`proposalCount`) is small in practice, and we + * walk newest -> oldest with a hard early-stop on the protocol-wide proposal lifetime cap. + */ + public async hasActiveProposalWithPayload(payload: Hex): Promise { + const proposalCount = await this.getProposalCount(); + if (proposalCount === 0n) { + return false; + } + + // Anything created before this cutoff is guaranteed terminal regardless of its frozen config. + const block = await this.client.getBlock(); + const hardCutoff = block.timestamp - MAX_PROPOSAL_LIFETIME_SECONDS; + + const target = payload.toLowerCase() as Hex; + + // Proposals are append-only with monotonically non-decreasing creation timestamps, so iterating + // from newest -> oldest lets us early-stop as soon as we cross the lifetime cutoff. + for (let id = proposalCount - 1n; id >= 0n; id--) { + const proposal = await this.getProposal(id); + + // Hard early-stop: every older proposal is also older than the cutoff and therefore terminal. + if (proposal.creation < hardCutoff) { + return false; + } + + const original = await this.getOriginalPayload(proposal.payload); + if (original === undefined || original.toLowerCase() !== target) { + continue; + } + + // The wrapper unwraps to our payload. Only treat this as "already proposed" if the proposal + // is still live -- terminal states allow re-proposing the same payload in a later round. + if (TERMINAL_PROPOSAL_STATES.has(proposal.state)) { + continue; + } + + return true; + } + + return false; + } + + /** + * Resolves the original payload behind a `GSEPayload` wrapper. Returns `undefined` if the + * wrapper does not implement `IProposerPayload.getOriginalPayload` (e.g. proposals created via + * `Governance.proposeWithLock`, which bypass GSEPayload entirely and store the raw `IPayload` + * directly). Results are memoized indefinitely because deployed wrapper bytecode is immutable. + */ + private async getOriginalPayload(wrapper: EthAddress): Promise { + const key = wrapper.toString(); + if (this.originalPayloadCache.has(key)) { + return this.originalPayloadCache.get(key); + } + let original: Hex | undefined; + try { + original = await this.client.readContract({ + address: key, + abi: ProposerPayloadAbi, + functionName: 'getOriginalPayload', + }); + } catch { + original = undefined; } - return state as ProposalState; + this.originalPayloadCache.set(key, original); + return original; } public async awaitProposalActive({ proposalId, logger }: { proposalId: bigint; logger: Logger }) { diff --git a/yarn-project/ethereum/src/contracts/governance_proposer.ts b/yarn-project/ethereum/src/contracts/governance_proposer.ts index a7c203a67bdf..0210211bb28a 100644 --- a/yarn-project/ethereum/src/contracts/governance_proposer.ts +++ b/yarn-project/ethereum/src/contracts/governance_proposer.ts @@ -15,7 +15,7 @@ import { import type { L1TxRequest, L1TxUtils } from '../l1_tx_utils/index.js'; import type { ViemClient } from '../types.js'; import { type IEmpireBase, encodeSignal, encodeSignalWithSignature, signSignalWithSig } from './empire_base.js'; -import { extractProposalIdFromLogs } from './governance.js'; +import { ReadOnlyGovernanceContract, extractProposalIdFromLogs } from './governance.js'; export class GovernanceProposerContract implements IEmpireBase { private readonly proposer: GetContractReturnType; @@ -110,10 +110,27 @@ export class GovernanceProposerContract implements IEmpireBase { }; } - /** Checks if a payload was ever submitted to governance via submitRoundWinner. */ - public async hasPayloadBeenProposed(payload: Hex, fromBlock: bigint): Promise { - const events = await this.proposer.getEvents.PayloadSubmitted({ payload }, { fromBlock, strict: true }); - return events.length > 0; + /** + * Resolves the Governance contract this proposer submits winners to. Lazily reads + * `GovernanceProposer.getGovernance()` (which itself looks the address up via the registry) and + * memoizes the resulting wrapper. + */ + @memoize + public async getGovernance(): Promise { + const address = await this.proposer.read.getGovernance(); + return new ReadOnlyGovernanceContract(address, this.client); + } + + /** + * Returns true iff the given original payload is currently the subject of a live (non-terminal) + * Governance proposal. Delegates to `ReadOnlyGovernanceContract.hasActiveProposalWithPayload`, which + * implements the actual sweep against the Governance contract -- this method exists only as a + * convenience wrapper so callers that already hold a GovernanceProposer reference don't have to + * resolve the Governance address themselves. + */ + public async hasActiveProposalWithPayload(payload: Hex): Promise { + const governance = await this.getGovernance(); + return governance.hasActiveProposalWithPayload(payload); } public async submitRoundWinner( diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index c565a79244c9..93ac3a7646ed 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -654,9 +654,10 @@ describe('SequencerPublisher', () => { ).toEqual(false); }); - it.each([undefined])('does not signal for payload with empty code', async code => { + it('does not signal for payload with empty code', async () => { const { govPayload } = mockGovernancePayload(); - l1TxUtils.getCode.mockReturnValue(Promise.resolve(code)); + l1TxUtils.getCode.mockReturnValue(Promise.resolve(undefined)); + ``; expect( await publisher.enqueueGovernanceCastSignal( @@ -670,7 +671,7 @@ describe('SequencerPublisher', () => { it('stops signalling when payload was previously proposed', async () => { const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(true); + governanceProposerContract.hasActiveProposalWithPayload.mockResolvedValue(true); expect( await publisher.enqueueGovernanceCastSignal( @@ -684,7 +685,7 @@ describe('SequencerPublisher', () => { it('continues signalling when payload was NOT proposed', async () => { const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(false); + governanceProposerContract.hasActiveProposalWithPayload.mockResolvedValue(false); expect( await publisher.enqueueGovernanceCastSignal( @@ -696,33 +697,13 @@ describe('SequencerPublisher', () => { ).toEqual(true); }); - it('caches proposed result and prevents repeated L1 calls', async () => { + it('re-checks on every call without caching, so re-signaling resumes if a proposal becomes terminal', async () => { const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed.mockResolvedValue(true); - - await publisher.enqueueGovernanceCastSignal( - govPayload, - SlotNumber(2), - EthAddress.fromString(testHarnessAttesterAccount.address), - msg => testHarnessAttesterAccount.signTypedData(msg), - ); - - await publisher.enqueueGovernanceCastSignal( - govPayload, - SlotNumber(3), - EthAddress.fromString(testHarnessAttesterAccount.address), - msg => testHarnessAttesterAccount.signTypedData(msg), - ); - - expect(governanceProposerContract.hasPayloadBeenProposed).toHaveBeenCalledTimes(1); - }); - - it('retries on transient RPC failure and succeeds', async () => { - const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed - .mockRejectedValueOnce(new Error('RPC error')) - .mockRejectedValueOnce(new Error('RPC error')) - .mockResolvedValueOnce(false); + // Simulates a payload that has a live proposal in slot 2 but whose proposal becomes terminal + // (Dropped/Rejected/Expired/Executed) by slot 3. The contracts allow re-signaling the same + // payload in a later round once the previous proposal is dead, so the publisher must re-check + // each slot rather than cache the first `true` result indefinitely. + governanceProposerContract.hasActiveProposalWithPayload.mockResolvedValueOnce(true).mockResolvedValueOnce(false); expect( await publisher.enqueueGovernanceCastSignal( @@ -731,12 +712,26 @@ describe('SequencerPublisher', () => { EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), + ).toEqual(false); + + expect( + await publisher.enqueueGovernanceCastSignal( + govPayload, + SlotNumber(3), + EthAddress.fromString(testHarnessAttesterAccount.address), + msg => testHarnessAttesterAccount.signTypedData(msg), + ), ).toEqual(true); + + expect(governanceProposerContract.hasActiveProposalWithPayload).toHaveBeenCalledTimes(2); }); - it('fails closed on persistent RPC failure', async () => { + it('fails open on persistent RPC failure and signals anyway', async () => { + // Failing closed (skipping the signal) on transient RPC errors would let a flaky L1 endpoint + // silence governance participation entirely. Failing open at worst produces a duplicate signal + // that the contract simply counts alongside others in the round. const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed.mockRejectedValue(new Error('RPC error')); + governanceProposerContract.hasActiveProposalWithPayload.mockRejectedValue(new Error('RPC error')); expect( await publisher.enqueueGovernanceCastSignal( @@ -745,14 +740,14 @@ describe('SequencerPublisher', () => { EthAddress.fromString(testHarnessAttesterAccount.address), msg => testHarnessAttesterAccount.signTypedData(msg), ), - ).toEqual(false); + ).toEqual(true); }); - it('does not cache false result and re-checks on subsequent calls', async () => { + it('re-checks each call (no caching of false results)', async () => { const { govPayload } = mockGovernancePayload(); - governanceProposerContract.hasPayloadBeenProposed.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + governanceProposerContract.hasActiveProposalWithPayload.mockResolvedValueOnce(false).mockResolvedValueOnce(true); - // First call: not proposed, signalling proceeds + // First call: no live proposal, signalling proceeds expect( await publisher.enqueueGovernanceCastSignal( govPayload, @@ -762,7 +757,7 @@ describe('SequencerPublisher', () => { ), ).toEqual(true); - // Second call: now proposed, signalling stops + // Second call: live proposal now exists, signalling stops expect( await publisher.enqueueGovernanceCastSignal( govPayload, @@ -772,6 +767,6 @@ describe('SequencerPublisher', () => { ), ).toEqual(false); - expect(governanceProposerContract.hasPayloadBeenProposed).toHaveBeenCalledTimes(2); + expect(governanceProposerContract.hasActiveProposalWithPayload).toHaveBeenCalledTimes(2); }); }); diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index b82a078112c0..faae6363153e 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -5,7 +5,6 @@ import type { L1ContractsConfig } from '@aztec/ethereum/config'; import { FeeAssetPriceOracle, type GovernanceProposerContract, - type IEmpireBase, MULTI_CALL_3_ADDRESS, Multicall3, RollupContract, @@ -36,7 +35,6 @@ import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature, type ViemSignature } from '@aztec/foundation/eth-signature'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { makeBackoff, retry } from '@aztec/foundation/retry'; import { InterruptibleSleep } from '@aztec/foundation/sleep'; import { bufferToHex } from '@aztec/foundation/string'; import { type DateProvider, Timer } from '@aztec/foundation/timer'; @@ -150,7 +148,6 @@ export class SequencerPublisher { protected lastActions: Partial> = {}; private isPayloadEmptyCache: Map = new Map(); - private payloadProposedCache: Set = new Set(); protected log: Logger; protected ethereumSlotDuration: bigint; @@ -904,7 +901,7 @@ export class SequencerPublisher { slotNumber: SlotNumber, signalType: GovernanceSignalAction, payload: EthAddress, - base: IEmpireBase, + base: GovernanceProposerContract, signerAddress: EthAddress, signer: (msg: TypedDataDefinition) => Promise<`0x${string}`>, ): Promise { @@ -935,29 +932,23 @@ export class SequencerPublisher { return false; } - // Check if payload was already submitted to governance - const cacheKey = payload.toString(); - if (!this.payloadProposedCache.has(cacheKey)) { - try { - const l1StartBlock = await this.rollupContract.getL1StartBlock(); - const proposed = await retry( - () => base.hasPayloadBeenProposed(payload.toString(), l1StartBlock), - 'Check if payload was proposed', - makeBackoff([0, 1, 2]), - this.log, - true, - ); - if (proposed) { - this.payloadProposedCache.add(cacheKey); - } - } catch (err) { - this.log.warn(`Failed to check if payload ${payload} was proposed after retries, skipping signal`, err); - return false; - } + // Skip signaling if there is already a live (non-terminal) Governance proposal for this + // payload. This is intentionally not cached: a previously-live proposal may transition to + // a terminal state (Dropped/Rejected/Expired/Executed), at which point we may want to re-signal + // the same payload in a future round. + let proposed = false; + try { + proposed = await base.hasActiveProposalWithPayload(payload.toString()); + } catch (err) { + // We deliberately swallow the error and proceed to signal. Failing closed (skipping the + // signal) on transient RPC errors would let a flaky L1 endpoint silence governance + // participation entirely; failing open at worst produces a duplicate signal that the + // contract will simply count alongside others in the round. + this.log.error(`Failed to check if payload ${payload} was already proposed (signalling anyway)`, err); } - if (this.payloadProposedCache.has(cacheKey)) { - this.log.info(`Payload ${payload} was already proposed to governance, stopping signals`); + if (proposed) { + this.log.info(`Payload ${payload} has a live governance proposal, stopping signals`); return false; } From 1d01c59e7e60d62e72b9c44454207d77673ad2e6 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Wed, 6 May 2026 14:24:37 -0400 Subject: [PATCH 12/55] fix(docs): allow webapp-tutorial yarn install to populate empty lockfile in CI (#23000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the `docs` build failure on `merge-train/spartan` (CI run [25449092262](https://github.com/AztecProtocol/aztec-packages/actions/runs/25449092262), log [27a4351a1e5e3568](http://ci.aztec-labs.com/27a4351a1e5e3568)). ## Problem `validate-webapp-tutorial` in `docs/examples/bootstrap.sh` intentionally starts each run with an empty `yarn.lock`, then runs `yarn install` to populate it from the `link:` paths it just wrote into `package.json`. In CI, Yarn 4 auto-enables `--immutable` when it detects `CI=1`, so the install fails with `YN0028 (frozen lockfile exception)` because populating an empty lockfile counts as modifying it. ``` ➤ YN0028: │ The lockfile would have been modified by this install, which is explicitly forbidden. ➤ YN0000: · Failed with errors in 6s 829ms ERROR: Contract artifact not found at /home/aztec-dev/aztec-packages/docs/target/pod_racing_contract-PodRacing.json ``` (The "Contract artifact not found" line is a downstream symptom — the script doesn't run with `set -e`, so after `yarn install` fails it continues into the artifact check and reports a misleading error.) ## Fix Set `YARN_ENABLE_IMMUTABLE_INSTALLS=false` for that one `yarn install` call, since populating the lockfile is the intended behaviour. ## Verification Reproduced locally: `CI=true yarn install` against the webapp-tutorial fails with `YN0028`; with `YARN_ENABLE_IMMUTABLE_INSTALLS=false` it succeeds. ClaudeBox log: https://claudebox.work/s/a1863de35053b544?run=1 --- docs/examples/bootstrap.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/examples/bootstrap.sh b/docs/examples/bootstrap.sh index 23c4fe19a64c..c3d91f209838 100755 --- a/docs/examples/bootstrap.sh +++ b/docs/examples/bootstrap.sh @@ -172,7 +172,9 @@ function validate-webapp-tutorial { # Fresh yarn setup for linking yarn config set nodeLinker node-modules 2>/dev/null || true - yarn install + # Yarn 4 auto-enables --immutable when CI is set; we intentionally start + # with an empty yarn.lock that this install populates, so disable that. + YARN_ENABLE_IMMUTABLE_INSTALLS=false yarn install # yarn's `link:` protocol creates portals into yarn-project/*, which require # --preserve-symlinks for Node's ESM loader to resolve dependencies correctly From c4e19147404639f4c78db89833a42982ab7dafa3 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 7 May 2026 06:09:25 -0300 Subject: [PATCH 13/55] test(e2e): enable pipelining in l1-reorgs and mbps redistribution tests (#23009) No major changes needed --- .../epochs_l1_reorgs.parallel.test.ts | 44 +++++---- .../epochs_mbps_redistribution.test.ts | 2 + .../epochs_proof_fails.parallel.test.ts | 91 ++++++++++--------- 3 files changed, 79 insertions(+), 58 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts index 97111c2d2a6d..18a6fe059fdb 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts @@ -60,9 +60,6 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { }; beforeEach(async () => { - // Note: pipelining is NOT enabled for this test because it manipulates L1 state directly - // (reorgs, tx cancellation) with cancelTxOnTimeout: false and maxSpeedUpAttempts: 0, - // which conflicts with pipelining's assumption that previous checkpoints land on L1 promptly. test = await EpochsTestContext.setup({ numberOfAccounts: 1, maxSpeedUpAttempts: 0, // Do not speed up l1 txs, we dont want them to land @@ -78,6 +75,10 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { aztecProofSubmissionEpochs: 1, // Use 32 slots/epoch (matching real Ethereum mainnet) anvilSlotsInAnEpoch: 32, + // Pipelining + multi-blocks-per-slot: 8s blocks fit ~4 blocks per 36s slot, and TX_COUNT=8 + // ensures multiple checkpoints have multiple blocks + enableProposerPipelining: true, + inboxLag: 2, }); ({ proverDelayer, sequencerDelayer, context, logger, monitor, L1_BLOCK_TIME_IN_S, L2_SLOT_DURATION_IN_S } = test); node = context.aztecNode; @@ -220,12 +221,12 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { const targetProvenCheckpoint = CheckpointNumber(initialProvenCheckpoint + 1); // Wait until we have proven something and the nodes have caught up - // Use a longer timeout since we need to wait for the epoch to complete (~288s) plus proving time + // Use a longer timeout since we need to wait for the epoch to complete (~288s) plus proving time. const epochDurationSeconds = test.constants.epochDuration * test.constants.slotDuration; logger.warn(`Waiting for initial proof to land`); const provenCheckpoint = await test.waitUntilProvenCheckpointNumber( targetProvenCheckpoint, - epochDurationSeconds * 2, + epochDurationSeconds * 4, ); await retryUntil(() => getProvenCheckpointNumber(node).then(cp => cp >= provenCheckpoint), 'node sync', 10, 0.1); @@ -270,11 +271,16 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { proverDelayer.cancelNextTx(); // Expect pending chain to advance, so there's something to be pruned - await retryUntil(() => getCheckpointNumber(node).then(cp => cp > initialCheckpoint), 'node sync', 60, 0.1); + await retryUntil( + () => getCheckpointNumber(node).then(cp => cp > initialCheckpoint), + 'node sync', + L2_SLOT_DURATION_IN_S * 4, + 0.1, + ); // Wait until the end of the proof submission window for the first unproven epoch const firstUnprovenCheckpoint = CheckpointNumber(initialProvenCheckpoint + 1); - await test.waitUntilCheckpointNumber(firstUnprovenCheckpoint, 60); + await test.waitUntilCheckpointNumber(firstUnprovenCheckpoint, L2_SLOT_DURATION_IN_S * 4); const epochToWaitFor = await test.rollup.getEpochNumberForCheckpoint(firstUnprovenCheckpoint); await test.waitUntilLastSlotOfProofSubmissionWindow(epochToWaitFor); await monitor.run(true); @@ -339,18 +345,21 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { // Wait until CHECKPOINT_NUMBER is mined and node synced, and stop the sequencer const CHECKPOINT_NUMBER = CheckpointNumber(initialCheckpoint + 3); - await test.waitUntilCheckpointNumber(CHECKPOINT_NUMBER, L2_SLOT_DURATION_IN_S * 7); + await test.waitUntilCheckpointNumber(CHECKPOINT_NUMBER, L2_SLOT_DURATION_IN_S * 10); expect(monitor.checkpointNumber).toEqual(CHECKPOINT_NUMBER); + // Stop the sequencer immediately so any in-flight pipelined publish for CHECKPOINT_NUMBER+1 + // doesn't extend l1BlockNumber before we capture it. setConfig alone is not enough under + // pipelining because already-constructed jobs snapshot the old config. + await context.sequencer!.stop(); + logger.warn(`Sequencer stopped`); const l1BlockNumber = monitor.l1BlockNumber; // Wait for node to sync to the checkpoint. await retryUntil(() => getCheckpointNumber(node).then(b => b === CHECKPOINT_NUMBER), 'node sync', 10, 0.1); + logger.warn(`Reached checkpoint ${CHECKPOINT_NUMBER}`); // Verify multi-block checkpoints were built before we do the reorg await test.assertMultipleBlocksPerSlot(2); - logger.warn(`Reached checkpoint ${CHECKPOINT_NUMBER}. Stopping block production.`); - await context.aztecNodeAdmin.setConfig({ minTxsPerBlock: 100 }); - // Remove the L2 block from L1 const l1BlocksToReorg = monitor.l1BlockNumber - l1BlockNumber + 1; await context.cheatCodes.eth.reorgWithReplacement(l1BlocksToReorg); @@ -374,7 +383,7 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { // Wait until the checkpoint *before* CHECKPOINT_NUMBER is mined and node synced const CHECKPOINT_NUMBER = CheckpointNumber(initialCheckpoint + 3); const prevCheckpointNumber = CheckpointNumber(CHECKPOINT_NUMBER - 1); - await test.waitUntilCheckpointNumber(prevCheckpointNumber, L2_SLOT_DURATION_IN_S * 7); + await test.waitUntilCheckpointNumber(prevCheckpointNumber, L2_SLOT_DURATION_IN_S * 10); expect(monitor.checkpointNumber).toEqual(prevCheckpointNumber); // Wait for node to sync to the checkpoint await retryUntil(() => getCheckpointNumber(node).then(b => b === prevCheckpointNumber), 'node sync', 5, 0.1); @@ -382,11 +391,14 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { // Verify multi-block checkpoints were built before we do the reorg await test.assertMultipleBlocksPerSlot(2); - // Cancel the next tx to be mined and pause the sequencer + // Cancel the next tx to be mined (the proposal for CHECKPOINT_NUMBER) and pause the sequencer. + // Under pipelining we then stop the sequencer entirely so an in-flight pipelined job for + // CHECKPOINT_NUMBER+1 cannot escape and publish onto L1 before our reorg captures the gap. sequencerDelayer.cancelNextTx(); await retryUntil(() => sequencerDelayer.getCancelledTxs().length, 'next block', L2_SLOT_DURATION_IN_S * 2, 0.1); const [l2BlockTx] = sequencerDelayer.getCancelledTxs(); - await context.aztecNodeAdmin.setConfig({ minTxsPerBlock: 100 }); + await context.sequencer!.stop(); + logger.warn(`Sequencer stopped`); // Save the L1 block number when the L2 block would have been mined const l1BlockNumber = monitor.l1BlockNumber; @@ -447,7 +459,7 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { it('updates L1 to L2 messages changed due to an L1 reorg', async () => { // Send L2 txs to trigger multi-block checkpoints and wait for them to land in a checkpoint await sendTransactions(TX_COUNT, 100); - await test.waitUntilCheckpointNumber(CheckpointNumber(2), L2_SLOT_DURATION_IN_S * 4); + await test.waitUntilCheckpointNumber(CheckpointNumber(2), L2_SLOT_DURATION_IN_S * 6); // Send 3 messages and wait for archiver sync logger.warn(`Sending 3 cross chain messages`); @@ -484,7 +496,7 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { it('handles missed message inserted by an L1 reorg', async () => { // Send L2 txs to trigger multi-block checkpoints and wait for them to land in a checkpoint await sendTransactions(TX_COUNT, 200); - await test.waitUntilCheckpointNumber(CheckpointNumber(2), L2_SLOT_DURATION_IN_S * 4); + await test.waitUntilCheckpointNumber(CheckpointNumber(2), L2_SLOT_DURATION_IN_S * 6); // Send a message and wait for node to sync it logger.warn(`Sending first cross chain message`); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts index ae015d03e5e3..4a0bf8a70f21 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps_redistribution.test.ts @@ -94,6 +94,8 @@ describe('e2e_epochs/epochs_mbps_redistribution', () => { test = await EpochsTestContext.setup({ numberOfAccounts: 0, initialValidators: validators, + enableProposerPipelining: true, + inboxLag: 2, mockGossipSubNetwork: true, disableAnvilTestWatcher: true, startProverNode: true, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_fails.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_fails.parallel.test.ts index da68fd17e758..49fd2abe5097 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_fails.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_fails.parallel.test.ts @@ -2,13 +2,15 @@ import { getTimestampRangeForEpoch } from '@aztec/aztec.js/block'; import type { Logger } from '@aztec/aztec.js/log'; import { BatchedBlob } from '@aztec/blob-lib/types'; import { RollupContract } from '@aztec/ethereum/contracts'; -import { type Delayer, waitUntilL1Timestamp } from '@aztec/ethereum/l1-tx-utils'; +import type { Delayer } from '@aztec/ethereum/l1-tx-utils'; import { ChainMonitor } from '@aztec/ethereum/test'; import type { ViemClient } from '@aztec/ethereum/types'; -import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, EpochNumber } from '@aztec/foundation/branded-types'; import { promiseWithResolvers } from '@aztec/foundation/promise'; +import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import type { TestProverNode } from '@aztec/prover-node/test'; +import type { SequencerEvents } from '@aztec/sequencer-client'; import { type L1RollupConstants, getEpochAtSlot } from '@aztec/stdlib/epoch-helpers'; import { Proof } from '@aztec/stdlib/proofs'; import { RootRollupPublicInputs } from '@aztec/stdlib/rollup'; @@ -27,27 +29,27 @@ describe('e2e_epochs/epochs_proof_fails', () => { let constants: L1RollupConstants; let logger: Logger; let proverDelayer: Delayer; - let sequencerDelayer: Delayer; let monitor: ChainMonitor; - let L1_BLOCK_TIME_IN_S: number; let L2_SLOT_DURATION_IN_S: number; let test: EpochsTestContext; beforeEach(async () => { - // Note: pipelining is NOT enabled for this test because it deliberately manipulates L1 tx timing - // (via proverDelayer/sequencerDelayer with cancelTxOnTimeout: false and maxSpeedUpAttempts: 0), - // which conflicts with pipelining's assumption that previous checkpoints land on L1 promptly. test = await EpochsTestContext.setup({ maxSpeedUpAttempts: 0, // No speed ups startProverNode: false, // Avoid early proving ethereumSlotDuration: 8, - aztecEpochDuration: 8, // Bump empoch duration so we can land at least one block in epoch 0 + aztecEpochDuration: 8, // Bump epoch duration so we can land at least one block in epoch 0 + aztecSlotDurationInL1Slots: 2, + blockDurationMs: 3000, // 3s blocks → 2 blocks per checkpoint under pipelining cancelTxOnTimeout: false, + enableProposerPipelining: true, + enforceTimeTable: true, + inboxLag: 2, }); - ({ sequencerDelayer, context, l1Client, rollup, constants, logger, monitor } = test); - ({ L1_BLOCK_TIME_IN_S, L2_SLOT_DURATION_IN_S } = test); + ({ context, l1Client, rollup, constants, logger, monitor } = test); + ({ L2_SLOT_DURATION_IN_S } = test); }); afterEach(async () => { @@ -56,13 +58,14 @@ describe('e2e_epochs/epochs_proof_fails', () => { }); it('does not allow submitting proof after epoch end', async () => { - // Here we cause a re-org by not publishing the proof for epoch 0 until after the end of epoch 1 - // The proof will be rejected and a re-org will take place - - // Ensure that there was at least one checkpoint mined in epoch 0, otherwise this test fails, since it - // relies on the proof for epoch zero not landing in time, which will never happen if there is - // nothing to prove on epoch zero. We need to wait for the checkpoint L1 tx to be mined, not just - // for the block to appear in the node's world state, since the propose tx may still be in-flight. + // Here we cause a re-org by not publishing the proof for epoch 0 until after the end of epoch 1. + // The proof will be rejected and a re-org will take place via the next post-deadline propose tx. + const publishedEvents: Parameters[0][] = []; + test.context.sequencer!.getSequencer().on('checkpoint-published', args => publishedEvents.push(args)); + + // Ensure that there was at least one checkpoint mined in epoch 0, otherwise this test fails, since + // it relies on the proof for epoch zero not landing in time, which will never happen if there is + // nothing to prove on epoch zero. await test.waitUntilCheckpointNumber(CheckpointNumber(1)); const firstCheckpoint = await rollup.getCheckpoint(CheckpointNumber(1)); const firstCheckpointEpoch = getEpochAtSlot(firstCheckpoint.slotNumber, test.constants); @@ -75,38 +78,42 @@ describe('e2e_epochs/epochs_proof_fails', () => { // Get the prover delayer from the newly created prover node proverDelayer = proverNode.getProverNode()!.getDelayer()!; - // Hold off prover tx until end epoch 1 + // Hold off the prover tx until epoch 2 starts (i.e. past the proof submission deadline) const [epoch2Start] = getTimestampRangeForEpoch(EpochNumber(2), constants); proverDelayer.pauseNextTxUntilTimestamp(epoch2Start); - logger.info(`Delayed prover tx until epoch 2 starts at ${epoch2Start}`); + logger.warn(`Delayed prover tx until epoch 2 starts at ${epoch2Start}`); - // Wait until the start of epoch 1 and grab the checkpoint number + // Wait until the start of epoch 1 and capture the checkpoint number before the rollback await test.waitUntilEpochStarts(EpochNumber(1)); - const checkpointNumberAtEndOfEpoch0 = await rollup.getCheckpointNumber(); - logger.info(`Starting epoch 1 after checkpoint ${checkpointNumberAtEndOfEpoch0}`); - - // Wait until the last checkpoint of epoch 1 is published and then hold off the sequencer. - await test.waitUntilCheckpointNumber( - CheckpointNumber(checkpointNumberAtEndOfEpoch0 + test.epochDuration), - test.L2_SLOT_DURATION_IN_S * (test.epochDuration + 4), + const checkpointBeforeRollback = await rollup.getCheckpointNumber(); + logger.warn(`Starting epoch 1 after checkpoint ${checkpointBeforeRollback}`); + expect(checkpointBeforeRollback).toBeGreaterThan(CheckpointNumber(1)); + + // Wait for the rollback to land via natural sequencer activity in epoch 2. We poll the + // checkpoint number rather than a fixed timestamp because the exact slot that triggers the + // prune depends on poll timing (see comment above). + await test.waitUntilEpochStarts(EpochNumber(2)); + await retryUntil( + async () => (await rollup.getCheckpointNumber()) < checkpointBeforeRollback, + 'rollup rolled back', + L2_SLOT_DURATION_IN_S * 4, + 0.2, ); - sequencerDelayer.pauseNextTxUntilTimestamp(epoch2Start + BigInt(L1_BLOCK_TIME_IN_S)); - // Next sequencer to publish a block should trigger a rollback to block 1 - await waitUntilL1Timestamp(l1Client, epoch2Start + BigInt(L1_BLOCK_TIME_IN_S)); - expect(await rollup.getCheckpointNumber()).toEqual(CheckpointNumber(1)); - expect(await rollup.getSlotNumber()).toEqual(SlotNumber(2 * test.epochDuration)); - - // The prover tx should have been rejected, and mined strictly before the one that triggered the rollback + // The prover tx should have been rejected as it was submitted past the deadline const lastProverTxHash = proverDelayer.getSentTxHashes().at(-1); + expect(lastProverTxHash).toBeDefined(); const lastProverTxReceipt = await l1Client.getTransactionReceipt({ hash: lastProverTxHash! }); expect(lastProverTxReceipt.status).toEqual('reverted'); - const lastL2BlockTxHash = sequencerDelayer.getSentTxHashes().at(-1); - const lastL2BlockTxReceipt = await l1Client.getTransactionReceipt({ hash: lastL2BlockTxHash! }); - expect(lastL2BlockTxReceipt.status).toEqual('success'); - expect(lastL2BlockTxReceipt.blockNumber).toBeGreaterThan(lastProverTxReceipt!.blockNumber); - logger.info(`Test succeeded`); + // The post-rollback chain tip should be in epoch 2 (the rollback-triggering propose was made + // during epoch 2, after the deadline) + const checkpointAfterRollback = await rollup.getCheckpointNumber(); + expect(checkpointAfterRollback).toBeLessThan(checkpointBeforeRollback); + const latestCheckpoint = await rollup.getCheckpoint(checkpointAfterRollback); + expect(getEpochAtSlot(latestCheckpoint.slotNumber, test.constants)).toEqual(EpochNumber(2)); + + logger.warn(`Test succeeded`); }); it('aborts proving if end of next epoch is reached', async () => { @@ -149,17 +156,17 @@ describe('e2e_epochs/epochs_proof_fails', () => { context.proverNode = proverNode; await test.waitUntilEpochStarts(1); - logger.info(`Starting epoch 1`); + logger.warn(`Starting epoch 1`); const proverTxCount = proverDelayer.getSentTxHashes().length; await test.waitUntilEpochStarts(2); - logger.info(`Starting epoch 2`); + logger.warn(`Starting epoch 2`); // No proof for epoch zero should have landed during epoch one expect(monitor.provenCheckpointNumber).toEqual(CheckpointNumber(0)); // Wait until the prover job finalizes (and a bit more) and check that it aborted and never attempted to submit a tx - logger.info(`Awaiting finalize epoch`); + logger.warn(`Awaiting finalize epoch`); await finalizeEpochPromise.promise; await sleep(1000); expect(proverDelayer.getSentTxHashes().length - proverTxCount).toEqual(0); From c763be73c1701ac6f690486c7e93f1cf115d0f43 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Thu, 7 May 2026 07:04:22 -0300 Subject: [PATCH 14/55] fix(archiver): restore pending block height metric under pipelining (#22994) ## Motivation The `aztec.archiver.block_height` series with no status attribute (rendered as the "Pending chain" line on the network, prover, and fisherman Grafana dashboards) stopped being published a couple of weeks ago. With pipelining enabled every checkpoint arriving from L1 already has its blocks in the proposed store, so the L1 synchronizer always took the new promotion fast path introduced in #22716, leaving `checkpointsToAdd` empty and skipping the metric call. ## Approach Record the checkpointed block-height metrics across all valid checkpoints in the batch instead of only the ones routed through `addCheckpoints`, so the promoted checkpoint contributes too. The duration is averaged over the full batch since `addCheckpoints` performs the work for both paths in a single transaction. ## Changes - **archiver (`l1_synchronizer.ts`)**: Move the `processNewCheckpointedBlocks` call to use `validCheckpoints` rather than `checkpointsToAdd`, restoring the empty-status `block_height`, `checkpoint_height`, `sync_block_count`, and `sync_per_checkpoint` series under pipelining. --------- Co-authored-by: Alex Gherghisan --- yarn-project/archiver/src/modules/instrumentation.ts | 2 +- yarn-project/archiver/src/modules/l1_synchronizer.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn-project/archiver/src/modules/instrumentation.ts b/yarn-project/archiver/src/modules/instrumentation.ts index f3d4d4a03b41..4e031872ca35 100644 --- a/yarn-project/archiver/src/modules/instrumentation.ts +++ b/yarn-project/archiver/src/modules/instrumentation.ts @@ -136,7 +136,7 @@ export class ArchiverInstrumentation { } this.syncDurationPerCheckpoint.record(Math.ceil(syncTimePerCheckpoint)); - this.blockHeight.record(Math.max(...blocks.map(b => b.number))); + this.blockHeight.record(Math.max(...blocks.map(b => b.number)), { [Attributes.STATUS]: 'checkpointed' }); this.checkpointHeight.record(Math.max(...blocks.map(b => b.checkpointNumber))); this.syncBlockCount.add(blocks.length); } diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index f31f08cd0658..9ef83634d899 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -973,10 +973,10 @@ export class ArchiverL1Synchronizer implements Traceable { ), ); - if (checkpointsToAdd.length > 0) { + if (validCheckpoints.length > 0) { this.instrumentation.processNewCheckpointedBlocks( - processDuration / checkpointsToAdd.length, - checkpointsToAdd.flatMap(c => c.checkpoint.blocks), + processDuration / validCheckpoints.length, + validCheckpoints.flatMap(c => c.checkpoint.blocks), ); } From edd4560d338a78bb533b358a01c7632163e0b9cb Mon Sep 17 00:00:00 2001 From: Facundo Date: Thu, 7 May 2026 09:50:56 -0300 Subject: [PATCH 15/55] chore(p2p): remove skipped validation result option (#23034) It was only there for historical reasons and no prod validator could return "skipped". See https://github.com/AztecProtocol/aztec-packages/pull/22118 . --- .../aggregate_tx_validator.test.ts | 26 +++---------------- .../tx_validator/aggregate_tx_validator.ts | 15 +++-------- .../public_processor/public_processor.ts | 5 ---- .../stdlib/src/tx/validator/tx_validator.ts | 6 +---- 4 files changed, 7 insertions(+), 45 deletions(-) diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.test.ts b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.test.ts index 999027fb274a..4c04c92e94b6 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.test.ts @@ -7,8 +7,8 @@ describe('AggregateTxValidator', () => { it('allows txs that pass all validation', async () => { const txs = await Promise.all([mockTx(0), mockTx(1), mockTx(2), mockTx(3), mockTx(4)]); const agg = new AggregateTxValidator( - new TxDenyList([txs[0].getTxHash(), txs[1].getTxHash(), txs[4].getTxHash()], []), - new TxDenyList([txs[2].getTxHash(), txs[4].getTxHash()], []), + new TxDenyList([txs[0].getTxHash(), txs[1].getTxHash(), txs[4].getTxHash()]), + new TxDenyList([txs[2].getTxHash(), txs[4].getTxHash()]), ); await expect(agg.validateTx(txs[0])).resolves.toEqual({ result: 'invalid', reason: ['Denied'] }); @@ -18,35 +18,15 @@ describe('AggregateTxValidator', () => { await expect(agg.validateTx(txs[4])).resolves.toEqual({ result: 'invalid', reason: ['Denied', 'Denied'] }); }); - it('aggregate skipped txs ', async () => { - const txs = await Promise.all([mockTx(0), mockTx(1), mockTx(2), mockTx(3), mockTx(4)]); - const agg = new AggregateTxValidator( - new TxDenyList([txs[0].getTxHash()], []), - new TxDenyList([txs[4].getTxHash()], [txs[1].getTxHash(), txs[2].getTxHash()]), - new TxDenyList([], [txs[4].getTxHash()]), - ); - - await expect(agg.validateTx(txs[0])).resolves.toEqual({ result: 'invalid', reason: ['Denied'] }); - await expect(agg.validateTx(txs[1])).resolves.toEqual({ result: 'skipped', reason: ['Skipped'] }); - await expect(agg.validateTx(txs[2])).resolves.toEqual({ result: 'skipped', reason: ['Skipped'] }); - await expect(agg.validateTx(txs[3])).resolves.toEqual({ result: 'valid' }); - await expect(agg.validateTx(txs[4])).resolves.toEqual({ result: 'invalid', reason: ['Denied', 'Skipped'] }); - }); - class TxDenyList implements TxValidator { denyList: Set; - skippedList: Set; - constructor(deniedTxHashes: TxHash[], skippedTxHashes: TxHash[]) { + constructor(deniedTxHashes: TxHash[]) { this.denyList = new Set(deniedTxHashes.map(hash => hash.toString())); - this.skippedList = new Set(skippedTxHashes.map(hash => hash.toString())); } validateTx(tx: AnyTx): Promise { const txHash = 'txHash' in tx ? tx.txHash : tx.hash; - if (this.skippedList.has(txHash.toString())) { - return Promise.resolve({ result: 'skipped', reason: ['Skipped'] }); - } if (this.denyList.has(txHash.toString())) { return Promise.resolve({ result: 'invalid', reason: ['Denied'] }); } diff --git a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts index 8b81492ef5d3..3c538d2506a1 100644 --- a/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts +++ b/yarn-project/p2p/src/msg_validators/tx_validator/aggregate_tx_validator.ts @@ -11,22 +11,13 @@ export class AggregateTxValidator implements TxValidator { } async validateTx(tx: T): Promise { - const aggregate: { result: string; reason?: string[] } = { result: 'valid', reason: [] }; + const reasons: string[] = []; for (const validator of this.validators) { const result = await validator.validateTx(tx); if (result.result === 'invalid') { - aggregate.result = 'invalid'; - aggregate.reason!.push(...result.reason); - } else if (result.result === 'skipped') { - if (aggregate.result === 'valid') { - aggregate.result = 'skipped'; - } - aggregate.reason!.push(...result.reason); + reasons.push(...result.reason); } } - if (aggregate.result === 'valid') { - delete aggregate.reason; - } - return aggregate as TxValidationResult; + return reasons.length > 0 ? { result: 'invalid', reason: reasons } : { result: 'valid' }; } } diff --git a/yarn-project/simulator/src/public/public_processor/public_processor.ts b/yarn-project/simulator/src/public/public_processor/public_processor.ts index f1922dab36cd..adb4d80ec5f1 100644 --- a/yarn-project/simulator/src/public/public_processor/public_processor.ts +++ b/yarn-project/simulator/src/public/public_processor/public_processor.ts @@ -226,11 +226,6 @@ export class PublicProcessor implements Traceable { failed.push({ tx, error: new Error(`Tx failed preprocess validation: ${reason}`) }); returns.push(new NestedProcessReturnValues([])); continue; - } else if (result.result === 'skipped') { - const reason = result.reason.join(', '); - this.log.debug(`Skipping tx ${txHash.toString()} due to pre-process validation: ${reason}`); - returns.push(new NestedProcessReturnValues([])); - continue; } else { this.log.trace(`Tx ${txHash.toString()} is valid before processing.`); } diff --git a/yarn-project/stdlib/src/tx/validator/tx_validator.ts b/yarn-project/stdlib/src/tx/validator/tx_validator.ts index 5851591004f1..a4bd0104260d 100644 --- a/yarn-project/stdlib/src/tx/validator/tx_validator.ts +++ b/yarn-project/stdlib/src/tx/validator/tx_validator.ts @@ -15,10 +15,7 @@ export function hasPublicCalls(tx: AnyTx): boolean { return tx.data.numberOfPublicCallRequests() > 0; } -export type TxValidationResult = - | { result: 'valid' } - | { result: 'invalid'; reason: string[] } - | { result: 'skipped'; reason: string[] }; +export type TxValidationResult = { result: 'valid' } | { result: 'invalid'; reason: string[] }; export interface TxValidator { validateTx(tx: T): Promise; @@ -28,6 +25,5 @@ export const TxValidationResultSchema = zodFor()( z.discriminatedUnion('result', [ z.object({ result: z.literal('valid') }), z.object({ result: z.literal('invalid'), reason: z.array(z.string()) }), - z.object({ result: z.literal('skipped'), reason: z.array(z.string()) }), ]), ); From 47c0755d52d40c41806a64de10fe2ce62fd4d0f4 Mon Sep 17 00:00:00 2001 From: Facundo Date: Thu, 7 May 2026 10:17:40 -0300 Subject: [PATCH 16/55] refactor(p2p)!: remove slow tx collection flow (#22878) Given the current block building, validation and proving architecture, it is expected that nodes will always have to request TXs via the fast flow. This PR removes the slow flow and makes handling of mined L2 blocks use the fast flow. Closes https://linear.app/aztec-labs/issue/A-1012/tx-collection-remove-slow-flow . --- yarn-project/foundation/src/config/env_var.ts | 10 +- .../p2p/src/client/p2p_client.test.ts | 6 +- yarn-project/p2p/src/client/p2p_client.ts | 9 +- ...nt.integration_message_propagation.test.ts | 2 +- yarn-project/p2p/src/config.ts | 8 + .../batch-tx-requester/batch_tx_requester.ts | 2 +- .../p2p/src/services/tx_collection/config.ts | 70 +---- .../tx_collection/fast_tx_collection.ts | 7 + .../services/tx_collection/instrumentation.ts | 8 +- .../tx_collection/slow_tx_collection.ts | 266 ------------------ .../tx_collection/tx_collection.test.ts | 247 +--------------- .../services/tx_collection/tx_collection.ts | 140 ++------- .../tx_collection/tx_collection_sink.ts | 4 +- 13 files changed, 58 insertions(+), 721 deletions(-) delete mode 100644 yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts diff --git a/yarn-project/foundation/src/config/env_var.ts b/yarn-project/foundation/src/config/env_var.ts index 4eb5634ea0a4..37f34baf21d0 100644 --- a/yarn-project/foundation/src/config/env_var.ts +++ b/yarn-project/foundation/src/config/env_var.ts @@ -157,6 +157,7 @@ export type EnvVar = | 'P2P_DROP_TX_CHANCE' | 'P2P_TX_POOL_DELETE_TXS_AFTER_REORG' | 'P2P_MIN_TX_POOL_AGE_MS' + | 'P2P_MISSING_TX_COLLECTION_DEADLINE_MS' | 'P2P_RPC_PRICE_BUMP_PERCENTAGE' | 'DEBUG_P2P_INSTRUMENT_MESSAGES' | 'PEER_ID_PRIVATE_KEY' @@ -260,25 +261,16 @@ export type EnvVar = | 'SPONSORED_FPC' | 'PREFUND_ADDRESSES' | 'TX_COLLECTION_FAST_NODES_TIMEOUT_BEFORE_REQ_RESP_MS' - | 'TX_COLLECTION_SLOW_NODES_INTERVAL_MS' - | 'TX_COLLECTION_SLOW_REQ_RESP_INTERVAL_MS' - | 'TX_COLLECTION_SLOW_REQ_RESP_TIMEOUT_MS' - | 'TX_COLLECTION_RECONCILE_INTERVAL_MS' - | 'TX_COLLECTION_DISABLE_SLOW_DURING_FAST_REQUESTS' | 'TX_COLLECTION_FAST_NODE_INTERVAL_MS' | 'TX_COLLECTION_FAST_MAX_PARALLEL_REQUESTS_PER_NODE' | 'TX_COLLECTION_NODE_RPC_MAX_BATCH_SIZE' | 'TX_COLLECTION_NODE_RPC_URLS' | 'TX_COLLECTION_MISSING_TXS_COLLECTOR_TYPE' | 'TX_COLLECTION_FILE_STORE_URLS' - | 'TX_COLLECTION_FILE_STORE_SLOW_DELAY_MS' | 'TX_COLLECTION_FILE_STORE_FAST_DELAY_MS' | 'TX_COLLECTION_FILE_STORE_FAST_WORKER_COUNT' - | 'TX_COLLECTION_FILE_STORE_SLOW_WORKER_COUNT' | 'TX_COLLECTION_FILE_STORE_FAST_BACKOFF_BASE_MS' - | 'TX_COLLECTION_FILE_STORE_SLOW_BACKOFF_BASE_MS' | 'TX_COLLECTION_FILE_STORE_FAST_BACKOFF_MAX_MS' - | 'TX_COLLECTION_FILE_STORE_SLOW_BACKOFF_MAX_MS' | 'TX_FILE_STORE_URL' | 'TX_FILE_STORE_UPLOAD_CONCURRENCY' | 'TX_FILE_STORE_MAX_QUEUE_SIZE' diff --git a/yarn-project/p2p/src/client/p2p_client.test.ts b/yarn-project/p2p/src/client/p2p_client.test.ts index 12b921ca5e1b..86df6d146a25 100644 --- a/yarn-project/p2p/src/client/p2p_client.test.ts +++ b/yarn-project/p2p/src/client/p2p_client.test.ts @@ -454,11 +454,11 @@ describe('P2P Client', () => { txPool.hasTxs.mockResolvedValue([true, false, true]); blockSource.addProposedBlocks([block]); - txCollection.startCollecting.mockClear(); + txCollection.collectFastForBlock.mockClear(); await client.sync(); - expect(txCollection.startCollecting).toHaveBeenCalledTimes(1); - const [actualBlock, actualTxHashes] = txCollection.startCollecting.mock.calls[0]; + expect(txCollection.collectFastForBlock).toHaveBeenCalledTimes(1); + const [actualBlock, actualTxHashes] = txCollection.collectFastForBlock.mock.calls[0]; expect(actualBlock.number).toEqual(block.number); expect(await actualBlock.hash()).toEqual(await block.hash()); expect(actualTxHashes).toEqual([block.body.txEffects[1].txHash]); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 914e08a6b08f..06386b609cde 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -625,13 +625,13 @@ export class P2PClient extends WithTracer implements P2P { await this.handleMinedBlocks(blocks); await this.maybeCallPrepareForSlot(); - await this.startCollectingMissingTxs(blocks); + await this.collectingMissingTxs(blocks); const lastBlock = blocks.at(-1)!; await this.synchedLatestSlot.set(BigInt(lastBlock.header.getSlot())); } - /** Request txs for unproven blocks so the prover node has more chances to get them. */ - private async startCollectingMissingTxs(blocks: L2Block[]): Promise { + /** Request txs for unproven blocks so the prover node can prove. */ + private async collectingMissingTxs(blocks: L2Block[]): Promise { try { // TODO(#15435): If the archiver has lagged behind L1, the reported proven block number may // be much lower than the actual one, and it does not update until the pending chain is @@ -651,7 +651,8 @@ export class P2PClient extends WithTracer implements P2P { `Starting collection of ${missingTxHashes.length} missing txs for unproven mined block ${block.number}`, { missingTxHashes, blockNumber: block.number, blockHash: await block.hash().then(h => h.toString()) }, ); - this.txCollection.startCollecting(block, missingTxHashes); + const deadline = new Date(this._dateProvider.now() + this.config.p2pMissingTxCollectionDeadlineMs); + await this.txCollection.collectFastForBlock(block, missingTxHashes, { deadline }); } } } catch (err) { diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts index ca9ad2bbd2ad..8b69eb5a7517 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_message_propagation.test.ts @@ -79,7 +79,7 @@ describe('p2p client integration message propagation', () => { worldState.getSnapshot.mockReturnValue(mockMerkleTreeOps); txPool.isEmpty.mockResolvedValue(true); - txPool.hasTxs.mockResolvedValue([]); + txPool.hasTxs.mockImplementation(txHashes => Promise.resolve(txHashes.map(() => true))); txPool.addPendingTxs.mockImplementation((txs: Tx[]) => Promise.resolve({ accepted: txs.map(tx => tx.getTxHash()), diff --git a/yarn-project/p2p/src/config.ts b/yarn-project/p2p/src/config.ts index c675e73f5284..e9b2e76e950c 100644 --- a/yarn-project/p2p/src/config.ts +++ b/yarn-project/p2p/src/config.ts @@ -216,6 +216,9 @@ export interface P2PConfig /** Minimum age (ms) a transaction must have been in the pool before it's eligible for block building. */ minTxPoolAgeMs: number; + /** Deadline in ms used when collecting missing txs for unproven mined blocks. */ + p2pMissingTxCollectionDeadlineMs: number; + /** Minimum percentage fee increase required to replace an existing tx via RPC (0 = no bump). */ priceBumpPercentage: bigint; @@ -532,6 +535,11 @@ export const p2pConfigMappings: ConfigMappingsType = { description: 'Minimum age (ms) a transaction must have been in the pool before it is eligible for block building.', ...numberConfigHelper(2_000), }, + p2pMissingTxCollectionDeadlineMs: { + env: 'P2P_MISSING_TX_COLLECTION_DEADLINE_MS', + description: 'Deadline in ms used when collecting missing txs for unproven mined blocks.', + ...numberConfigHelper(72_000), + }, priceBumpPercentage: { env: 'P2P_RPC_PRICE_BUMP_PERCENTAGE', description: diff --git a/yarn-project/p2p/src/services/reqresp/batch-tx-requester/batch_tx_requester.ts b/yarn-project/p2p/src/services/reqresp/batch-tx-requester/batch_tx_requester.ts index 08d2c613a569..77c7f99e2c81 100644 --- a/yarn-project/p2p/src/services/reqresp/batch-tx-requester/batch_tx_requester.ts +++ b/yarn-project/p2p/src/services/reqresp/batch-tx-requester/batch_tx_requester.ts @@ -97,7 +97,7 @@ export class BatchTxRequester { } /* - * Fetches all missing transactions and yields them one by one + * Fetches all missing transactions and yields them one by one * */ public async *run(): AsyncGenerator { try { diff --git a/yarn-project/p2p/src/services/tx_collection/config.ts b/yarn-project/p2p/src/services/tx_collection/config.ts index f86950af78e8..bc4d6c77aab3 100644 --- a/yarn-project/p2p/src/services/tx_collection/config.ts +++ b/yarn-project/p2p/src/services/tx_collection/config.ts @@ -1,9 +1,4 @@ -import { - type ConfigMappingsType, - booleanConfigHelper, - enumConfigHelper, - numberConfigHelper, -} from '@aztec/foundation/config'; +import { type ConfigMappingsType, enumConfigHelper, numberConfigHelper } from '@aztec/foundation/config'; import { MAX_RPC_TXS_LEN } from '@aztec/stdlib/interfaces/api-limit'; export type MissingTxsCollectorType = 'new' | 'old'; @@ -11,16 +6,6 @@ export type MissingTxsCollectorType = 'new' | 'old'; export type TxCollectionConfig = { /** How long to wait before starting reqresp for fast collection */ txCollectionFastNodesTimeoutBeforeReqRespMs: number; - /** How often to collect from configured nodes */ - txCollectionSlowNodesIntervalMs: number; - /** How ofter to collect from peers */ - txCollectionSlowReqRespIntervalMs: number; - /** How long to wait for a reqresp response during slow collection */ - txCollectionSlowReqRespTimeoutMs: number; - /** How often to reconcile found txs with the tx pool */ - txCollectionReconcileIntervalMs: number; - /** Whether to disable the slow collection loop if we are dealing with any immediate requests */ - txCollectionDisableSlowDuringFastRequests: boolean; /** How many ms to wait between retried request to a node via RPC during fast collection */ txCollectionFastNodeIntervalMs: number; /** A comma-separated list of Aztec node RPC URLs to use for tx collection */ @@ -33,22 +18,14 @@ export type TxCollectionConfig = { txCollectionMissingTxsCollectorType: MissingTxsCollectorType; /** A comma-separated list of file store URLs (s3://, gs://, file://, http://) for tx collection */ txCollectionFileStoreUrls: string[]; - /** Delay in ms before file store collection starts after slow collection is triggered */ - txCollectionFileStoreSlowDelayMs: number; /** Delay in ms before file store collection starts after fast collection is triggered */ txCollectionFileStoreFastDelayMs: number; /** Number of concurrent workers for fast file store collection */ txCollectionFileStoreFastWorkerCount: number; - /** Number of concurrent workers for slow file store collection */ - txCollectionFileStoreSlowWorkerCount: number; /** Base backoff time in ms for fast file store collection retries */ txCollectionFileStoreFastBackoffBaseMs: number; - /** Base backoff time in ms for slow file store collection retries */ - txCollectionFileStoreSlowBackoffBaseMs: number; /** Max backoff time in ms for fast file store collection retries */ txCollectionFileStoreFastBackoffMaxMs: number; - /** Max backoff time in ms for slow file store collection retries */ - txCollectionFileStoreSlowBackoffMaxMs: number; }; export const txCollectionConfigMappings: ConfigMappingsType = { @@ -57,31 +34,6 @@ export const txCollectionConfigMappings: ConfigMappingsType description: 'How long to wait before starting reqresp for fast collection', ...numberConfigHelper(200), }, - txCollectionSlowNodesIntervalMs: { - env: 'TX_COLLECTION_SLOW_NODES_INTERVAL_MS', - description: 'How often to collect from configured nodes in the slow collection loop', - ...numberConfigHelper(12_000), - }, - txCollectionSlowReqRespIntervalMs: { - env: 'TX_COLLECTION_SLOW_REQ_RESP_INTERVAL_MS', - description: 'How often to collect from peers via reqresp in the slow collection loop', - ...numberConfigHelper(12_000), - }, - txCollectionSlowReqRespTimeoutMs: { - env: 'TX_COLLECTION_SLOW_REQ_RESP_TIMEOUT_MS', - description: 'How long to wait for a reqresp response during slow collection', - ...numberConfigHelper(20_000), - }, - txCollectionReconcileIntervalMs: { - env: 'TX_COLLECTION_RECONCILE_INTERVAL_MS', - description: 'How often to reconcile found txs from the tx pool', - ...numberConfigHelper(60_000), - }, - txCollectionDisableSlowDuringFastRequests: { - env: 'TX_COLLECTION_DISABLE_SLOW_DURING_FAST_REQUESTS', - description: 'Whether to disable the slow collection loop if we are dealing with any immediate requests', - ...booleanConfigHelper(true), - }, txCollectionFastNodeIntervalMs: { env: 'TX_COLLECTION_FAST_NODE_INTERVAL_MS', description: 'How many ms to wait between retried request to a node via RPC during fast collection', @@ -123,11 +75,6 @@ export const txCollectionConfigMappings: ConfigMappingsType .filter(url => url.length > 0), defaultValue: [], }, - txCollectionFileStoreSlowDelayMs: { - env: 'TX_COLLECTION_FILE_STORE_SLOW_DELAY_MS', - description: 'Delay before file store collection starts after slow collection', - ...numberConfigHelper(24_000), - }, txCollectionFileStoreFastDelayMs: { env: 'TX_COLLECTION_FILE_STORE_FAST_DELAY_MS', description: 'Delay before file store collection starts after fast collection', @@ -138,29 +85,14 @@ export const txCollectionConfigMappings: ConfigMappingsType description: 'Number of concurrent workers for fast file store collection', ...numberConfigHelper(5), }, - txCollectionFileStoreSlowWorkerCount: { - env: 'TX_COLLECTION_FILE_STORE_SLOW_WORKER_COUNT', - description: 'Number of concurrent workers for slow file store collection', - ...numberConfigHelper(2), - }, txCollectionFileStoreFastBackoffBaseMs: { env: 'TX_COLLECTION_FILE_STORE_FAST_BACKOFF_BASE_MS', description: 'Base backoff time in ms for fast file store collection retries', ...numberConfigHelper(1_000), }, - txCollectionFileStoreSlowBackoffBaseMs: { - env: 'TX_COLLECTION_FILE_STORE_SLOW_BACKOFF_BASE_MS', - description: 'Base backoff time in ms for slow file store collection retries', - ...numberConfigHelper(5_000), - }, txCollectionFileStoreFastBackoffMaxMs: { env: 'TX_COLLECTION_FILE_STORE_FAST_BACKOFF_MAX_MS', description: 'Max backoff time in ms for fast file store collection retries', ...numberConfigHelper(5_000), }, - txCollectionFileStoreSlowBackoffMaxMs: { - env: 'TX_COLLECTION_FILE_STORE_SLOW_BACKOFF_MAX_MS', - description: 'Max backoff time in ms for slow file store collection retries', - ...numberConfigHelper(30_000), - }, }; diff --git a/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts index d9f8b9fb5cb9..fdc8c2d83ddd 100644 --- a/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/fast_tx_collection.ts @@ -335,6 +335,13 @@ export class FastTxCollection { } } + /** Returns the tx hashes that are still missing (from all requests). */ + public getMissingTxHashes(): TxHash[] { + return Array.from(this.requests.values()).flatMap(request => + Array.from(request.requestTracker.missingTxHashes).map(TxHash.fromString), + ); + } + /** * Stop collecting all txs for blocks less than or requal to the block number specified. * To be called when we no longer care about gathering txs up to a certain block, eg when they become proven or finalized. diff --git a/yarn-project/p2p/src/services/tx_collection/instrumentation.ts b/yarn-project/p2p/src/services/tx_collection/instrumentation.ts index 16068bb7045f..5cc38ab8c002 100644 --- a/yarn-project/p2p/src/services/tx_collection/instrumentation.ts +++ b/yarn-project/p2p/src/services/tx_collection/instrumentation.ts @@ -18,13 +18,7 @@ export class TxCollectionInstrumentation { const meter = client.getMeter(name); this.txsCollected = createUpDownCounterWithDefault(meter, Metrics.TX_COLLECTOR_COUNT, { - [Attributes.TX_COLLECTION_METHOD]: [ - 'fast-req-resp', - 'fast-node-rpc', - 'slow-req-resp', - 'slow-node-rpc', - 'file-store', - ], + [Attributes.TX_COLLECTION_METHOD]: ['fast-req-resp', 'fast-node-rpc', 'file-store'], }); this.collectionDurationPerTx = meter.createHistogram(Metrics.TX_COLLECTOR_DURATION_PER_TX); diff --git a/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts deleted file mode 100644 index d2d2f3603e71..000000000000 --- a/yarn-project/p2p/src/services/tx_collection/slow_tx_collection.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { chunk } from '@aztec/foundation/collection'; -import { type Logger, createLogger } from '@aztec/foundation/log'; -import { boundInclusive } from '@aztec/foundation/number'; -import { RunningPromise } from '@aztec/foundation/promise'; -import { DateProvider } from '@aztec/foundation/timer'; -import type { L2Block } from '@aztec/stdlib/block'; -import { type L1RollupConstants, getEpochAtSlot, getTimestampRangeForEpoch } from '@aztec/stdlib/epoch-helpers'; -import { type Tx, TxHash } from '@aztec/stdlib/tx'; - -import { type ReqRespInterface, ReqRespSubProtocol, chunkTxHashesRequest } from '../reqresp/index.js'; -import type { TxCollectionConfig } from './config.js'; -import type { FastTxCollection } from './fast_tx_collection.js'; -import type { MissingTxInfo } from './tx_collection.js'; -import type { TxCollectionSink } from './tx_collection_sink.js'; -import type { TxSource } from './tx_source.js'; - -export class SlowTxCollection { - /** Map from tx hash to missing tx info to collect via slow loop */ - private missingTxs: Map = new Map(); - - /** One slow collection loop for each node tx source we have */ - private nodesSlowCollectionLoops: RunningPromise[]; - - /** Slow collection loop for reqresp which collects txs from peers */ - private reqrespSlowCollectionLoop: RunningPromise; - - constructor( - private reqResp: Pick, - private nodes: TxSource[], - private txCollectionSink: TxCollectionSink, - private fastCollection: Pick, - private constants: L1RollupConstants, - private config: TxCollectionConfig, - private dateProvider: DateProvider = new DateProvider(), - private log: Logger = createLogger('p2p:tx_collection_service'), - ) { - this.nodesSlowCollectionLoops = this.nodes.map( - node => - new RunningPromise( - () => this.collectMissingTxsFromNode(node), - this.log, - this.config.txCollectionSlowNodesIntervalMs, - ), - ); - - this.reqrespSlowCollectionLoop = new RunningPromise( - () => this.collectMissingTxsViaReqResp(), - this.log, - this.config.txCollectionSlowReqRespIntervalMs, - ); - } - - public getMissingTxHashes() { - return Array.from(this.missingTxs.keys()).map(TxHash.fromString); - } - - public start() { - this.nodesSlowCollectionLoops.forEach(loop => loop.start()); - this.reqrespSlowCollectionLoop.start(); - } - - public async stop() { - await Promise.all([ - this.reqrespSlowCollectionLoop.stop(), - ...this.nodesSlowCollectionLoops.map(loop => loop.stop()), - ]); - } - - public async trigger() { - await Promise.all([ - this.reqrespSlowCollectionLoop.trigger(), - ...this.nodesSlowCollectionLoops.map(loop => loop.trigger()), - ]); - } - - /** Starts collecting the given tx hashes for the given L2Block in the slow loop */ - public startCollecting(block: L2Block, txHashes: TxHash[]) { - const slot = block.header.getSlot(); - const deadline = this.getDeadlineForSlot(slot); - if (+deadline < this.dateProvider.now()) { - this.log.debug(`Skipping collection of txs for block ${block.number} at slot ${slot} as it is already expired`, { - blockNumber: block.number, - slot: slot.toString(), - txHashes: txHashes.map(txHash => txHash.toString()), - deadline: +deadline, - now: this.dateProvider.now(), - }); - } - - for (const txHash of txHashes) { - this.missingTxs.set(txHash.toString(), { - block, - blockNumber: block.number, - deadline: this.getDeadlineForSlot(block.header.getSlot()), - readyForReqResp: this.nodes.length === 0, // If we have no nodes, we can start reqresp immediately - }); - } - } - - /** Entrypoint for the node slow collection loop */ - private async collectMissingTxsFromNode(node: TxSource) { - // If we have any fast requests, we skip the slow collection for this node if configured to do so - const requests = this.fastCollection.getFastCollectionRequests(); - if (this.config.txCollectionDisableSlowDuringFastRequests && requests.size > 0) { - this.log.trace(`Skipping node slow collection due to active fast requests`); - return; - } - - // Gather all missing txs that are not in fast collection and request them from the node - const missingTxs = this.getMissingTxsForSlowCollection(); - if (missingTxs.length === 0) { - return; - } - - // Group by block so we pass the correct mined context to the sink - for (const entries of this.groupByBlock(missingTxs)) { - const block = entries[0][1].block; - const txHashes = entries.map(([txHash]) => TxHash.fromString(txHash)); - for (const batch of chunk(txHashes, this.config.txCollectionNodeRpcMaxBatchSize)) { - await this.txCollectionSink.collect( - () => node.getTxsByHash(batch), - batch.map(h => h.toString()), - { - description: `node ${node.getInfo()}`, - node: node.getInfo(), - method: 'slow-node-rpc', - }, - { type: 'mined', block }, - ); - } - } - - // Mark every tx that is still missing as ready for reqresp. - // Note that we can just mark all requested txs as ready for reqresp, without filtering out the ones - // we retrieved, since the ones already found will have been removed from `missingTxs`. - missingTxs.forEach(([_txHash, info]) => { - info.readyForReqResp = true; - }); - } - - /** Entrypoint for the reqresp slow collection loop */ - private async collectMissingTxsViaReqResp() { - // If we have any fast requests, we skip the slow collection if configured to do so - const requests = this.fastCollection.getFastCollectionRequests(); - if (this.config.txCollectionDisableSlowDuringFastRequests && requests.size > 0) { - this.log.trace(`Skipping reqresp slow collection due to active fast requests`); - return; - } - - // Gather all missing txs that are not in fast collection and are ready for reqresp - // A tx is flagged as ready for reqresp if it has been requested from a node at least once - const missingTxs = this.getMissingTxsForSlowCollection({ onlyReqRespReady: true }); - if (missingTxs.length === 0) { - return; - } - - const pinnedPeer = undefined; - const timeoutMs = this.config.txCollectionSlowReqRespTimeoutMs; - const maxRetryAttempts = 3; - - // Group by block so we pass the correct mined context to the sink - for (const entries of this.groupByBlock(missingTxs)) { - const block = entries[0][1].block; - const txHashes = entries.map(([txHash]) => TxHash.fromString(txHash)); - const maxPeers = boundInclusive(Math.ceil(txHashes.length / 3), 4, 16); - await this.txCollectionSink.collect( - async () => { - const txs = await this.reqResp.sendBatchRequest( - ReqRespSubProtocol.TX, - chunkTxHashesRequest(txHashes), - pinnedPeer, - timeoutMs, - maxPeers, - maxRetryAttempts, - ); - return { validTxs: txs.flat(), invalidTxHashes: [] }; - }, - txHashes.map(h => h.toString()), - { description: 'slow reqresp', timeoutMs, method: 'slow-req-resp' }, - { type: 'mined', block }, - ); - } - } - - /** Retrieves all missing txs for the slow collection process. This is, all missing txs that are not part of a fast request. */ - private getMissingTxsForSlowCollection(opts: { onlyReqRespReady?: boolean } = {}): [string, MissingTxInfo][] { - // Remove expired txs from missingTxs - const now = this.dateProvider.now(); - const expiredTxs = Array.from(this.missingTxs.entries()).filter(([_, value]) => +value.deadline < now); - expiredTxs.forEach(([txHash]) => this.missingTxs.delete(txHash)); - - // Gather all txs that are marked for fast collection, we do not want to collect them via slow collection. - // There are some situations where a tx is in both slow and fast collection, for example when a prover node - // is fast-collecting missing txs for proving an epoch, and still has the tx in the slow collection loops - // from mined unproven blocks it has seen in the past. - const fastRequests = this.fastCollection.getFastCollectionRequests(); - const fastCollectionTxs: Set = new Set( - fastRequests.values().flatMap(r => Array.from(r.requestTracker.missingTxHashes)), - ); - - // Return all missing txs that are not in fastCollectionTxs and are ready for reqresp if requested - return Array.from(this.missingTxs.entries()) - .filter(([txHash, _]) => !fastCollectionTxs.has(txHash)) - .filter(([_, value]) => !opts.onlyReqRespReady || value.readyForReqResp); - } - - /** Stop collecting the given txs since we have found them. Called whenever tx pool emits a tx-added event. */ - public foundTxs(txs: Tx[]): void { - for (const txHash of txs.map(tx => tx.txHash)) { - this.missingTxs.delete(txHash.toString()); - } - } - - /** - * Stop collecting all txs for blocks less than or requal to the block number specified. - * To be called when we no longer care about gathering txs up to a certain block, eg when they become proven or finalized. - */ - public stopCollectingForBlocksUpTo(blockNumber: BlockNumber): void { - for (const [txHash, info] of this.missingTxs.entries()) { - if (info.blockNumber <= blockNumber) { - this.missingTxs.delete(txHash); - } - } - } - - /** - * Stop collecting all txs for blocks greater than the block number specified. - * To be called when there is a chain prune and previously mined txs are no longer relevant. - */ - public stopCollectingForBlocksAfter(blockNumber: BlockNumber): void { - for (const [txHash, info] of this.missingTxs.entries()) { - if (info.blockNumber > blockNumber) { - this.missingTxs.delete(txHash); - } - } - } - - /** Groups missing tx entries by block number. */ - private groupByBlock(entries: [string, MissingTxInfo][]): [string, MissingTxInfo][][] { - const groups = new Map(); - for (const entry of entries) { - const bn = +entry[1].blockNumber; - let group = groups.get(bn); - if (!group) { - group = []; - groups.set(bn, group); - } - group.push(entry); - } - return [...groups.values()]; - } - - /** Computes the proof submission deadline for a given slot, a tx mined in this slot is no longer interesting after this deadline */ - private getDeadlineForSlot(slotNumber: SlotNumber): Date { - return getProofDeadlineForSlot(slotNumber, this.constants); - } -} - -/** Computes the proof submission deadline for a given slot. A tx mined in this slot is no longer interesting after this deadline. */ -export function getProofDeadlineForSlot(slotNumber: SlotNumber, constants: L1RollupConstants): Date { - const epoch = getEpochAtSlot(slotNumber, constants); - const submissionEndEpoch = EpochNumber(epoch + constants.proofSubmissionEpochs); - const submissionEndTimestamp = getTimestampRangeForEpoch(submissionEndEpoch, constants)[1]; - return new Date(Number(submissionEndTimestamp) * 1000); -} diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts index f09bdc14074c..87e36be90b8b 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection.test.ts @@ -21,7 +21,6 @@ import { ReqRespStatus } from '../reqresp/status.js'; import { type TxCollectionConfig, txCollectionConfigMappings } from './config.js'; import { FastTxCollection } from './fast_tx_collection.js'; import type { FileStoreTxSource } from './file_store_tx_source.js'; -import type { SlowTxCollection } from './slow_tx_collection.js'; import { type FastCollectionRequest, TxCollection } from './tx_collection.js'; import type { TxSource } from './tx_source.js'; @@ -141,7 +140,6 @@ describe('TxCollection', () => { txCollectionFastMaxParallelRequestsPerNode: 2, txCollectionFastNodeIntervalMs: 100, txCollectionMissingTxsCollectorType: 'old', - txCollectionFileStoreSlowDelayMs: 100, txCollectionFileStoreFastDelayMs: 100, }; @@ -158,235 +156,6 @@ describe('TxCollection', () => { await txCollection.stop(); }); - describe('slow collection', () => { - it('collects missing txs from node', async () => { - txCollection.startCollecting(block, txHashes); - - setNodeTxs(nodes[0], txs); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsMinedInPool(txs); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).not.toHaveBeenCalled(); - expect(nodes[1].getTxsByHash).not.toHaveBeenCalled(); - }); - - it('collects missing txs from multiple nodes', async () => { - txCollection.startCollecting(block, txHashes); - - setNodeTxs(nodes[0], [txs[0]]); - setNodeTxs(nodes[1], [txs[1]]); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsMinedInPool([txs[0]]); - expectTxsMinedInPool([txs[1]]); - - jest.clearAllMocks(); - setNodeTxs(nodes[0], [txs[0], txs[2]]); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - expectTxsMinedInPool([txs[2]]); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).not.toHaveBeenCalled(); - expect(nodes[1].getTxsByHash).not.toHaveBeenCalled(); - }); - - it('collects tx from nodes in batches', async () => { - txs = await Promise.all(times(8, () => makeTx())); - txHashes = txs.map(tx => tx.txHash); - txCollection.startCollecting(block, txHashes); - - setNodeTxs(nodes[0], txs); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(0, 5)); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes.slice(5, 10)); - expectTxsMinedInPool(txs.slice(0, 5)); - expectTxsMinedInPool(txs.slice(5, 10)); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).not.toHaveBeenCalled(); - expect(nodes[1].getTxsByHash).not.toHaveBeenCalled(); - }); - - it('collects missing txs via reqresp after having been requested once from nodes', async () => { - txCollection.startCollecting(block, txHashes); - - setNodeTxs(nodes[0], [txs[0]]); - setNodeTxs(nodes[1], [txs[1]]); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(reqResp.sendBatchRequest).not.toHaveBeenCalled(); - expectTxsMinedInPool([txs[0]]); - expectTxsMinedInPool([txs[1]]); - - jest.clearAllMocks(); - setReqRespTxs([txs[2]]); - await txCollection.trigger(); - expectReqRespToHaveBeenCalledWith([txHashes[2]]); - expectTxsMinedInPool([txs[2]]); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).not.toHaveBeenCalled(); - expect(nodes[1].getTxsByHash).not.toHaveBeenCalled(); - }); - - it('collects missing txs directly via reqresp if there are no nodes configured', async () => { - txCollection = new TestTxCollection(mockP2PService, [], constants, txPool, config, [], dateProvider); - txCollection.startCollecting(block, txHashes); - - setReqRespTxs([txs[0]]); - await txCollection.trigger(); - expectReqRespToHaveBeenCalledWith(txHashes); - expectTxsMinedInPool([txs[0]]); - - jest.clearAllMocks(); - setReqRespTxs([txs[1]]); - await txCollection.trigger(); - expectReqRespToHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsMinedInPool([txs[1]]); - }); - - it('rejects expired txs', async () => { - const block1 = await makeL2Block(1, 1); - const block2 = await makeL2Block(100, 100); - - txCollection.startCollecting(block1, [txHashes[0]]); - txCollection.startCollecting(block2, [txHashes[1]]); - const newTime = (Number(constants.l1GenesisTime) + constants.epochDuration * constants.slotDuration * 2) * 1000; - dateProvider.setTime(newTime); - - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); - }); - - it('does not request missing txs being collected via fast collection', async () => { - config = { ...config, txCollectionDisableSlowDuringFastRequests: false }; - txCollection = new TestTxCollection(mockP2PService, nodes, constants, txPool, config, [], dateProvider); - - const innerCollectFastPromise = promiseWithResolvers(); - jest.spyOn(txCollection.fastCollection, 'collectFast').mockImplementation(async request => { - txCollection.fastCollection.requests.add(request); - await innerCollectFastPromise.promise; - txCollection.fastCollection.requests.delete(request); - }); - - txCollection.startCollecting(block, txHashes); - void txCollection.collectFastForBlock(block, [txHashes[0]], { deadline }); - - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); - - innerCollectFastPromise.resolve(); - }); - - it('pauses slow collection if fast collection is ongoing', async () => { - config = { ...config, txCollectionDisableSlowDuringFastRequests: true }; - txCollection = new TestTxCollection(mockP2PService, nodes, constants, txPool, config, [], dateProvider); - - const innerCollectFastPromise = promiseWithResolvers(); - jest.spyOn(txCollection.fastCollection, 'collectFast').mockImplementation(async request => { - txCollection.fastCollection.requests.add(request); - await innerCollectFastPromise.promise; - txCollection.fastCollection.requests.delete(request); - }); - - txCollection.startCollecting(block, txHashes); - void txCollection.collectFastForBlock(block, [txHashes[0]], { deadline }); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).not.toHaveBeenCalled(); - - innerCollectFastPromise.resolve(); - }); - - it('stops collecting a tx when found via fast collection', async () => { - config = { ...config, txCollectionDisableSlowDuringFastRequests: true }; - txCollection = new TestTxCollection(mockP2PService, nodes, constants, txPool, config, [], dateProvider); - - setNodeTxs(nodes[0], txs); - txCollection.startCollecting(block, txHashes); - - await txCollection.collectFastForBlock(block, [txHashes[0]], { deadline }); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[0]]); - expectTxsMinedInPool([txs[0]]); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsMinedInPool([txs[1], txs[2]]); - }); - - it('stops collecting a tx when reported as found from the pool', async () => { - txCollection.startCollecting(block, txHashes); - - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - - txCollection.handleTxsAddedToPool({ txs: [txs[0]], source: 'test' }); - - jest.clearAllMocks(); - setNodeTxs(nodes[0], [txs[1]]); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1], txHashes[2]]); - expectTxsMinedInPool([txs[1]]); - }); - - it('stops collecting txs based on block number', async () => { - const blocks = await Promise.all(times(3, i => makeL2Block(i + 1))); - txCollection.startCollecting(blocks[0], [txHashes[0]]); - txCollection.startCollecting(blocks[1], [txHashes[1]]); - txCollection.startCollecting(blocks[2], [txHashes[2]]); - - await txCollection.trigger(); - // Each block's txs are requested separately since they're grouped by block - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[0]]); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - - jest.clearAllMocks(); - txCollection.stopCollectingForBlocksUpTo(BlockNumber(1)); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - - jest.clearAllMocks(); - txCollection.stopCollectingForBlocksAfter(BlockNumber(2)); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[1]]); - }); - - it('reconciles found transactions with the tx pool if not advertised via events', async () => { - txCollection.startCollecting(block, txHashes); - - setNodeTxs(nodes[0], [txs[0]]); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith(txHashes); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith(txHashes); - expectTxsMinedInPool([txs[0]]); - - jest.clearAllMocks(); - txPool.getTxsByHash.mockResolvedValue([txs[1]]); - await txCollection.trigger(); - - jest.clearAllMocks(); - await txCollection.trigger(); - expect(nodes[0].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - expect(nodes[1].getTxsByHash).toHaveBeenCalledWith([txHashes[2]]); - }); - }); - describe('fast collection', () => { it('collects txs from nodes only', async () => { setNodeTxs(nodes[0], txs); @@ -753,19 +522,21 @@ describe('TxCollection', () => { ); }); - it('collects txs from file store after slow delay', async () => { + it('collects txs from file store after configured delay', async () => { setFileStoreTxs(fileStoreSources[0], txs); await txCollection.start(); - txCollection.startCollecting(block, txHashes); + deadline = new Date(dateProvider.now() + 500); + const collectionPromise = txCollection.collectFastForBlock(block, txHashes, { deadline }); // File store should not have been called yet (delay hasn't elapsed) expect(fileStoreSources[0].getTxsByHash).not.toHaveBeenCalled(); - // Advance time past the 4s slow delay + // Advance time past the configured file store delay dateProvider.setTime(dateProvider.now() + 200); // Allow the async sleep resolution and worker processing to complete - await sleep(100); + await sleep(200); + await collectionPromise; // File store should now have been called for each tx expect(fileStoreSources[0].getTxsByHash).toHaveBeenCalled(); @@ -775,7 +546,8 @@ describe('TxCollection', () => { setFileStoreTxs(fileStoreSources[0], txs); await txCollection.start(); - txCollection.startCollecting(block, txHashes); + deadline = new Date(dateProvider.now() + 500); + const collectionPromise = txCollection.collectFastForBlock(block, txHashes, { deadline }); // Simulate all txs found via P2P before delay expires txCollection.handleTxsAddedToPool({ txs, source: 'test' }); @@ -783,6 +555,7 @@ describe('TxCollection', () => { // Now advance time past the delay dateProvider.setTime(dateProvider.now() + 200); await sleep(100); + await collectionPromise; // File store should not have downloaded any txs because they were all found const allCalls = fileStoreSources.flatMap(s => s.getTxsByHash.mock.calls); @@ -798,9 +571,7 @@ class TestFastTxCollection extends FastTxCollection { } class TestTxCollection extends TxCollection { - declare slowCollection: SlowTxCollection; declare fastCollection: TestFastTxCollection; - declare fileStoreSlowCollection: TxCollection['fileStoreSlowCollection']; declare fileStoreFastCollection: TxCollection['fileStoreFastCollection']; declare handleTxsAddedToPool: TxPoolV2Events['txs-added']; } diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection.ts index c49d0e2e4a05..9a609fb408a3 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection.ts @@ -1,7 +1,5 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; -import { compactArray } from '@aztec/foundation/collection'; import { type Logger, createLogger } from '@aztec/foundation/log'; -import { RunningPromise } from '@aztec/foundation/promise'; import { sleep } from '@aztec/foundation/sleep'; import { DateProvider } from '@aztec/foundation/timer'; import type { L2Block, L2BlockInfo } from '@aztec/stdlib/block'; @@ -20,13 +18,10 @@ import { FastTxCollection } from './fast_tx_collection.js'; import { FileStoreTxCollection } from './file_store_tx_collection.js'; import type { FileStoreTxSource } from './file_store_tx_source.js'; import type { IRequestTracker } from './request_tracker.js'; -import { SlowTxCollection, getProofDeadlineForSlot } from './slow_tx_collection.js'; import { type TxAddContext, TxCollectionSink } from './tx_collection_sink.js'; import type { TxSource } from './tx_source.js'; -export type CollectionMethod = 'fast-req-resp' | 'fast-node-rpc' | 'slow-req-resp' | 'slow-node-rpc' | 'file-store'; - -export type MissingTxInfo = { block: L2Block; blockNumber: BlockNumber; deadline: Date; readyForReqResp: boolean }; +export type CollectionMethod = 'fast-req-resp' | 'fast-node-rpc' | 'file-store'; export type FastCollectionRequestInput = | { type: 'block'; block: L2Block } @@ -38,33 +33,22 @@ export type FastCollectionRequest = FastCollectionRequestInput & { }; /** - * Coordinates tx collection from remote RPC nodes and reqresp. + * Coordinates tx collection from remote RPC nodes, reqresp, and file store. * - * The slow collection loops are used for periodically collecting missing txs from mined blocks, - * from remote RPC nodes and via reqresp. The fast collection methods are used to quickly gather - * txs, usually for attesting to block proposals or preparing to prove an epoch. The slow and fast - * collection instances both send their txs to the collection sink, which handles metrics and adds - * them to the tx pool. Whenever a tx is added to either the sink or the pool, this service is notified - * via events and notifies the slow and fast collection loops to stop collecting that tx, so that we don't - * collect the same tx multiple times. + * The fast collection methods quickly gather txs from RPC nodes and reqresp, usually for attesting + * to block proposals or preparing to prove an epoch. A delayed file-store fallback can also fetch + * txs if configured. Both paths send txs to the collection sink, which handles metrics and adds + * them to the tx pool. Whenever a tx is added to either the sink or the pool, this service is + * notified via events and stops collecting that tx across all in-flight requests. */ export class TxCollection { - /** Slow collection background loops */ - protected readonly slowCollection: SlowTxCollection; - /** Fast collection methods */ protected readonly fastCollection: FastTxCollection; - /** File store collection for slow (mined block) path */ - protected readonly fileStoreSlowCollection: FileStoreTxCollection; - /** File store collection for fast (proposal/proving) path */ protected readonly fileStoreFastCollection: FileStoreTxCollection; - /** Loop for periodically reconciling found transactions from the tx pool in case we missed some */ - private readonly reconcileFoundTxsLoop: RunningPromise; - - /** Handles txs found by the slow and fast collection loops */ + /** Handles txs found by collection paths before adding to the pool */ private readonly txCollectionSink: TxCollectionSink; /** Handler for the txs-added event from the tx pool */ @@ -101,29 +85,7 @@ export class TxCollection { this.log, ); - this.slowCollection = new SlowTxCollection( - this.p2pService.reqResp, - this.nodes, - this.txCollectionSink, - this.fastCollection, - constants, - this.config, - this.dateProvider, - this.log, - ); - this.hasFileStoreSources = fileStoreSources.length > 0; - this.fileStoreSlowCollection = new FileStoreTxCollection( - fileStoreSources, - this.txCollectionSink, - { - workerCount: config.txCollectionFileStoreSlowWorkerCount, - backoffBaseMs: config.txCollectionFileStoreSlowBackoffBaseMs, - backoffMaxMs: config.txCollectionFileStoreSlowBackoffMaxMs, - }, - this.dateProvider, - this.log, - ); this.fileStoreFastCollection = new FileStoreTxCollection( fileStoreSources, this.txCollectionSink, @@ -136,12 +98,6 @@ export class TxCollection { this.log, ); - this.reconcileFoundTxsLoop = new RunningPromise( - () => this.reconcileFoundTxsWithPool(), - this.log, - this.config.txCollectionReconcileIntervalMs, - ); - this.handleTxsFound = (args: Parameters[0]) => { this.foundTxs(args.txs); }; @@ -159,10 +115,7 @@ export class TxCollection { /** Starts all collection loops. */ public start(): Promise { this.started = true; - this.slowCollection.start(); - this.fileStoreSlowCollection.start(); this.fileStoreFastCollection.start(); - this.reconcileFoundTxsLoop.start(); // TODO(palla/txs): Collect mined unproven tx hashes for txs we dont have in the pool and populate missingTxs on startup return Promise.resolve(); @@ -171,61 +124,17 @@ export class TxCollection { /** Stops all activity. */ public async stop() { this.started = false; - await Promise.all([ - this.slowCollection.stop(), - this.fastCollection.stop(), - this.fileStoreSlowCollection.stop(), - this.fileStoreFastCollection.stop(), - this.reconcileFoundTxsLoop.stop(), - ]); + await Promise.all([this.fastCollection.stop(), this.fileStoreFastCollection.stop()]); this.txPool.removeListener('txs-added', this.handleTxsAddedToPool); this.txCollectionSink.removeListener('txs-added', this.handleTxsFound); } - /** Force trigger the slow collection and reconciliation loops */ - public async trigger() { - await Promise.all([this.reconcileFoundTxsLoop.trigger(), this.slowCollection.trigger()]); - } - /** Returns L1 rollup constants. */ public getConstants(): L1RollupConstants { return this.constants; } - /** Starts collecting the given tx hashes for the given L2Block in the slow loop */ - public startCollecting(block: L2Block, txHashes: TxHash[]) { - this.slowCollection.startCollecting(block, txHashes); - - // Delay file store collection to give P2P methods time to find txs first - if (this.hasFileStoreSources) { - const context: TxAddContext = { type: 'mined', block }; - const deadline = getProofDeadlineForSlot(block.header.getSlot(), this.constants); - sleep(this.config.txCollectionFileStoreSlowDelayMs) - .then(() => { - if (this.started) { - // Only queue txs that are still missing after the delay - const stillMissing = new Set(this.slowCollection.getMissingTxHashes().map(h => h.toString())); - const remaining = txHashes.filter(h => stillMissing.has(h.toString())); - if (remaining.length > 0) { - this.fileStoreSlowCollection.startCollecting(remaining, context, deadline); - } - } - }) - .catch(err => this.log.error('Error in file store slow delay', err)); - } - } - - /** Collects the set of txs for the given block proposal as fast as possible */ - public collectFastForProposal( - blockProposal: BlockProposal, - blockNumber: BlockNumber, - txHashes: TxHash[] | string[], - opts: { deadline: Date; pinnedPeer?: PeerId }, - ) { - return this.collectFastFor({ type: 'proposal', blockProposal, blockNumber }, txHashes, opts); - } - /** Collects the set of txs for the given mined block as fast as possible */ public collectFastForBlock( block: L2Block, @@ -248,8 +157,15 @@ export class TxCollection { const context = this.getAddContextForInput(input); sleep(this.config.txCollectionFileStoreFastDelayMs) .then(() => { - if (this.started) { - this.fileStoreFastCollection.startCollecting(hashes, context, opts.deadline); + if (!this.started) { + return; + } + + // Only queue txs that are still missing after the delay. + const missingTxHashStrings = new Set(this.fastCollection.getMissingTxHashes().map(hash => hash.toString())); + const missingTxHashesToCollect = hashes.filter(hash => missingTxHashStrings.has(hash.toString())); + if (missingTxHashesToCollect.length > 0) { + this.fileStoreFastCollection.startCollecting(missingTxHashesToCollect, context, opts.deadline); } }) .catch(err => this.log.error('Error in file store fast delay', err)); @@ -269,20 +185,16 @@ export class TxCollection { /** Mark the given txs as found. Stops collecting them. */ private foundTxs(txs: Tx[]) { - this.slowCollection.foundTxs(txs); this.fastCollection.foundTxs(txs); - this.fileStoreSlowCollection.foundTxs(txs); this.fileStoreFastCollection.foundTxs(txs); } /** - * Stop collecting all txs for blocks less than or requal to the block number specified. + * Stop collecting all txs for blocks less than or equal to the block number specified. * To be called when we no longer care about gathering txs up to a certain block, eg when they become proven or finalized. */ public stopCollectingForBlocksUpTo(blockNumber: BlockNumber): void { - this.slowCollection.stopCollectingForBlocksUpTo(blockNumber); this.fastCollection.stopCollectingForBlocksUpTo(blockNumber); - this.fileStoreSlowCollection.clearPending(); this.fileStoreFastCollection.clearPending(); } @@ -291,21 +203,7 @@ export class TxCollection { * To be called when there is a chain prune and previously mined txs are no longer relevant. */ public stopCollectingForBlocksAfter(blockNumber: BlockNumber): void { - this.slowCollection.stopCollectingForBlocksAfter(blockNumber); this.fastCollection.stopCollectingForBlocksAfter(blockNumber); - this.fileStoreSlowCollection.clearPending(); this.fileStoreFastCollection.clearPending(); } - - /** Every now and then, check if the pool has received one of the txs we are looking for, just to catch any race conditions */ - private async reconcileFoundTxsWithPool() { - const missingTxHashes = this.slowCollection.getMissingTxHashes(); - const foundTxs = compactArray(await this.txPool.getTxsByHash(missingTxHashes)); - if (foundTxs.length > 0) { - this.log.verbose(`Found ${foundTxs.length} txs in the pool during reconciliation`, { - foundTxs: foundTxs.map(t => t.getTxHash().toString()), - }); - this.foundTxs(foundTxs); - } - } } diff --git a/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts b/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts index 7848111d16b0..09ace5b931a3 100644 --- a/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts +++ b/yarn-project/p2p/src/services/tx_collection/tx_collection_sink.ts @@ -16,7 +16,7 @@ import type { TxSourceCollectionResult } from './tx_source.js'; export type TxAddContext = { type: 'proposal'; blockHeader: BlockHeader } | { type: 'mined'; block: L2Block }; /** - * Executes collection requests from the fast and slow collection loops, and handles collected txs + * Executes collection requests from fast collection paths, and handles collected txs * by adding them to the tx pool and emitting events, as well as handling logging and metrics. */ export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitter) { @@ -101,7 +101,7 @@ export class TxCollectionSink extends (EventEmitter as new () => TypedEventEmitt // Report metrics for the collection this.instrumentation.increaseTxsFor(info.method, txs.length, info.duration); - // Mark txs as found in the slow missing txs set and all fast requests + // Mark txs as found in all in-flight collection requests this.emit('txs-added', { txs }); // Add the txs to the tx pool using the appropriate method based on context From 22b578d4a23aefe86a1bcb31f6e42351144cda49 Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Thu, 7 May 2026 16:48:06 +0100 Subject: [PATCH 17/55] chore(spartan): add next-net-clone environment config (#22995) ## Summary - Adds a new spartan environment file (`environments/next-net-clone.env`) for cloning the next-net deployment configuration. - Adds the corresponding `!environments/next-net-clone.env` allow-line to `spartan/.gitignore` so the file isn't ignored. ## Context Split out from the broader optimistic-proving work (#22990) so it can land independently. Pure config; no code changes. --- spartan/.gitignore | 1 + spartan/environments/next-net-clone.env | 79 +++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 spartan/environments/next-net-clone.env diff --git a/spartan/.gitignore b/spartan/.gitignore index 51ea42703871..a7a3bb21bc6b 100644 --- a/spartan/.gitignore +++ b/spartan/.gitignore @@ -19,6 +19,7 @@ environments/* !environments/devnet.env !environments/block-capacity.env !environments/next-net.env +!environments/next-net-clone.env !environments/next-scenario.env !environments/scenario.local.env !environments/source-env.sh diff --git a/spartan/environments/next-net-clone.env b/spartan/environments/next-net-clone.env new file mode 100644 index 000000000000..b159feccf9c1 --- /dev/null +++ b/spartan/environments/next-net-clone.env @@ -0,0 +1,79 @@ +CREATE_ETH_DEVNET=false +GCP_REGION=us-west1-a +CLUSTER=aztec-gke-private +NETWORK=next-net +NAMESPACE=${NAMESPACE:-next-net-clone} +DESTROY_NAMESPACE=true +ETHEREUM_CHAIN_ID=11155111 +ETHEREUM_RPC_URLS=REPLACE_WITH_GCP_SECRET +ETHEREUM_CONSENSUS_HOST_URLS=REPLACE_WITH_GCP_SECRET +ETHEREUM_CONSENSUS_HOST_API_KEYS=REPLACE_WITH_GCP_SECRET +ETHEREUM_CONSENSUS_HOST_API_KEY_HEADERS=REPLACE_WITH_GCP_SECRET +FUNDING_PRIVATE_KEY=REPLACE_WITH_GCP_SECRET +LABS_INFRA_MNEMONIC=REPLACE_WITH_GCP_SECRET +ROLLUP_DEPLOYMENT_PRIVATE_KEY=REPLACE_WITH_GCP_SECRET +OTEL_COLLECTOR_ENDPOINT=REPLACE_WITH_GCP_SECRET +VERIFY_CONTRACTS=false +ETHERSCAN_API_KEY=REPLACE_WITH_GCP_SECRET +DEPLOY_INTERNAL_BOOTNODE=true +STORE_SNAPSHOT_URL= +#BLOB_BUCKET_DIRECTORY=${BLOB_BUCKET_DIRECTORY:-next-net/blobs} +#BLOB_FILE_STORE_URLS="," +#TX_FILE_STORE_ENABLED=true +#TX_FILE_STORE_BUCKET_DIRECTORY=${TX_FILE_STORE_BUCKET_DIRECTORY:-next-net/txs} +#TX_COLLECTION_FILE_STORE_URLS="https://aztec-labs-snapshots.com/${TX_FILE_STORE_BUCKET_DIRECTORY}" +R2_ACCESS_KEY_ID=REPLACE_WITH_GCP_SECRET +R2_SECRET_ACCESS_KEY=REPLACE_WITH_GCP_SECRET +#PROVER_FAILED_PROOF_STORE=gs://aztec-develop/next-net/failed-proofs +#L1_TX_FAILED_STORE=gs://aztec-develop/next-net/failed-l1-txs +TEST_ACCOUNTS=true +SPONSORED_FPC=true + +SEQ_ENABLE_PROPOSER_PIPELINING=true +SEQ_MIN_TX_PER_BLOCK=1 +SEQ_MAX_TX_PER_CHECKPOINT=12 + +# Build checkpoint even if block is empty. +SEQ_BUILD_CHECKPOINT_IF_EMPTY=true +SEQ_BLOCK_DURATION_MS=5500 + +AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET=2 +AZTEC_LAG_IN_EPOCHS_FOR_RANDAO=2 +AZTEC_INBOX_LAG=2 + +VALIDATOR_REPLICAS=4 +VALIDATORS_PER_NODE=12 +VALIDATOR_PUBLISHERS_PER_REPLICA=4 +VALIDATOR_PUBLISHER_MNEMONIC_START_INDEX=5000 + +PUBLISHERS_PER_PROVER=2 +PROVER_PUBLISHER_MNEMONIC_START_INDEX=8000 + +BOT_TRANSFERS_REPLICAS=1 +BOT_TRANSFERS_TX_INTERVAL_SECONDS=250 +BOT_TRANSFERS_FOLLOW_CHAIN=PENDING + +BOT_SWAPS_REPLICAS=1 +BOT_SWAPS_FOLLOW_CHAIN=PENDING +BOT_SWAPS_TX_INTERVAL_SECONDS=350 + +CREATE_ROLLUP_CONTRACTS=true + +DEBUG_P2P_INSTRUMENT_MESSAGES=true + +RPC_INGRESS_ENABLED=false +#RPC_INGRESS_HOSTS='["nextnet.aztec-labs.com"]' +#RPC_INGRESS_STATIC_IP_NAME=nextnet-rpc-ip +#RPC_INGRESS_SSL_CERT_NAMES='["nextnet-rpc-cert"]' + +VALIDATOR_HA_REPLICAS=1 +VALIDATOR_RESOURCE_PROFILE="prod-spot" + +REAL_VERIFIER=true +AZTEC_SLOT_DURATION=72 +AZTEC_EPOCH_DURATION=32 +AZTEC_TARGET_COMMITTEE_SIZE=48 +AZTEC_LAG_IN_EPOCHS_FOR_VALIDATOR_SET=2 +AZTEC_LAG_IN_EPOCHS_FOR_RANDAO=2 +AZTEC_PROOF_SUBMISSION_EPOCHS=1 + From e62bd701d7d1987c4ef870d808529f1bfbab93e4 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 04:59:01 -0300 Subject: [PATCH 18/55] chore(sequencer): add context to proposer-rollup-check-failed logs (#23071) Logging-only change --- .../src/sequencer/sequencer.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 109e0ab467d5..b074c2f0d653 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -408,10 +408,22 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter Date: Fri, 8 May 2026 05:00:02 -0300 Subject: [PATCH 19/55] test(e2e): wait for archiver sync before asserting pipelining (#22997) Alternative fix for flake in epochs_mbps.pipeline. See also https://gist.github.com/AztecBot/164f3e04bd74f48cafd6505433935421. --- .../epochs_mbps.pipeline.parallel.test.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts index 013324576541..35f3fb1acc40 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.pipeline.parallel.test.ts @@ -12,6 +12,7 @@ import { asyncMap } from '@aztec/foundation/async-map'; import { BlockNumber, CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { times, timesAsync } from '@aztec/foundation/collection'; import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; import { bufferToHex } from '@aztec/foundation/string'; import { executeTimeout } from '@aztec/foundation/timer'; import { TestContract } from '@aztec/noir-test-contracts.js/Test'; @@ -113,8 +114,25 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); } - /** Retrieves all checkpoints from the archiver, checks that one has the target block count, and returns its number. */ - async function assertMultipleBlocksPerSlot(targetBlockCount: number, logger: Logger): Promise { + /** + * Waits until the archiver's checkpointed chain tip has reached `targetBlockNumber`, then retrieves all checkpoints, + * checks that one has the target block count, and returns its number. + */ + async function assertMultipleBlocksPerSlot( + targetBlockCount: number, + targetBlockNumber: BlockNumber, + logger: Logger, + ): Promise { + await retryUntil( + async () => { + const checkpointed = await archiver.getBlockNumber({ tag: 'checkpointed' }); + return checkpointed !== undefined && checkpointed >= targetBlockNumber; + }, + `archiver checkpointed block ${targetBlockNumber}`, + 10, + 0.1, + ); + const checkpoints = await archiver.getCheckpoints({ from: CheckpointNumber(1), limit: 50 }); logger.warn(`Retrieved ${checkpoints.length} checkpoints from archiver`, { checkpoints: checkpoints.map(pc => pc.checkpoint.getStats()), @@ -253,14 +271,19 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { // Wait until all txs are mined const timeout = test.L2_SLOT_DURATION_IN_S * 5; - await executeTimeout( + const receipts = await executeTimeout( () => Promise.all(txHashes.map(txHash => waitForTx(context.aztecNode, txHash, { timeout }))), timeout * 1000, ); logger.warn(`All txs have been mined`); - // Verify MBPS works with pipelining - const multiBlockCheckpoint = await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); + // Verify MBPS works with pipelining; target the highest block number across mined receipts + const maxMinedBlockNumber = BlockNumber(Math.max(...receipts.map(r => r.blockNumber ?? 0))); + const multiBlockCheckpoint = await assertMultipleBlocksPerSlot( + EXPECTED_BLOCKS_PER_CHECKPOINT, + maxMinedBlockNumber, + logger, + ); // Verify the pipelining offset: build slot N vs submission slot N+1 await assertProposerPipelining(blockProposedEvents, logger); @@ -348,14 +371,15 @@ describe('e2e_epochs/epochs_mbps_pipeline', () => { // Wait for a new checkpoint (recovery) - where all txs end up mined const timeout = test.L2_SLOT_DURATION_IN_S * 5; - await executeTimeout( + const receipts = await executeTimeout( () => Promise.all(txHashes.map(txHash => waitForTx(context.aztecNode, txHash, { timeout }))), timeout * 1000, ); logger.warn(`All txs have been mined`); - // Verify MBPS works with pipelining - await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, logger); + // Verify MBPS works with pipelining; target the highest block number across mined receipts + const maxMinedBlockNumber = BlockNumber(Math.max(...receipts.map(r => r.blockNumber ?? 0))); + await assertMultipleBlocksPerSlot(EXPECTED_BLOCKS_PER_CHECKPOINT, maxMinedBlockNumber, logger); // Verify the pipelining offset: build slot N vs submission slot N+1 await assertProposerPipelining(blockProposedEvents, logger); From e0899d6c6c5e7c91b03e03a1114e00ced16f918e Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 05:12:26 -0300 Subject: [PATCH 20/55] refactor(node-rpc)!: remove deprecated AztecNode methods and L2BlockSource tip helpers (#22934) ## Motivation After three back-to-back unifications of the block/checkpoint APIs (#22781, #22809, plus the two query-object refactors on this stack), four `@deprecated` `AztecNode` RPC methods and three redundant `L2BlockSource` tip-number helpers had outlived their replacements and remained only as stop-gaps. This PR retires them and migrates every caller to the canonical query-object APIs. ## Approach Removed `isL1ToL2MessageSynced`, `getL2Tips`, `getBlockHeader`, `getCheckpointedBlocks` from `AztecNode`, and `getProvenBlockNumber`, `getCheckpointedL2BlockNumber`, `getFinalizedL2BlockNumber` from `L2BlockSource`. Callers now use `getL1ToL2MessageCheckpoint`, `getChainTips`, `getBlock(...).header`, `getBlocks(..., { onlyCheckpointed, includeL1PublishInfo, includeAttestations })`, and `getBlockNumber({ tag })` respectively. `BlockIncludeOptions` was split into a single-block variant and a `BlocksIncludeOptions` extension so `onlyCheckpointed` is rejected at the type level on `getBlock`. Internal `BlockStore` primitives are intentionally kept since they remain the underlying implementation. ## Changes - **stdlib (interfaces)**: dropped four `@deprecated` `AztecNode` methods + their zod entries; dropped three tip-number helpers from `L2BlockSource` and its archiver schema; split `BlockIncludeOptions` into single- and range-block variants - **aztec-node**: removed deprecated server impls; simplified `getBlockNumber(tip)` to a single `getBlockNumber({ tag: tip })` call; fixed `getL1ToL2MessageCheckpoint` to handle `messageIndex === 0n` correctly (previously coerced to `undefined` via truthy check) - **archiver**: dropped the now-unused tip-number passthroughs in `data_source_base` and the `MockL2BlockSource` overrides - **prover-node, p2p**: migrated `getProvenBlockNumber` callers to `getBlockNumber({ tag: 'proven' })` - **pxe**: adjusted `block_stream_source` to wrap `getChainTips()` into the `L2Tips` shape required by `L2BlockStream` - **txe**: added `l2TipsProvider` getter that adapts `getChainTips()` for the TXE state machine - **end-to-end (tests)**: migrated 15+ test files to the new APIs (`getBlocks` with `onlyCheckpointed`/`includeTransactions` where bodies are read, `getChainTips`, `getBlock(...).header`, `getL1ToL2MessageCheckpoint(...) !== undefined`) - **aztec-node, stdlib (tests)**: dropped tests of removed methods; added unit tests covering the `messageIndex === 0n` edge case - **docs**: updated the node-API generator to drop removed methods, regenerated the operator API reference, and migrated `node_getL2Tips` curl examples in the operator setup guides to `node_getChainTips` --- .../operators/reference/node-api-reference.md | 194 +++++------------- .../operators/setup/running-a-node.md | 2 +- .../operators/setup/sequencer-setup.md | 2 +- .../generate_node_api_reference.ts | 17 +- .../archiver/src/archiver-store.test.ts | 8 +- .../archiver/src/modules/data_source_base.ts | 12 -- .../archiver/src/test/mock_l2_block_source.ts | 14 +- .../aztec-node/src/aztec-node/server.test.ts | 69 ++----- .../aztec-node/src/aztec-node/server.ts | 68 ++---- .../src/bench/node_rpc_perf.test.ts | 32 +-- .../src/composed/ha/e2e_ha_full.test.ts | 35 +++- .../end-to-end/src/e2e_block_building.test.ts | 2 +- .../l1_to_l2.test.ts | 12 +- .../e2e_epochs/epochs_equivocation.test.ts | 2 +- .../epochs_high_tps_block_building.test.ts | 7 +- .../epochs_invalidate_block.parallel.test.ts | 8 +- .../epochs_l1_reorgs.parallel.test.ts | 26 +-- .../e2e_epochs/epochs_mbps.parallel.test.ts | 21 +- .../epochs_missed_l1_publish.test.ts | 15 +- .../end-to-end/src/e2e_epochs/epochs_test.ts | 2 +- .../src/e2e_expiration_timestamp.test.ts | 6 +- .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 3 - .../src/e2e_offchain_payment.test.ts | 2 +- .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 5 +- .../end-to-end/src/e2e_simple.test.ts | 2 +- .../end-to-end/src/e2e_snapshot_sync.test.ts | 2 +- .../end-to-end/src/e2e_state_vars.test.ts | 2 +- .../src/shared/cross_chain_test_harness.ts | 5 +- .../src/shared/gas_portal_test_harness.ts | 5 +- .../src/shared/wait_for_l1_to_l2_message.ts | 23 +++ .../end-to-end/src/spartan/mbps.test.ts | 6 +- .../src/spartan/n_tps_prove.test.ts | 2 +- .../src/spartan/setup_test_wallets.ts | 5 +- .../end-to-end/src/spartan/utils/nodes.ts | 4 +- yarn-project/p2p/src/client/p2p_client.ts | 2 +- .../src/monitors/epoch-monitor.test.ts | 5 +- .../prover-node/src/monitors/epoch-monitor.ts | 2 +- .../block_synchronizer/block_stream_source.ts | 5 +- .../block_synchronizer.test.ts | 10 +- .../block_synchronizer/block_synchronizer.ts | 2 +- yarn-project/pxe/src/pxe.test.ts | 46 +++-- .../stdlib/src/block/l2_block_source.ts | 19 -- .../stdlib/src/interfaces/archiver.test.ts | 24 --- .../stdlib/src/interfaces/archiver.ts | 3 - .../stdlib/src/interfaces/aztec-node.test.ts | 69 +------ .../stdlib/src/interfaces/aztec-node.ts | 51 ++--- .../stdlib/src/interfaces/block_response.ts | 15 +- .../oracle/txe_oracle_top_level_context.ts | 10 +- yarn-project/txe/src/state_machine/index.ts | 13 +- yarn-project/txe/src/txe_session.ts | 15 +- .../wallet-sdk/src/base-wallet/base_wallet.ts | 2 +- 51 files changed, 381 insertions(+), 532 deletions(-) create mode 100644 yarn-project/end-to-end/src/shared/wait_for_l1_to_l2_message.ts diff --git a/docs/docs-operate/operators/reference/node-api-reference.md b/docs/docs-operate/operators/reference/node-api-reference.md index 225b6321a493..c9935579110e 100644 --- a/docs/docs-operate/operators/reference/node-api-reference.md +++ b/docs/docs-operate/operators/reference/node-api-reference.md @@ -25,236 +25,170 @@ All methods use standard JSON RPC 2.0 format with methods prefixed by `node_` or ### node_getBlockNumber -Method to fetch the latest block number synchronized by the node. +Returns the block number at a given chain tip, or the latest proposed block number when +`tip` is omitted. -**Parameters**: None - -**Returns**: `number` - The block number. - -**Example**: - -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlockNumber","params":[],"id":1}' -``` - -### node_getProvenBlockNumber - -Fetches the latest proven block number. +**Parameters**: -**Parameters**: None +1. `tip` - `ChainTip | undefined` -**Returns**: `number` - The block number. +**Returns**: `number` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getProvenBlockNumber","params":[],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getBlockNumber","params":["0x1234..."],"id":1}' ``` -### node_getCheckpointedBlockNumber - -Fetches the latest checkpointed block number. - -**Parameters**: None +### node_getCheckpointNumber -**Returns**: `number` - The block number. +Returns the checkpoint number at a given chain tip, or the latest checkpoint number when +`tip` is omitted. -**Example**: +**Remarks**: **Semantic foot-gun**: block-side `'proposed'` means "latest proposed block" (chain +head), but checkpoint-side `'proposed'` means "latest confirmed checkpoint" — pre-L1-confirm +checkpoints are not exposed over RPC. `'checkpointed'` on the checkpoint side is equivalent. -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getCheckpointedBlockNumber","params":[],"id":1}' -``` - -### node_getCheckpointNumber - -Method to fetch the latest checkpoint number synchronized by the node. +**Parameters**: -**Parameters**: None +1. `tip` - `ChainTip | undefined` -**Returns**: `number` - The checkpoint number. +**Returns**: `number` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getCheckpointNumber","params":[],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getCheckpointNumber","params":["0x1234..."],"id":1}' ``` -### node_getL2Tips +### node_getChainTips Returns the tips of the L2 chain. **Parameters**: None -**Returns**: `L2Tips` +**Returns**: `ChainTips` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getL2Tips","params":[],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getChainTips","params":[],"id":1}' ``` ### node_getBlock -Get a block specified by its block number or 'latest'. - -**Parameters**: - -1. `blockParameter` - `BlockHash | number | "latest"` - The block parameter (block number, block hash, or 'latest'). - -**Returns**: `L2Block | undefined` - The requested block. - -**Example**: - -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlock","params":["latest"],"id":1}' -``` - -### node_getBlockByHash - -Get a block specified by its hash. +Unified block fetch. Returns the block identified by `param`, with optional fields controlled +by `options`. **Parameters**: -1. `blockHash` - `BlockHash` - The block hash being requested. +1. `param` - `BlockHash | number | "latest"` - A block number, block hash, archive root, chain-tip name, or object variant. +2. `options` - `BlockIncludeOptions | undefined` - Narrowing options: `includeTransactions`, `includeL1PublishInfo`, `includeAttestations`. -**Returns**: `L2Block | undefined` - The requested block. +**Returns**: `BlockResponse | undefined` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlockByHash","params":["0x1234..."],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getBlock","params":["latest","0x1234..."],"id":1}' ``` -### node_getBlockByArchive +### node_getBlockData -Get a block specified by its archive root. +Lightweight block-metadata fetch. Returns the block identified by `param` without transaction +bodies or other optional context. Cheaper than `getBlock` for header-only access. **Parameters**: -1. `archive` - `Fr` - The archive root being requested. +1. `param` - `BlockHash | number | "latest"` - A block number, block hash, archive root, chain-tip name, or object variant. -**Returns**: `L2Block | undefined` - The requested block. +**Returns**: `BlockData | undefined` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlockByArchive","params":["0x1234..."],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getBlockData","params":["latest"],"id":1}' ``` ### node_getBlocks -Method to request blocks. Will attempt to return all requested blocks but will return only those available. +Returns up to `limit` blocks starting from `from`, projected to the +shape determined by `options`. **Parameters**: -1. `from` - `number` - The start of the range of blocks to return. -2. `limit` - `number` - The maximum number of blocks to return. - -**Returns**: `L2Block[]` - The blocks requested. - -**Example**: - -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlocks","params":[1,100],"id":1}' -``` - -### node_getBlockHeader - -Returns the block header for a given block number, block hash, or 'latest'. - -**Parameters**: - -1. `block` - `BlockHash | number | "latest" | undefined` - The block parameter (block number, block hash, or 'latest'). Defaults to 'latest'. +1. `from` - `number` +2. `limit` - `number` +3. `options` - `BlocksIncludeOptions | undefined` -**Returns**: `BlockHeader | undefined` - The requested block header. +**Returns**: `BlockResponse[]` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlockHeader","params":["latest"],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getBlocks","params":[1,100,"0x1234..."],"id":1}' ``` -### node_getBlockHeaderByArchive +### node_getCheckpoint -Get a block header specified by its archive root. +Unified checkpoint fetch. Returns the checkpoint identified by `param`, with optional fields +controlled by `options`. **Parameters**: -1. `archive` - `Fr` - The archive root being requested. +1. `param` - `CheckpointParameter` +2. `options` - `CheckpointIncludeOptions | undefined` -**Returns**: `BlockHeader | undefined` - The requested block header. +**Returns**: `CheckpointResponse | undefined` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getBlockHeaderByArchive","params":["0x1234..."],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getCheckpoint","params":["0x1234...","0x1234..."],"id":1}' ``` ### node_getCheckpoints -Retrieves a collection of checkpoints. - -**Parameters**: - -1. `checkpointNumber` - `number` - The first checkpoint to be retrieved. -2. `limit` - `number` - The number of checkpoints to be retrieved. - -**Returns**: `PublishedCheckpoint[]` - The collection of complete checkpoints. - -**Example**: - -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getCheckpoints","params":[1,100],"id":1}' -``` - -### node_getCheckpointedBlocks +Returns up to `limit` checkpoints starting from `from`, projected to the + shape determined by `options`. **Parameters**: 1. `from` - `number` 2. `limit` - `number` +3. `options` - `CheckpointIncludeOptions | undefined` -**Returns**: `CheckpointedL2Block[]` +**Returns**: `CheckpointResponse[]` **Example**: ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getCheckpointedBlocks","params":[1,100],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getCheckpoints","params":[1,100,"0x1234..."],"id":1}' ``` -### node_getCheckpointsDataForEpoch +### node_getCheckpointsData -Gets lightweight checkpoint metadata for a given epoch, without fetching full block data. +Gets lightweight checkpoint metadata for a contiguous range or for an entire epoch. **Parameters**: -1. `epochNumber` - `number` - Epoch for which we want checkpoint data +1. `query` - `CheckpointsQuery` - Either `{ from, limit }` or `{ epoch }`. **Returns**: `CheckpointData[]` @@ -263,7 +197,7 @@ Gets lightweight checkpoint metadata for a given epoch, without fetching full bl ```bash curl -X POST http://localhost:8080 \ -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_getCheckpointsDataForEpoch","params":[12345],"id":1}' + -d '{"jsonrpc":"2.0","method":"node_getCheckpointsData","params":["0x1234..."],"id":1}' ``` ## Transaction operations @@ -648,26 +582,6 @@ curl -X POST http://localhost:8080 \ -d '{"jsonrpc":"2.0","method":"node_getL1ToL2MessageCheckpoint","params":["0x1234..."],"id":1}' ``` -### node_isL1ToL2MessageSynced - -Returns whether an L1 to L2 message is synced by archiver. - -**Deprecated**: Use `getL1ToL2MessageCheckpoint` instead. This method may return true even if the message is not ready to use. - -**Parameters**: - -1. `l1ToL2Message` - `Fr` - The L1 to L2 message to check. - -**Returns**: `boolean` - Whether the message is synced. - -**Example**: - -```bash -curl -X POST http://localhost:8080 \ - -H 'Content-Type: application/json' \ - -d '{"jsonrpc":"2.0","method":"node_isL1ToL2MessageSynced","params":["0x1234..."],"id":1}' -``` - ### node_getL2ToL1Messages Returns all the L2 to L1 messages in an epoch. diff --git a/docs/docs-operate/operators/setup/running-a-node.md b/docs/docs-operate/operators/setup/running-a-node.md index 5550a94cb1d8..a2b95fede1f7 100644 --- a/docs/docs-operate/operators/setup/running-a-node.md +++ b/docs/docs-operate/operators/setup/running-a-node.md @@ -136,7 +136,7 @@ Check the current sync status: ```bash curl -s -X POST -H 'Content-Type: application/json' \ --d '{"jsonrpc":"2.0","method":"node_getL2Tips","params":[],"id":67}' \ +-d '{"jsonrpc":"2.0","method":"node_getChainTips","params":[],"id":67}' \ http://localhost:8080 | jq -r ".result.proven.number" ``` diff --git a/docs/docs-operate/operators/setup/sequencer-setup.md b/docs/docs-operate/operators/setup/sequencer-setup.md index 2c187b2956fa..f4b1265d4c20 100644 --- a/docs/docs-operate/operators/setup/sequencer-setup.md +++ b/docs/docs-operate/operators/setup/sequencer-setup.md @@ -453,7 +453,7 @@ Check the current sync status (this may take a few minutes): ```bash curl -s -X POST -H 'Content-Type: application/json' \ --d '{"jsonrpc":"2.0","method":"node_getL2Tips","params":[],"id":67}' \ +-d '{"jsonrpc":"2.0","method":"node_getChainTips","params":[],"id":67}' \ http://localhost:8080 | jq -r ".result.proven.number" ``` diff --git a/docs/scripts/node_api_reference_generation/generate_node_api_reference.ts b/docs/scripts/node_api_reference_generation/generate_node_api_reference.ts index df491aa20277..ef664c4ef88a 100644 --- a/docs/scripts/node_api_reference_generation/generate_node_api_reference.ts +++ b/docs/scripts/node_api_reference_generation/generate_node_api_reference.ts @@ -307,7 +307,8 @@ function simplifyZodType(expr: string): string { if (e === 'BlockParameterSchema') return 'BlockHash | number | "latest"'; // Known schema objects - if (e === 'L2TipsSchema') return 'L2Tips'; + if (e === 'ChainTipsSchema') return 'ChainTips'; + if (e === 'ChainTipSchema') return 'ChainTip'; if (e === 'WorldStateSyncStatusSchema') return 'WorldStateSyncStatus'; if (e === 'NodeInfoSchema') return 'NodeInfo'; if (e === 'L1ContractAddressesSchema') return 'L1ContractAddresses'; @@ -419,19 +420,14 @@ const METHOD_GROUPS: { heading: string; namespace: string; methods: string[] }[] namespace: 'node', methods: [ 'getBlockNumber', - 'getProvenBlockNumber', - 'getCheckpointedBlockNumber', 'getCheckpointNumber', - 'getL2Tips', + 'getChainTips', 'getBlock', - 'getBlockByHash', - 'getBlockByArchive', + 'getBlockData', 'getBlocks', - 'getBlockHeader', - 'getBlockHeaderByArchive', + 'getCheckpoint', 'getCheckpoints', - 'getCheckpointedBlocks', - 'getCheckpointsDataForEpoch', + 'getCheckpointsData', ], }, { @@ -472,7 +468,6 @@ const METHOD_GROUPS: { heading: string; namespace: string; methods: string[] }[] methods: [ 'getL1ToL2MessageMembershipWitness', 'getL1ToL2MessageCheckpoint', - 'isL1ToL2MessageSynced', 'getL2ToL1Messages', ], }, diff --git a/yarn-project/archiver/src/archiver-store.test.ts b/yarn-project/archiver/src/archiver-store.test.ts index 7033798c65d1..dd84e9b5a824 100644 --- a/yarn-project/archiver/src/archiver-store.test.ts +++ b/yarn-project/archiver/src/archiver-store.test.ts @@ -995,13 +995,13 @@ describe('Archiver Store', () => { // Mark checkpoints 1 and 2 as proven and finalized await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(2)); await archiverStore.blocks.setFinalizedCheckpointNumber(CheckpointNumber(2)); - expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(4)); + expect(await archiver.getBlockNumber({ tag: 'finalized' })).toEqual(BlockNumber(4)); // Roll back to block 2 (end of checkpoint 1), which is before finalized block 4 await archiver.rollbackTo(BlockNumber(2)); expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(1)); - expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); + expect(await archiver.getBlockNumber({ tag: 'finalized' })).toEqual(BlockNumber(2)); }); it('preserves finalized checkpoint number when target is after finalized block', async () => { @@ -1016,13 +1016,13 @@ describe('Archiver Store', () => { // Mark checkpoint 1 as finalized, checkpoint 2 as proven await archiverStore.blocks.setProvenCheckpointNumber(CheckpointNumber(2)); await archiverStore.blocks.setFinalizedCheckpointNumber(CheckpointNumber(1)); - expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); + expect(await archiver.getBlockNumber({ tag: 'finalized' })).toEqual(BlockNumber(2)); // Roll back to block 4 (end of checkpoint 2), which is after finalized block 2 await archiver.rollbackTo(BlockNumber(4)); expect(await archiver.getCheckpointNumber()).toEqual(CheckpointNumber(2)); - expect(await archiver.getFinalizedL2BlockNumber()).toEqual(BlockNumber(2)); + expect(await archiver.getBlockNumber({ tag: 'finalized' })).toEqual(BlockNumber(2)); }); }); diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index 9e75f7e4b894..f941f119fa62 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -167,18 +167,6 @@ export abstract class ArchiverDataSourceBase return this.stores.blocks.getBlockNumber(resolved); } - public getProvenBlockNumber(): Promise { - return this.stores.blocks.getProvenBlockNumber(); - } - - public getCheckpointedL2BlockNumber(): Promise { - return this.stores.blocks.getCheckpointedL2BlockNumber(); - } - - public getFinalizedL2BlockNumber(): Promise { - return this.stores.blocks.getFinalizedL2BlockNumber(); - } - /** * Resolves a {@link CheckpointQuery} to a concrete `CheckpointNumber`, or undefined when the * query refers to a position that has no checkpoint yet (e.g. `{ slot }` not found). diff --git a/yarn-project/archiver/src/test/mock_l2_block_source.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts index ad18bcf2b2c2..3b4d597ca096 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -260,18 +260,6 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return block ? block.header.globalVariables.blockNumber : undefined; } - public getProvenBlockNumber() { - return Promise.resolve(BlockNumber(this.provenBlockNumber)); - } - - public getCheckpointedL2BlockNumber() { - return Promise.resolve(BlockNumber(this.checkpointedBlockNumber)); - } - - public getFinalizedL2BlockNumber() { - return Promise.resolve(BlockNumber(this.finalizedBlockNumber)); - } - public getProposedCheckpointL2BlockNumber() { return Promise.resolve(BlockNumber(this.proposedCheckpointBlockNumber)); } @@ -412,7 +400,7 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { async getL2Tips(): Promise { const [latest, proven, finalized, checkpointed, proposedCheckpoint] = [ await this.getBlockNumber(), - await this.getProvenBlockNumber(), + this.provenBlockNumber, this.finalizedBlockNumber, this.checkpointedBlockNumber, await this.getProposedCheckpointL2BlockNumber(), diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index f6f991affcc6..cc9a75e7fe83 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -36,6 +36,7 @@ import type { ContractDataSource } from '@aztec/stdlib/contract'; import { EmptyL1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; import type { L2LogsSource, MerkleTreeReadOperations, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; +import { InboxLeaf } from '@aztec/stdlib/messaging'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { mockTx } from '@aztec/stdlib/testing'; @@ -97,6 +98,7 @@ describe('aztec node', () => { let merkleTreeOps: MockProxy; let worldState: MockProxy; let l2BlockSource: MockProxy; + let l1ToL2MessageSource: MockProxy; let lastBlockNumber: BlockNumber; let node: TestAztecNodeService; let feePayer: AztecAddress; @@ -181,7 +183,7 @@ describe('aztec node', () => { const l2LogsSource = mock(); - const l1ToL2MessageSource = mock(); + l1ToL2MessageSource = mock(); // all txs use the same allowed FPC class const contractSource = mock(); @@ -364,52 +366,6 @@ describe('aztec node', () => { }); }); - describe('getBlockHeader', () => { - let initialHeader: BlockHeader; - let header1: BlockHeader; - let header2: BlockHeader; - - beforeEach(() => { - initialHeader = BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber.ZERO }), - }); - header1 = BlockHeader.empty({ - globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(1) }), - }); - header2 = BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(2) }) }); - - // Archiver returns the genesis block data for block 0 queries (including {tag:'proposed'} at genesis). - l2BlockSource.getBlockData.mockResolvedValue({ header: initialHeader } as any); - l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber(2)); - }); - - it('returns requested block number', async () => { - l2BlockSource.getBlockData.mockResolvedValue({ header: header1 } as any); - expect(await node.getBlockHeader(BlockNumber(1))).toEqual(header1); - }); - - it('returns latest', async () => { - l2BlockSource.getBlockData.mockResolvedValue({ header: header2 } as any); - expect(await node.getBlockHeader('latest')).toEqual(header2); - }); - - it('returns initial header on zero', async () => { - // Archiver returns synthetic genesis block data when queried with block 0. - expect(await node.getBlockHeader(BlockNumber.ZERO)).toEqual(initialHeader); - }); - - it('returns initial header if no blocks mined', async () => { - // When no blocks have been mined, {tag:'proposed'} resolves to the genesis block. - l2BlockSource.getBlockNumber.mockResolvedValue(BlockNumber.ZERO); - expect(await node.getBlockHeader('latest')).toEqual(initialHeader); - }); - - it('returns undefined for non-existent block', async () => { - l2BlockSource.getBlockData.mockResolvedValue(undefined); - expect(await node.getBlockHeader(BlockNumber(3))).toEqual(undefined); - }); - }); - describe('getBlock', () => { let blockData1: BlockData; let blockData2: BlockData; @@ -1371,4 +1327,23 @@ describe('aztec node', () => { expect(result).toEqual(CheckpointNumber(5)); }); }); + + describe('getL1ToL2MessageCheckpoint', () => { + it('returns the checkpoint for a message at index 0n', async () => { + const msg = Fr.random(); + l1ToL2MessageSource.getL1ToL2MessageIndex.mockResolvedValue(0n); + + const result = await node.getL1ToL2MessageCheckpoint(msg); + expect(result).toEqual(InboxLeaf.checkpointNumberFromIndex(0n)); + expect(result).not.toBeUndefined(); + }); + + it('returns undefined when the message is not found', async () => { + const msg = Fr.random(); + l1ToL2MessageSource.getL1ToL2MessageIndex.mockResolvedValue(undefined); + + const result = await node.getL1ToL2MessageCheckpoint(msg); + expect(result).toBeUndefined(); + }); + }); }); diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index 33d224f67fc4..a7ef8e339b9d 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -50,6 +50,7 @@ import { import { CollectionLimitsConfig, PublicSimulatorConfig } from '@aztec/stdlib/avm'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { + type BlockData, BlockHash, type BlockParameter, BlockTag, @@ -77,6 +78,7 @@ import type { AztecNodeDebug, BlockIncludeOptions, BlockResponse, + BlocksIncludeOptions, ChainTip, ChainTips, CheckpointIncludeOptions, @@ -102,7 +104,6 @@ import type { Offense } from '@aztec/stdlib/slashing'; import type { NullifierLeafPreimage, PublicDataTreeLeafPreimage } from '@aztec/stdlib/trees'; import { MerkleTreeId, NullifierMembershipWitness, PublicDataWitness } from '@aztec/stdlib/trees'; import { - type BlockHeader, type FeeProvider, type GlobalVariableBuilder as GlobalVariableBuilderInterface, type IndexedTxEffect, @@ -219,47 +220,15 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return { proposed, checkpointed, proven, finalized }; } - public getL2Tips() { - return this.blockSource.getL2Tips(); - } - - public async getBlockHeader(number: BlockNumber | 'latest'): Promise { - if (number === 'latest') { - return (await this.blockSource.getBlockData({ tag: 'proposed' }))?.header; - } - return (await this.blockSource.getBlockData({ number }))?.header; - } - - public async getCheckpointedBlocks(from: BlockNumber, limit: number): Promise { - const blocks = await this.blockSource.getBlocks({ from, limit, onlyCheckpointed: true }); - const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(blocks); - return Promise.all( - blocks.map(block => - blockResponseFromL2Block( - block, - { includeTransactions: true, includeL1PublishInfo: true, includeAttestations: true }, - ctxByCheckpoint.get(block.checkpointNumber), - ), - ), - ); - } - public getCheckpointsData(query: CheckpointsQuery) { return this.blockSource.getCheckpointsData(query); } - public getBlockNumber(tip?: ChainTip): Promise { - switch (tip) { - case undefined: - case 'proposed': - return this.blockSource.getBlockNumber(); - case 'checkpointed': - return this.blockSource.getCheckpointedL2BlockNumber(); - case 'proven': - return this.blockSource.getProvenBlockNumber(); - case 'finalized': - return this.blockSource.getFinalizedL2BlockNumber(); + public async getBlockNumber(tip?: ChainTip): Promise { + if (tip === undefined || tip === 'proposed') { + return this.blockSource.getBlockNumber(); } + return (await this.blockSource.getBlockNumber({ tag: tip })) ?? BlockNumber.ZERO; } public async getCheckpointNumber(tip?: ChainTip): Promise { @@ -396,21 +365,27 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb return blockResponseFromBlockData(data, options, ctx) as BlockResponse; } - public async getBlocks( + public getBlockData(param: BlockParameter): Promise { + const query = this.normalizeBlockParameter(param); + return this.blockSource.getBlockData(query); + } + + public async getBlocks( from: BlockNumber, limit: number, options: Opts = {} as Opts, ): Promise[]> { const wantTxs = !!options.includeTransactions; const wantContext = !!options.includeL1PublishInfo || !!options.includeAttestations; + const onlyCheckpointed = !!options.onlyCheckpointed; if (wantTxs) { - const blocks = await this.blockSource.getBlocks({ from, limit }); + const blocks = await this.blockSource.getBlocks({ from, limit, onlyCheckpointed }); const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(wantContext ? blocks : []); return (await Promise.all( blocks.map(block => blockResponseFromL2Block(block, options, ctxByCheckpoint.get(block.checkpointNumber))), )) as BlockResponse[]; } - const dataItems = await this.blockSource.getBlocksData({ from, limit }); + const dataItems = await this.blockSource.getBlocksData({ from, limit, onlyCheckpointed }); const ctxByCheckpoint = await this.#getCheckpointContextsForBlocks(wantContext ? dataItems : []); return (await Promise.all( dataItems.map(data => blockResponseFromBlockData(data, options, ctxByCheckpoint.get(data.checkpointNumber))), @@ -421,7 +396,6 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb async #getCheckpointContextsForBlocks( blocks: { checkpointNumber: CheckpointNumber }[], // TODO(palla): CheckpointNumber should be accepted by this lint rule - // eslint-disable-next-line aztec-custom/no-non-primitive-in-collections ): Promise> { const unique = Array.from(new Set(blocks.map(b => b.checkpointNumber))); const entries = await Promise.all(unique.map(async n => [n, await this.#getCheckpointContext(n)] as const)); @@ -1368,17 +1342,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb public async getL1ToL2MessageCheckpoint(l1ToL2Message: Fr): Promise { const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message); - return messageIndex ? InboxLeaf.checkpointNumberFromIndex(messageIndex) : undefined; - } - - /** - * Returns whether an L1 to L2 message is synced by archiver and if it's ready to be included in a block. - * @param l1ToL2Message - The L1 to L2 message to check. - * @returns Whether the message is synced and ready to be included in a block. - */ - public async isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise { - const messageIndex = await this.l1ToL2MessageSource.getL1ToL2MessageIndex(l1ToL2Message); - return messageIndex !== undefined; + return messageIndex !== undefined ? InboxLeaf.checkpointNumberFromIndex(messageIndex) : undefined; } /** diff --git a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts index 788a08ef828e..f2db3a0ba546 100644 --- a/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts +++ b/yarn-project/end-to-end/src/bench/node_rpc_perf.test.ts @@ -292,9 +292,9 @@ describe('e2e_node_rpc_perf', () => { expect(stats.avg).toBeLessThan(1000); }); - it('benchmarks getL2Tips', async () => { - const { stats } = await benchmark('getL2Tips', () => aztecNode.getL2Tips()); - addResult('getL2Tips', stats); + it('benchmarks getChainTips', async () => { + const { stats } = await benchmark('getChainTips', () => aztecNode.getChainTips()); + addResult('getChainTips', stats); expect(stats.avg).toBeLessThan(1000); }); @@ -304,9 +304,9 @@ describe('e2e_node_rpc_perf', () => { expect(stats.avg).toBeLessThan(3000); }); - it('benchmarks getBlockHeader', async () => { - const { stats } = await benchmark('getBlockHeader', () => aztecNode.getBlockHeader(BlockNumber(blockNumber))); - addResult('getBlockHeader', stats); + it('benchmarks getBlockData', async () => { + const { stats } = await benchmark('getBlockData', () => aztecNode.getBlockData(BlockNumber(blockNumber))); + addResult('getBlockData', stats); expect(stats.avg).toBeLessThan(2000); }); @@ -317,10 +317,16 @@ describe('e2e_node_rpc_perf', () => { expect(stats.avg).toBeLessThan(5000); }); - it('benchmarks getCheckpointedBlocks (5 blocks)', async () => { + it('benchmarks getBlocks_checkpointed (5 blocks)', async () => { const fromBlock = BlockNumber(Math.max(1, blockNumber - 4)); - const { stats } = await benchmark('getCheckpointedBlocks', () => aztecNode.getCheckpointedBlocks(fromBlock, 5)); - addResult('getCheckpointedBlocks_5', stats); + const { stats } = await benchmark('getBlocks_checkpointed', () => + aztecNode.getBlocks(fromBlock, 5, { + includeL1PublishInfo: true, + includeAttestations: true, + onlyCheckpointed: true, + }), + ); + addResult('getBlocks_checkpointed_5', stats); expect(stats.avg).toBeLessThan(5000); }); @@ -332,9 +338,11 @@ describe('e2e_node_rpc_perf', () => { expect(stats.avg).toBeLessThan(3000); }); - it('benchmarks getBlock header by archive', async () => { - const { stats } = await benchmark('getBlockHeaderByArchive', () => aztecNode.getBlock({ archive: blockArchive })); - addResult('getBlockHeaderByArchive', stats); + it('benchmarks getBlockData by archive', async () => { + const { stats } = await benchmark('getBlockData_byArchive', () => + aztecNode.getBlockData({ archive: blockArchive }), + ); + addResult('getBlockData_byArchive', stats); expect(stats.avg).toBeLessThan(2000); }); }); diff --git a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts index 0fb6aa174db5..9a228508423b 100644 --- a/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts +++ b/yarn-project/end-to-end/src/composed/ha/e2e_ha_full.test.ts @@ -343,7 +343,12 @@ describe('HA Full Setup', () => { logger.info(`Contract deployed in block ${receipt.blockNumber}`); // Get the block with attestations - const [block] = await aztecNode.getCheckpointedBlocks(receipt.blockNumber!, 1); + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } @@ -455,7 +460,12 @@ describe('HA Full Setup', () => { logger.info(`Transaction mined in block ${receipt.blockNumber}`); // Get the slot of the block that was just built - const [block] = await aztecNode.getCheckpointedBlocks(receipt.blockNumber!, 1); + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } @@ -608,7 +618,12 @@ describe('HA Full Setup', () => { from: ownerAddress, }); expect(receipt.receipt.blockNumber).toBeDefined(); - const [block] = await aztecNode.getCheckpointedBlocks(receipt.receipt.blockNumber!, 1); + const [block] = await aztecNode.getBlocks(receipt.receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); const [cp] = await aztecNode.getCheckpoints(block!.checkpointNumber, 1, { includeAttestations: true }); const att = (cp.attestations ?? []).filter(a => !a.signature.isEmpty()); expect(att.length).toBeGreaterThanOrEqual(quorum); @@ -660,7 +675,12 @@ describe('HA Full Setup', () => { receipts.push(receipt); // Find which node produced this block - const [block] = await aztecNode.getCheckpointedBlocks(receipt.blockNumber!, 1); + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } @@ -723,7 +743,12 @@ describe('HA Full Setup', () => { // Verify no double-signing occurred across all blocks const quorum = Math.floor((COMMITTEE_SIZE * 2) / 3) + 1; for (const receipt of receipts) { - const [block] = await aztecNode.getCheckpointedBlocks(receipt.blockNumber!, 1); + const [block] = await aztecNode.getBlocks(receipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); if (!block) { throw new Error(`Block ${receipt.blockNumber} not found`); } diff --git a/yarn-project/end-to-end/src/e2e_block_building.test.ts b/yarn-project/end-to-end/src/e2e_block_building.test.ts index 0cd67c7bf181..a89b49c30949 100644 --- a/yarn-project/end-to-end/src/e2e_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_block_building.test.ts @@ -591,7 +591,7 @@ describe('e2e_block_building', () => { await Promise.race(txHashes.map(txHash => waitForTx(aztecNode, txHash, { timeout: 60 }))); logger.warn(`At least one tx has been mined`); - const lastBlock = await context.aztecNode.getBlockHeader('latest'); + const lastBlock = (await context.aztecNode.getBlockData('latest'))?.header; expect(lastBlock).toBeDefined(); logger.warn(`Latest block is ${lastBlock!.getBlockNumber()}`, { state: lastBlock?.state.partial }); diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts index 1f8a1ab77649..e367b87028f6 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/l1_to_l2.test.ts @@ -67,7 +67,11 @@ describe('e2e_cross_chain_messaging l1_to_l2', () => { if (!isCheckpointed) { return undefined; } - const [checkpointedBlock] = await aztecNode.getCheckpointedBlocks(blockNumber, 1); + const [checkpointedBlock] = await aztecNode.getBlocks(blockNumber, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + onlyCheckpointed: true, + }); return checkpointedBlock.checkpointNumber; }, 'wait for block to checkpoint', @@ -206,7 +210,11 @@ describe('e2e_cross_chain_messaging l1_to_l2', () => { async (scope: 'private' | 'public') => { // Stop proving const lastProven = await aztecNode.getBlockNumber(); - const [checkpointedProvenBlock] = await aztecNode.getCheckpointedBlocks(lastProven, 1); + const [checkpointedProvenBlock] = await aztecNode.getBlocks(lastProven, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + onlyCheckpointed: true, + }); log.warn(`Stopping proof submission at checkpoint ${checkpointedProvenBlock.checkpointNumber} to allow drift`); t.context.watcher.setIsMarkingAsProven(false); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_equivocation.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_equivocation.test.ts index d1d5aa747698..a72862c14d1d 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_equivocation.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_equivocation.test.ts @@ -261,7 +261,7 @@ describe('e2e_epochs/epochs_equivocation', () => { [nodeB, nodeC, nodeD].map((node, idx) => retryUntil( async () => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); return tips.checkpointed.checkpoint.number >= healTarget; }, `${'BCD'[idx]} synced to checkpoint ${healTarget}`, diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts index ba1983f0fe93..33c8c1ae5f1c 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_high_tps_block_building.test.ts @@ -171,7 +171,12 @@ describe('e2e_epochs/epochs_high_tps_block_building', () => { // checkpoints whose target slot is at or after the slot we waited for, assert every checkpoint is fully // filled (BLOCKS_PER_CHECKPOINT blocks × TXS_PER_BLOCK txs each) and the checkpoint tx landed in the 1st // or 2nd L1 block of the target slot. - const blocks = await nodes[0].getCheckpointedBlocks(BlockNumber(1), 50); + const blocks = await nodes[0].getBlocks(BlockNumber(1), 50, { + includeL1PublishInfo: true, + includeAttestations: true, + includeTransactions: true, + onlyCheckpointed: true, + }); const ethereumSlotDuration = test.L1_BLOCK_TIME_IN_S; const checkpoints = chunkBy(blocks, b => Number(b.checkpointNumber)); let checkedFullCheckpoints = 0; diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts index aaa4220dc5b4..1f952fd76ef6 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_invalidate_block.parallel.test.ts @@ -172,7 +172,7 @@ describe('e2e_epochs/epochs_invalidate_block', () => { disableConfig: Record; }) { const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; + const initialCheckpointNumber = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; sequencers.forEach(sequencer => { sequencer.updateConfig({ ...opts.attackConfig, minTxsPerBlock: 0 }); @@ -264,7 +264,7 @@ describe('e2e_epochs/epochs_invalidate_block', () => { it('proposer invalidates previous checkpoint with multiple blocks while posting its own', async () => { const sequencers = nodes.map(node => node.getSequencer()!); const [initialCheckpointNumber, initialBlockNumber] = await nodes[0] - .getL2Tips() + .getChainTips() .then(t => [t.checkpointed.checkpoint.number, t.checkpointed.block.number] as const); // Configure all sequencers to skip collecting attestations before starting @@ -376,7 +376,7 @@ describe('e2e_epochs/epochs_invalidate_block', () => { logger.warn(`Started all sequencers, waiting for first checkpoint before applying malicious config`); // Wait for at least one checkpoint to be mined so that any in-progress slot has completed - const initialCheckpointNumber = (await nodes[0].getL2Tips()).checkpointed.checkpoint.number; + const initialCheckpointNumber = (await nodes[0].getChainTips()).checkpointed.checkpoint.number; await test.waitUntilCheckpointNumber(CheckpointNumber(initialCheckpointNumber + 1), test.L2_SLOT_DURATION_IN_S * 4); const { l2SlotNumber: currentSlot } = await test.monitor.run(); logger.warn(`First checkpoint mined, current slot is ${currentSlot}`); @@ -469,7 +469,7 @@ describe('e2e_epochs/epochs_invalidate_block', () => { // instead waits for a committee member to invalidate the block after several proposers not doing so. it('committee member invalidates a block if proposer does not come through', async () => { const sequencers = nodes.map(node => node.getSequencer()!); - const initialCheckpointNumber = await nodes[0].getL2Tips().then(t => t.checkpointed.checkpoint.number); + const initialCheckpointNumber = await nodes[0].getChainTips().then(t => t.checkpointed.checkpoint.number); // Configure all sequencers to skip collecting attestations before starting logger.warn('Configuring all sequencers to skip attestation collection and invalidation as proposer'); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts index 18a6fe059fdb..a75838de66ba 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_l1_reorgs.parallel.test.ts @@ -4,6 +4,7 @@ import { AztecAddress } from '@aztec/aztec.js/addresses'; import { NO_WAIT } from '@aztec/aztec.js/contracts'; import { Fr } from '@aztec/aztec.js/fields'; import type { Logger } from '@aztec/aztec.js/log'; +import { isL1ToL2MessageReady, waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import type { AztecNode } from '@aztec/aztec.js/node'; import { createBlobClient } from '@aztec/blob-client/client'; import { Blob } from '@aztec/blob-lib'; @@ -101,10 +102,12 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { }; /** Returns the last synced checkpoint number for a node */ - const getCheckpointNumber = (node: AztecNode) => node.getL2Tips().then(tips => tips.checkpointed.checkpoint.number); + const getCheckpointNumber = (node: AztecNode) => + node.getChainTips().then(tips => tips.checkpointed.checkpoint.number); /** Returns the last proven checkpoint number for a node */ - const getProvenCheckpointNumber = (node: AztecNode) => node.getL2Tips().then(tips => tips.proven.checkpoint.number); + const getProvenCheckpointNumber = (node: AztecNode) => + node.getChainTips().then(tips => tips.proven.checkpoint.number); it('prunes L2 blocks if a proof is removed due to an L1 reorg', async () => { /** Logs a full state snapshot: L1 latest/finalized and archiver L2 tips. */ @@ -469,12 +472,9 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { }); logger.warn(`Sent messages on L1 blocks ${msgs.map(m => m.txReceipt.blockNumber)}`); - await retryUntil( - () => node.isL1ToL2MessageSynced(msgs.at(-1)!.msgHash), - 'message sync', - msgs.length * L1_BLOCK_TIME_IN_S * 2, - 1, - ); + await waitForL1ToL2MessageReady(node, msgs.at(-1)!.msgHash, { + timeoutSeconds: msgs.length * L1_BLOCK_TIME_IN_S * 2, + }); // Reorg the last message out logger.warn(`Triggering reorg to remove last message`); @@ -485,9 +485,9 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { logger.warn(`Sent new message on L1 block ${newMsg.txReceipt.blockNumber}`); // New msg gets synced, and old one is out - await retryUntil(() => node.isL1ToL2MessageSynced(newMsg.msgHash), 'new message sync', L1_BLOCK_TIME_IN_S * 6, 1); - expect(await node.isL1ToL2MessageSynced(msgs[0].msgHash)).toBe(true); - expect(await node.isL1ToL2MessageSynced(msgs.at(-1)!.msgHash)).toBe(false); + await waitForL1ToL2MessageReady(node, newMsg.msgHash, { timeoutSeconds: L1_BLOCK_TIME_IN_S * 6 }); + expect(await isL1ToL2MessageReady(node, msgs[0].msgHash)).toBe(true); + expect(await isL1ToL2MessageReady(node, msgs.at(-1)!.msgHash)).toBe(false); // Verify multi-block checkpoints were built await test.assertMultipleBlocksPerSlot(2); @@ -502,7 +502,7 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { logger.warn(`Sending first cross chain message`); const firstMsg = await sendMessage(); logger.warn(`Sent first message on L1 block ${firstMsg.txReceipt.blockNumber}`); - await retryUntil(() => node.isL1ToL2MessageSynced(firstMsg.msgHash), '1st msg sync', L1_BLOCK_TIME_IN_S * 3, 1); + await waitForL1ToL2MessageReady(node, firstMsg.msgHash, { timeoutSeconds: L1_BLOCK_TIME_IN_S * 3 }); logger.warn(`Synced first message`); // Next message shall not land @@ -528,7 +528,7 @@ describe('e2e_epochs/epochs_l1_reorgs', () => { // Archiver should see the new message and should be able to accept a third one on top, without any rolling hash issues logger.warn(`Reorged-in second message on L1 block ${secondMsg.txReceipt.blockNumber}. Sending third message.`); const thirdMsg = await sendMessage(); - await retryUntil(() => node.isL1ToL2MessageSynced(thirdMsg.msgHash), '3rd msg sync', L1_BLOCK_TIME_IN_S * 3, 1); + await waitForL1ToL2MessageReady(node, thirdMsg.msgHash, { timeoutSeconds: L1_BLOCK_TIME_IN_S * 3 }); // Verify multi-block checkpoints were built await test.assertMultipleBlocksPerSlot(2); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index 881483e8c801..75d9f1584bc5 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -299,7 +299,8 @@ describe('e2e_epochs/epochs_mbps', () => { // wait for the other node to synch const maxBlockNumber = Math.max(...receipts.map(r => r.blockNumber!)); await retryUntil( - async () => ((await archiver.getCheckpointedL2BlockNumber()) >= maxBlockNumber ? true : undefined), + async () => + ((await archiver.getBlockNumber({ tag: 'checkpointed' })) ?? 0) >= maxBlockNumber ? true : undefined, `archiver to checkpoint block ${maxBlockNumber}`, test.L2_SLOT_DURATION_IN_S * 3, 0.1, @@ -576,12 +577,26 @@ describe('e2e_epochs/epochs_mbps', () => { // Verify both blocks belong to the same checkpoint. const deployCheckpointedBlock = await retryUntil( - async () => (await context.aztecNode.getCheckpointedBlocks(deployReceipt.blockNumber!, 1))[0], + async () => + ( + await context.aztecNode.getBlocks(deployReceipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + onlyCheckpointed: true, + }) + )[0], 'deploy checkpointed block', timeout, ); const callCheckpointedBlock = await retryUntil( - async () => (await context.aztecNode.getCheckpointedBlocks(callReceipt.blockNumber!, 1))[0], + async () => + ( + await context.aztecNode.getBlocks(callReceipt.blockNumber!, 1, { + includeL1PublishInfo: true, + includeAttestations: true, + onlyCheckpointed: true, + }) + )[0], 'call checkpointed block', timeout, ); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts index 49a99125455d..17f5e913a8d7 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_publish.test.ts @@ -11,8 +11,9 @@ import { SecretValue } from '@aztec/foundation/config'; import { retryUntil } from '@aztec/foundation/retry'; import { bufferToHex } from '@aztec/foundation/string'; import { timeoutPromise } from '@aztec/foundation/timer'; -import { type L2Block, L2BlockSourceEvents, type L2Tips } from '@aztec/stdlib/block'; +import { type L2Block, L2BlockSourceEvents } from '@aztec/stdlib/block'; import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import type { ChainTips } from '@aztec/stdlib/interfaces/server'; import { jest } from '@jest/globals'; import { privateKeyToAccount } from 'viem/accounts'; @@ -181,14 +182,14 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { // We capture the L2 tips synchronously inside the handler — the archiver has already removed // the pruned blocks at emit time, so this snapshot reflects the rolled-back state before any // new pipelined block can be applied. - type PruneObservation = { slotNumber: SlotNumber; blocks: L2Block[]; tipsAtPrune: L2Tips }; + type PruneObservation = { slotNumber: SlotNumber; blocks: L2Block[]; tipsAtPrune: ChainTips }; const prunePromises: Promise[] = nodes.map( (node, idx) => new Promise(resolve => { const archiver = node.getBlockSource() as Archiver; // eslint-disable-next-line @typescript-eslint/no-misused-promises archiver.events.once(L2BlockSourceEvents.L2PruneUncheckpointed, async ev => { - const tipsAtPrune = await node.getL2Tips(); + const tipsAtPrune = await node.getChainTips(); logger.warn(`Node ${idx} pruned uncheckpointed blocks`, { slotNumber: ev.slotNumber, blocks: ev.blocks.map(b => ({ number: b.number, slot: b.header.globalVariables.slotNumber })), @@ -234,7 +235,7 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { nodes.map((node, idx) => retryUntil( async () => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); if (tips.proposed.number === 0) { return false; } @@ -254,7 +255,7 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { nodes.map((node, idx) => retryUntil( async () => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); if (tips.proposed.number === 0) { return false; } @@ -311,7 +312,7 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { nodes.map((node, idx) => retryUntil( async () => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); if (tips.proposed.number === 0) { return false; } @@ -339,7 +340,7 @@ describe('e2e_epochs/epochs_missed_l1_publish', () => { nodes.map((node, idx) => retryUntil( async () => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); if (tips.checkpointed.checkpoint.number === 0) { return false; } diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 4a0fa3336427..7884c1c1b844 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -398,7 +398,7 @@ export class EpochsTestContext { await sleep(waitTime); const [syncState, tips] = await Promise.all([ this.context.aztecNode.getWorldStateSyncStatus(), - await this.context.aztecNode.getL2Tips(), + await this.context.aztecNode.getChainTips(), ]); this.logger.info(`Wait for node synch ${blockNumber} ${type}`, { blockNumber, type, syncState, tips }); if (type === 'proven') { diff --git a/yarn-project/end-to-end/src/e2e_expiration_timestamp.test.ts b/yarn-project/end-to-end/src/e2e_expiration_timestamp.test.ts index 8ba0b26759bd..7f8700cd7d5a 100644 --- a/yarn-project/end-to-end/src/e2e_expiration_timestamp.test.ts +++ b/yarn-project/end-to-end/src/e2e_expiration_timestamp.test.ts @@ -34,7 +34,7 @@ describe('e2e_expiration_timestamp', () => { let expirationTimestamp: bigint; beforeEach(async () => { - const header = await aztecNode.getBlockHeader('latest'); + const header = (await aztecNode.getBlockData('latest'))?.header; if (!header) { throw new Error('Block header not found in the setup of e2e_expiration_timestamp.test.ts'); } @@ -87,7 +87,7 @@ describe('e2e_expiration_timestamp', () => { let expirationTimestamp: bigint; beforeEach(async () => { - const header = await aztecNode.getBlockHeader('latest'); + const header = (await aztecNode.getBlockData('latest'))?.header; if (!header) { throw new Error('Block header not found in the setup of e2e_expiration_timestamp.test.ts'); } @@ -142,7 +142,7 @@ describe('e2e_expiration_timestamp', () => { let expirationTimestamp: bigint; beforeEach(async () => { - const header = await aztecNode.getBlockHeader('latest'); + const header = (await aztecNode.getBlockData('latest'))?.header; if (!header) { throw new Error('Block header not found in the setup of e2e_expiration_timestamp.test.ts'); } diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 84eca5503904..4b136bd07a9b 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -293,9 +293,6 @@ describe('L1Publisher integration', () => { getBlockNumber(): Promise { return Promise.resolve(BlockNumber(blocks.at(-1)?.number ?? BlockNumber.ZERO)); }, - getProvenBlockNumber(): Promise { - return Promise.resolve(BlockNumber(blocks.at(-1)?.number ?? BlockNumber.ZERO)); - }, }); const worldStateConfig: WorldStateConfig = { diff --git a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts index 57fe3aa3491e..52087116c316 100644 --- a/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts +++ b/yarn-project/end-to-end/src/e2e_offchain_payment.test.ts @@ -63,7 +63,7 @@ describe('e2e_offchain_payment', () => { // between the retryUntil returning and pauseSync executing. await retryUntil( async () => { - const tips = await aztecNode.getL2Tips(); + const tips = await aztecNode.getChainTips(); if (tips.checkpointed.block.number >= block) { await aztecNodeAdmin.pauseSync(); return true; diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index fb5bd2afffd9..28a9a5d105ae 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -13,7 +13,6 @@ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses' import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; -import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { GovernanceAbi, @@ -43,6 +42,7 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNodes, createProverNode } from '../fixtures/setup_p2p_test.js'; import { setupSharedBlobStorage } from '../fixtures/utils.js'; +import { waitForL1ToL2MessageSeen } from '../shared/wait_for_l1_to_l2_message.js'; import { TestWallet } from '../test-wallet/test_wallet.js'; import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES } from './p2p_network.js'; @@ -307,8 +307,7 @@ describe('e2e_p2p_add_rollup', () => { }); const makeMessageConsumable = async (msgHash: Fr) => { - // We poll isL1ToL2MessageSynced endpoint until the message is available - await retryUntil(async () => await node.isL1ToL2MessageSynced(msgHash), 'message sync', 10); + await waitForL1ToL2MessageSeen(node, msgHash, { timeoutSeconds: 10 }); const { receipt } = await testContract.methods .create_l2_to_l1_message_arbitrary_recipient_private(contentOutFromRollup, ethRecipient) diff --git a/yarn-project/end-to-end/src/e2e_simple.test.ts b/yarn-project/end-to-end/src/e2e_simple.test.ts index c235a1c892d0..0732b761fb88 100644 --- a/yarn-project/end-to-end/src/e2e_simple.test.ts +++ b/yarn-project/end-to-end/src/e2e_simple.test.ts @@ -53,7 +53,7 @@ describe('e2e_simple', () => { afterAll(() => teardown()); it('returns initial block data', async () => { - const initialHeader = await aztecNode.getBlockHeader(BlockNumber.ZERO); + const initialHeader = (await aztecNode.getBlockData(BlockNumber.ZERO))?.header; expect(initialHeader).toBeDefined(); const initialHeaderHash = await initialHeader!.hash(); const initialBlockByHash = await aztecNode.getBlock(initialHeaderHash, { includeTransactions: true }); diff --git a/yarn-project/end-to-end/src/e2e_snapshot_sync.test.ts b/yarn-project/end-to-end/src/e2e_snapshot_sync.test.ts index 818423f7b621..965a158c367f 100644 --- a/yarn-project/end-to-end/src/e2e_snapshot_sync.test.ts +++ b/yarn-project/end-to-end/src/e2e_snapshot_sync.test.ts @@ -71,7 +71,7 @@ describe('e2e_snapshot_sync', () => { }; const expectNodeSyncedToL2Block = async (node: AztecNode, blockNumber: number) => { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); expect(tips.proposed.number).toBeGreaterThanOrEqual(blockNumber); const worldState = await node.getWorldStateSyncStatus(); expect(worldState.latestBlockNumber).toBeGreaterThanOrEqual(blockNumber); diff --git a/yarn-project/end-to-end/src/e2e_state_vars.test.ts b/yarn-project/end-to-end/src/e2e_state_vars.test.ts index 29435110942e..d1f1c32d0644 100644 --- a/yarn-project/end-to-end/src/e2e_state_vars.test.ts +++ b/yarn-project/end-to-end/src/e2e_state_vars.test.ts @@ -387,7 +387,7 @@ describe('e2e_state_vars', () => { // can happen at the anchor timestamp itself. For this reason, the latest timestamp at which a change is // guaranteed to not have happened is the anchor timestamp + the new delay - 1. const expectedModifiedExpirationTimestamp = - (await aztecNode.getBlockHeader('latest'))!.globalVariables.timestamp + newDelay - 1n; + (await aztecNode.getBlockData('latest'))!.header.globalVariables.timestamp + newDelay - 1n; // We now call our AuthContract to see if the change in expiration timestamp has reflected our delay change const tx = await proveInteraction(wallet, authContract.methods.get_authorized_in_private(), { diff --git a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts index c1b2b39780eb..cdfd91e4af3a 100644 --- a/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/cross_chain_test_harness.ts @@ -17,7 +17,6 @@ import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { EpochNumber } from '@aztec/foundation/branded-types'; -import { retryUntil } from '@aztec/foundation/retry'; import { TestERC20Abi, TokenPortalAbi, TokenPortalBytecode } from '@aztec/l1-artifacts'; import { TokenContract } from '@aztec/noir-contracts.js/Token'; import { TokenBridgeContract } from '@aztec/noir-contracts.js/TokenBridge'; @@ -25,6 +24,7 @@ import { TokenBridgeContract } from '@aztec/noir-contracts.js/TokenBridge'; import { type Hex, getContract } from 'viem'; import { mintTokensToPrivate } from '../fixtures/token_utils.js'; +import { waitForL1ToL2MessageSeen } from './wait_for_l1_to_l2_message.js'; /** * Deploy L1 token and portal, initialize portal, deploy a non native l2 token contract, its L2 bridge contract and attach is to the portal. @@ -346,8 +346,7 @@ export class CrossChainTestHarness { */ async makeMessageConsumable(msgHash: Fr | Hex) { const frMsgHash = typeof msgHash === 'string' ? Fr.fromHexString(msgHash) : msgHash; - // We poll isL1ToL2MessageSynced endpoint until the message is available - await retryUntil(async () => await this.aztecNode.isL1ToL2MessageSynced(frMsgHash), 'message sync', 10); + await waitForL1ToL2MessageSeen(this.aztecNode, frMsgHash, { timeoutSeconds: 10 }); await this.mintTokensPublicOnL2(0n); await this.mintTokensPublicOnL2(0n); diff --git a/yarn-project/end-to-end/src/shared/gas_portal_test_harness.ts b/yarn-project/end-to-end/src/shared/gas_portal_test_harness.ts index 4ba87e8bc1d0..e894e77862a7 100644 --- a/yarn-project/end-to-end/src/shared/gas_portal_test_harness.ts +++ b/yarn-project/end-to-end/src/shared/gas_portal_test_harness.ts @@ -10,6 +10,8 @@ import { FeeJuiceContract } from '@aztec/noir-contracts.js/FeeJuice'; import { ProtocolContractAddress } from '@aztec/protocol-contracts'; import type { AztecNodeAdmin } from '@aztec/stdlib/interfaces/client'; +import { waitForL1ToL2MessageSeen } from './wait_for_l1_to_l2_message.js'; + export interface IGasBridgingTestHarness { getL1FeeJuiceBalance(address: EthAddress): Promise; prepareTokensOnL1(owner: AztecAddress): Promise; @@ -144,8 +146,7 @@ export class GasBridgingTestHarness implements IGasBridgingTestHarness { await this.mintTokensOnL1(); const claim = await this.sendTokensToPortalPublic(bridgeAmount, owner); - const isSynced = async () => await this.aztecNode.isL1ToL2MessageSynced(Fr.fromHexString(claim.messageHash)); - await retryUntil(isSynced, `message ${claim.messageHash} sync`, 24, 1); + await waitForL1ToL2MessageSeen(this.aztecNode, Fr.fromHexString(claim.messageHash), { timeoutSeconds: 24 }); // Progress by 2 L2 blocks so that the l1ToL2Message added above will be available to use on L2. await this.advanceL2Block(); diff --git a/yarn-project/end-to-end/src/shared/wait_for_l1_to_l2_message.ts b/yarn-project/end-to-end/src/shared/wait_for_l1_to_l2_message.ts new file mode 100644 index 000000000000..ddce9a05c061 --- /dev/null +++ b/yarn-project/end-to-end/src/shared/wait_for_l1_to_l2_message.ts @@ -0,0 +1,23 @@ +import type { Fr } from '@aztec/aztec.js/fields'; +import type { AztecNode } from '@aztec/aztec.js/node'; +import { retryUntil } from '@aztec/foundation/retry'; + +/** + * Waits until the archiver has seen the L1 to L2 message and indexed it. Unlike + * {@link waitForL1ToL2MessageReady} from `@aztec/aztec.js/messaging`, this does not + * require the L2 chain to have advanced to the message's checkpoint — it only confirms + * the message has been picked up from L1. Use this in tests that explicitly produce L2 + * blocks afterwards to make the message consumable. + */ +export function waitForL1ToL2MessageSeen( + node: Pick, + l1ToL2MessageHash: Fr, + opts: { timeoutSeconds: number }, +) { + return retryUntil( + async () => (await node.getL1ToL2MessageCheckpoint(l1ToL2MessageHash)) !== undefined, + `L1 to L2 message ${l1ToL2MessageHash.toString()} seen`, + opts.timeoutSeconds, + 1, + ); +} diff --git a/yarn-project/end-to-end/src/spartan/mbps.test.ts b/yarn-project/end-to-end/src/spartan/mbps.test.ts index 4467dc5832b0..774965975c09 100644 --- a/yarn-project/end-to-end/src/spartan/mbps.test.ts +++ b/yarn-project/end-to-end/src/spartan/mbps.test.ts @@ -178,7 +178,7 @@ describe('multi-blocks-per-slot network test', () => { config.AZTEC_SLOT_DURATION * 10, 2, ); - const tipsAtReceipts = await aztecNode.getL2Tips(); + const tipsAtReceipts = await aztecNode.getChainTips(); logger.info( `Tips after receipts: proposed=${tipsAtReceipts.proposed.number}, checkpointed=${tipsAtReceipts.checkpointed.block.number}`, ); @@ -194,7 +194,7 @@ describe('multi-blocks-per-slot network test', () => { (_, i) => minTxBlockNumber + i, ); const allHeaders = await Promise.all( - allBlockNumbers.map(blockNumber => aztecNode.getBlockHeader(BlockNumber(blockNumber))), + allBlockNumbers.map(blockNumber => aztecNode.getBlockData(BlockNumber(blockNumber)).then(b => b?.header)), ); if (allHeaders.some(header => !header)) { throw new Error('Failed to load block headers for block range'); @@ -237,7 +237,7 @@ describe('multi-blocks-per-slot network test', () => { expect(blocksInSlotCount).toBeGreaterThanOrEqual(2); await retryUntil( async () => { - const tips = await aztecNode.getL2Tips(); + const tips = await aztecNode.getChainTips(); return Number(tips.checkpointed.block.number) >= maxTxBlockNumber; }, 'checkpointed tip to reach multi-block slot', diff --git a/yarn-project/end-to-end/src/spartan/n_tps_prove.test.ts b/yarn-project/end-to-end/src/spartan/n_tps_prove.test.ts index 3519bdce1092..7dd82411b8aa 100644 --- a/yarn-project/end-to-end/src/spartan/n_tps_prove.test.ts +++ b/yarn-project/end-to-end/src/spartan/n_tps_prove.test.ts @@ -551,7 +551,7 @@ describe(`prove ${TARGET_TPS}TPS test`, () => { const maxBlockPerEpoch = new Map(); for (const [blockNum, txCount] of txsPerBlock) { - const header = await aztecNode.getBlockHeader(BlockNumber(blockNum)); + const header = (await aztecNode.getBlockData(BlockNumber(blockNum)))?.header; const epoch = Math.floor(Number(header!.getSlot()) / epochDurationSlots); txsPerEpoch.set(epoch, (txsPerEpoch.get(epoch) ?? 0) + txCount); maxBlockPerEpoch.set(epoch, Math.max(maxBlockPerEpoch.get(epoch) ?? 0, blockNum)); diff --git a/yarn-project/end-to-end/src/spartan/setup_test_wallets.ts b/yarn-project/end-to-end/src/spartan/setup_test_wallets.ts index 8f6be72d87e3..76a30d61c029 100644 --- a/yarn-project/end-to-end/src/spartan/setup_test_wallets.ts +++ b/yarn-project/end-to-end/src/spartan/setup_test_wallets.ts @@ -6,6 +6,7 @@ import { L1FeeJuicePortalManager } from '@aztec/aztec.js/ethereum'; import { FeeJuicePaymentMethodWithClaim } from '@aztec/aztec.js/fee'; import { type FeePaymentMethod, SponsoredFeePaymentMethod } from '@aztec/aztec.js/fee'; import { Fr } from '@aztec/aztec.js/fields'; +import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { type AztecNode, createAztecNodeClient, waitForTx } from '@aztec/aztec.js/node'; import type { Wallet } from '@aztec/aztec.js/wallet'; import { createEthereumChain } from '@aztec/ethereum/chain'; @@ -332,9 +333,7 @@ async function bridgeL1FeeJuice( const portal = await L1FeeJuicePortalManager.new(aztecNode, l1Client, log); const claim = await portal.bridgeTokensPublic(recipient, amount, true /* mint */); - const isSynced = async () => - (await aztecNode.getL1ToL2MessageCheckpoint(Fr.fromHexString(claim.messageHash))) !== undefined; - await retryUntil(isSynced, `message ${claim.messageHash} sync`, 24, 0.5); + await waitForL1ToL2MessageReady(aztecNode, Fr.fromHexString(claim.messageHash), { timeoutSeconds: 24 }); log.info(`Created a claim for ${amount} L1 fee juice to ${recipient}.`, claim); return claim; diff --git a/yarn-project/end-to-end/src/spartan/utils/nodes.ts b/yarn-project/end-to-end/src/spartan/utils/nodes.ts index c31c182f80aa..5f44c3384759 100644 --- a/yarn-project/end-to-end/src/spartan/utils/nodes.ts +++ b/yarn-project/end-to-end/src/spartan/utils/nodes.ts @@ -71,7 +71,7 @@ export async function waitForProvenToAdvance( // Get current proven block number let initialProvenBlock: number; try { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); initialProvenBlock = Number(tips.proven.block.number); log.info(`Current proven block: ${initialProvenBlock}. Waiting for it to increase...`); } catch (err) { @@ -82,7 +82,7 @@ export async function waitForProvenToAdvance( await retryUntil( async () => { try { - const tips = await node.getL2Tips(); + const tips = await node.getChainTips(); const currentProvenBlock = Number(tips.proven.block.number); const proposedBlock = Number(tips.proposed.number); diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 06386b609cde..be44da36cab8 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -639,7 +639,7 @@ export class P2PClient extends WithTracer implements P2P { // are already proven, but the archiver has not yet updated its state. Until this is properly // fixed, it is mitigated by the expiration date of collection requests, which depends on // the slot number of the block. - const provenBlockNumber = await this.l2BlockSource.getProvenBlockNumber(); + const provenBlockNumber = (await this.l2BlockSource.getBlockNumber({ tag: 'proven' })) ?? BlockNumber.ZERO; const unprovenBlocks = blocks.filter(block => block.number > provenBlockNumber); for (const block of unprovenBlocks) { const txHashes = block.body.txEffects.map(txEffect => txEffect.txHash); diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts index a32bb5d76933..4034787d11f7 100644 --- a/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.test.ts @@ -45,7 +45,10 @@ describe('EpochMonitor', () => { : ({ header: { getSlot: () => SlotNumber.fromBigInt(slot) } as unknown as BlockHeader } as any), ); }, - getProvenBlockNumber() { + getBlockNumber(query?: any) { + if (query && 'tag' in query && query.tag === 'proven') { + return Promise.resolve(BlockNumber(provenBlockNumber)); + } return Promise.resolve(BlockNumber(provenBlockNumber)); }, }); diff --git a/yarn-project/prover-node/src/monitors/epoch-monitor.ts b/yarn-project/prover-node/src/monitors/epoch-monitor.ts index 42694d81613b..ccb694119976 100644 --- a/yarn-project/prover-node/src/monitors/epoch-monitor.ts +++ b/yarn-project/prover-node/src/monitors/epoch-monitor.ts @@ -96,7 +96,7 @@ export class EpochMonitor implements Traceable { } private async getEpochNumberToProve() { - const lastBlockProven = await this.l2BlockSource.getProvenBlockNumber(); + const lastBlockProven = (await this.l2BlockSource.getBlockNumber({ tag: 'proven' })) ?? BlockNumber.ZERO; const firstBlockToProve = BlockNumber(lastBlockProven + 1); const firstBlockHeaderToProve = (await this.l2BlockSource.getBlockData({ number: firstBlockToProve }))?.header; if (!firstBlockHeaderToProve) { diff --git a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts index 82182ad8cac8..2009d350b2a1 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_stream_source.ts @@ -20,7 +20,10 @@ export function blockStreamSourceFromAztecNode( node: AztecNode, ): Pick { return { - getL2Tips: () => node.getL2Tips(), + getL2Tips: async () => { + const tips = await node.getChainTips(); + return { ...tips, proposedCheckpoint: tips.checkpointed }; + }, async getBlockData(query: BlockQuery): Promise { const response = await node.getBlock(query); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts index af264b14f6de..7f2e9a4ffeb2 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.test.ts @@ -5,6 +5,7 @@ import type { AztecAsyncKVStore } from '@aztec/kv-store'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { L2TipsKVStore } from '@aztec/kv-store/stores'; import { + type BlockData, BlockHash, GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH, @@ -149,7 +150,14 @@ describe('BlockSynchronizer', () => { }); blockStream.sync.mockReturnValue(syncBlocker); const genesisBlock = await L2Block.random(BlockNumber(0)); - aztecNode.getBlock.mockResolvedValue({ header: genesisBlock.header } as any); + const genesisBlockData: BlockData = { + header: genesisBlock.header, + archive: genesisBlock.archive, + blockHash: await genesisBlock.hash(), + checkpointNumber: genesisBlock.checkpointNumber, + indexWithinCheckpoint: genesisBlock.indexWithinCheckpoint, + }; + aztecNode.getBlockData.mockResolvedValue(genesisBlockData); // Start a sync (don't await) const syncPromise = synchronizer.sync(); diff --git a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts index 4a5d4fc15871..d523684b68fd 100644 --- a/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts +++ b/yarn-project/pxe/src/block_synchronizer/block_synchronizer.ts @@ -193,7 +193,7 @@ export class BlockSynchronizer implements L2BlockStreamEventHandler { } if (!currentHeader) { // REFACTOR: We should know the header of the genesis block without having to request it from the node. - await this.anchorBlockStore.setHeader((await this.node.getBlock(BlockNumber.ZERO))!.header); + await this.anchorBlockStore.setHeader((await this.node.getBlockData(BlockNumber.ZERO))!.header); } await this.blockStream.sync(); } diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 311716cd32e2..047fd854b605 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -1,6 +1,6 @@ import { BBBundlePrivateKernelProver } from '@aztec/bb-prover/client/bundle'; import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; -import { BlockNumber, CheckpointNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { AztecLMDBStoreV2, openTmpStore } from '@aztec/kv-store/lmdb-v2'; @@ -9,15 +9,21 @@ import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/prov import { WASMSimulator } from '@aztec/simulator/client'; import { EventSelector } from '@aztec/stdlib/abi'; import { AztecAddress } from '@aztec/stdlib/aztec-address'; -import { BlockHash, GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH } from '@aztec/stdlib/block'; +import { + type BlockData, + BlockHash, + GENESIS_BLOCK_HEADER_HASH, + GENESIS_CHECKPOINT_HEADER_HASH, +} from '@aztec/stdlib/block'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; -import type { AztecNode } from '@aztec/stdlib/interfaces/client'; +import type { AztecNode, BlockResponse } from '@aztec/stdlib/interfaces/client'; import { SiloedTag } from '@aztec/stdlib/logs'; import { randomContractArtifact, randomContractInstanceWithAddress, randomDeployedContract, } from '@aztec/stdlib/testing'; +import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { BlockHeader, GlobalVariables, TxHash } from '@aztec/stdlib/tx'; import { mock } from 'jest-mock-extended'; @@ -182,10 +188,28 @@ describe('PXE', () => { const blockHeader = BlockHeader.empty({ globalVariables, }); - node.getBlockHeader.mockResolvedValue(blockHeader); - node.getBlock.mockResolvedValue({ header: blockHeader } as any); + const blockHash = BlockHash.random(); + const archive = AppendOnlyTreeSnapshot.empty(); + const checkpointNumber = CheckpointNumber.fromBlockNumber(lastKnownBlockNumber); + const blockResponse: BlockResponse = { + header: blockHeader, + archive, + hash: blockHash, + checkpointNumber, + indexWithinCheckpoint: IndexWithinCheckpoint.ZERO, + number: lastKnownBlockNumber, + }; + const blockData: BlockData = { + header: blockHeader, + archive, + blockHash, + checkpointNumber, + indexWithinCheckpoint: IndexWithinCheckpoint.ZERO, + }; + node.getBlock.mockResolvedValue(blockResponse); + node.getBlockData.mockResolvedValue(blockData); - // Mock getL2Tips which is needed for syncing tagged logs + // Mock getChainTips which is needed for syncing tagged logs const tipId = { block: { number: lastKnownBlockNumber, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, checkpoint: { @@ -193,12 +217,11 @@ describe('PXE', () => { hash: GENESIS_CHECKPOINT_HEADER_HASH.toString(), }, }; - node.getL2Tips.mockResolvedValue({ + node.getChainTips.mockResolvedValue({ proposed: { number: lastKnownBlockNumber, hash: GENESIS_BLOCK_HEADER_HASH.toString() }, checkpointed: tipId, proven: tipId, finalized: tipId, - proposedCheckpoint: tipId, }); // This is read when PXE tries to resolve the @@ -289,7 +312,6 @@ describe('PXE', () => { describe('filtering', () => { let eventsInPastBlocks: PackedPrivateEvent[]; let eventsInLatestKnownBlock: PackedPrivateEvent[]; - let _eventsInNotYetSyncedBlocks: PackedPrivateEvent[]; beforeEach(async () => { eventsInPastBlocks = await Promise.all([ @@ -302,10 +324,8 @@ describe('PXE', () => { storeEvent(lastKnownBlockNumber), ]); - _eventsInNotYetSyncedBlocks = await Promise.all([ - storeEvent(lastKnownBlockNumber + 1), - storeEvent(lastKnownBlockNumber + 1), - ]); + // Events in not-yet-synced blocks; stored only to verify they are filtered out. + await Promise.all([storeEvent(lastKnownBlockNumber + 1), storeEvent(lastKnownBlockNumber + 1)]); await privateEventStore.commit('test'); }); diff --git a/yarn-project/stdlib/src/block/l2_block_source.ts b/yarn-project/stdlib/src/block/l2_block_source.ts index 7aa47bd16ac3..98d8146a24a1 100644 --- a/yarn-project/stdlib/src/block/l2_block_source.ts +++ b/yarn-project/stdlib/src/block/l2_block_source.ts @@ -125,25 +125,6 @@ export interface L2BlockSource { */ getCheckpointNumber(): Promise; - /** - * Gets the number of the latest L2 block proven seen by the block source implementation. - * @returns The number of the latest L2 block proven seen by the block source implementation. - */ - getProvenBlockNumber(): Promise; - - /** - * Gets the number of the latest L2 block checkpointed seen by the block source implementation. - * @returns The number of the latest L2 block checkpointed seen by the block source implementation. - */ - getCheckpointedL2BlockNumber(): Promise; - - /** - * Returns the finalized L2 block number. A block is finalized when it was proven - * in an L1 block that has itself been finalized on Ethereum. - * @returns The finalized block number. - */ - getFinalizedL2BlockNumber(): Promise; - /** * Gets a single confirmed checkpoint matching the given query. * Heavy shape: includes nested full `L2Block`s with transaction bodies. diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index d59c2fe873a5..db269fcab3ef 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -95,21 +95,6 @@ describe('ArchiverApiSchema', () => { expect(result).toEqual(CheckpointNumber(1)); }); - it('getProvenBlockNumber', async () => { - const result = await context.client.getProvenBlockNumber(); - expect(result).toEqual(BlockNumber(1)); - }); - - it('getCheckpointedL2BlockNumber', async () => { - const result = await context.client.getCheckpointedL2BlockNumber(); - expect(result).toEqual(BlockNumber(1)); - }); - - it('getFinalizedL2BlockNumber', async () => { - const result = await context.client.getFinalizedL2BlockNumber(); - expect(result).toEqual(BlockNumber(0)); - }); - it('getCheckpoint', async () => { const response = await context.client.getCheckpoint({ number: CheckpointNumber(1) }); expect(response).toBeDefined(); @@ -418,18 +403,9 @@ class MockArchiver implements ArchiverApi { getBlockNumber(_query?: BlockQuery): Promise { return Promise.resolve(BlockNumber(1)); } - getProvenBlockNumber(): Promise { - return Promise.resolve(BlockNumber(1)); - } - getCheckpointedL2BlockNumber(): Promise { - return Promise.resolve(BlockNumber(1)); - } getCheckpointNumber(): Promise { return Promise.resolve(CheckpointNumber(1)); } - getFinalizedL2BlockNumber(): Promise { - return Promise.resolve(BlockNumber(0)); - } getBlock(_query: BlockQuery): Promise { return L2Block.random(BlockNumber(1)); } diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index fdba0a9c54e6..38d8ef9750f8 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -96,10 +96,7 @@ export const ArchiverApiSchema: ApiSchemaFor = { getRollupAddress: z.function().args().returns(schemas.EthAddress), getRegistryAddress: z.function().args().returns(schemas.EthAddress), getBlockNumber: z.function().args(optional(BlockQuerySchema)).returns(BlockNumberSchema.optional()), - getProvenBlockNumber: z.function().args().returns(BlockNumberSchema), - getCheckpointedL2BlockNumber: z.function().args().returns(BlockNumberSchema), getCheckpointNumber: z.function().args().returns(CheckpointNumberSchema), - getFinalizedL2BlockNumber: z.function().args().returns(BlockNumberSchema), getCheckpoint: z.function().args(CheckpointQuerySchema).returns(PublishedCheckpoint.schema.optional()), getCheckpoints: z.function().args(CheckpointsQuerySchema).returns(z.array(PublishedCheckpoint.schema)), getCheckpointData: z.function().args(CheckpointQuerySchema).returns(CheckpointDataSchema.optional()), diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts index 3aa8d1be7caf..d007587d1790 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.test.ts @@ -13,9 +13,10 @@ import times from 'lodash.times'; import type { ContractArtifact } from '../abi/abi.js'; import { AztecAddress } from '../aztec-address/index.js'; +import type { BlockData } from '../block/block_data.js'; import type { DataInBlock } from '../block/in_block.js'; import { BlockHash, type BlockParameter } from '../block/index.js'; -import type { CheckpointsQuery, L2Tips } from '../block/l2_block_source.js'; +import type { CheckpointsQuery } from '../block/l2_block_source.js'; import type { CheckpointData } from '../checkpoint/checkpoint_data.js'; import { type ContractClassPublic, @@ -38,7 +39,6 @@ import { getTokenContractArtifact } from '../tests/fixtures.js'; import { MerkleTreeId } from '../trees/merkle_tree_id.js'; import { NullifierMembershipWitness } from '../trees/nullifier_membership_witness.js'; import { PublicDataWitness } from '../trees/public_data_witness.js'; -import { BlockHeader } from '../tx/block_header.js'; import type { IndexedTxEffect } from '../tx/indexed_tx_effect.js'; import { PublicSimulationOutput } from '../tx/public_simulation_output.js'; import { Tx } from '../tx/tx.js'; @@ -50,7 +50,7 @@ import type { SingleValidatorStats, ValidatorsStats } from '../validators/types. import type { AllowedElement } from './allowed_element.js'; import { MAX_RPC_LEN } from './api_limit.js'; import { type AztecNode, AztecNodeApiSchema } from './aztec-node.js'; -import type { BlockIncludeOptions, BlockResponse } from './block_response.js'; +import type { BlockIncludeOptions, BlockResponse, BlocksIncludeOptions } from './block_response.js'; import type { ChainTip, ChainTips } from './chain_tips.js'; import type { CheckpointParameter } from './checkpoint_parameter.js'; import type { CheckpointIncludeOptions, CheckpointResponse } from './checkpoint_response.js'; @@ -99,21 +99,6 @@ describe('AztecNodeApiSchema', () => { }); }); - it('getL2Tips', async () => { - const result = await context.client.getL2Tips(); - const expectedTipId = { - block: { number: 1, hash: `0x01` }, - checkpoint: { number: 1, hash: `0x01` }, - }; - expect(result).toEqual({ - proposed: { number: 1, hash: `0x01` }, - checkpointed: expectedTipId, - proposedCheckpoint: expectedTipId, - proven: expectedTipId, - finalized: expectedTipId, - }); - }); - it('findLeavesIndexes', async () => { const response = await context.client.findLeavesIndexes(BlockNumber(1), MerkleTreeId.ARCHIVE, [ Fr.random(), @@ -136,11 +121,6 @@ describe('AztecNodeApiSchema', () => { expect(response).toEqual(5); }); - it('isL1ToL2MessageSynced', async () => { - const response = await context.client.isL1ToL2MessageSynced(Fr.random()); - expect(response).toBe(true); - }); - it('getL2ToL1Messages', async () => { const response = await context.client.getL2ToL1Messages(EpochNumber(1)); expect(response.length).toBe(3); @@ -180,14 +160,9 @@ describe('AztecNodeApiSchema', () => { expect(response).toBeUndefined(); }); - it('getBlockHeader', async () => { - const response = await context.client.getBlockHeader(BlockNumber(1)); - expect(response).toBeInstanceOf(BlockHeader); - }); - - it('getCheckpointedBlocks', async () => { - const response = await context.client.getCheckpointedBlocks(BlockNumber(1), 10); - expect(response).toEqual([]); + it('getBlockData', async () => { + const response = await context.client.getBlockData(BlockNumber(1)); + expect(response).toBeUndefined(); }); it('getCheckpoint', async () => { @@ -561,7 +536,11 @@ class MockAztecNode implements AztecNode { return Promise.resolve(undefined); } - getBlocks( + getBlockData(_param: BlockParameter): Promise { + return Promise.resolve(undefined); + } + + getBlocks( _from: BlockNumber, _limit: number, _options?: Opts, @@ -588,28 +567,6 @@ class MockAztecNode implements AztecNode { return Promise.resolve([]); } - getL2Tips(): Promise { - const tipId = { - block: { number: BlockNumber(1), hash: `0x01` }, - checkpoint: { number: CheckpointNumber(1), hash: `0x01` }, - }; - return Promise.resolve({ - proposed: { number: BlockNumber(1), hash: `0x01` }, - checkpointed: tipId, - proposedCheckpoint: tipId, - proven: tipId, - finalized: tipId, - }); - } - - getBlockHeader(_number: BlockNumber | 'latest'): Promise { - return Promise.resolve(BlockHeader.empty()); - } - - getCheckpointedBlocks(_from: BlockNumber, _limit: number): Promise { - return Promise.resolve([]); - } - findLeavesIndexes( referenceBlock: BlockParameter, treeId: MerkleTreeId, @@ -660,10 +617,6 @@ class MockAztecNode implements AztecNode { expect(l1ToL2Message).toBeInstanceOf(Fr); return Promise.resolve(CheckpointNumber(5)); } - isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise { - expect(l1ToL2Message).toBeInstanceOf(Fr); - return Promise.resolve(true); - } getL2ToL1Messages(_epoch: EpochNumber): Promise { return Promise.resolve( Array.from({ length: 3 }, (_, i) => diff --git a/yarn-project/stdlib/src/interfaces/aztec-node.ts b/yarn-project/stdlib/src/interfaces/aztec-node.ts index 9463400d8a34..3b06ec6bc1e0 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node.ts @@ -19,10 +19,11 @@ import { MembershipWitness, SiblingPath } from '@aztec/foundation/trees'; import { z } from 'zod'; import type { AztecAddress } from '../aztec-address/index.js'; +import { type BlockData, BlockDataSchema } from '../block/block_data.js'; import { BlockHash } from '../block/block_hash.js'; import { type BlockParameter, BlockParameterSchema } from '../block/block_parameter.js'; import { type DataInBlock, dataInBlockSchemaFor } from '../block/in_block.js'; -import { type CheckpointsQuery, CheckpointsQuerySchema, type L2Tips, L2TipsSchema } from '../block/l2_block_source.js'; +import { type CheckpointsQuery, CheckpointsQuerySchema } from '../block/l2_block_source.js'; import { type CheckpointData, CheckpointDataSchema } from '../checkpoint/checkpoint_data.js'; import { type ContractClassPublic, @@ -43,7 +44,6 @@ import { MerkleTreeId } from '../trees/merkle_tree_id.js'; import { NullifierMembershipWitness } from '../trees/nullifier_membership_witness.js'; import { PublicDataWitness } from '../trees/public_data_witness.js'; import { - BlockHeader, type IndexedTxEffect, PublicSimulationOutput, SimulationOverrides, @@ -64,6 +64,8 @@ import { BlockIncludeOptionsSchema, type BlockResponse, BlockResponseSchema, + type BlocksIncludeOptions, + BlocksIncludeOptionsSchema, } from './block_response.js'; import { type ChainTip, ChainTipSchema, type ChainTips, ChainTipsSchema } from './chain_tips.js'; import { type CheckpointParameter, CheckpointParameterSchema } from './checkpoint_parameter.js'; @@ -183,14 +185,6 @@ export interface AztecNode { /** Returns the L2 checkpoint number in which this L1 to L2 message becomes available, or undefined if not found. */ getL1ToL2MessageCheckpoint(l1ToL2Message: Fr): Promise; - /** - * Returns whether an L1 to L2 message is synced by archiver. - * @param l1ToL2Message - The L1 to L2 message to check. - * @returns Whether the message is synced. - * @deprecated Use `getL1ToL2MessageCheckpoint` instead. This method may return true even if the message is not ready to use. - */ - isL1ToL2MessageSynced(l1ToL2Message: Fr): Promise; - /** * Returns all the L2 to L1 messages in an epoch. * @param epoch - The epoch at which to get the data. @@ -218,16 +212,6 @@ export interface AztecNode { /** Returns the tips of the L2 chain. */ getChainTips(): Promise; - // TODO(spl/new-rpc-api): the following methods are kept on the interface as a stop-gap because - // `L2BlockStream` (used by PXE's block synchronizer) and `computeL2ToL1MembershipWitness` (used - // by end-to-end tests) still consume the internal archiver shapes. Remove them when those - // consumers are rewired to the unified `BlockResponse` / `CheckpointResponse` API. - /** @deprecated Scheduled for removal; use `getChainTips` for public callers. */ - getL2Tips(): Promise; - /** @deprecated Scheduled for removal; use `getBlock(param).then(r => r?.header)`. */ - getBlockHeader(number: BlockNumber | 'latest'): Promise; - /** @deprecated Scheduled for removal; use `getBlocks(from, limit, { includeL1PublishInfo: true, includeAttestations: true })`. */ - getCheckpointedBlocks(from: BlockNumber, limit: number): Promise; /** * Gets lightweight checkpoint metadata for a contiguous range or for an entire epoch. * @param query - Either `{ from, limit }` or `{ epoch }`. @@ -245,11 +229,18 @@ export interface AztecNode { options?: Opts, ): Promise | undefined>; + /** + * Lightweight block-metadata fetch. Returns the block identified by `param` without transaction + * bodies or other optional context. Cheaper than `getBlock` for header-only access. + * @param param - A block number, block hash, archive root, chain-tip name, or object variant. + */ + getBlockData(param: BlockParameter): Promise; + /** * Returns up to `limit` blocks starting from `from`, projected to the {@link BlockResponse} * shape determined by `options`. */ - getBlocks( + getBlocks( from: BlockNumber, limit: number, options?: Opts, @@ -555,8 +546,6 @@ export const AztecNodeApiSchema: ApiSchemaFor = { getL1ToL2MessageCheckpoint: z.function().args(schemas.Fr).returns(CheckpointNumberSchema.optional()), - isL1ToL2MessageSynced: z.function().args(schemas.Fr).returns(z.boolean()), - getL2ToL1Messages: z .function() .args(EpochNumberSchema) @@ -568,18 +557,6 @@ export const AztecNodeApiSchema: ApiSchemaFor = { getChainTips: z.function().args().returns(ChainTipsSchema), - getL2Tips: z.function().args().returns(L2TipsSchema), - - getBlockHeader: z - .function() - .args(z.union([BlockNumberSchema, z.literal('latest')])) - .returns(BlockHeader.schema.optional()), - - getCheckpointedBlocks: z - .function() - .args(BlockNumberPositiveSchema, z.number().gt(0).lte(MAX_RPC_BLOCKS_LEN)) - .returns(z.array(BlockResponseSchema)), - getCheckpointsData: z.function().args(CheckpointsQuerySchema).returns(z.array(CheckpointDataSchema)), getBlock: z @@ -587,9 +564,11 @@ export const AztecNodeApiSchema: ApiSchemaFor = { .args(BlockParameterSchema, optional(BlockIncludeOptionsSchema)) .returns(BlockResponseSchema.optional()), + getBlockData: z.function().args(BlockParameterSchema).returns(BlockDataSchema.optional()), + getBlocks: z .function() - .args(BlockNumberPositiveSchema, z.number().gt(0).lte(MAX_RPC_BLOCKS_LEN), optional(BlockIncludeOptionsSchema)) + .args(BlockNumberPositiveSchema, z.number().gt(0).lte(MAX_RPC_BLOCKS_LEN), optional(BlocksIncludeOptionsSchema)) .returns(z.array(BlockResponseSchema)), getCheckpoint: z diff --git a/yarn-project/stdlib/src/interfaces/block_response.ts b/yarn-project/stdlib/src/interfaces/block_response.ts index 3236855cb451..36be93a4d458 100644 --- a/yarn-project/stdlib/src/interfaces/block_response.ts +++ b/yarn-project/stdlib/src/interfaces/block_response.ts @@ -15,7 +15,7 @@ import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; import { BlockHeader } from '../tx/block_header.js'; import { type L1PublishInfo, L1PublishInfoSchema } from './l1_publish_info.js'; -/** Options for narrowing the response of `getBlock` / `getBlocks`. */ +/** Options for narrowing the response of `getBlock`. */ export type BlockIncludeOptions = { /** Include the block body (tx effects). Off by default. */ includeTransactions?: boolean; @@ -31,6 +31,19 @@ export const BlockIncludeOptionsSchema: z.ZodType = z.objec includeAttestations: z.boolean().optional(), }); +/** Options for narrowing the response of `getBlocks` (range). Extends single-block options with range-only flags. */ +export type BlocksIncludeOptions = BlockIncludeOptions & { + /** Only return blocks that belong to a confirmed L1 checkpoint. Off by default. */ + onlyCheckpointed?: boolean; +}; + +export const BlocksIncludeOptionsSchema: z.ZodType = z.object({ + includeTransactions: z.boolean().optional(), + includeL1PublishInfo: z.boolean().optional(), + includeAttestations: z.boolean().optional(), + onlyCheckpointed: z.boolean().optional(), +}); + /** Required metadata always present on a {@link BlockResponse}. */ export type BlockResponseBase = { /** Block header. */ diff --git a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts index ff53702ddd8b..a28da8dfaa73 100644 --- a/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts +++ b/yarn-project/txe/src/oracle/txe_oracle_top_level_context.ts @@ -170,7 +170,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } async getLastBlockTimestamp() { - return (await this.stateMachine.node.getBlockHeader('latest'))!.globalVariables.timestamp; + return (await this.stateMachine.node.getBlockData('latest'))!.header.globalVariables.timestamp; } async getLastTxEffects() { @@ -409,7 +409,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl senderForTags: from, simulator, messageContextService: this.stateMachine.messageContextService, - l2TipsStore: this.stateMachine.node, + l2TipsStore: this.stateMachine.l2TipsProvider, }); // Note: This is a slight modification of simulator.run without any of the checks. Maybe we should modify simulator.run with a boolean value to skip checks. @@ -775,7 +775,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.stateMachine.contractSyncService, - l2TipsStore: this.stateMachine.node, + l2TipsStore: this.stateMachine.l2TipsProvider, jobId, scopes, simulator, @@ -808,7 +808,7 @@ export class TXEOracleTopLevelContext implements IMiscOracle, ITxeExecutionOracl } private async getLastBlockNumber(): Promise { - const header = await this.stateMachine.node.getBlockHeader('latest'); - return header ? header.globalVariables.blockNumber : BlockNumber.ZERO; + const block = await this.stateMachine.node.getBlock('latest'); + return block ? block.header.globalVariables.blockNumber : BlockNumber.ZERO; } } diff --git a/yarn-project/txe/src/state_machine/index.ts b/yarn-project/txe/src/state_machine/index.ts index ec00644c007e..746f79f0b9dd 100644 --- a/yarn-project/txe/src/state_machine/index.ts +++ b/yarn-project/txe/src/state_machine/index.ts @@ -5,7 +5,7 @@ import { Fr } from '@aztec/foundation/curves/bn254'; import { createLogger } from '@aztec/foundation/log'; import { type AnchorBlockStore, type ContractStore, ContractSyncService, type NoteStore } from '@aztec/pxe/server'; import { MessageContextService } from '@aztec/pxe/simulator'; -import { L2Block } from '@aztec/stdlib/block'; +import { L2Block, type L2TipsProvider } from '@aztec/stdlib/block'; import { Checkpoint, L1PublishedData, PublishedCheckpoint } from '@aztec/stdlib/checkpoint'; import type { AztecNode } from '@aztec/stdlib/interfaces/client'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; @@ -78,6 +78,17 @@ export class TXEStateMachine { return new this(node, synchronizer, archiver, anchorBlockStore, contractSyncService, messageContextService); } + /** Returns an {@link L2TipsProvider} backed by this node's chain tips. */ + public get l2TipsProvider(): L2TipsProvider { + const node = this.node; + return { + getL2Tips: async () => { + const tips = await node.getChainTips(); + return { ...tips, proposedCheckpoint: tips.checkpointed }; + }, + }; + } + public async handleL2Block(block: L2Block) { // Create a checkpoint from the block manually. // TXE uses 1-block-per-checkpoint for testing simplicity, so we can use block number as checkpoint number. diff --git a/yarn-project/txe/src/txe_session.ts b/yarn-project/txe/src/txe_session.ts index 0d9278312ccf..32ad4233b759 100644 --- a/yarn-project/txe/src/txe_session.ts +++ b/yarn-project/txe/src/txe_session.ts @@ -358,7 +358,8 @@ export class TXESession implements TXESessionStateHandler { this.resetLastCall(); // Capture the anchor *before* `work` runs: private/public executor calls mine a new block as a // side effect, and that block's timestamp should not be attributed to this call's anchor. - const anchorBlockTimestamp = (await this.stateMachine.node.getBlockHeader('latest'))!.globalVariables.timestamp; + const anchorBlockTimestamp = (await this.stateMachine.node.getBlockData('latest'))!.header.globalVariables + .timestamp; const { result, txHash } = await work(); this.setLastCallContext(txHash ?? Fr.ZERO, anchorBlockTimestamp); return result; @@ -431,13 +432,13 @@ export class TXESession implements TXESessionStateHandler { // Private execution has two associated block numbers: the anchor block (i.e. the historical block that is used to // build the proof), and the *next* block, i.e. the one we'll create once the execution ends, and which will contain // a single transaction with the effects of what was done in the test. - const anchorBlock = await this.stateMachine.node.getBlockHeader(anchorBlockNumber ?? 'latest'); + const anchorBlock = await this.stateMachine.node.getBlock(anchorBlockNumber ?? 'latest').then(b => b?.header); await new NoteService(this.noteStore, this.stateMachine.node, anchorBlock!, this.currentJobId).syncNoteNullifiers( contractAddress, await this.keyStore.getAccounts(), ); - const latestBlock = await this.stateMachine.node.getBlockHeader('latest'); + const latestBlock = await this.stateMachine.node.getBlock('latest').then(b => b?.header); const nextBlockGlobalVariables = makeGlobalVariables(undefined, { blockNumber: BlockNumber(latestBlock!.globalVariables.blockNumber + 1), @@ -474,7 +475,7 @@ export class TXESession implements TXESessionStateHandler { capsuleService: new CapsuleService(this.capsuleStore, await this.keyStore.getAccounts()), privateEventStore: this.privateEventStore, contractSyncService: this.stateMachine.contractSyncService, - l2TipsStore: this.stateMachine.node, + l2TipsStore: this.stateMachine.l2TipsProvider, jobId: this.currentJobId, scopes: await this.keyStore.getAccounts(), messageContextService: this.stateMachine.messageContextService, @@ -502,7 +503,7 @@ export class TXESession implements TXESessionStateHandler { // The PublicContext will create a block with a single transaction in it, containing the effects of what was done in // the test. The block therefore gets the *next* block number and timestamp. - const latestHeader = (await this.stateMachine.node.getBlockHeader('latest'))!; + const latestHeader = (await this.stateMachine.node.getBlockData('latest'))!.header; const globalVariables = makeGlobalVariables(undefined, { blockNumber: BlockNumber(latestHeader.globalVariables.blockNumber + 1), timestamp: this.nextBlockTimestamp, @@ -558,7 +559,7 @@ export class TXESession implements TXESessionStateHandler { privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.stateMachine.contractSyncService, - l2TipsStore: this.stateMachine.node, + l2TipsStore: this.stateMachine.l2TipsProvider, jobId: this.currentJobId, scopes: await this.keyStore.getAccounts(), simulator: new WASMSimulator(), @@ -657,7 +658,7 @@ export class TXESession implements TXESessionStateHandler { privateEventStore: this.privateEventStore, messageContextService: this.stateMachine.messageContextService, contractSyncService: this.stateMachine.contractSyncService, - l2TipsStore: this.stateMachine.node, + l2TipsStore: this.stateMachine.l2TipsProvider, jobId: this.currentJobId, scopes, simulator, diff --git a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts index 0aeb23222d54..ce0bf8d6cff6 100644 --- a/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts +++ b/yarn-project/wallet-sdk/src/base-wallet/base_wallet.ts @@ -418,7 +418,7 @@ export abstract class BaseWallet implements Wallet { try { blockHeader = await this.pxe.getSyncedBlockHeader(); } catch { - blockHeader = (await this.aztecNode.getBlockHeader('latest'))!; + blockHeader = (await this.aztecNode.getBlockData('latest'))!.header; } const simulationOrigin = opts.from === NO_FROM ? AztecAddress.ZERO : opts.from; From 7513e78eabe918a71566bc948b4d54a2d1fa4198 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Fri, 8 May 2026 08:08:21 -0400 Subject: [PATCH 21/55] test: mark tx_stats_bench 10 TPS as flake-retryable on merge-train/spartan (#23083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation The `verifies transactions at 10 TPS` sub-test of [`yarn-project/end-to-end/src/bench/tx_stats_bench.test.ts`](https://github.com/AztecProtocol/aztec-packages/blob/merge-train/spartan/yarn-project/end-to-end/src/bench/tx_stats_bench.test.ts) is now reliably flaking on the `bench all` step of `merge-train/spartan`. It has fired on at least two different merge-train commits hours apart, with no relation to either commit's diff: | Run | Triggering merge-train commit | CI log | |---|---|---| | [25546251580](https://github.com/AztecProtocol/aztec-packages/actions/runs/25546251580) | #22934 (refactor(node-rpc)! removing deprecated AztecNode methods) | http://ci.aztec-labs.com/1778227975844707 | | [25552992890](https://github.com/AztecProtocol/aztec-packages/actions/runs/25552992890) | #22405 (feat(p2p): detect and track announce IP changes at runtime) | http://ci.aztec-labs.com/1778237470322975 | Both runs hit the same assertion: ``` ● transaction benchmarks › verifies transactions at 10 TPS expect(received).toBe(expected) // Object.is equality Expected: true Received: false at bench/tx_stats_bench.test.ts:268 ``` Sub-test failing log on the latest run: http://ci.aztec-labs.com/ca459ca73d02002c (`bench all` parent: http://ci.aztec-labs.com/90616bad7bf7ebaa). The other three sub-tests in the suite (compression; single private verify x20 serial; single public verify x20 serial) pass cleanly against the same proven txs in both runs. The failure is in the stress sub-test that fires 600 IVC verifications at 10/s with 8 concurrent IVC verifiers (`BB_NUM_IVC_VERIFIERS=8`, `BB_IVC_CONCURRENCY=1`). At least one verification returns `valid: false` under load. ## Cause Neither triggering commit touches the IVC verifier path: - #22934 is a pure node-rpc surface refactor. - #22405 is p2p / discv5 ENR plumbing. The two failures sharing this signature across unrelated diffs is strong evidence that the flake is independent of the merge-train commit and stems from the bench infrastructure itself. The likely culprit is the recent bb-prover migration to the bb.js `NativeUnixSocket` backend (#21564), which spawns a fresh bb subprocess per Chonk verification via `withVerifierInstance`. Under 8x parallel verifications on the CPU-isolated bench host (each verifier requesting 16 threads, 8 × 16 = 128 threads on 56 isolated cores), transient verifier failures appear. The bench-output log shows continuous `bb.js - Received signal 15, shutting down gracefully...` traffic during the 10 TPS phase — verifier instances are being torn down rapidly, and at least one verification slips through with a stale/incomplete response. Because the serial sub-tests (`numIterations = 20` sequential) pass cleanly in both runs, this is a stress-only interaction, not a correctness regression. ## Approach Add `tx_stats_bench` to `.test_patterns.yml` with an `error_regex` anchored to the test file's stack-trace line (`tx_stats_bench.test.ts::`), and assign `*charlie` as owner (author of the bb.js migration). With this entry, `ci3/run_test_cmd` retries the test once on failure and treats a single retry-pass as a flake instead of a hard fail, unblocking the merge train for unrelated commits while Charlie investigates the underlying concurrency interaction with the bb.js backend. The `error_regex` is intentionally narrow (file + line + column from the stack trace) so other ways tx_stats_bench could fail (timeout, OOM, infra) are still surfaced as hard fails. ## Changes - `.test_patterns.yml`: add a `tx_stats_bench` entry with an error_regex anchored to the test file's stack-trace line and `*charlie` as owner. ClaudeBox logs: - https://claudebox.work/s/6e7853d3a073145f?run=1 (initial diagnosis on #22934 failure) - https://claudebox.work/s/c12a360275f05ad3?run=1 (this update on #22405 recurrence) --- .test_patterns.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.test_patterns.yml b/.test_patterns.yml index 01ef2559b445..dc2f2ae85d0f 100644 --- a/.test_patterns.yml +++ b/.test_patterns.yml @@ -302,6 +302,14 @@ tests: owners: - *adam + # tx_stats_bench's 10 TPS sub-test occasionally returns valid:false from a single IVC + # verification under heavy concurrency (8 parallel verifiers, each spawning a fresh bb subprocess + # via the bb.js NativeUnixSocket backend introduced in #21564). The serial sub-tests pass. + - regex: "tx_stats_bench" + error_regex: "tx_stats_bench\\.test\\.ts:[0-9]+:[0-9]+" + owners: + - *charlie + - regex: "src/e2e_token_bridge_tutorial.test.ts" error_regex: "Error: Unable to find low leaf for block" owners: From 5dc327077f9f79516f2a838cef5c46eea904bbdd Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 11:00:38 -0300 Subject: [PATCH 22/55] fix(sequencer): bind vote-only multicalls to target slot under pipelining (#23090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slashing votes are EIP-712-signed for `targetSlot` (the pipelined proposal slot, not the wall-clock slot) and submitted via Multicall3.aggregate3 with allowFailure: true. The contract verifies the signature against getCurrentSlot() derived from block.timestamp, so the multicall must mine in the slot the vote was signed for or the inner sub-call reverts silently and VoteCast is never emitted. Two paths in the sequencer were sending vote-only multicalls without delaying submission to the target-slot start: 1. CheckpointProposalJob.execute() if (!broadcast) branch — proposer enqueued votes but did not build a checkpoint. 2. Sequencer.tryVoteWhenSyncFails — proposer enqueued votes in a slot where archiver sync had not caught up. Both now route through `sendRequestsAt(getTimestampForSlot(targetSlot))` when proposer pipelining is enabled. The sync-failure path uses fire-and-forget so the wait does not block the sequencer's work loop. --- .../src/sequencer/checkpoint_proposal_job.ts | 13 +++++++++++-- .../sequencer-client/src/sequencer/sequencer.ts | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 586b3d544de5..6bde4e0d549c 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -198,9 +198,18 @@ export class CheckpointProposalJob implements Traceable { if (!broadcast) { await Promise.all(votesPromises); - // Still submit votes even without a checkpoint + // Still submit votes even without a checkpoint. + // Under proposer pipelining, vote-offenses signatures are EIP-712-bound to `targetSlot` + // (the pipelined slot in which the multicall is expected to mine). Submitting at the + // wall-clock time would let the multicall mine in a different L2 slot, causing + // signature verification to fail silently inside Multicall3. Delay submission to the + // start of `targetSlot` so the tx mines in the slot the vote was signed for. if (!this.config.fishermanMode) { - this.pendingL1Submission = this.publisher.sendRequestsAt(this.dateProvider.nowAsDate()).then(() => {}); + const isPipelining = this.epochCache.isProposerPipeliningEnabled(); + const submitAfter = isPipelining + ? new Date(Number(getTimestampForSlot(this.targetSlot, this.l1Constants)) * 1000) + : this.dateProvider.nowAsDate(); + this.pendingL1Submission = this.publisher.sendRequestsAt(submitAfter).then(() => {}); } return undefined; } diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index b074c2f0d653..e670e509d28d 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -14,7 +14,7 @@ import type { SlasherClientInterface } from '@aztec/slasher'; import type { BlockData, L2BlockSink, L2BlockSource, ValidateCheckpointResult } from '@aztec/stdlib/block'; import type { Checkpoint, ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; import type { ChainConfig } from '@aztec/stdlib/config'; -import { getSlotStartBuildTimestamp } from '@aztec/stdlib/epoch-helpers'; +import { getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { type ResolvedSequencerConfig, type SequencerConfig, @@ -785,7 +785,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter { + this.log.error(`Failed to publish votes despite sync failure for slot ${slot}`, err, { slot }); + }); + } else { + await publisher.sendRequests(); + } } /** From d14b8a0f29485dcf4e1131b3e8a5dc2a618d616d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 12:23:35 -0300 Subject: [PATCH 23/55] feat(sequencer): build optimistically across pruning epoch boundary (#23056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation At a pruning epoch boundary, today's `canProposeAtTime` simulation pre-emptively reverts when an unproven epoch's deadline is about to expire — even if the proof lands seconds later. The slot is silently skipped. This loses a checkpoint window for no good reason: the publisher's preCheck right before L1 submission is the authoritative gate. Similarly, the simulation overrides applied to the preCheck flight due to pipelining (as in overriding the pending chain with the last mined slot) meant that we were silently missing the case where the epoch prune did trigger, so we were sending the tx and reverting. This is fixed by having different plans for the first simulation and the right-before-submission simulation. That said, sequencer publisher checks are a bit convoluted now, so I'm making a pass at them to try and simplify in a later PR. ## Approach Apply a proven-override at the three pre-submission simulation sites (`canProposeAt`, the globals builder, and enqueue-time `validateCheckpointForSubmission`) that forces `pending == proven` so `STFLib.canPruneAtTime` short-circuits to false. Submission's preCheck runs without the override against real L1 state and decides whether to actually send. A new structured `preparing-checkpoint` sequencer event surfaces the override/parent state for tests. Tip storage now goes through a single `makeChainTipsOverride` to avoid same-slot state-diff clobbering. ## Changes - **archiver**: `isPruneDueAtSlot(slot)` on `L2BlockSource` replicates `STFLib.canPruneAtTime` locally (no L1 RPC). - **ethereum**: `RollupContract.makeChainTipsOverride({pending?, proven?})` writes a single combined state-diff and guards `proven > pending`. `forPendingCheckpoint(n)` → `withChainTips({pending?, proven?})` on the simulation overrides builder. - **sequencer-client (publisher)**: `enqueueProposeCheckpoint` accepts `preCheckSimulationOverridesPlan` separately from `simulationOverridesPlan`; the preCheck closure uses it (no fallback) so the parent / proven overrides never reach pre-send validation. - **sequencer-client (sequencer)**: applies the proven override at the canProposeAt site, plumbs it through `prunePending` to `CheckpointProposalJob` so the globals builder and enqueue-time validation see it. New `pauseProposingForSlots` test-only config. - **sequencer-client (events)**: new `preparing-checkpoint` event with `targetSlot`, `checkpointNumber`, `hadProposedParent`, `provenOverride`. - **ethereum (test infra)**: `Delayer.pauseNextTxUntil*` accept a per-call timeout to support boundary tests that need to wait > 180s. - **end-to-end (new tests)**: `epochs_proof_at_boundary.parallel.test.ts` covers smoke + four boundary scenarios — proof lands during pipeline sleep; proof lands well before deadline; proof never lands (with parent); proof lands / never lands without proposed parent — using structured events and `retryUntil` rather than log greps. - **stdlib + interfaces**: schemas and configs updated for the new RPC method and the new sequencer config knob. --- .../archiver/src/archiver-misc.test.ts | 93 ++++- .../archiver/src/modules/data_source_base.ts | 32 +- .../archiver/src/test/mock_l2_block_source.ts | 4 + .../epochs_proof_at_boundary.parallel.test.ts | 389 ++++++++++++++++++ .../e2e_l1_publisher/e2e_l1_publisher.test.ts | 2 +- .../contracts/chain_state_override.test.ts | 67 +++ .../src/contracts/chain_state_override.ts | 47 ++- .../ethereum/src/contracts/rollup.test.ts | 116 ++++-- yarn-project/ethereum/src/contracts/rollup.ts | 30 +- .../ethereum/src/l1_tx_utils/tx_delayer.ts | 23 +- yarn-project/sequencer-client/src/config.ts | 4 + .../src/publisher/sequencer-publisher.test.ts | 87 +++- .../src/publisher/sequencer-publisher.ts | 13 +- .../src/sequencer/chain_state_overrides.ts | 51 ++- .../sequencer/checkpoint_proposal_job.test.ts | 58 ++- .../src/sequencer/checkpoint_proposal_job.ts | 35 +- .../sequencer-client/src/sequencer/events.ts | 20 + .../src/sequencer/sequencer.test.ts | 149 ++++++- .../src/sequencer/sequencer.ts | 45 +- .../stdlib/src/block/l2_block_source.ts | 7 + .../stdlib/src/interfaces/archiver.test.ts | 8 + .../stdlib/src/interfaces/archiver.ts | 1 + yarn-project/stdlib/src/interfaces/configs.ts | 7 +- 23 files changed, 1184 insertions(+), 104 deletions(-) create mode 100644 yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts create mode 100644 yarn-project/ethereum/src/contracts/chain_state_override.test.ts diff --git a/yarn-project/archiver/src/archiver-misc.test.ts b/yarn-project/archiver/src/archiver-misc.test.ts index 433b83dc0f2e..700a863c07a8 100644 --- a/yarn-project/archiver/src/archiver-misc.test.ts +++ b/yarn-project/archiver/src/archiver-misc.test.ts @@ -4,15 +4,19 @@ import type { EpochCache, EpochCommitteeInfo } from '@aztec/epoch-cache'; import { DefaultL1ContractsConfig } from '@aztec/ethereum/config'; import type { RollupContract } from '@aztec/ethereum/contracts'; import type { ViemPublicClient } from '@aztec/ethereum/types'; -import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import type { L2Tips } from '@aztec/stdlib/block'; +import type { CheckpointData } from '@aztec/stdlib/checkpoint'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { BlockHeader } from '@aztec/stdlib/tx'; import { getTelemetryClient } from '@aztec/telemetry-client'; +import { jest } from '@jest/globals'; import { EventEmitter } from 'events'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -194,4 +198,91 @@ describe('Archiver misc', () => { expect(await archiver.getSyncedL2EpochNumber()).toEqual(EpochNumber(1)); }); }); + + describe('isPruneDueAtSlot', () => { + /** + * Builds a fake L2Tips. `pending` is the L1-confirmed pending checkpoint (= `tips.checkpointed` + * in production). `proposedCheckpoint` is set to `pending + 1` to catch any implementation that + * accidentally reads the local-optimistic proposed checkpoint instead of the L1-confirmed one. + */ + function makeTips(pending: CheckpointNumber, proven: CheckpointNumber): L2Tips { + const block = { number: BlockNumber(0), hash: '0x' }; + const tip = (n: CheckpointNumber) => ({ block, checkpoint: { number: n, hash: '0x' } }); + const proposedAhead = CheckpointNumber(Number(pending) + 1); + return { + proposed: block, + proposedCheckpoint: tip(proposedAhead), + checkpointed: tip(pending), + proven: tip(proven), + finalized: tip(proven), + }; + } + + /** Builds a fake CheckpointData with only the fields these methods read. */ + function makeCheckpointData(checkpointNumber: CheckpointNumber, slotNumber: SlotNumber): CheckpointData { + return { + checkpointNumber, + header: CheckpointHeader.empty({ slotNumber }), + } as unknown as CheckpointData; + } + + /** + * Stubs `getL2Tips` and `getCheckpointData` so that the methods under test see a + * synthetic chain. Each entry in `checkpoints` maps a checkpoint number to its slot. + */ + function stubChain(args: { + pending: CheckpointNumber; + proven: CheckpointNumber; + checkpoints: Array<{ number: CheckpointNumber; slot: SlotNumber }>; + }): void { + jest.spyOn(archiver, 'getL2Tips').mockResolvedValue(makeTips(args.pending, args.proven)); + jest + .spyOn(archiver, 'getCheckpointData') + .mockImplementation((query: any): Promise => { + if ('number' in query) { + const entry = args.checkpoints.find(c => c.number === query.number); + return Promise.resolve(entry ? makeCheckpointData(entry.number, entry.slot) : undefined); + } + return Promise.resolve(undefined); + }); + } + + // proofSubmissionEpochs = 1 (from beforeEach). With epochDuration = 4: + // epoch 0 = slots 0-3, epoch 1 = slots 4-7, epoch 2 = slots 8-11, epoch 3 = slots 12-15. + // Deadline epoch for an epoch K is K + proofSubmissionEpochs + 1 = K + 2. + // So a checkpoint in epoch K becomes "prune-due" when the current slot's epoch >= K + 2. + + it('returns false when pending equals proven', async () => { + stubChain({ pending: CheckpointNumber(3), proven: CheckpointNumber(3), checkpoints: [] }); + expect(await archiver.isPruneDueAtSlot(SlotNumber(20))).toBe(false); + }); + + it('returns false when slot is before the deadline', async () => { + // Oldest unproven checkpoint is in epoch 0 (slot 2). Deadline epoch = 2. + // Slot 7 is in epoch 1, which is before the deadline. + stubChain({ + pending: CheckpointNumber(2), + proven: CheckpointNumber(0), + checkpoints: [ + { number: CheckpointNumber(1), slot: SlotNumber(2) }, + { number: CheckpointNumber(2), slot: SlotNumber(3) }, + ], + }); + expect(await archiver.isPruneDueAtSlot(SlotNumber(7))).toBe(false); + }); + + it('returns true when slot is at or after the deadline', async () => { + // Oldest unproven checkpoint is in epoch 0. Deadline epoch = 2. + // Slot 8 is the first slot of epoch 2, so the deadline has expired. + stubChain({ + pending: CheckpointNumber(2), + proven: CheckpointNumber(0), + checkpoints: [ + { number: CheckpointNumber(1), slot: SlotNumber(2) }, + { number: CheckpointNumber(2), slot: SlotNumber(3) }, + ], + }); + expect(await archiver.isPruneDueAtSlot(SlotNumber(8))).toBe(true); + }); + }); }); diff --git a/yarn-project/archiver/src/modules/data_source_base.ts b/yarn-project/archiver/src/modules/data_source_base.ts index f941f119fa62..f4fbdff572c2 100644 --- a/yarn-project/archiver/src/modules/data_source_base.ts +++ b/yarn-project/archiver/src/modules/data_source_base.ts @@ -30,7 +30,14 @@ import { PublishedCheckpoint, } from '@aztec/stdlib/checkpoint'; import type { ContractClassPublic, ContractDataSource, ContractInstanceWithAddress } from '@aztec/stdlib/contract'; -import { type L1RollupConstants, getSlotRangeForEpoch } from '@aztec/stdlib/epoch-helpers'; +import { + type L1RollupConstants, + getEpochAtSlot, + getEpochNumberAtTimestamp, + getLastL1SlotTimestampForL2Slot, + getProofSubmissionDeadlineEpoch, + getSlotRangeForEpoch, +} from '@aztec/stdlib/epoch-helpers'; import type { GetContractClassLogsResponse, GetPublicLogsResponse } from '@aztec/stdlib/interfaces/client'; import type { L2LogsSource } from '@aztec/stdlib/interfaces/server'; import type { LogFilter, SiloedTag, Tag, TxScopedL2Log } from '@aztec/stdlib/logs'; @@ -143,6 +150,29 @@ export abstract class ArchiverDataSourceBase abstract syncImmediate(): Promise; + public async isPruneDueAtSlot(slot: SlotNumber): Promise { + if (!this.l1Constants) { + throw new Error('isPruneDueAtSlot requires l1Constants'); + } + const tips = await this.getL2Tips(); + const proven = tips.proven.checkpoint.number; + const pending = tips.checkpointed.checkpoint.number; + if (pending === proven) { + return false; + } + + const oldestUnproven = await this.getCheckpointData({ number: CheckpointNumber(Number(proven) + 1) }); + if (!oldestUnproven) { + return false; + } + + const slotTs = getLastL1SlotTimestampForL2Slot(slot, this.l1Constants); + const slotEpoch = getEpochNumberAtTimestamp(slotTs, this.l1Constants); + const oldestUnprovenEpoch = getEpochAtSlot(oldestUnproven.header.slotNumber, this.l1Constants); + const deadlineEpoch = getProofSubmissionDeadlineEpoch(oldestUnprovenEpoch, this.l1Constants); + return slotEpoch >= deadlineEpoch; + } + public getCheckpointNumber(): Promise { return this.stores.blocks.getLatestCheckpointNumber(); } diff --git a/yarn-project/archiver/src/test/mock_l2_block_source.ts b/yarn-project/archiver/src/test/mock_l2_block_source.ts index 3b4d597ca096..5462e9668074 100644 --- a/yarn-project/archiver/src/test/mock_l2_block_source.ts +++ b/yarn-project/archiver/src/test/mock_l2_block_source.ts @@ -480,6 +480,10 @@ export class MockL2BlockSource implements L2BlockSource, ContractDataSource { return Promise.resolve(EmptyL1RollupConstants); } + isPruneDueAtSlot(_slot: SlotNumber): Promise { + return Promise.resolve(false); + } + getGenesisValues(): Promise<{ genesisArchiveRoot: Fr }> { return Promise.resolve({ genesisArchiveRoot: this.genesisArchiveRoot ?? new Fr(GENESIS_ARCHIVE_ROOT) }); } diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts new file mode 100644 index 000000000000..456f1476b5ba --- /dev/null +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts @@ -0,0 +1,389 @@ +import type { AztecNodeService } from '@aztec/aztec-node'; +import { EthAddress } from '@aztec/aztec.js/addresses'; +import { Fr } from '@aztec/aztec.js/fields'; +import type { Logger } from '@aztec/aztec.js/log'; +import type { Operator } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import type { Delayer } from '@aztec/ethereum/l1-tx-utils'; +import { asyncMap } from '@aztec/foundation/async-map'; +import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { times } from '@aztec/foundation/collection'; +import { SecretValue } from '@aztec/foundation/config'; +import { retryUntil } from '@aztec/foundation/retry'; +import { bufferToHex } from '@aztec/foundation/string'; +import type { SequencerClient, SequencerEvents } from '@aztec/sequencer-client'; +import { getEpochAtSlot, getEpochNumberAtTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; + +import { jest } from '@jest/globals'; +import { privateKeyToAccount } from 'viem/accounts'; + +import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; +import { EpochsTestContext, type EpochsTestOpts } from './epochs_test.js'; + +jest.setTimeout(1000 * 60 * 10); + +const NODE_COUNT = 3; + +type PreparingEvent = Parameters[0]; +type PublishedEvent = Parameters[0]; + +describe('e2e_epochs/epochs_proof_at_boundary', () => { + let context: EndToEndContext; + let logger: Logger; + + let test: EpochsTestContext; + let validators: (Operator & { privateKey: `0x${string}` })[]; + let nodes: AztecNodeService[]; + let proverNode: AztecNodeService; + + const setupTest = async ( + overrides: Partial = {}, + validatorOverrides: { minTxsPerBlock?: number; maxTxsPerBlock?: number } = {}, + ) => { + validators = times(NODE_COUNT, i => { + const privateKey = bufferToHex(getPrivateKeyFromIndex(i + 3)!); + const attester = EthAddress.fromString(privateKeyToAccount(privateKey).address); + return { attester, withdrawer: attester, privateKey, bn254SecretKey: new SecretValue(Fr.random().toBigInt()) }; + }); + + test = await EpochsTestContext.setup({ + numberOfAccounts: 0, + initialValidators: validators, + mockGossipSubNetwork: true, + disableAnvilTestWatcher: true, + aztecProofSubmissionEpochs: 1024, + aztecSlotDurationInL1Slots: 3, + ethereumSlotDuration: 12, + blockDurationMs: 6000, + startProverNode: false, + enforceTimeTable: true, + skipInitialSequencer: true, + enableProposerPipelining: true, + inboxLag: 2, + ...overrides, + }); + + ({ context, logger } = test); + + const minTxsPerBlock = validatorOverrides.minTxsPerBlock ?? 0; + const maxTxsPerBlock = validatorOverrides.maxTxsPerBlock ?? 1; + logger.warn(`Initial setup complete. Starting ${NODE_COUNT} validator nodes.`); + nodes = await asyncMap(validators, ({ privateKey }) => + test.createValidatorNode([privateKey], { minTxsPerBlock, maxTxsPerBlock }), + ); + + proverNode = await test.createProverNode({ + cancelTxOnTimeout: false, + maxSpeedUpAttempts: 0, + dontStart: true, + }); + context.proverNode = proverNode; + + logger.warn(`Test setup completed.`, { validators: validators.map(v => v.attester.toString()) }); + }; + + const collectSequencerEvents = (sequencers: SequencerClient[]) => { + const published: PublishedEvent[] = []; + const preparing: PreparingEvent[] = []; + const publishFailures: Parameters[0][] = []; + const blockProposed: Parameters[0][] = []; + + for (const sequencer of sequencers) { + const seq = sequencer.getSequencer(); + seq.on('checkpoint-published', args => published.push(args)); + seq.on('preparing-checkpoint', args => preparing.push(args)); + seq.on('checkpoint-publish-failed', args => publishFailures.push(args)); + seq.on('block-proposed', args => blockProposed.push(args)); + } + + return { published, preparing, publishFailures, blockProposed }; + }; + + const computeBoundarySlot = async () => { + await retryUntil( + async () => { + await test.monitor.run(true); + return test.monitor.checkpointNumber >= CheckpointNumber(1); + }, + 'first checkpoint mined', + 120, + 0.5, + ); + + const firstCheckpoint = await test.rollup.getCheckpoint(CheckpointNumber(1)); + const firstCheckpointEpoch = getEpochAtSlot(firstCheckpoint.slotNumber, test.constants); + logger.warn(`First checkpoint landed in slot ${firstCheckpoint.slotNumber} (epoch ${firstCheckpointEpoch}).`); + + const nowTs = BigInt(test.context.dateProvider.nowInSeconds()); + const requiredTs = nowTs + BigInt(test.L2_SLOT_DURATION_IN_S * 2); + const minEpochFromTime = EpochNumber(getEpochNumberAtTimestamp(requiredTs - 1n, test.constants) + 1); + const boundaryEpoch = EpochNumber(Math.max(firstCheckpointEpoch + 2, Number(minEpochFromTime))); + const boundarySlot = SlotNumber(Number(boundaryEpoch) * test.epochDuration); + const boundaryTs = getTimestampForSlot(boundarySlot, test.constants); + logger.warn(`Targeting boundary at slot ${boundarySlot} (epoch ${boundaryEpoch}, ts ${boundaryTs}).`); + + return { boundarySlot, boundaryEpoch, boundaryTs, firstCheckpointEpoch }; + }; + + const waitPastBoundary = async (boundarySlot: SlotNumber) => { + await test.monitor.waitUntilL2Slot(SlotNumber(boundarySlot + 2)); + await retryUntil( + async () => { + await test.monitor.run(true); + return test.monitor.l2SlotNumber >= boundarySlot + 2; + }, + 'archiver caught up past boundary', + 30, + 0.5, + ); + }; + + const waitForFirstCheckpointAfterBoundary = async ( + events: ReturnType, + boundarySlot: SlotNumber, + ): Promise => { + const result = await retryUntil( + () => events.published.find(p => Number(p.slot) > Number(boundarySlot)), + 'first checkpoint published after boundary', + Number(test.L2_SLOT_DURATION_IN_S) * 4, + 0.5, + ); + if (!result) { + throw new Error('No checkpoint was published after the boundary'); + } + logger.warn(`First post-boundary checkpoint published`, result); + return result; + }; + + // Asserts that no propose tx ever reached L1 for the boundary slot: the publisher dropped it at + // preCheck. We check both that no `checkpoint-published` fired AND that every publish-failure + // event for that slot is missing 'propose' from sentActions / failedActions. + const assertBoundaryDidNotPropose = (events: ReturnType, boundarySlot: SlotNumber) => { + const boundaryPublished = events.published.find(p => Number(p.slot) === Number(boundarySlot)); + expect(boundaryPublished).toBeUndefined(); + + const boundaryFailures = events.publishFailures.filter(e => Number(e.slot) === Number(boundarySlot)); + expect(boundaryFailures.length).toBeGreaterThan(0); + for (const failure of boundaryFailures) { + expect(failure.sentActions ?? []).not.toContain('propose'); + expect(failure.failedActions ?? []).not.toContain('propose'); + } + }; + + // Tighter happy-path bound: the proof must land BEFORE the boundary slot's pipelined build kicks + // off. With pipelining, the boundary slot's build starts at the start of the previous L2 slot + // (i.e. boundaryTs - L2_SLOT_DURATION_IN_S). If the proof's L1 block is strictly earlier than + // that, the build at the boundary observes `tips.proven` already advanced and skips the override. + const assertProofMinedBeforeBoundaryBuild = async (proofReceipt: { blockNumber: bigint }, boundaryTs: bigint) => { + const proofBlock = await test.l1Client.getBlock({ blockNumber: proofReceipt.blockNumber }); + expect(proofBlock.timestamp).toBeLessThan(boundaryTs - BigInt(test.L2_SLOT_DURATION_IN_S)); + logger.warn(`Proof tx mined at L1 ts ${proofBlock.timestamp}`, { + blockNumber: proofReceipt.blockNumber, + boundaryTs, + }); + }; + + // Tighter window: proof lands in the L2 slot immediately before the boundary (the pipeline-sleep + // window), strictly before the boundary timestamp. + const assertProofMinedJustBeforeBoundary = async (proofReceipt: { blockNumber: bigint }, boundaryTs: bigint) => { + const proofBlock = await test.l1Client.getBlock({ blockNumber: proofReceipt.blockNumber }); + expect(proofBlock.timestamp).toBeLessThan(boundaryTs); + expect(proofBlock.timestamp).toBeGreaterThan(boundaryTs - BigInt(test.L2_SLOT_DURATION_IN_S)); + logger.warn(`Proof tx mined at L1 ts ${proofBlock.timestamp}`, { + blockNumber: proofReceipt.blockNumber, + boundaryTs, + }); + }; + + afterEach(async () => { + jest.restoreAllMocks(); + await test?.teardown(); + }); + + it('proof lands during slot build and checkpoint succeeds at boundary', async () => { + // The proof for the unproven epoch lands AFTER the boundary slot's pipelined build starts but + // BEFORE the publisher's preCheck. The proven-override lets the boundary checkpoint build + // before the proof has landed; the preCheck succeeds because the proof arrives in time. + await setupTest({ aztecProofSubmissionEpochs: 1 }); + + const sequencers = nodes.map(node => node.getSequencer()!); + const events = collectSequencerEvents(sequencers); + + const { boundarySlot, boundaryTs } = await computeBoundarySlot(); + + const proofMineTarget = boundaryTs - BigInt(test.L1_BLOCK_TIME_IN_S); + const proofWaitTimeoutSeconds = Number(proofMineTarget) - test.context.dateProvider.nowInSeconds() + 60; + + // Arm the delayer BEFORE starting the prover so the very first tx the prover submits cannot + // escape the delay window. + const proverDelayer: Delayer = proverNode.getProverNode()!.getDelayer()!; + proverDelayer.pauseNextTxUntilTimestamp(proofMineTarget, proofWaitTimeoutSeconds); + await proverNode.getProverNode()!.start(); + logger.warn(`Scheduled proof tx to mine at L1 timestamp ${proofMineTarget}`, { + proofMineTarget, + boundarySlot, + boundaryTs, + }); + + await waitPastBoundary(boundarySlot); + + const sentProofTxHashes = proverDelayer.getSentTxHashes(); + expect(sentProofTxHashes.length).toBeGreaterThan(0); + const proofTxHash = sentProofTxHashes[0]; + const proofReceipt = await test.l1Client.getTransactionReceipt({ hash: proofTxHash }); + expect(proofReceipt.status).toEqual('success'); + + await assertProofMinedJustBeforeBoundary(proofReceipt, boundaryTs); + + const boundaryPublished = events.published.find(p => Number(p.slot) === Number(boundarySlot)); + expect(boundaryPublished).toBeDefined(); + + const boundaryPreparing = events.preparing.filter(p => Number(p.targetSlot) === Number(boundarySlot)); + expect(boundaryPreparing.some(p => p.provenOverride !== undefined)).toBe(true); + expect(boundaryPreparing.some(p => p.hadProposedParent)).toBe(true); + + expect(Number(test.monitor.checkpointNumber)).toBeGreaterThanOrEqual(Number(boundaryPublished!.checkpoint)); + logger.warn(`Test passed. Final tip checkpoint=${test.monitor.checkpointNumber}`); + }); + + it('proof lands well before deadline and checkpoint succeeds without override', async () => { + // Sanity check: the prover runs on its natural schedule, so the proof lands well before the + // boundary epoch. By the time the boundary slot is built `tips.proven` is already advanced, + // `isPruneDueAtSlot` returns false, and the proven-override does not fire. + await setupTest({ aztecProofSubmissionEpochs: 1 }); + + const sequencers = nodes.map(node => node.getSequencer()!); + const events = collectSequencerEvents(sequencers); + + const { boundarySlot, boundaryTs } = await computeBoundarySlot(); + + const proverDelayer: Delayer = proverNode.getProverNode()!.getDelayer()!; + await proverNode.getProverNode()!.start(); + + await waitPastBoundary(boundarySlot); + + const sentProofTxHashes = proverDelayer.getSentTxHashes(); + expect(sentProofTxHashes.length).toBeGreaterThan(0); + const proofReceipt = await test.l1Client.getTransactionReceipt({ hash: sentProofTxHashes[0] }); + expect(proofReceipt.status).toEqual('success'); + await assertProofMinedBeforeBoundaryBuild(proofReceipt, boundaryTs); + + const boundaryPublished = events.published.find(p => Number(p.slot) === Number(boundarySlot)); + expect(boundaryPublished).toBeDefined(); + + const boundaryPreparing = events.preparing.filter(p => Number(p.targetSlot) === Number(boundarySlot)); + expect(boundaryPreparing.some(p => p.hadProposedParent)).toBe(true); + expect(boundaryPreparing.every(p => p.provenOverride === undefined)).toBe(true); + + expect(Number(test.monitor.checkpointNumber)).toBeGreaterThanOrEqual(Number(boundaryPublished!.checkpoint)); + }); + + it('proof never lands so no checkpoint submission is attempted', async () => { + // The boundary slot's build applies the proven-override, but the publisher's preCheck rejects + // the propose tx because the proof never landed. After the prune fires on a later slot, a + // fresh propose advances the chain and a checkpoint is published in the new epoch. + await setupTest({ aztecProofSubmissionEpochs: 1 }); + + const sequencers = nodes.map(node => node.getSequencer()!); + const events = collectSequencerEvents(sequencers); + + const { boundarySlot, boundaryEpoch } = await computeBoundarySlot(); + + // Arm the delayer to drop the proof tx BEFORE starting the prover so it cannot escape. + const proverDelayer: Delayer = proverNode.getProverNode()!.getDelayer()!; + proverDelayer.cancelNextTx(); + await proverNode.getProverNode()!.start(); + logger.warn(`Cancelled prover node's next tx; proof will never land.`); + + await waitPastBoundary(boundarySlot); + + assertBoundaryDidNotPropose(events, boundarySlot); + + const boundaryPreparing = events.preparing.filter(p => Number(p.targetSlot) === Number(boundarySlot)); + expect(boundaryPreparing.some(p => p.hadProposedParent)).toBe(true); + expect(boundaryPreparing.some(p => p.provenOverride !== undefined)).toBe(true); + + // After the boundary fails, the next slot's propose tx triggers the on-chain prune (since the + // proof never landed and the deadline has expired) and resets `tips.pending`. The fresh + // checkpoint against the genesis archive lands at boundarySlot+1: empirically the next slot's + // proposer rebuilds with no proposed parent (the boundary's pipelined parent is invalidated + // when its publish fails), the on-chain prune fires in-tx on this propose, and the propose + // is accepted in the same slot. + const firstPostBoundary = await waitForFirstCheckpointAfterBoundary(events, boundarySlot); + expect(Number(firstPostBoundary.slot)).toBe(Number(boundarySlot) + 1); + expect(getEpochAtSlot(firstPostBoundary.slot, test.constants)).toBe(boundaryEpoch); + }); + + it('proof lands without a proposed parent and boundary checkpoint succeeds', async () => { + // The slot before the boundary is paused so the boundary slot's build does not see a proposed + // parent. The proof still lands well before the deadline, so the proven-override never fires + // and the boundary checkpoint is published normally. + await setupTest({ aztecProofSubmissionEpochs: 1 }); + + const sequencers = nodes.map(node => node.getSequencer()!); + const events = collectSequencerEvents(sequencers); + + const { boundarySlot } = await computeBoundarySlot(); + const slotN = SlotNumber(Number(boundarySlot) - 1); + logger.warn(`Pausing proposing for slot ${slotN}`); + + for (const sequencer of sequencers) { + sequencer.updateConfig({ pauseProposingForSlots: [slotN] }); + } + + await proverNode.getProverNode()!.start(); + const proverDelayer: Delayer = proverNode.getProverNode()!.getDelayer()!; + + await waitPastBoundary(boundarySlot); + + const sentProofTxHashes = proverDelayer.getSentTxHashes(); + expect(sentProofTxHashes.length).toBeGreaterThan(0); + const proofReceipt = await test.l1Client.getTransactionReceipt({ hash: sentProofTxHashes[0] }); + expect(proofReceipt.status).toEqual('success'); + + const boundaryPublished = events.published.find(p => Number(p.slot) === Number(boundarySlot)); + expect(boundaryPublished).toBeDefined(); + + const boundaryPreparing = events.preparing.filter(p => Number(p.targetSlot) === Number(boundarySlot)); + expect(boundaryPreparing.length).toBeGreaterThan(0); + expect(boundaryPreparing.every(p => !p.hadProposedParent)).toBe(true); + expect(boundaryPreparing.every(p => p.provenOverride === undefined)).toBe(true); + + expect(Number(test.monitor.checkpointNumber)).toBeGreaterThanOrEqual(Number(boundaryPublished!.checkpoint)); + }); + + it('proof never lands without a proposed parent so no checkpoint submission is attempted', async () => { + // Same as the no-parent variant above but with the proof never landing. The proven-override + // fires (no parent + prune is due) but the publisher's preCheck rejects the propose, so no + // checkpoint is published for the boundary slot. + await setupTest({ aztecProofSubmissionEpochs: 1 }); + + const sequencers = nodes.map(node => node.getSequencer()!); + const events = collectSequencerEvents(sequencers); + + const { boundarySlot } = await computeBoundarySlot(); + const slotN = SlotNumber(Number(boundarySlot) - 1); + logger.warn(`Pausing proposing for slot ${slotN}`); + + for (const sequencer of sequencers) { + sequencer.updateConfig({ pauseProposingForSlots: [slotN] }); + } + + const proverDelayer: Delayer = proverNode.getProverNode()!.getDelayer()!; + proverDelayer.cancelNextTx(); + await proverNode.getProverNode()!.start(); + + await waitPastBoundary(boundarySlot); + + assertBoundaryDidNotPropose(events, boundarySlot); + + const boundaryPreparing = events.preparing.filter(p => Number(p.targetSlot) === Number(boundarySlot)); + expect(boundaryPreparing.length).toBeGreaterThan(0); + expect(boundaryPreparing.every(p => !p.hadProposedParent)).toBe(true); + expect(boundaryPreparing.some(p => p.provenOverride !== undefined)).toBe(true); + + // See the parent test for the reasoning: the on-chain prune fires in-tx on the next slot's + // propose, so the first post-boundary checkpoint lands at boundarySlot+1. + const firstPostBoundary = await waitForFirstCheckpointAfterBoundary(events, boundarySlot); + expect(Number(firstPostBoundary.slot)).toBe(Number(boundarySlot) + 1); + }); +}); diff --git a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts index 4b136bd07a9b..70503811095d 100644 --- a/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts +++ b/yarn-project/end-to-end/src/e2e_l1_publisher/e2e_l1_publisher.test.ts @@ -785,7 +785,7 @@ describe('L1Publisher integration', () => { const forcePendingCheckpointNumber = invalidateRequest?.forcePendingCheckpointNumber; expect(forcePendingCheckpointNumber).toEqual(0); const invalidationSimulationOverridesPlan = new SimulationOverridesBuilder() - .forPendingCheckpoint(forcePendingCheckpointNumber ?? CheckpointNumber.ZERO) + .withChainTips({ pending: forcePendingCheckpointNumber ?? CheckpointNumber.ZERO }) .build(); // We cannot propose directly, we need to assume the previous checkpoint is invalidated diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.test.ts b/yarn-project/ethereum/src/contracts/chain_state_override.test.ts new file mode 100644 index 000000000000..86e9ec510624 --- /dev/null +++ b/yarn-project/ethereum/src/contracts/chain_state_override.test.ts @@ -0,0 +1,67 @@ +import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; + +import { SimulationOverridesBuilder } from './chain_state_override.js'; + +describe('SimulationOverridesBuilder', () => { + it('returns undefined when no overrides are configured', () => { + expect(new SimulationOverridesBuilder().build()).toBeUndefined(); + }); + + it('merges chain tips set across multiple withChainTips calls', () => { + const plan = new SimulationOverridesBuilder() + .withChainTips({ pending: CheckpointNumber(7) }) + .withChainTips({ proven: CheckpointNumber(3) }) + .build(); + + expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) }); + }); + + it('lets later withChainTips calls overwrite the same half', () => { + const plan = new SimulationOverridesBuilder() + .withChainTips({ pending: CheckpointNumber(7) }) + .withChainTips({ pending: CheckpointNumber(8) }) + .build(); + + expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(8) }); + }); + + it('throws when withPendingArchive is called without a pending chain-tip override', () => { + expect(() => new SimulationOverridesBuilder().withPendingArchive(Fr.random())).toThrow( + /withChainTips\(\{ pending \}\) must be called/, + ); + }); + + it('throws when withPendingArchive is called and only proven is set', () => { + expect(() => + new SimulationOverridesBuilder().withChainTips({ proven: CheckpointNumber(3) }).withPendingArchive(Fr.random()), + ).toThrow(/withChainTips\(\{ pending \}\) must be called/); + }); + + it('attaches archive override after pending chain-tip override is set', () => { + const archive = Fr.random(); + const plan = new SimulationOverridesBuilder() + .withChainTips({ pending: CheckpointNumber(7) }) + .withPendingArchive(archive) + .build(); + + expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7) }); + expect(plan?.pendingCheckpointState?.archive).toEqual(archive); + }); + + it('SimulationOverridesBuilder.from copies chainTipsOverride', () => { + const plan = new SimulationOverridesBuilder() + .withChainTips({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) }) + .build(); + + const rebuilt = SimulationOverridesBuilder.from(plan).build(); + expect(rebuilt?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) }); + }); + + it('merge folds chain tips per-half', () => { + const builder = new SimulationOverridesBuilder().withChainTips({ pending: CheckpointNumber(7) }); + builder.merge({ chainTipsOverride: { proven: CheckpointNumber(3) } }); + const plan = builder.build(); + expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) }); + }); +}); diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.ts b/yarn-project/ethereum/src/contracts/chain_state_override.ts index a695b3c4ba5a..f4771883db57 100644 --- a/yarn-project/ethereum/src/contracts/chain_state_override.ts +++ b/yarn-project/ethereum/src/contracts/chain_state_override.ts @@ -11,16 +11,21 @@ export type PendingCheckpointOverrideState = { feeHeader?: FeeHeader; }; +export type ChainTipsOverride = { + pending?: CheckpointNumber; + proven?: CheckpointNumber; +}; + /** Describes the simulated L1 rollup state that downstream calls should observe. */ export type SimulationOverridesPlan = { - pendingCheckpointNumber?: CheckpointNumber; + chainTipsOverride?: ChainTipsOverride; pendingCheckpointState?: PendingCheckpointOverrideState; disableBlobCheck?: boolean; }; /** Builds a single-checkpoint simulation plan before it is translated into a viem state override. */ export class SimulationOverridesBuilder { - private pendingCheckpointNumber?: CheckpointNumber; + private chainTipsOverride?: ChainTipsOverride; private pendingCheckpointState?: PendingCheckpointOverrideState; private disableBlobCheck = false; @@ -29,13 +34,15 @@ export class SimulationOverridesBuilder { return new SimulationOverridesBuilder().merge(plan); } - /** Merges another plan into this builder. Later values win. */ + /** Merges another plan into this builder. Later values win on a per-half basis for chain tips. */ public merge(plan: SimulationOverridesPlan | undefined): this { if (!plan) { return this; } - this.pendingCheckpointNumber = plan.pendingCheckpointNumber; + if (plan.chainTipsOverride) { + this.chainTipsOverride = { ...(this.chainTipsOverride ?? {}), ...plan.chainTipsOverride }; + } this.pendingCheckpointState = plan.pendingCheckpointState ? { ...(this.pendingCheckpointState ?? {}), ...plan.pendingCheckpointState } : this.pendingCheckpointState; @@ -44,9 +51,13 @@ export class SimulationOverridesBuilder { return this; } - /** Sets the checkpoint number that archive and fee header overrides should attach to. */ - public forPendingCheckpoint(pendingCheckpointNumber: CheckpointNumber | undefined): this { - this.pendingCheckpointNumber = pendingCheckpointNumber; + /** + * Sets the pending and/or proven checkpoint number overrides. Subsequent calls merge into the existing + * override on a per-half basis, so callers can set pending in one call and proven in another without + * clobbering each other. + */ + public withChainTips(override: ChainTipsOverride): this { + this.chainTipsOverride = { ...(this.chainTipsOverride ?? {}), ...override }; return this; } @@ -72,20 +83,20 @@ export class SimulationOverridesBuilder { /** Builds the final plan, or `undefined` when no overrides were configured. */ public build(): SimulationOverridesPlan | undefined { - if (!this.pendingCheckpointState && this.pendingCheckpointNumber === undefined && !this.disableBlobCheck) { + if (!this.pendingCheckpointState && !this.chainTipsOverride && !this.disableBlobCheck) { return undefined; } return { - pendingCheckpointNumber: this.pendingCheckpointNumber, + chainTipsOverride: this.chainTipsOverride, pendingCheckpointState: this.pendingCheckpointState, disableBlobCheck: this.disableBlobCheck || undefined, }; } private assertPendingCheckpointNumber(): void { - if (this.pendingCheckpointNumber === undefined) { - throw new Error('pendingCheckpointNumber must be set before attaching archive or fee header overrides'); + if (this.chainTipsOverride?.pending === undefined) { + throw new Error('withChainTips({ pending }) must be called before attaching archive or fee header overrides'); } } } @@ -101,20 +112,18 @@ export async function buildSimulationOverridesStateOverride( const rollupStateDiff: NonNullable = []; - if (plan.pendingCheckpointNumber !== undefined) { - rollupStateDiff.push( - ...extractRollupStateDiff(await rollup.makePendingCheckpointNumberOverride(plan.pendingCheckpointNumber)), - ); + if (plan.chainTipsOverride) { + rollupStateDiff.push(...extractRollupStateDiff(await rollup.makeChainTipsOverride(plan.chainTipsOverride))); } - if (plan.pendingCheckpointState && plan.pendingCheckpointNumber === undefined) { - throw new Error('pendingCheckpointState requires pendingCheckpointNumber to be set'); + if (plan.pendingCheckpointState && plan.chainTipsOverride?.pending === undefined) { + throw new Error('pendingCheckpointState requires chainTipsOverride.pending to be set'); } if (plan.pendingCheckpointState?.archive) { rollupStateDiff.push( ...extractRollupStateDiff( - rollup.makeArchiveOverride(plan.pendingCheckpointNumber!, plan.pendingCheckpointState.archive), + rollup.makeArchiveOverride(plan.chainTipsOverride!.pending!, plan.pendingCheckpointState.archive), ), ); } @@ -122,7 +131,7 @@ export async function buildSimulationOverridesStateOverride( if (plan.pendingCheckpointState?.feeHeader) { rollupStateDiff.push( ...extractRollupStateDiff( - await rollup.makeFeeHeaderOverride(plan.pendingCheckpointNumber!, plan.pendingCheckpointState.feeHeader), + await rollup.makeFeeHeaderOverride(plan.chainTipsOverride!.pending!, plan.pendingCheckpointState.feeHeader), ), ); } diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts index b2e0f3238e4b..e3518004cfef 100644 --- a/yarn-project/ethereum/src/contracts/rollup.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup.test.ts @@ -266,52 +266,96 @@ describe('Rollup', () => { await anvil?.stop().catch(err => createLogger('cleanup').error(err)); }); - describe('makePendingCheckpointNumberOverride', () => { - it('creates state override that correctly overrides pending checkpoint number', async () => { - const testProvenCheckpointNumber = CheckpointNumber(42); - const testPendingCheckpointNumber = CheckpointNumber(100); - const newPendingCheckpointNumber = CheckpointNumber(150); - - // Set storage directly using cheat codes - // The storage slot stores both values: pending (high 128 bits) | proven (low 128 bits) + describe('makeChainTipsOverride', () => { + const testProvenCheckpointNumber = CheckpointNumber(42); + const testPendingCheckpointNumber = CheckpointNumber(100); + + async function setLiveTips(pending: CheckpointNumber, proven: CheckpointNumber) { const storageSlot = RollupContract.stfStorageSlot; - const packedValue = (BigInt(testPendingCheckpointNumber) << 128n) | BigInt(testProvenCheckpointNumber); + const packedValue = (BigInt(pending) << 128n) | BigInt(proven); await cheatCodes.store(EthAddress.fromString(rollupAddress), BigInt(storageSlot), packedValue); + } + + async function readOverridden(stateOverride: Awaited>) { + const [pendingResult, provenResult] = await Promise.all([ + publicClient.simulateContract({ + address: rollupAddress, + abi: RollupAbi as Abi, + functionName: 'getPendingCheckpointNumber', + stateOverride, + }), + publicClient.simulateContract({ + address: rollupAddress, + abi: RollupAbi as Abi, + functionName: 'getProvenCheckpointNumber', + stateOverride, + }), + ]); + return { + pending: CheckpointNumber.fromBigInt(pendingResult.result), + proven: CheckpointNumber.fromBigInt(provenResult.result), + }; + } - // Verify the values were set correctly by calling the getters directly - const provenCheckpointNumber = await rollup.getProvenCheckpointNumber(); - const pendingCheckpointNumber = await rollup.getCheckpointNumber(); + it('emits a single combined state-diff when both pending and proven are set', async () => { + await setLiveTips(testPendingCheckpointNumber, testProvenCheckpointNumber); - expect(provenCheckpointNumber).toBe(testProvenCheckpointNumber); - expect(pendingCheckpointNumber).toBe(testPendingCheckpointNumber); + const newPending = CheckpointNumber(150); + const newProven = CheckpointNumber(75); + const stateOverride = await rollup.makeChainTipsOverride({ pending: newPending, proven: newProven }); - // Create the override - const stateOverride = await rollup.makePendingCheckpointNumberOverride(newPendingCheckpointNumber); + expect(stateOverride).toHaveLength(1); + expect(stateOverride[0].stateDiff).toHaveLength(1); + expect(stateOverride[0].stateDiff![0].slot).toBe(RollupContract.stfStorageSlot); + const expectedValue = (BigInt(newPending) << 128n) | BigInt(newProven); + expect(stateOverride[0].stateDiff![0].value).toBe(`0x${expectedValue.toString(16).padStart(64, '0')}`); - // Test the override using simulateContract - const { result: overriddenPendingCheckpointNumber } = await publicClient.simulateContract({ - address: rollupAddress, - abi: RollupAbi as Abi, - functionName: 'getPendingCheckpointNumber', - stateOverride, - }); + const observed = await readOverridden(stateOverride); + expect(observed.pending).toBe(newPending); + expect(observed.proven).toBe(newProven); + }); - // The overridden value should be the new pending checkpoint number - expect(overriddenPendingCheckpointNumber).toBe(BigInt(newPendingCheckpointNumber)); + it('preserves the live proven half when only pending is overridden', async () => { + await setLiveTips(testPendingCheckpointNumber, testProvenCheckpointNumber); - // Verify that the proven checkpoint number is preserved in the override - const { result: overriddenProvenCheckpointNumber } = await publicClient.simulateContract({ - address: rollupAddress, - abi: RollupAbi as Abi, - functionName: 'getProvenCheckpointNumber', - stateOverride, - }); + const newPending = CheckpointNumber(150); + const stateOverride = await rollup.makeChainTipsOverride({ pending: newPending }); - expect(CheckpointNumber.fromBigInt(overriddenProvenCheckpointNumber)).toBe(testProvenCheckpointNumber); + const observed = await readOverridden(stateOverride); + expect(observed.pending).toBe(newPending); + expect(observed.proven).toBe(testProvenCheckpointNumber); + }); + + it('preserves the live pending half when only proven is overridden', async () => { + await setLiveTips(testPendingCheckpointNumber, testProvenCheckpointNumber); + + const newProven = CheckpointNumber(75); + const stateOverride = await rollup.makeChainTipsOverride({ proven: newProven }); + + const observed = await readOverridden(stateOverride); + expect(observed.pending).toBe(testPendingCheckpointNumber); + expect(observed.proven).toBe(newProven); + }); + + it('returns an empty override when neither pending nor proven is set', async () => { + const stateOverride = await rollup.makeChainTipsOverride({}); + expect(stateOverride).toEqual([]); + }); + + it('throws when the resulting proven > pending', async () => { + await setLiveTips(testPendingCheckpointNumber, testProvenCheckpointNumber); + + await expect( + rollup.makeChainTipsOverride({ pending: CheckpointNumber(50), proven: CheckpointNumber(100) }), + ).rejects.toThrow(/proven .* > pending/); + }); + + it('throws when only proven is set and the resulting proven > live pending', async () => { + await setLiveTips(CheckpointNumber(10), CheckpointNumber(5)); - // Verify the actual storage hasn't changed - const actualPendingCheckpointNumber = await rollup.getCheckpointNumber(); - expect(actualPendingCheckpointNumber).toBe(testPendingCheckpointNumber); + await expect(rollup.makeChainTipsOverride({ proven: CheckpointNumber(20) })).rejects.toThrow( + /proven .* > pending/, + ); }); }); diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 8356e78f54c9..366a2a049d46 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -844,20 +844,32 @@ export class RollupContract { } /** - * Returns a state override that sets the pending checkpoint number to the specified value. Useful for simulations. - * Requires querying the current state of the contract to get the current proven checkpoint number, as they are both - * stored in the same slot. If the argument is undefined, it returns an empty override. + * Returns a state override that sets the pending and/or proven checkpoint numbers. Useful for simulations. + * Both values share a single storage slot (pending in the upper 128 bits, proven in the lower 128 bits), so + * a single combined override is emitted to avoid the second state-diff clobbering the first. The current live + * value is read once to preserve any half not being overridden. Returns an empty override if neither is set. + * + * Throws if the resulting `proven > pending`, which would crash the simulation: `STFLib.canPruneAtTime` calls + * `getEpochForCheckpoint(proven + 1)` whenever `pending != proven`, and that asserts `_n <= tips.pending`. */ - public async makePendingCheckpointNumberOverride( - forcePendingCheckpointNumber: CheckpointNumber | undefined, - ): Promise { - if (forcePendingCheckpointNumber === undefined) { + public async makeChainTipsOverride(override: { + pending?: CheckpointNumber; + proven?: CheckpointNumber; + }): Promise { + if (override.pending === undefined && override.proven === undefined) { return []; } const slot = RollupContract.stfStorageSlot; const currentValue = await this.client.getStorageAt({ address: this.address, slot }); - const currentProvenCheckpointNumber = currentValue ? hexToBigInt(currentValue) & ((1n << 128n) - 1n) : 0n; - const newValue = (BigInt(forcePendingCheckpointNumber) << 128n) | currentProvenCheckpointNumber; + const currentRaw = currentValue ? hexToBigInt(currentValue) : 0n; + const currentPending = currentRaw >> 128n; + const currentProven = currentRaw & ((1n << 128n) - 1n); + const newPending = override.pending !== undefined ? BigInt(override.pending) : currentPending; + const newProven = override.proven !== undefined ? BigInt(override.proven) : currentProven; + if (newProven > newPending) { + throw new Error(`Invalid chain tips override: proven (${newProven}) > pending (${newPending})`); + } + const newValue = (newPending << 128n) | newProven; return [ { address: this.address, diff --git a/yarn-project/ethereum/src/l1_tx_utils/tx_delayer.ts b/yarn-project/ethereum/src/l1_tx_utils/tx_delayer.ts index a5bb36d63c39..6c102d83dd80 100644 --- a/yarn-project/ethereum/src/l1_tx_utils/tx_delayer.ts +++ b/yarn-project/ethereum/src/l1_tx_utils/tx_delayer.ts @@ -81,7 +81,11 @@ export class Delayer { public maxInclusionTimeIntoSlot: number | undefined = undefined; public ethereumSlotDuration: bigint; - public nextWait: { l1Timestamp: bigint } | { l1BlockNumber: bigint } | { indefinitely: true } | undefined = undefined; + public nextWait: + | { l1Timestamp: bigint; timeoutSeconds?: number } + | { l1BlockNumber: bigint; timeoutSeconds?: number } + | { indefinitely: true } + | undefined = undefined; public sentTxHashes: Hex[] = []; public cancelledTxs: Hex[] = []; @@ -110,13 +114,13 @@ export class Delayer { } /** Delays the next tx to be sent so it lands on the given L1 block number. */ - pauseNextTxUntilBlock(l1BlockNumber: number | bigint) { - this.nextWait = { l1BlockNumber: BigInt(l1BlockNumber) }; + pauseNextTxUntilBlock(l1BlockNumber: number | bigint, timeoutSeconds?: number) { + this.nextWait = { l1BlockNumber: BigInt(l1BlockNumber), timeoutSeconds }; } /** Delays the next tx to be sent so it lands on the given timestamp. */ - pauseNextTxUntilTimestamp(l1Timestamp: number | bigint) { - this.nextWait = { l1Timestamp: BigInt(l1Timestamp) }; + pauseNextTxUntilTimestamp(l1Timestamp: number | bigint, timeoutSeconds?: number) { + this.nextWait = { l1Timestamp: BigInt(l1Timestamp), timeoutSeconds }; } /** Delays the next tx to be sent indefinitely. */ @@ -198,9 +202,14 @@ export function wrapClientWithDelayer(client: T, delayer: // Or wait until the desired block number or timestamp wait = 'l1BlockNumber' in waitUntil - ? waitUntilBlock(publicClient, waitUntil.l1BlockNumber - 1n, logger) + ? waitUntilBlock(publicClient, waitUntil.l1BlockNumber - 1n, logger, waitUntil.timeoutSeconds) : 'l1Timestamp' in waitUntil - ? waitUntilL1Timestamp(publicClient, waitUntil.l1Timestamp - delayer.ethereumSlotDuration, logger) + ? waitUntilL1Timestamp( + publicClient, + waitUntil.l1Timestamp - delayer.ethereumSlotDuration, + logger, + waitUntil.timeoutSeconds, + ) : undefined; logger.info(`Delaying tx ${txHash} until ${inspect(waitUntil)}`, { diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 25fd297ddbdf..970d143ceb33 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -235,6 +235,10 @@ export const sequencerConfigMappings: ConfigMappingsType = { description: 'Skip broadcasting checkpoint and block proposals via gossipsub when proposer (for testing only)', ...booleanConfigHelper(false), }, + pauseProposingForSlots: { + description: + 'List of slots for which the sequencer will not produce a proposal (for testing only). Attestation paths are unaffected.', + }, ...pickConfigMappings(p2pConfigMappings, ['txPublicSetupAllowListExtend']), }; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index 93ac3a7646ed..6aff7e8c0203 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -6,6 +6,7 @@ import { type GovernanceProposerContract, Multicall3, type RollupContract, + type SimulationOverridesPlan, type SlashingProposerContract, } from '@aztec/ethereum/contracts'; import { @@ -15,7 +16,8 @@ import { defaultL1TxUtilsConfig, } from '@aztec/ethereum/l1-tx-utils'; import { FormattedViemError } from '@aztec/ethereum/utils'; -import { BlockNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { TimeoutError } from '@aztec/foundation/error'; import { EthAddress } from '@aztec/foundation/eth-address'; import { sleep } from '@aztec/foundation/sleep'; @@ -461,6 +463,89 @@ describe('SequencerPublisher', () => { expect(forwardSpy).not.toHaveBeenCalled(); }); + it('preCheck closure uses preCheckSimulationOverridesPlan, not the enqueue-time plan', async () => { + (publisher.epochCache.isProposerPipeliningEnabled as jest.Mock).mockReturnValue(true); + + const validateSpy = jest.spyOn(publisher, 'validateCheckpointForSubmission').mockResolvedValue(undefined); + + const enqueuePlan: SimulationOverridesPlan = { + chainTipsOverride: { pending: CheckpointNumber(7) }, + pendingCheckpointState: { archive: Fr.random() }, + }; + const preCheckPlan: SimulationOverridesPlan = { + chainTipsOverride: { pending: CheckpointNumber(8) }, + }; + + await publisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(testSignatureContext), + Signature.empty(), + { simulationOverridesPlan: enqueuePlan, preCheckSimulationOverridesPlan: preCheckPlan }, + ); + + // Enqueue-time validation called with the enqueue plan (plus withoutBlobCheck applied). + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy.mock.calls[0][3]).toMatchObject({ + chainTipsOverride: { pending: CheckpointNumber(7) }, + disableBlobCheck: true, + }); + + // The pending preCheck request should now run the preCheck closure with the preCheck plan. + const requests: { preCheck?: () => Promise }[] = (publisher as any).requests; + expect(requests).toHaveLength(1); + const preCheck = requests[0].preCheck; + expect(preCheck).toBeDefined(); + + validateSpy.mockClear(); + await preCheck!(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy.mock.calls[0][3]).toMatchObject({ + chainTipsOverride: { pending: CheckpointNumber(8) }, + disableBlobCheck: true, + }); + // And not the enqueue plan's archive override. + expect(validateSpy.mock.calls[0][3]?.pendingCheckpointState).toBeUndefined(); + }); + + it('preCheck does not fall back to the enqueue plan when preCheckSimulationOverridesPlan is omitted', async () => { + (publisher.epochCache.isProposerPipeliningEnabled as jest.Mock).mockReturnValue(true); + + const validateSpy = jest.spyOn(publisher, 'validateCheckpointForSubmission').mockResolvedValue(undefined); + + const enqueuePlan: SimulationOverridesPlan = { + chainTipsOverride: { pending: CheckpointNumber(7) }, + pendingCheckpointState: { archive: Fr.random() }, + }; + + await publisher.enqueueProposeCheckpoint( + new Checkpoint(l2Block.archive, header, [l2Block], l2Block.checkpointNumber), + CommitteeAttestationsAndSigners.empty(testSignatureContext), + Signature.empty(), + { simulationOverridesPlan: enqueuePlan }, + ); + + expect(validateSpy).toHaveBeenCalledTimes(1); + expect(validateSpy.mock.calls[0][3]).toMatchObject({ + chainTipsOverride: { pending: CheckpointNumber(7) }, + disableBlobCheck: true, + }); + + const requests: { preCheck?: () => Promise }[] = (publisher as any).requests; + expect(requests).toHaveLength(1); + const preCheck = requests[0].preCheck; + expect(preCheck).toBeDefined(); + + validateSpy.mockClear(); + await preCheck!(); + + expect(validateSpy).toHaveBeenCalledTimes(1); + const preCheckArg = validateSpy.mock.calls[0][3]; + expect(preCheckArg?.disableBlobCheck).toBe(true); + expect(preCheckArg?.chainTipsOverride).toBeUndefined(); + expect(preCheckArg?.pendingCheckpointState).toBeUndefined(); + }); + it('returns errorMsg if forwarder tx reverts', async () => { forwardSpy.mockResolvedValue({ receipt: { ...proposeTxReceipt, status: 'reverted' }, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index faae6363153e..b5f49d000293 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -120,6 +120,13 @@ export type InvalidateCheckpointRequest = { type EnqueueProposeCheckpointOpts = { txTimeoutAt?: Date; simulationOverridesPlan?: SimulationOverridesPlan; + /** + * Overrides to apply to the preCheck simulation right before L1 submission. + * Intentionally separate from `simulationOverridesPlan`: enqueue-time validation + * may need pipelined-parent / pretend-proof-landed overrides, but preCheck must + * reflect real L1 state to catch state drift between build and submission. + */ + preCheckSimulationOverridesPlan?: SimulationOverridesPlan; }; interface RequestWithExpiry { @@ -1157,6 +1164,10 @@ export class SequencerPublisher { .withoutBlobCheck() .build(); + const preCheckSimulationOverridesPlan = SimulationOverridesBuilder.from(opts.preCheckSimulationOverridesPlan) + .withoutBlobCheck() + .build(); + try { // @note This will make sure that we are passing the checks for our header ASSUMING that the data is also made available // This means that we can avoid the simulation issues in later checks. @@ -1188,7 +1199,7 @@ export class SequencerPublisher { checkpoint, attestationsAndSigners, attestationsAndSignersSignature, - simulationOverridesPlan, + preCheckSimulationOverridesPlan, ); }; } diff --git a/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts b/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts index e2cdc58522e2..eddc44a04dcc 100644 --- a/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts +++ b/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts @@ -9,6 +9,14 @@ type PipelinedParentSimulationOverridesPlanInput = { proposedCheckpointData?: ProposedCheckpointData; rollup: RollupContract; log: Logger; + /** + * Whether proposer pipelining is enabled. Controls only the parent pending/fee-header + * portion of the plan — the proven override below is independent of pipelining because + * the boundary build needs it for globals and enqueue-time validation regardless. + */ + pipeliningEnabled: boolean; + /** If set, also overrides `tips.proven` so `canPruneAtTime` returns false at the simulation timestamp. */ + prunePending?: { provenOverride: CheckpointNumber }; }; type SubmissionSimulationOverridesPlanInput = { @@ -18,16 +26,29 @@ type SubmissionSimulationOverridesPlanInput = { pipeliningEnabled: boolean; }; -/** Builds the simulated parent checkpoint view used while constructing a pipelined proposal. */ +/** + * Builds the simulated chain view used while constructing a checkpoint proposal. May carry: + * - A pending parent override + fee header (only when pipelining is enabled). + * - A proven override (whenever `prunePending` is set, even with pipelining off — the boundary + * build needs it for the globals builder's mana-min-fee lookup and the enqueue-time + * submission simulation regardless of pipelining). + */ export async function buildPipelinedParentSimulationOverridesPlan( input: PipelinedParentSimulationOverridesPlanInput, ): Promise { - const parentCheckpointNumber = CheckpointNumber(input.checkpointNumber - 1); - const builder = new SimulationOverridesBuilder().forPendingCheckpoint(parentCheckpointNumber); + const builder = new SimulationOverridesBuilder(); + + if (input.pipeliningEnabled) { + const parentCheckpointNumber = CheckpointNumber(input.checkpointNumber - 1); + builder.withChainTips({ pending: parentCheckpointNumber }); + const pendingFeeHeader = await computePipelinedParentFeeHeader(input); + if (pendingFeeHeader) { + builder.withPendingFeeHeader(pendingFeeHeader); + } + } - const pendingFeeHeader = await computePipelinedParentFeeHeader(input); - if (pendingFeeHeader) { - builder.withPendingFeeHeader(pendingFeeHeader); + if (input.prunePending) { + builder.withChainTips({ proven: input.prunePending.provenOverride }); } return builder.build(); @@ -38,11 +59,12 @@ export function buildSubmissionSimulationOverridesPlan( input: SubmissionSimulationOverridesPlanInput, ): SimulationOverridesPlan | undefined { const pendingCheckpointNumber = - input.invalidateToPendingCheckpointNumber ?? input.pipelinedParentPlan?.pendingCheckpointNumber; + input.invalidateToPendingCheckpointNumber ?? input.pipelinedParentPlan?.chainTipsOverride?.pending; - const builder = SimulationOverridesBuilder.from(input.pipelinedParentPlan).forPendingCheckpoint( - pendingCheckpointNumber, - ); + const builder = SimulationOverridesBuilder.from(input.pipelinedParentPlan); + if (pendingCheckpointNumber !== undefined) { + builder.withChainTips({ pending: pendingCheckpointNumber }); + } if (input.pipeliningEnabled && pendingCheckpointNumber !== undefined) { builder.withPendingArchive(input.lastArchiveRoot); @@ -51,8 +73,15 @@ export function buildSubmissionSimulationOverridesPlan( return builder.build(); } +type PipelinedParentFeeHeaderInput = { + checkpointNumber: CheckpointNumber; + proposedCheckpointData?: ProposedCheckpointData; + rollup: RollupContract; + log: Logger; +}; + /** Derives the pending parent fee header used during pipelined proposal simulation. */ -export async function computePipelinedParentFeeHeader(input: PipelinedParentSimulationOverridesPlanInput) { +export async function computePipelinedParentFeeHeader(input: PipelinedParentFeeHeaderInput) { if (!input.proposedCheckpointData || input.checkpointNumber < 2) { return undefined; } diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index e95e55ed41b4..390d48600f1b 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -69,7 +69,10 @@ import { mockTxIterator, setupTxsAndBlock, } from '../test/utils.js'; -import { computePipelinedParentFeeHeader } from './chain_state_overrides.js'; +import { + buildPipelinedParentSimulationOverridesPlan, + computePipelinedParentFeeHeader, +} from './chain_state_overrides.js'; import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import type { CheckpointProposalJobMetricsRecorder } from './checkpoint_proposal_job_metrics.js'; import type { SequencerEvents } from './events.js'; @@ -768,6 +771,59 @@ describe('CheckpointProposalJob', () => { }); }); + describe('buildPipelinedParentSimulationOverridesPlan', () => { + const checkpointNumberUnderTest = CheckpointNumber(2); + + it('sets pending override for the parent checkpoint when pipelining is enabled', async () => { + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: checkpointNumberUnderTest, + proposedCheckpointData: undefined, + rollup: publisher.rollupContract, + log: createLogger('test'), + pipeliningEnabled: true, + }); + expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); + expect(plan?.chainTipsOverride?.proven).toBeUndefined(); + }); + + it('returns undefined when pipelining off and no prunePending', async () => { + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: checkpointNumberUnderTest, + proposedCheckpointData: undefined, + rollup: publisher.rollupContract, + log: createLogger('test'), + pipeliningEnabled: false, + }); + expect(plan).toBeUndefined(); + }); + + it('returns plan with proven-only override when pipelining off and prunePending is set', async () => { + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: checkpointNumberUnderTest, + proposedCheckpointData: undefined, + rollup: publisher.rollupContract, + log: createLogger('test'), + pipeliningEnabled: false, + prunePending: { provenOverride: CheckpointNumber(0) }, + }); + expect(plan?.chainTipsOverride?.pending).toBeUndefined(); + expect(plan?.chainTipsOverride?.proven).toEqual(CheckpointNumber(0)); + }); + + it('attaches both parent and proven overrides when pipelining on and prunePending is set', async () => { + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: checkpointNumberUnderTest, + proposedCheckpointData: undefined, + rollup: publisher.rollupContract, + log: createLogger('test'), + pipeliningEnabled: true, + prunePending: { provenOverride: CheckpointNumber(0) }, + }); + expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); + expect(plan?.chainTipsOverride?.proven).toEqual(CheckpointNumber(0)); + }); + }); + describe('pipelining parent checkpoint validation', () => { const parentCheckpointHeader = CheckpointHeader.empty(); const parentCheckpointHash = parentCheckpointHeader.hash().toString(); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 6bde4e0d549c..3c5317dca265 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -104,7 +104,11 @@ export class CheckpointProposalJob implements Traceable { /** Tracks the fire-and-forget L1 submission promise so it can be awaited during shutdown. */ private pendingL1Submission: Promise | undefined; - /** Pipelined parent chain state used while building and later submitting this checkpoint. */ + /** + * Build-time chain state overrides used both during build (globals + invariant checks) and + * later for enqueue-time submission validation. May carry the pipelined parent override, the + * pretend-proof-landed (`proven`) override at an epoch boundary, or both. + */ private pipelinedParentSimulationOverridesPlan?: SimulationOverridesPlan; private getSignatureContext(): CoordinationSignatureContext { @@ -144,6 +148,7 @@ export class CheckpointProposalJob implements Traceable { public readonly tracer: Tracer, bindings?: LoggerBindings, private readonly proposedCheckpointData?: ProposedCheckpointData, + private readonly prunePending?: { provenOverride: CheckpointNumber }, ) { this.log = createLogger('sequencer:checkpoint-proposal', { ...bindings, @@ -354,16 +359,24 @@ export class CheckpointProposalJob implements Traceable { } const isPipelining = this.epochCache.isProposerPipeliningEnabled(); - const submissionSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({ + const enqueueSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({ pipelinedParentPlan: this.pipelinedParentSimulationOverridesPlan, invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber, lastArchiveRoot: checkpoint.header.lastArchiveRoot, pipeliningEnabled: isPipelining, }); + const preCheckSimulationOverridesPlan = buildSubmissionSimulationOverridesPlan({ + pipelinedParentPlan: undefined, + invalidateToPendingCheckpointNumber: this.invalidateCheckpoint?.forcePendingCheckpointNumber, + lastArchiveRoot: checkpoint.header.lastArchiveRoot, + pipeliningEnabled: isPipelining, + }); + await this.publisher.enqueueProposeCheckpoint(checkpoint, attestations, attestationsSignature, { txTimeoutAt, - ...(submissionSimulationOverridesPlan ? { simulationOverridesPlan: submissionSimulationOverridesPlan } : {}), + simulationOverridesPlan: enqueueSimulationOverridesPlan, + preCheckSimulationOverridesPlan, }); } @@ -549,14 +562,14 @@ export class CheckpointProposalJob implements Traceable { // When pipelining, force the proposed checkpoint number and fee header to our parent so the // fee computation sees the same chain tip that L1 will see once the previous pipelined checkpoint lands. const isPipelining = this.epochCache.isProposerPipeliningEnabled(); - this.pipelinedParentSimulationOverridesPlan = isPipelining - ? await buildPipelinedParentSimulationOverridesPlan({ - checkpointNumber: this.checkpointNumber, - proposedCheckpointData: this.proposedCheckpointData, - rollup: this.publisher.rollupContract, - log: this.log, - }) - : undefined; + this.pipelinedParentSimulationOverridesPlan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: this.checkpointNumber, + proposedCheckpointData: this.proposedCheckpointData, + rollup: this.publisher.rollupContract, + log: this.log, + pipeliningEnabled: isPipelining, + prunePending: this.prunePending, + }); const checkpointGlobalVariables = await this.globalsBuilder.buildCheckpointGlobalVariables( coinbase, diff --git a/yarn-project/sequencer-client/src/sequencer/events.ts b/yarn-project/sequencer-client/src/sequencer/events.ts index 11e8ab74dd38..a0fa73c011e4 100644 --- a/yarn-project/sequencer-client/src/sequencer/events.ts +++ b/yarn-project/sequencer-client/src/sequencer/events.ts @@ -10,6 +10,26 @@ export type SequencerEvents = { secondsIntoSlot?: number; slot?: SlotNumber; }) => void; + /** + * Emitted by the sequencer once it has decided it is going to attempt to build a + * checkpoint for `targetSlot`, after computing the L1 simulation overrides used by + * `canProposeAt`. Fired BEFORE the L1 simulation is run, so consumers can observe the + * decision regardless of whether the propose ultimately lands. + * + * - `hadProposedParent` indicates whether the build saw a proposed (pipelined) parent + * checkpoint that hasn't landed on L1 yet. + * - `provenOverride` is the assumed proven checkpoint number when the proven-override + * for a pending prune was applied; `undefined` when no override was applied. + * - `simulatedPending` is the pending checkpoint passed to L1 simulation (when + * pipelining or invalidating; undefined otherwise). + */ + ['preparing-checkpoint']: (args: { + targetSlot: SlotNumber; + checkpointNumber: CheckpointNumber; + hadProposedParent: boolean; + provenOverride: CheckpointNumber | undefined; + simulatedPending: CheckpointNumber | undefined; + }) => void; ['proposer-rollup-check-failed']: (args: { reason: string; slot: SlotNumber }) => void; ['block-tx-count-check-failed']: (args: { minTxs: number; availableTxs: number; slot: SlotNumber }) => void; ['block-build-failed']: (args: { reason: string; slot: SlotNumber }) => void; diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 033aad7108ed..30b5186779a6 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -393,6 +393,30 @@ describe('sequencer', () => { expectPublisherProposeL2Block(); }); + it('does not build a block when targetSlot is in pauseProposingForSlots', async () => { + await setupSingleTxBlock(); + const targetSlot = block.header.globalVariables.slotNumber; + sequencer.updateConfig({ pauseProposingForSlots: [targetSlot] }); + + await sequencer.work(); + + expect(checkpointBuilder.buildBlockCalls).toHaveLength(0); + expect(publisher.canProposeAt).not.toHaveBeenCalled(); + expect(publisher.enqueueProposeCheckpoint).not.toHaveBeenCalled(); + }); + + it('still builds a block when targetSlot is not in pauseProposingForSlots', async () => { + await setupSingleTxBlock(); + const targetSlot = block.header.globalVariables.slotNumber; + const otherSlot = SlotNumber(Number(targetSlot) + 1); + sequencer.updateConfig({ pauseProposingForSlots: [otherSlot] }); + + await sequencer.work(); + await sequencer.awaitLastProposalSubmission(); + + expectPublisherProposeL2Block(); + }); + it('does not build a block if it does not have enough time left in the slot', async () => { await setupSingleTxBlock(); @@ -1081,7 +1105,7 @@ describe('sequencer', () => { await sequencer.work(); const simulationOverridesPlan = publisher.canProposeAt.mock.calls.at(-1)?.[2]; - expect(simulationOverridesPlan?.pendingCheckpointNumber).toEqual(CheckpointNumber(1)); + expect(simulationOverridesPlan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); expect(simulationOverridesPlan?.pendingCheckpointState?.archive).toEqual(expect.anything()); }); @@ -1174,6 +1198,129 @@ describe('sequencer', () => { expect(publisher.canProposeAt.mock.calls.at(-1)?.[2]).toBeUndefined(); }); + + it('attaches proven override equal to real pending when isPruneDueAtSlot returns true', async () => { + await setupSingleTxBlock(); + + // No proposed checkpoint, so we exercise the standalone proven override path. + // The default `getL2Tips` mock has checkpointed.checkpoint.number == CheckpointNumber.ZERO. + l2BlockSource.isPruneDueAtSlot.mockResolvedValue(true); + + await sequencer.work(); + + const plan = publisher.canProposeAt.mock.calls.at(-1)?.[2]; + expect(plan?.chainTipsOverride?.proven).toEqual(CheckpointNumber.ZERO); + }); + + it('uses the simulated pending as the proven override when the caller overrides pending', async () => { + await setupSingleTxBlock(); + + // Set up a pipelined parent (pending override = parentCheckpointNumber = 1). + const nonGenesisHash = Fr.random().toString(); + const proposedCheckpointHash = Fr.random().toString(); + worldState.status.mockResolvedValue({ + state: WorldStateRunningState.IDLE, + syncSummary: { + latestBlockNumber: BlockNumber(1), + latestBlockHash: nonGenesisHash, + finalizedBlockNumber: BlockNumber.ZERO, + oldestHistoricBlockNumber: BlockNumber.ZERO, + treesAreSynched: true, + }, + } satisfies WorldStateSynchronizerStatus); + const tipsWithBlock1 = { + proposed: { number: BlockNumber(1), hash: nonGenesisHash }, + proposedCheckpoint: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber(1), hash: proposedCheckpointHash }, + }, + checkpointed: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + proven: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + finalized: { + block: { number: BlockNumber(1), hash: nonGenesisHash }, + checkpoint: { number: CheckpointNumber.ZERO, hash: GENESIS_CHECKPOINT_HEADER_HASH.toString() }, + }, + }; + l2BlockSource.getL2Tips.mockResolvedValue(tipsWithBlock1); + l1ToL2MessageSource.getL2Tips.mockResolvedValue(tipsWithBlock1); + p2p.getStatus.mockResolvedValue({ + syncedToL2Block: { number: BlockNumber(1), hash: nonGenesisHash }, + } as any); + l2BlockSource.getBlockData.mockResolvedValue({ + header: BlockHeader.empty({ globalVariables: GlobalVariables.empty({ blockNumber: BlockNumber(1) }) }), + archive: AppendOnlyTreeSnapshot.empty(), + blockHash: BlockHash.ZERO, + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + } satisfies BlockData); + l2BlockSource.getProposedCheckpointData.mockResolvedValue({ + checkpointNumber: CheckpointNumber(1), + } as any); + + // The sequencer sets proven == simulated pending so canPruneAtTime short-circuits to false. + l2BlockSource.isPruneDueAtSlot.mockResolvedValue(true); + + await sequencer.work(); + + const plan = publisher.canProposeAt.mock.calls.at(-1)?.[2]; + expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); + expect(plan?.chainTipsOverride?.proven).toEqual(CheckpointNumber(1)); + }); + + it('does not attach proven override when isPruneDueAtSlot returns false', async () => { + await setupSingleTxBlock(); + + l2BlockSource.isPruneDueAtSlot.mockResolvedValue(false); + + await sequencer.work(); + + const plan = publisher.canProposeAt.mock.calls.at(-1)?.[2]; + expect(plan?.chainTipsOverride?.proven).toBeUndefined(); + }); + + it('emits preparing-checkpoint with provenOverride when prune is due', async () => { + await setupSingleTxBlock(); + + l2BlockSource.isPruneDueAtSlot.mockResolvedValue(true); + + const events: any[] = []; + sequencer.on('preparing-checkpoint', args => events.push(args)); + + await sequencer.work(); + + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + targetSlot: SlotNumber(2), + checkpointNumber: expect.anything(), + hadProposedParent: false, + provenOverride: CheckpointNumber.ZERO, + simulatedPending: undefined, + }); + }); + + it('emits preparing-checkpoint without provenOverride when no prune is due', async () => { + await setupSingleTxBlock(); + + l2BlockSource.isPruneDueAtSlot.mockResolvedValue(false); + + const events: any[] = []; + sequencer.on('preparing-checkpoint', args => events.push(args)); + + await sequencer.work(); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + targetSlot: SlotNumber(2), + hadProposedParent: false, + provenOverride: undefined, + }); + }); }); describe('view-based proposer lookup', () => { diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index e670e509d28d..abac077c8852 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -286,6 +286,15 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter s === targetSlot)) { + this.log.warn(`Skipping proposal for paused slot ${targetSlot} (test-only pauseProposingForSlots hook)`, { + targetSlot, + }); + return undefined; + } + // Check all components are synced to latest as seen by the archiver (queries all subsystems) const syncedTo = await this.checkSync({ ts, slot }); if (!syncedTo) { @@ -383,11 +392,14 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter TypedEventEmitter; + /** + * Returns true iff `canPruneAtTime` would be true at the latest L1 timestamp inside + * the L2 slot's window. Computed entirely from local archiver state (no L1 RPC). + * @param slot - The L2 slot to check. + */ + isPruneDueAtSlot(slot: SlotNumber): Promise; + /** Returns values for the genesis block */ getGenesisValues(): Promise<{ genesisArchiveRoot: Fr }>; diff --git a/yarn-project/stdlib/src/interfaces/archiver.test.ts b/yarn-project/stdlib/src/interfaces/archiver.test.ts index db269fcab3ef..3ee0d7305a5b 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.test.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.test.ts @@ -313,6 +313,11 @@ describe('ArchiverApiSchema', () => { const result = await context.client.getBlocksData({ from: BlockNumber(1), limit: 1 }); expect(result).toEqual([]); }); + + it('isPruneDueAtSlot', async () => { + const result = await context.client.isPruneDueAtSlot(SlotNumber(1)); + expect(result).toBe(false); + }); }); describe('BlockQuerySchema', () => { @@ -579,4 +584,7 @@ class MockArchiver implements ArchiverApi { getL1Timestamp(): Promise { return Promise.resolve(1n); } + isPruneDueAtSlot(_slot: SlotNumber): Promise { + return Promise.resolve(false); + } } diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 38d8ef9750f8..005958289b8d 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -130,6 +130,7 @@ export const ArchiverApiSchema: ApiSchemaFor = { getL1ToL2MessageIndex: z.function().args(schemas.Fr).returns(schemas.BigInt.optional()), getDebugFunctionName: z.function().args(schemas.AztecAddress, schemas.FunctionSelector).returns(optional(z.string())), getL1Constants: z.function().args().returns(L1RollupConstantsSchema), + isPruneDueAtSlot: z.function().args(schemas.SlotNumber).returns(z.boolean()), getGenesisValues: z .function() .args() diff --git a/yarn-project/stdlib/src/interfaces/configs.ts b/yarn-project/stdlib/src/interfaces/configs.ts index efcf3942b32d..c85ebc7a4f6e 100644 --- a/yarn-project/stdlib/src/interfaces/configs.ts +++ b/yarn-project/stdlib/src/interfaces/configs.ts @@ -1,3 +1,4 @@ +import { type SlotNumber, SlotNumberSchema } from '@aztec/foundation/branded-types'; import type { EthAddress } from '@aztec/foundation/eth-address'; import type { Prettify } from '@aztec/foundation/types'; @@ -87,6 +88,8 @@ export interface SequencerConfig { skipPublishingCheckpointsPercent?: number; /** Skip broadcasting checkpoint and block proposals via gossipsub when proposer (for testing only) */ skipBroadcastProposals?: boolean; + /** List of slots for which the sequencer will not produce a proposal (for testing only). Attestation paths are unaffected. */ + pauseProposingForSlots?: SlotNumber[]; } export const SequencerConfigSchema = zodFor()( @@ -130,6 +133,7 @@ export const SequencerConfigSchema = zodFor()( minBlocksForCheckpoint: z.number().positive().optional(), skipPublishingCheckpointsPercent: z.number().gte(0).lte(100).optional(), skipBroadcastProposals: z.boolean().optional(), + pauseProposingForSlots: z.array(SlotNumberSchema).optional(), }), ); @@ -152,7 +156,8 @@ type SequencerConfigOptionalKeys = | 'maxL2BlockGas' | 'maxDABlockGas' | 'redistributeCheckpointBudget' - | 'skipBroadcastProposals'; + | 'skipBroadcastProposals' + | 'pauseProposingForSlots'; export type ResolvedSequencerConfig = Prettify< Required> & Pick From a358573008c11d2586f8894ec71f16b066ed3725 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 14:55:21 -0300 Subject: [PATCH 24/55] fix(sequencer): use chainTipsOverride.pending for log context (#23098) ## Summary Fixes a build error in `sequencer-client/src/sequencer/sequencer.ts:454`: ``` error TS2551: Property 'pendingCheckpointNumber' does not exist on type 'SimulationOverridesPlan'. Did you mean 'pendingCheckpointState'? ``` The `pendingCheckpointNumber` field was removed from `SimulationOverridesPlan` and replaced with `chainTipsOverride.pending`. The log context in `proposeContext` was still referencing the old field. Updated the reference to use `simulationOverridesPlan?.chainTipsOverride?.pending`, matching the existing usage on line 438. ## Test plan - `yarn workspace @aztec/sequencer-client build` succeeds --- yarn-project/sequencer-client/src/sequencer/sequencer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index abac077c8852..f29148fcfbf5 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -451,7 +451,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter Date: Fri, 8 May 2026 14:59:41 -0400 Subject: [PATCH 25/55] test(e2e): relax post-boundary slot assertion in epochs_proof_at_boundary (#23108) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes flaky CI on `merge-train/spartan` ([run](https://github.com/AztecProtocol/aztec-packages/actions/runs/25570963690), [log](http://ci.aztec-labs.com/1778262953204813)) where `epochs_proof_at_boundary.parallel.test.ts > proof never lands so no checkpoint submission is attempted` failed with: ``` expect(received).toBe(expected) Expected: 31 Received: 32 > 312 | expect(Number(firstPostBoundary.slot)).toBe(Number(boundarySlot) + 1); ``` ## Root cause The assertion's inline comment explicitly acknowledges this is *empirical*: whether the on-chain prune fires in-tx at `boundarySlot+1` or only at `boundarySlot+2` depends on real-time L1 / proposer-rebuild timing. In this run, slot 31's pipelined propose still failed (`Rollup__InvalidArchive`) and slot 32 was the first slot where the propose was accepted and the checkpoint published. The merge-train head — #23098 (one-line log-context fix) — cannot influence this timing. The flake originated from #23056 (`feat(sequencer): build optimistically across pruning epoch boundary`) earlier in the same train. ## Fix Relax `toBe(boundarySlot + 1)` → `toBeLessThanOrEqual(boundarySlot + 2)` for both the no-parent and with-parent variants of "proof never lands". The lower bound is already enforced by `waitForFirstCheckpointAfterBoundary` filtering for `slot > boundarySlot`. The test's intent (a checkpoint lands in the new epoch shortly after the boundary) is preserved. The other two boundary tests where the proof DOES land use `checkpointNumber >= boundaryPublished.checkpoint`, not slot equality, so they aren't affected. Full analysis: https://gist.github.com/AztecBot/b4010e694332cca93a51024915867e9a ## Test plan CI on this PR. The container ClaudeBox runs in lacks docker / writeable cache, so local `./bootstrap.sh ci` could not be executed. ClaudeBox log: https://claudebox.work/s/d49b46d7e0cb49a6?run=1 --- .../epochs_proof_at_boundary.parallel.test.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts index 456f1476b5ba..9ae7af5040f8 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_proof_at_boundary.parallel.test.ts @@ -302,14 +302,13 @@ describe('e2e_epochs/epochs_proof_at_boundary', () => { expect(boundaryPreparing.some(p => p.hadProposedParent)).toBe(true); expect(boundaryPreparing.some(p => p.provenOverride !== undefined)).toBe(true); - // After the boundary fails, the next slot's propose tx triggers the on-chain prune (since the - // proof never landed and the deadline has expired) and resets `tips.pending`. The fresh - // checkpoint against the genesis archive lands at boundarySlot+1: empirically the next slot's - // proposer rebuilds with no proposed parent (the boundary's pipelined parent is invalidated - // when its publish fails), the on-chain prune fires in-tx on this propose, and the propose - // is accepted in the same slot. + // After the boundary fails, a subsequent slot's propose tx triggers the on-chain prune (since + // the proof never landed and the deadline has expired) and resets `tips.pending`. The fresh + // checkpoint against the genesis archive should land within a few slots of the boundary — + // empirically the next slot or two depending on whether the proposer rebuilds in time and + // whether the on-chain prune fires in-tx on the first post-boundary propose attempt. const firstPostBoundary = await waitForFirstCheckpointAfterBoundary(events, boundarySlot); - expect(Number(firstPostBoundary.slot)).toBe(Number(boundarySlot) + 1); + expect(Number(firstPostBoundary.slot)).toBeLessThanOrEqual(Number(boundarySlot) + 2); expect(getEpochAtSlot(firstPostBoundary.slot, test.constants)).toBe(boundaryEpoch); }); @@ -381,9 +380,9 @@ describe('e2e_epochs/epochs_proof_at_boundary', () => { expect(boundaryPreparing.every(p => !p.hadProposedParent)).toBe(true); expect(boundaryPreparing.some(p => p.provenOverride !== undefined)).toBe(true); - // See the parent test for the reasoning: the on-chain prune fires in-tx on the next slot's - // propose, so the first post-boundary checkpoint lands at boundarySlot+1. + // See the parent test for the reasoning: a subsequent slot's propose triggers the on-chain + // prune in-tx, so the first post-boundary checkpoint lands within a couple of slots. const firstPostBoundary = await waitForFirstCheckpointAfterBoundary(events, boundarySlot); - expect(Number(firstPostBoundary.slot)).toBe(Number(boundarySlot) + 1); + expect(Number(firstPostBoundary.slot)).toBeLessThanOrEqual(Number(boundarySlot) + 2); }); }); From 4735e42737f465bc7eb27c69be059df7a243da8d Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 16:06:58 -0300 Subject: [PATCH 26/55] fix(archiver): move L2 tips cache refresh out of write transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ArchiverDataStoreUpdater used to call `l2TipsCache.refresh()` inside the `db.transactionAsync()` callback for every writer path. Two issues: 1. Mid-tx visibility. `refresh()` reassigns its internal #tipsPromise synchronously, which was observable to other callers before LMDB had actually committed. A concurrent reader calling `getL2Tips()` after the reassignment but before commit picks up a promise loaded against the in-flight tx state, while a sibling read on `#proposedCheckpoints` directly outside the tx still sees pre-commit state — split-snapshot reads in the sequencer's `checkSync()`. 2. No rollback on tx abort. If the LMDB transaction threw or aborted, the cache had already been replaced with a promise loaded against in-flight writes that would never commit. Future readers would see a cache reflecting rolled-back state. Refresh now runs after the writer transaction has fully committed, so it loads from the committed store and is never replaced when the writer aborts. This does not close the JS-side race window completely — there is still a small "tips lag store" window between LMDB commit returning and `refresh()` finishing its `loadFromStore`. The sequencer's `checkSync()` consistency checks (sequencer-client/src/sequencer/sequencer.ts ~L700) already handle that residual window by detecting the mismatch and returning undefined; those checks are intentionally left in place. --- .../src/modules/data_store_updater.test.ts | 28 +++++++++++++++++++ .../src/modules/data_store_updater.ts | 18 ++++++------ .../archiver/src/store/l2_tips_cache.ts | 5 ++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index 3722d6eb8654..6316a16d5198 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -7,12 +7,15 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { L2Block } from '@aztec/stdlib/block'; import { ContractClassLog, PrivateLog } from '@aztec/stdlib/logs'; import '@aztec/stdlib/testing/jest'; +import { BlockHeader } from '@aztec/stdlib/tx'; +import { jest } from '@jest/globals'; import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js'; +import { L2TipsCache } from '../store/l2_tips_cache.js'; import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js'; import { ArchiverDataStoreUpdater } from './data_store_updater.js'; @@ -215,4 +218,29 @@ describe('ArchiverDataStoreUpdater', () => { expect(publicLogsAfter.logs.length).toBe(0); }); }); + + describe('l2 tips cache refresh', () => { + it('does not refresh the cache when the writer transaction aborts', async () => { + const initialBlockHash = await BlockHeader.empty().hash(); + const tipsCache = new L2TipsCache(store.blocks, initialBlockHash); + const updaterWithCache = new ArchiverDataStoreUpdater(store, tipsCache); + + const tipsBefore = await tipsCache.getL2Tips(); + + const block = await L2Block.random(BlockNumber(1), { + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + + const failure = new Error('forced failure inside writer transaction'); + const addProposedBlockSpy = jest.spyOn(store.blocks, 'addProposedBlock').mockRejectedValueOnce(failure); + + await expect(updaterWithCache.addProposedBlock(block)).rejects.toBe(failure); + + const tipsAfter = await tipsCache.getL2Tips(); + expect(tipsAfter).toEqual(tipsBefore); + + addProposedBlockSpy.mockRestore(); + }); + }); }); diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index e1b50131d589..2fb394c497a4 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -74,9 +74,9 @@ export class ArchiverDataStoreUpdater { this.addContractDataToDb(block), ]); - await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); + await this.l2TipsCache?.refresh(); return result; } @@ -144,18 +144,17 @@ export class ArchiverDataStoreUpdater { : undefined, ]); - await this.l2TipsCache?.refresh(); return { prunedBlocks, lastAlreadyInsertedBlockNumber }; }); + await this.l2TipsCache?.refresh(); return result; } public async addProposedCheckpoint(proposedCheckpoint: ProposedCheckpointInput) { const result = await this.stores.db.transactionAsync(async () => { await this.stores.blocks.addProposedCheckpoint(proposedCheckpoint); - await this.l2TipsCache?.refresh(); }); - + await this.l2TipsCache?.refresh(); return result; } @@ -256,9 +255,9 @@ export class ArchiverDataStoreUpdater { // Clear all pending proposed checkpoints since their blocks have been pruned await this.stores.blocks.deleteProposedCheckpoints(); - await this.l2TipsCache?.refresh(); return result; }); + await this.l2TipsCache?.refresh(); return result; } @@ -289,7 +288,7 @@ export class ArchiverDataStoreUpdater { * @returns True if the operation is successful. */ public async removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise { - return await this.stores.db.transactionAsync(async () => { + const result = await this.stores.db.transactionAsync(async () => { const { blocksRemoved = [] } = await this.stores.blocks.removeCheckpointsAfter(checkpointNumber); const opResults = await Promise.all([ @@ -300,9 +299,10 @@ export class ArchiverDataStoreUpdater { this.stores.logs.deleteLogs(blocksRemoved), ]); - await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); + await this.l2TipsCache?.refresh(); + return result; } /** @@ -312,8 +312,8 @@ export class ArchiverDataStoreUpdater { public async setProvenCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setProvenCheckpointNumber(checkpointNumber); - await this.l2TipsCache?.refresh(); }); + await this.l2TipsCache?.refresh(); } /** @@ -323,8 +323,8 @@ export class ArchiverDataStoreUpdater { public async setFinalizedCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setFinalizedCheckpointNumber(checkpointNumber); - await this.l2TipsCache?.refresh(); }); + await this.l2TipsCache?.refresh(); } /** Extracts and stores contract data from a single block. */ diff --git a/yarn-project/archiver/src/store/l2_tips_cache.ts b/yarn-project/archiver/src/store/l2_tips_cache.ts index 21c4b08f47d0..bc69983fc722 100644 --- a/yarn-project/archiver/src/store/l2_tips_cache.ts +++ b/yarn-project/archiver/src/store/l2_tips_cache.ts @@ -13,7 +13,8 @@ import type { BlockStore } from './block_store.js'; /** * In-memory cache for L2 chain tips (proposed, checkpointed, proven, finalized). * Populated from the BlockStore on first access, then kept up-to-date by the ArchiverDataStoreUpdater. - * Refresh calls should happen within the store transaction that mutates block data to ensure consistency. + * Refresh calls should happen *after* the store transaction that mutates block data has committed, + * so the cache loads from committed state and is never replaced if the writer aborts. */ export class L2TipsCache { #tipsPromise: Promise | undefined; @@ -34,7 +35,7 @@ export class L2TipsCache { return (this.#tipsPromise ??= this.loadFromStore()); } - /** Reloads the L2 tips from the block store. Should be called within the store transaction that mutates data. */ + /** Reloads the L2 tips from the block store. Should be called after the writer transaction has committed. */ public async refresh(): Promise { this.#tipsPromise = this.loadFromStore(); await this.#tipsPromise; From 6716d66cbf79f934f5cf5389d0aa61485f4dfd74 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Fri, 8 May 2026 16:15:08 -0300 Subject: [PATCH 27/55] Revert "fix(archiver): move L2 tips cache refresh out of write transactions" This reverts commit 4735e42737f465bc7eb27c69be059df7a243da8d. --- .../src/modules/data_store_updater.test.ts | 28 ------------------- .../src/modules/data_store_updater.ts | 18 ++++++------ .../archiver/src/store/l2_tips_cache.ts | 5 ++-- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index 6316a16d5198..3722d6eb8654 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -7,15 +7,12 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { L2Block } from '@aztec/stdlib/block'; import { ContractClassLog, PrivateLog } from '@aztec/stdlib/logs'; import '@aztec/stdlib/testing/jest'; -import { BlockHeader } from '@aztec/stdlib/tx'; -import { jest } from '@jest/globals'; import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js'; -import { L2TipsCache } from '../store/l2_tips_cache.js'; import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js'; import { ArchiverDataStoreUpdater } from './data_store_updater.js'; @@ -218,29 +215,4 @@ describe('ArchiverDataStoreUpdater', () => { expect(publicLogsAfter.logs.length).toBe(0); }); }); - - describe('l2 tips cache refresh', () => { - it('does not refresh the cache when the writer transaction aborts', async () => { - const initialBlockHash = await BlockHeader.empty().hash(); - const tipsCache = new L2TipsCache(store.blocks, initialBlockHash); - const updaterWithCache = new ArchiverDataStoreUpdater(store, tipsCache); - - const tipsBefore = await tipsCache.getL2Tips(); - - const block = await L2Block.random(BlockNumber(1), { - checkpointNumber: CheckpointNumber(1), - indexWithinCheckpoint: IndexWithinCheckpoint(0), - }); - - const failure = new Error('forced failure inside writer transaction'); - const addProposedBlockSpy = jest.spyOn(store.blocks, 'addProposedBlock').mockRejectedValueOnce(failure); - - await expect(updaterWithCache.addProposedBlock(block)).rejects.toBe(failure); - - const tipsAfter = await tipsCache.getL2Tips(); - expect(tipsAfter).toEqual(tipsBefore); - - addProposedBlockSpy.mockRestore(); - }); - }); }); diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index 2fb394c497a4..e1b50131d589 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -74,9 +74,9 @@ export class ArchiverDataStoreUpdater { this.addContractDataToDb(block), ]); + await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); - await this.l2TipsCache?.refresh(); return result; } @@ -144,17 +144,18 @@ export class ArchiverDataStoreUpdater { : undefined, ]); + await this.l2TipsCache?.refresh(); return { prunedBlocks, lastAlreadyInsertedBlockNumber }; }); - await this.l2TipsCache?.refresh(); return result; } public async addProposedCheckpoint(proposedCheckpoint: ProposedCheckpointInput) { const result = await this.stores.db.transactionAsync(async () => { await this.stores.blocks.addProposedCheckpoint(proposedCheckpoint); + await this.l2TipsCache?.refresh(); }); - await this.l2TipsCache?.refresh(); + return result; } @@ -255,9 +256,9 @@ export class ArchiverDataStoreUpdater { // Clear all pending proposed checkpoints since their blocks have been pruned await this.stores.blocks.deleteProposedCheckpoints(); + await this.l2TipsCache?.refresh(); return result; }); - await this.l2TipsCache?.refresh(); return result; } @@ -288,7 +289,7 @@ export class ArchiverDataStoreUpdater { * @returns True if the operation is successful. */ public async removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise { - const result = await this.stores.db.transactionAsync(async () => { + return await this.stores.db.transactionAsync(async () => { const { blocksRemoved = [] } = await this.stores.blocks.removeCheckpointsAfter(checkpointNumber); const opResults = await Promise.all([ @@ -299,10 +300,9 @@ export class ArchiverDataStoreUpdater { this.stores.logs.deleteLogs(blocksRemoved), ]); + await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); - await this.l2TipsCache?.refresh(); - return result; } /** @@ -312,8 +312,8 @@ export class ArchiverDataStoreUpdater { public async setProvenCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setProvenCheckpointNumber(checkpointNumber); + await this.l2TipsCache?.refresh(); }); - await this.l2TipsCache?.refresh(); } /** @@ -323,8 +323,8 @@ export class ArchiverDataStoreUpdater { public async setFinalizedCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setFinalizedCheckpointNumber(checkpointNumber); + await this.l2TipsCache?.refresh(); }); - await this.l2TipsCache?.refresh(); } /** Extracts and stores contract data from a single block. */ diff --git a/yarn-project/archiver/src/store/l2_tips_cache.ts b/yarn-project/archiver/src/store/l2_tips_cache.ts index bc69983fc722..21c4b08f47d0 100644 --- a/yarn-project/archiver/src/store/l2_tips_cache.ts +++ b/yarn-project/archiver/src/store/l2_tips_cache.ts @@ -13,8 +13,7 @@ import type { BlockStore } from './block_store.js'; /** * In-memory cache for L2 chain tips (proposed, checkpointed, proven, finalized). * Populated from the BlockStore on first access, then kept up-to-date by the ArchiverDataStoreUpdater. - * Refresh calls should happen *after* the store transaction that mutates block data has committed, - * so the cache loads from committed state and is never replaced if the writer aborts. + * Refresh calls should happen within the store transaction that mutates block data to ensure consistency. */ export class L2TipsCache { #tipsPromise: Promise | undefined; @@ -35,7 +34,7 @@ export class L2TipsCache { return (this.#tipsPromise ??= this.loadFromStore()); } - /** Reloads the L2 tips from the block store. Should be called after the writer transaction has committed. */ + /** Reloads the L2 tips from the block store. Should be called within the store transaction that mutates data. */ public async refresh(): Promise { this.#tipsPromise = this.loadFromStore(); await this.#tipsPromise; From 6338c3f1158dd31086ebdb7ffc6375ff69dada22 Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Mon, 11 May 2026 08:26:20 -0400 Subject: [PATCH 28/55] fix(bb-prover): pool long-lived bb verifier processes instead of spawning per-call (#23093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Follow-up to #21564 (bb-prover bb.js migration) addressing the IVC verification perf regression that surfaced in `tx_stats_bench`. The migration kept the legacy spawn-per-verification model: every chonk/ultra-honk verification through `BBCircuitVerifier` spawned a fresh `bb` process and SIGTERMed it after one proof. `BB_NUM_IVC_VERIFIERS=8` only capped concurrency at the queue layer (`QueuedIVCVerifier`), not the number of bb processes. That made the bench spawn ~600 bb processes over its 60s 10 TPS phase inside an 8-CPU isolate. Two compounding problems: 1. ~50–100 ms of `bb` startup tax on every verification's hot path. 2. The bind→listen race in `NativeUnixSocket`: bb's socket file appears after `bind()` but before `listen()`. A TS `connect()` landing in that window gets `ECONNREFUSED`. Vanishingly rare under low load; reliable flake under contention. Diagnosis at http://ci.aztec-labs.com/735256f13a268733. ## What ### Make `BB_NUM_IVC_VERIFIERS` mean what its name says (commits aa99817, 0f4cb77) Pool of long-lived bb verifier processes instead of fresh-per-call. The factory class is renamed `BBJsProverFactory` → `BBJsFactory` (it's used for both proving and verifying) and given a single `getInstance(): Promise` method: - `new BBJsFactory(path)` → no pool. Every `getInstance()` spawns a fresh bb that is destroyed on dispose. Same as the previous `withFreshInstance` behaviour — used by `BBNativeRollupProver`, the AVM proving tester, and ivc-integration helpers, so their semantics are unchanged. - `new BBJsFactory(path, { poolSize: N })` → pool of N long-lived bb processes, lazily spawned on first acquire. Used by `BBCircuitVerifier` with `poolSize: numConcurrentIVCVerifiers`. Callers use `await using inst = await factory.getInstance()` for RAII-style release, matching the codebase's preference for `AsyncDisposable`. `BBCircuitVerifier.stop` (already wired through to aztec-node shutdown) tears the pool down. ### Close the bind→listen race in bb.js (commit 8e519b0) `barretenberg/ts/src/bb_backends/node/native_socket.ts`: retry `connect()` on `ECONNREFUSED` with exponential backoff (capped at 50 ms) up to the existing 5 s budget. Other socket errors fail fast as before. Pool startup still spawns N bb processes in parallel, so the race surface is reduced from ~600 to N — the retry handles the residual. ### Server-side Chonk proof split (commit 97577cf) `splitChonkProofToStructured` in TS had three hand-maintained constants (`MERGE_PROOF_SIZE`, `ECCVM_PROOF_LENGTH`, `JOINT_PROOF_LENGTH`) duplicating C++ values. When C++ shifted Chonk layout (e.g. databus relation changes shrinking the oink portion in the previous round of regressions), these went stale and verification failed deep in the verifier with an opaque "OinkVerifier: num_public_inputs mismatch with VK". Add a new `ChonkVerifyFromFields` bbapi command that takes a flat `Vec` and calls `ChonkProof::from_field_elements` server-side, then runs the verifier. The TS layer now passes flat fields straight through — no layout knowledge, no hand-maintained constants. - `bbapi_chonk.{hpp,cpp}`: new struct + `execute()`. - `bbapi_execute.hpp`: register the variant. - `bb_js_backend.ts`: `verifyChonkProof` calls the new API; `splitChonkProofToStructured` and the 3 constants are deleted. ### Disposal robustness (commit 5cde220) The first cut of `BBJsFactory` had three `.catch(() => {})` clauses that silently swallowed bb `destroy()` errors, and an `initPool()` that dropped already-spawned bb children if a sibling creation failed (`Promise.all` short-circuit). Both would manifest as the Jest "worker failed to exit gracefully" warning we hit on one test run. Now: destroy errors propagate (`AggregateError` for the pool path); `initPool` uses `allSettled` and tears down anything it spawned if any sibling rejects. ### Playground bundle size (commit 1681d33) The new `ChonkVerifyFromFields` bbapi variant tipped the playground main entrypoint over the 1750 KB hard limit. Bumped to 1800 with a bump-log entry. ## Effect - `tx_stats_bench`: 600 bb spawns → 8 bb spawns at boot, then 8 long-lived processes serve every verification. The bind→listen race surface drops 75×, *and* the residual is handled by the connect retry. Per-call ~50–100 ms `bb` startup cost disappears from the verifier hot path. - Brittle TS Chonk constants are gone — Chonk layout changes in C++ can no longer manifest as opaque verifier errors in TS. - Disposal failures surface instead of leaking bb children. - Behaviour for proving paths (`BBNativeRollupProver`, AVM tests, ivc-integration) is unchanged — they still spawn fresh per call. ClaudeBox log: https://claudebox.work/s/2d65052b0deaeab2?run=3 --------- Co-authored-by: Charlie <5764343+charlielye@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/barretenberg/bbapi/bbapi_chonk.cpp | 27 ++ .../src/barretenberg/bbapi/bbapi_chonk.hpp | 30 +++ .../src/barretenberg/bbapi/bbapi_execute.hpp | 2 + .../ts/src/bb_backends/node/native_socket.ts | 101 +++++--- playground/vite.config.ts | 3 +- .../avm_proving_tests/avm_proving_tester.ts | 13 +- .../bb-prover/src/bb/bb_js_backend.ts | 235 +++++++++++++----- yarn-project/bb-prover/src/config.ts | 5 +- .../bb-prover/src/prover/server/bb_prover.ts | 41 +-- .../bb-prover/src/verifier/bb_verifier.ts | 27 +- .../ivc-integration/src/prove_native.ts | 133 +++++----- 11 files changed, 421 insertions(+), 196 deletions(-) diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.cpp b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.cpp index 881af15ccf18..40569f436517 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.cpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.cpp @@ -189,6 +189,33 @@ ChonkVerify::Response ChonkVerify::execute(const BBApiRequest& /*request*/) && return { .valid = verified }; } +ChonkVerifyFromFields::Response ChonkVerifyFromFields::execute(const BBApiRequest& /*request*/) && +{ + BB_BENCH_NAME(MSGPACK_SCHEMA_NAME); + + using VerificationKey = Chonk::MegaVerificationKey; + validate_vk_size(vk); + + auto hiding_kernel_vk = std::make_shared(from_buffer(vk)); + + // Validate total field count: must match num_public_inputs + fixed overhead. + const size_t expected_field_count = + static_cast(hiding_kernel_vk->num_public_inputs) + ChonkProof::PROOF_LENGTH_WITHOUT_PUB_INPUTS; + if (proof.size() != expected_field_count) { + throw_or_abort("ChonkVerifyFromFields: proof has wrong field count: expected " + + std::to_string(expected_field_count) + ", got " + std::to_string(proof.size())); + } + + // Split the flat field array into the structured ChonkProof. Layout knowledge stays here. + auto structured = ChonkProof::from_field_elements(proof); + + auto vk_and_hash = std::make_shared(hiding_kernel_vk); + ChonkNativeVerifier verifier(vk_and_hash); + const bool verified = verifier.verify(structured); + + return { .valid = verified }; +} + ChonkBatchVerify::Response ChonkBatchVerify::execute(const BBApiRequest& /*request*/) && { BB_BENCH_NAME(MSGPACK_SCHEMA_NAME); diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.hpp b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.hpp index fac4688ba54e..65c4763519f4 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.hpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_chonk.hpp @@ -157,6 +157,36 @@ struct ChonkVerify { bool operator==(const ChonkVerify&) const = default; }; +/** + * @struct ChonkVerifyFromFields + * @brief Verify a Chonk proof passed as a flat field-element array (with public inputs prepended). + * + * The split into structured ChonkProof sub-proofs is done server-side via + * ChonkProof::from_field_elements, so callers do not need to know the per-component sub-proof + * sizes. This is the recommended entry point for TypeScript callers that hold the proof as a + * flat Fr[] (e.g. from tx.chonkProof.attachPublicInputs). + */ +struct ChonkVerifyFromFields { + static constexpr const char MSGPACK_SCHEMA_NAME[] = "ChonkVerifyFromFields"; + + struct Response { + static constexpr const char MSGPACK_SCHEMA_NAME[] = "ChonkVerifyFromFieldsResponse"; + + /** @brief True if the proof is valid */ + bool valid; + SERIALIZATION_FIELDS(valid); + bool operator==(const Response&) const = default; + }; + + /** @brief Flat proof field elements with public inputs prepended */ + std::vector proof; + /** @brief The verification key */ + std::vector vk; + Response execute(const BBApiRequest& request = {}) &&; + SERIALIZATION_FIELDS(proof, vk); + bool operator==(const ChonkVerifyFromFields&) const = default; +}; + /** * @struct ChonkComputeVk * @brief Compute MegaHonk verification key for a circuit to be accumulated in Chonk diff --git a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_execute.hpp b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_execute.hpp index ebfce39ddb7b..b28cb2e849fe 100644 --- a/barretenberg/cpp/src/barretenberg/bbapi/bbapi_execute.hpp +++ b/barretenberg/cpp/src/barretenberg/bbapi/bbapi_execute.hpp @@ -27,6 +27,7 @@ using Command = NamedUnion((resolve, reject) => { - this.socket = net.connect(this.socketPath); + // Connect with retry on ECONNREFUSED. The socket file appears after bb's bind() but + // before its listen(); a connect() landing in that window gets ECONNREFUSED. Retry + // briefly until bb is listening or we hit the 5s budget. + const socket = await this.connectWithRetry(startTime); + this.socket = socket; - // Disable Nagle's algorithm for lower latency - this.socket.setNoDelay(true); - - // Set up event handlers - this.socket.once('connect', () => { - // Socket starts referenced - will be unreferenced when no callbacks pending + // Clear connection timeout on successful connection + if (this.connectionTimeout) { + clearTimeout(this.connectionTimeout); + this.connectionTimeout = null; + } - // Clear connection timeout on successful connection - if (this.connectionTimeout) { - clearTimeout(this.connectionTimeout); - this.connectionTimeout = null; - } - resolve(); - }); + // Set up persistent handlers now that we're connected. + socket.on('data', (chunk: Buffer) => { + this.handleData(chunk); + }); - this.socket.once('error', err => { - reject(new Error(`Failed to connect to bb socket: ${err.message}`)); - }); + socket.on('error', err => { + const error = new Error(`Socket error: ${err.message}`); + for (const callback of this.pendingCallbacks) { + callback.reject(error); + } + this.pendingCallbacks = []; + }); - // Set up data handler after connection is established - this.socket.on('data', (chunk: Buffer) => { - this.handleData(chunk); - }); + socket.on('end', () => { + const error = new Error('Socket connection ended unexpectedly'); + for (const callback of this.pendingCallbacks) { + callback.reject(error); + } + this.pendingCallbacks = []; + }); + } - // Handle ongoing errors after initial connection - this.socket.on('error', err => { - // Reject all pending callbacks - const error = new Error(`Socket error: ${err.message}`); - for (const callback of this.pendingCallbacks) { - callback.reject(error); + private async connectWithRetry(startTime: number): Promise { + let attempt = 0; + let lastErr: Error | undefined; + while (Date.now() - startTime < 5000) { + try { + return await this.attemptConnect(); + } catch (err) { + lastErr = err as Error; + const code = (err as NodeJS.ErrnoException).code; + if (code !== 'ECONNREFUSED') { + throw new Error(`Failed to connect to bb socket: ${lastErr.message}`); } - this.pendingCallbacks = []; - }); + // bb has bound the path but not yet called listen(); back off and retry. + const delay = Math.min(50, 5 * 2 ** attempt++); + await new Promise(resolve => setTimeout(resolve, delay)); + } + } + throw new Error(`Timeout connecting to bb socket: ${lastErr?.message ?? 'unknown'}`); + } - this.socket.on('end', () => { - // Reject all pending callbacks - const error = new Error('Socket connection ended unexpectedly'); - for (const callback of this.pendingCallbacks) { - callback.reject(error); - } - this.pendingCallbacks = []; - }); + private attemptConnect(): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(this.socketPath); + socket.setNoDelay(true); + const onConnect = () => { + socket.removeListener('error', onError); + resolve(socket); + }; + const onError = (err: Error) => { + socket.removeListener('connect', onConnect); + socket.destroy(); + reject(err); + }; + socket.once('connect', onConnect); + socket.once('error', onError); }); } diff --git a/playground/vite.config.ts b/playground/vite.config.ts index a465bb66a3e2..ac6e32d62b66 100644 --- a/playground/vite.config.ts +++ b/playground/vite.config.ts @@ -136,9 +136,10 @@ export default defineConfig(({ mode }) => { // Bump log: // - AD: bumped from 1600 => 1680 as we now have a 20kb msgpack lib in bb.js and other logic got us 50kb higher, adding some wiggle room. // - MW: bumped from 1700 => 1750 after adding the noble curves pkg to foundation required for blob batching calculations. + // - CL: bumped from 1750 => 1800 after adding the ChonkVerifyFromFields bbapi variant (PR #23093). { pattern: /assets\/index-.*\.js$/, - maxSizeKB: 1750, + maxSizeKB: 1800, description: 'Main entrypoint, hard limit', }, // Bump log: diff --git a/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts b/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts index d0953a606e4a..53d2a0fc5e71 100644 --- a/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts +++ b/yarn-project/bb-prover/src/avm_proving_tests/avm_proving_tester.ts @@ -17,7 +17,7 @@ import { NativeWorldStateService } from '@aztec/world-state'; import path from 'path'; -import { BBJsProverFactory } from '../bb/bb_js_backend.js'; +import { BBJsFactory } from '../bb/bb_js_backend.js'; const BB_PATH = path.resolve('../../barretenberg/cpp/build/bin/bb-avm'); @@ -32,7 +32,7 @@ const provingConfig: PublicSimulatorConfig = PublicSimulatorConfig.from({ }); export class AvmProvingTester extends PublicTxSimulationTester { - private readonly bbJsFactory = new BBJsProverFactory(BB_PATH); + private readonly bbJsFactory = new BBJsFactory(BB_PATH); constructor( private checkCircuitOnly: boolean, @@ -64,13 +64,15 @@ export class AvmProvingTester extends PublicTxSimulationTester { const inputsBuffer = avmCircuitInputs.serializeWithMessagePack(); if (this.checkCircuitOnly) { - const { passed, stats } = await this.bbJsFactory.withFreshInstance(i => i.checkAvmCircuit(inputsBuffer)); + await using instance = await this.bbJsFactory.getInstance(); + const { passed, stats } = await instance.checkAvmCircuit(inputsBuffer); this.recordProverMetrics(stats, txLabel); expect(passed).toBe(true); return []; } - const { proof, stats } = await this.bbJsFactory.withFreshInstance(i => i.generateAvmProof(inputsBuffer)); + await using instance = await this.bbJsFactory.getInstance(); + const { proof, stats } = await instance.generateAvmProof(inputsBuffer); this.recordProverMetrics(stats, txLabel); return proof; } @@ -81,7 +83,8 @@ export class AvmProvingTester extends PublicTxSimulationTester { return; } const piBuffer = publicInputs.serializeWithMessagePack(); - const { verified } = await this.bbJsFactory.withFreshInstance(i => i.verifyAvmProof(proof, piBuffer)); + await using instance = await this.bbJsFactory.getInstance(); + const { verified } = await instance.verifyAvmProof(proof, piBuffer); expect(verified).toBe(true); } diff --git a/yarn-project/bb-prover/src/bb/bb_js_backend.ts b/yarn-project/bb-prover/src/bb/bb_js_backend.ts index 8a96c6f381dd..5a66d727112a 100644 --- a/yarn-project/bb-prover/src/bb/bb_js_backend.ts +++ b/yarn-project/bb-prover/src/bb/bb_js_backend.ts @@ -1,6 +1,6 @@ -import { type AvmStat, type BackendOptions, BackendType, Barretenberg, type ChonkProof } from '@aztec/bb.js'; -import { IPA_PROOF_LENGTH } from '@aztec/constants'; +import { type AvmStat, type BackendOptions, BackendType, Barretenberg } from '@aztec/bb.js'; import type { LogFn, Logger } from '@aztec/foundation/log'; +import { FifoMemoryQueue } from '@aztec/foundation/queue'; import { Timer } from '@aztec/foundation/timer'; import type { UltraHonkFlavor } from '../honk.js'; @@ -192,8 +192,9 @@ export class BBJsInstance implements BBJsApi { } /** - * Verify a Chonk (IVC) proof by splitting flat fields into the structured ChonkProof format. - * Mirrors C++ ChonkProof::from_field_elements() logic. + * Verify a Chonk (IVC) proof passed as flat field elements (with public inputs prepended). + * The split into structured sub-proofs happens server-side in `ChonkProof::from_field_elements`, + * so this layer doesn't need to know per-component sub-proof sizes. * @param fieldsWithPublicInputs - Flat proof fields as 32-byte Uint8Arrays (public inputs prepended). * @param verificationKey - The VK bytes. */ @@ -202,8 +203,7 @@ export class BBJsInstance implements BBJsApi { verificationKey: Uint8Array, ): Promise<{ verified: boolean; durationMs: number }> { const timer = new Timer(); - const proof = splitChonkProofToStructured(fieldsWithPublicInputs); - const result = await this.api.chonkVerify({ proof, vk: verificationKey }); + const result = await this.api.chonkVerifyFromFields({ proof: fieldsWithPublicInputs, vk: verificationKey }); return { verified: result.valid, durationMs: timer.ms() }; } @@ -237,42 +237,148 @@ export class BBJsInstance implements BBJsApi { } } +/** Options for {@link BBJsFactory}. */ +export interface BBJsFactoryOptions { + /** + * Number of long-lived bb processes to keep in the pool. + * If omitted, every `getInstance()` call spawns a fresh bb that is destroyed on dispose. + */ + poolSize?: number; + logger?: Logger; + threads?: number; + debugDir?: string; +} + /** - * Factory for managing BBJsInstance lifecycle. - * Provides fresh instances for proving (each spawns a new bb process) - * and can pool instances for verification. + * Manages bb.js instance lifecycle. By default every `getInstance()` call spawns a fresh + * bb process that is destroyed when the borrow is disposed. Pass `poolSize` to keep a fixed + * set of long-lived bb processes that are reused across calls — useful when the per-call + * bb startup cost dominates the workload (e.g. high-rate IVC verification). + * + * Idiomatic usage: + * ``` + * await using inst = await factory.getInstance(); + * await inst.someMethod(...); + * // disposed automatically when `inst` goes out of scope + * ``` */ -export class BBJsProverFactory { +export class BBJsFactory { + private readonly poolSize?: number; + private readonly logger?: Logger; + private readonly threads?: number; + private readonly debugDir?: string; + + /** Available pooled instances when poolSize is set; otherwise undefined. */ + private pool?: FifoMemoryQueue; + /** Lazily-resolved on first `getInstance()` call to prevent racing pool initialization. */ + private initPromise?: Promise; + private destroyed = false; + constructor( private bbPath: string, - private logger?: Logger, - private threads?: number, - private debugDir?: string, - ) {} + options: BBJsFactoryOptions = {}, + ) { + this.poolSize = options.poolSize; + this.logger = options.logger; + this.threads = options.threads; + this.debugDir = options.debugDir; + if (this.poolSize !== undefined && this.poolSize < 1) { + throw new Error(`BBJsFactory poolSize must be >= 1, got ${this.poolSize}`); + } + } /** - * Run an operation with a fresh Barretenberg instance. - * The instance is created before the operation and destroyed after. - * Suitable for proving where process startup is negligible relative to proof time. + * Acquire a bb instance. The returned object implements `BBJsApi` and `AsyncDisposable`. + * With no pool: spawns a fresh bb that is destroyed on dispose. With a pool: borrows from + * the pool and returns to it on dispose. */ - async withFreshInstance(fn: (instance: BBJsApi) => Promise): Promise { - const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; - const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads); - const instance = await this.maybeWrapDebug(raw); - try { - return await fn(instance); - } finally { - await instance.destroy(); + async getInstance(): Promise { + if (this.destroyed) { + throw new Error('BBJsFactory has been destroyed'); } + if (this.poolSize === undefined) { + // No pool: fresh-per-call, dispose destroys. + const instance = await this.createInstance(); + return this.makeOwned(instance); + } + if (!this.initPromise) { + this.initPromise = this.initPool(); + } + await this.initPromise; + const pool = this.pool; + if (!pool) { + throw new Error('BBJsFactory has been destroyed'); + } + const instance = await pool.get(); + if (!instance) { + throw new Error('BBJsFactory was destroyed while waiting for an instance'); + } + return this.makeBorrowed(instance); } /** - * Run a verification operation. - * Currently creates a fresh instance per call (matches current behavior of spawning bb per verification). - * Can be extended to use a pool if needed. + * Tear down all pooled instances. Idempotent. No-op when no pool is configured (fresh-per-call + * instances are destroyed by their own dispose callbacks). Instances currently held by an + * in-flight pooled borrow are destroyed by their dispose callback when released. */ - withVerifierInstance(fn: (instance: BBJsApi) => Promise): Promise { - return this.withFreshInstance(fn); + async destroy(): Promise { + if (this.destroyed) { + return; + } + this.destroyed = true; + const pool = this.pool; + this.pool = undefined; + if (!pool) { + return; + } + const idle: BBJsApi[] = []; + while (pool.length() > 0) { + const item = pool.getImmediate(); + if (item) { + idle.push(item); + } + } + pool.cancel(); + // Aggregate teardown failures so a single bb child that fails to shut down doesn't mask others. + const results = await Promise.allSettled(idle.map(item => item.destroy())); + const errors = results.filter((r): r is PromiseRejectedResult => r.status === 'rejected').map(r => r.reason); + if (errors.length > 0) { + throw new AggregateError(errors, `BBJsFactory.destroy: ${errors.length} bb instance(s) failed to shut down`); + } + } + + private async initPool(): Promise { + // Use allSettled so that if any createInstance() rejects we can destroy the rest instead of + // leaking bb child processes whose creation succeeded. + const results = await Promise.allSettled(Array.from({ length: this.poolSize! }, () => this.createInstance())); + const items: BBJsApi[] = []; + const errors: unknown[] = []; + for (const result of results) { + if (result.status === 'fulfilled') { + items.push(result.value); + } else { + errors.push(result.reason); + } + } + if (errors.length > 0 || this.destroyed) { + // Either creation failed or destroy() raced ahead — clean up everything we successfully spawned. + await Promise.all(items.map(item => item.destroy())); + if (errors.length > 0) { + throw errors[0]; + } + return; + } + const pool = new FifoMemoryQueue(); + for (const item of items) { + pool.put(item); + } + this.pool = pool; + } + + private async createInstance(): Promise { + const logFn = this.logger ? (msg: string) => this.logger!.verbose(`bb.js - ${msg}`) : undefined; + const raw = await BBJsInstance.create(this.bbPath, logFn, this.threads); + return this.maybeWrapDebug(raw); } /** Wrap the instance in a debug wrapper if debugDir is configured. */ @@ -283,38 +389,47 @@ export class BBJsProverFactory { } return instance; } -} - -/** - * Split a flat Chonk proof field array into the structured ChonkProof format expected by bb.js chonkVerify. - * Mirrors C++ ChonkProof::from_field_elements() in barretenberg/cpp/src/barretenberg/chonk/chonk_proof.cpp, - * which derives the hiding_oink_proof size as the remainder after subtracting the 4 fixed-size sub-proofs. - * This makes the split automatically adapt to any number of public inputs prepended to the oink portion. - * - * The 4 fixed sub-proof sizes below must match C++. If the Chonk proof layout changes in C++, expect - * verification to start failing with cryptic verifier errors; update the constants here to match. - */ -function splitChonkProofToStructured(fields: Uint8Array[]): ChonkProof { - // Fixed sub-proof sizes — must match C++ ChonkProof layout. - const MERGE_PROOF_SIZE = 42; // bb::MERGE_PROOF_SIZE - const ECCVM_PROOF_LENGTH = 608; // bb::ECCVMFlavor::PROOF_LENGTH - const JOINT_PROOF_LENGTH = 489; // bb::ChonkProof::JOINT_PROOF_LENGTH - const fixedTailSize = MERGE_PROOF_SIZE + ECCVM_PROOF_LENGTH + IPA_PROOF_LENGTH + JOINT_PROOF_LENGTH; - if (fields.length < fixedTailSize) { - throw new Error( - `splitChonkProofToStructured: proof too short (got ${fields.length} fields, need at least ${fixedTailSize})`, - ); + /** + * Wrap a fresh instance with an `AsyncDisposable` that destroys it on dispose. Used when no + * pool is configured. Destroy errors are propagated so that a teardown failure (e.g. a bb child + * that didn't shut down cleanly) surfaces instead of being silently swallowed. + */ + private makeOwned(instance: BBJsApi): BBJsApi & AsyncDisposable { + return this.makeDisposable(instance, () => instance.destroy()); } - // hiding_oink_proof absorbs the leading portion (public inputs + oink payload); size is derived. - const oinkSize = fields.length - fixedTailSize; - let offset = 0; - const hidingOinkProof = fields.slice(offset, (offset += oinkSize)); - const mergeProof = fields.slice(offset, (offset += MERGE_PROOF_SIZE)); - const eccvmProof = fields.slice(offset, (offset += ECCVM_PROOF_LENGTH)); - const ipaProof = fields.slice(offset, (offset += IPA_PROOF_LENGTH)); - const jointProof = fields.slice(offset, (offset += JOINT_PROOF_LENGTH)); + /** + * Wrap a pooled instance with an `AsyncDisposable` that returns it to the pool (or destroys it + * if the factory was destroyed in the meantime). Destroy errors are propagated. + */ + private makeBorrowed(instance: BBJsApi): BBJsApi & AsyncDisposable { + return this.makeDisposable(instance, async () => { + const pool = this.pool; + if (pool && !this.destroyed) { + pool.put(instance); + } else { + await instance.destroy(); + } + }); + } - return { hidingOinkProof, mergeProof, eccvmProof, ipaProof, jointProof }; + private makeDisposable(instance: BBJsApi, onDispose: () => void | Promise): BBJsApi & AsyncDisposable { + let disposed = false; + const dispose = async (): Promise => { + if (disposed) { + return; + } + disposed = true; + await onDispose(); + }; + return new Proxy(instance as BBJsApi & AsyncDisposable, { + get(target, prop, receiver) { + if (prop === Symbol.asyncDispose) { + return dispose; + } + return Reflect.get(target, prop, receiver); + }, + }); + } } diff --git a/yarn-project/bb-prover/src/config.ts b/yarn-project/bb-prover/src/config.ts index fa8e6c7a3bf1..9a9aa68d62af 100644 --- a/yarn-project/bb-prover/src/config.ts +++ b/yarn-project/bb-prover/src/config.ts @@ -3,7 +3,10 @@ export interface BBConfig { bbWorkingDirectory: string; /** Whether to skip tmp dir cleanup for debugging purposes */ bbSkipCleanup: boolean; - /** Max concurrent verifications for the RPC verifier (QueuedIVCVerifier). */ + /** + * Number of long-lived bb processes pooled by the RPC verifier (BBCircuitVerifier). + * Also caps concurrent verifications via the wrapping QueuedIVCVerifier. + */ numConcurrentIVCVerifiers: number; /** Thread count for the RPC IVC verifier. */ bbIVCConcurrency: number; diff --git a/yarn-project/bb-prover/src/prover/server/bb_prover.ts b/yarn-project/bb-prover/src/prover/server/bb_prover.ts index 1ef34221bb02..16560ef487bf 100644 --- a/yarn-project/bb-prover/src/prover/server/bb_prover.ts +++ b/yarn-project/bb-prover/src/prover/server/bb_prover.ts @@ -90,7 +90,7 @@ import { promises as fs } from 'fs'; import { ungzip } from 'pako'; import * as path from 'path'; -import { type BBJsProofResult, BBJsProverFactory } from '../../bb/bb_js_backend.js'; +import { BBJsFactory, type BBJsProofResult } from '../../bb/bb_js_backend.js'; import type { ACVMConfig, BBConfig } from '../../config.js'; import { getUltraHonkFlavorForCircuit } from '../../honk.js'; import { ProverInstrumentation } from '../../instrumentation.js'; @@ -108,14 +108,14 @@ export interface BBProverConfig extends BBConfig, ACVMConfig { */ export class BBNativeRollupProver implements ServerCircuitProver { private instrumentation: ProverInstrumentation; - private bbJsFactory: BBJsProverFactory; + private bbJsFactory: BBJsFactory; constructor( private config: BBProverConfig, telemetry: TelemetryClient, ) { this.instrumentation = new ProverInstrumentation(telemetry, 'BBNativeRollupProver'); - this.bbJsFactory = new BBJsProverFactory(config.bbBinaryPath, logger, undefined, config.bbDebugOutputDir); + this.bbJsFactory = new BBJsFactory(config.bbBinaryPath, { logger, debugDir: config.bbDebugOutputDir }); } get tracer() { @@ -485,19 +485,18 @@ export class BBNativeRollupProver implements ServerCircuitProver { // Decompress bytecode for bb.js const bytecode = ungzip(Buffer.from(artifact.bytecode, 'base64')); - // Prove the circuit via bb.js API (spawns a fresh bb process per proof) + // Prove the circuit via bb.js API logger.debug(`Proving ${circuitType} via bb.js...`); let proofResult: BBJsProofResult; try { - proofResult = await this.bbJsFactory.withFreshInstance(instance => - instance.generateProof( - circuitType, - bytecode, - this.getVerificationKeyDataForCircuit(circuitType).keyAsBytes, - witness, - getUltraHonkFlavorForCircuit(circuitType), - ), + await using instance = await this.bbJsFactory.getInstance(); + proofResult = await instance.generateProof( + circuitType, + bytecode, + this.getVerificationKeyDataForCircuit(circuitType).keyAsBytes, + witness, + getUltraHonkFlavorForCircuit(circuitType), ); } catch (error) { throw new ProvingError(`Failed to generate proof for ${circuitType}: ${error}`); @@ -515,9 +514,8 @@ export class BBNativeRollupProver implements ServerCircuitProver { logger.info(`Proving avm-circuit for TX ${input.hints.tx.hash}...`); const inputsBuffer = input.serializeWithMessagePack(); - const { proof: proofFieldArrays, durationMs } = await this.bbJsFactory.withFreshInstance(instance => - instance.generateAvmProof(inputsBuffer), - ); + await using instance = await this.bbJsFactory.getInstance(); + const { proof: proofFieldArrays, durationMs } = await instance.generateAvmProof(inputsBuffer); // Convert Uint8Array[] (32-byte field elements) to Fr[] const proofFields = proofFieldArrays.map(f => Fr.fromBuffer(Buffer.from(f))); @@ -639,8 +637,12 @@ export class BBNativeRollupProver implements ServerCircuitProver { let verified: boolean; let durationMs: number; try { - ({ verified, durationMs } = await this.bbJsFactory.withVerifierInstance(instance => - instance.verifyProof(proofFields, verificationKey.keyAsBytes, publicInputFields, flavor), + await using instance = await this.bbJsFactory.getInstance(); + ({ verified, durationMs } = await instance.verifyProof( + proofFields, + verificationKey.keyAsBytes, + publicInputFields, + flavor, )); } catch (error) { throw new ProvingError(`Failed to verify proof for ${circuitType}: ${error}`); @@ -664,9 +666,8 @@ export class BBNativeRollupProver implements ServerCircuitProver { } const piBuffer = publicInputs.serializeWithMessagePack(); - const { verified, durationMs } = await this.bbJsFactory.withVerifierInstance(instance => - instance.verifyAvmProof(proofFields, piBuffer), - ); + await using instance = await this.bbJsFactory.getInstance(); + const { verified, durationMs } = await instance.verifyAvmProof(proofFields, piBuffer); if (!verified) { throw new ProvingError('Failed to verify AVM proof!'); diff --git a/yarn-project/bb-prover/src/verifier/bb_verifier.ts b/yarn-project/bb-prover/src/verifier/bb_verifier.ts index cdeeb997eb0d..2281d54f2759 100644 --- a/yarn-project/bb-prover/src/verifier/bb_verifier.ts +++ b/yarn-project/bb-prover/src/verifier/bb_verifier.ts @@ -15,22 +15,28 @@ import type { VerificationKeyData } from '@aztec/stdlib/vks'; import { promises as fs } from 'fs'; -import { BBJsProverFactory } from '../bb/bb_js_backend.js'; +import { BBJsFactory } from '../bb/bb_js_backend.js'; import type { BBConfig } from '../config.js'; import { getUltraHonkFlavorForCircuit } from '../honk.js'; export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { - private bbJsFactory: BBJsProverFactory; + private bbJsFactory: BBJsFactory; private constructor( private config: BBConfig, private logger: Logger, ) { - this.bbJsFactory = new BBJsProverFactory(config.bbBinaryPath, logger, undefined, config.bbDebugOutputDir); + // BB_NUM_IVC_VERIFIERS bounds the number of long-lived bb processes the pool keeps alive. + // If 0, fall back to spawning a fresh bb per verification. + this.bbJsFactory = new BBJsFactory(config.bbBinaryPath, { + poolSize: config.numConcurrentIVCVerifiers > 0 ? config.numConcurrentIVCVerifiers : undefined, + logger, + debugDir: config.bbDebugOutputDir, + }); } public stop(): Promise { - return Promise.resolve(); + return this.bbJsFactory.destroy(); } public static async new(config: BBConfig, logger = createLogger('bb-prover:verifier')) { @@ -60,8 +66,12 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { const publicInputFields = splitBufferToFieldArrays(proof.buffer.subarray(0, proof.numPublicInputs * 32)); const proofFields = splitBufferToFieldArrays(proof.buffer.subarray(proof.numPublicInputs * 32)); - const { verified, durationMs } = await this.bbJsFactory.withVerifierInstance(instance => - instance.verifyProof(proofFields, verificationKey.keyAsBytes, publicInputFields, flavor), + await using instance = await this.bbJsFactory.getInstance(); + const { verified, durationMs } = await instance.verifyProof( + proofFields, + verificationKey.keyAsBytes, + publicInputFields, + flavor, ); if (!verified) { @@ -89,9 +99,8 @@ export class BBCircuitVerifier implements ClientProtocolCircuitVerifier { const proofWithPubInputs = tx.chonkProof.attachPublicInputs(tx.data.publicInputs().toFields()); const fieldsAsBuffers = proofWithPubInputs.fieldsWithPublicInputs.map(f => new Uint8Array(f.toBuffer())); - const { verified, durationMs } = await this.bbJsFactory.withVerifierInstance(instance => - instance.verifyChonkProof(fieldsAsBuffers, verificationKey.keyAsBytes), - ); + await using instance = await this.bbJsFactory.getInstance(); + const { verified, durationMs } = await instance.verifyChonkProof(fieldsAsBuffers, verificationKey.keyAsBytes); if (!verified) { throw new Error(`Failed to verify ${proofType} proof for ${circuit}!`); diff --git a/yarn-project/ivc-integration/src/prove_native.ts b/yarn-project/ivc-integration/src/prove_native.ts index dbf5f30d8d4f..1f76eea2da23 100644 --- a/yarn-project/ivc-integration/src/prove_native.ts +++ b/yarn-project/ivc-integration/src/prove_native.ts @@ -1,4 +1,4 @@ -import { BBJsProverFactory, type UltraHonkFlavor, constructRecursiveProofFromBuffers } from '@aztec/bb-prover'; +import { BBJsFactory, type UltraHonkFlavor, constructRecursiveProofFromBuffers } from '@aztec/bb-prover'; import { AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED, CHONK_PROOF_LENGTH, @@ -47,42 +47,45 @@ async function proveRollupCircuit - instance.generateProof(name, bytecode, vkBuffer, decompressedWitness, flavor), - ); - - const vk = await VerificationKeyData.fromFrBuffer(vkBuffer); - - // Construct proof from in-memory buffers - const proof = constructRecursiveProofFromBuffers( - proofResult.proofFields, - proofResult.publicInputFields, - vk, - proofLength, - ); - - // Verify the proof via bb.js - const publicInputFields = proofResult.publicInputFields; - const proofFields = proofResult.proofFields; - - const { verified } = await factory.withVerifierInstance(instance => - instance.verifyProof(proofFields, vk.keyAsBytes, publicInputFields, flavor), - ); - - if (!verified) { - throw new Error(`Failed to verify proof from key!`); + const factory = new BBJsFactory(pathToBB, { logger }); + try { + // Decompress witness and bytecode for bb.js + const decompressedWitness = ungzip(witness); + const bytecode = ungzip(Buffer.from(circuit.bytecode, 'base64')); + const vkBuffer = Buffer.from(circuit.verificationKey.bytes, 'hex'); + + // Generate proof via bb.js + await using proveInstance = await factory.getInstance(); + const proofResult = await proveInstance.generateProof(name, bytecode, vkBuffer, decompressedWitness, flavor); + + const vk = await VerificationKeyData.fromFrBuffer(vkBuffer); + + // Construct proof from in-memory buffers + const proof = constructRecursiveProofFromBuffers( + proofResult.proofFields, + proofResult.publicInputFields, + vk, + proofLength, + ); + + // Verify the proof via bb.js + await using verifyInstance = await factory.getInstance(); + const { verified } = await verifyInstance.verifyProof( + proofResult.proofFields, + vk.keyAsBytes, + proofResult.publicInputFields, + flavor, + ); + + if (!verified) { + throw new Error(`Failed to verify proof from key!`); + } + logger.info(`Successfully verified proof from key`); + + return makeProofAndVerificationKey(proof, vk); + } finally { + await factory.destroy(); } - logger.info(`Successfully verified proof from key`); - - return makeProofAndVerificationKey(proof, vk); } export function proveRollupHonk( @@ -135,30 +138,38 @@ export async function proveAvm( publicInputs: AvmCircuitPublicInputs; }> { const bbPath = path.resolve('../../barretenberg/cpp/build/bin/bb-avm'); - const factory = new BBJsProverFactory(bbPath, logger); - - const inputsBuffer = avmCircuitInputs.serializeWithMessagePack(); - const { proof: proofFields } = await factory.withFreshInstance(i => i.generateAvmProof(inputsBuffer)); - - // Convert Uint8Array field elements → Fr[] - const proof: Fr[] = proofFields.map(f => Fr.fromBuffer(Buffer.from(f))); - - // Extend to a fixed-size padded proof — any new AVM circuit column changes the proof length and we - // don't have a mechanism to feed a cpp constant into noir/TS. - // TODO(#13390): Revive a non-padded AVM proof - while (proof.length < AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED) { - proof.push(new Fr(0)); - } - - // Explicit verify pass against the serialized public inputs (matches the legacy binary flow). - const piBuffer = avmCircuitInputs.publicInputs.serializeWithMessagePack(); - const { verified: reVerified } = await factory.withFreshInstance(i => i.verifyAvmProof(proofFields, piBuffer)); - if (!reVerified) { - throw new Error('AVM V2 proof verification failed'); + const factory = new BBJsFactory(bbPath, { logger }); + try { + const inputsBuffer = avmCircuitInputs.serializeWithMessagePack(); + let proofFields: Uint8Array[]; + { + await using instance = await factory.getInstance(); + ({ proof: proofFields } = await instance.generateAvmProof(inputsBuffer)); + } + + // Convert Uint8Array field elements → Fr[] + const proof: Fr[] = proofFields.map(f => Fr.fromBuffer(Buffer.from(f))); + + // Extend to a fixed-size padded proof — any new AVM circuit column changes the proof length and we + // don't have a mechanism to feed a cpp constant into noir/TS. + // TODO(#13390): Revive a non-padded AVM proof + while (proof.length < AVM_V2_PROOF_LENGTH_IN_FIELDS_PADDED) { + proof.push(new Fr(0)); + } + + // Explicit verify pass against the serialized public inputs (matches the legacy binary flow). + const piBuffer = avmCircuitInputs.publicInputs.serializeWithMessagePack(); + await using verifyInstance = await factory.getInstance(); + const { verified: reVerified } = await verifyInstance.verifyAvmProof(proofFields, piBuffer); + if (!reVerified) { + throw new Error('AVM V2 proof verification failed'); + } + + return { + proof, + publicInputs: avmCircuitInputs.publicInputs, + }; + } finally { + await factory.destroy(); } - - return { - proof, - publicInputs: avmCircuitInputs.publicInputs, - }; } From 1db868c20bfa225e783c7e598fdf6d85ee3edf6b Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 09:30:54 -0300 Subject: [PATCH 29/55] fix(sequencer): anchor fee asset price modifier to predicted parent (#23113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation Under proposer pipelining, checkpoint N's fee asset price modifier is computed in slot N-1 before checkpoint N-1 has landed on L1. The proposer was reading `rollupContract.getEthPerFeeAsset()`, which still reflects the latest published checkpoint (commonly N-2), while L1 later applies the modifier against checkpoint N-1's `ethPerFeeAsset`. The mismatch produced a 1-checkpoint drift between the proposer's intended new price and the price L1 actually stored, causing the e2e price-convergence test to oscillate around the target instead of converging. ## Approach Threads the predicted parent's `ethPerFeeAsset` through to the modifier computation. `buildPipelinedParentSimulationOverridesPlan` already derives that fee header for global-variable simulation overrides; the pending fee header on the resulting plan is used as the reference price for the bps calculation. Non-pipelined paths and genesis (checkpoint < 2) fall back to today's `getEthPerFeeAsset()` read. ## Changes - **ethereum**: `FeeAssetPriceOracle.computePriceModifier()` now takes an optional `currentPriceE12`; when supplied, the L1 read is skipped. - **sequencer-client**: `SequencerPublisher.getFeeAssetPriceModifier()` forwards the optional predicted price; `checkpoint_proposal_job` reads it from the pipelined simulation overrides plan and passes it through. - **end-to-end**: enables proposer pipelining + `inboxLag: 2` in `fee_asset_price_oracle_gossip.test.ts`. - **ethereum (tests)**: adds 3 unit tests covering the L1-read fallback, the predicted-price short-circuit, and concrete-value consistency with `RollupContract.computeChildFeeHeader` (asserting modifier truncation leaves a sub-bp gap to target). Note: if pipelining is enabled at checkpoint ≥ 2 but `proposedCheckpointData` is missing, the predicted-parent will be `undefined` and the code silently falls back to the stale L1 read. That's a pre-existing failure-mode behavior, not introduced here. --- .../fee_asset_price_oracle_gossip.test.ts | 2 + .../contracts/fee_asset_price_oracle.test.ts | 70 +++++++++++++++++++ .../src/contracts/fee_asset_price_oracle.ts | 15 ++-- .../src/publisher/sequencer-publisher.ts | 10 ++- .../src/sequencer/checkpoint_proposal_job.ts | 7 +- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts index 04b436680edb..c384797a938c 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/fee_asset_price_oracle_gossip.test.ts @@ -63,6 +63,8 @@ describe('e2e_p2p_network', () => { slashingRoundSizeInEpochs: 2, slashingQuorum: 5, listenAddress: '127.0.0.1', + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts index 7d4f5d78d6b2..4e56319a2a5b 100644 --- a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.test.ts @@ -1,8 +1,14 @@ +import { jest } from '@jest/globals'; +import { type MockProxy, mock } from 'jest-mock-extended'; + +import type { ViemClient } from '../types.js'; import { + FeeAssetPriceOracle, MAX_FEE_ASSET_PRICE_MODIFIER_BPS, sqrtPriceX96ToEthPerFeeAssetE12, validateFeeAssetPriceModifier, } from './fee_asset_price_oracle.js'; +import { RollupContract } from './rollup.js'; describe('Uniswap Price Oracle', () => { describe('sqrtPriceX96ToEthPerFeeAssetE12', () => { @@ -52,6 +58,70 @@ describe('Uniswap Price Oracle', () => { }); }); + describe('computePriceModifier', () => { + let client: MockProxy; + let rollupContract: MockProxy; + let oracle: FeeAssetPriceOracle; + const oraclePriceE12 = 10n ** 7n; + + beforeEach(() => { + client = mock(); + rollupContract = mock(); + oracle = new FeeAssetPriceOracle(client, rollupContract); + + // Inject a stub uniswap oracle so we can drive the oracle price without touching the + // real Uniswap V4 StateView contract. + jest.spyOn(oracle, 'getUniswapOracle').mockResolvedValue({ + getMeanEthPerFeeAssetE12: () => Promise.resolve(oraclePriceE12), + } as never); + }); + + it('reads ethPerFeeAsset from L1 when no predicted price is provided', async () => { + const onChainPriceE12 = 9_950_000n; + rollupContract.getEthPerFeeAsset.mockResolvedValue(onChainPriceE12); + + const modifier = await oracle.computePriceModifier(); + + expect(rollupContract.getEthPerFeeAsset).toHaveBeenCalledTimes(1); + expect(modifier).toBe(oracle.computePriceModifierBps(onChainPriceE12, oraclePriceE12)); + }); + + it('uses the supplied predicted price and skips the L1 read when provided', async () => { + const predictedPriceE12 = 10_050_000n; + const modifier = await oracle.computePriceModifier(predictedPriceE12); + + expect(rollupContract.getEthPerFeeAsset).not.toHaveBeenCalled(); + expect(modifier).toBe(oracle.computePriceModifierBps(predictedPriceE12, oraclePriceE12)); + }); + + it('emits the modifier that drives the predicted parent toward (but not exactly to) the target', async () => { + // Pick a target within the ±100 bps cap so the test exercises truncation rather than the clamp. + // P=10_100_000, T=10_150_000: + // raw bps = floor((T - P) * 10_000 / P) = floor(50_000 * 10_000 / 10_100_000) = 49 + // child = floor(P * (10_000 + 49) / 10_000) = 10_149_490 + // Note 10_149_490 != 10_150_000 — bps truncation leaves the child ~510 LSB (~0.5 bp) shy + // of the target. The e2e test depends on this sub-bp gap: convergence is monotonic and + // within sub-bp of target, not exact equality. + const predictedParentE12 = 10_100_000n; + const targetE12 = 10_150_000n; + jest.spyOn(oracle, 'getUniswapOracle').mockResolvedValue({ + getMeanEthPerFeeAssetE12: () => Promise.resolve(targetE12), + } as never); + + const modifier = await oracle.computePriceModifier(predictedParentE12); + expect(modifier).toBe(49n); + + const child = RollupContract.computeChildFeeHeader( + { excessMana: 0n, manaUsed: 0n, ethPerFeeAsset: predictedParentE12, congestionCost: 0n, proverCost: 0n }, + 0n, + modifier, + 100n, + ); + expect(child.ethPerFeeAsset).toBe(10_149_490n); + expect(child.ethPerFeeAsset).not.toBe(targetE12); + }); + }); + describe('validateFeeAssetPriceModifier', () => { it('accepts 0 modifier', () => { expect(validateFeeAssetPriceModifier(0n)).toBe(true); diff --git a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts index 540d954628d4..6b5b9b51da99 100644 --- a/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts +++ b/yarn-project/ethereum/src/contracts/fee_asset_price_oracle.ts @@ -63,28 +63,33 @@ export class FeeAssetPriceOracle { * * Returns 0 if not on mainnet or if the oracle query fails. * + * @param currentPriceE12 - Optional override for the parent checkpoint's eth-per-fee-asset + * (E12 scale). When omitted, the latest published value is read from L1. Pipelined + * proposers should supply the predicted parent value so the modifier they emit aligns + * with the parent fee header L1 will see when the previous pipelined checkpoint lands. * @returns The price modifier in basis points (positive to increase price, negative to decrease) */ - async computePriceModifier(): Promise { + async computePriceModifier(currentPriceE12?: bigint): Promise { const uniswapOracle = await this.getUniswapOracle(); if (!uniswapOracle) { return 0n; } try { - // Get current on-chain price (ETH per fee asset, E12) - const currentPriceE12 = await this.rollupContract.getEthPerFeeAsset(); + // Get current on-chain price (ETH per fee asset, E12), preferring the caller-supplied value. + const resolvedCurrentPriceE12 = currentPriceE12 ?? (await this.rollupContract.getEthPerFeeAsset()); // Get oracle price (median of last N blocks, ETH per fee asset, E12) const oraclePriceE12 = await uniswapOracle.getMeanEthPerFeeAssetE12(); // Compute modifier in basis points - const modifier = this.computePriceModifierBps(currentPriceE12, oraclePriceE12); + const modifier = this.computePriceModifierBps(resolvedCurrentPriceE12, oraclePriceE12); this.log.debug('Computed price modifier', { - currentPriceE12: currentPriceE12.toString(), + currentPriceE12: resolvedCurrentPriceE12.toString(), oraclePriceE12: oraclePriceE12.toString(), modifierBps: modifier.toString(), + currentPriceFromCaller: currentPriceE12 !== undefined, }); return modifier; diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts index b5f49d000293..47819bcb1221 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.ts @@ -287,10 +287,14 @@ export class SequencerPublisher { /** * Gets the fee asset price modifier from the oracle. - * Returns 0n if the oracle query fails. + * + * @param predictedParentEthPerFeeAssetE12 - Optional predicted parent eth-per-fee-asset (E12). + * Pipelined proposers should pass the value from the predicted parent fee header so the + * modifier matches the parent L1 will use when applying it. + * @returns The fee asset price modifier in basis points, or 0n if the oracle query fails. */ - public getFeeAssetPriceModifier(): Promise { - return this.feeAssetPriceOracle.computePriceModifier(); + public getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12?: bigint): Promise { + return this.feeAssetPriceOracle.computePriceModifier(predictedParentEthPerFeeAssetE12); } public getSenderAddress() { diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 3c5317dca265..c0228cae85c0 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -587,8 +587,11 @@ export class CheckpointProposalJob implements Traceable { .filter(c => c.checkpointNumber < this.checkpointNumber) .map(c => c.checkpointOutHash); - // Get the fee asset price modifier from the oracle - const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(); + // Anchor the modifier to the predicted parent fee header: L1 will apply it against + // that, not against the latest published checkpoint (which lags by one under pipelining). + const predictedParentEthPerFeeAssetE12 = + this.pipelinedParentSimulationOverridesPlan?.pendingCheckpointState?.feeHeader?.ethPerFeeAsset; + const feeAssetPriceModifier = await this.publisher.getFeeAssetPriceModifier(predictedParentEthPerFeeAssetE12); // Create a long-lived forked world state for the checkpoint builder await using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 }); From 7ece28618be33eeb2c9d857a12e523fa739469ed Mon Sep 17 00:00:00 2001 From: spypsy Date: Mon, 11 May 2026 14:22:28 +0100 Subject: [PATCH 30/55] chore: error log when L1 head timestamp drifts (#22947) --- yarn-project/archiver/src/modules/l1_synchronizer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn-project/archiver/src/modules/l1_synchronizer.ts b/yarn-project/archiver/src/modules/l1_synchronizer.ts index 9ef83634d899..fe0f07a812bc 100644 --- a/yarn-project/archiver/src/modules/l1_synchronizer.ts +++ b/yarn-project/archiver/src/modules/l1_synchronizer.ts @@ -152,11 +152,11 @@ export class ArchiverL1Synchronizer implements Traceable { return; } - // Warn if the latest L1 block timestamp is too old + // Log at error if the latest L1 block timestamp is too old const maxAllowedDelay = this.config.maxAllowedEthClientDriftSeconds; const now = this.dateProvider.nowInSeconds(); if (maxAllowedDelay > 0 && Number(currentL1Timestamp) <= now - maxAllowedDelay) { - this.log.warn( + this.log.error( `Latest L1 block ${currentL1BlockNumber} timestamp ${currentL1Timestamp} is too old. Make sure your Ethereum node is synced.`, { currentL1BlockNumber, currentL1Timestamp, now, maxAllowedDelay }, ); From 8447782504e9704f9dde477ce3a91f4c136d7e02 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 11:15:44 -0300 Subject: [PATCH 31/55] fix(sequencer): override full parent checkpoint cell in pipelined simulation (#23073) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation When proposer pipelining is enabled, the sequencer simulates `propose()` for checkpoint K one slot ahead while K-1 has not yet landed on L1. The previous override only patched `tips.pending`, `archives[K-1]`, and (sometimes) the fee header, leaving the rest of `tempCheckpointLogs[K-1]` at storage zero. With `slotNumber` zeroed, `canPruneAtTime` falsely declared the proof window expired, the contract returned `proven` from `getEffectivePendingCheckpointNumber`, and the precheck reverted with `Rollup__InvalidArchive` — surfacing as a `proposer-rollup-check-failed` storm whenever a checkpoint took an extra L1 block to land. Additionally, fixes a bug in L2-to-L1 messages related to how the `outHash` is computed by the proposer (see "include parent checkpointOutHash when pipelining same-epoch builds"). Also adds sanity checks to `checkSync` to guard against race conditions when querying archiver data. ## Approach The simulated `tempCheckpointLogs[K-1]` cell is now byte-faithful with what L1 will see once K-1 actually lands: header hash, out hash, payload digest, slot number, and fee header. `blobCommitmentsHash` and `attestationsHash` are intentionally left out — the propose path never asserts on them. The override is built through a single per-cell helper that throws on `slotNumber > uint32`, mirroring the on-chain `SafeCast.toUint32`. ## Changes - **stdlib (`checkpoint/digest.ts`)**: new shared `computeCheckpointPayloadDigest` helper. Archiver migrated to it. - **ethereum (`rollup.ts` / `chain_state_override.ts`)**: replaces `makeFeeHeaderOverride` with `makeTempCheckpointLogOverride` (all-required) and `makeTempCheckpointLogPartialOverride` (subset). Extends `PendingCheckpointOverrideState` and `SimulationOverridesBuilder` with `withPendingHeaderHash/OutHash/PayloadDigest/SlotNumber`. Plan translation now goes through the partial helper so a missing fee header no longer suppresses the rest. - **sequencer-client**: `buildPipelinedParentSimulationOverridesPlan` takes a `signatureContext`, populates the new fields when `proposedCheckpointData` matches the parent, and guards against stale entries. The inline override in `Sequencer` is consolidated through the helper, with a defensive archive fallback when `proposedCheckpointData` is absent. `CheckpointProposalJob` threads the signature context through. - **end-to-end (`epochs_mbps.parallel.test`)**: switches the test to the pipelined-MBPS timing (12s L1 / 72s L2 / 5500ms blocks, `enableProposerPipelining: true`, `perBlockAllocationMultiplier: 8`) and asserts there are no `proposer-rollup-check-failed` events under normal operation. - **.test_patterns.yml**: marks the L2-to-L1-messages variant of the test as `skip: true` for an unrelated `Tx dropped by P2P node` flake under the new pipelined timing — tracked as a follow-up. - **tests**: new unit tests for `makeTempCheckpointLogOverride` (storage-slot round-trip via `getCheckpoint`, slot-overflow throw, partial-emission), `withPending*` builders, and the populated/empty/stale-checkpoint paths in `buildPipelinedParentSimulationOverridesPlan`. --- .../archiver/src/l1/calldata_retriever.ts | 9 +- .../e2e_epochs/epochs_mbps.parallel.test.ts | 42 ++-- .../contracts/chain_state_override.test.ts | 27 ++- .../src/contracts/chain_state_override.ts | 39 +++- .../ethereum/src/contracts/rollup.test.ts | 105 +++++++++- yarn-project/ethereum/src/contracts/rollup.ts | 100 ++++++--- .../src/sequencer/chain_state_overrides.ts | 19 ++ .../sequencer/checkpoint_proposal_job.test.ts | 193 +++++++++++++++++- .../src/sequencer/checkpoint_proposal_job.ts | 49 ++++- .../src/sequencer/sequencer.test.ts | 21 +- .../src/sequencer/sequencer.ts | 59 +++++- yarn-project/stdlib/src/checkpoint/digest.ts | 28 +++ yarn-project/stdlib/src/checkpoint/index.ts | 1 + 13 files changed, 618 insertions(+), 74 deletions(-) create mode 100644 yarn-project/stdlib/src/checkpoint/digest.ts diff --git a/yarn-project/archiver/src/l1/calldata_retriever.ts b/yarn-project/archiver/src/l1/calldata_retriever.ts index 22acd1df7c02..0c2f79f74a7f 100644 --- a/yarn-project/archiver/src/l1/calldata_retriever.ts +++ b/yarn-project/archiver/src/l1/calldata_retriever.ts @@ -7,7 +7,7 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import type { Logger } from '@aztec/foundation/log'; import { RollupAbi } from '@aztec/l1-artifacts'; import { CommitteeAttestation } from '@aztec/stdlib/block'; -import { ConsensusPayload, getHashedSignaturePayloadTypedData } from '@aztec/stdlib/p2p'; +import { computeCheckpointPayloadDigest } from '@aztec/stdlib/checkpoint'; import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { @@ -473,13 +473,12 @@ export class CalldataRetriever { /** Computes the keccak256 payload digest from the checkpoint header, archive root, and fee asset price modifier. */ private computePayloadDigest(header: CheckpointHeader, archiveRoot: Fr, feeAssetPriceModifier: bigint): Hex { - const consensusPayload = new ConsensusPayload( + return computeCheckpointPayloadDigest({ header, archiveRoot, feeAssetPriceModifier, - this.getSignatureContext(), - ); - return getHashedSignaturePayloadTypedData(consensusPayload).toString(); + signatureContext: this.getSignatureContext(), + }).toString(); } /** diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts index 75d9f1584bc5..a59b47b78071 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_mbps.parallel.test.ts @@ -30,7 +30,7 @@ import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js'; import { type EndToEndContext, getPrivateKeyFromIndex } from '../fixtures/utils.js'; import { TestWallet } from '../test-wallet/test_wallet.js'; import { proveInteraction } from '../test-wallet/utils.js'; -import { EpochsTestContext } from './epochs_test.js'; +import { EpochsTestContext, type TrackedSequencerEvent } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 20); @@ -61,6 +61,7 @@ describe('e2e_epochs/epochs_mbps', () => { let crossChainContract: TestContract | undefined; let wallet: TestWallet; let from: AztecAddress; + let failEvents: TrackedSequencerEvent[]; /** * Creates validators and sets up the test context with MBPS configuration. @@ -81,30 +82,28 @@ describe('e2e_epochs/epochs_mbps', () => { }); // Setup context with the given set of validators and MBPS configuration. - // Timing calculation for 3 blocks per checkpoint with 8s sub-slots: - // - initializationOffset ≈ 0.5s (test mode with ethereumSlotDuration < 8) - // - 3 blocks × 8s = 24s - // - checkpointFinalization = 0.5s (assemble) + 0 (p2p in test) + 2s (L1 publish) = 2.5s - // - finalBlockDuration = 8s - // - Total: 0.5 + 24 + 8 + 2.5 = 35s → use 36s for margin + // Pipelining is enabled, so we adopt the wider timing used by the dedicated + // epochs_mbps.pipeline.parallel test (72s L2 slots, 12s L1 slots, 5500ms blocks). + // The tighter 36s/4s timing produces CheckpointNumberNotSequentialError on non-proposer + // nodes when the pipelined proposer races ahead of L1 confirmation (see A-914). test = await EpochsTestContext.setup({ numberOfAccounts: 0, initialValidators: validators, + enableProposerPipelining: true, mockGossipSubNetwork: true, disableAnvilTestWatcher: true, startProverNode: true, + // Mirrors the pipeline-MBPS sibling: more blocks per slot needs a larger per-block gas + // allocation multiplier so each block can fit non-trivial txs. + perBlockAllocationMultiplier: 8, aztecEpochDuration: 4, enforceTimeTable: true, - // L1 slot duration - using < 8 to enable test mode optimizations - ethereumSlotDuration: 4, - // L2 slot duration - should fit 3 blocks (8s each) + overhead - aztecSlotDuration: 36, - // Block duration of 8s as specified - blockDurationMs: 8000, - // L1 publishing time - l1PublishingTime: 2, - // Reduce attestation propagation time for tests - attestationPropagationTime: 0.5, + // L1 slot duration - mirrors the pipeline-MBPS test for headroom on the parent's L1 tx + ethereumSlotDuration: 12, + // L2 slot duration - should fit several blocks (5.5s each) with pipelining overhead + aztecSlotDuration: 72, + // Block duration of 5.5s, matches the pipeline sibling + blockDurationMs: 5500, // Committee size of 3 aztecTargetCommitteeSize: 3, // Additional options (minTxsPerBlock, maxTxsPerBlock, etc.) @@ -125,6 +124,10 @@ describe('e2e_epochs/epochs_mbps', () => { test.createValidatorNode([privateKey], { dontStartSequencer: true }), ); logger.warn(`Started ${NODE_COUNT} validator nodes.`, { validators: validators.map(v => v.attester.toString()) }); + ({ failEvents } = test.watchSequencerEvents( + nodes.map(n => n.getSequencer()!), + i => ({ validator: validators[i].attester }), + )); // Point the wallet at a validator node. The initial node-0 has all validator keys in its config, // so it rejects block proposals from validators thinking they come from itself. By redirecting @@ -185,6 +188,11 @@ describe('e2e_epochs/epochs_mbps', () => { /** Waits until a specific multi-block checkpoint is proven, verifying that proving succeeds with MBPS blocks. */ async function waitForProvenCheckpoint(targetCheckpoint: CheckpointNumber) { + test.assertNoFailuresFromSequencers(failEvents); + + logger.warn(`Stopping validator sequencers before waiting for checkpoint ${targetCheckpoint} to be proven`); + await Promise.all(nodes.map(n => n.getSequencer()?.stop())); + const provenTimeout = test.L2_SLOT_DURATION_IN_S * test.epochDuration * 4; logger.warn(`Waiting for checkpoint ${targetCheckpoint} to be proven (timeout=${provenTimeout}s)`); await test.waitUntilProvenCheckpointNumber(targetCheckpoint, provenTimeout); diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.test.ts b/yarn-project/ethereum/src/contracts/chain_state_override.test.ts index 86e9ec510624..77d3b79f2459 100644 --- a/yarn-project/ethereum/src/contracts/chain_state_override.test.ts +++ b/yarn-project/ethereum/src/contracts/chain_state_override.test.ts @@ -1,4 +1,5 @@ -import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { SimulationOverridesBuilder } from './chain_state_override.js'; @@ -64,4 +65,28 @@ describe('SimulationOverridesBuilder', () => { const plan = builder.build(); expect(plan?.chainTipsOverride).toEqual({ pending: CheckpointNumber(7), proven: CheckpointNumber(3) }); }); + + it('attaches temp checkpoint log fields under the configured pending checkpoint', () => { + const headerHash = Fr.random(); + const outHash = Fr.random(); + const payloadDigest = Buffer32.random(); + const slotNumber = SlotNumber(42); + const plan = new SimulationOverridesBuilder() + .withChainTips({ pending: CheckpointNumber(7) }) + .withPendingTempCheckpointLogFields({ headerHash, outHash, payloadDigest, slotNumber }) + .build(); + + expect(plan?.pendingCheckpointState).toEqual({ headerHash, outHash, payloadDigest, slotNumber }); + }); + + it('throws when withPendingTempCheckpointLogFields is called without a pending chain-tip override', () => { + expect(() => + new SimulationOverridesBuilder().withPendingTempCheckpointLogFields({ + headerHash: Fr.random(), + outHash: Fr.random(), + payloadDigest: Buffer32.random(), + slotNumber: SlotNumber(42), + }), + ).toThrow(/withChainTips\(\{ pending \}\) must be called/); + }); }); diff --git a/yarn-project/ethereum/src/contracts/chain_state_override.ts b/yarn-project/ethereum/src/contracts/chain_state_override.ts index f4771883db57..6358f0cde0e0 100644 --- a/yarn-project/ethereum/src/contracts/chain_state_override.ts +++ b/yarn-project/ethereum/src/contracts/chain_state_override.ts @@ -1,14 +1,25 @@ import { toHex as toPaddedHex } from '@aztec/foundation/bigint-buffer'; -import type { CheckpointNumber } from '@aztec/foundation/branded-types'; +import type { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import type { Buffer32 } from '@aztec/foundation/buffer'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { StateOverride } from 'viem'; import { type FeeHeader, RollupContract } from './rollup.js'; +/** + * Override values for the pending checkpoint that the simulation should treat as already applied. + * Every field is optional at plan-building time so callers can populate them incrementally; whatever + * is present at translation time is forwarded to the partial `tempCheckpointLogs` helper so the + * load-bearing `slotNumber` can land even if other fields could not be derived locally. + */ export type PendingCheckpointOverrideState = { archive?: Fr; feeHeader?: FeeHeader; + headerHash?: Fr; + outHash?: Fr; + payloadDigest?: Buffer32; + slotNumber?: SlotNumber; }; export type ChainTipsOverride = { @@ -75,6 +86,22 @@ export class SimulationOverridesBuilder { return this; } + /** + * Overrides the locally-derivable `tempCheckpointLogs` cell fields for the configured pending + * checkpoint. Callers populate these together because they all come from the same proposed + * checkpoint payload — there is no use case for setting them independently. + */ + public withPendingTempCheckpointLogFields(fields: { + headerHash: Fr; + outHash: Fr; + payloadDigest: Buffer32; + slotNumber: SlotNumber; + }): this { + this.assertPendingCheckpointNumber(); + this.pendingCheckpointState = { ...(this.pendingCheckpointState ?? {}), ...fields }; + return this; + } + /** Disables blob checking for simulations that cannot provide DA inputs. */ public withoutBlobCheck(): this { this.disableBlobCheck = true; @@ -128,10 +155,16 @@ export async function buildSimulationOverridesStateOverride( ); } - if (plan.pendingCheckpointState?.feeHeader) { + if (plan.pendingCheckpointState) { rollupStateDiff.push( ...extractRollupStateDiff( - await rollup.makeFeeHeaderOverride(plan.chainTipsOverride!.pending!, plan.pendingCheckpointState.feeHeader), + await rollup.makeTempCheckpointLogOverride(plan.chainTipsOverride!.pending!, { + headerHash: plan.pendingCheckpointState.headerHash, + outHash: plan.pendingCheckpointState.outHash, + payloadDigest: plan.pendingCheckpointState.payloadDigest, + slotNumber: plan.pendingCheckpointState.slotNumber, + feeHeader: plan.pendingCheckpointState.feeHeader, + }), ), ); } diff --git a/yarn-project/ethereum/src/contracts/rollup.test.ts b/yarn-project/ethereum/src/contracts/rollup.test.ts index e3518004cfef..3c258eefc7ae 100644 --- a/yarn-project/ethereum/src/contracts/rollup.test.ts +++ b/yarn-project/ethereum/src/contracts/rollup.test.ts @@ -1,5 +1,6 @@ import { getPublicClient } from '@aztec/ethereum/client'; -import { CheckpointNumber } from '@aztec/foundation/branded-types'; +import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { createLogger } from '@aztec/foundation/log'; @@ -392,6 +393,108 @@ describe('Rollup', () => { }); }); + describe('makeTempCheckpointLogOverride', () => { + const fields = { + headerHash: Fr.random(), + outHash: Fr.random(), + payloadDigest: Buffer32.random(), + slotNumber: SlotNumber(42), + feeHeader: { + manaUsed: 12345n, + excessMana: 67890n, + ethPerFeeAsset: 1_000_000_000_000n, + congestionCost: 99999n, + proverCost: 55555n, + } as FeeHeader, + }; + + function getDiffMap( + checkpointNumber: CheckpointNumber, + override: Awaited>, + ) { + const map = new Map(); + for (const entry of override) { + for (const diff of entry.stateDiff ?? []) { + map.set(diff.slot.toLowerCase(), diff.value.toLowerCase()); + } + } + const slotFor = async (field: TempCheckpointLogField) => + `0x${(await rollup.getTempCheckpointLogStorageSlot(checkpointNumber, field)).toString(16).padStart(64, '0')}`.toLowerCase(); + return { map, slotFor }; + } + + it('emits one diff entry per required field at the expected storage slot', async () => { + const checkpointNumber = CheckpointNumber(7); + const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, fields); + const { map, slotFor } = getDiffMap(checkpointNumber, override); + + expect(override).toHaveLength(1); + expect(override[0].stateDiff).toHaveLength(5); + expect(map.get(await slotFor(TempCheckpointLogField.HeaderHash))).toBe( + fields.headerHash.toString().toLowerCase(), + ); + expect(map.get(await slotFor(TempCheckpointLogField.OutHash))).toBe(fields.outHash.toString().toLowerCase()); + expect(map.get(await slotFor(TempCheckpointLogField.PayloadDigest))).toBe( + fields.payloadDigest.toString().toLowerCase(), + ); + expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe( + `0x${BigInt(fields.slotNumber).toString(16).padStart(64, '0')}`.toLowerCase(), + ); + expect(map.get(await slotFor(TempCheckpointLogField.FeeHeader))).toBe( + `0x${RollupContract.compressFeeHeader(fields.feeHeader).toString(16).padStart(64, '0')}`.toLowerCase(), + ); + }); + + it('throws when slotNumber overflows uint32 (matches L1 SafeCast.toUint32 semantics)', async () => { + const checkpointNumber = CheckpointNumber(3); + const slotNumber = SlotNumber(0xdeadbeef + 0x1_0000_0000); + await expect(rollup.makeTempCheckpointLogOverride(checkpointNumber, { ...fields, slotNumber })).rejects.toThrow( + /does not fit in uint32/, + ); + }); + + it('partial override emits only the supplied fields', async () => { + const checkpointNumber = CheckpointNumber(13); + const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, { + slotNumber: SlotNumber(7), + }); + const { map, slotFor } = getDiffMap(checkpointNumber, override); + expect(override[0].stateDiff).toHaveLength(1); + expect(map.get(await slotFor(TempCheckpointLogField.SlotNumber))).toBe( + `0x${7n.toString(16).padStart(64, '0')}`.toLowerCase(), + ); + }); + + it('partial override returns an empty array when no fields are supplied', async () => { + const override = await rollup.makeTempCheckpointLogOverride(CheckpointNumber(13), {}); + expect(override).toEqual([]); + }); + + it('round-trips slot, header hash, and fee header through getCheckpoint', async () => { + // Reset tips so checkpoint 0 is in range, then build an override and read it back through the contract. + await cheatCodes.store( + EthAddress.fromString(rollupAddress), + RollupContract.chainTipsStorageSlot, + RollupContract.packChainTips(0n, 0n), + ); + + const checkpointNumber = CheckpointNumber(0); + const override = await rollup.makeTempCheckpointLogOverride(checkpointNumber, fields); + + const { result } = await publicClient.simulateContract({ + address: rollupAddress, + abi: RollupAbi as Abi, + functionName: 'getCheckpoint', + args: [BigInt(checkpointNumber)], + stateOverride: override, + }); + const checkpoint = result as { headerHash: `0x${string}`; outHash: `0x${string}`; slotNumber: bigint }; + expect(checkpoint.headerHash.toLowerCase()).toBe(fields.headerHash.toString().toLowerCase()); + expect(checkpoint.outHash.toLowerCase()).toBe(fields.outHash.toString().toLowerCase()); + expect(checkpoint.slotNumber).toBe(BigInt(fields.slotNumber)); + }); + }); + describe('getSlashingProposer', () => { it('returns a slashing proposer', async () => { const slashingProposer = await rollup.getSlashingProposer(); diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 366a2a049d46..7ed52771746b 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -140,6 +140,20 @@ export enum TempCheckpointLogField { FeeHeader = 6, } +/** + * Field-level override input for `tempCheckpointLogs[checkpointNumber]`. Covers the fields the + * `propose()` path actually reads back. `payloadDigest` is `Buffer32` because it carries an + * arbitrary `bytes32` value rather than a BN254 scalar. `slotNumber` carries the uint32 portion + * of the on-chain `CompressedSlot`. + */ +export type TempCheckpointLogOverrideFields = { + headerHash?: Fr; + outHash?: Fr; + payloadDigest?: Buffer32; + slotNumber?: SlotNumber; + feeHeader?: FeeHeader; +}; + /** Components of the minimum fee per mana, as returned by the L1 rollup contract. */ export type ManaMinFeeComponents = { sequencerCost: bigint; @@ -879,39 +893,56 @@ export class RollupContract { } /** - * Returns a state override that sets tempCheckpointLogs[checkpointNumber].feeHeader to the compressed fee header. - * Used when simulating a propose call where the parent checkpoint hasn't landed on L1 yet (pipelining). + * Returns a state override that patches `tempCheckpointLogs[checkpointNumber]` with every field that + * the L1 contract reads during `propose()` for the checkpoint that builds on top of it (proposer + * pipelining simulation). Mirrors the writes done by `ProposeLib.addTempCheckpointLog`. + * + * `blobCommitmentsHash` and `attestationsHash` are intentionally not exposed here — the propose path + * never asserts against them, so leaving them at storage zero is harmless. */ - public async makeFeeHeaderOverride(checkpointNumber: CheckpointNumber, feeHeader: FeeHeader): Promise { - const { epochDuration, proofSubmissionEpochs } = await this.getRollupConstants(); - const roundaboutSize = BigInt(epochDuration * (proofSubmissionEpochs + 1) + 1); - const circularIndex = BigInt(checkpointNumber) % roundaboutSize; - - // tempCheckpointLogs is at offset 2 in RollupStore - const tempCheckpointLogsMappingBase = hexToBigInt(RollupContract.stfStorageSlot) + 2n; + public async makeTempCheckpointLogOverride( + checkpointNumber: CheckpointNumber, + fields: TempCheckpointLogOverrideFields, + ): Promise { + const constants = await this.getRollupConstants(); + const slotAt = (field: TempCheckpointLogField) => + `0x${this.computeTempCheckpointLogStorageSlot(checkpointNumber, field, constants).toString(16).padStart(64, '0')}` as const; + const word = (v: bigint) => `0x${v.toString(16).padStart(64, '0')}` as const; - // Solidity mapping slot: keccak256(abi.encode(key, baseSlot)) - const structBaseSlot = hexToBigInt( - keccak256( - encodeAbiParameters([{ type: 'uint256' }, { type: 'uint256' }], [circularIndex, tempCheckpointLogsMappingBase]), - ), - ); + const stateDiff: { slot: `0x${string}`; value: `0x${string}` }[] = []; - // feeHeader is the 7th field (offset 6) in CompressedTempCheckpointLog - const feeHeaderSlot = structBaseSlot + 6n; - const compressed = RollupContract.compressFeeHeader(feeHeader); + if (fields.headerHash) { + stateDiff.push({ slot: slotAt(TempCheckpointLogField.HeaderHash), value: fields.headerHash.toString() }); + } + if (fields.outHash) { + stateDiff.push({ slot: slotAt(TempCheckpointLogField.OutHash), value: fields.outHash.toString() }); + } + if (fields.payloadDigest) { + stateDiff.push({ + slot: slotAt(TempCheckpointLogField.PayloadDigest), + value: fields.payloadDigest.toString() as `0x${string}`, + }); + } + if (fields.slotNumber !== undefined) { + // CompressedSlot is uint32 on L1 (SafeCast.toUint32 reverts on overflow). Match that behavior here + // so a malformed override surfaces immediately rather than silently truncating into a wrong slot. + const slotNumber = BigInt(fields.slotNumber); + if (slotNumber < 0n || slotNumber > 0xffffffffn) { + throw new Error(`slotNumber ${slotNumber} does not fit in uint32`); + } + stateDiff.push({ slot: slotAt(TempCheckpointLogField.SlotNumber), value: word(slotNumber) }); + } + if (fields.feeHeader) { + stateDiff.push({ + slot: slotAt(TempCheckpointLogField.FeeHeader), + value: word(RollupContract.compressFeeHeader(fields.feeHeader)), + }); + } - return [ - { - address: this.address, - stateDiff: [ - { - slot: `0x${feeHeaderSlot.toString(16).padStart(64, '0')}`, - value: `0x${compressed.toString(16).padStart(64, '0')}`, - }, - ], - }, - ]; + if (stateDiff.length === 0) { + return []; + } + return [{ address: this.address, stateDiff }]; } /** @@ -1342,11 +1373,20 @@ export class RollupContract { checkpointNumber: CheckpointNumber, field: TempCheckpointLogField, ): Promise { - const fieldOffset = BigInt(field); const [epochDuration, proofSubmissionEpochs] = await Promise.all([ this.getEpochDuration(), this.getProofSubmissionEpochs(), ]); + return this.computeTempCheckpointLogStorageSlot(checkpointNumber, field, { epochDuration, proofSubmissionEpochs }); + } + + private computeTempCheckpointLogStorageSlot( + checkpointNumber: CheckpointNumber, + field: TempCheckpointLogField, + constants: { epochDuration: number; proofSubmissionEpochs: number }, + ): bigint { + const fieldOffset = BigInt(field); + const { epochDuration, proofSubmissionEpochs } = constants; const roundaboutSize = BigInt(epochDuration) * (BigInt(proofSubmissionEpochs) + 1n) + 1n; const tempCheckpointLogsBase = BigInt(RollupContract.stfStorageSlot) + 2n; const circularIndex = BigInt(checkpointNumber) % roundaboutSize; diff --git a/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts b/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts index eddc44a04dcc..412f1562d461 100644 --- a/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts +++ b/yarn-project/sequencer-client/src/sequencer/chain_state_overrides.ts @@ -2,12 +2,15 @@ import { RollupContract, SimulationOverridesBuilder, type SimulationOverridesPla import { CheckpointNumber } from '@aztec/foundation/branded-types'; import type { Fr } from '@aztec/foundation/curves/bn254'; import type { Logger } from '@aztec/foundation/log'; +import { computeCheckpointPayloadDigest } from '@aztec/stdlib/checkpoint'; import type { ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; +import type { CoordinationSignatureContext } from '@aztec/stdlib/p2p'; type PipelinedParentSimulationOverridesPlanInput = { checkpointNumber: CheckpointNumber; proposedCheckpointData?: ProposedCheckpointData; rollup: RollupContract; + signatureContext: CoordinationSignatureContext; log: Logger; /** * Whether proposer pipelining is enabled. Controls only the parent pending/fee-header @@ -41,6 +44,22 @@ export async function buildPipelinedParentSimulationOverridesPlan( if (input.pipeliningEnabled) { const parentCheckpointNumber = CheckpointNumber(input.checkpointNumber - 1); builder.withChainTips({ pending: parentCheckpointNumber }); + + if (input.proposedCheckpointData) { + const { header, archive, checkpointOutHash, feeAssetPriceModifier } = input.proposedCheckpointData; + builder.withPendingArchive(archive.root).withPendingTempCheckpointLogFields({ + headerHash: header.hash(), + outHash: checkpointOutHash, + slotNumber: header.slotNumber, + payloadDigest: computeCheckpointPayloadDigest({ + header, + archiveRoot: archive.root, + feeAssetPriceModifier, + signatureContext: input.signatureContext, + }), + }); + } + const pendingFeeHeader = await computePipelinedParentFeeHeader(input); if (pendingFeeHeader) { builder.withPendingFeeHeader(pendingFeeHeader); diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts index 390d48600f1b..83345c8eef76 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.test.ts @@ -180,6 +180,7 @@ describe('CheckpointProposalJob', () => { epoch: EpochNumber(1), isEscapeHatchOpen: false, }); + epochCache.getL1Constants.mockImplementation(() => l1Constants); publisher = mockDeep(); publisher.epochCache = epochCache; @@ -554,6 +555,134 @@ describe('CheckpointProposalJob', () => { // Verify getCheckpointsData was called with targetEpoch (1), not the wall-clock epoch (0) expect(l2BlockSource.getCheckpointsData).toHaveBeenCalledWith({ epoch: targetEpoch }); }); + + it('splices the parent checkpointOutHash from proposedCheckpointData when pipelining and parent not yet on L1', async () => { + // Build checkpoint 2, where the parent (checkpoint 1) is in the same epoch but not yet checkpointed on L1. + epochCache.isProposerPipeliningEnabled.mockReturnValue(true); + checkpointNumber = CheckpointNumber(2); + + // L1 archiver knows nothing yet — checkpoint 1's L1 tx is still in flight. + l2BlockSource.getCheckpointsData.mockResolvedValue([]); + + const parentCheckpointOutHash = Fr.random(); + const parentHeader = CheckpointHeader.empty(); + parentHeader.slotNumber = SlotNumber(newSlotNumber); // same epoch as targetEpoch (epoch 0) + const proposedCheckpointData: ProposedCheckpointData = { + checkpointNumber: CheckpointNumber(1), + header: parentHeader, + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: parentCheckpointOutHash, + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 5000n, + feeAssetPriceModifier: 100n, + }; + + job = createCheckpointProposalJob({ + targetSlot: SlotNumber(newSlotNumber + 1), + proposedCheckpointData, + }); + job.setTimetable( + new SequencerTimetable({ + ethereumSlotDuration, + aztecSlotDuration: slotDuration, + l1PublishingTime: ethereumSlotDuration, + enforce: config.enforceTimeTable, + }), + ); + + const { txs, block } = await setupTxsAndBlock(p2p, globalVariables, 1, chainId); + checkpointBuilder.seedBlocks([block], [txs]); + validatorClient.collectAttestations.mockResolvedValue(getAttestations(block)); + + await job.executeAndAwait(); + + expect(checkpointsBuilder.startCheckpointCalls).toHaveLength(1); + const call = checkpointsBuilder.startCheckpointCalls[0]; + expect(call.previousCheckpointOutHashes).toEqual([parentCheckpointOutHash]); + }); + + it('does not splice the parent outHash when pipelining is disabled', async () => { + epochCache.isProposerPipeliningEnabled.mockReturnValue(false); + checkpointNumber = CheckpointNumber(2); + + l2BlockSource.getCheckpointsData.mockResolvedValue([]); + + const proposedCheckpointData: ProposedCheckpointData = { + checkpointNumber: CheckpointNumber(1), + header: CheckpointHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.random(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 5000n, + feeAssetPriceModifier: 100n, + }; + + job = createCheckpointProposalJob({ proposedCheckpointData }); + job.setTimetable( + new SequencerTimetable({ + ethereumSlotDuration, + aztecSlotDuration: slotDuration, + l1PublishingTime: ethereumSlotDuration, + enforce: config.enforceTimeTable, + }), + ); + + const { txs, block } = await setupTxsAndBlock(p2p, globalVariables, 1, chainId); + checkpointBuilder.seedBlocks([block], [txs]); + validatorClient.collectAttestations.mockResolvedValue(getAttestations(block)); + + await job.executeAndAwait(); + + expect(checkpointsBuilder.startCheckpointCalls).toHaveLength(1); + expect(checkpointsBuilder.startCheckpointCalls[0].previousCheckpointOutHashes).toEqual([]); + }); + + it('does not splice the parent outHash when the parent is in a different epoch', async () => { + // Parent checkpoint sits at the last slot of the previous epoch; we are building the first + // checkpoint of the new epoch, so the parent's outHash must NOT contribute to our epochOutHash. + epochCache.isProposerPipeliningEnabled.mockReturnValue(true); + const targetEpoch = EpochNumber(1); + const targetSlot = SlotNumber(l1Constants.epochDuration); + const slotNow = SlotNumber(l1Constants.epochDuration - 1); + + checkpointNumber = CheckpointNumber(2); + + l2BlockSource.getCheckpointsData.mockResolvedValue([]); + + const parentHeader = CheckpointHeader.empty(); + parentHeader.slotNumber = slotNow; // last slot of previous epoch + const proposedCheckpointData: ProposedCheckpointData = { + checkpointNumber: CheckpointNumber(1), + header: parentHeader, + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.random(), + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 5000n, + feeAssetPriceModifier: 100n, + }; + + job = createCheckpointProposalJob({ slotNow, targetSlot, targetEpoch, proposedCheckpointData }); + job.setTimetable( + new SequencerTimetable({ + ethereumSlotDuration, + aztecSlotDuration: slotDuration, + l1PublishingTime: ethereumSlotDuration, + enforce: config.enforceTimeTable, + }), + ); + + const { txs, block } = await setupTxsAndBlock(p2p, globalVariables, 1, chainId); + checkpointBuilder.seedBlocks([block], [txs]); + validatorClient.collectAttestations.mockResolvedValue(getAttestations(block)); + + await job.execute(); + + expect(checkpointsBuilder.startCheckpointCalls).toHaveLength(1); + expect(checkpointsBuilder.startCheckpointCalls[0].previousCheckpointOutHashes).toEqual([]); + }); }); /** @@ -677,11 +806,11 @@ describe('CheckpointProposalJob', () => { const pipelinedCheckpointNumber = CheckpointNumber(3); const pendingData: ProposedCheckpointData = { - checkpointNumber: CheckpointNumber(1), + checkpointNumber: CheckpointNumber(2), header: CheckpointHeader.empty(), archive: AppendOnlyTreeSnapshot.empty(), checkpointOutHash: Fr.ZERO, - startBlock: BlockNumber(1), + startBlock: BlockNumber(2), blockCount: 1, totalManaUsed: 5000n, feeAssetPriceModifier: 100n, @@ -779,6 +908,7 @@ describe('CheckpointProposalJob', () => { checkpointNumber: checkpointNumberUnderTest, proposedCheckpointData: undefined, rollup: publisher.rollupContract, + signatureContext, log: createLogger('test'), pipeliningEnabled: true, }); @@ -791,6 +921,7 @@ describe('CheckpointProposalJob', () => { checkpointNumber: checkpointNumberUnderTest, proposedCheckpointData: undefined, rollup: publisher.rollupContract, + signatureContext, log: createLogger('test'), pipeliningEnabled: false, }); @@ -802,6 +933,7 @@ describe('CheckpointProposalJob', () => { checkpointNumber: checkpointNumberUnderTest, proposedCheckpointData: undefined, rollup: publisher.rollupContract, + signatureContext, log: createLogger('test'), pipeliningEnabled: false, prunePending: { provenOverride: CheckpointNumber(0) }, @@ -815,6 +947,7 @@ describe('CheckpointProposalJob', () => { checkpointNumber: checkpointNumberUnderTest, proposedCheckpointData: undefined, rollup: publisher.rollupContract, + signatureContext, log: createLogger('test'), pipeliningEnabled: true, prunePending: { provenOverride: CheckpointNumber(0) }, @@ -822,6 +955,62 @@ describe('CheckpointProposalJob', () => { expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); expect(plan?.chainTipsOverride?.proven).toEqual(CheckpointNumber(0)); }); + + it('populates the per-checkpoint state from proposedCheckpointData when pipelining is enabled', async () => { + const proposedHeader = CheckpointHeader.empty({ slotNumber: SlotNumber(123) }); + const proposedArchive = new AppendOnlyTreeSnapshot(Fr.random(), 1); + const proposedOutHash = Fr.random(); + const proposedFeeHeader: FeeHeader = { + manaUsed: 3000n, + excessMana: 1000n, + ethPerFeeAsset: 500n, + congestionCost: 50n, + proverCost: 10n, + }; + jest.spyOn(publisher.rollupContract, 'getCheckpoint').mockResolvedValue({ feeHeader: proposedFeeHeader } as any); + jest.spyOn(publisher.rollupContract, 'getManaTarget').mockResolvedValue(10_000n); + + const proposedData: ProposedCheckpointData = { + checkpointNumber: CheckpointNumber(1), + header: proposedHeader, + archive: proposedArchive, + checkpointOutHash: proposedOutHash, + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 5000n, + feeAssetPriceModifier: 100n, + }; + + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: CheckpointNumber(2), + proposedCheckpointData: proposedData, + rollup: publisher.rollupContract, + signatureContext, + log: createLogger('test'), + pipeliningEnabled: true, + }); + + expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); + expect(plan?.pendingCheckpointState?.archive).toEqual(proposedArchive.root); + expect(plan?.pendingCheckpointState?.headerHash).toEqual(proposedHeader.hash()); + expect(plan?.pendingCheckpointState?.outHash).toEqual(proposedOutHash); + expect(plan?.pendingCheckpointState?.slotNumber).toEqual(SlotNumber(123)); + expect(plan?.pendingCheckpointState?.payloadDigest).toBeDefined(); + expect(plan?.pendingCheckpointState?.feeHeader).toBeDefined(); + }); + + it('omits per-checkpoint state when proposedCheckpointData is undefined', async () => { + const plan = await buildPipelinedParentSimulationOverridesPlan({ + checkpointNumber: checkpointNumberUnderTest, + proposedCheckpointData: undefined, + rollup: publisher.rollupContract, + signatureContext, + log: createLogger('test'), + pipeliningEnabled: true, + }); + expect(plan?.chainTipsOverride?.pending).toEqual(CheckpointNumber(1)); + expect(plan?.pendingCheckpointState).toBeUndefined(); + }); }); describe('pipelining parent checkpoint validation', () => { diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index c0228cae85c0..49dc82291cf5 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -34,7 +34,12 @@ import { type ValidateCheckpointResult, } from '@aztec/stdlib/block'; import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint'; -import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; +import { + computeQuorum, + getEpochAtSlot, + getSlotStartBuildTimestamp, + getTimestampForSlot, +} from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import { type BlockBuilderOptions, @@ -411,6 +416,37 @@ export class CheckpointProposalJob implements Traceable { } } + /** + * Returns the out hashes of all checkpoints in `targetEpoch` that precede the one being built. + * Under pipelining, the parent checkpoint may not be on L1 yet at build time, so the on-chain + * archiver is missing it; in that case we splice in the parent's `checkpointOutHash` from the + * proposed-checkpoint payload (when it is in the same epoch) so the resulting `epochOutHash` + * matches what other validators and L1 will compute once the parent lands. + */ + private async collectPreviousCheckpointOutHashes(): Promise { + const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1); + const checkpointed = (await this.l2BlockSource.getCheckpointsData({ epoch: this.targetEpoch })) + .filter(c => c.checkpointNumber < this.checkpointNumber) + .map(c => ({ checkpointNumber: c.checkpointNumber, checkpointOutHash: c.checkpointOutHash })); + + const shouldSpliceParent = + this.epochCache.isProposerPipeliningEnabled() && + this.proposedCheckpointData !== undefined && + this.proposedCheckpointData.checkpointNumber === parentCheckpointNumber && + getEpochAtSlot(this.proposedCheckpointData.header.slotNumber, this.epochCache.getL1Constants()) === + this.targetEpoch && + !checkpointed.some(c => c.checkpointNumber === parentCheckpointNumber); + + if (shouldSpliceParent) { + checkpointed.push({ + checkpointNumber: parentCheckpointNumber, + checkpointOutHash: this.proposedCheckpointData!.checkpointOutHash, + }); + } + + return checkpointed.sort((a, b) => a.checkpointNumber - b.checkpointNumber).map(c => c.checkpointOutHash); + } + /** * Waits for the parent checkpoint to land on L1 before submitting a pipelined checkpoint. * Polls until the archiver has synced L1 past the parent's slot, then verifies: @@ -566,6 +602,7 @@ export class CheckpointProposalJob implements Traceable { checkpointNumber: this.checkpointNumber, proposedCheckpointData: this.proposedCheckpointData, rollup: this.publisher.rollupContract, + signatureContext: this.signatureContext, log: this.log, pipeliningEnabled: isPipelining, prunePending: this.prunePending, @@ -582,10 +619,12 @@ export class CheckpointProposalJob implements Traceable { const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(this.checkpointNumber); const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages); - // Collect the out hashes of all the checkpoints before this one in the same epoch - const previousCheckpointOutHashes = (await this.l2BlockSource.getCheckpointsData({ epoch: this.targetEpoch })) - .filter(c => c.checkpointNumber < this.checkpointNumber) - .map(c => c.checkpointOutHash); + // Collect the out hashes of all the checkpoints before this one in the same epoch. + // Under pipelining, the parent checkpoint may not be on L1 yet at build time, so + // `getCheckpointsData` would miss it. Splice in the parent's checkpointOutHash from the + // proposed-checkpoint payload so the resulting `epochOutHash` matches what the validators + // (and L1) compute once the parent lands on L1. + const previousCheckpointOutHashes = await this.collectPreviousCheckpointOutHashes(); // Anchor the modifier to the predicted parent fee header: L1 will apply it against // that, not against the latest published checkpoint (which lags by one under pipelining). diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index 30b5186779a6..f3a23eb0eac4 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -28,7 +28,7 @@ import { type L2BlockSource, type ValidateCheckpointNegativeResult, } from '@aztec/stdlib/block'; -import { Checkpoint } from '@aztec/stdlib/checkpoint'; +import { Checkpoint, type ProposedCheckpointData } from '@aztec/stdlib/checkpoint'; import type { ChainConfig } from '@aztec/stdlib/config'; import type { L1RollupConstants } from '@aztec/stdlib/epoch-helpers'; import { GasFees } from '@aztec/stdlib/gas'; @@ -40,6 +40,7 @@ import { type WorldStateSynchronizerStatus, } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; import { AppendOnlyTreeSnapshot } from '@aztec/stdlib/trees'; import { BlockHeader, GlobalVariables, type Tx } from '@aztec/stdlib/tx'; import type { FullNodeCheckpointsBuilder, ValidatorClient } from '@aztec/validator-client'; @@ -1100,7 +1101,14 @@ describe('sequencer', () => { } satisfies BlockData); l2BlockSource.getProposedCheckpointData.mockResolvedValue({ checkpointNumber: CheckpointNumber(1), - } as any); + header: CheckpointHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.ZERO, + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 0n, + feeAssetPriceModifier: 0n, + } satisfies ProposedCheckpointData); await sequencer.work(); @@ -1261,7 +1269,14 @@ describe('sequencer', () => { } satisfies BlockData); l2BlockSource.getProposedCheckpointData.mockResolvedValue({ checkpointNumber: CheckpointNumber(1), - } as any); + header: CheckpointHeader.empty(), + archive: AppendOnlyTreeSnapshot.empty(), + checkpointOutHash: Fr.ZERO, + startBlock: BlockNumber(1), + blockCount: 1, + totalManaUsed: 0n, + feeAssetPriceModifier: 0n, + } satisfies ProposedCheckpointData); // The sequencer sets proven == simulated pending so canPruneAtTime short-circuits to false. l2BlockSource.isPruneDueAtSlot.mockResolvedValue(true); diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index f29148fcfbf5..93a8d5cde177 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -33,6 +33,7 @@ import { DefaultSequencerConfig } from '../config.js'; import type { GlobalVariableBuilder } from '../global_variable_builder/global_builder.js'; import type { SequencerPublisherFactory } from '../publisher/sequencer-publisher-factory.js'; import type { InvalidateCheckpointRequest, SequencerPublisher } from '../publisher/sequencer-publisher.js'; +import { buildPipelinedParentSimulationOverridesPlan } from './chain_state_overrides.js'; import { CheckpointProposalJob } from './checkpoint_proposal_job.js'; import { CheckpointProposalJobMetrics } from './checkpoint_proposal_job_metrics.js'; import { CheckpointVoter } from './checkpoint_voter.js'; @@ -389,12 +390,20 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter TypedEventEmitter l2Tips.checkpointed.checkpoint.number; + // The l2Tips and proposedCheckpointData reads above come from independent archiver snapshots + // (a JS-side tips cache vs. a direct store read on `#proposedCheckpoints`). A concurrent archiver + // write that mutates both can be observed split, leaving us with `hasProposedCheckpoint=true` but + // no proposedCheckpointData (or one whose number doesn't match the tip). Refuse to proceed in that + // window — the next checkSync tick will see a coherent snapshot. + if ( + hasProposedCheckpoint && + (!proposedCheckpointData || + proposedCheckpointData.checkpointNumber !== l2Tips.proposedCheckpoint.checkpoint.number) + ) { + this.log.warn(`Sequencer sync check failed: inconsistent proposed-checkpoint state`, { + proposedCheckpointTipNumber: l2Tips.proposedCheckpoint.checkpoint.number, + checkpointedTipNumber: l2Tips.checkpointed.checkpoint.number, + proposedCheckpointDataNumber: proposedCheckpointData?.checkpointNumber, + syncedL2Slot, + ...args, + }); + return undefined; + } + + // Check that the proposed checkpoint is indeed the parent of the checkpoint we'll be building + // The checkpoint number to build is derived as blockData.checkpointNumber + 1 + if (proposedCheckpointData && proposedCheckpointData.checkpointNumber !== blockData.checkpointNumber) { + this.log.warn(`Sequencer sync check failed: proposed checkpoint number mismatch`, { + proposedCheckpointNumber: proposedCheckpointData.checkpointNumber, + blockCheckpointNumber: blockData.checkpointNumber, + syncedL2Slot, + ...args, + }); + return undefined; + } + return { blockData, blockNumber: blockData.header.getBlockNumber(), diff --git a/yarn-project/stdlib/src/checkpoint/digest.ts b/yarn-project/stdlib/src/checkpoint/digest.ts new file mode 100644 index 000000000000..7f16ff83c7a5 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/digest.ts @@ -0,0 +1,28 @@ +import type { Buffer32 } from '@aztec/foundation/buffer'; +import type { Fr } from '@aztec/foundation/curves/bn254'; + +import { ConsensusPayload } from '../p2p/consensus_payload.js'; +import { type CoordinationSignatureContext, getHashedSignaturePayloadTypedData } from '../p2p/signature_utils.js'; +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; + +/** + * Computes the EIP-712 payload digest for a checkpoint proposal — the digest validators sign + * and the L1 contract verifies during `propose()`. Mirrors `ProposeLib.digest(ProposePayload)` on L1. + * + * The result is the same `bytes32` that gets stored in `tempCheckpointLogs[checkpointNumber].payloadDigest`, + * so this helper is also reused when constructing simulation state overrides for pipelined proposals. + */ +export function computeCheckpointPayloadDigest(args: { + header: CheckpointHeader; + archiveRoot: Fr; + feeAssetPriceModifier: bigint; + signatureContext: CoordinationSignatureContext; +}): Buffer32 { + const consensusPayload = new ConsensusPayload( + args.header, + args.archiveRoot, + args.feeAssetPriceModifier, + args.signatureContext, + ); + return getHashedSignaturePayloadTypedData(consensusPayload); +} diff --git a/yarn-project/stdlib/src/checkpoint/index.ts b/yarn-project/stdlib/src/checkpoint/index.ts index 96c176e1d861..3f3f57631e96 100644 --- a/yarn-project/stdlib/src/checkpoint/index.ts +++ b/yarn-project/stdlib/src/checkpoint/index.ts @@ -1,5 +1,6 @@ export * from './checkpoint.js'; export * from './checkpoint_data.js'; export * from './checkpoint_info.js'; +export * from './digest.js'; export * from './published_checkpoint.js'; export * from './validate.js'; From cc2e61288e5f5acaaac946a289c72413d1bcdde3 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 11:55:38 -0300 Subject: [PATCH 32/55] test(e2e): enable pipelining on missed l1 slot test (#23068) Enable pipelining on the missed L1 slot e2e test --- .../e2e_epochs/epochs_missed_l1_slot.test.ts | 153 ++++++++++++++---- .../end-to-end/src/e2e_epochs/epochs_test.ts | 13 +- 2 files changed, 133 insertions(+), 33 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts index 717e983ef463..0c32eaab5353 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_missed_l1_slot.test.ts @@ -1,13 +1,19 @@ +import type { AztecAddress } from '@aztec/aztec.js/addresses'; +import { NO_WAIT } from '@aztec/aztec.js/contracts'; +import { Fr } from '@aztec/aztec.js/fields'; import type { ChainMonitorEventMap } from '@aztec/ethereum/test'; import { CheckpointNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { timesAsync } from '@aztec/foundation/collection'; import { AbortError } from '@aztec/foundation/error'; import { sleep } from '@aztec/foundation/sleep'; import { executeTimeout } from '@aztec/foundation/timer'; +import type { TestContract } from '@aztec/noir-test-contracts.js/Test'; import { SequencerState } from '@aztec/sequencer-client'; import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { jest } from '@jest/globals'; +import { proveInteraction } from '../test-wallet/utils.js'; import { EpochsTestContext } from './epochs_test.js'; jest.setTimeout(1000 * 60 * 10); @@ -16,28 +22,86 @@ jest.setTimeout(1000 * 60 * 10); // all L1 blocks of the previous slot. This happens when an L1 slot is missed (no block produced). // The fix relies on getSyncedL2SlotNumber using the latest synced checkpoint slot as a signal, // bypassing the stale L1 timestamp when L1 blocks are missing. -// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/14766 +// Regression test for https://github.com/AztecProtocol/aztec-packages/issues/14766. +// +// ├──────── L2 slot N ────────┤├─────── L2 slot N+1 ───────┤├── L2 slot N+2 ──┤ +// │ ││ ││ +// L1: │ mining → CP_N pub → FREEZE│├══════ paused L1 ══════════┤│RESUME → mining +// │ ▲ ▲ ││ ▲│ +// │ (1) checkpoint │ ││ (4) │ +// │ in first half │ ││ eth.mine() +// │ of slot N │ ││ +// │ (2) eth.setIntervalMining(0) +// +// Cycle@wallClock=N (target=N+1): +// checkSync(slot=N) ─→ PROPOSER_CHECK(slot=N) ─→ INITIALIZING_CHECKPOINT(target=N+1) +// ─→ ... ─→ PUBLISHING_CHECKPOINT(target=N+1) ✗ blocked on L1 pause until RESUME +// +// Cycle@wallClock=N+1 (target=N+2) ← THE BUG-FIX CYCLE +// checkSync(slot=N+1) — requires syncedSlot ≥ N +// ✗ without fix: slotFromL1Sync stuck at N-1 +// (L1 frozen mid-slot N) → STUCK FOREVER +// ✓ with fix: slotFromCheckpoint = N (CP_N is on L1) +// → checkSync passes +// ─→ PROPOSER_CHECK(slot=N+1) ← TEST WAITS +// ─→ canProposeAt rollup check ✗ blocks further progress until parent CP_N+1 is on L1 +// (pipelining override needs hasProposedCheckpoint, which is sourced from L1 and +// is false while CP_N+1's tx sits in mempool during the pause). +// +// Test signal: state-changed with newState=PROPOSER_CHECK && slot=N+1 (wall-clock). +// - PROPOSER_CHECK is reached only after `checkSync` returns syncedTo (line ~290 of +// sequencer.ts), so observing it for wall-clock slot N+1 directly proves the bug fix: +// without the fix, checkSync would block on slot N+1 forever during the L1 pause. +// - Slot N+1 (wall-clock) is unique to this cycle: the prior cycle ran at wall-clock N. describe('e2e_epochs/epochs_missed_l1_slot', () => { let test: EpochsTestContext; + let contract: TestContract; + let from: AztecAddress; // Use enough L1 slots per L2 slot to have room for pausing mining mid-slot. // With 6 L1 slots per L2 slot (L1=8s, L2=48s), we have plenty of time to // publish a checkpoint and pause mining without accidentally skipping a slot. const L1_SLOTS_PER_L2_SLOT = 6; + // Block duration tuned to reliably produce 2+ blocks per checkpoint under pipelining: + // timeAvailableForBlocks = aztecSlotDuration - checkpointInitializationTime - timeReservedAtEnd + // = 48 - 1 - (1 + 4 + 8) = 34s, which fits ~4 blocks of 8s each. + const BLOCK_DURATION_MS = 8_000; + + // Pre-prove this many txs at the start so blocks have content during the test. + const TX_COUNT = 12; + beforeEach(async () => { - // Note: pipelining is NOT enabled for this test because it deliberately pauses L1 mining - // to simulate missed L1 slots, which conflicts with pipelining's assumption that previous - // checkpoints land on L1 promptly. test = await EpochsTestContext.setup({ numberOfAccounts: 0, + // The 8s blockDurationMs leaves a per-block DA gas budget too small to fit an account + // deploy, so use the hardcoded-account fast-path (funded via genesis) even though we + // keep the initial sequencer running for the test. + useHardcodedAccount: true, minTxsPerBlock: 0, + maxTxsPerBlock: 1, + blockDurationMs: BLOCK_DURATION_MS, aztecSlotDurationInL1Slots: L1_SLOTS_PER_L2_SLOT, startProverNode: false, aztecProofSubmissionEpochs: 1024, disableAnvilTestWatcher: true, enforceTimeTable: true, + enableProposerPipelining: true, + inboxLag: 2, + // Required for the proposer's own broadcasts to route through the local + // proposal handler (the dummy p2p service drops them). Without this, the + // archiver's #proposedCheckpoints map stays empty and the pipelining + // override path is never taken. + mockGossipSubNetwork: true, + // With L1=12s on CI, aztecSlotDuration=72s and blockDurationMs=8000ms gives only ~1/9 of + // slot mana per block — too small for emit_nullifier's daGas (~196k) under the default + // 1.2 allocation. Bump it so the pre-proved txs actually land and step 6's + // assertMultipleBlocksPerSlot has data to verify against. + perBlockAllocationMultiplier: 8, }); + + from = test.context.accounts[0]; + contract = await test.registerTestContract(test.context.wallet); }); afterEach(async () => { @@ -51,30 +115,44 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { const L1_BLOCK_TIME = test.L1_BLOCK_TIME_IN_S; const L2_SLOT_DURATION = test.L2_SLOT_DURATION_IN_S; - // Step 1: Wait for a checkpoint that's published NOT in the last L1 slot of its L2 slot. - // We need the checkpoint to land early enough that when we pause mining, the archiver's - // L1 timestamp is still in the middle of the slot (not at the end). - logger.info('Waiting for a checkpoint published early in its L2 slot...'); + // Pre-prove a batch of txs and send them so blocks have content while building checkpoints. + // Done before waiting for the early checkpoint so that mbps is exercised by the time we pause. + logger.info(`Pre-proving ${TX_COUNT} transactions`); + const txs = await timesAsync(TX_COUNT, i => + proveInteraction(context.wallet, contract.methods.emit_nullifier(new Fr(i + 1)), { from }), + ); + const txHashes = await Promise.all(txs.map(tx => tx.send({ wait: NO_WAIT }))); + logger.info(`Sent ${txHashes.length} transactions`); + + // Step 1: Wait for a checkpoint published in the first half of its L2 slot. + // We need CP_N's L1 timestamp to be solidly mid-slot so that slotFromL1Sync (computed from + // the *next* L1 block's slot) is still N-1 when we pause. If CP_N landed too late in the + // slot (e.g. in the last L1 slot of L2 slot N), slotFromL1Sync would already be N and the + // bug would not be exercised. + logger.info('Waiting for a checkpoint published in the first half of its L2 slot...'); const checkpointEvent = await executeTimeout( signal => new Promise((res, rej) => { const handleCheckpoint = (...[ev]: ChainMonitorEventMap['checkpoint']) => { - // Skip the initial checkpoint (genesis state). + // Skip the genesis checkpoint. if (ev.checkpointNumber === 0) { return; } const slotStart = getTimestampForSlot(ev.l2SlotNumber, constants); - const lastL1SlotStart = slotStart + BigInt(L2_SLOT_DURATION - L1_BLOCK_TIME); - if (ev.timestamp < lastL1SlotStart) { + // Half-slot cutoff keeps slotFromL1Sync at N-1 with comfortable margin: at the cutoff + // the next L1 block lands at slotStart + L2_SLOT_DURATION/2 + L1_BLOCK_TIME, which is + // still well within slot N (since L1 < L2/2). + const cutoff = slotStart + BigInt(Math.floor(L2_SLOT_DURATION / 2)); + if (ev.timestamp < cutoff) { logger.info( `Checkpoint ${ev.checkpointNumber} in slot ${ev.l2SlotNumber} at L1 timestamp ${ev.timestamp}`, - { slotStart, lastL1SlotStart }, + { slotStart, cutoff }, ); res(ev); monitor.off('checkpoint', handleCheckpoint); } else { logger.info( - `Skipping checkpoint ${ev.checkpointNumber}: published at ${ev.timestamp} (last L1 slot starts at ${lastL1SlotStart})`, + `Skipping checkpoint ${ev.checkpointNumber}: published at ${ev.timestamp} (cutoff ${cutoff})`, ); } }; @@ -84,20 +162,20 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { }; monitor.on('checkpoint', handleCheckpoint); }), - 60_000, + 120_000, 'Wait for early checkpoint', ); const checkpointSlotNumber = checkpointEvent.l2SlotNumber; const nextSlotNumber = SlotNumber(checkpointSlotNumber + 1); - const nextSlotTimestamp = Number(getTimestampForSlot(nextSlotNumber, constants)); + const lastL1SlotStart = + getTimestampForSlot(checkpointSlotNumber, constants) + BigInt(L2_SLOT_DURATION - L1_BLOCK_TIME); logger.info(`Using checkpoint ${checkpointEvent.checkpointNumber} in L2 slot ${checkpointSlotNumber}`, { nextSlotNumber, - nextSlotTimestamp, }); - // Step 2: Wait briefly for the sequencer to finish its current work cycle, then pause mining. + // Step 2: Brief pause so the sequencer settles, then freeze L1 mining. await sleep(1500); logger.info('Pausing L1 block production (simulating missed L1 slots)...'); @@ -107,19 +185,29 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { const frozenL1Timestamp = await eth.lastBlockTimestamp(); logger.info(`L1 mining paused at L1 timestamp ${frozenL1Timestamp}`); - // Step 3: Wait until the sequencer reaches PUBLISHING_CHECKPOINT during the mining pause. - // With the fix: the sequencer sees the checkpoint for slot N, so getSyncedL2SlotNumber - // returns N, checkSync passes for slot N+1, and it advances all the way to publishing. - // Without the fix: getSyncedL2SlotNumber is stuck at N-1, checkSync fails, sequencer - // stays in IDLE/SYNCHRONIZING and never reaches PUBLISHING_CHECKPOINT. + // Sanity: the frozen L1 timestamp must be before the last L1 slot of L2 slot N. Otherwise + // slotFromL1Sync already advanced to N and the regression isn't being exercised. + expect(BigInt(frozenL1Timestamp)).toBeLessThan(lastL1SlotStart); + + // Step 3: During the pause, wait for the sequencer cycle running at wall-clock = N+1 + // to pass `checkSync(slot=N+1)`. We wait for `state-changed` with + // `newState=PROPOSER_CHECK && slot=N+1`: PROPOSER_CHECK is set right after `checkSync` + // returns a non-undefined sync result (sequencer.ts line ~290/330), so observing it for + // wall-clock slot N+1 directly proves the regression is fixed. We do NOT wait for any + // later state because the canProposeAt rollup-contract check fails while CP_N+1's L1 tx + // sits in mempool during the pause (pipelining's override depends on + // `hasProposedCheckpoint`, which is sourced from L1 and is false in this window). const sequencer = context.sequencer!.getSequencer(); + const targetSlotForBugFixCycle = SlotNumber(nextSlotNumber + 1); - logger.info('Waiting for sequencer to reach PUBLISHING_CHECKPOINT during mining pause...'); + logger.info( + `Waiting for sequencer to reach INITIALIZING_CHECKPOINT for target slot ${targetSlotForBugFixCycle} during mining pause...`, + ); await executeTimeout( signal => new Promise((res, rej) => { - const stateListener = ({ newState }: { newState: SequencerState }) => { - if (newState === SequencerState.PUBLISHING_CHECKPOINT) { + const stateListener = (args: { newState: SequencerState; slot?: SlotNumber }) => { + if (args.newState === SequencerState.INITIALIZING_CHECKPOINT && args.slot === targetSlotForBugFixCycle) { sequencer.off('state-changed', stateListener); res(); } @@ -130,20 +218,22 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { }; sequencer.on('state-changed', stateListener); }), - L2_SLOT_DURATION * 2 * 1000, - 'Wait for sequencer to reach PUBLISHING_CHECKPOINT', + L2_SLOT_DURATION * 3 * 1000, + `Wait for sequencer INITIALIZING_CHECKPOINT at target slot ${targetSlotForBugFixCycle}`, ); - logger.info('Sequencer reached PUBLISHING_CHECKPOINT during mining pause'); + logger.info( + `Sequencer reached INITIALIZING_CHECKPOINT for target slot ${targetSlotForBugFixCycle} during mining pause`, + ); - // Step 4: Resume mining so the pending L1 tx lands and the test can clean up. + // Step 4: Resume mining so the pending L1 txs land and the test can clean up. logger.info('Resuming L1 block production...'); const resumeTimestamp = Math.floor(context.dateProvider.now() / 1000); await eth.setNextBlockTimestamp(resumeTimestamp); await eth.mine(); await eth.setIntervalMining(L1_BLOCK_TIME); - // Step 5: Wait for the next checkpoint to confirm the block was actually published. + // Step 5: Wait for the next checkpoint to confirm block production resumed cleanly. const finalCheckpoint = CheckpointNumber(checkpointEvent.checkpointNumber + 1); logger.info(`Waiting for checkpoint ${finalCheckpoint}...`); await test.waitUntilCheckpointNumber(finalCheckpoint, 60); @@ -151,5 +241,8 @@ describe('e2e_epochs/epochs_missed_l1_slot', () => { logger.info(`Checkpoint ${finalCheckpoint} published in slot ${monitor.l2SlotNumber}`); expect(monitor.checkpointNumber).toBeGreaterThanOrEqual(finalCheckpoint); + + // Step 6: Verify multi-blocks-per-slot was actually exercised. + await test.assertMultipleBlocksPerSlot(2); }); }); diff --git a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts index 7884c1c1b844..d6d6905b6c61 100644 --- a/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts +++ b/yarn-project/end-to-end/src/e2e_epochs/epochs_test.ts @@ -60,6 +60,11 @@ export type EpochsTestOpts = Partial & { aztecSlotDurationInL1Slots?: number; /** Skip creating/registering the hardcoded account during setup (for tests that handle accounts themselves). */ skipHardcodedAccount?: boolean; + /** + * Force the hardcoded-account fast-path even when an initial sequencer is running. Useful for + * tests with tight per-block gas budgets that can't fit a full account-deploy tx. + */ + useHardcodedAccount?: boolean; }; export type TrackedSequencerEvent = { @@ -130,9 +135,11 @@ export class EpochsTestContext { this.L1_BLOCK_TIME_IN_S = ethereumSlotDuration; this.L2_SLOT_DURATION_IN_S = aztecSlotDuration; - // When skipInitialSequencer is set, auto-create a hardcoded account funded via genesis. - // This avoids needing to deploy accounts on-chain (which would require a running sequencer). - const useHardcodedAccount = opts.skipInitialSequencer && !opts.skipHardcodedAccount; + // Auto-create a hardcoded account funded via genesis when: + // - skipInitialSequencer is set (no sequencer to deploy on-chain), or + // - useHardcodedAccount is explicitly requested (e.g. tight per-block gas budgets that + // can't fit a full account-deploy tx). + const useHardcodedAccount = (opts.skipInitialSequencer || opts.useHardcodedAccount) && !opts.skipHardcodedAccount; let hardcodedAccountData: InitialAccountData | undefined; if (useHardcodedAccount) { hardcodedAccountData = await EpochsTestContext.getHardcodedAccountData(Fr.random(), Fr.random()); From 7c9e3d5852fc4f10f0802839fb6a4f4608375966 Mon Sep 17 00:00:00 2001 From: spypsy Date: Mon, 11 May 2026 17:28:57 +0100 Subject: [PATCH 33/55] fix: more robust metrics reporting in IRM monitor (#23038) Fixes an issue where stuck requests would update the gauge after it was already updated by subsequent requests that succeeded quickly. This forces the gauge to always be updated in sequence, or the result is just dropped Also added some logging so we can see what's happening --- .github/workflows/deploy-irm.yml | 4 +- spartan/metrics/irm-monitor/VERSION | 2 +- spartan/metrics/irm-monitor/index.ts | 137 +++++++++++++++--- .../kubernetes/monitoring-deployment.yaml | 4 +- .../kubernetes/monitoring-secret.yaml | 2 +- .../irm-monitor/scripts/update-monitoring.sh | 6 +- 6 files changed, 125 insertions(+), 30 deletions(-) diff --git a/.github/workflows/deploy-irm.yml b/.github/workflows/deploy-irm.yml index bd2d7f1924f0..b9f18c59cb93 100644 --- a/.github/workflows/deploy-irm.yml +++ b/.github/workflows/deploy-irm.yml @@ -69,7 +69,7 @@ jobs: CLUSTER_NAME: ${{ inputs.cluster }} GKE_CLUSTER_CONTEXT: "gke_testnet-440309_us-west1-a_${{ inputs.cluster }}" REGION: us-west1-a - INFURA_SECRET_NAME: infura-${{ inputs.l1_network }}-url + ETHEREUM_HOSTS_SECRET_NAME: irm-ethereum-hosts-${{ inputs.l1_network }} runs-on: ubuntu-latest steps: @@ -150,4 +150,4 @@ jobs: echo "L1 network: ${{ inputs.l1_network }}" echo "Image tag: ${IMAGE_TAG}" - ./spartan/metrics/irm-monitor/scripts/update-monitoring.sh $NAMESPACE $MONITORING_NAMESPACE ${{ inputs.network }} $INFURA_SECRET_NAME + ./spartan/metrics/irm-monitor/scripts/update-monitoring.sh $NAMESPACE $MONITORING_NAMESPACE ${{ inputs.network }} $ETHEREUM_HOSTS_SECRET_NAME diff --git a/spartan/metrics/irm-monitor/VERSION b/spartan/metrics/irm-monitor/VERSION index 227cea215648..3e3c2f1e5edb 100644 --- a/spartan/metrics/irm-monitor/VERSION +++ b/spartan/metrics/irm-monitor/VERSION @@ -1 +1 @@ -2.0.0 +2.1.1 diff --git a/spartan/metrics/irm-monitor/index.ts b/spartan/metrics/irm-monitor/index.ts index ecea19e73bdb..ea756cfc5938 100644 --- a/spartan/metrics/irm-monitor/index.ts +++ b/spartan/metrics/irm-monitor/index.ts @@ -2,21 +2,26 @@ import express, { Request, Response } from "express"; import { createPublicClient, http } from "viem"; import client from "prom-client"; -const { ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOST, NETWORK } = process.env; +const { ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOSTS, NETWORK } = process.env; ////////////////////////////// // IMPORTANT: Bump VERSION file when making changes ////////////////////////////// -if (!ROLLUP_CONTRACT_ADDRESS || !ETHEREUM_HOST || !NETWORK) { +const ethereumRpcUrls = (ETHEREUM_HOSTS ?? "") + .split(",") + .map((u: string) => u.trim()) + .filter(Boolean); + +if (!ROLLUP_CONTRACT_ADDRESS || ethereumRpcUrls.length === 0 || !NETWORK) { console.error( - "ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOST and NETWORK are required. Provided: ", + "ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOSTS and NETWORK are required. Provided: ", ROLLUP_CONTRACT_ADDRESS, - ETHEREUM_HOST, - NETWORK + ETHEREUM_HOSTS, + NETWORK, ); throw new Error( - "ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOST and NETWORK are required" + "ROLLUP_CONTRACT_ADDRESS, ETHEREUM_HOSTS and NETWORK are required", ); } @@ -24,11 +29,23 @@ if (!ROLLUP_CONTRACT_ADDRESS.startsWith("0x")) { throw new Error("ROLLUP_CONTRACT_ADDRESS must start with 0x"); } -const transport = http(ETHEREUM_HOST); +const RPC_TIMEOUT_MS = 12_000; -const publicClient = createPublicClient({ - transport, -}); +const publicClientsByRpcUrl = new Map< + string, + ReturnType +>(); + +function getPublicClient(rpcUrl: string) { + let c = publicClientsByRpcUrl.get(rpcUrl); + if (!c) { + c = createPublicClient({ + transport: http(rpcUrl, { timeout: RPC_TIMEOUT_MS }), + }); + publicClientsByRpcUrl.set(rpcUrl, c); + } + return c; +} const ROLLUP_ABI = [ { @@ -74,23 +91,95 @@ const pendingCheckpointNumberGauge = new client.Gauge({ labelNames: ["network"], }); -async function updateCheckpointNumbers(): Promise { - try { - const provenCheckpointNumber = await publicClient.readContract({ +const POLL_INTERVAL_MS = 36_000; + +let lastStartedUpdateId = 0; + +async function readCheckpointsFromRpc( + rpcUrl: string, + blockNumber: bigint, +): Promise<{ proven: number; pending: number }> { + const publicClient = getPublicClient(rpcUrl); + const [provenCheckpointNumber, pendingCheckpointNumber] = await Promise.all([ + publicClient.readContract({ address: ROLLUP_CONTRACT_ADDRESS as `0x${string}`, abi: ROLLUP_ABI, functionName: "getProvenCheckpointNumber", - }); - provenCheckpointNumberGauge.set(Number(provenCheckpointNumber)); - - const pendingCheckpointNumber = await publicClient.readContract({ + blockNumber, + }), + publicClient.readContract({ address: ROLLUP_CONTRACT_ADDRESS as `0x${string}`, abi: ROLLUP_ABI, functionName: "getPendingCheckpointNumber", + blockNumber, + }), + ]); + return { + proven: Number(provenCheckpointNumber), + pending: Number(pendingCheckpointNumber), + }; +} + +async function updateCheckpointNumbers(): Promise { + const thisUpdateId = ++lastStartedUpdateId; + const startedAt = Date.now(); + try { + const blockNumber = await getPublicClient( + ethereumRpcUrls[0]!, + ).getBlockNumber(); + const settled = await Promise.allSettled( + ethereumRpcUrls.map((url) => readCheckpointsFromRpc(url, blockNumber)), + ); + + if (thisUpdateId !== lastStartedUpdateId) { + console.log("skipped stale checkpoint read", { + updateId: thisUpdateId, + latestUpdateId: lastStartedUpdateId, + elapsedMs: Date.now() - startedAt, + }); + return; + } + + const successes: { proven: number; pending: number }[] = []; + const failures: { rpcUrl: string; reason: unknown }[] = []; + for (let i = 0; i < settled.length; i++) { + const r = settled[i]!; + const rpcUrl = ethereumRpcUrls[i]!; + if (r.status === "fulfilled") { + successes.push(r.value); + } else { + failures.push({ rpcUrl, reason: r.reason }); + } + } + + if (successes.length === 0) { + console.error( + `checkpoint update failed: all ${ethereumRpcUrls.length} RPC host(s) failed (updateId=${thisUpdateId})`, + failures, + ); + return; + } + + const proven = Math.max(...successes.map((s) => s.proven)); + const pending = Math.max(...successes.map((s) => s.pending)); + provenCheckpointNumberGauge.set(proven); + pendingCheckpointNumberGauge.set(pending); + console.log("checkpoints updated", { + updateId: thisUpdateId, + proven, + pending, + rpcHostsOk: successes.length, + rpcHostsFailed: failures.length, + elapsedMs: Date.now() - startedAt, }); - pendingCheckpointNumberGauge.set(Number(pendingCheckpointNumber)); + if (failures.length > 0) { + console.warn( + `checkpoint read: ${failures.length} RPC host(s) failed; using max across ${successes.length} successful response(s)`, + failures.map((f) => ({ rpcUrl: f.rpcUrl, reason: f.reason })), + ); + } } catch (error) { - console.error("Error updating checkpoint numbers:", error); + console.error(`checkpoint update failed (updateId=${thisUpdateId})`, error); } } @@ -102,10 +191,16 @@ app.get("/metrics", async (_req: Request, res: Response) => { const port = process.env.PORT ? Number(process.env.PORT) : 8080; app.listen(port, () => { - console.log(`Metrics server listening on port ${port}`); + console.log("metrics server listening", { + port, + network: NETWORK, + rollup: ROLLUP_CONTRACT_ADDRESS, + ethereumRpcUrls, + pollIntervalMs: POLL_INTERVAL_MS, + }); }); -setInterval(updateCheckpointNumbers, 36000); +setInterval(updateCheckpointNumbers, POLL_INTERVAL_MS); updateCheckpointNumbers(); // Expose default process metrics, including process_start_time_seconds diff --git a/spartan/metrics/irm-monitor/kubernetes/monitoring-deployment.yaml b/spartan/metrics/irm-monitor/kubernetes/monitoring-deployment.yaml index f16c6e730387..d44a3b9f5c19 100644 --- a/spartan/metrics/irm-monitor/kubernetes/monitoring-deployment.yaml +++ b/spartan/metrics/irm-monitor/kubernetes/monitoring-deployment.yaml @@ -23,11 +23,11 @@ spec: containerPort: 8080 protocol: TCP env: - - name: ETHEREUM_HOST + - name: ETHEREUM_HOSTS valueFrom: secretKeyRef: name: irm-monitor-secrets - key: infura-sepolia-url + key: ethereum-hosts - name: ROLLUP_CONTRACT_ADDRESS value: "${ROLLUP_CONTRACT_ADDRESS}" - name: NETWORK diff --git a/spartan/metrics/irm-monitor/kubernetes/monitoring-secret.yaml b/spartan/metrics/irm-monitor/kubernetes/monitoring-secret.yaml index d59202568945..ad84f40b5b53 100644 --- a/spartan/metrics/irm-monitor/kubernetes/monitoring-secret.yaml +++ b/spartan/metrics/irm-monitor/kubernetes/monitoring-secret.yaml @@ -4,5 +4,5 @@ metadata: name: irm-monitor-secrets type: Opaque data: - infura-sepolia-url: "" + ethereum-hosts: "" grafana-cloud-password: "" diff --git a/spartan/metrics/irm-monitor/scripts/update-monitoring.sh b/spartan/metrics/irm-monitor/scripts/update-monitoring.sh index 1926953bb76e..2f3bec4d8eaa 100755 --- a/spartan/metrics/irm-monitor/scripts/update-monitoring.sh +++ b/spartan/metrics/irm-monitor/scripts/update-monitoring.sh @@ -8,7 +8,7 @@ set -e NAMESPACE=${1:-"testnet"} MONITORING_NAMESPACE=${2:-"$NAMESPACE-irm"} NETWORK=${3:-"$NAMESPACE"} -INFURA_URL_SECRET=${4:-"infura-sepolia-url"} +ETHEREUM_HOSTS_SECRET=${4:-"irm-ethereum-hosts-sepolia"} # Deployment name includes the monitoring namespace prefix export DEPLOYMENT_NAME="${MONITORING_NAMESPACE}-monitor" @@ -130,7 +130,7 @@ fi # Fetch GCP secrets echo "Fetching GCP secrets..." -INFURA_URL=$(gcloud secrets versions access latest --secret=$INFURA_URL_SECRET) +ETHEREUM_HOSTS=$(gcloud secrets versions access latest --secret=$ETHEREUM_HOSTS_SECRET) GRAFANA_PASSWORD=$(gcloud secrets versions access latest --secret=grafana-cloud-password) # Ensure monitoring namespace exists @@ -139,7 +139,7 @@ kubectl get ns "$MONITORING_NAMESPACE" >/dev/null 2>&1 || kubectl create ns "$MO # Create/update secrets echo "Applying monitoring secrets..." kubectl -n "$MONITORING_NAMESPACE" create secret generic irm-monitor-secrets \ - --from-literal=infura-sepolia-url="$INFURA_URL" \ + --from-literal=ethereum-hosts="$ETHEREUM_HOSTS" \ --from-literal=grafana-cloud-password="$GRAFANA_PASSWORD" \ --dry-run=client -o yaml | kubectl apply -f - From 7e6f438b4fe5e4d385af01ac871e28d4031543ab Mon Sep 17 00:00:00 2001 From: spypsy Date: Mon, 11 May 2026 17:29:13 +0100 Subject: [PATCH 34/55] fix: preserve LMDB slashing protection (#23145) - Preserve local validator slashing-protection records across the known LMDB schema 1 -> 2 migration. - Add a fail-closed schema mismatch policy for versioned stores and wire it into signing protection. - Add regression coverage for preserving legacy duty records and refusing newer stored schemas. Fixes [A-1029](https://linear.app/aztec-labs/issue/A-1029/prevent-lmdb-slashing-protection-reset-on-schema-mismatch) Fixes https://github.com/AztecProtocol/aztec-claude/issues/888 --- yarn-project/kv-store/src/lmdb-v2/factory.ts | 11 +- .../database-version/version_manager.test.ts | 25 +++++ .../src/database-version/version_manager.ts | 17 +++ .../validator-ha-signer/src/db/lmdb.test.ts | 106 +++++++++++++++++- .../validator-ha-signer/src/db/lmdb.ts | 45 +++++++- .../validator-ha-signer/src/factory.ts | 27 ++++- 6 files changed, 221 insertions(+), 10 deletions(-) diff --git a/yarn-project/kv-store/src/lmdb-v2/factory.ts b/yarn-project/kv-store/src/lmdb-v2/factory.ts index 73a8c48295ca..fcfaea1a68f9 100644 --- a/yarn-project/kv-store/src/lmdb-v2/factory.ts +++ b/yarn-project/kv-store/src/lmdb-v2/factory.ts @@ -1,6 +1,6 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { type LoggerBindings, createLogger } from '@aztec/foundation/log'; -import { DatabaseVersionManager } from '@aztec/stdlib/database-version/manager'; +import { DatabaseVersionManager, type SchemaVersionMismatchPolicy } from '@aztec/stdlib/database-version/manager'; import type { DataStoreConfig } from '@aztec/stdlib/kv-store'; import { mkdir, mkdtemp, rm } from 'fs/promises'; @@ -11,11 +11,18 @@ import { AztecLMDBStoreV2 } from './store.js'; const MAX_READERS = 16; +/** Optional versioning hooks for persistent LMDB stores. */ +export type CreateStoreOptions = { + onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise; + schemaVersionMismatchPolicy?: SchemaVersionMismatchPolicy; +}; + export async function createStore( name: string, schemaVersion: number, config: DataStoreConfig, bindings?: LoggerBindings, + options: CreateStoreOptions = {}, ): Promise { const log = createLogger('kv-store:lmdb-v2:' + name, bindings); const { dataDirectory, l1Contracts } = config; @@ -35,6 +42,8 @@ export async function createStore( dataDirectory: subDir, onOpen: dbDirectory => AztecLMDBStoreV2.new(dbDirectory, config.dataStoreMapSizeKb, MAX_READERS, () => Promise.resolve(), bindings), + onUpgrade: options.onUpgrade, + schemaVersionMismatchPolicy: options.schemaVersionMismatchPolicy, }); log.info( diff --git a/yarn-project/stdlib/src/database-version/version_manager.test.ts b/yarn-project/stdlib/src/database-version/version_manager.test.ts index 391772da8d49..d961d735b282 100644 --- a/yarn-project/stdlib/src/database-version/version_manager.test.ts +++ b/yarn-project/stdlib/src/database-version/version_manager.test.ts @@ -15,6 +15,7 @@ describe('VersionManager', () => { let versionManager: DatabaseVersionManager; let currentVersion: number; let rollupAddress: EthAddress; + let expectVersionFileWritten: boolean; beforeEach(() => { fs = { @@ -28,6 +29,7 @@ describe('VersionManager', () => { currentVersion = 42; rollupAddress = EthAddress.random(); + expectVersionFileWritten = true; openSpy = jest.fn(() => Promise.resolve({})); upgradeSpy = jest.fn(() => Promise.resolve()); @@ -43,6 +45,9 @@ describe('VersionManager', () => { describe('open', () => { afterEach(() => { + if (!expectVersionFileWritten) { + return; + } // Verify version file was created expect(fs.writeFile).toHaveBeenCalledWith( join(tempDir, DatabaseVersionManager.VERSION_FILE), @@ -96,6 +101,26 @@ describe('VersionManager', () => { expect(upgradeSpy).not.toHaveBeenCalled(); }); + it('unless schema mismatches are configured to throw', async () => { + fs.readFile.mockResolvedValueOnce(new DatabaseVersion(currentVersion + 1, rollupAddress).toBuffer()); + versionManager = new DatabaseVersionManager({ + schemaVersion: currentVersion, + rollupAddress, + dataDirectory: tempDir, + onOpen: openSpy, + onUpgrade: upgradeSpy, + schemaVersionMismatchPolicy: 'throw', + fileSystem: fs, + }); + expectVersionFileWritten = false; + + await expect(versionManager.open()).rejects.toThrow( + `stored schema version ${currentVersion + 1} is incompatible with expected schema version ${currentVersion}`, + ); + expect(fs.rm).not.toHaveBeenCalled(); + expect(openSpy).not.toHaveBeenCalled(); + }); + it('when the upgrade fails', async () => { fs.readFile.mockResolvedValueOnce(new DatabaseVersion(currentVersion - 1, rollupAddress).toBuffer()); upgradeSpy.mockRejectedValueOnce(new Error('Test: failed upgrade')); diff --git a/yarn-project/stdlib/src/database-version/version_manager.ts b/yarn-project/stdlib/src/database-version/version_manager.ts index 37bddd847083..2e4807ff0a97 100644 --- a/yarn-project/stdlib/src/database-version/version_manager.ts +++ b/yarn-project/stdlib/src/database-version/version_manager.ts @@ -9,6 +9,7 @@ import { DatabaseVersion } from './database_version.js'; export type DatabaseVersionManagerFs = Pick; export const DATABASE_VERSION_FILE_NAME = 'db_version'; +export type SchemaVersionMismatchPolicy = 'reset' | 'throw'; export type DatabaseVersionManagerOptions = { schemaVersion: number; @@ -16,6 +17,7 @@ export type DatabaseVersionManagerOptions = { dataDirectory: string; onOpen: (dataDir: string) => Promise; onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise; + schemaVersionMismatchPolicy?: SchemaVersionMismatchPolicy; fileSystem?: DatabaseVersionManagerFs; log?: Logger; }; @@ -34,6 +36,7 @@ export class DatabaseVersionManager { private dataDirectory: string; private onOpen: (dataDir: string) => Promise; private onUpgrade?: (dataDir: string, currentVersion: number, latestVersion: number) => Promise; + private schemaVersionMismatchPolicy: SchemaVersionMismatchPolicy; private fileSystem: DatabaseVersionManagerFs; private log: Logger; @@ -45,6 +48,7 @@ export class DatabaseVersionManager { * @param dataDirectory - The directory where version information will be stored * @param onOpen - A callback to the open the database at the given location * @param onUpgrade - An optional callback to upgrade the database before opening. If not provided it will reset the database + * @param schemaVersionMismatchPolicy - Whether schema mismatches should reset data or throw * @param fileSystem - An interface to access the filesystem * @param log - Optional custom logger * @param options - Configuration options @@ -55,6 +59,7 @@ export class DatabaseVersionManager { dataDirectory, onOpen, onUpgrade, + schemaVersionMismatchPolicy = 'reset', fileSystem = fs, log = createLogger(`foundation:version-manager`), }: DatabaseVersionManagerOptions) { @@ -68,6 +73,7 @@ export class DatabaseVersionManager { this.dataDirectory = dataDirectory; this.onOpen = onOpen; this.onUpgrade = onUpgrade; + this.schemaVersionMismatchPolicy = schemaVersionMismatchPolicy; this.fileSystem = fileSystem; this.log = log; } @@ -115,10 +121,21 @@ export class DatabaseVersionManager { try { await this.onUpgrade(this.dataDirectory, storedVersion.schemaVersion, this.currentVersion.schemaVersion); } catch (error) { + if (this.schemaVersionMismatchPolicy === 'throw') { + throw new Error( + `Failed to upgrade database at ${this.dataDirectory} from schema version ${storedVersion.schemaVersion} to ${this.currentVersion.schemaVersion}`, + { cause: error }, + ); + } this.log.error(`Failed to upgrade: ${error}. Falling back to reset.`); needsReset = true; } } else if (cmp !== 0) { + if (this.schemaVersionMismatchPolicy === 'throw') { + throw new Error( + `Cannot open database at ${this.dataDirectory}: stored schema version ${storedVersion.schemaVersion} is incompatible with expected schema version ${this.currentVersion.schemaVersion}`, + ); + } if (shouldLogDataReset) { this.log.info( `Can't upgrade from version ${storedVersion} to ${this.currentVersion}. Resetting database at ${this.dataDirectory}`, diff --git a/yarn-project/validator-ha-signer/src/db/lmdb.test.ts b/yarn-project/validator-ha-signer/src/db/lmdb.test.ts index a30e473a6298..fdba9399fce6 100644 --- a/yarn-project/validator-ha-signer/src/db/lmdb.test.ts +++ b/yarn-project/validator-ha-signer/src/db/lmdb.test.ts @@ -1,15 +1,16 @@ import { BlockNumber, CheckpointNumber, IndexWithinCheckpoint, SlotNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { TestDateProvider } from '@aztec/foundation/timer'; -import { type AztecLMDBStoreV2, openStoreAt, openTmpStore } from '@aztec/kv-store/lmdb-v2'; +import { type AztecLMDBStoreV2, createStore, openStoreAt, openTmpStore } from '@aztec/kv-store/lmdb-v2'; import { afterEach, beforeEach, describe, expect, it } from '@jest/globals'; import { mkdir, mkdtemp, rm } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; +import { createLocalSignerWithProtection } from '../factory.js'; import { LmdbSlashingProtectionDatabase } from './lmdb.js'; -import { DutyStatus, DutyType } from './types.js'; +import { DutyStatus, DutyType, type StoredDutyRecord } from './types.js'; describe('LmdbSlashingProtectionDatabase', () => { let store: AztecLMDBStoreV2; @@ -418,3 +419,104 @@ describe('LmdbSlashingProtectionDatabase - persistence across restarts', () => { } }); }); + +describe('LmdbSlashingProtectionDatabase - schema migration', () => { + const ROLLUP_ADDRESS = EthAddress.random(); + const VALIDATOR_ADDRESS = EthAddress.random(); + const SLOT = SlotNumber(100); + const BLOCK_NUMBER = BlockNumber(50); + const BLOCK_INDEX = IndexWithinCheckpoint(0); + const DUTY_TYPE = DutyType.BLOCK_PROPOSAL; + const MESSAGE_HASH = '0xdeadbeef'; + const NODE_ID = 'local'; + const SIGNATURE = '0xsignature'; + + type StoredDutyRecordV1 = Omit; + + let dataDir: string; + let dateProvider: TestDateProvider; + + const defaultParams = () => ({ + rollupAddress: ROLLUP_ADDRESS, + validatorAddress: VALIDATOR_ADDRESS, + slot: SLOT, + blockNumber: BLOCK_NUMBER, + checkpointNumber: CheckpointNumber(1), + blockIndexWithinCheckpoint: BLOCK_INDEX, + dutyType: DUTY_TYPE, + messageHash: MESSAGE_HASH, + nodeId: NODE_ID, + }); + + const createConfig = () => ({ + l1Contracts: { rollupAddress: ROLLUP_ADDRESS }, + nodeId: NODE_ID, + pollingIntervalMs: 100, + signingTimeoutMs: 3_000, + dataDirectory: dataDir, + dataStoreMapSizeKb: 1024 * 1024, + }); + + const seedSchemaVersion1Duty = async (record: StoredDutyRecordV1) => { + const store = await createStore('signing-protection', 1, createConfig()); + const duties = store.openMap('signing-protection-duties'); + await duties.set( + `${record.rollupAddress}:${record.validatorAddress}:${record.slot}:${record.dutyType}:${record.blockIndexWithinCheckpoint}`, + record, + ); + await store.close(); + }; + + beforeEach(async () => { + dataDir = await mkdtemp(join(tmpdir(), 'lmdb-slashing-migration-')); + await mkdir(dataDir, { recursive: true }); + dateProvider = new TestDateProvider(); + }); + + afterEach(async () => { + await rm(dataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + }); + + it('migrates schema 1 duty records without allowing duplicate signing', async () => { + await seedSchemaVersion1Duty({ + rollupAddress: ROLLUP_ADDRESS.toString(), + validatorAddress: VALIDATOR_ADDRESS.toString(), + slot: SLOT.toString(), + blockNumber: BLOCK_NUMBER.toString(), + blockIndexWithinCheckpoint: BLOCK_INDEX, + dutyType: DUTY_TYPE, + status: DutyStatus.SIGNED, + messageHash: MESSAGE_HASH, + signature: SIGNATURE, + nodeId: NODE_ID, + lockToken: 'legacy-lock-token', + startedAtMs: dateProvider.now(), + completedAtMs: dateProvider.now(), + }); + + const { db } = await createLocalSignerWithProtection(createConfig(), { dateProvider }); + try { + const result = await db.tryInsertOrGetExisting(defaultParams()); + + expect(result.isNew).toBe(false); + expect(result.record.status).toBe(DutyStatus.SIGNED); + expect(result.record.signature).toBe(SIGNATURE); + expect(result.record.checkpointNumber).toBe(CheckpointNumber(0)); + } finally { + await db.close(); + } + }); + + it('fails closed instead of resetting when the stored schema is newer', async () => { + const store = await createStore( + 'signing-protection', + LmdbSlashingProtectionDatabase.SCHEMA_VERSION + 1, + createConfig(), + ); + await store.close(); + + await expect(createLocalSignerWithProtection(createConfig(), { dateProvider })).rejects.toThrow( + `stored schema version ${LmdbSlashingProtectionDatabase.SCHEMA_VERSION + 1} is incompatible with expected schema version ${LmdbSlashingProtectionDatabase.SCHEMA_VERSION}`, + ); + }); +}); diff --git a/yarn-project/validator-ha-signer/src/db/lmdb.ts b/yarn-project/validator-ha-signer/src/db/lmdb.ts index 2b350585fd24..5c95f53b0bd7 100644 --- a/yarn-project/validator-ha-signer/src/db/lmdb.ts +++ b/yarn-project/validator-ha-signer/src/db/lmdb.ts @@ -13,6 +13,7 @@ import { EthAddress } from '@aztec/foundation/eth-address'; import { type Logger, createLogger } from '@aztec/foundation/log'; import type { DateProvider } from '@aztec/foundation/timer'; import type { AztecAsyncKVStore, AztecAsyncMap } from '@aztec/kv-store'; +import { openStoreAt } from '@aztec/kv-store/lmdb-v2'; import type { SlashingProtectionDatabase, TryInsertOrGetResult } from '../types.js'; import { @@ -24,6 +25,48 @@ import { recordFromFields, } from './types.js'; +const DUTIES_MAP_NAME = 'signing-protection-duties'; +const LEGACY_CHECKPOINT_NUMBER = '0'; + +type StoredDutyRecordV1 = Omit & { checkpointNumber?: undefined }; +type MigratableStoredDutyRecord = StoredDutyRecord | StoredDutyRecordV1; + +function needsCheckpointNumberMigration(record: MigratableStoredDutyRecord): record is StoredDutyRecordV1 { + return record.checkpointNumber === undefined; +} + +/** + * Migrates local slashing-protection duties from schema 1 to schema 2. + */ +export async function migrateLmdbSlashingProtectionDatabase( + dataDirectory: string, + currentVersion: number, + latestVersion: number, + dbMapSizeKb?: number, +): Promise { + if (currentVersion !== 1 || latestVersion !== LmdbSlashingProtectionDatabase.SCHEMA_VERSION) { + throw new Error(`Unsupported LMDB slashing-protection migration ${currentVersion} -> ${latestVersion}`); + } + + const store = await openStoreAt(dataDirectory, dbMapSizeKb); + try { + const duties = store.openMap(DUTIES_MAP_NAME); + const migratedRecords: { key: string; value: StoredDutyRecord }[] = []; + + for await (const [key, record] of duties.entriesAsync()) { + if (needsCheckpointNumberMigration(record)) { + migratedRecords.push({ key, value: { ...record, checkpointNumber: LEGACY_CHECKPOINT_NUMBER } }); + } + } + + if (migratedRecords.length > 0) { + await duties.setMany(migratedRecords); + } + } finally { + await store.close(); + } +} + function dutyKey( rollupAddress: string, validatorAddress: string, @@ -51,7 +94,7 @@ export class LmdbSlashingProtectionDatabase implements SlashingProtectionDatabas private readonly dateProvider: DateProvider, ) { this.log = createLogger('slashing-protection:lmdb'); - this.duties = store.openMap('signing-protection-duties'); + this.duties = store.openMap(DUTIES_MAP_NAME); } /** diff --git a/yarn-project/validator-ha-signer/src/factory.ts b/yarn-project/validator-ha-signer/src/factory.ts index 7818126cfb2d..e04ca013b34f 100644 --- a/yarn-project/validator-ha-signer/src/factory.ts +++ b/yarn-project/validator-ha-signer/src/factory.ts @@ -8,7 +8,7 @@ import { getTelemetryClient } from '@aztec/telemetry-client'; import { Pool } from 'pg'; -import { LmdbSlashingProtectionDatabase } from './db/lmdb.js'; +import { LmdbSlashingProtectionDatabase, migrateLmdbSlashingProtectionDatabase } from './db/lmdb.js'; import { PostgresSlashingProtectionDatabase } from './db/postgres.js'; import { HASignerMetrics } from './metrics.js'; import type { CreateHASignerDeps, CreateLocalSignerWithProtectionDeps, SlashingProtectionDatabase } from './types.js'; @@ -119,11 +119,26 @@ export async function createLocalSignerWithProtection( const telemetryClient = deps?.telemetryClient ?? getTelemetryClient(); const dateProvider = deps?.dateProvider ?? new DateProvider(); - const kvStore = await createStore('signing-protection', LmdbSlashingProtectionDatabase.SCHEMA_VERSION, { - dataDirectory: config.dataDirectory, - dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb, - l1Contracts: config.l1Contracts, - }); + const kvStore = await createStore( + 'signing-protection', + LmdbSlashingProtectionDatabase.SCHEMA_VERSION, + { + dataDirectory: config.dataDirectory, + dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb, + l1Contracts: config.l1Contracts, + }, + undefined, + { + onUpgrade: (dataDirectory, currentVersion, latestVersion) => + migrateLmdbSlashingProtectionDatabase( + dataDirectory, + currentVersion, + latestVersion, + config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb, + ), + schemaVersionMismatchPolicy: 'throw', + }, + ); const db = new LmdbSlashingProtectionDatabase(kvStore, dateProvider); From 61a23c958c95828eb2985efb70247456308845e5 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 13:42:43 -0300 Subject: [PATCH 35/55] test(e2e): enable pipelining on p2p tests (#23070) Toggle pipelining on all e2e p2p tests --- ...asted_invalid_block_proposal_slash.test.ts | 2 + .../e2e_p2p/data_withholding_slash.test.ts | 7 +++ .../duplicate_attestation_slash.test.ts | 2 + .../e2e_p2p/duplicate_proposal_slash.test.ts | 46 ++++++++++++++----- .../src/e2e_p2p/gossip_network.test.ts | 2 + .../e2e_p2p/gossip_network_no_cheat.test.ts | 2 + .../src/e2e_p2p/inactivity_slash_test.ts | 2 + ...vity_slash_with_consecutive_epochs.test.ts | 2 +- ...tiple_validators_sentinel.parallel.test.ts | 15 ++++++ .../e2e_p2p/preferred_gossip_network.test.ts | 3 ++ .../src/e2e_p2p/rediscovery.test.ts | 2 + .../end-to-end/src/e2e_p2p/reex.test.ts | 2 + yarn-project/end-to-end/src/e2e_p2p/shared.ts | 5 +- .../src/e2e_p2p/slash_veto_demo.test.ts | 2 + .../upgrade_governance_proposer.test.ts | 3 ++ .../e2e_p2p/valid_epoch_pruned_slash.test.ts | 2 + .../src/e2e_p2p/validators_sentinel.test.ts | 16 +++++++ 17 files changed, 101 insertions(+), 14 deletions(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts index 8dc7dd33c2b9..02dd223b3b86 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/broadcasted_invalid_block_proposal_slash.test.ts @@ -62,6 +62,8 @@ describe('e2e_p2p_broadcasted_invalid_block_proposal_slash', () => { ethereumSlotDuration: ETHEREUM_SLOT_DURATION, aztecSlotDuration: AZTEC_SLOT_DURATION, aztecTargetCommitteeSize: COMMITTEE_SIZE, + enableProposerPipelining: true, + inboxLag: 2, aztecProofSubmissionEpochs: 1024, // effectively do not reorg slashInactivityConsecutiveEpochThreshold: 32, // effectively do not slash for inactivity minTxsPerBlock: 0, // always be building diff --git a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts index 84171f697fb2..bd63a95eda13 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts @@ -73,6 +73,8 @@ describe('e2e_p2p_data_withholding_slash', () => { slashAmountLarge: slashingUnit * 3n, slashSelfAllowed: true, minTxsPerBlock: 0, + enableProposerPipelining: true, + inboxLag: 2, }, }); @@ -165,6 +167,11 @@ describe('e2e_p2p_data_withholding_slash', () => { // Re-create the nodes. // ASSUMING they sync in the middle of the epoch, they will "see" the reorg, and try to slash. + // Reset minTxsPerBlock to 0 so re-created validators build empty checkpoints. Under proposer + // pipelining, the vote-offenses signature is bound to the target slot and the multicall is only + // delayed to the target slot start when a checkpoint is being proposed; without a proposal, + // votes would mine in the current wall-clock slot, causing the EIP-712 signature verification to fail. + t.ctx.aztecNodeConfig.minTxsPerBlock = 0; t.logger.warn('Re-creating nodes'); nodes = await createNodes( t.ctx.aztecNodeConfig, diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts index 0f6cd6c0f9c3..ce4a8f706999 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_attestation_slash.test.ts @@ -89,6 +89,8 @@ describe('e2e_p2p_duplicate_attestation_slash', () => { slashDuplicateProposalPenalty: slashingUnit, slashDuplicateAttestationPenalty: slashingUnit, slashingOffsetInRounds: 1, + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts index 51b8e4b4083f..697e2f539039 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/duplicate_proposal_slash.test.ts @@ -2,6 +2,7 @@ import type { AztecNodeService } from '@aztec/aztec-node'; import type { TestAztecNodeService } from '@aztec/aztec-node/test'; import { EthAddress } from '@aztec/aztec.js/addresses'; import { EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; import { bufferToHex } from '@aztec/foundation/string'; import { OffenseType } from '@aztec/slasher'; import { TopicType } from '@aztec/stdlib/p2p'; @@ -79,6 +80,8 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { blockDurationMs: BLOCK_DURATION * 1000, slashDuplicateProposalPenalty: slashingUnit, slashingOffsetInRounds: 1, + enableProposerPipelining: true, + inboxLag: 2, }, }); @@ -220,28 +223,47 @@ describe('e2e_p2p_duplicate_proposal_slash', () => { t.logger.warn('Starting all sequencers'); await Promise.all(nodes.map(n => n.getSequencer()!.start())); - // Now warp to the target epoch — sequencers are already running - t.logger.warn(`Advancing to target epoch ${targetEpoch}`); - await t.ctx.cheatCodes.rollup.advanceToEpoch(targetEpoch); + // Now warp to one slot before the target epoch — sequencers are already running. + // Under proposer pipelining, the malicious proposers begin building for the first + // slot of the target epoch one slot earlier; warping to the start of the epoch + // would force both AVM-heavy duplicate proposals to serialize past the slot + // boundary, after which honest receivers reject them as late. + t.logger.warn(`Advancing to one slot before target epoch ${targetEpoch}`); + await t.ctx.cheatCodes.rollup.advanceToEpoch(targetEpoch, { offset: -AZTEC_SLOT_DURATION }); - // Wait for offense to be detected - // The honest nodes should detect the duplicate proposal from the malicious validator + // Wait for offense to be detected. Under proposer pipelining, checkpoint proposals are broadcast + // at the slot boundary while the receivers' wall clocks may have already advanced past the build + // slot — when that happens, honest nodes reject the gossip with "invalid slot number" before + // duplicate detection runs, so DUPLICATE_PROPOSAL is only observed by whichever node managed to + // process both proposals while still in the build slot (often the other malicious node, since + // they receive each other's broadcasts immediately). We therefore collect offenses from every + // node in the network and assert that at least one of them recorded the duplicate proposal. t.logger.warn('Waiting for duplicate proposal offense to be detected...'); - const offenses = await awaitOffenseDetected({ + await awaitOffenseDetected({ epochDuration: t.ctx.aztecNodeConfig.aztecEpochDuration, logger: t.logger, - nodeAdmin: honestNode1, // Use honest node to check for offenses + nodeAdmin: honestNode1, slashingRoundSize, waitUntilOffenseCount: 1, timeoutSeconds: AZTEC_SLOT_DURATION * 16, }); - t.logger.warn(`Collected offenses`, { offenses }); + // Poll every node for DUPLICATE_PROPOSAL offenses, retrying briefly so any node that detected + // the duplicate after the initial offense was collected has time to flush it through the + // slasher's offenses-collector. + const proposalOffenses = await retryUntil( + async () => { + const allOffenses = (await Promise.all(nodes.map(n => n.getSlashOffenses('all')))).flat(); + const filtered = allOffenses.filter(o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL); + if (filtered.length > 0) { + return filtered; + } + }, + 'duplicate proposal offense', + AZTEC_SLOT_DURATION * 4, + ); - // Filter to only DUPLICATE_PROPOSAL offenses. The two malicious nodes sharing the same key - // will also each self-attest to their own (different) checkpoint proposals, which causes honest - // nodes to detect a DUPLICATE_ATTESTATION as well. We only care about proposals here. - const proposalOffenses = offenses.filter(o => o.offenseType === OffenseType.DUPLICATE_PROPOSAL); + t.logger.warn(`Collected duplicate proposal offenses`, { proposalOffenses }); expect(proposalOffenses.length).toBeGreaterThan(0); for (const offense of proposalOffenses) { expect(offense.validator.toString()).toEqual(maliciousValidatorAddress.toString()); diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts index 2ff865324b5f..aad5e52e0631 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network.test.ts @@ -69,6 +69,8 @@ describe('e2e_p2p_network', () => { slashingRoundSizeInEpochs: 2, slashingQuorum: 5, listenAddress: '127.0.0.1', + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts index 242a2fef3e16..9fa76164243d 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/gossip_network_no_cheat.test.ts @@ -68,6 +68,8 @@ describe('e2e_p2p_network', () => { // Without this, no blocks are built until txs arrive, and a failed checkpoint during tx // submission causes block pruning that invalidates tx references. minTxsPerBlock: 0, + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts index efcc6d4833a5..20fff9244f6d 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_test.ts @@ -58,6 +58,8 @@ export class P2PInactivityTest { basePort: BOOT_NODE_UDP_PORT, startProverNode: true, initialConfig: { + enableProposerPipelining: true, + inboxLag: 2, anvilSlotsInAnEpoch: 4, proverNodeConfig: { proverNodeEpochProvingDelayMs: AZTEC_SLOT_DURATION * 1000 }, aztecTargetCommitteeSize: COMMITTEE_SIZE, diff --git a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_with_consecutive_epochs.test.ts b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_with_consecutive_epochs.test.ts index 93513f4b879f..78af77bc07d6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_with_consecutive_epochs.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/inactivity_slash_with_consecutive_epochs.test.ts @@ -55,7 +55,7 @@ describe('e2e_p2p_inactivity_slash_with_consecutive_epochs', () => { return offenses.length > 0 ? offenses : undefined; }, 'slash offenses', - slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 2, + slashInactivityConsecutiveEpochThreshold * aztecEpochDuration * aztecSlotDuration * 4, ); expect(unique(offenses.map(o => o.validator.toString()))).toEqual([offlineValidator.toString()]); expect(unique(offenses.map(o => o.offenseType))).toEqual([OffenseType.INACTIVITY]); diff --git a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts index e90f0a918d46..b270212db706 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/multiple_validators_sentinel.parallel.test.ts @@ -58,6 +58,8 @@ describe('e2e_p2p_multiple_validators_sentinel', () => { slashingRoundSizeInEpochs: 2, sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable + enableProposerPipelining: true, + inboxLag: 2, }, }); @@ -103,7 +105,20 @@ describe('e2e_p2p_multiple_validators_sentinel', () => { }); it('collects attestations for all validators on a node', async () => { + // Ensure all nodes see each other, especially the sentinel, before starting slot counting + await t.waitForP2PMeshConnectivity([...nodes, sentinel]); + + // Wait until validator nodes have advanced past their first proposed slot so that the + // pipelining warm-up period (where some attestations may be missed) is behind us. await t.monitor.run(); + const warmupSlot = Number(t.monitor.l2SlotNumber) + 1; + t.logger.info(`Waiting for warmup slot ${warmupSlot} before establishing initial slot`); + await retryUntil( + async () => (await t.monitor.run()).l2SlotNumber >= warmupSlot, + 'warmup slot', + AZTEC_SLOT_DURATION * 3, + ); + const { checkpointNumber: initialBlock, l2SlotNumber: initialSlot } = t.monitor; const timeout = AZTEC_SLOT_DURATION * SLOT_COUNT * 4; diff --git a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts index 03bd23ebb57e..409d8580e684 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/preferred_gossip_network.test.ts @@ -142,6 +142,9 @@ describe('e2e_p2p_preferred_network', () => { p2pDisableStatusHandshake: false, // Just for testing be aggressive here, don't allow any auth handshake failures p2pMaxFailedAuthAttemptsAllowed: 0, + minTxsPerBlock: 0, + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/rediscovery.test.ts b/yarn-project/end-to-end/src/e2e_p2p/rediscovery.test.ts index 527750cc83e8..e4ddef8157f5 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/rediscovery.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/rediscovery.test.ts @@ -35,6 +35,8 @@ describe('e2e_p2p_rediscovery', () => { ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, aztecSlotDuration: 24, listenAddress: '127.0.0.1', + enableProposerPipelining: true, + inboxLag: 2, }, }); await t.setup(); diff --git a/yarn-project/end-to-end/src/e2e_p2p/reex.test.ts b/yarn-project/end-to-end/src/e2e_p2p/reex.test.ts index 47f81edb57d4..aa1369606450 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/reex.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/reex.test.ts @@ -47,6 +47,8 @@ describe('e2e_p2p_reex', () => { txTimeoutMs: 30_000, listenAddress: '127.0.0.1', aztecProofSubmissionEpochs: 1024, // effectively do not reorg + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/shared.ts b/yarn-project/end-to-end/src/e2e_p2p/shared.ts index 2b454d59845e..4c46ec6e5c57 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/shared.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/shared.ts @@ -281,7 +281,10 @@ export async function awaitCommitteeKicked({ expect(attesterInfo.status).toEqual(1); // Validating } - const timeout = slashingRoundSize * 2 * aztecSlotDuration + 30; + // Allow up to four round-lengths so that under proposer pipelining, where individual rounds + // sometimes fail to gather quorum because parts of the committee miss votes due to chain-state + // races, we still see a later round execute the slash. + const timeout = slashingRoundSize * 4 * aztecSlotDuration + 30; logger.info(`Waiting for slash to be executed (timeout ${timeout}s)`); await awaitProposalExecution(slashingProposer, timeout, logger); diff --git a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts index f6ffbe12c371..c7fa39824224 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/slash_veto_demo.test.ts @@ -76,6 +76,8 @@ describe('veto slash', () => { aztecProofSubmissionEpochs: 1024, // effectively do not reorg listenAddress: '127.0.0.1', minTxsPerBlock: 0, + enableProposerPipelining: true, + inboxLag: 2, aztecEpochDuration: EPOCH_DURATION, sentinelEnabled: true, slashSelfAllowed: true, diff --git a/yarn-project/end-to-end/src/e2e_p2p/upgrade_governance_proposer.test.ts b/yarn-project/end-to-end/src/e2e_p2p/upgrade_governance_proposer.test.ts index f4b5fa4d3acb..205899701ace 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/upgrade_governance_proposer.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/upgrade_governance_proposer.test.ts @@ -54,6 +54,9 @@ describe('e2e_p2p_governance_proposer', () => { governanceProposerRoundSize: 10, activationThreshold: 10n ** 22n, ejectionThreshold: 5n ** 22n, + enableProposerPipelining: true, + inboxLag: 2, + minTxsPerBlock: 0, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts index e0245c2d6e2e..c9129bf2f3d6 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/valid_epoch_pruned_slash.test.ts @@ -69,6 +69,8 @@ describe('e2e_p2p_valid_epoch_pruned_slash', () => { slashAmountMedium: slashingUnit * 2n, slashAmountLarge: slashingUnit * 3n, aztecTargetCommitteeSize: COMMITTEE_SIZE, + enableProposerPipelining: true, + inboxLag: 2, }, }); diff --git a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts index 4d0b38d533dd..189404f772a9 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/validators_sentinel.test.ts @@ -50,6 +50,8 @@ describe('e2e_p2p_validators_sentinel', () => { slashingRoundSizeInEpochs: 2, sentinelEnabled: true, slashInactivityPenalty: 0n, // Set to 0 to disable + enableProposerPipelining: true, + inboxLag: 2, }, }); @@ -85,6 +87,20 @@ describe('e2e_p2p_validators_sentinel', () => { describe('with an offline validator', () => { let stats: ValidatorsStats; beforeAll(async () => { + // Ensure all running validator nodes see each other before starting block counting. + await t.waitForP2PMeshConnectivity(nodes); + + // Wait until validator nodes have advanced past their first proposed slot so that the + // pipelining warm-up period (where some attestations may be missed) is behind us. + await t.monitor.run(); + const warmupSlot = Number(t.monitor.l2SlotNumber) + 1; + t.logger.info(`Waiting for warmup slot ${warmupSlot} before establishing initial block`); + await retryUntil( + async () => (await t.monitor.run()).l2SlotNumber >= warmupSlot, + 'warmup slot', + AZTEC_SLOT_DURATION * 3, + ); + const currentBlock = t.monitor.checkpointNumber; const blockCount = BLOCK_COUNT; const timeout = AZTEC_SLOT_DURATION * blockCount * 8; From 4d8791a9c2529f12a31b37dda644e7b1741f14b2 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 13:53:14 -0300 Subject: [PATCH 36/55] fix(archiver): move L2 tips cache refresh out of write transactions (#23110) Move every `l2TipsCache.refresh()` call in `ArchiverDataStoreUpdater` out of the surrounding `db.transactionAsync` callback and into the post-commit code path. This addresses two issues with the previous in-transaction refresh: 1. **Mid-tx visibility.** `L2TipsCache.refresh()` reassigns its internal `#tipsPromise` synchronously, which was observable to other callers before LMDB committed. A concurrent reader calling `getL2Tips()` after the reassignment but before commit would pick up a promise loaded against in-flight tx state, while a sibling read on the store directly outside the tx still saw pre-commit state. 2. **No rollback on tx abort.** If the LMDB transaction threw or aborted, the cache was already replaced with a promise loaded against in-flight writes that would never commit. Future readers saw a cache reflecting rolled-back state. Refresh now runs after the writer transaction has fully committed, so it loads from the committed store and is never replaced when the writer aborts. ## Notes - This intentionally leaves a narrow JS-side race window between LMDB commit returning and `refresh()` finishing its `loadFromStore`. --- .../src/modules/data_store_updater.test.ts | 28 +++++++++++++++++++ .../src/modules/data_store_updater.ts | 18 ++++++------ .../archiver/src/store/l2_tips_cache.ts | 5 ++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/yarn-project/archiver/src/modules/data_store_updater.test.ts b/yarn-project/archiver/src/modules/data_store_updater.test.ts index 3722d6eb8654..6316a16d5198 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.test.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.test.ts @@ -7,12 +7,15 @@ import { AztecAddress } from '@aztec/stdlib/aztec-address'; import { L2Block } from '@aztec/stdlib/block'; import { ContractClassLog, PrivateLog } from '@aztec/stdlib/logs'; import '@aztec/stdlib/testing/jest'; +import { BlockHeader } from '@aztec/stdlib/tx'; +import { jest } from '@jest/globals'; import { readFileSync } from 'fs'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; import { type ArchiverDataStores, createArchiverDataStores } from '../store/data_stores.js'; +import { L2TipsCache } from '../store/l2_tips_cache.js'; import { makeCheckpoint, makePublishedCheckpoint } from '../test/mock_structs.js'; import { ArchiverDataStoreUpdater } from './data_store_updater.js'; @@ -215,4 +218,29 @@ describe('ArchiverDataStoreUpdater', () => { expect(publicLogsAfter.logs.length).toBe(0); }); }); + + describe('l2 tips cache refresh', () => { + it('does not refresh the cache when the writer transaction aborts', async () => { + const initialBlockHash = await BlockHeader.empty().hash(); + const tipsCache = new L2TipsCache(store.blocks, initialBlockHash); + const updaterWithCache = new ArchiverDataStoreUpdater(store, tipsCache); + + const tipsBefore = await tipsCache.getL2Tips(); + + const block = await L2Block.random(BlockNumber(1), { + checkpointNumber: CheckpointNumber(1), + indexWithinCheckpoint: IndexWithinCheckpoint(0), + }); + + const failure = new Error('forced failure inside writer transaction'); + const addProposedBlockSpy = jest.spyOn(store.blocks, 'addProposedBlock').mockRejectedValueOnce(failure); + + await expect(updaterWithCache.addProposedBlock(block)).rejects.toBe(failure); + + const tipsAfter = await tipsCache.getL2Tips(); + expect(tipsAfter).toEqual(tipsBefore); + + addProposedBlockSpy.mockRestore(); + }); + }); }); diff --git a/yarn-project/archiver/src/modules/data_store_updater.ts b/yarn-project/archiver/src/modules/data_store_updater.ts index e1b50131d589..2fb394c497a4 100644 --- a/yarn-project/archiver/src/modules/data_store_updater.ts +++ b/yarn-project/archiver/src/modules/data_store_updater.ts @@ -74,9 +74,9 @@ export class ArchiverDataStoreUpdater { this.addContractDataToDb(block), ]); - await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); + await this.l2TipsCache?.refresh(); return result; } @@ -144,18 +144,17 @@ export class ArchiverDataStoreUpdater { : undefined, ]); - await this.l2TipsCache?.refresh(); return { prunedBlocks, lastAlreadyInsertedBlockNumber }; }); + await this.l2TipsCache?.refresh(); return result; } public async addProposedCheckpoint(proposedCheckpoint: ProposedCheckpointInput) { const result = await this.stores.db.transactionAsync(async () => { await this.stores.blocks.addProposedCheckpoint(proposedCheckpoint); - await this.l2TipsCache?.refresh(); }); - + await this.l2TipsCache?.refresh(); return result; } @@ -256,9 +255,9 @@ export class ArchiverDataStoreUpdater { // Clear all pending proposed checkpoints since their blocks have been pruned await this.stores.blocks.deleteProposedCheckpoints(); - await this.l2TipsCache?.refresh(); return result; }); + await this.l2TipsCache?.refresh(); return result; } @@ -289,7 +288,7 @@ export class ArchiverDataStoreUpdater { * @returns True if the operation is successful. */ public async removeCheckpointsAfter(checkpointNumber: CheckpointNumber): Promise { - return await this.stores.db.transactionAsync(async () => { + const result = await this.stores.db.transactionAsync(async () => { const { blocksRemoved = [] } = await this.stores.blocks.removeCheckpointsAfter(checkpointNumber); const opResults = await Promise.all([ @@ -300,9 +299,10 @@ export class ArchiverDataStoreUpdater { this.stores.logs.deleteLogs(blocksRemoved), ]); - await this.l2TipsCache?.refresh(); return opResults.every(Boolean); }); + await this.l2TipsCache?.refresh(); + return result; } /** @@ -312,8 +312,8 @@ export class ArchiverDataStoreUpdater { public async setProvenCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setProvenCheckpointNumber(checkpointNumber); - await this.l2TipsCache?.refresh(); }); + await this.l2TipsCache?.refresh(); } /** @@ -323,8 +323,8 @@ export class ArchiverDataStoreUpdater { public async setFinalizedCheckpointNumber(checkpointNumber: CheckpointNumber): Promise { await this.stores.db.transactionAsync(async () => { await this.stores.blocks.setFinalizedCheckpointNumber(checkpointNumber); - await this.l2TipsCache?.refresh(); }); + await this.l2TipsCache?.refresh(); } /** Extracts and stores contract data from a single block. */ diff --git a/yarn-project/archiver/src/store/l2_tips_cache.ts b/yarn-project/archiver/src/store/l2_tips_cache.ts index 21c4b08f47d0..bc69983fc722 100644 --- a/yarn-project/archiver/src/store/l2_tips_cache.ts +++ b/yarn-project/archiver/src/store/l2_tips_cache.ts @@ -13,7 +13,8 @@ import type { BlockStore } from './block_store.js'; /** * In-memory cache for L2 chain tips (proposed, checkpointed, proven, finalized). * Populated from the BlockStore on first access, then kept up-to-date by the ArchiverDataStoreUpdater. - * Refresh calls should happen within the store transaction that mutates block data to ensure consistency. + * Refresh calls should happen *after* the store transaction that mutates block data has committed, + * so the cache loads from committed state and is never replaced if the writer aborts. */ export class L2TipsCache { #tipsPromise: Promise | undefined; @@ -34,7 +35,7 @@ export class L2TipsCache { return (this.#tipsPromise ??= this.loadFromStore()); } - /** Reloads the L2 tips from the block store. Should be called within the store transaction that mutates data. */ + /** Reloads the L2 tips from the block store. Should be called after the writer transaction has committed. */ public async refresh(): Promise { this.#tipsPromise = this.loadFromStore(); await this.#tipsPromise; From 30640dfecd6baaab795a5c51bced4242c33e2825 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 17:12:38 -0300 Subject: [PATCH 37/55] test(e2e): fix data_withholding_slash flake by freezing L1 across restart (#23162) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Motivation `e2e_p2p_data_withholding_slash` was flaky because L1 raced past the epoch-8 prune deadline (`aztecProofSubmissionEpochs=0` makes the deadline ~32s after slot 17) while we stopped, wiped, and recreated the 4 validators (~28s). The recreated archivers detected the prune during their initial L1 sync and emitted `L2PruneUnproven` for epoch 8 with the original tx-carrying block, but `EpochPruneWatcher.start()` is only invoked inside `void archiver.waitForInitialSync().then(...)` in `aztec-node/server.ts`, so the listener wasn't attached yet and the event dropped silently. The recreated validators then built an empty epoch 10 on top of genesis which pruned cleanly later, producing 4 `VALID_EPOCH_PRUNED` offenses instead of the expected 4 `DATA_WITHHOLDING`. ## Approach Pause anvil block production between `removeInitialNode` and `stopNodes` so L1 stays inside epoch 8 across the recreate gap. The recreated archivers then ingest checkpoint 1 cleanly during initial sync (no prune fires, nothing to miss), `EpochPruneWatcher.start()` attaches its listener, and we resume L1 with an explicit warp + mine + interval restart so the deadline crossing is deterministic — the prune now fires while the watcher is live, producing `DATA_WITHHOLDING` for epoch 8 as the test expects. A `getCurrentEpoch < 9` assertion right after pausing fails fast if the timing window ever tightens further. ## Changes - **end-to-end (tests)**: in `data_withholding_slash.test.ts`, pause L1 mining after `removeInitialNode` and before `stopNodes`; resume after `waitForP2PMeshConnectivity` by warping to current wall-clock time, mining one L1 block, and restoring interval mining. Add a fail-fast assertion that we are still in epoch 8 when we pause. --- .../e2e_p2p/data_withholding_slash.test.ts | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts index bd63a95eda13..808b7222a1d0 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/data_withholding_slash.test.ts @@ -157,8 +157,36 @@ describe('e2e_p2p_data_withholding_slash', () => { t.logger.warn('L2 txs mined'); t.logger.warn('Stopping nodes'); + // removeInitialNode sends a dummy L1 tx and awaits its receipt to sync the + // dateProvider, so it must run while L1 mining is still active. await t.removeInitialNode(); - // Now stop the nodes, + + // Pause L1 block production while we tear down and recreate validators. With + // `aztecProofSubmissionEpochs=0`, epoch 8 becomes prunable as soon as epoch 9 begins + // (~32s after slot 17). The stop/wipe/recreate cycle takes longer than that, so L1 + // would otherwise race past the prune deadline before the recreated nodes come up. + // When that happens, the recreated archivers detect the prune during their initial + // sync (`handleEpochPrune` emits `L2PruneUnproven`), but the `EpochPruneWatcher` + // listener is only attached after `archiver.waitForInitialSync()` resolves + // (see `aztec-node/server.ts`), so the event is dropped and `DATA_WITHHOLDING` is + // never emitted. By freezing L1 here, the recreated archivers ingest checkpoint 1 + // cleanly during initial sync, the watcher starts and attaches its listener, and + // then we resume L1 below so the prune fires while the listener is live. + const ethCheatCodes = t.ctx.cheatCodes.eth; + await ethCheatCodes.setAutomine(false); + await ethCheatCodes.setIntervalMining(0); + + // Fail fast if we paused too late — i.e. if L1 already crossed into epoch 9 before + // we got here. In that case the recreated nodes would still see the prune during + // initial sync and the test would flake exactly the same way. + const epochAtPause = await rollup.getCurrentEpoch(); + expect(Number(epochAtPause)).toBeLessThan(9); + + // Now stop the validator nodes. With L1 paused, any in-flight L1 submissions from + // the validator sequencers would hang `sequencer.stop()` (it awaits pending L1 + // submissions). Since `minTxsPerBlock=1` and no txs are queued for slot 18+, the + // sequencers don't submit further L1 transactions after the slot-17 checkpoint + // (already published before `waitForTx` returned), so this is safe. await t.stopNodes(nodes); // And remove the data directories (which forms the crux of the "attack") for (let i = 0; i < NUM_VALIDATORS; i++) { @@ -186,6 +214,16 @@ describe('e2e_p2p_data_withholding_slash', () => { // Wait for P2P mesh to be fully formed before proceeding await t.waitForP2PMeshConnectivity(nodes, NUM_VALIDATORS); + // Resume L1 block production. Warp L1 forward to current wall-clock time so the + // epoch-8 deadline is crossed immediately on the next L1 block, then re-enable + // interval mining. By now each recreated archiver has block 1 stored locally and + // its `EpochPruneWatcher` listener is attached, so the next sync iteration emits + // `L2PruneUnproven` for epoch 8 to a live listener → `DATA_WITHHOLDING`. + const resumeTimestamp = Math.floor(t.ctx.dateProvider.now() / 1000); + await ethCheatCodes.setNextBlockTimestamp(resumeTimestamp); + await ethCheatCodes.mine(); + await ethCheatCodes.setIntervalMining(t.ctx.aztecNodeConfig.ethereumSlotDuration); + const offenses = await awaitOffenseDetected({ epochDuration: t.ctx.aztecNodeConfig.aztecEpochDuration, logger: t.logger, From 02265b1777f0ead0defe3b6c8dd8882b30f234dd Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Mon, 11 May 2026 17:59:26 -0300 Subject: [PATCH 38/55] fix(validator): include proposed checkpoint out-hashes when validating checkpoint proposals (#23119) Extract the fix for `outHash`es added in #23073 in the proposer so that it can be reused in validators as well. Enables pipelining on `add_rollup` e2e p2p test, which was failing because of this bug. Also adds a bunch of logging, which was needed to track down the issue. Builds on top of #23073 --- .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 33 +++++- .../light/lightweight_checkpoint_builder.ts | 8 ++ .../src/orchestrator/orchestrator.ts | 16 ++- .../prover-node/src/job/epoch-proving-job.ts | 7 +- .../prover-node/src/prover-node-publisher.ts | 109 +++++++++++++++++- .../src/sequencer/checkpoint_proposal_job.ts | 61 +++------- yarn-project/stdlib/src/checkpoint/index.ts | 1 + .../previous_checkpoint_out_hashes.ts | 61 ++++++++++ .../src/rollup/block_rollup_public_inputs.ts | 13 +++ .../rollup/checkpoint_rollup_public_inputs.ts | 10 ++ .../validator-client/src/proposal_handler.ts | 31 +++-- 11 files changed, 287 insertions(+), 63 deletions(-) create mode 100644 yarn-project/stdlib/src/checkpoint/previous_checkpoint_out_hashes.ts diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index 28a9a5d105ae..5c4c468fbcdf 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -5,6 +5,7 @@ import { EthAddress } from '@aztec/aztec.js/addresses'; import { waitForProven } from '@aztec/aztec.js/contracts'; import { generateClaimSecret } from '@aztec/aztec.js/ethereum'; import { Fr } from '@aztec/aztec.js/fields'; +import { waitForL1ToL2MessageReady } from '@aztec/aztec.js/messaging'; import { RollupCheatCodes } from '@aztec/aztec/testing'; import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; @@ -13,6 +14,7 @@ import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses' import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { retryUntil } from '@aztec/foundation/retry'; import { sleep } from '@aztec/foundation/sleep'; import { GovernanceAbi, @@ -42,7 +44,6 @@ import { shouldCollectMetrics } from '../fixtures/fixtures.js'; import { sendL1ToL2Message } from '../fixtures/l1_to_l2_messaging.js'; import { ATTESTER_PRIVATE_KEYS_START_INDEX, createNodes, createProverNode } from '../fixtures/setup_p2p_test.js'; import { setupSharedBlobStorage } from '../fixtures/utils.js'; -import { waitForL1ToL2MessageSeen } from '../shared/wait_for_l1_to_l2_message.js'; import { TestWallet } from '../test-wallet/test_wallet.js'; import { P2PNetworkTest, SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES } from './p2p_network.js'; @@ -53,7 +54,7 @@ const BOOT_NODE_UDP_PORT = 4500; const DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'add-rollup-old-')); const DATA_DIR_NEW = fs.mkdtempSync(path.join(os.tmpdir(), 'add-rollup-new-')); -jest.setTimeout(1000 * 60 * 10); +jest.setTimeout(1000 * 60 * 20); /** * This test emulates the addition of a new rollup to the registry and tests that cross-chain messages work. @@ -80,6 +81,14 @@ describe('e2e_p2p_add_rollup', () => { ...SHORTENED_BLOCK_TIME_CONFIG_NO_PRUNES, listenAddress: '127.0.0.1', governanceProposerRoundSize: 10, + enableProposerPipelining: true, + // Allow validators to build empty checkpoints under pipelining so the chain keeps + // advancing while we wait for L1->L2 messages to land in the next checkpoint's inbox tree. + minTxsPerBlock: 0, + // Pipelining starts cycle for checkpoint N+1 during slot N, but the inbox tree for + // checkpoint N is only sealed when checkpoint N is published. inboxLag: 2 sources + // L1->L2 messages from checkpoint N-1 (already sealed), avoiding L1ToL2MessagesNotReadyError. + inboxLag: 2, }, startProverNode: false, // Start one later using p2p. }); @@ -307,7 +316,10 @@ describe('e2e_p2p_add_rollup', () => { }); const makeMessageConsumable = async (msgHash: Fr) => { - await waitForL1ToL2MessageSeen(node, msgHash, { timeoutSeconds: 10 }); + // Wait until the message is ready to be consumed (the rollup has reached the message's checkpoint). + // Using waitForL1ToL2MessageReady rather than isL1ToL2MessageSynced because with `inboxLag > 0` + // a synced message is not yet present in the latest checkpoint's inbox tree. + await waitForL1ToL2MessageReady(node, msgHash, { timeoutSeconds: 120 }); const { receipt } = await testContract.methods .create_l2_to_l1_message_arbitrary_recipient_private(contentOutFromRollup, ethRecipient) @@ -578,6 +590,21 @@ describe('e2e_p2p_add_rollup', () => { // The new rollup should have no checkpoints expect(await newRollup.getCheckpointNumber()).toBe(CheckpointNumber(0)); + // Wait for the new rollup to publish its first checkpoint AND for `nodes[0]` to have synced + // it locally, before the second bridging step. The bridge wallet uses + // `syncChainTip: 'checkpointed'`, which falls back to the genesis block when no checkpoint + // exists. After warping ~500 epochs forward, txs anchored at genesis would expire before + // being included. We poll the node's local view (not just the L1 rollup contract) so the PXE + // and the assertion observe the same chain state. + t.logger.info(`Waiting for new rollup to publish its first checkpoint`); + await retryUntil( + async () => Number(await nodes[0].getCheckpointNumber('checkpointed')) > 0, + 'newRollup first checkpoint synced by node', + 300, + 2, + ); + t.logger.info(`New rollup published its first checkpoint`); + // Bridge into and out of the new rollup to ensure that it works. await bridging( nodes[0], diff --git a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts index 1e72a75d7684..cf81946f3b39 100644 --- a/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts +++ b/yarn-project/prover-client/src/light/lightweight_checkpoint_builder.ts @@ -290,6 +290,14 @@ export class LightweightCheckpointBuilder { totalManaUsed, }); + this.logger.debug(`Completed checkpoint ${this.checkpointNumber}`, { + checkpointNumber: this.checkpointNumber, + headerHash: header.hash().toString(), + checkpointOutHash: checkpointOutHash.toString(), + numPreviousCheckpointOutHashes: this.previousCheckpointOutHashes.length, + ...header.toInspect(), + }); + return new Checkpoint(newArchive, header, blocks, this.checkpointNumber, this.feeAssetPriceModifier); } diff --git a/yarn-project/prover-client/src/orchestrator/orchestrator.ts b/yarn-project/prover-client/src/orchestrator/orchestrator.ts index a74e7bb452c2..f9068d6f7f1c 100644 --- a/yarn-project/prover-client/src/orchestrator/orchestrator.ts +++ b/yarn-project/prover-client/src/orchestrator/orchestrator.ts @@ -896,7 +896,11 @@ export class ProvingOrchestrator implements EpochProver { }, ), async result => { - this.logger.debug(`Completed ${rollupType} proof for block ${provingState.blockNumber}`); + this.logger.debug(`Completed ${rollupType} proof for block ${provingState.blockNumber}`, { + blockNumber: provingState.blockNumber, + checkpointIndex: provingState.parentCheckpoint.index, + ...result.inputs.toInspect(), + }); const leafLocation = provingState.setBlockRootRollupProof(result); const checkpointProvingState = provingState.parentCheckpoint; @@ -1015,6 +1019,11 @@ export class ProvingOrchestrator implements EpochProver { signal => this.prover.getBlockMergeRollupProof(inputs, signal, provingState.epochNumber), ), async result => { + this.logger.debug(`Completed block merge rollup proof for checkpoint ${provingState.index}`, { + checkpointIndex: provingState.index, + mergeLocation: location, + ...result.inputs.toInspect(), + }); provingState.setBlockMergeRollupProof(location, result); await this.checkAndEnqueueNextBlockMergeRollup(provingState, location); }, @@ -1067,7 +1076,10 @@ export class ProvingOrchestrator implements EpochProver { return; } - this.logger.debug(`Completed ${rollupType} proof for checkpoint ${provingState.index}.`); + this.logger.debug(`Completed ${rollupType} proof for checkpoint ${provingState.index}`, { + checkpointIndex: provingState.index, + ...result.inputs.toInspect(), + }); const leafLocation = provingState.setCheckpointRootRollupProof(result); const epochProvingState = provingState.parentEpoch; diff --git a/yarn-project/prover-node/src/job/epoch-proving-job.ts b/yarn-project/prover-node/src/job/epoch-proving-job.ts index 022fcc02289e..bb13d23c5569 100644 --- a/yarn-project/prover-node/src/job/epoch-proving-job.ts +++ b/yarn-project/prover-node/src/job/epoch-proving-job.ts @@ -191,11 +191,12 @@ export class EpochProvingJob implements Traceable { const previousHeader = previousBlockHeaders[checkpointIndex]; const l1ToL2Messages = this.getL1ToL2Messages(checkpoint); - this.log.verbose(`Starting processing checkpoint ${checkpoint.number}`, { + this.log.debug(`Starting processing checkpoint ${checkpoint.number}`, { number: checkpoint.number, checkpointHash: checkpoint.hash().toString(), - lastArchive: checkpoint.header.lastArchiveRoot, - previousHeader: previousHeader.hash(), + headerHash: checkpoint.header.hash().toString(), + numL1ToL2Messages: l1ToL2Messages.length, + previousBlockNumber: previousHeader.globalVariables.blockNumber, uuid: this.uuid, }); diff --git a/yarn-project/prover-node/src/prover-node-publisher.ts b/yarn-project/prover-node/src/prover-node-publisher.ts index b045e7cd912b..b1ec554523cb 100644 --- a/yarn-project/prover-node/src/prover-node-publisher.ts +++ b/yarn-project/prover-node/src/prover-node-publisher.ts @@ -203,10 +203,14 @@ export class ProverNodePublisher { const argsPublicInputs = [...publicInputs.toFields()]; if (!areArraysEqual(rollupPublicInputs, argsPublicInputs, (a, b) => a.equals(b))) { - const fmt = (inputs: Fr[] | readonly string[]) => inputs.map(x => x.toString()).join(', '); - throw new Error( - `Root rollup public inputs mismatch:\nRollup: ${fmt(rollupPublicInputs)}\nComputed:${fmt(argsPublicInputs)}`, - ); + throw await reportPublicInputsMismatch({ + rollupPublicInputs, + argsPublicInputs, + fromCheckpoint, + toCheckpoint, + rollupContract: this.rollupContract, + log: this.log, + }); } } @@ -372,3 +376,100 @@ export class ProverNodePublisher { }; } } + +/** + * Decodes a `Root rollup public inputs mismatch`, fetches the on-chain CheckpointLog for any + * mismatching `checkpointHeaderHashes[i]`, emits a structured error log, and returns a thrown-ready + * Error with a human-readable summary. + * + * Layout of `RootRollupPublicInputs.toFields()`: + * [0] previousArchiveRoot + * [1] endArchiveRoot + * [2] outHash + * [3 .. 3+N-1] checkpointHeaderHashes[i] for i in 0..N-1 (N = MAX_CHECKPOINTS_PER_EPOCH) + * [3+N .. 3+3N-1] fees[i] = (recipient, value) for i in 0..N-1 + * [3+3N .. 3+3N+4] EpochConstantData (chainId, version, vkTreeRoot, protocolContractsHash, proverId) + * [3+3N+5 ..] blobPublicInputs (FinalBlobAccumulator) + */ +async function reportPublicInputsMismatch(input: { + rollupPublicInputs: readonly Fr[]; + argsPublicInputs: readonly Fr[]; + fromCheckpoint: CheckpointNumber; + toCheckpoint: CheckpointNumber; + rollupContract: RollupContract; + log: Logger; +}): Promise { + const { rollupPublicInputs, argsPublicInputs, fromCheckpoint, toCheckpoint, rollupContract, log } = input; + const N = MAX_CHECKPOINTS_PER_EPOCH; + const constantsStart = 3 + 3 * N; + const blobStart = constantsStart + 5; + const constantLabels = ['chainId', 'version', 'vkTreeRoot', 'protocolContractsHash', 'proverId']; + + const diffs: { index: number; label: string; rollup: Fr; computed: Fr; checkpointIndex?: number }[] = []; + const len = Math.max(rollupPublicInputs.length, argsPublicInputs.length); + for (let i = 0; i < len; i++) { + const a = rollupPublicInputs[i] ?? Fr.ZERO; + const b = argsPublicInputs[i] ?? Fr.ZERO; + if (a.equals(b)) { + continue; + } + let label: string; + let checkpointIndex: number | undefined; + if (i === 0) { + label = 'previousArchiveRoot'; + } else if (i === 1) { + label = 'endArchiveRoot'; + } else if (i === 2) { + label = 'outHash'; + } else if (i < 3 + N) { + checkpointIndex = i - 3; + label = `checkpointHeaderHashes[${checkpointIndex}]`; + } else if (i < 3 + 3 * N) { + const feePairIndex = i - (3 + N); + const feeIndex = Math.floor(feePairIndex / 2); + const sub = feePairIndex % 2 === 0 ? 'recipient' : 'value'; + label = `fees[${feeIndex}].${sub}`; + } else if (i < blobStart) { + label = `constants.${constantLabels[i - constantsStart]}`; + } else { + label = `blobPublicInputs[${i - blobStart}]`; + } + diffs.push({ index: i, label, rollup: a, computed: b, checkpointIndex }); + } + + // For each mismatching checkpointHeaderHash, fetch the L1 CheckpointLog so the operator can + // see what was published on-chain alongside the prover's recomputed hash. + const onChainCheckpoints = await Promise.all( + diffs + .filter(d => d.checkpointIndex !== undefined) + .map(async d => { + const checkpointNumber = CheckpointNumber(fromCheckpoint + d.checkpointIndex!); + try { + const cp = await rollupContract.getCheckpoint(checkpointNumber); + return { checkpointIndex: d.checkpointIndex!, checkpointNumber, headerHash: cp.headerHash.toString() }; + } catch (err) { + return { checkpointIndex: d.checkpointIndex!, checkpointNumber, error: (err as Error).message }; + } + }), + ); + + log.error(`Root rollup public inputs mismatch`, undefined, { + fromCheckpoint, + toCheckpoint, + numDiffs: diffs.length, + diffs: diffs.map(d => ({ + index: d.index, + label: d.label, + rollup: d.rollup.toString(), + computed: d.computed.toString(), + })), + onChainCheckpoints, + }); + + const fmt = (inputs: readonly Fr[]) => inputs.map(x => x.toString()).join(', '); + const summary = diffs.map(d => `[${d.index} ${d.label}] L1=${d.rollup} prover=${d.computed}`).join('\n'); + return new Error( + `Root rollup public inputs mismatch (${diffs.length} fields differ):\n${summary}\n` + + `Rollup: ${fmt(rollupPublicInputs)}\nComputed:${fmt(argsPublicInputs)}`, + ); +} diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts index 49dc82291cf5..6c8af0a66a07 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -33,13 +33,13 @@ import { MaliciousCommitteeAttestationsAndSigners, type ValidateCheckpointResult, } from '@aztec/stdlib/block'; -import { type Checkpoint, type ProposedCheckpointData, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { - computeQuorum, - getEpochAtSlot, - getSlotStartBuildTimestamp, - getTimestampForSlot, -} from '@aztec/stdlib/epoch-helpers'; + type Checkpoint, + type ProposedCheckpointData, + getPreviousCheckpointOutHashes, + validateCheckpoint, +} from '@aztec/stdlib/checkpoint'; +import { computeQuorum, getSlotStartBuildTimestamp, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import { type BlockBuilderOptions, @@ -416,37 +416,6 @@ export class CheckpointProposalJob implements Traceable { } } - /** - * Returns the out hashes of all checkpoints in `targetEpoch` that precede the one being built. - * Under pipelining, the parent checkpoint may not be on L1 yet at build time, so the on-chain - * archiver is missing it; in that case we splice in the parent's `checkpointOutHash` from the - * proposed-checkpoint payload (when it is in the same epoch) so the resulting `epochOutHash` - * matches what other validators and L1 will compute once the parent lands. - */ - private async collectPreviousCheckpointOutHashes(): Promise { - const parentCheckpointNumber = CheckpointNumber(this.checkpointNumber - 1); - const checkpointed = (await this.l2BlockSource.getCheckpointsData({ epoch: this.targetEpoch })) - .filter(c => c.checkpointNumber < this.checkpointNumber) - .map(c => ({ checkpointNumber: c.checkpointNumber, checkpointOutHash: c.checkpointOutHash })); - - const shouldSpliceParent = - this.epochCache.isProposerPipeliningEnabled() && - this.proposedCheckpointData !== undefined && - this.proposedCheckpointData.checkpointNumber === parentCheckpointNumber && - getEpochAtSlot(this.proposedCheckpointData.header.slotNumber, this.epochCache.getL1Constants()) === - this.targetEpoch && - !checkpointed.some(c => c.checkpointNumber === parentCheckpointNumber); - - if (shouldSpliceParent) { - checkpointed.push({ - checkpointNumber: parentCheckpointNumber, - checkpointOutHash: this.proposedCheckpointData!.checkpointOutHash, - }); - } - - return checkpointed.sort((a, b) => a.checkpointNumber - b.checkpointNumber).map(c => c.checkpointOutHash); - } - /** * Waits for the parent checkpoint to land on L1 before submitting a pipelined checkpoint. * Polls until the archiver has synced L1 past the parent's slot, then verifies: @@ -620,11 +589,19 @@ export class CheckpointProposalJob implements Traceable { const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages); // Collect the out hashes of all the checkpoints before this one in the same epoch. - // Under pipelining, the parent checkpoint may not be on L1 yet at build time, so - // `getCheckpointsData` would miss it. Splice in the parent's checkpointOutHash from the - // proposed-checkpoint payload so the resulting `epochOutHash` matches what the validators - // (and L1) compute once the parent lands on L1. - const previousCheckpointOutHashes = await this.collectPreviousCheckpointOutHashes(); + // Under pipelining the parent checkpoint may not be on L1 yet at build time, so the helper + // splices in the parent's checkpointOutHash from the locally-known proposed checkpoint so + // the resulting `epochOutHash` matches what validators (and L1) compute once the parent + // lands on L1. + const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({ + blockSource: this.l2BlockSource, + epoch: this.targetEpoch, + checkpointNumber: this.checkpointNumber, + l1Constants: this.epochCache.getL1Constants(), + pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(), + proposedCheckpointData: this.proposedCheckpointData, + log: this.log, + }); // Anchor the modifier to the predicted parent fee header: L1 will apply it against // that, not against the latest published checkpoint (which lags by one under pipelining). diff --git a/yarn-project/stdlib/src/checkpoint/index.ts b/yarn-project/stdlib/src/checkpoint/index.ts index 3f3f57631e96..33dec7639e8c 100644 --- a/yarn-project/stdlib/src/checkpoint/index.ts +++ b/yarn-project/stdlib/src/checkpoint/index.ts @@ -2,5 +2,6 @@ export * from './checkpoint.js'; export * from './checkpoint_data.js'; export * from './checkpoint_info.js'; export * from './digest.js'; +export * from './previous_checkpoint_out_hashes.js'; export * from './published_checkpoint.js'; export * from './validate.js'; diff --git a/yarn-project/stdlib/src/checkpoint/previous_checkpoint_out_hashes.ts b/yarn-project/stdlib/src/checkpoint/previous_checkpoint_out_hashes.ts new file mode 100644 index 000000000000..870db05da8a7 --- /dev/null +++ b/yarn-project/stdlib/src/checkpoint/previous_checkpoint_out_hashes.ts @@ -0,0 +1,61 @@ +import { CheckpointNumber, type EpochNumber } from '@aztec/foundation/branded-types'; +import type { Fr } from '@aztec/foundation/curves/bn254'; +import type { Logger } from '@aztec/foundation/log'; + +import type { L2BlockSource } from '../block/l2_block_source.js'; +import { type L1RollupConstants, getEpochAtSlot } from '../epoch-helpers/index.js'; +import type { ProposedCheckpointData } from './checkpoint_data.js'; + +/** + * Returns the out hashes (in epoch order) of all checkpoints in `epoch` that precede + * `checkpointNumber`, used to compute the `epochOutHash` baked into that checkpoint's header. + * + * Under proposer pipelining the parent checkpoint may not be confirmed on L1 by the time the + * builder runs, so the on-chain archiver query (`getCheckpointsData`) is missing it and the + * resulting `epochOutHash` would diverge from what other validators (and L1) compute after the + * parent lands. To avoid that, the parent's `checkpointOutHash` is spliced in from the locally + * known proposed checkpoint when: + * - pipelining is enabled, + * - the archiver lookup is genuinely short (its last entry is not already cp `N-1`), + * - and the proposed cp `N-1` is in the same target epoch (otherwise we're at an epoch boundary + * and the previous-epoch cps must be excluded). + * + * Callers may either pass the already-loaded `proposedCheckpointData` (the proposer has it on + * hand) or leave it undefined, in which case it's fetched via `getProposedCheckpointData`. + */ +export async function getPreviousCheckpointOutHashes(input: { + blockSource: Pick; + epoch: EpochNumber; + checkpointNumber: CheckpointNumber; + l1Constants: Pick; + pipeliningEnabled: boolean; + proposedCheckpointData?: ProposedCheckpointData; + log?: Logger; +}): Promise { + const { blockSource, epoch, checkpointNumber, l1Constants, pipeliningEnabled, log } = input; + + const checkpointed = (await blockSource.getCheckpointsData({ epoch })) + .filter(c => c.checkpointNumber < checkpointNumber) + .sort((a, b) => a.checkpointNumber - b.checkpointNumber); + + if (!pipeliningEnabled || checkpointNumber === 0) { + return checkpointed.map(c => c.checkpointOutHash); + } + + const parentCheckpoint = CheckpointNumber(checkpointNumber - 1); + if (checkpointed.at(-1)?.checkpointNumber === parentCheckpoint) { + return checkpointed.map(c => c.checkpointOutHash); + } + + const proposedParent = + input.proposedCheckpointData ?? (await blockSource.getProposedCheckpointData({ number: parentCheckpoint })); + if (!proposedParent || proposedParent.checkpointNumber !== parentCheckpoint) { + return checkpointed.map(c => c.checkpointOutHash); + } + if (getEpochAtSlot(proposedParent.header.slotNumber, l1Constants) !== epoch) { + return checkpointed.map(c => c.checkpointOutHash); + } + + log?.debug(`Splicing pipelined parent cp ${parentCheckpoint} outHash for cp ${checkpointNumber}`); + return [...checkpointed.map(c => c.checkpointOutHash), proposedParent.checkpointOutHash]; +} diff --git a/yarn-project/stdlib/src/rollup/block_rollup_public_inputs.ts b/yarn-project/stdlib/src/rollup/block_rollup_public_inputs.ts index 4f1d528fc78d..12b6b1926e10 100644 --- a/yarn-project/stdlib/src/rollup/block_rollup_public_inputs.ts +++ b/yarn-project/stdlib/src/rollup/block_rollup_public_inputs.ts @@ -119,6 +119,19 @@ export class BlockRollupPublicInputs { return this.toBuffer(); } + toInspect() { + return { + previousArchiveRoot: this.previousArchive.root.toString(), + newArchiveRoot: this.newArchive.root.toString(), + blockHeadersHash: this.blockHeadersHash.toString(), + inHash: this.inHash.toString(), + outHash: this.outHash.toString(), + timestamp: this.timestamp.toString(), + accumulatedFees: this.accumulatedFees.toString(), + accumulatedManaUsed: this.accumulatedManaUsed.toString(), + }; + } + static get schema() { return bufferSchemaFor(BlockRollupPublicInputs); } diff --git a/yarn-project/stdlib/src/rollup/checkpoint_rollup_public_inputs.ts b/yarn-project/stdlib/src/rollup/checkpoint_rollup_public_inputs.ts index fe80522432ab..dc3049babbe8 100644 --- a/yarn-project/stdlib/src/rollup/checkpoint_rollup_public_inputs.ts +++ b/yarn-project/stdlib/src/rollup/checkpoint_rollup_public_inputs.ts @@ -101,6 +101,16 @@ export class CheckpointRollupPublicInputs { return this.toBuffer(); } + toInspect() { + return { + checkpointHeaderHash: this.checkpointHeaderHashes[0].toString(), + previousArchiveRoot: this.previousArchive.root.toString(), + newArchiveRoot: this.newArchive.root.toString(), + previousOutHashRoot: this.previousOutHash.root.toString(), + newOutHashRoot: this.newOutHash.root.toString(), + }; + } + /** Creates an instance from a hex string. */ static get schema() { return bufferSchemaFor(CheckpointRollupPublicInputs); diff --git a/yarn-project/validator-client/src/proposal_handler.ts b/yarn-project/validator-client/src/proposal_handler.ts index 1a6d3215ac2a..34106e89a7f5 100644 --- a/yarn-project/validator-client/src/proposal_handler.ts +++ b/yarn-project/validator-client/src/proposal_handler.ts @@ -20,7 +20,7 @@ import { DateProvider, Timer } from '@aztec/foundation/timer'; import type { P2P, PeerId } from '@aztec/p2p'; import { BlockProposalValidator } from '@aztec/p2p/msg_validators'; import type { BlockData, L2Block, L2BlockSink, L2BlockSource } from '@aztec/stdlib/block'; -import { validateCheckpoint } from '@aztec/stdlib/checkpoint'; +import { getPreviousCheckpointOutHashes, validateCheckpoint } from '@aztec/stdlib/checkpoint'; import { getEpochAtSlot, getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { Gas } from '@aztec/stdlib/gas'; import type { ITxProvider, ValidatorClientFullConfig, WorldStateSynchronizer } from '@aztec/stdlib/interfaces/server'; @@ -347,11 +347,18 @@ export class ProposalHandler { return { isValid: false, blockNumber, reason: 'txs_not_available' }; } - // Collect the out hashes of all the checkpoints before this one in the same epoch + // Collect the out hashes of all the checkpoints before this one in the same epoch. + // Mirror the proposer-side fallback: under pipelining the immediately-preceding cp may not + // yet be on L1, in which case the helper grafts the locally-known proposed cp's outHash. const epoch = getEpochAtSlot(slotNumber, this.epochCache.getL1Constants()); - const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsData({ epoch })) - .filter(c => c.checkpointNumber < checkpointNumber) - .map(c => c.checkpointOutHash); + const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({ + blockSource: this.blockSource, + epoch, + checkpointNumber, + l1Constants: this.epochCache.getL1Constants(), + pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(), + log: this.log, + }); // Try re-executing the transactions in the proposal if needed let reexecutionResult; @@ -854,11 +861,17 @@ export class ProposalHandler { // Get L1-to-L2 messages for this checkpoint const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(checkpointNumber); - // Collect the out hashes of all the checkpoints before this one in the same epoch + // Collect the out hashes of all the checkpoints before this one in the same epoch. + // See note on the analogous block-proposal site: the helper handles pipelining lag. const epoch = getEpochAtSlot(slot, this.epochCache.getL1Constants()); - const previousCheckpointOutHashes = (await this.blockSource.getCheckpointsData({ epoch })) - .filter(c => c.checkpointNumber < checkpointNumber) - .map(c => c.checkpointOutHash); + const previousCheckpointOutHashes = await getPreviousCheckpointOutHashes({ + blockSource: this.blockSource, + epoch, + checkpointNumber, + l1Constants: this.epochCache.getL1Constants(), + pipeliningEnabled: this.epochCache.isProposerPipeliningEnabled(), + log: this.log, + }); // Fork world state at the block before the first block const parentBlockNumber = BlockNumber(firstBlock.number - 1); From cf6332427e081eb57e9c906e76ef96310e7ecc30 Mon Sep 17 00:00:00 2001 From: spypsy Date: Tue, 12 May 2026 10:21:10 +0100 Subject: [PATCH 39/55] refactor(config): drop nested config option, flatten l1Contracts (#23143) - Drop the `nested` field from `ConfigMapping` and the recursive branch in `getConfigFromMappings`. Env-var groups now have to be expressed as flat keys. - Flatten `l1Contracts` from every config that exposed it (`ChainConfig`, `DataStoreConfig`, `L1ReaderConfig`, `BaseSignerConfig`, `AztecNodeConfig`, archiver, etc.); L1 contract addresses are top-level keys everywhere. - Centralise the L1 address mapping in `ethereum/src/l1_contract_addresses.ts` and add `pickL1ContractAddressMappings`, `pickL1ContractAddressesSchema`, `pickL1ContractAddresses`, `randomL1ContractAddresses` so other configs can compose the addresses they need without reintroducing nesting. Fixes [A-986](https://linear.app/aztec-labs/issue/A-986/flatten-l1-contracts-config-remove-nested-top-level-mapping-entries) --- yarn-project/archiver/src/config.ts | 5 - yarn-project/archiver/src/factory.ts | 9 +- .../aztec-node/src/aztec-node/config.test.ts | 4 +- .../aztec-node/src/aztec-node/config.ts | 7 - .../aztec-node/src/aztec-node/server.test.ts | 11 +- .../aztec-node/src/aztec-node/server.ts | 19 +- .../aztec-node/src/sentinel/factory.ts | 2 +- .../aztec-node/src/sentinel/sentinel.test.ts | 2 +- .../aztec-node/src/sentinel/sentinel.ts | 4 +- yarn-project/aztec/src/cli/cmds/standby.ts | 4 +- yarn-project/aztec/src/cli/cmds/start_node.ts | 8 +- .../aztec/src/cli/cmds/start_prover_broker.ts | 17 +- .../aztec/src/local-network/local-network.ts | 12 +- .../blob-client/src/client/factory.ts | 4 +- .../cross_chain_messaging_test.ts | 5 +- .../end-to-end/src/e2e_p2p/add_rollup.test.ts | 7 +- .../end-to-end/src/e2e_synching.test.ts | 4 +- yarn-project/end-to-end/src/fixtures/setup.ts | 2 +- yarn-project/ethereum/src/contracts/inbox.ts | 2 +- yarn-project/ethereum/src/contracts/outbox.ts | 2 +- yarn-project/ethereum/src/contracts/rollup.ts | 2 +- .../ethereum/src/l1_contract_addresses.ts | 207 ++++++++++-------- yarn-project/ethereum/src/l1_reader.ts | 11 +- yarn-project/foundation/src/config/index.ts | 33 ++- yarn-project/kv-store/src/indexeddb/index.ts | 2 +- yarn-project/kv-store/src/lmdb-v2/factory.ts | 4 +- yarn-project/kv-store/src/lmdb/index.ts | 4 +- .../kv-store/src/sqlite-opfs/index.ts | 2 +- .../node-lib/src/actions/snapshot-sync.ts | 8 +- .../node-lib/src/factories/l1_tx_utils.ts | 2 +- yarn-project/p2p/src/client/factory.ts | 2 +- .../services/libp2p/libp2p_service.test.ts | 4 +- .../p2p/src/services/libp2p/libp2p_service.ts | 2 +- .../src/testbench/worker_client_manager.ts | 4 +- yarn-project/p2p/src/versioning.test.ts | 6 +- .../src/proving_broker/proving_broker.test.ts | 4 +- .../broker_persisted_database.test.ts | 14 +- .../proving_broker_database/persisted.ts | 4 +- .../src/test/proving_broker_testbench.test.ts | 15 +- .../prover-node/src/bin/run-failed-epoch.ts | 3 +- yarn-project/prover-node/src/factory.ts | 2 +- .../src/prover-node-publisher.test.ts | 15 +- .../src/entrypoints/client/bundle/utils.ts | 4 +- .../pxe/src/entrypoints/client/lazy/utils.ts | 4 +- .../pxe/src/entrypoints/server/utils.ts | 10 +- yarn-project/pxe/src/pxe.test.ts | 4 +- .../src/client/sequencer-client.ts | 6 +- yarn-project/sequencer-client/src/config.ts | 2 - .../global_variable_builder/fee_provider.ts | 2 +- .../global_variable_builder/global_builder.ts | 5 +- .../sequencer-client/src/publisher/config.ts | 4 +- .../src/publisher/sequencer-publisher.test.ts | 4 - .../checkpoint_voter.ha.integration.test.ts | 2 +- .../src/sequencer/sequencer.test.ts | 4 +- .../src/sequencer/sequencer.ts | 4 +- .../slasher/src/factory/create_facade.ts | 2 +- .../stdlib/src/config/chain-config.ts | 16 +- yarn-project/stdlib/src/ha-signing/config.ts | 25 +-- .../stdlib/src/interfaces/archiver.ts | 4 - .../src/interfaces/aztec-node-admin.test.ts | 4 +- .../stdlib/src/interfaces/aztec-node-admin.ts | 7 +- yarn-project/stdlib/src/kv-store/config.ts | 13 +- .../stdlib/src/versioning/versioning.ts | 2 +- yarn-project/validator-client/src/factory.ts | 2 +- .../src/proposal_handler.test.ts | 2 +- .../src/validator.ha.integration.test.ts | 2 +- .../src/validator.integration.test.ts | 4 +- .../validator-client/src/validator.test.ts | 2 +- .../validator-client/src/validator.ts | 4 +- .../validator-ha-signer/src/db/lmdb.test.ts | 2 +- .../validator-ha-signer/src/factory.ts | 4 +- .../src/slashing_protection_service.test.ts | 14 +- .../src/slashing_protection_service.ts | 4 +- .../src/validator_ha_signer.test.ts | 34 +-- .../src/validator_ha_signer.ts | 2 +- .../src/embedded/entrypoints/browser.ts | 2 +- .../wallets/src/embedded/entrypoints/node.ts | 2 +- .../world-state/src/synchronizer/factory.ts | 8 +- .../world-state/src/test/integration.test.ts | 2 +- 79 files changed, 327 insertions(+), 371 deletions(-) diff --git a/yarn-project/archiver/src/config.ts b/yarn-project/archiver/src/config.ts index 8e6586d3fd9a..4d7e1197638c 100644 --- a/yarn-project/archiver/src/config.ts +++ b/yarn-project/archiver/src/config.ts @@ -1,6 +1,5 @@ import { type BlobClientConfig, blobClientConfigMapping } from '@aztec/blob-client/client/config'; import { type L1ContractsConfig, l1ContractsConfigMappings } from '@aztec/ethereum/config'; -import { l1ContractAddressesMapping } from '@aztec/ethereum/l1-contract-addresses'; import { type L1ReaderConfig, l1ReaderConfigMappings } from '@aztec/ethereum/l1-reader'; import { type ConfigMappingsType, @@ -87,10 +86,6 @@ export const archiverConfigMappings: ConfigMappingsType = { ...numberConfigHelper(1000), }, ...l1ContractsConfigMappings, - l1Contracts: { - description: 'The deployed L1 contract addresses', - nested: l1ContractAddressesMapping, - }, }; /** diff --git a/yarn-project/archiver/src/factory.ts b/yarn-project/archiver/src/factory.ts index e5eb2eb1bac4..f486dfbbb5c4 100644 --- a/yarn-project/archiver/src/factory.ts +++ b/yarn-project/archiver/src/factory.ts @@ -2,6 +2,7 @@ import { EpochCache } from '@aztec/epoch-cache'; import { createEthereumChain } from '@aztec/ethereum/chain'; import { makeL1HttpTransport } from '@aztec/ethereum/client'; import { InboxContract, RollupContract } from '@aztec/ethereum/contracts'; +import { pickL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ViemPublicDebugClient } from '@aztec/ethereum/types'; import { BlockNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; @@ -80,8 +81,8 @@ export async function createArchiver( }) as ViemPublicDebugClient; // Create L1 contract instances - const rollup = new RollupContract(publicClient, config.l1Contracts.rollupAddress); - const inbox = new InboxContract(publicClient, config.l1Contracts.inboxAddress); + const rollup = new RollupContract(publicClient, config.rollupAddress); + const inbox = new InboxContract(publicClient, config.inboxAddress); // Fetch L1 constants from rollup contract const [ @@ -132,7 +133,7 @@ export async function createArchiver( mapArchiverConfig(config), ); - const epochCache = deps.epochCache ?? (await EpochCache.create(config.l1Contracts.rollupAddress, config, deps)); + const epochCache = deps.epochCache ?? (await EpochCache.create(config.rollupAddress, config, deps)); const telemetry = deps.telemetry ?? getTelemetryClient(); const instrumentation = await ArchiverInstrumentation.new(telemetry, () => archiverStore.db.estimateSize()); @@ -167,7 +168,7 @@ export async function createArchiver( publicClient, debugClient, rollup, - { ...config.l1Contracts, slashingProposerAddress }, + { ...pickL1ContractAddresses(config), slashingProposerAddress }, archiverStore, archiverConfig, deps.blobClient, diff --git a/yarn-project/aztec-node/src/aztec-node/config.test.ts b/yarn-project/aztec-node/src/aztec-node/config.test.ts index 0cbd120fba45..430f2ae7f59d 100644 --- a/yarn-project/aztec-node/src/aztec-node/config.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/config.test.ts @@ -52,7 +52,7 @@ describe('createKeyStoreForValidator', () => { web3SignerUrl, validatorAddresses: validatorAddresses.map(addr => addr), sequencerPublisherAddresses: publisherAddresses.map(addr => addr), - l1Contracts: { rollupAddress: EthAddress.random() }, + rollupAddress: EthAddress.random(), } as SequencerTxSenderConfig & ValidatorClientConfig & SequencerClientConfig & SharedNodeConfig; }; @@ -72,7 +72,7 @@ describe('createKeyStoreForValidator', () => { sequencerPublisherPrivateKeys: undefined, coinbase: undefined, feeRecipient: undefined, - l1Contracts: { rollupAddress: EthAddress.random() }, + rollupAddress: EthAddress.random(), } as unknown as SequencerTxSenderConfig & ValidatorClientConfig & SequencerClientConfig & SharedNodeConfig; const result = createKeyStoreForValidator(config); expect(result).toBeUndefined(); diff --git a/yarn-project/aztec-node/src/aztec-node/config.ts b/yarn-project/aztec-node/src/aztec-node/config.ts index 5c74334a94be..e279494485dc 100644 --- a/yarn-project/aztec-node/src/aztec-node/config.ts +++ b/yarn-project/aztec-node/src/aztec-node/config.ts @@ -1,6 +1,5 @@ import { type ArchiverConfig, archiverConfigMappings } from '@aztec/archiver/config'; import { type GenesisStateConfig, genesisStateConfigMappings } from '@aztec/ethereum/config'; -import { type L1ContractAddresses, l1ContractAddressesMapping } from '@aztec/ethereum/l1-contract-addresses'; import { type ConfigMappingsType, booleanConfigHelper, getConfigFromMappings } from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; import { @@ -53,8 +52,6 @@ export type AztecNodeConfig = ArchiverConfig & NodeRPCConfig & SlasherConfig & ProverNodeConfig & { - /** L1 contracts addresses */ - l1Contracts: L1ContractAddresses; /** Whether the validator is disabled for this node */ disableValidator: boolean; /** Whether to skip waiting for the archiver to be fully synced before starting other services */ @@ -81,10 +78,6 @@ export const aztecNodeConfigMappings: ConfigMappingsType = { ...nodeRpcConfigMappings, ...slasherConfigMappings, ...specificProverNodeConfigMappings, - l1Contracts: { - description: 'The deployed L1 contract addresses', - nested: l1ContractAddressesMapping, - }, disableValidator: { env: 'VALIDATOR_DISABLED', description: 'Whether the validator is disabled for this node.', diff --git a/yarn-project/aztec-node/src/aztec-node/server.test.ts b/yarn-project/aztec-node/src/aztec-node/server.test.ts index cc9a75e7fe83..b4ca0084accd 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.test.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.test.ts @@ -191,13 +191,10 @@ describe('aztec node', () => { const nodeConfigFromEnvVars: AztecNodeConfig = getConfigEnvVars(); nodeConfig = { ...nodeConfigFromEnvVars, - l1Contracts: { - ...nodeConfigFromEnvVars.l1Contracts, - rollupAddress: EthAddress.ZERO, - registryAddress: EthAddress.ZERO, - inboxAddress: EthAddress.ZERO, - outboxAddress: EthAddress.ZERO, - }, + rollupAddress: EthAddress.ZERO, + registryAddress: EthAddress.ZERO, + inboxAddress: EthAddress.ZERO, + outboxAddress: EthAddress.ZERO, }; // Inject a spurious config value to test that the config is correctly picked up diff --git a/yarn-project/aztec-node/src/aztec-node/server.ts b/yarn-project/aztec-node/src/aztec-node/server.ts index a7ef8e339b9d..f2e651bf6bc9 100644 --- a/yarn-project/aztec-node/src/aztec-node/server.ts +++ b/yarn-project/aztec-node/src/aztec-node/server.ts @@ -8,7 +8,7 @@ import { EpochCache, type EpochCacheInterface } from '@aztec/epoch-cache'; import { createEthereumChain } from '@aztec/ethereum/chain'; import { getPublicClient, makeL1HttpTransport } from '@aztec/ethereum/client'; import { RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import { type L1ContractAddresses, pickL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { chunkBy, compactArray, pick, unique } from '@aztec/foundation/collection'; @@ -195,7 +195,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb this.tracer = telemetry.getTracer('AztecNodeService'); this.log.info(`Aztec Node version: ${this.packageVersion}`); - this.log.info(`Aztec Node started on chain 0x${l1ChainId.toString(16)}`, config.l1Contracts); + this.log.info(`Aztec Node started on chain 0x${l1ChainId.toString(16)}`, pickL1ContractAddresses(config)); // A defensive check that protects us against introducing a bug in the complex `createAndSync` function. We must // never have debugLogStore enabled when not in test mode because then we would be accumulating debug logs in @@ -533,14 +533,13 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb const l1ContractsAddresses = await RegistryContract.collectAddresses( publicClient, - config.l1Contracts.registryAddress, + config.registryAddress, config.rollupVersion ?? 'canonical', ); - // Overwrite the passed in vars. - config.l1Contracts = { ...config.l1Contracts, ...l1ContractsAddresses }; + Object.assign(config, l1ContractsAddresses); - const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); + const rollupContract = new RollupContract(publicClient, config.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupVersionFromRollup, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), @@ -561,7 +560,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb // attempt snapshot sync if possible await trySnapshotSync(config, log); - const epochCache = await EpochCache.create(config.l1Contracts.rollupAddress, config, { dateProvider }); + const epochCache = await EpochCache.create(config.rollupAddress, config, { dateProvider }); // Track started resources so we can clean up on partial failure during node creation. const started: { stop?(): Promise | void }[] = []; @@ -608,7 +607,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb } const globalVariableBuilderConfig = { - l1Contracts: config.l1Contracts, + rollupAddress: config.rollupAddress, ethereumSlotDuration: config.ethereumSlotDuration, rollupVersion: BigInt(config.rollupVersion), l1GenesisTime, @@ -779,7 +778,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb slasherClient = await createSlasher( config, - config.l1Contracts, + pickL1ContractAddresses(config), getPublicClient(config), watchers, dateProvider, @@ -950,7 +949,7 @@ export class AztecNodeService implements AztecNode, AztecNodeAdmin, AztecNodeDeb * @returns - The currently deployed L1 contract addresses. */ public getL1ContractAddresses(): Promise { - return Promise.resolve(this.config.l1Contracts); + return Promise.resolve(pickL1ContractAddresses(this.config)); } public getEncodedEnr(): Promise { diff --git a/yarn-project/aztec-node/src/sentinel/factory.ts b/yarn-project/aztec-node/src/sentinel/factory.ts index 1bec08b6a22e..0db6ac82874f 100644 --- a/yarn-project/aztec-node/src/sentinel/factory.ts +++ b/yarn-project/aztec-node/src/sentinel/factory.ts @@ -15,7 +15,7 @@ export async function createSentinel( epochCache: EpochCache, archiver: L2BlockSource, p2p: P2PClient, - config: SentinelConfig & DataStoreConfig & SlasherConfig & Pick, + config: SentinelConfig & DataStoreConfig & SlasherConfig & Pick, logger = createLogger('node:sentinel'), ): Promise { if (!config.sentinelEnabled) { diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts index 1d745c9d4582..7bc4d050a71c 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.test.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.test.ts @@ -56,7 +56,7 @@ describe('sentinel', () => { slashInactivityTargetPercentage: 0.8, slashInactivityConsecutiveEpochThreshold: 1, l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, - l1Contracts: { rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress }, + rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress, }; beforeEach(async () => { diff --git a/yarn-project/aztec-node/src/sentinel/sentinel.ts b/yarn-project/aztec-node/src/sentinel/sentinel.ts index 611a67cb36bf..e1d4c153169c 100644 --- a/yarn-project/aztec-node/src/sentinel/sentinel.ts +++ b/yarn-project/aztec-node/src/sentinel/sentinel.ts @@ -48,7 +48,7 @@ export type SentinelRuntimeConfig = Pick< SlasherConfig, 'slashInactivityTargetPercentage' | 'slashInactivityPenalty' | 'slashInactivityConsecutiveEpochThreshold' > & - Pick; + Pick; /** Maps a validator status to its category: proposer or attestation. */ function statusToCategory(status: ValidatorStatusInSlot): ValidatorStatusType { @@ -96,7 +96,7 @@ export class Sentinel extends (EventEmitter as new () => WatcherEmitter) impleme private getSignatureContext(): CoordinationSignatureContext { return { chainId: this.config.l1ChainId, - rollupAddress: this.config.l1Contracts.rollupAddress, + rollupAddress: this.config.rollupAddress, }; } diff --git a/yarn-project/aztec/src/cli/cmds/standby.ts b/yarn-project/aztec/src/cli/cmds/standby.ts index 74b2522ad9e4..d2769d36788d 100644 --- a/yarn-project/aztec/src/cli/cmds/standby.ts +++ b/yarn-project/aztec/src/cli/cmds/standby.ts @@ -67,7 +67,7 @@ export async function waitForCompatibleRollup( config: { l1RpcUrls: string[]; l1ChainId: number; - l1Contracts: { registryAddress: EthAddress }; + registryAddress: EthAddress; rollupVersion?: number; }, expected: { genesisArchiveRoot: Fr; vkTreeRoot: Fr; protocolContractsHash: Fr }, @@ -77,7 +77,7 @@ export async function waitForCompatibleRollup( const publicClient = getPublicClient(config); const rollupVersion: number | 'canonical' = config.rollupVersion ?? 'canonical'; - const registry = new RegistryContract(publicClient, config.l1Contracts.registryAddress); + const registry = new RegistryContract(publicClient, config.registryAddress); const rollupAddress = await registry.getRollupAddress(rollupVersion); const rollup = new RollupContract(publicClient, rollupAddress.toString()); diff --git a/yarn-project/aztec/src/cli/cmds/start_node.ts b/yarn-project/aztec/src/cli/cmds/start_node.ts index 90b54d802db7..02a1b5bf0c2a 100644 --- a/yarn-project/aztec/src/cli/cmds/start_node.ts +++ b/yarn-project/aztec/src/cli/cmds/start_node.ts @@ -88,7 +88,7 @@ export async function startNode( const followsCanonicalRollup = typeof nodeConfig.rollupVersion !== 'number' || (nodeConfig.rollupVersion as unknown as string) === 'canonical'; - if (!nodeConfig.l1Contracts.registryAddress || nodeConfig.l1Contracts.registryAddress.isZero()) { + if (!nodeConfig.registryAddress || nodeConfig.registryAddress.isZero()) { throw new Error('L1 registry address is required to start Aztec Node'); } @@ -102,7 +102,7 @@ export async function startNode( ); const { addresses, config } = await getL1Config( - nodeConfig.l1Contracts.registryAddress, + nodeConfig.registryAddress, nodeConfig.l1RpcUrls, nodeConfig.l1ChainId, nodeConfig.rollupVersion, @@ -118,9 +118,7 @@ export async function startNode( nodeConfig = { ...nodeConfig, - l1Contracts: { - ...addresses, - }, + ...addresses, ...config, }; diff --git a/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts b/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts index e35928714fc8..bab990e9992a 100644 --- a/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts +++ b/yarn-project/aztec/src/cli/cmds/start_prover_broker.ts @@ -29,33 +29,32 @@ export async function startProverBroker( process.exit(1); } - const config: ProverBrokerConfig = { + const baseConfig: ProverBrokerConfig = { ...getProverNodeBrokerConfigFromEnv(), // get default config from env ...extractRelevantOptions(options, proverBrokerConfigMappings, 'proverBroker'), // override with command line options }; - if (!config.l1Contracts.registryAddress || config.l1Contracts.registryAddress.isZero()) { + if (!baseConfig.registryAddress || baseConfig.registryAddress.isZero()) { throw new Error('L1 registry address is required to start Aztec Node without --deploy-aztec-contracts option'); } const genesisConfig = getGenesisStateConfigEnvVars(); const { genesisArchiveRoot } = await computeExpectedGenesisRoot(genesisConfig, userLog); await waitForCompatibleRollup( - config, + baseConfig, { genesisArchiveRoot, vkTreeRoot: getVKTreeRoot(), protocolContractsHash }, options.port, userLog, ); const { addresses, config: rollupConfig } = await getL1Config( - config.l1Contracts.registryAddress, - config.l1RpcUrls, - config.l1ChainId, - config.rollupVersion, + baseConfig.registryAddress, + baseConfig.l1RpcUrls, + baseConfig.l1ChainId, + baseConfig.rollupVersion, ); - config.l1Contracts = addresses; - config.rollupVersion = rollupConfig.rollupVersion; + const config: ProverBrokerConfig = { ...baseConfig, ...addresses, rollupVersion: rollupConfig.rollupVersion }; const client = await initTelemetryClient(getTelemetryClientConfig()); const broker = await createAndStartProvingBroker(config, client); diff --git a/yarn-project/aztec/src/local-network/local-network.ts b/yarn-project/aztec/src/local-network/local-network.ts index 860f46e64560..6293df2653c5 100644 --- a/yarn-project/aztec/src/local-network/local-network.ts +++ b/yarn-project/aztec/src/local-network/local-network.ts @@ -11,6 +11,7 @@ import { waitForPublicClient } from '@aztec/ethereum/client'; import { getL1ContractsConfigEnvVars } from '@aztec/ethereum/config'; import { NULL_KEY } from '@aztec/ethereum/constants'; import { deployAztecL1Contracts } from '@aztec/ethereum/deploy-aztec-l1-contracts'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { EthCheatCodes } from '@aztec/ethereum/test'; import { SecretValue } from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -59,7 +60,7 @@ export async function deployContractsToL1( genesisArchiveRoot?: Fr; feeJuicePortalInitialBalance?: bigint; } = {}, -) { +): Promise { await waitForPublicClient(aztecNodeConfig); const l1Contracts = await deployAztecL1Contracts(aztecNodeConfig.l1RpcUrls[0], privateKey, foundry.id, { @@ -74,10 +75,10 @@ export async function deployContractsToL1( realVerifier: false, }); - aztecNodeConfig.l1Contracts = l1Contracts.l1ContractAddresses; + Object.assign(aztecNodeConfig, l1Contracts.l1ContractAddresses); aztecNodeConfig.rollupVersion = l1Contracts.rollupVersion; - return aztecNodeConfig.l1Contracts; + return l1Contracts.l1ContractAddresses; } /** Local network settings. */ @@ -262,11 +263,10 @@ export async function createAztecNode( options: { genesis?: GenesisData } = {}, ) { // TODO(#12272): will clean this up. This is criminal. - const { l1Contracts, ...rest } = getConfigEnvVars(); + // Not sure why this was ever done. Will be fixed in A-989, A-991, A-990. const aztecNodeConfig: AztecNodeConfig = { - ...rest, + ...getConfigEnvVars(), ...config, - l1Contracts: { ...l1Contracts, ...config.l1Contracts }, }; const node = await AztecNodeService.createAndSync( aztecNodeConfig, diff --git a/yarn-project/blob-client/src/client/factory.ts b/yarn-project/blob-client/src/client/factory.ts index d506064a2268..5fe846b2df0a 100644 --- a/yarn-project/blob-client/src/client/factory.ts +++ b/yarn-project/blob-client/src/client/factory.ts @@ -48,7 +48,7 @@ export function createBlobClient(config?: BlobClientConfig, deps?: CreateBlobCli export interface BlobClientWithFileStoresConfig extends BlobClientConfig { l1ChainId: number; rollupVersion: number; - l1Contracts: { rollupAddress: { toString(): string } }; + rollupAddress: { toString(): string }; } /** @@ -73,7 +73,7 @@ export async function createBlobClientWithFileStores( const fileStoreMetadata: BlobFileStoreMetadata = { l1ChainId: config.l1ChainId, rollupVersion: config.rollupVersion, - rollupAddress: config.l1Contracts.rollupAddress.toString(), + rollupAddress: config.rollupAddress.toString(), }; // Disable internal retries for blob file stores — retry logic is handled by HttpBlobClient. diff --git a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts index b2e7aa800d04..4f9fbc5ba363 100644 --- a/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts +++ b/yarn-project/end-to-end/src/e2e_cross_chain_messaging/cross_chain_messaging_test.ts @@ -12,6 +12,7 @@ import type { DeployAztecL1ContractsReturnType, } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; +import { pickL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { EpochNumber } from '@aztec/foundation/branded-types'; import { sleep } from '@aztec/foundation/sleep'; @@ -171,7 +172,7 @@ export class CrossChainMessagingTest { const l1Client = createExtendedL1Client(this.aztecNodeConfig.l1RpcUrls, MNEMONIC); this.l1Client = l1Client; - const l1Contracts = this.aztecNodeConfig.l1Contracts; + const l1Contracts = pickL1ContractAddresses(this.aztecNodeConfig); this.rollup = new RollupContract(l1Client, l1Contracts.rollupAddress.toString()); this.inbox = new InboxContract(l1Client, l1Contracts.inboxAddress.toString()); this.outbox = new OutboxContract(l1Client, l1Contracts.outboxAddress.toString()); @@ -185,7 +186,7 @@ export class CrossChainMessagingTest { tokenPortalAddress, crossChainContext.underlying, l1Client, - this.aztecNodeConfig.l1Contracts, + pickL1ContractAddresses(this.aztecNodeConfig), this.wallet, this.ownerAddress, ); diff --git a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts index 5c4c468fbcdf..e386f7ae17c3 100644 --- a/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts +++ b/yarn-project/end-to-end/src/e2e_p2p/add_rollup.test.ts @@ -10,7 +10,7 @@ import { RollupCheatCodes } from '@aztec/aztec/testing'; import { FeeAssetHandlerContract, RegistryContract, RollupContract } from '@aztec/ethereum/contracts'; import { deployRollupForUpgrade } from '@aztec/ethereum/deploy-aztec-l1-contracts'; import { deployL1Contract } from '@aztec/ethereum/deploy-l1-contract'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import { type L1ContractAddresses, pickL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { L1TxUtils, createL1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import type { ExtendedViemWalletClient } from '@aztec/ethereum/types'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; @@ -555,7 +555,8 @@ describe('e2e_p2p_add_rollup', () => { dataDirectory: DATA_DIR_NEW, rollupVersion: Number(newVersion), governanceProposerPayload: EthAddress.ZERO, - l1Contracts: { ...t.ctx.deployL1ContractsValues.l1ContractAddresses, ...addresses }, + ...t.ctx.deployL1ContractsValues.l1ContractAddresses, + ...addresses, }; await setupSharedBlobStorage(newConfig); @@ -610,7 +611,7 @@ describe('e2e_p2p_add_rollup', () => { nodes[0], initialTestAccounts[0], t.ctx.deployL1ContractsValues.l1Client, - newConfig.l1Contracts, + pickL1ContractAddresses(newConfig), BigInt(newConfig.rollupVersion), newConfig.l1RpcUrls, ); diff --git a/yarn-project/end-to-end/src/e2e_synching.test.ts b/yarn-project/end-to-end/src/e2e_synching.test.ts index 0ac2cfc8e6de..54e278458161 100644 --- a/yarn-project/end-to-end/src/e2e_synching.test.ts +++ b/yarn-project/end-to-end/src/e2e_synching.test.ts @@ -441,10 +441,10 @@ describe('e2e_synching', () => { const rollupContract = new RollupContract(deployL1ContractsValues.l1Client, rollupAddress); const governanceProposerContract = new GovernanceProposerContract( deployL1ContractsValues.l1Client, - config.l1Contracts.governanceProposerAddress.toString(), + config.governanceProposerAddress.toString(), ); const slashingProposerContract = await rollupContract.getSlashingProposer(); - const epochCache = await EpochCache.create(config.l1Contracts.rollupAddress, config, { dateProvider }); + const epochCache = await EpochCache.create(config.rollupAddress, config, { dateProvider }); const sequencerPublisherMetrics: MockProxy = mock(); const publisher = new SequencerPublisher( { diff --git a/yarn-project/end-to-end/src/fixtures/setup.ts b/yarn-project/end-to-end/src/fixtures/setup.ts index db1e0154288a..fb6b464defca 100644 --- a/yarn-project/end-to-end/src/fixtures/setup.ts +++ b/yarn-project/end-to-end/src/fixtures/setup.ts @@ -454,7 +454,7 @@ export async function setup( }, ); - config.l1Contracts = deployL1ContractsValues.l1ContractAddresses; + Object.assign(config, deployL1ContractsValues.l1ContractAddresses); config.rollupVersion = deployL1ContractsValues.rollupVersion; if (enableAutomine) { diff --git a/yarn-project/ethereum/src/contracts/inbox.ts b/yarn-project/ethereum/src/contracts/inbox.ts index f061fd8db016..77bcfd1a1f15 100644 --- a/yarn-project/ethereum/src/contracts/inbox.ts +++ b/yarn-project/ethereum/src/contracts/inbox.ts @@ -38,7 +38,7 @@ export class InboxContract { static getFromConfig(config: L1ReaderConfig) { const client = getPublicClient(config); - const address = config.l1Contracts.inboxAddress.toString(); + const address = config.inboxAddress.toString(); return new InboxContract(client, address); } diff --git a/yarn-project/ethereum/src/contracts/outbox.ts b/yarn-project/ethereum/src/contracts/outbox.ts index 698c4fa40665..a331fb616b42 100644 --- a/yarn-project/ethereum/src/contracts/outbox.ts +++ b/yarn-project/ethereum/src/contracts/outbox.ts @@ -38,7 +38,7 @@ export class OutboxContract { static getFromConfig(config: L1ReaderConfig) { const client = getPublicClient(config); - const address = config.l1Contracts.outboxAddress.toString(); + const address = config.outboxAddress.toString(); return new OutboxContract(client, address); } diff --git a/yarn-project/ethereum/src/contracts/rollup.ts b/yarn-project/ethereum/src/contracts/rollup.ts index 7ed52771746b..75ba04c42ec2 100644 --- a/yarn-project/ethereum/src/contracts/rollup.ts +++ b/yarn-project/ethereum/src/contracts/rollup.ts @@ -262,7 +262,7 @@ export class RollupContract { static getFromConfig(config: L1ReaderConfig) { const client = getPublicClient(config); - const address = config.l1Contracts.rollupAddress.toString(); + const address = config.rollupAddress.toString(); return new RollupContract(client, address); } diff --git a/yarn-project/ethereum/src/l1_contract_addresses.ts b/yarn-project/ethereum/src/l1_contract_addresses.ts index 07eb87694dca..27c66ce2956b 100644 --- a/yarn-project/ethereum/src/l1_contract_addresses.ts +++ b/yarn-project/ethereum/src/l1_contract_addresses.ts @@ -1,15 +1,15 @@ -import type { ConfigMappingsType } from '@aztec/foundation/config'; +import type { ConfigMapping, ConfigMappingsType } from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; -import { schemas, zodFor } from '@aztec/foundation/schemas'; +import { schemas } from '@aztec/foundation/schemas'; import { z } from 'zod'; /** - * The names of the current L1 contract addresses. + * Required L1 contract address keys. * NOTE: When changing this list, make sure to update CLI & CI scripts accordingly. * For reference: https://github.com/AztecProtocol/aztec-packages/pull/5553 */ -export const L1ContractsNames = [ +const REQUIRED_L1_CONTRACT_ADDRESS_KEYS = [ 'rollupAddress', 'registryAddress', 'inboxAddress', @@ -23,95 +23,128 @@ export const L1ContractsNames = [ 'stakingAssetAddress', ] as const; +/** Optional L1 contract address keys — present only in some deployments. */ +export const OPTIONAL_L1_CONTRACT_ADDRESS_KEYS = [ + 'feeAssetHandlerAddress', + 'stakingAssetHandlerAddress', + 'zkPassportVerifierAddress', + 'gseAddress', + 'dateGatedRelayerAddress', +] as const; + +type RequiredContractKey = (typeof REQUIRED_L1_CONTRACT_ADDRESS_KEYS)[number]; +type OptionalContractKey = (typeof OPTIONAL_L1_CONTRACT_ADDRESS_KEYS)[number]; + /** Provides the directory of current L1 contract addresses */ export type L1ContractAddresses = { - [K in (typeof L1ContractsNames)[number]]: EthAddress; + [K in RequiredContractKey]: EthAddress; } & { - feeAssetHandlerAddress?: EthAddress | undefined; - stakingAssetHandlerAddress?: EthAddress | undefined; - zkPassportVerifierAddress?: EthAddress | undefined; - gseAddress?: EthAddress | undefined; - dateGatedRelayerAddress?: EthAddress | undefined; + [K in OptionalContractKey]?: EthAddress; }; -export const L1ContractAddressesSchema = zodFor()( - z.object({ - rollupAddress: schemas.EthAddress, - registryAddress: schemas.EthAddress, - inboxAddress: schemas.EthAddress, - outboxAddress: schemas.EthAddress, - feeJuiceAddress: schemas.EthAddress, - stakingAssetAddress: schemas.EthAddress, - feeJuicePortalAddress: schemas.EthAddress, - coinIssuerAddress: schemas.EthAddress, - rewardDistributorAddress: schemas.EthAddress, - governanceProposerAddress: schemas.EthAddress, - governanceAddress: schemas.EthAddress, - feeAssetHandlerAddress: schemas.EthAddress.optional(), - stakingAssetHandlerAddress: schemas.EthAddress.optional(), - zkPassportVerifierAddress: schemas.EthAddress.optional(), - gseAddress: schemas.EthAddress.optional(), - dateGatedRelayerAddress: schemas.EthAddress.optional(), - }), -); +/** + * Names of required L1 contract addresses. + * NOTE: When changing the set of contracts, update CLI & CI scripts accordingly. + * For reference: https://github.com/AztecProtocol/aztec-packages/pull/5553 + */ +export const L1ContractsNames = REQUIRED_L1_CONTRACT_ADDRESS_KEYS; const parseEnv = (val: string) => EthAddress.fromString(val); +const addrEnv = (description: string, env?: ConfigMapping['env']): ConfigMapping => ({ + description, + parseEnv, + ...(env && { env }), +}); -export const l1ContractAddressesMapping: ConfigMappingsType< - Omit -> = { - registryAddress: { - env: 'REGISTRY_CONTRACT_ADDRESS', - description: 'The deployed L1 registry contract address.', - parseEnv, - }, - feeAssetHandlerAddress: { - env: 'FEE_ASSET_HANDLER_CONTRACT_ADDRESS', - description: 'The deployed L1 feeAssetHandler contract address', - parseEnv, - }, - rollupAddress: { - description: 'The deployed L1 rollup contract address.', - parseEnv, - }, - inboxAddress: { - description: 'The deployed L1 inbox contract address.', - parseEnv, - }, - outboxAddress: { - description: 'The deployed L1 outbox contract address.', - parseEnv, - }, - feeJuiceAddress: { - description: 'The deployed L1 Fee Juice contract address.', - parseEnv, - }, - stakingAssetAddress: { - description: 'The deployed L1 staking asset contract address.', - parseEnv, - }, - feeJuicePortalAddress: { - description: 'The deployed L1 Fee Juice portal contract address.', - parseEnv, - }, - coinIssuerAddress: { - description: 'The deployed L1 coinIssuer contract address', - parseEnv, - }, - rewardDistributorAddress: { - description: 'The deployed L1 rewardDistributor contract address', - parseEnv, - }, - governanceProposerAddress: { - description: 'The deployed L1 governanceProposer contract address', - parseEnv, - }, - governanceAddress: { - description: 'The deployed L1 governance contract address', - parseEnv, - }, - stakingAssetHandlerAddress: { - description: 'The deployed L1 stakingAssetHandler contract address', - parseEnv, - }, +/** + * Config mapping for all L1 contract addresses. + * This is the single source of truth for which contract addresses exist and how to parse them. + */ +export const l1ContractAddressesMapping: ConfigMappingsType = { + rollupAddress: addrEnv('The deployed L1 rollup contract address.'), + registryAddress: addrEnv('The deployed L1 registry contract address.', 'REGISTRY_CONTRACT_ADDRESS'), + inboxAddress: addrEnv('The deployed L1 inbox contract address.'), + outboxAddress: addrEnv('The deployed L1 outbox contract address.'), + feeJuiceAddress: addrEnv('The deployed L1 Fee Juice contract address.'), + feeJuicePortalAddress: addrEnv('The deployed L1 Fee Juice portal contract address.'), + coinIssuerAddress: addrEnv('The deployed L1 coinIssuer contract address'), + rewardDistributorAddress: addrEnv('The deployed L1 rewardDistributor contract address'), + governanceProposerAddress: addrEnv('The deployed L1 governanceProposer contract address'), + governanceAddress: addrEnv('The deployed L1 governance contract address'), + stakingAssetAddress: addrEnv('The deployed L1 staking asset contract address.'), + feeAssetHandlerAddress: addrEnv( + 'The deployed L1 feeAssetHandler contract address', + 'FEE_ASSET_HANDLER_CONTRACT_ADDRESS', + ), + stakingAssetHandlerAddress: addrEnv('The deployed L1 stakingAssetHandler contract address'), + zkPassportVerifierAddress: addrEnv('The deployed L1 ZK passport verifier contract address'), + gseAddress: addrEnv('The deployed L1 GSE contract address'), + dateGatedRelayerAddress: addrEnv('The deployed L1 date-gated relayer contract address'), }; + +/** Keys present in {@link l1ContractAddressesMapping}. */ +export type L1ContractAddressMappingKey = keyof typeof l1ContractAddressesMapping; + +type RequiredAddressShape = { [K in RequiredContractKey]: typeof schemas.EthAddress }; +type OptionalAddressShape = { [K in OptionalContractKey]: ReturnType }; + +export const L1ContractAddressesSchema = z.object({ + ...(Object.fromEntries(REQUIRED_L1_CONTRACT_ADDRESS_KEYS.map(k => [k, schemas.EthAddress])) as RequiredAddressShape), + ...(Object.fromEntries( + OPTIONAL_L1_CONTRACT_ADDRESS_KEYS.map(k => [k, schemas.EthAddress.optional()]), + ) as OptionalAddressShape), +}); + +/** + * Selects entries from {@link l1ContractAddressesMapping} so composed configs can reuse the same + * env vars, descriptions, and parsers without duplicating definitions. + * + * @example + * ```ts + * const mappings = { + * ...pickL1ContractAddressMappings('rollupAddress'), + * nodeId: { ... }, + * }; + * ``` + */ +export function pickL1ContractAddressMappings( + ...keys: K +): Pick { + return Object.fromEntries(keys.map(key => [key, l1ContractAddressesMapping[key]] as const)) as Pick< + typeof l1ContractAddressesMapping, + K[number] + >; +} + +/** + * Like {@link pickL1ContractAddressMappings}, but returns a fragment of {@link L1ContractAddressesSchema}'s shape for + * spreading into `z.object({ ...pickL1ContractAddressesSchemaShape('rollupAddress'), localField: z... })`. + */ +export function pickL1ContractAddressesSchema( + ...keys: K +): Pick { + return Object.fromEntries(keys.map(key => [key, L1ContractAddressesSchema.shape[key]])) as Pick< + typeof L1ContractAddressesSchema.shape, + K[number] + >; +} + +/** Builds an {@link L1ContractAddresses} object from config that stores those fields at the top level. */ +export function pickL1ContractAddresses(config: T): L1ContractAddresses { + const result: Partial = {}; + for (const key of REQUIRED_L1_CONTRACT_ADDRESS_KEYS) { + result[key] = config[key]; + } + for (const key of OPTIONAL_L1_CONTRACT_ADDRESS_KEYS) { + const v = config[key]; + if (v !== undefined) { + result[key] = v; + } + } + return result as L1ContractAddresses; +} + +export function randomL1ContractAddresses(includeOptional: boolean = false): L1ContractAddresses { + const keys = includeOptional ? [...L1ContractsNames, ...OPTIONAL_L1_CONTRACT_ADDRESS_KEYS] : L1ContractsNames; + return Object.fromEntries(keys.map(name => [name, EthAddress.random()])) as L1ContractAddresses; +} diff --git a/yarn-project/ethereum/src/l1_reader.ts b/yarn-project/ethereum/src/l1_reader.ts index b23ed6f1e621..40b50602de22 100644 --- a/yarn-project/ethereum/src/l1_reader.ts +++ b/yarn-project/ethereum/src/l1_reader.ts @@ -8,26 +8,20 @@ import { import { type L1ContractAddresses, l1ContractAddressesMapping } from './l1_contract_addresses.js'; /** Configuration of the L1GlobalReader. */ -export interface L1ReaderConfig { +export type L1ReaderConfig = { /** List of URLs of Ethereum RPC nodes that services will connect to (comma separated). */ l1RpcUrls: string[]; /** The RPC Url of the ethereum debug host for trace and debug methods. */ l1DebugRpcUrls: string[]; /** The chain ID of the ethereum host. */ l1ChainId: number; - /** The deployed l1 contract addresses */ - l1Contracts: L1ContractAddresses; /** The polling interval viem uses in ms */ viemPollingIntervalMS: number; /** Timeout for HTTP requests to the L1 RPC node in ms. */ l1HttpTimeoutMS?: number; -} +} & L1ContractAddresses; export const l1ReaderConfigMappings: ConfigMappingsType = { - l1Contracts: { - description: 'The deployed L1 contract addresses', - nested: l1ContractAddressesMapping, - }, l1ChainId: { env: 'L1_CHAIN_ID', ...optionalNumberConfigHelper(), @@ -55,6 +49,7 @@ export const l1ReaderConfigMappings: ConfigMappingsType = { description: 'Timeout for HTTP requests to the L1 RPC node in ms.', ...optionalNumberConfigHelper(), }, + ...l1ContractAddressesMapping, }; export function getL1ReaderConfigFromEnv(): L1ReaderConfig { diff --git a/yarn-project/foundation/src/config/index.ts b/yarn-project/foundation/src/config/index.ts index 5eb462ce13d4..e11130b3ca30 100644 --- a/yarn-project/foundation/src/config/index.ts +++ b/yarn-project/foundation/src/config/index.ts @@ -18,7 +18,6 @@ export interface ConfigMapping { printDefault?: (val: any) => string; description: string; isBoolean?: boolean; - nested?: Record>; fallback?: EnvVar[]; /** * List of deprecated env vars that are still supported but will log a warning. @@ -82,26 +81,20 @@ export function getConfigFromMappings(configMappings: ConfigMappingsType): const config = {} as T; for (const key in configMappings) { - const { env, parseEnv, defaultValue, nested, fallback, deprecatedFallback } = configMappings[key]; - if (nested) { - (config as any)[key] = getConfigFromMappings(nested); - } else { - // Use the shared utility function - try { - (config as any)[key] = getValueFromEnvWithFallback(env, parseEnv, defaultValue, fallback); - } catch (e: any) { - throw new Error(`Failed to parse config '${key}' (env: ${env ?? 'none'}): ${e.message}`); - } + const { env, parseEnv, defaultValue, fallback, deprecatedFallback } = configMappings[key]; + try { + (config as any)[key] = getValueFromEnvWithFallback(env, parseEnv, defaultValue, fallback); + } catch (e: any) { + throw new Error(`Failed to parse config '${key}' (env: ${env ?? 'none'}): ${e.message}`); + } - // Check for deprecated env vars and warn if logger is set - if (deprecatedFallback?.length) { - const userLog = createConsoleLogger('[DEPRECATED]'); - for (const { env: deprecatedEnv, message } of deprecatedFallback) { - if (process.env[deprecatedEnv]) { - const warningMessage = - message ?? `Environment variable ${deprecatedEnv} is deprecated. Please use ${env} instead.`; - userLog(warningMessage, { deprecatedEnvVar: deprecatedEnv, newEnvVar: env }); - } + if (deprecatedFallback?.length) { + const userLog = createConsoleLogger('[DEPRECATED]'); + for (const { env: deprecatedEnv, message } of deprecatedFallback) { + if (process.env[deprecatedEnv]) { + const warningMessage = + message ?? `Environment variable ${deprecatedEnv} is deprecated. Please use ${env} instead.`; + userLog(warningMessage, { deprecatedEnvVar: deprecatedEnv, newEnvVar: env }); } } } diff --git a/yarn-project/kv-store/src/indexeddb/index.ts b/yarn-project/kv-store/src/indexeddb/index.ts index e0dd61a961e8..45555699afc5 100644 --- a/yarn-project/kv-store/src/indexeddb/index.ts +++ b/yarn-project/kv-store/src/indexeddb/index.ts @@ -23,7 +23,7 @@ export async function createStore( : `Creating ${name} ephemeral data store with map size ${config.dataStoreMapSizeKb} KB`, ); const store = await AztecIndexedDBStore.open(createLogger('kv-store:indexeddb'), dataDirectory ?? '', false); - return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.l1Contracts?.rollupAddress, log); + return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log); } export function openTmpStore(ephemeral: boolean = false): Promise { diff --git a/yarn-project/kv-store/src/lmdb-v2/factory.ts b/yarn-project/kv-store/src/lmdb-v2/factory.ts index fcfaea1a68f9..eb668537fe86 100644 --- a/yarn-project/kv-store/src/lmdb-v2/factory.ts +++ b/yarn-project/kv-store/src/lmdb-v2/factory.ts @@ -25,7 +25,7 @@ export async function createStore( options: CreateStoreOptions = {}, ): Promise { const log = createLogger('kv-store:lmdb-v2:' + name, bindings); - const { dataDirectory, l1Contracts } = config; + const { dataDirectory, rollupAddress: rollupFromConfig } = config; let store: AztecLMDBStoreV2; if (typeof dataDirectory !== 'undefined') { @@ -33,7 +33,7 @@ export async function createStore( const subDir = join(dataDirectory, name); await mkdir(subDir, { recursive: true }); - const rollupAddress = l1Contracts ? l1Contracts.rollupAddress : EthAddress.ZERO; + const rollupAddress = rollupFromConfig ?? EthAddress.ZERO; // Create a version manager const versionManager = new DatabaseVersionManager({ diff --git a/yarn-project/kv-store/src/lmdb/index.ts b/yarn-project/kv-store/src/lmdb/index.ts index aac0204c4655..ca1708d90b7e 100644 --- a/yarn-project/kv-store/src/lmdb/index.ts +++ b/yarn-project/kv-store/src/lmdb/index.ts @@ -26,8 +26,8 @@ export function createStore( ); const store = AztecLmdbStore.open(dataDirectory, config.dataStoreMapSizeKb, false); - if (config.l1Contracts?.rollupAddress) { - return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.l1Contracts.rollupAddress, log); + if (config.rollupAddress) { + return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log); } return store; } diff --git a/yarn-project/kv-store/src/sqlite-opfs/index.ts b/yarn-project/kv-store/src/sqlite-opfs/index.ts index 18153d6439d7..892d47861c7e 100644 --- a/yarn-project/kv-store/src/sqlite-opfs/index.ts +++ b/yarn-project/kv-store/src/sqlite-opfs/index.ts @@ -19,7 +19,7 @@ export async function createStore( : `Creating ${name} ephemeral SQLite-OPFS data store with map size ${config.dataStoreMapSizeKb} KB`, ); const store = await AztecSQLiteOPFSStore.open(createLogger('kv-store:sqlite-opfs'), name, false); - return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.l1Contracts?.rollupAddress, log); + return initStoreForRollupAndSchemaVersion(store, schemaVersion, config.rollupAddress, log); } export function openTmpStore(ephemeral: boolean = false): Promise { diff --git a/yarn-project/node-lib/src/actions/snapshot-sync.ts b/yarn-project/node-lib/src/actions/snapshot-sync.ts index ad2e4dd9dbf7..c6e51f33e12b 100644 --- a/yarn-project/node-lib/src/actions/snapshot-sync.ts +++ b/yarn-project/node-lib/src/actions/snapshot-sync.ts @@ -38,7 +38,7 @@ type SnapshotSyncConfig = Pick & Pick & Pick & DataStoreConfig & - Required> & + Required> & EthereumClientConfig & { snapshotsUrls?: string[]; minL1BlocksToTriggerReplace?: number; @@ -49,7 +49,7 @@ type SnapshotSyncConfig = Pick & * Behaviour depends on syncing mode. */ export async function trySnapshotSync(config: SnapshotSyncConfig, log: Logger) { - const { syncMode, snapshotsUrls, dataDirectory, l1ChainId, rollupVersion, l1Contracts } = config; + const { syncMode, snapshotsUrls, dataDirectory, l1ChainId, rollupVersion, rollupAddress } = config; if (syncMode === 'full') { log.debug('Snapshot sync is disabled. Running full sync.', { syncMode: syncMode }); return false; @@ -106,7 +106,7 @@ export async function trySnapshotSync(config: SnapshotSyncConfig, log: Logger) { const indexMetadata: SnapshotsIndexMetadata = { l1ChainId, rollupVersion, - rollupAddress: l1Contracts.rollupAddress, + rollupAddress, }; // Fetch latest snapshot from each URL @@ -194,7 +194,7 @@ export async function trySnapshotSync(config: SnapshotSyncConfig, log: Logger) { try { await snapshotSync(snapshot, log, { dataDirectory: config.dataDirectory!, - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, fileStore, }); log.info(`Snapshot synced to L1 block ${l1BlockNumber} L2 block ${l2BlockNumber}`, { diff --git a/yarn-project/node-lib/src/factories/l1_tx_utils.ts b/yarn-project/node-lib/src/factories/l1_tx_utils.ts index 4799c3f93e08..f7f00a2c10b9 100644 --- a/yarn-project/node-lib/src/factories/l1_tx_utils.ts +++ b/yarn-project/node-lib/src/factories/l1_tx_utils.ts @@ -34,7 +34,7 @@ async function createSharedDeps( // Note that we do NOT bind them to the rollup address, since we still need to // monitor and cancel txs for previous rollups to free up our nonces. - const noRollupConfig = omit(config, 'l1Contracts'); + const noRollupConfig = omit(config, 'rollupAddress'); const kvStore = await createStore(L1_TX_STORE_NAME, L1TxStore.SCHEMA_VERSION, noRollupConfig, logger.getBindings()); const store = new L1TxStore(kvStore, logger); diff --git a/yarn-project/p2p/src/client/factory.ts b/yarn-project/p2p/src/client/factory.ts index 8080d68ae005..ef236f257a7d 100644 --- a/yarn-project/p2p/src/client/factory.ts +++ b/yarn-project/p2p/src/client/factory.ts @@ -80,7 +80,7 @@ export async function createP2PClient( const attestationStore = await createStore(P2P_ATTESTATION_STORE_NAME, 2, config, bindings); const l1Constants = await archiver.getL1Constants(); - const rollupAddress = inputConfig.l1Contracts.rollupAddress.toString().toLowerCase().replace(/^0x/, ''); + const rollupAddress = inputConfig.rollupAddress.toString().toLowerCase().replace(/^0x/, ''); const txFileStoreBasePath = `aztec-${inputConfig.l1ChainId}-${inputConfig.rollupVersion}-0x${rollupAddress}`; const allowedInSetup = [ diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts index b397a0a4928a..91d0abe8e93b 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.test.ts @@ -1335,9 +1335,7 @@ class TestLibP2PService extends LibP2PService { disableTransactions: false, l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, rollupVersion: 1, - l1Contracts: { - rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress, - }, + rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress, ...configOverrides, }; diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 327e3e4411ef..16d1552e8cd5 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -239,7 +239,7 @@ export class LibP2PService extends WithTracer implements P2PService { p2pPropagationTime, signatureContext: { chainId: config.l1ChainId, - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, }, }; this.blockProposalValidator = new BlockProposalValidator(epochCache, proposalValidatorOpts); diff --git a/yarn-project/p2p/src/testbench/worker_client_manager.ts b/yarn-project/p2p/src/testbench/worker_client_manager.ts index d1e3187bcc4b..4f737e900e6e 100644 --- a/yarn-project/p2p/src/testbench/worker_client_manager.ts +++ b/yarn-project/p2p/src/testbench/worker_client_manager.ts @@ -29,9 +29,7 @@ const tsconfigPath = path.join(p2pRoot, 'tsconfig.json'); const testChainConfig: ChainConfig = { l1ChainId: 31337, rollupVersion: 1, - l1Contracts: { - rollupAddress: EthAddress.random(), - }, + rollupAddress: EthAddress.random(), }; export interface ReqRespBenchmarkConfig { diff --git a/yarn-project/p2p/src/versioning.test.ts b/yarn-project/p2p/src/versioning.test.ts index 72495d82e672..6b98456c47bd 100644 --- a/yarn-project/p2p/src/versioning.test.ts +++ b/yarn-project/p2p/src/versioning.test.ts @@ -23,10 +23,8 @@ describe('versioning', () => { chainConfig = { l1ChainId: 1, - l1Contracts: { - rollupAddress: EthAddress.random(), - }, rollupVersion: 3, + rollupAddress: EthAddress.random(), }; }); @@ -34,7 +32,7 @@ describe('versioning', () => { const versions = setAztecEnrKey(enr, chainConfig); expect(versions.l1ChainId).toEqual(1); expect(versions.rollupVersion).toEqual(3); - expect(versions.l1RollupAddress).toEqual(chainConfig.l1Contracts.rollupAddress); + expect(versions.l1RollupAddress).toEqual(chainConfig.rollupAddress); expect(Buffer.from(versionSet!).toString()).toEqual(compressComponentVersions(versions)); checkCompressedComponentVersion(Buffer.from(versionSet!).toString(), versions); diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts b/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts index 6806947a8ac0..34fc09fe7b06 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker.test.ts @@ -29,9 +29,7 @@ describe.each([ proverBrokerPollIntervalMs: 1000, proverBrokerBatchIntervalMs: 10, proverBrokerBatchSize: 1, - l1Contracts: { - rollupAddress: EthAddress.random(), - } as any, + rollupAddress: EthAddress.random(), }; const database = await KVBrokerDatabase.new(config); const cleanup = () => { diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts index 8eb3ecddfcc1..2f921e75090a 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database/broker_persisted_database.test.ts @@ -1,3 +1,4 @@ +import { randomL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { EpochNumber } from '@aztec/foundation/branded-types'; import { EthAddress } from '@aztec/foundation/eth-address'; import { toArray } from '@aztec/foundation/iterable'; @@ -31,14 +32,12 @@ describe('ProvingBrokerPersistedDatabase', () => { proverBrokerBatchIntervalMs: 10, proverBrokerMaxEpochsToKeepResultsFor: 1, proverBrokerDebugReplayEnabled: false, - l1Contracts: { - rollupAddress: EthAddress.random(), - } as any, l1RpcUrls: [], l1DebugRpcUrls: [], l1ChainId: 42, viemPollingIntervalMS: 100, rollupVersion: 42, + ...randomL1ContractAddresses(), }; db = await KVBrokerDatabase.new(config); }); @@ -313,10 +312,7 @@ describe('ProvingBrokerPersistedDatabase', () => { // Now create another instance const secondDb = await KVBrokerDatabase.new({ ...config, - l1Contracts: { - ...config.l1Contracts, - rollupAddress: EthAddress.random(), - }, + rollupAddress: EthAddress.random(), }); // db should be empty @@ -341,14 +337,12 @@ describe('ProvingBrokerPersistedDatabase', () => { proverBrokerBatchIntervalMs: 10, proverBrokerMaxEpochsToKeepResultsFor: 1, proverBrokerDebugReplayEnabled: false, - l1Contracts: { - rollupAddress: EthAddress.random(), - } as any, l1RpcUrls: [], l1DebugRpcUrls: [], l1ChainId: 42, viemPollingIntervalMS: 100, rollupVersion: 42, + ...randomL1ContractAddresses(), }; db = await KVBrokerDatabase.new(config); commitSpy = jest.spyOn(db, 'commitWrites'); diff --git a/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts b/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts index 504e44c2d240..021a1e64c84c 100644 --- a/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts +++ b/yarn-project/prover-client/src/proving_broker/proving_broker_database/persisted.ts @@ -154,7 +154,7 @@ export class KVBrokerDatabase implements ProvingBrokerDatabase { const db = await openVersionedStoreAt( fullDirectory, SingleEpochDatabase.SCHEMA_VERSION, - config.l1Contracts.rollupAddress, + config.rollupAddress, config.dataStoreMapSizeKb, ); const epochDb = new SingleEpochDatabase(db); @@ -222,7 +222,7 @@ export class KVBrokerDatabase implements ProvingBrokerDatabase { const db = await openVersionedStoreAt( newEpochDirectory, SingleEpochDatabase.SCHEMA_VERSION, - this.config.l1Contracts.rollupAddress, + this.config.rollupAddress, this.config.dataStoreMapSizeKb, ); epochDb = new SingleEpochDatabase(db); diff --git a/yarn-project/prover-client/src/test/proving_broker_testbench.test.ts b/yarn-project/prover-client/src/test/proving_broker_testbench.test.ts index f9a4cf574056..67bff56cdb43 100644 --- a/yarn-project/prover-client/src/test/proving_broker_testbench.test.ts +++ b/yarn-project/prover-client/src/test/proving_broker_testbench.test.ts @@ -3,12 +3,17 @@ * * These benchmarks test the KV database (production configuration) for realistic performance metrics. */ -import { type L1ContractAddresses, L1ContractsNames } from '@aztec/ethereum/l1-contract-addresses'; +import { + type L1ContractAddresses, + L1ContractsNames, + randomL1ContractAddresses, +} from '@aztec/ethereum/l1-contract-addresses'; import { EpochNumber } from '@aztec/foundation/branded-types'; import { times } from '@aztec/foundation/collection'; import { EthAddress } from '@aztec/foundation/eth-address'; // import { createLogger } from '@aztec/foundation/log'; import { Timer } from '@aztec/foundation/timer'; +import { emptyChainConfig } from '@aztec/stdlib/config'; import { ProvingRequestType } from '@aztec/stdlib/proofs'; import { mkdtemp, rm } from 'fs/promises'; @@ -27,11 +32,10 @@ const benchTimer = new Timer(); async function createKVDatabase(l1Contracts?: L1ContractAddresses) { const directory = await mkdtemp(join(tmpdir(), 'proving-broker-bench')); const database = await KVBrokerDatabase.new({ + ...emptyChainConfig, ...defaultProverBrokerConfig, dataDirectory: directory, - l1Contracts: - l1Contracts ?? - (Object.fromEntries(L1ContractsNames.map(name => [name, EthAddress.random()])) as L1ContractAddresses), + ...(l1Contracts ?? randomL1ContractAddresses()), }); return { database, directory }; } @@ -602,9 +606,10 @@ describe('Proving Broker: Benchmarks', () => { const initStart = timer.ms(); databaseHandle = await KVBrokerDatabase.new({ + ...emptyChainConfig, ...defaultProverBrokerConfig, dataDirectory: tempDirectory, - l1Contracts: l1Contracts, + ...l1Contracts, }); // Create new broker instance and measure startup time diff --git a/yarn-project/prover-node/src/bin/run-failed-epoch.ts b/yarn-project/prover-node/src/bin/run-failed-epoch.ts index 9406faef4fc1..dd549a6a3632 100644 --- a/yarn-project/prover-node/src/bin/run-failed-epoch.ts +++ b/yarn-project/prover-node/src/bin/run-failed-epoch.ts @@ -1,6 +1,5 @@ /* eslint-disable no-console */ import { getL1ContractsConfigEnvVars } from '@aztec/ethereum/config'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { EthAddress } from '@aztec/foundation/eth-address'; import { jsonParseWithSchema, jsonStringify } from '@aztec/foundation/json-rpc'; import { createLogger } from '@aztec/foundation/log'; @@ -50,7 +49,7 @@ async function rerunFailedEpoch(provingJobUrl: string, baseLocalDir: string) { logger.info(`Rerunning proving job from ${jobPath} with state from ${dataDir}`, metadata); const result = await rerunEpochProvingJob(jobPath, logger, { ...config, - l1Contracts: { rollupAddress: metadata.rollupAddress } as L1ContractAddresses, + rollupAddress: metadata.rollupAddress, rollupVersion: metadata.rollupVersion, }); diff --git a/yarn-project/prover-node/src/factory.ts b/yarn-project/prover-node/src/factory.ts index 6f2ceda0926d..ec80add799d9 100644 --- a/yarn-project/prover-node/src/factory.ts +++ b/yarn-project/prover-node/src/factory.ts @@ -100,7 +100,7 @@ export async function createProverNode( pollingInterval: config.viemPollingIntervalMS, }); - const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); + const rollupContract = new RollupContract(publicClient, config.rollupAddress.toString()); const l1TxUtils = deps.l1TxUtils ? [deps.l1TxUtils] diff --git a/yarn-project/prover-node/src/prover-node-publisher.test.ts b/yarn-project/prover-node/src/prover-node-publisher.test.ts index bd5af18a337e..f7a22a777829 100644 --- a/yarn-project/prover-node/src/prover-node-publisher.test.ts +++ b/yarn-project/prover-node/src/prover-node-publisher.test.ts @@ -1,5 +1,6 @@ import { BatchedBlob } from '@aztec/blob-lib/types'; import type { RollupContract } from '@aztec/ethereum/contracts'; +import { randomL1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { L1TxUtils } from '@aztec/ethereum/l1-tx-utils'; import { CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; @@ -35,19 +36,7 @@ describe('prover-node-publisher', () => { l1DebugRpcUrls: [], publisherPrivateKeys: [new SecretValue('0x1234')], viemPollingIntervalMS: 1000, - l1Contracts: { - rollupAddress: EthAddress.random(), - registryAddress: EthAddress.random(), - inboxAddress: EthAddress.random(), - outboxAddress: EthAddress.random(), - rewardDistributorAddress: EthAddress.random(), - feeJuicePortalAddress: EthAddress.random(), - coinIssuerAddress: EthAddress.random(), - governanceAddress: EthAddress.random(), - governanceProposerAddress: EthAddress.random(), - feeJuiceAddress: EthAddress.random(), - stakingAssetAddress: EthAddress.random(), - }, + ...randomL1ContractAddresses(), }; }); diff --git a/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts b/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts index ebd772321793..a09cae2c6e12 100644 --- a/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts +++ b/yarn-project/pxe/src/entrypoints/client/bundle/utils.ts @@ -28,10 +28,10 @@ export async function createPXE( const actor = options.loggerActorLabel; const loggers = options.loggers ?? {}; - const l1Contracts = await aztecNode.getL1ContractAddresses(); + const l1ContractAddresses = await aztecNode.getL1ContractAddresses(); const configWithContracts = { ...config, - l1Contracts, + ...l1ContractAddresses, } as PXEConfig; const storeLogger = loggers.store ?? createLogger('pxe:data:idb', { actor }); diff --git a/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts b/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts index 43a6bdf717a7..821a4ae781b9 100644 --- a/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts +++ b/yarn-project/pxe/src/entrypoints/client/lazy/utils.ts @@ -26,10 +26,10 @@ export async function createPXE( ) { const actor = options.loggerActorLabel; - const l1Contracts = await aztecNode.getL1ContractAddresses(); + const l1ContractAddresses = await aztecNode.getL1ContractAddresses(); const configWithContracts = { ...config, - l1Contracts, + ...l1ContractAddresses, } as PXEConfig; const loggers = options.loggers ?? {}; diff --git a/yarn-project/pxe/src/entrypoints/server/utils.ts b/yarn-project/pxe/src/entrypoints/server/utils.ts index d5fec68be4b2..4521175610e5 100644 --- a/yarn-project/pxe/src/entrypoints/server/utils.ts +++ b/yarn-project/pxe/src/entrypoints/server/utils.ts @@ -1,4 +1,5 @@ import { BBBundlePrivateKernelProver } from '@aztec/bb-prover/client/bundle'; +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { createLogger } from '@aztec/foundation/log'; import { createStore } from '@aztec/kv-store/lmdb-v2'; import { BundledProtocolContractsProvider } from '@aztec/protocol-contracts/providers/bundle'; @@ -11,7 +12,10 @@ import { PXE } from '../../pxe.js'; import { PXE_DATA_SCHEMA_VERSION } from '../../storage/index.js'; import { type PXECreationOptions, isPrivateKernelProver } from '../pxe_creation_options.js'; -type PXEConfigWithoutDefaults = Omit; +type PXEConfigWithoutDefaults = Omit< + PXEConfig, + 'l1ChainId' | 'l2BlockBatchSize' | 'rollupVersion' | keyof L1ContractAddresses +>; export async function createPXE( aztecNode: AztecNode, @@ -27,10 +31,10 @@ export async function createPXE( const simulator = new SimulatorRecorderWrapper(new WASMSimulator(simulatorLogger), recorder); const loggers = options.loggers ?? {}; - const { l1ChainId, l1ContractAddresses: l1Contracts, rollupVersion } = await aztecNode.getNodeInfo(); + const { l1ChainId, l1ContractAddresses, rollupVersion } = await aztecNode.getNodeInfo(); const configWithContracts: PXEConfig = { ...config, - l1Contracts, + ...l1ContractAddresses, l1ChainId, rollupVersion, l2BlockBatchSize: 50, diff --git a/yarn-project/pxe/src/pxe.test.ts b/yarn-project/pxe/src/pxe.test.ts index 047fd854b605..701837f21220 100644 --- a/yarn-project/pxe/src/pxe.test.ts +++ b/yarn-project/pxe/src/pxe.test.ts @@ -15,6 +15,7 @@ import { GENESIS_BLOCK_HEADER_HASH, GENESIS_CHECKPOINT_HEADER_HASH, } from '@aztec/stdlib/block'; +import { emptyChainConfig } from '@aztec/stdlib/config'; import { getContractClassFromArtifact } from '@aztec/stdlib/contract'; import type { AztecNode, BlockResponse } from '@aztec/stdlib/interfaces/client'; import { SiloedTag } from '@aztec/stdlib/logs'; @@ -45,10 +46,11 @@ describe('PXE', () => { const kernelProver = new BBBundlePrivateKernelProver(simulator); const protocolContractsProvider = new BundledProtocolContractsProvider(); const config: PXEConfig = { + ...emptyChainConfig, l2BlockBatchSize: 50, dataDirectory: undefined, dataStoreMapSizeKb: 1024 * 1024, - l1Contracts: { rollupAddress: EthAddress.random() }, + rollupAddress: EthAddress.random(), l1ChainId: 31337, rollupVersion: 1, }; diff --git a/yarn-project/sequencer-client/src/client/sequencer-client.ts b/yarn-project/sequencer-client/src/client/sequencer-client.ts index 282733a0b085..8198590eea3e 100644 --- a/yarn-project/sequencer-client/src/client/sequencer-client.ts +++ b/yarn-project/sequencer-client/src/client/sequencer-client.ts @@ -92,7 +92,7 @@ export class SequencerClient { bindings: log.getBindings(), funder: deps.funderL1TxUtils, }); - const rollupContract = new RollupContract(publicClient, config.l1Contracts.rollupAddress.toString()); + const rollupContract = new RollupContract(publicClient, config.rollupAddress.toString()); const [l1GenesisTime, slotDuration, rollupManaLimit] = await Promise.all([ rollupContract.getL1GenesisTime(), rollupContract.getSlotDuration(), @@ -101,12 +101,12 @@ export class SequencerClient { const governanceProposerContract = new GovernanceProposerContract( publicClient, - config.l1Contracts.governanceProposerAddress.toString(), + config.governanceProposerAddress.toString(), ); const epochCache = deps.epochCache ?? (await EpochCache.create( - config.l1Contracts.rollupAddress, + config.rollupAddress, { l1RpcUrls: rpcUrls, l1ChainId: chainId, diff --git a/yarn-project/sequencer-client/src/config.ts b/yarn-project/sequencer-client/src/config.ts index 970d143ceb33..34c14f2a509f 100644 --- a/yarn-project/sequencer-client/src/config.ts +++ b/yarn-project/sequencer-client/src/config.ts @@ -243,8 +243,6 @@ export const sequencerConfigMappings: ConfigMappingsType = { }; export const sequencerClientConfigMappings: ConfigMappingsType = { - // chainConfigMappings must come first: its l1Contracts only maps rollupAddress, - // while l1ReaderConfigMappings (spread later) maps all L1 contract addresses. ...chainConfigMappings, ...validatorClientConfigMappings, ...sequencerConfigMappings, diff --git a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts index 519146884a84..19d1d74b9382 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/fee_provider.ts @@ -27,7 +27,7 @@ export class FeeProviderImpl implements FeeProvider { this.ethereumSlotDuration = config.ethereumSlotDuration; this.l1GenesisTime = config.l1GenesisTime; - this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); + this.rollupContract = new RollupContract(this.publicClient, config.rollupAddress); this.feePredictor = new FeePredictor(this.rollupContract, this.publicClient, this.dateProvider, { slotDuration: config.slotDuration, l1GenesisTime: config.l1GenesisTime, diff --git a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts index 84c9d08fb591..6bba5543958e 100644 --- a/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts +++ b/yarn-project/sequencer-client/src/global_variable_builder/global_builder.ts @@ -3,7 +3,6 @@ import { type SimulationOverridesPlan, buildSimulationOverridesStateOverride, } from '@aztec/ethereum/contracts'; -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import type { ViemPublicClient } from '@aztec/ethereum/types'; import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Fr } from '@aztec/foundation/curves/bn254'; @@ -20,7 +19,7 @@ import { GlobalVariables } from '@aztec/stdlib/tx'; /** Configuration for the GlobalVariableBuilder (excludes L1 client config). */ export type GlobalVariableBuilderConfig = { - l1Contracts: Pick; + rollupAddress: EthAddress; ethereumSlotDuration: number; rollupVersion: bigint; } & Pick; @@ -49,7 +48,7 @@ export class GlobalVariableBuilder implements GlobalVariableBuilderInterface { this.aztecSlotDuration = config.slotDuration; this.l1GenesisTime = config.l1GenesisTime; - this.rollupContract = new RollupContract(this.publicClient, config.l1Contracts.rollupAddress); + this.rollupContract = new RollupContract(this.publicClient, config.rollupAddress); } /** diff --git a/yarn-project/sequencer-client/src/publisher/config.ts b/yarn-project/sequencer-client/src/publisher/config.ts index a990ebc7c021..fe934a2ede88 100644 --- a/yarn-project/sequencer-client/src/publisher/config.ts +++ b/yarn-project/sequencer-client/src/publisher/config.ts @@ -115,7 +115,7 @@ export function getPublisherConfigFromSequencerConfig(config: SequencerPublisher }; } -export const proverTxSenderConfigMappings: ConfigMappingsType> = { +export const proverTxSenderConfigMappings: ConfigMappingsType = { ...l1ReaderConfigMappings, proverPublisherPrivateKeys: { env: `PROVER_PUBLISHER_PRIVATE_KEYS`, @@ -133,7 +133,7 @@ export const proverTxSenderConfigMappings: ConfigMappingsType> = { +export const sequencerTxSenderConfigMappings: ConfigMappingsType = { ...l1ReaderConfigMappings, sequencerPublisherPrivateKeys: { env: `SEQ_PUBLISHER_PRIVATE_KEYS`, diff --git a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts index 6aff7e8c0203..ce8479609636 100644 --- a/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts +++ b/yarn-project/sequencer-client/src/publisher/sequencer-publisher.test.ts @@ -118,10 +118,6 @@ describe('SequencerPublisher', () => { const config = { l1RpcUrls: [`http://127.0.0.1:8545`], l1ChainId: 1, - l1Contracts: { - rollupAddress: EthAddress.ZERO.toString(), - governanceProposerAddress: mockGovernanceProposerAddress, - }, aztecSlotDuration: 36, ...defaultL1TxUtilsConfig, } as unknown as TxSenderConfig & diff --git a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts index 720e699d3d96..3a641f2bb8b3 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_voter.ha.integration.test.ts @@ -236,7 +236,7 @@ describe('CheckpointVoter HA Integration', () => { disabledValidators: [], haSigningEnabled: true, l1ChainId: 1, - l1Contracts: { rollupAddress: EthAddress.fromString(rollupContract.address.toString()) }, + rollupAddress: EthAddress.fromString(rollupContract.address.toString()), nodeId: config.nodeId || 'ha-node-1', pollingIntervalMs: 100, signingTimeoutMs: 3000, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts index f3a23eb0eac4..b2f0828f5341 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.test.ts @@ -360,11 +360,11 @@ describe('sequencer', () => { dateProvider = new TestDateProvider(); signatureContext = { chainId: chainId.toNumber(), rollupAddress: EthAddress.random() }; - const config: SequencerConfig & Pick = { + const config: SequencerConfig & Pick = { enforceTimeTable: true, maxTxsPerBlock: 4, l1ChainId: signatureContext.chainId, - l1Contracts: { rollupAddress: signatureContext.rollupAddress }, + rollupAddress: signatureContext.rollupAddress, }; sequencer = new TestSequencer( publisherFactory, diff --git a/yarn-project/sequencer-client/src/sequencer/sequencer.ts b/yarn-project/sequencer-client/src/sequencer/sequencer.ts index 93a8d5cde177..e49c922f378c 100644 --- a/yarn-project/sequencer-client/src/sequencer/sequencer.ts +++ b/yarn-project/sequencer-client/src/sequencer/sequencer.ts @@ -102,7 +102,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter, + config: SequencerConfig & Pick, protected telemetry: TelemetryClient = getTelemetryClient(), protected log = createLogger('sequencer'), ) { @@ -116,7 +116,7 @@ export class Sequencer extends (EventEmitter as new () => TypedEventEmitter, + l1Contracts: Pick, l1Client: ViemClient, watchers: Watcher[], dateProvider: DateProvider, diff --git a/yarn-project/stdlib/src/config/chain-config.ts b/yarn-project/stdlib/src/config/chain-config.ts index d512d49588a3..f5b06fc64431 100644 --- a/yarn-project/stdlib/src/config/chain-config.ts +++ b/yarn-project/stdlib/src/config/chain-config.ts @@ -1,4 +1,4 @@ -import { l1ContractAddressesMapping } from '@aztec/ethereum/l1-contract-addresses'; +import { type L1ContractAddresses, pickL1ContractAddressMappings } from '@aztec/ethereum/l1-contract-addresses'; import { type ConfigMappingsType, numberConfigHelper } from '@aztec/foundation/config'; import { EthAddress } from '@aztec/foundation/eth-address'; @@ -7,8 +7,8 @@ export { type AllowedElement } from '../interfaces/allowed_element.js'; export const emptyChainConfig: ChainConfig = { l1ChainId: 0, - l1Contracts: { rollupAddress: EthAddress.ZERO }, rollupVersion: 0, + rollupAddress: EthAddress.ZERO, }; export const chainConfigMappings: ConfigMappingsType = { @@ -28,10 +28,7 @@ export const chainConfigMappings: ConfigMappingsType = { return parsed; }, }, - l1Contracts: { - description: 'The deployed L1 contract addresses', - nested: l1ContractAddressesMapping, - }, + ...pickL1ContractAddressMappings('rollupAddress'), }; /** Chain configuration. */ @@ -40,9 +37,4 @@ export type ChainConfig = { l1ChainId: number; /** The version of the rollup. */ rollupVersion: number; - /** The address to the L1 contracts. */ - l1Contracts: { - /** The address to rollup */ - rollupAddress: EthAddress; - }; -}; +} & Pick; diff --git a/yarn-project/stdlib/src/ha-signing/config.ts b/yarn-project/stdlib/src/ha-signing/config.ts index 1c8f768d4972..0a4a84376739 100644 --- a/yarn-project/stdlib/src/ha-signing/config.ts +++ b/yarn-project/stdlib/src/ha-signing/config.ts @@ -1,4 +1,8 @@ -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; +import { + type L1ContractAddresses, + pickL1ContractAddressMappings, + pickL1ContractAddressesSchema, +} from '@aztec/ethereum/l1-contract-addresses'; import { type ConfigMappingsType, SecretValue, @@ -9,7 +13,6 @@ import { optionalNumberConfigHelper, secretStringConfigHelper, } from '@aztec/foundation/config'; -import { EthAddress } from '@aztec/foundation/eth-address'; import type { ZodFor } from '@aztec/foundation/schemas'; import { z } from 'zod'; @@ -17,9 +20,7 @@ import { z } from 'zod'; /** * Base signing protection configuration shared by both HA (Postgres) and local (LMDB) signers. */ -export interface BaseSignerConfig { - /** L1 contract addresses (rollup address required) */ - l1Contracts: Pick; +export type BaseSignerConfig = { /** Unique identifier for this node */ nodeId: string; /** How long to wait between polls when a duty is being signed (ms) */ @@ -30,18 +31,9 @@ export interface BaseSignerConfig { maxStuckDutiesAgeMs?: number; /** Optional: clean up old duties after this many hours (disabled if not set) */ cleanupOldDutiesAfterHours?: number; -} +} & Pick; export const baseSignerConfigMappings: ConfigMappingsType = { - l1Contracts: { - description: 'L1 contract addresses (rollup address required)', - nested: { - rollupAddress: { - description: 'The Ethereum address of the rollup contract (must be set programmatically)', - parseEnv: (val: string) => EthAddress.fromString(val), - }, - }, - }, nodeId: { env: 'VALIDATOR_HA_NODE_ID', description: 'The unique identifier for this node', @@ -67,15 +59,16 @@ export const baseSignerConfigMappings: ConfigMappingsType = { description: 'Optional: clean up old duties after this many hours (disabled if not set)', ...optionalNumberConfigHelper(), }, + ...pickL1ContractAddressMappings('rollupAddress'), }; export const BaseSignerConfigSchema = z.object({ - l1Contracts: z.object({ rollupAddress: z.instanceof(EthAddress) }), nodeId: z.string(), pollingIntervalMs: z.number().min(0), signingTimeoutMs: z.number().min(0), maxStuckDutiesAgeMs: z.number().min(0).optional(), cleanupOldDutiesAfterHours: z.number().min(0).optional(), + ...pickL1ContractAddressesSchema('rollupAddress'), }) satisfies ZodFor; /** diff --git a/yarn-project/stdlib/src/interfaces/archiver.ts b/yarn-project/stdlib/src/interfaces/archiver.ts index 005958289b8d..39b3f1a906d8 100644 --- a/yarn-project/stdlib/src/interfaces/archiver.ts +++ b/yarn-project/stdlib/src/interfaces/archiver.ts @@ -1,4 +1,3 @@ -import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { BlockNumberSchema, CheckpointNumberSchema, EpochNumberSchema } from '@aztec/foundation/branded-types'; import type { ApiSchemaFor } from '@aztec/foundation/schemas'; @@ -49,9 +48,6 @@ export type ArchiverSpecificConfig = { /** The polling interval viem uses in ms */ viemPollingIntervalMS?: number; - /** The deployed L1 contract addresses */ - l1Contracts: L1ContractAddresses; - /** The max number of logs that can be obtained in 1 "getPublicLogs" call. */ maxLogs?: number; diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts index 8b42f9ba73dd..f6b72d85bb31 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.test.ts @@ -136,9 +136,7 @@ class MockAztecNodeAdmin implements AztecNodeAdmin { maxStuckDutiesAgeMs: 72000, dataStoreMapSizeKb: 128 * 1024 * 1024, l1ChainId: 1, - l1Contracts: { - rollupAddress: EthAddress.random(), - }, + rollupAddress: EthAddress.random(), }); } startSnapshotUpload(_location: string): Promise { diff --git a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts index fb4e062682b9..309b7ea6f94b 100644 --- a/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts +++ b/yarn-project/stdlib/src/interfaces/aztec-node-admin.ts @@ -1,3 +1,4 @@ +import type { L1ContractAddresses } from '@aztec/ethereum/l1-contract-addresses'; import { createSafeJsonRpcClient, defaultFetch } from '@aztec/foundation/json-rpc/client'; import { z } from 'zod'; @@ -71,8 +72,8 @@ export interface AztecNodeAdmin { reloadKeystore(): Promise; } -// L1 contracts are not mutable via admin updates. -export type AztecNodeAdminConfig = Omit & +// L1 contract addresses are pinned at startup and are not mutable via admin updates. +export type AztecNodeAdminConfig = Omit & SequencerConfig & ProverConfig & SlasherConfig & @@ -87,7 +88,7 @@ export type AztecNodeAdminConfig = Omit>; export const dataConfigMappings: ConfigMappingsType = { dataDirectory: { @@ -18,12 +16,7 @@ export const dataConfigMappings: ConfigMappingsType = { description: 'The maximum possible size of a data store DB in KB. Can be overridden by component-specific options.', ...numberConfigHelper(128 * 1_024 * 1_024), // Defaulted to 128 GB }, - l1Contracts: { - description: 'The deployed L1 contract addresses', - nested: { - rollupAddress: l1ContractAddressesMapping.rollupAddress, - }, - }, + ...pickL1ContractAddressMappings('rollupAddress'), }; /** diff --git a/yarn-project/stdlib/src/versioning/versioning.ts b/yarn-project/stdlib/src/versioning/versioning.ts index 32efb4da71de..df7b0f4da241 100644 --- a/yarn-project/stdlib/src/versioning/versioning.ts +++ b/yarn-project/stdlib/src/versioning/versioning.ts @@ -26,7 +26,7 @@ export function getComponentsVersionsFromConfig( ): ComponentsVersions { return { l1ChainId: config.l1ChainId, - l1RollupAddress: config.l1Contracts?.rollupAddress, // This should not be undefined, but sometimes the config lies to us and it is... + l1RollupAddress: config.rollupAddress, rollupVersion: config.rollupVersion, l2ProtocolContractsHash: l2ProtocolContractsHash.toString(), l2CircuitsVkTreeRoot: l2CircuitsVkTreeRoot.toString(), diff --git a/yarn-project/validator-client/src/factory.ts b/yarn-project/validator-client/src/factory.ts index 32a8491c24ad..9917d528ca9f 100644 --- a/yarn-project/validator-client/src/factory.ts +++ b/yarn-project/validator-client/src/factory.ts @@ -35,7 +35,7 @@ export function createProposalHandler( maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint, signatureContext: { chainId: config.l1ChainId, - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, }, }); return new ProposalHandler( diff --git a/yarn-project/validator-client/src/proposal_handler.test.ts b/yarn-project/validator-client/src/proposal_handler.test.ts index ab85a7287b47..b464dd7cd7a9 100644 --- a/yarn-project/validator-client/src/proposal_handler.test.ts +++ b/yarn-project/validator-client/src/proposal_handler.test.ts @@ -85,7 +85,7 @@ describe('ProposalHandler checkpoint validation', () => { config = { l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, - l1Contracts: { rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress }, + rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress, } as ValidatorClientFullConfig; handler = new ProposalHandler( diff --git a/yarn-project/validator-client/src/validator.ha.integration.test.ts b/yarn-project/validator-client/src/validator.ha.integration.test.ts index e3b523d13493..81c6cbd2985c 100644 --- a/yarn-project/validator-client/src/validator.ha.integration.test.ts +++ b/yarn-project/validator-client/src/validator.ha.integration.test.ts @@ -142,7 +142,7 @@ describe('ValidatorClient HA Integration', () => { disableValidator: false, disabledValidators: [], slashBroadcastedInvalidBlockPenalty: 1n, - l1Contracts: { rollupAddress }, + rollupAddress, l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, slashDuplicateProposalPenalty: 1n, slashDuplicateAttestationPenalty: 1n, diff --git a/yarn-project/validator-client/src/validator.integration.test.ts b/yarn-project/validator-client/src/validator.integration.test.ts index 249d6e026043..ef320186d103 100644 --- a/yarn-project/validator-client/src/validator.integration.test.ts +++ b/yarn-project/validator-client/src/validator.integration.test.ts @@ -109,7 +109,7 @@ describe('ValidatorClient Integration', () => { // production wiring (see aztec-node/server.ts). Both sides must agree on the genesis hash for // L2BlockStream's `areBlockHashesEqualAt` check to succeed at block 0. const wsConfig = { - l1Contracts: { rollupAddress }, + rollupAddress, worldStateBlockCheckIntervalMS: 20, worldStateBlockRequestBatchSize: 10, worldStateDbMapSizeKb: 1024 * 1024, @@ -170,7 +170,7 @@ describe('ValidatorClient Integration', () => { // Create and start validator const validator = await ValidatorClient.new( { - l1Contracts: { rollupAddress }, + rollupAddress, l1ChainId: chainId.toNumber(), validatorPrivateKeys: new SecretValue([privateKey]), attestationPollingIntervalMs: 100, diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 0e891cba35ef..a54a0d62873c 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -184,7 +184,7 @@ describe('ValidatorClient', () => { disableTransactions: false, haSigningEnabled: false, l1ChainId: TEST_COORDINATION_SIGNATURE_CONTEXT.chainId, - l1Contracts: { rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress }, + rollupAddress: TEST_COORDINATION_SIGNATURE_CONTEXT.rollupAddress, nodeId: 'test-node-id', pollingIntervalMs: 1000, signingTimeoutMs: 1000, diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index d92a51717233..f8c85442b676 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -204,7 +204,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) maxBlocksPerCheckpoint: config.maxBlocksPerCheckpoint, signatureContext: { chainId: config.l1ChainId, - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, }, }); const proposalHandler = new ProposalHandler( @@ -288,7 +288,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) private getSignatureContext(): CoordinationSignatureContext { return { chainId: this.config.l1ChainId, - rollupAddress: this.config.l1Contracts.rollupAddress, + rollupAddress: this.config.rollupAddress, }; } diff --git a/yarn-project/validator-ha-signer/src/db/lmdb.test.ts b/yarn-project/validator-ha-signer/src/db/lmdb.test.ts index fdba9399fce6..2e67f6c826d5 100644 --- a/yarn-project/validator-ha-signer/src/db/lmdb.test.ts +++ b/yarn-project/validator-ha-signer/src/db/lmdb.test.ts @@ -449,7 +449,7 @@ describe('LmdbSlashingProtectionDatabase - schema migration', () => { }); const createConfig = () => ({ - l1Contracts: { rollupAddress: ROLLUP_ADDRESS }, + rollupAddress: ROLLUP_ADDRESS, nodeId: NODE_ID, pollingIntervalMs: 100, signingTimeoutMs: 3_000, diff --git a/yarn-project/validator-ha-signer/src/factory.ts b/yarn-project/validator-ha-signer/src/factory.ts index e04ca013b34f..14ced7f09512 100644 --- a/yarn-project/validator-ha-signer/src/factory.ts +++ b/yarn-project/validator-ha-signer/src/factory.ts @@ -125,7 +125,7 @@ export async function createLocalSignerWithProtection( { dataDirectory: config.dataDirectory, dataStoreMapSizeKb: config.signingProtectionMapSizeKb ?? config.dataStoreMapSizeKb, - l1Contracts: config.l1Contracts, + rollupAddress: config.rollupAddress, }, undefined, { @@ -175,7 +175,7 @@ export function createSignerFromSharedDb( db: SlashingProtectionDatabase, config: Pick< ValidatorHASignerConfig, - 'nodeId' | 'pollingIntervalMs' | 'signingTimeoutMs' | 'maxStuckDutiesAgeMs' | 'l1Contracts' + 'nodeId' | 'pollingIntervalMs' | 'signingTimeoutMs' | 'maxStuckDutiesAgeMs' | 'rollupAddress' >, deps?: CreateLocalSignerWithProtectionDeps, ): { signer: ValidatorHASigner; db: SlashingProtectionDatabase } { diff --git a/yarn-project/validator-ha-signer/src/slashing_protection_service.test.ts b/yarn-project/validator-ha-signer/src/slashing_protection_service.test.ts index 21a2cce62e82..576446c86ac6 100644 --- a/yarn-project/validator-ha-signer/src/slashing_protection_service.test.ts +++ b/yarn-project/validator-ha-signer/src/slashing_protection_service.test.ts @@ -52,7 +52,7 @@ describe('SlashingProtectionService', () => { dateProvider = new TestDateProvider(); config = { - l1Contracts: { rollupAddress: ROLLUP_ADDRESS }, + rollupAddress: ROLLUP_ADDRESS, nodeId: NODE_ID, pollingIntervalMs: 50, signingTimeoutMs: 1000, @@ -615,7 +615,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: rollupAddress1 }, + rollupAddress: rollupAddress1, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -623,7 +623,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: rollupAddress2 }, + rollupAddress: rollupAddress2, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -704,7 +704,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: oldRollupAddress }, + rollupAddress: oldRollupAddress, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -712,7 +712,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: newRollupAddress }, + rollupAddress: newRollupAddress, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -784,7 +784,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: rollupAddress1 }, + rollupAddress: rollupAddress1, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -908,7 +908,7 @@ describe('SlashingProtectionService', () => { db, { ...config, - l1Contracts: { rollupAddress: newRollupAddress }, + rollupAddress: newRollupAddress, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); diff --git a/yarn-project/validator-ha-signer/src/slashing_protection_service.ts b/yarn-project/validator-ha-signer/src/slashing_protection_service.ts index 0b7d97f15ea8..f545a8c2634f 100644 --- a/yarn-project/validator-ha-signer/src/slashing_protection_service.ts +++ b/yarn-project/validator-ha-signer/src/slashing_protection_service.ts @@ -241,10 +241,10 @@ export class SlashingProtectionService { */ async start() { // One-time cleanup at startup: remove duties from previous rollup versions - const numOutdatedRollupDuties = await this.db.cleanupOutdatedRollupDuties(this.config.l1Contracts.rollupAddress); + const numOutdatedRollupDuties = await this.db.cleanupOutdatedRollupDuties(this.config.rollupAddress); if (numOutdatedRollupDuties > 0) { this.log.info(`Cleaned up ${numOutdatedRollupDuties} duties with outdated rollup address at startup`, { - currentRollupAddress: this.config.l1Contracts.rollupAddress.toString(), + currentRollupAddress: this.config.rollupAddress.toString(), }); this.metrics.recordCleanup('outdated_rollup', numOutdatedRollupDuties); } diff --git a/yarn-project/validator-ha-signer/src/validator_ha_signer.test.ts b/yarn-project/validator-ha-signer/src/validator_ha_signer.test.ts index 47cabea31c24..d71b0d11e78a 100644 --- a/yarn-project/validator-ha-signer/src/validator_ha_signer.test.ts +++ b/yarn-project/validator-ha-signer/src/validator_ha_signer.test.ts @@ -49,7 +49,7 @@ describe('ValidatorHASigner', () => { dateProvider = new TestDateProvider(); config = { - l1Contracts: { rollupAddress: EthAddress.random() }, + rollupAddress: EthAddress.random(), nodeId: NODE_ID, pollingIntervalMs: 50, signingTimeoutMs: 1000, @@ -70,7 +70,7 @@ describe('ValidatorHASigner', () => { it('should not initialize when nodeId is not explicitly set', () => { const defaultConfig = { ...defaultValidatorHASignerConfig, - l1Contracts: { rollupAddress: EthAddress.random() }, + rollupAddress: EthAddress.random(), }; const metrics = new HASignerMetrics(telemetryClient, 'test-node'); expect(() => new ValidatorHASigner(db, defaultConfig, { metrics, dateProvider })).toThrow( @@ -124,7 +124,7 @@ describe('ValidatorHASigner', () => { // Verify duty was recorded const dutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -160,7 +160,7 @@ describe('ValidatorHASigner', () => { // Verify duty was deleted const dutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -275,7 +275,7 @@ describe('ValidatorHASigner', () => { // Verify both duties exist const blockDutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -286,7 +286,7 @@ describe('ValidatorHASigner', () => { nodeId: NODE_ID, }); const attestationDutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(0), @@ -363,7 +363,7 @@ describe('ValidatorHASigner', () => { // Verify both duties exist const blockDutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -374,7 +374,7 @@ describe('ValidatorHASigner', () => { nodeId: NODE_ID, }); const blockDutyResult2 = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -436,7 +436,7 @@ describe('ValidatorHASigner', () => { // Verify all three duties exist in database const block0Result = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot, blockNumber, @@ -447,7 +447,7 @@ describe('ValidatorHASigner', () => { nodeId: NODE_ID, }); const block1Result = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot, blockNumber, @@ -458,7 +458,7 @@ describe('ValidatorHASigner', () => { nodeId: NODE_ID, }); const checkpointResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot, blockNumber: BlockNumber(0), @@ -623,7 +623,7 @@ describe('ValidatorHASigner', () => { // Verify both duties were recorded with blockNumber = 0 const governanceResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(0), // getBlockNumberFromSigningContext returns 0 for vote duties @@ -633,7 +633,7 @@ describe('ValidatorHASigner', () => { nodeId: NODE_ID, }); const slashingResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(0), @@ -871,7 +871,7 @@ describe('ValidatorHASigner', () => { // Verify the duty is recorded in the database with the winning nodeId const dutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: sameSlot, blockNumber: sameBlockNumber, @@ -997,7 +997,7 @@ describe('ValidatorHASigner', () => { // Verify the duty is marked as signed by the second signer const dutyResult = await db.tryInsertOrGetExisting({ - rollupAddress: config.l1Contracts.rollupAddress, + rollupAddress: config.rollupAddress, validatorAddress: VALIDATOR_ADDRESS, slot: SlotNumber(100), blockNumber: BlockNumber(50), @@ -1022,7 +1022,7 @@ describe('ValidatorHASigner', () => { db, { ...config, - l1Contracts: { rollupAddress: oldRollupAddress }, + rollupAddress: oldRollupAddress, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); @@ -1053,7 +1053,7 @@ describe('ValidatorHASigner', () => { db, { ...config, - l1Contracts: { rollupAddress: newRollupAddress }, + rollupAddress: newRollupAddress, }, { metrics: new HASignerMetrics(telemetryClient, config.nodeId), dateProvider }, ); diff --git a/yarn-project/validator-ha-signer/src/validator_ha_signer.ts b/yarn-project/validator-ha-signer/src/validator_ha_signer.ts index 8ad988b3cd55..777c028a2285 100644 --- a/yarn-project/validator-ha-signer/src/validator_ha_signer.ts +++ b/yarn-project/validator-ha-signer/src/validator_ha_signer.ts @@ -68,7 +68,7 @@ export class ValidatorHASigner { if (!config.nodeId || config.nodeId === '') { throw new Error('NODE_ID is required for high-availability setups'); } - this.rollupAddress = config.l1Contracts.rollupAddress; + this.rollupAddress = config.rollupAddress; this.slashingProtection = new SlashingProtectionService(db, config, { metrics: deps.metrics, dateProvider: deps.dateProvider, diff --git a/yarn-project/wallets/src/embedded/entrypoints/browser.ts b/yarn-project/wallets/src/embedded/entrypoints/browser.ts index d32bdc02d957..6237e53cf240 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/browser.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/browser.ts @@ -62,7 +62,7 @@ export class BrowserEmbeddedWallet extends EmbeddedWallet { { dataDirectory: `wallet_data_${l1Contracts.rollupAddress}`, dataStoreMapSizeKb: pxeConfig.dataStoreMapSizeKb, - l1Contracts, + rollupAddress: l1Contracts.rollupAddress, }, 1, rootLogger.createChild('wallet:data'), diff --git a/yarn-project/wallets/src/embedded/entrypoints/node.ts b/yarn-project/wallets/src/embedded/entrypoints/node.ts index 6417be006560..0973d5024133 100644 --- a/yarn-project/wallets/src/embedded/entrypoints/node.ts +++ b/yarn-project/wallets/src/embedded/entrypoints/node.ts @@ -70,7 +70,7 @@ export class NodeEmbeddedWallet extends EmbeddedWallet { { dataDirectory: `wallet_data_${l1Contracts.rollupAddress}`, dataStoreMapSizeKb: pxeConfig.dataStoreMapSizeKb, - l1Contracts, + rollupAddress: l1Contracts.rollupAddress, }, rootLogger.createChild('wallet:data').getBindings(), )); diff --git a/yarn-project/world-state/src/synchronizer/factory.ts b/yarn-project/world-state/src/synchronizer/factory.ts index 5f8c2a5f52f9..f306ea1aedd2 100644 --- a/yarn-project/world-state/src/synchronizer/factory.ts +++ b/yarn-project/world-state/src/synchronizer/factory.ts @@ -43,7 +43,7 @@ export async function createWorldState( | 'messageTreeMapSizeKb' | 'publicDataTreeMapSizeKb' > & - Pick, + Pick, genesis: GenesisData = EMPTY_GENESIS_DATA, instrumentation: WorldStateInstrumentation = new WorldStateInstrumentation(getTelemetryClient()), bindings?: LoggerBindings, @@ -58,14 +58,14 @@ export async function createWorldState( publicDataTreeMapSizeKb: config.publicDataTreeMapSizeKb ?? dataStoreMapSizeKb, }; - if (!config.l1Contracts?.rollupAddress) { + if (!config.rollupAddress) { throw new Error('Rollup address is required to create a world state synchronizer.'); } // If a data directory is provided in config, then create a persistent store. const merkleTrees = dataDirectory ? await NativeWorldStateService.new( - config.l1Contracts.rollupAddress, + config.rollupAddress, dataDirectory, wsTreeMapSizes, genesis, @@ -73,7 +73,7 @@ export async function createWorldState( bindings, ) : await NativeWorldStateService.tmp( - config.l1Contracts.rollupAddress, + config.rollupAddress, !['true', '1'].includes(process.env.DEBUG_WORLD_STATE!), genesis, instrumentation, diff --git a/yarn-project/world-state/src/test/integration.test.ts b/yarn-project/world-state/src/test/integration.test.ts index a594ac0bf229..a1c1b9d3dcd8 100644 --- a/yarn-project/world-state/src/test/integration.test.ts +++ b/yarn-project/world-state/src/test/integration.test.ts @@ -49,7 +49,7 @@ describe('world-state integration', () => { config = { dataDirectory: undefined, dataStoreMapSizeKb: 1024 * 1024, - l1Contracts: { rollupAddress }, + rollupAddress, worldStateBlockCheckIntervalMS: 20, worldStateBlockRequestBatchSize: 5, worldStateDbMapSizeKb: 1024 * 1024, From 371e11d4085dd33c7d6851b8d5bbf06457dc9dda Mon Sep 17 00:00:00 2001 From: Aztec Bot <49558828+AztecBot@users.noreply.github.com> Date: Tue, 12 May 2026 07:11:50 -0400 Subject: [PATCH 40/55] test(e2e): bump bash TIMEOUT for e2e_p2p/add_rollup to match jest 20m (#23177) --- yarn-project/end-to-end/bootstrap.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/yarn-project/end-to-end/bootstrap.sh b/yarn-project/end-to-end/bootstrap.sh index bbb731adaa7f..e1f55e3090eb 100755 --- a/yarn-project/end-to-end/bootstrap.sh +++ b/yarn-project/end-to-end/bootstrap.sh @@ -44,6 +44,14 @@ function test_cmds { local name=${test#*e2e_} name=e2e_${name%.test.ts} + # Per-test bash TIMEOUT overrides — keep in sync with the test file's jest.setTimeout. + local test_prefix="$prefix" + case "$name" in + e2e_p2p/add_rollup) + test_prefix="$prefix:TIMEOUT=20m" + ;; + esac + # Check if this is a .parallel.test.ts file if [[ "$test" == *.parallel.test.ts ]]; then # Extract individual test names and create a command for each @@ -51,11 +59,11 @@ function test_cmds { # Create a safe name for the individual test (replace spaces with underscores) local safe_test_name=$(echo "$test_name" | sed 's/ /_/g') local full_name="${name}_${safe_test_name}" - echo "$prefix:NAME=$full_name $(set_dump_avm $full_name) $run_test_script simple $test \"$test_name\"" + echo "$test_prefix:NAME=$full_name $(set_dump_avm $full_name) $run_test_script simple $test \"$test_name\"" done < <(extract_test_names "$test") else # Regular test file - run the whole file - echo "$prefix:NAME=$name $(set_dump_avm $name) $run_test_script simple $test" + echo "$test_prefix:NAME=$name $(set_dump_avm $name) $run_test_script simple $test" fi done From b9f7b339901e3612fcbe668cf6067c1e402a1269 Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Tue, 12 May 2026 13:07:19 +0100 Subject: [PATCH 41/55] fix(p2p): chunk archive of mined txs on block finalization (A-969) (#23085) ## Summary `handleFinalizedBlock` was hydrating every mined-finalized tx into a single in-memory array before archiving and deleting them. This change processes the mined txs in chunks of 100. Peak memory is bounded regardless of epoch size. The deleted-pool finalize step runs once at the end after all chunks are processed. Fixes A-969. ## Test plan - New unit test `archives and deletes all mined txs across many chunks when finalizing a single block` that finalizes 250 txs in a single call (above the 100-tx chunk size) and checks that all are archived and marked deleted. - All 239 existing `tx_pool_v2.test.ts` cases still pass. --- .../mem_pools/tx_pool_v2/tx_pool_v2.test.ts | 18 ++++++++++ .../mem_pools/tx_pool_v2/tx_pool_v2_impl.ts | 34 ++++++++++++------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts index 66f96bdbaa67..33a52a8ebd25 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2.test.ts @@ -3139,6 +3139,24 @@ describe('TxPoolV2', () => { expect(await pool.getArchivedTxByHash(txs[3].getTxHash())).toBeDefined(); expect(await pool.getArchivedTxByHash(txs[4].getTxHash())).toBeDefined(); }); + + it('archives and deletes all mined txs across many chunks when finalizing a single block', async () => { + // Use a count larger than the internal FINALIZE_BLOCK_CHUNK_SIZE (100) so we exercise + // the chunked path of handleFinalizedBlock. + const txCount = 250; + await pool.updateConfig({ archivedTxLimit: txCount }); + + const txs = await timesAsync(txCount, i => mockTx(i + 1)); + await pool.addPendingTxs(txs); + await pool.handleMinedBlock(makeBlock(txs, slot1Header)); + + await pool.handleFinalizedBlock(slot1Header); + + for (const tx of txs) { + expect(await pool.getTxStatus(tx.getTxHash())).toBe('deleted'); + expect(await pool.getArchivedTxByHash(tx.getTxHash())).toBeDefined(); + } + }); }); describe('nullifier index consistency', () => { diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index fa9d256e18b7..2039402181cc 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -40,6 +40,13 @@ import { import { type TxMetaData, type TxState, buildTxMetaData, checkNullifierConflict } from './tx_metadata.js'; import { TxPoolIndices } from './tx_pool_indices.js'; +/** + * Maximum number of full transactions to load into memory at once when finalizing a block. + * Bounds peak memory while archiving and hard-deleting mined txs (~23k txs/epoch at 10 TPS would + * otherwise OOM the node). + */ +const FINALIZE_BLOCK_CHUNK_SIZE = 100; + /** * Callbacks for the implementation to notify the outer class about events and metrics. */ @@ -640,28 +647,29 @@ export class TxPoolV2Impl { // Step 1: Find mined txs at or before finalized block const minedTxsToFinalize = this.#indices.findTxsMinedAtOrBefore(blockNumber); - await this.#store.transactionAsync(async () => { - // Step 2: Collect mined txs for archiving (before deletion) - const txsToArchive: Tx[] = []; - if (this.#archive.isEnabled()) { - for (const txHashStr of minedTxsToFinalize) { + // Step 2: Archive in chunks if archiving is enabled. Hydrating an entire epoch's worth of + // mined txs at once would OOM under load. When archiving is disabled there is no need to hydrate the txs at all. + if (this.#archive.isEnabled()) { + for (let i = 0; i < minedTxsToFinalize.length; i += FINALIZE_BLOCK_CHUNK_SIZE) { + const chunk = minedTxsToFinalize.slice(i, i + FINALIZE_BLOCK_CHUNK_SIZE); + const txsToArchive: Tx[] = []; + for (const txHashStr of chunk) { const buffer = await this.#txsDB.getAsync(txHashStr); if (buffer) { txsToArchive.push(Tx.fromBuffer(buffer)); } } + if (txsToArchive.length > 0) { + await this.#archive.archiveTxs(txsToArchive); + } } + } - // Step 3: Delete mined txs from active pool + // Step 3: Delete mined txs from the active pool and finalize soft-deleted txs in one + // transaction. Only tx hashes are touched here, so memory is bounded and atomicity is preserved. + await this.#store.transactionAsync(async () => { await this.#deleteTxsBatch(minedTxsToFinalize); - - // Step 4: Finalize soft-deleted txs await this.#deletedPool.finalizeBlock(blockNumber); - - // Step 5: Archive mined txs - if (txsToArchive.length > 0) { - await this.#archive.archiveTxs(txsToArchive); - } }); if (minedTxsToFinalize.length > 0) { From 26d9543d0cd88e37b88d1d06826f8a72b753e2bc Mon Sep 17 00:00:00 2001 From: PhilWindle <60546371+PhilWindle@users.noreply.github.com> Date: Tue, 12 May 2026 13:07:21 +0100 Subject: [PATCH 42/55] fix(p2p): stream tx pool hydration to bound startup memory (A-968) (#23086) ## Summary `hydrateFromDatabase` was deserializing every persisted tx into a single `{ tx, meta }[]` array before doing anything else. Stream the txs DB instead: deserialize, build metadata, and resolve mined status one tx at a time, dropping the Tx reference before moving on. Only the much smaller `TxMetaData` objects accumulate. The previously separate `#loadAllTxsFromDb` and `#markMinedStatusBatch` helpers fold into the streaming loop (they were only used here). Fixes A-968. ## Test plan - All 238 existing `tx_pool_v2.test.ts` cases pass (including the hydration / restart suite). - All 25 `tx_pool_v2.compat.test.ts` cases pass. --- .../mem_pools/tx_pool_v2/tx_pool_v2_impl.ts | 117 ++++++++---------- 1 file changed, 50 insertions(+), 67 deletions(-) diff --git a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts index 2039402181cc..51283ab6ecc5 100644 --- a/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts +++ b/yarn-project/p2p/src/mem_pools/tx_pool_v2/tx_pool_v2_impl.ts @@ -147,39 +147,67 @@ export class TxPoolV2Impl { // Step 0: Hydrate deleted pool state await this.#deletedPool.hydrateFromDatabase(); - // Step 1: Load all transactions from DB (excluding soft-deleted) - const { loaded, errors: deserializationErrors } = await this.#loadAllTxsFromDb(); - - // Step 2: Check mined status for each tx - await this.#markMinedStatusBatch(loaded.map(l => l.meta)); - - // Step 3: Partition by mined status - const mined: TxMetaData[] = []; - const nonMined: { tx: Tx; meta: TxMetaData }[] = []; - for (const entry of loaded) { - if (entry.meta.minedL2BlockId !== undefined) { - mined.push(entry.meta); + // Step 1: Stream txs from DB, building metadata and mined status one at a time. + const minedMetas: TxMetaData[] = []; + const pendingMetas: TxMetaData[] = []; + const deserializationErrors: string[] = []; + + for await (const [txHashStr, buffer] of this.#txsDB.entriesAsync()) { + // Skip soft-deleted transactions - they stay in DB but not in indices + if (this.#deletedPool.isSoftDeleted(txHashStr)) { + continue; + } + + let meta: TxMetaData; + let txEffect: Awaited>; + try { + const tx = Tx.fromBuffer(buffer); + // Resolve allowed-setup-calls and the tx effect concurrently + // getTxEffect failures are non-fatal (we just treat the tx as not-yet-mined), so + // its rejection is swallowed before the Promise.all so it can't fail-fast the batch. + const txEffectPromise = this.#l2BlockSource.getTxEffect(tx.getTxHash()).catch(err => { + this.#log.warn(`Failed to check mined status for tx ${txHashStr}`, { err }); + return undefined; + }); + const [allowedSetupCalls, fetchedTxEffect] = await Promise.all([ + this.#checkAllowedSetupCalls(tx), + txEffectPromise, + ]); + meta = await buildTxMetaData(tx, allowedSetupCalls); + txEffect = fetchedTxEffect; + } catch (err) { + this.#log.warn(`Failed to deserialize tx ${txHashStr}, deleting`, { err }); + deserializationErrors.push(txHashStr); + continue; + } + + if (txEffect) { + meta.minedL2BlockId = { + number: txEffect.l2BlockNumber, + hash: txEffect.l2BlockHash.toString(), + }; + } + + if (meta.minedL2BlockId !== undefined) { + minedMetas.push(meta); } else { - nonMined.push(entry); + pendingMetas.push(meta); } } - // Step 4: Validate non-mined transactions - const { valid, invalid } = await this.#revalidateMetadata( - nonMined.map(e => e.meta), - 'on startup', - ); + // Step 2: Validate non-mined transactions + const { valid, invalid } = await this.#revalidateMetadata(pendingMetas, 'on startup'); - // Step 5: Populate mined indices (these don't need conflict resolution) - for (const meta of mined) { + // Step 3: Populate mined indices (these don't need conflict resolution) + for (const meta of minedMetas) { this.#indices.addMined(meta); } - // Step 6: Rebuild pending pool by running pre-add rules for each tx + // Step 4: Rebuild pending pool by running pre-add rules for each tx // This resolves nullifier conflicts, fee payer balance issues, and pool size limits const { rejected } = await this.#rebuildPendingPool(valid); - // Step 7: Delete invalid and rejected txs from DB only (indices were never populated for these) + // Step 5: Delete invalid and rejected txs from DB only (indices were never populated for these) const toDelete = [...deserializationErrors, ...invalid, ...rejected]; if (toDelete.length === 0) { return; @@ -984,51 +1012,6 @@ export class TxPoolV2Impl { }; } - /** Loads all transactions from the database, returning loaded txs and deserialization errors */ - async #loadAllTxsFromDb(): Promise<{ - loaded: { tx: Tx; meta: TxMetaData }[]; - errors: string[]; - }> { - const loaded: { tx: Tx; meta: TxMetaData }[] = []; - const errors: string[] = []; - - for await (const [txHashStr, buffer] of this.#txsDB.entriesAsync()) { - // Skip soft-deleted transactions - they stay in DB but not in indices - if (this.#deletedPool.isSoftDeleted(txHashStr)) { - continue; - } - - try { - const tx = Tx.fromBuffer(buffer); - const allowedSetupCalls = await this.#checkAllowedSetupCalls(tx); - const meta = await buildTxMetaData(tx, allowedSetupCalls); - loaded.push({ tx, meta }); - } catch (err) { - this.#log.warn(`Failed to deserialize tx ${txHashStr}, deleting`, { err }); - errors.push(txHashStr); - } - } - - return { loaded, errors }; - } - - /** Queries block source and marks mined status on transaction metadata */ - async #markMinedStatusBatch(metas: TxMetaData[]): Promise { - for (const meta of metas) { - try { - const txEffect = await this.#l2BlockSource.getTxEffect(TxHash.fromString(meta.txHash)); - if (txEffect) { - meta.minedL2BlockId = { - number: txEffect.l2BlockNumber, - hash: txEffect.l2BlockHash.toString(), - }; - } - } catch (err) { - this.#log.warn(`Failed to check mined status for tx ${meta.txHash}`, { err }); - } - } - } - /** * Rebuilds the pending pool by processing each tx through pre-add rules. * Starts with an empty pending pool and adds txs one by one, resolving conflicts. From dd65b1a9ab0075202d754770aad9d9c3fad58135 Mon Sep 17 00:00:00 2001 From: AztecBot Date: Tue, 12 May 2026 13:07:59 +0000 Subject: [PATCH 43/55] ci: daily merge-train/spartan stale-PR notifier (proposal) --- .../workflows/merge-train-stale-check.yml | 22 +++++++ ci3/merge_train_stale_check | 57 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 .github-new/workflows/merge-train-stale-check.yml create mode 100755 ci3/merge_train_stale_check diff --git a/.github-new/workflows/merge-train-stale-check.yml b/.github-new/workflows/merge-train-stale-check.yml new file mode 100644 index 000000000000..d6e60a7caa6a --- /dev/null +++ b/.github-new/workflows/merge-train-stale-check.yml @@ -0,0 +1,22 @@ +name: Merge-Train Stale Check + +on: + schedule: + # Daily at 09:07 UTC — once per day, off the round-minute mark. + - cron: "7 9 * * *" + workflow_dispatch: + +jobs: + spartan: + name: Check merge-train/spartan + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - name: Run stale check + env: + GH_TOKEN: ${{ secrets.AZTEC_BOT_GITHUB_TOKEN }} + SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} + run: ./ci3/merge_train_stale_check merge-train/spartan '#team-alpha' diff --git a/ci3/merge_train_stale_check b/ci3/merge_train_stale_check new file mode 100755 index 000000000000..f8488c115766 --- /dev/null +++ b/ci3/merge_train_stale_check @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Check whether the open PR for a merge-train branch has been open longer +# than a threshold (default 24h). If so, post a one-line alert to a Slack +# channel. Intended to be called from a daily scheduled GitHub Actions job. +# +# Usage: merge_train_stale_check +# +# Example: +# merge_train_stale_check merge-train/spartan '#team-alpha' +# +# Required env vars: +# GH_TOKEN — GitHub API token (used by `gh api`) +# SLACK_BOT_TOKEN — Slack bot token (consumed by ci3/slack_notify) +# +# Optional env vars: +# STALE_HOURS — alert threshold in hours (default 24) +# BASE_BRANCH — PR base branch to filter on (default "next") + +set -euo pipefail + +REF_NAME="${1:?Usage: $0 }" +CHANNEL="${2:?Usage: $0 }" +STALE_HOURS="${STALE_HOURS:-24}" +BASE_BRANCH="${BASE_BRANCH:-next}" + +pr_json=$(gh api "repos/AztecProtocol/aztec-packages/pulls?head=AztecProtocol:${REF_NAME}&state=open&base=${BASE_BRANCH}" --jq '.[0] // empty') + +if [[ -z "$pr_json" ]]; then + echo "$REF_NAME: no open PR targeting $BASE_BRANCH — nothing to alert." + exit 0 +fi + +pr_number=$(jq -r '.number' <<<"$pr_json") +pr_url=$(jq -r '.html_url' <<<"$pr_json") +created_at=$(jq -r '.created_at' <<<"$pr_json") + +now_s=$(date -u +%s) +created_s=$(date -u -d "$created_at" +%s) +age_hours=$(( (now_s - created_s) / 3600 )) + +echo "$REF_NAME PR #$pr_number opened $age_hours hour(s) ago (created $created_at)" + +if (( age_hours < STALE_HOURS )); then + echo "Within ${STALE_HOURS}h window — no alert." + exit 0 +fi + +mergeable_state=$(gh api "repos/AztecProtocol/aztec-packages/pulls/$pr_number" --jq '.mergeable_state // "unknown"') +days=$(( age_hours / 24 )) + +message=$(printf ':warning: `%s` has not merged into `%s` in %d day(s) — <%s|PR #%d> is in state `%s`. Please investigate.' \ + "$REF_NAME" "$BASE_BRANCH" "$days" "$pr_url" "$pr_number" "$mergeable_state") + +TOPDIR=$(git rev-parse --show-toplevel) +"$TOPDIR/ci3/slack_notify" "$message" "$CHANNEL" + +echo "Alert sent to $CHANNEL: $message" From 15dfe0d67e184d63bc88c6ab739ee1f29a49a6ce Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 12 May 2026 10:19:49 -0300 Subject: [PATCH 44/55] chore: remove orphan --archiver flag usages from start invocations (#23186) ## Motivation The top-level `--archiver` flag was removed from `aztec start`, but several scripts, Helm/Terraform values, and docs still pass it. Leaving these in place would break node and prover startup once they pick up the new CLI. ## Approach Grepped the repo for bare `--archiver` (excluding nested `--archiver.