From c1a00d281dc01a89febcdc493e6c190a3e9865d8 Mon Sep 17 00:00:00 2001 From: Santiago Palladino Date: Tue, 23 Dec 2025 22:25:28 -0300 Subject: [PATCH] feat: Handle multiple blocks per slot in validators --- ...e2e_multi_validator_node_key_store.test.ts | 15 +- .../end-to-end/src/e2e_p2p/reex.test.ts | 15 +- yarn-project/p2p/src/client/interface.ts | 28 +- yarn-project/p2p/src/client/p2p_client.ts | 38 ++- .../p2p_client.integration_block_txs.test.ts | 4 +- ...nt.integration_message_propagation.test.ts | 6 +- .../attestation_pool_test_suite.ts | 44 ++- .../kv_attestation_pool.test.ts | 6 +- .../attestation_pool/kv_attestation_pool.ts | 2 +- .../memory_attestation_pool.ts | 6 +- .../fisherman_attestation_validator.test.ts | 22 +- .../fisherman_attestation_validator.ts | 9 +- .../block_proposal_validator.test.ts | 18 +- .../block_proposal_validator.ts | 4 +- .../p2p/src/services/dummy_service.ts | 6 + .../p2p/src/services/encoding.test.ts | 4 + yarn-project/p2p/src/services/encoding.ts | 2 + .../p2p/src/services/libp2p/libp2p_service.ts | 65 +++-- yarn-project/p2p/src/services/service.ts | 17 +- .../p2p/src/services/tx_provider.test.ts | 12 +- .../sequencer/checkpoint_proposal_job.test.ts | 39 +-- .../src/sequencer/checkpoint_proposal_job.ts | 27 +- .../sequencer-client/src/test/utils.ts | 11 +- .../stdlib/src/interfaces/validator.ts | 36 ++- .../stdlib/src/p2p/block_proposal.test.ts | 61 ++-- yarn-project/stdlib/src/p2p/block_proposal.ts | 135 +++++++-- .../stdlib/src/p2p/checkpoint_attestation.ts | 214 ++++++++++++++ .../stdlib/src/p2p/checkpoint_proposal.ts | 268 ++++++++++++++++++ yarn-project/stdlib/src/p2p/index.ts | 2 + .../stdlib/src/p2p/signature_utils.ts | 3 + yarn-project/stdlib/src/p2p/topic_type.ts | 3 + yarn-project/stdlib/src/tests/mocks.ts | 90 +++++- .../txe/src/state_machine/dummy_p2p_client.ts | 15 +- .../src/block_proposal_handler.ts | 37 +-- .../src/duties/validation_service.test.ts | 55 ++-- .../src/duties/validation_service.ts | 75 ++++- .../validator-client/src/validator.test.ts | 153 +++++----- .../validator-client/src/validator.ts | 180 ++++++++---- 38 files changed, 1330 insertions(+), 397 deletions(-) create mode 100644 yarn-project/stdlib/src/p2p/checkpoint_attestation.ts create mode 100644 yarn-project/stdlib/src/p2p/checkpoint_proposal.ts diff --git a/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts b/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts index 0e0617a75c6a..6d0b14614531 100644 --- a/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts +++ b/yarn-project/end-to-end/src/composed/web3signer/e2e_multi_validator_node_key_store.test.ts @@ -9,7 +9,6 @@ import { getAddressFromPrivateKey } from '@aztec/ethereum/account'; import { getL1ContractsConfigEnvVars } from '@aztec/ethereum/config'; import { RollupContract } from '@aztec/ethereum/contracts'; import type { DeployAztecL1ContractsReturnType } from '@aztec/ethereum/deploy-aztec-l1-contracts'; -import { BlockNumber } from '@aztec/foundation/branded-types'; import { SecretValue } from '@aztec/foundation/config'; import { retryUntil } from '@aztec/foundation/retry'; import { type EthPrivateKey, KeystoreManager, loadKeystores, mergeKeystores } from '@aztec/node-keystore'; @@ -17,8 +16,7 @@ import { StatefulTestContractArtifact } from '@aztec/noir-test-contracts.js/Stat import type { Sequencer, SequencerClient, SequencerPublisherFactory } from '@aztec/sequencer-client'; import type { TestSequencer, TestSequencerClient } from '@aztec/sequencer-client/test'; import type { BlockProposalOptions } from '@aztec/stdlib/p2p'; -import type { CheckpointHeader } from '@aztec/stdlib/rollup'; -import type { Tx } from '@aztec/stdlib/tx'; +import type { BlockHeader, Tx } from '@aztec/stdlib/tx'; import { NodeKeystoreAdapter, ValidatorClient } from '@aztec/validator-client'; import { jest } from '@jest/globals'; @@ -361,8 +359,9 @@ describe('e2e_multi_validator_node', () => { const originalCreateProposal = validatorClient.createBlockProposal.bind(validatorClient); const createBlockProposal = ( - blockNumber: BlockNumber, - header: CheckpointHeader, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, archive: Fr, txs: Tx[], proposerAddress: EthAddress | undefined, @@ -371,15 +370,15 @@ describe('e2e_multi_validator_node', () => { if (proposerAddress) { requestedCoinbaseAddresses.set( proposerAddress.toString().toLowerCase(), - header.coinbase.toString().toLowerCase(), + blockHeader.globalVariables.coinbase.toString().toLowerCase(), ); requestedFeeRecipientAddresses.set( proposerAddress.toString().toLowerCase(), - header.feeRecipient.toString().toLowerCase(), + blockHeader.globalVariables.feeRecipient.toString().toLowerCase(), ); } - return originalCreateProposal(blockNumber, header, archive, txs, proposerAddress, options); + return originalCreateProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options); }; validatorClient.createBlockProposal = jest.fn(createBlockProposal); 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 b345a4b9857a..a04012a84fa5 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 @@ -8,7 +8,7 @@ import { unfreeze } from '@aztec/foundation/types'; import type { LibP2PService, P2PClient } from '@aztec/p2p'; import type { BlockBuilder } from '@aztec/sequencer-client'; import type { CppPublicTxSimulator, PublicTxResult } from '@aztec/simulator/server'; -import { BlockProposal, SignatureDomainSeparator, getHashedSignaturePayload } from '@aztec/stdlib/p2p'; +import { BlockProposal } from '@aztec/stdlib/p2p'; import { ReExFailedTxsError, ReExStateMismatchError, ReExTimeoutError } from '@aztec/stdlib/validators'; import type { ValidatorClient, ValidatorKeyStore } from '@aztec/validator-client'; @@ -141,13 +141,14 @@ describe('e2e_p2p_reex', () => { // We sign over the proposal using the node's signing key const signer = (node as any).sequencer.sequencer.validatorClient.validationService .keyStore as ValidatorKeyStore; - const newProposal = new BlockProposal( - proposal.payload, - await signer.signMessageWithAddress( - proposerAddress!, - getHashedSignaturePayload(proposal.payload, SignatureDomainSeparator.blockProposal), - ), + const newProposal = await BlockProposal.createProposalFromSigner( + proposal.blockHeader, + proposal.indexWithinCheckpoint, + proposal.inHash, + proposal.archiveRoot, proposal.txHashes, + undefined, + payload => signer.signMessageWithAddress(proposerAddress!, payload), ); const p2pService = (p2pClient as any).p2pService as LibP2PService; diff --git a/yarn-project/p2p/src/client/interface.ts b/yarn-project/p2p/src/client/interface.ts index 967b97102ad0..99104c1f4fc1 100644 --- a/yarn-project/p2p/src/client/interface.ts +++ b/yarn-project/p2p/src/client/interface.ts @@ -1,6 +1,12 @@ import type { EthAddress, L2BlockId } from '@aztec/stdlib/block'; import type { P2PApiFull } from '@aztec/stdlib/interfaces/server'; -import type { BlockAttestation, BlockProposal, P2PClientType } from '@aztec/stdlib/p2p'; +import type { + BlockAttestation, + BlockProposal, + CheckpointAttestation, + CheckpointProposal, + P2PClientType, +} from '@aztec/stdlib/p2p'; import type { Tx, TxHash } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; @@ -13,7 +19,7 @@ import type { ReqRespSubProtocolHandler, ReqRespSubProtocolValidators, } from '../services/reqresp/interface.js'; -import type { P2PBlockReceivedCallback } from '../services/service.js'; +import type { P2PBlockReceivedCallback, P2PCheckpointReceivedCallback } from '../services/service.js'; /** * Enum defining the possible states of the p2p client. @@ -50,9 +56,19 @@ export type P2P = P2PApiFull & */ broadcastProposal(proposal: BlockProposal): Promise; + /** + * Broadcasts a checkpoint proposal (last block in a checkpoint) to other peers. + * + * @param proposal - the checkpoint proposal + */ + broadcastCheckpointProposal(proposal: CheckpointProposal): Promise; + /** Broadcasts block attestations to other peers. */ broadcastAttestations(attestations: BlockAttestation[]): Promise; + /** Broadcasts checkpoint attestations to other peers. */ + broadcastCheckpointAttestations(attestations: CheckpointAttestation[]): Promise; + /** * Registers a callback from the validator client that determines how to behave when * foreign block proposals are received @@ -63,6 +79,14 @@ export type P2P = P2PApiFull & // ^ This pattern is not my favorite (md) registerBlockProposalHandler(callback: P2PBlockReceivedCallback): void; + /** + * Registers a callback from the validator client that determines how to behave when + * foreign checkpoint proposals are received + * + * @param handler - A function taking a received checkpoint proposal and producing attestations + */ + registerCheckpointProposalHandler(callback: P2PCheckpointReceivedCallback): void; + /** * Request a list of transactions from another peer by their tx hashes. * @param txHashes - Hashes of the txs to query. diff --git a/yarn-project/p2p/src/client/p2p_client.ts b/yarn-project/p2p/src/client/p2p_client.ts index 5a17af767ea7..e81a48f600ee 100644 --- a/yarn-project/p2p/src/client/p2p_client.ts +++ b/yarn-project/p2p/src/client/p2p_client.ts @@ -15,7 +15,13 @@ import type { import type { ContractDataSource } from '@aztec/stdlib/contract'; import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import { type PeerInfo, tryStop } from '@aztec/stdlib/interfaces/server'; -import { BlockAttestation, type BlockProposal, type P2PClientType } from '@aztec/stdlib/p2p'; +import { + BlockAttestation, + type BlockProposal, + CheckpointAttestation, + type CheckpointProposal, + type P2PClientType, +} from '@aztec/stdlib/p2p'; import type { Tx, TxHash } from '@aztec/stdlib/tx'; import { Attributes, @@ -40,7 +46,7 @@ import { type ReqRespSubProtocolValidators, } from '../services/reqresp/interface.js'; import { chunkTxHashesRequest } from '../services/reqresp/protocols/tx.js'; -import type { P2PBlockReceivedCallback, P2PService } from '../services/service.js'; +import type { P2PBlockReceivedCallback, P2PCheckpointReceivedCallback, P2PService } from '../services/service.js'; import { TxCollection } from '../services/tx_collection/tx_collection.js'; import { TxProvider } from '../services/tx_provider.js'; import { type P2P, P2PClientState, type P2PSyncState } from './interface.js'; @@ -114,7 +120,8 @@ export class P2PClient ); // Default to collecting all txs when we see a valid proposal - // This can be overridden by the validator client to attest, and it will call getTxsForBlockProposal on its own + // This can be overridden by the validator client to validate, and it will call getTxsForBlockProposal on its own + // Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals. // TODO(palla/txs): We should not trigger a request for txs on a proposal before fully validating it. We need to bring // validator-client code into here so we can validate a proposal is reasonable. this.registerBlockProposalHandler(async (block, sender) => { @@ -123,14 +130,14 @@ export class P2PClient const constants = this.txCollection.getConstants(); const nextSlotTimestampSeconds = Number(getTimestampForSlot(SlotNumber(block.slotNumber + 1), constants)); const deadline = new Date(nextSlotTimestampSeconds * 1000); - const parentBlock = await this.l2BlockSource.getBlockHeaderByArchive(block.payload.header.lastArchiveRoot); + const parentBlock = await this.l2BlockSource.getBlockHeaderByArchive(block.blockHeader.lastArchive.root); if (!parentBlock) { this.log.debug(`Cannot collect txs for proposal as parent block not found`); - return; + return false; } const blockNumber = BlockNumber(parentBlock.getBlockNumber() + 1); await this.txProvider.getTxsForBlockProposal(block, blockNumber, { pinnedPeer: sender, deadline }); - return undefined; + return true; }); // REFACTOR: Try replacing these with an L2TipsStore @@ -380,11 +387,26 @@ export class P2PClient return this.p2pService.propagate(proposal); } + @trackSpan('p2pClient.broadcastCheckpointProposal', async proposal => ({ + [Attributes.SLOT_NUMBER]: proposal.slotNumber, + [Attributes.BLOCK_ARCHIVE]: proposal.archive.toString(), + [Attributes.P2P_ID]: (await proposal.p2pMessageLoggingIdentifier()).toString(), + })) + public broadcastCheckpointProposal(proposal: CheckpointProposal): Promise { + this.log.verbose(`Broadcasting checkpoint proposal for slot ${proposal.slotNumber} to peers`); + return this.p2pService.propagate(proposal); + } + public async broadcastAttestations(attestations: BlockAttestation[]): Promise { this.log.verbose(`Broadcasting ${attestations.length} attestations to peers`); await Promise.all(attestations.map(att => this.p2pService.propagate(att))); } + public async broadcastCheckpointAttestations(attestations: CheckpointAttestation[]): Promise { + this.log.verbose(`Broadcasting ${attestations.length} checkpoint attestations to peers`); + await Promise.all(attestations.map(att => this.p2pService.propagate(att))); + } + public async getAttestationsForSlot(slot: SlotNumber, proposalId?: string): Promise { return await (proposalId ? this.attestationPool.getAttestationsForSlotAndProposal(slot, proposalId) @@ -405,6 +427,10 @@ export class P2PClient this.p2pService.registerBlockReceivedCallback(handler); } + public registerCheckpointProposalHandler(handler: P2PCheckpointReceivedCallback): void { + this.p2pService.registerCheckpointReceivedCallback(handler); + } + /** * Uses the batched Request Response protocol to request a set of transactions from the network. */ diff --git a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts index 912c5eab72f1..285958a6ea42 100644 --- a/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts +++ b/yarn-project/p2p/src/client/test/p2p_client.integration_block_txs.test.ts @@ -121,8 +121,8 @@ describe('p2p client integration block txs protocol ', () => { const createBlockProposal = (blockNumber: BlockNumber, blockHash: any, txHashes: any[]) => { return makeBlockProposal({ signer: Secp256k1Signer.random(), - header: makeL2BlockHeader(1, blockNumber), - archive: blockHash, + blockHeader: makeL2BlockHeader(1, blockNumber), + archiveRoot: blockHash, txHashes, }); }; 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 2d0ede637df9..bef1ac69e3b5 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 @@ -217,8 +217,8 @@ describe('p2p client integration message propagation', () => { expect(hashes[0].toString()).toEqual(hashes[1].toString()); expect(hashes[0].toString()).toEqual(hashes[2].toString()); - expect(messages[2].payload.toString()).toEqual(blockProposal.payload.toString()); - expect(messages[3].payload.toString()).toEqual(blockProposal.payload.toString()); + expect(messages[2].archive.toString()).toEqual(blockProposal.archive.toString()); + expect(messages[3].archive.toString()).toEqual(blockProposal.archive.toString()); expect(messages[4].payload.toString()).toEqual(attestation.payload.toString()); expect(messages[5].payload.toString()).toEqual(attestation.payload.toString()); } @@ -371,7 +371,7 @@ describe('p2p client integration message propagation', () => { const hashes = await Promise.all([tx, client2Messages![0]].map(tx => tx!.getTxHash())); expect(hashes[0].toString()).toEqual(hashes[1].toString()); - expect(client2Messages![1].payload.toString()).toEqual(blockProposal.payload.toString()); + expect(client2Messages![1].archive.toString()).toEqual(blockProposal.archive.toString()); expect(client2Messages![2].payload.toString()).toEqual(attestation.payload.toString()); // We expect that no messages were received by client 3 diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts index 05273408b9fa..42cb010606fc 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/attestation_pool_test_suite.ts @@ -2,14 +2,7 @@ import { SlotNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; import type { BlockAttestation, BlockProposal } from '@aztec/stdlib/p2p'; -import { - BlockProposal as BlockProposalClass, - ConsensusPayload, - SignatureDomainSeparator, - getHashedSignaturePayloadEthSignedMessage, -} from '@aztec/stdlib/p2p'; -import { makeL2BlockHeader } from '@aztec/stdlib/testing'; -import { TxHash } from '@aztec/stdlib/tx'; +import { makeBlockProposal, makeL2BlockHeader } from '@aztec/stdlib/testing'; import { jest } from '@jest/globals'; import { type MockProxy, mock } from 'jest-mock-extended'; @@ -41,16 +34,17 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo return signers.map(signer => mockAttestation(signer, slotNumber, archive)); }; - const mockBlockProposal = (signer: Secp256k1Signer, slotNumber: number, archive: Fr = Fr.random()): BlockProposal => { + const mockBlockProposalForPool = ( + signer: Secp256k1Signer, + slotNumber: number, + archive: Fr = Fr.random(), + ): BlockProposal => { const header = makeL2BlockHeader(1, 2, slotNumber); - const payload = new ConsensusPayload(header.toCheckpointHeader(), archive); - - const hash = getHashedSignaturePayloadEthSignedMessage(payload, SignatureDomainSeparator.blockProposal); - const signature = signer.sign(hash); - - const txHashes = [TxHash.random(), TxHash.random()]; // Mock tx hashes - - return new BlockProposalClass(payload, signature, txHashes); + return makeBlockProposal({ + signer, + blockHeader: header, + archiveRoot: archive, + }); }; // We compare buffers as the objects can have cached values attached to them which are not serialised @@ -290,7 +284,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should add and retrieve block proposal', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal = mockBlockProposal(signers[0], slotNumber, archive); + const proposal = mockBlockProposalForPool(signers[0], slotNumber, archive); const proposalId = proposal.archive.toString(); await ap.addBlockProposal(proposal); @@ -317,13 +311,13 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should update block proposal if added twice with same id', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal1 = mockBlockProposal(signers[0], slotNumber, archive); + const proposal1 = mockBlockProposalForPool(signers[0], slotNumber, archive); const proposalId = proposal1.archive.toString(); await ap.addBlockProposal(proposal1); // Create a new proposal with same archive but different signer - const proposal2 = mockBlockProposal(signers[1], slotNumber, archive); + const proposal2 = mockBlockProposalForPool(signers[1], slotNumber, archive); await ap.addBlockProposal(proposal2); @@ -336,8 +330,8 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should handle block proposals with different slots and same archive', async () => { const archive = Fr.random(); - const proposal1 = mockBlockProposal(signers[0], 100, archive); - const proposal2 = mockBlockProposal(signers[1], 200, archive); + const proposal1 = mockBlockProposalForPool(signers[0], 100, archive); + const proposal2 = mockBlockProposalForPool(signers[1], 200, archive); const proposalId = archive.toString(); await ap.addBlockProposal(proposal1); @@ -353,7 +347,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should delete block proposal when deleting attestations for slot and proposal', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal = mockBlockProposal(signers[0], slotNumber, archive); + const proposal = mockBlockProposalForPool(signers[0], slotNumber, archive); const proposalId = proposal.archive.toString(); // Add proposal and some attestations @@ -378,7 +372,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should delete block proposal when deleting attestations for slot', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal = mockBlockProposal(signers[0], slotNumber, archive); + const proposal = mockBlockProposalForPool(signers[0], slotNumber, archive); const proposalId = proposal.archive.toString(); // Add proposal @@ -401,7 +395,7 @@ export function describeAttestationPool(getAttestationPool: () => AttestationPoo it('should be able to fetch both block proposal and attestations', async () => { const slotNumber = 420; const archive = Fr.random(); - const proposal = mockBlockProposal(signers[0], slotNumber, archive); + const proposal = mockBlockProposalForPool(signers[0], slotNumber, archive); const proposalId = proposal.archive.toString(); // Add proposal first diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.test.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.test.ts index 621d47487e25..2419fc535c63 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.test.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.test.ts @@ -29,18 +29,18 @@ describe('KV Attestation Pool', () => { const header = makeL2BlockHeader(1, 2, slotNumber); // Add 1 proposal and re-add it (duplicate) → should not count against cap and not throw - const p0 = makeBlockProposal({ header, archive: Fr.random() }); + const p0 = makeBlockProposal({ blockHeader: header, archiveRoot: Fr.random() }); await kvAttestationPool.addBlockProposal(p0); await kvAttestationPool.addBlockProposal(p0); // idempotent // Add up to the cap: add (MAX_PROPOSALS_PER_SLOT - 1) more unique proposals for (let i = 0; i < MAX_PROPOSALS_PER_SLOT - 1; i++) { - const p = makeBlockProposal({ header, archive: Fr.random() }); + const p = makeBlockProposal({ blockHeader: header, archiveRoot: Fr.random() }); await kvAttestationPool.addBlockProposal(p); } // Adding one more unique proposal for same slot should throw (exceeds cap) - const overflow = makeBlockProposal({ header, archive: Fr.random() }); + const overflow = makeBlockProposal({ blockHeader: header, archiveRoot: Fr.random() }); await expect(kvAttestationPool.addBlockProposal(overflow)).rejects.toBeInstanceOf(ProposalSlotCapExceededError); }); }); diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.ts index c5faa1749707..b143c4799d59 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/kv_attestation_pool.ts @@ -248,7 +248,7 @@ export class KvAttestationPool implements AttestationPool { } public async hasBlockProposal(idOrProposal: string | BlockProposal): Promise { - const id = typeof idOrProposal === 'string' ? idOrProposal : idOrProposal.payload.archive.toString(); + const id = typeof idOrProposal === 'string' ? idOrProposal : idOrProposal.archive.toString(); return await this.proposals.hasAsync(id); } diff --git a/yarn-project/p2p/src/mem_pools/attestation_pool/memory_attestation_pool.ts b/yarn-project/p2p/src/mem_pools/attestation_pool/memory_attestation_pool.ts index 79bb9738f019..a26f73e54e46 100644 --- a/yarn-project/p2p/src/mem_pools/attestation_pool/memory_attestation_pool.ts +++ b/yarn-project/p2p/src/mem_pools/attestation_pool/memory_attestation_pool.ts @@ -206,9 +206,9 @@ export class InMemoryAttestationPool implements AttestationPool { // We initialize slot-proposal mapping if it does not exist // This is important to ensure we can delete this proposal if there were not attestations for it const slotProposalMapping = getSlotOrDefault(this.attestations, blockProposal.slotNumber); - slotProposalMapping.set(blockProposal.payload.archive.toString(), new Map()); + slotProposalMapping.set(blockProposal.archive.toString(), new Map()); - this.proposals.set(blockProposal.payload.archive.toString(), blockProposal); + this.proposals.set(blockProposal.archive.toString(), blockProposal); return Promise.resolve(); } @@ -217,7 +217,7 @@ export class InMemoryAttestationPool implements AttestationPool { } public hasBlockProposal(idOrProposal: string | BlockProposal): Promise { - const id = typeof idOrProposal === 'string' ? idOrProposal : idOrProposal.payload.archive.toString(); + const id = typeof idOrProposal === 'string' ? idOrProposal : idOrProposal.archive.toString(); return Promise.resolve(this.proposals.has(id)); } diff --git a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts index ea351e67b83e..6f516b703873 100644 --- a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.test.ts @@ -2,7 +2,7 @@ import type { EpochCache } from '@aztec/epoch-cache'; import { SlotNumber } from '@aztec/foundation/branded-types'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; -import { BlockProposal, ConsensusPayload, PeerErrorSeverity } from '@aztec/stdlib/p2p'; +import { PeerErrorSeverity } from '@aztec/stdlib/p2p'; import { makeBlockAttestation, makeBlockProposal, makeL2BlockHeader } from '@aztec/stdlib/testing'; import { getTelemetryClient } from '@aztec/telemetry-client'; @@ -122,9 +122,9 @@ describe('FishermanAttestationValidator', () => { // Create a matching proposal with the same payload const mockProposal = makeBlockProposal({ - header, + blockHeader: header, signer: proposer, - archive, + archiveRoot: archive, }); attestationPool.getBlockProposal.mockResolvedValue(mockProposal); @@ -148,7 +148,7 @@ describe('FishermanAttestationValidator', () => { // Create a proposal with a different payload const mockProposal = makeBlockProposal({ - header: header2, + blockHeader: header2, signer: proposer, }); @@ -187,12 +187,12 @@ describe('FishermanAttestationValidator', () => { proposerSigner: proposer, }); - // Create a proposal with the same header but manually create a different payload - const differentPayload = new ConsensusPayload( - header.toCheckpointHeader(), - Fr.random(), // Different archive - ); - const mockProposal = new BlockProposal(differentPayload, mockAttestation.proposerSignature, []); + // Create a proposal with the same header but a different archive + const mockProposal = makeBlockProposal({ + blockHeader: header, + signer: proposer, + archiveRoot: Fr.random(), // Different archive + }); attestationPool.getBlockProposal.mockResolvedValue(mockProposal); @@ -212,7 +212,7 @@ describe('FishermanAttestationValidator', () => { // Create a proposal with a different header (different hash) const mockProposal = makeBlockProposal({ - header: header2, + blockHeader: header2, signer: proposer, }); diff --git a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts index eed8956fdd71..7b6140524628 100644 --- a/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts +++ b/yarn-project/p2p/src/msg_validators/attestation_validator/fisherman_attestation_validator.ts @@ -55,10 +55,11 @@ export class FishermanAttestationValidator extends AttestationValidator { const proposal = await this.attestationPool.getBlockProposal(proposalId); if (proposal) { - // Compare the attestation payload with the proposal payload - if (!message.payload.equals(proposal.payload)) { + // Compare the attestation archive with the proposal archive + // Note: With the new MBPS model, we compare archives as the unique identifier + if (!message.archive.equals(proposal.archive)) { this.logger.error( - `Attestation payload mismatch for slot ${slotNumberBigInt}! ` + + `Attestation archive mismatch for slot ${slotNumberBigInt}! ` + `Attester ${attester.toString()} signed different data than the proposal.`, { slot: slotNumberBigInt.toString(), @@ -66,8 +67,6 @@ export class FishermanAttestationValidator extends AttestationValidator { proposer: proposer.toString(), proposalArchive: proposal.archive.toString(), attestationArchive: message.archive.toString(), - proposalHeader: proposal.payload.header.hash().toString(), - attestationHeader: message.payload.header.hash().toString(), }, ); diff --git a/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.test.ts b/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.test.ts index bcf30ce4a3ed..0f3ec304dc83 100644 --- a/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.test.ts +++ b/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.test.ts @@ -22,7 +22,7 @@ describe('BlockProposalValidator', () => { it('returns high tolerance error if slot number is not current or next slot', async () => { // Create a block proposal for slot 97 const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 97, 97), + blockHeader: makeL2BlockHeader(1, 97, 97), }); // Mock epoch cache to return different slot numbers @@ -44,7 +44,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal for current slot but with wrong proposer const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 100, 100), + blockHeader: makeL2BlockHeader(1, 100, 100), signer: invalidProposer, }); @@ -67,7 +67,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal for next slot but with wrong proposer const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 101, 101), + blockHeader: makeL2BlockHeader(1, 101, 101), signer: invalidProposer, }); @@ -89,7 +89,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal for next slot but with wrong proposer const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 101, 101), + blockHeader: makeL2BlockHeader(1, 101, 101), signer: currentProposer, }); @@ -111,7 +111,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal for current slot with correct proposer const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 100, 100), + blockHeader: makeL2BlockHeader(1, 100, 100), signer: currentProposer, }); @@ -133,7 +133,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal for next slot with correct proposer const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 101, 101), + blockHeader: makeL2BlockHeader(1, 101, 101), signer: nextProposer, }); @@ -156,7 +156,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal with transaction hashes const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 100, 100), + blockHeader: makeL2BlockHeader(1, 100, 100), signer: currentProposer, txHashes: [TxHash.random(), TxHash.random()], // Include some tx hashes }); @@ -179,7 +179,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal without transaction hashes const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 100, 100), + blockHeader: makeL2BlockHeader(1, 100, 100), signer: currentProposer, txHashes: [], // Empty tx hashes array }); @@ -202,7 +202,7 @@ describe('BlockProposalValidator', () => { // Create a block proposal with transaction hashes const mockProposal = makeBlockProposal({ - header: makeL2BlockHeader(1, 100, 100), + blockHeader: makeL2BlockHeader(1, 100, 100), signer: currentProposer, txHashes: [TxHash.random(), TxHash.random()], // Include some tx hashes }); diff --git a/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.ts b/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.ts index 9f4af683ee29..88460bab31b4 100644 --- a/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.ts +++ b/yarn-project/p2p/src/msg_validators/block_proposal_validator/block_proposal_validator.ts @@ -50,8 +50,8 @@ export class BlockProposalValidator implements P2PValidator { const { currentProposer, nextProposer, currentSlot, nextSlot } = await this.epochCache.getProposerAttesterAddressInCurrentOrNextSlot(); - // Check that the attestation is for the current or next slot - const slotNumber = block.payload.header.slotNumber; + // Check that the proposal is for the current or next slot + const slotNumber = block.slotNumber; if (slotNumber !== currentSlot && slotNumber !== nextSlot) { this.logger.debug(`Penalizing peer for invalid slot number ${slotNumber}`, { currentSlot, nextSlot }); return PeerErrorSeverity.HighToleranceError; diff --git a/yarn-project/p2p/src/services/dummy_service.ts b/yarn-project/p2p/src/services/dummy_service.ts index 913adfaeb7ee..4d0663c925ac 100644 --- a/yarn-project/p2p/src/services/dummy_service.ts +++ b/yarn-project/p2p/src/services/dummy_service.ts @@ -23,6 +23,7 @@ import type { GoodByeReason } from './reqresp/protocols/goodbye.js'; import { ReqRespStatus } from './reqresp/status.js'; import { type P2PBlockReceivedCallback, + type P2PCheckpointReceivedCallback, type P2PService, type PeerDiscoveryService, PeerDiscoveryState, @@ -74,6 +75,11 @@ export class DummyP2PService implements P2PService { */ public registerBlockReceivedCallback(_callback: P2PBlockReceivedCallback) {} + /** + * Register a callback into the validator client for when a checkpoint proposal is received + */ + public registerCheckpointReceivedCallback(_callback: P2PCheckpointReceivedCallback) {} + /** * Sends a request to a peer. * @param _protocol - The protocol to send the request on. diff --git a/yarn-project/p2p/src/services/encoding.test.ts b/yarn-project/p2p/src/services/encoding.test.ts index ebde13c4e572..648209a9a82d 100644 --- a/yarn-project/p2p/src/services/encoding.test.ts +++ b/yarn-project/p2p/src/services/encoding.test.ts @@ -308,6 +308,8 @@ describe('SnappyTransform', () => { [TopicType.tx]: 100, // 100kb [TopicType.block_attestation]: 10, // 10kb [TopicType.block_proposal]: 500, // 500kb + [TopicType.checkpoint_proposal]: 500, // 500kb + [TopicType.checkpoint_attestation]: 10, // 10kb }; const transform = new SnappyTransform(customMaxSizes); @@ -329,6 +331,8 @@ describe('SnappyTransform', () => { [TopicType.tx]: 100, [TopicType.block_attestation]: 10, [TopicType.block_proposal]: 500, + [TopicType.checkpoint_proposal]: 500, + [TopicType.checkpoint_attestation]: 10, }; const customDefaultMaxSize = 200; // 200kb const transform = new SnappyTransform(customMaxSizes, customDefaultMaxSize); diff --git a/yarn-project/p2p/src/services/encoding.ts b/yarn-project/p2p/src/services/encoding.ts index 5697592f6782..861e2d49629e 100644 --- a/yarn-project/p2p/src/services/encoding.ts +++ b/yarn-project/p2p/src/services/encoding.ts @@ -56,9 +56,11 @@ const DefaultMaxSizesKb: Record = { [TopicType.tx]: 512, // An attestation has roughly 30 fields, which is 1kb, so 5x is plenty [TopicType.block_attestation]: 5, + [TopicType.checkpoint_attestation]: 5, // Proposals may carry some tx objects, so we allow a larger size capped at 10mb // Note this may not be enough for carrying all tx objects in a block [TopicType.block_proposal]: 1024 * 10, + [TopicType.checkpoint_proposal]: 1024 * 10, }; /** diff --git a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts index 812e1b36dd75..f81e06218e71 100644 --- a/yarn-project/p2p/src/services/libp2p/libp2p_service.ts +++ b/yarn-project/p2p/src/services/libp2p/libp2p_service.ts @@ -15,6 +15,8 @@ import type { ClientProtocolCircuitVerifier, PeerInfo, WorldStateSynchronizer } import { BlockAttestation, BlockProposal, + CheckpointAttestation, + CheckpointProposal, type Gossipable, P2PClientType, P2PMessage, @@ -101,7 +103,12 @@ import { reqRespTxHandler, } from '../reqresp/protocols/index.js'; import { ReqResp } from '../reqresp/reqresp.js'; -import type { P2PBlockReceivedCallback, P2PService, PeerDiscoveryService } from '../service.js'; +import type { + P2PBlockReceivedCallback, + P2PCheckpointReceivedCallback, + P2PService, + PeerDiscoveryService, +} from '../service.js'; import { P2PInstrumentation } from './instrumentation.js'; interface ValidationResult { @@ -140,6 +147,13 @@ export class LibP2PService extends */ private blockReceivedCallback: P2PBlockReceivedCallback; + /** + * Callback for when a checkpoint proposal is received from a peer. + * @param checkpoint - The checkpoint proposal received from the peer. + * @returns The attestations for the checkpoint, if any. + */ + private checkpointReceivedCallback: P2PCheckpointReceivedCallback; + private gossipSubEventHandler: (e: CustomEvent) => void; private instrumentation: P2PInstrumentation; @@ -170,7 +184,9 @@ export class LibP2PService extends this.msgIdSeenValidators[TopicType.tx] = new MessageSeenValidator(config.seenMessageCacheSize); this.msgIdSeenValidators[TopicType.block_proposal] = new MessageSeenValidator(config.seenMessageCacheSize); + this.msgIdSeenValidators[TopicType.checkpoint_proposal] = new MessageSeenValidator(config.seenMessageCacheSize); this.msgIdSeenValidators[TopicType.block_attestation] = new MessageSeenValidator(config.seenMessageCacheSize); + this.msgIdSeenValidators[TopicType.checkpoint_attestation] = new MessageSeenValidator(config.seenMessageCacheSize); const versions = getVersions(config); this.protocolVersion = compressComponentVersions(versions); @@ -178,10 +194,18 @@ export class LibP2PService extends this.topicStrings[TopicType.tx] = createTopicString(TopicType.tx, this.protocolVersion); this.topicStrings[TopicType.block_proposal] = createTopicString(TopicType.block_proposal, this.protocolVersion); + this.topicStrings[TopicType.checkpoint_proposal] = createTopicString( + TopicType.checkpoint_proposal, + this.protocolVersion, + ); this.topicStrings[TopicType.block_attestation] = createTopicString( TopicType.block_attestation, this.protocolVersion, ); + this.topicStrings[TopicType.checkpoint_attestation] = createTopicString( + TopicType.checkpoint_attestation, + this.protocolVersion, + ); // Use FishermanAttestationValidator in fisherman mode to validate attestation payloads against proposals this.attestationValidator = config.fishermanMode @@ -191,11 +215,21 @@ export class LibP2PService extends this.gossipSubEventHandler = this.handleGossipSubEvent.bind(this); - this.blockReceivedCallback = async (block: BlockProposal): Promise => { + this.blockReceivedCallback = async (block: BlockProposal): Promise => { this.logger.debug( `Handler not yet registered: Block received callback not set. Received block for slot ${block.slotNumber} from peer.`, { p2pMessageIdentifier: await block.p2pMessageLoggingIdentifier() }, ); + return false; + }; + + this.checkpointReceivedCallback = async ( + checkpoint: CheckpointProposal, + ): Promise => { + this.logger.debug( + `Handler not yet registered: Checkpoint received callback not set. Received checkpoint for slot ${checkpoint.slotNumber} from peer.`, + { p2pMessageIdentifier: await checkpoint.p2pMessageLoggingIdentifier() }, + ); return undefined; }; } @@ -601,6 +635,10 @@ export class LibP2PService extends this.blockReceivedCallback = callback; } + public registerCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback) { + this.checkpointReceivedCallback = callback; + } + /** * Subscribes to a topic. * @param topic - The topic to subscribe to. @@ -881,7 +919,7 @@ export class LibP2PService extends isValid, exists, canAdd, - [Attributes.SLOT_NUMBER]: block.payload.header.slotNumber.toString(), + [Attributes.SLOT_NUMBER]: block.slotNumber.toString(), [Attributes.P2P_ID]: source.toString(), }); @@ -950,19 +988,12 @@ export class LibP2PService extends throw err; } await this.mempools.txPool.markTxsAsNonEvictable(block.txHashes); - const attestations = await this.blockReceivedCallback(block, sender); - - // TODO: fix up this pattern - the abstraction is not nice - // The attestation can be undefined if no handler is registered / the validator deems the block invalid / in fisherman mode - if (attestations?.length) { - for (const attestation of attestations) { - this.logger.verbose(`Broadcasting attestation for slot ${attestation.slotNumber}`, { - p2pMessageIdentifier: await attestation.p2pMessageLoggingIdentifier(), - slot: attestation.slotNumber, - archive: attestation.archive.toString(), - }); - await this.broadcastAttestation(attestation); - } + + // Call the block received callback to validate the proposal. + // Note: Validators do NOT attest to individual blocks, only to checkpoint proposals. + const isValid = await this.blockReceivedCallback(block, sender); + if (!isValid) { + this.logger.debug(`Block proposal validation failed for slot ${block.slotNumber}`); } } @@ -1388,7 +1419,7 @@ export class LibP2PService extends * @returns True if the block proposal is valid, false otherwise. */ @trackSpan('Libp2pService.validateBlockProposal', (_peerId, block) => ({ - [Attributes.SLOT_NUMBER]: block.payload.header.slotNumber.toString(), + [Attributes.SLOT_NUMBER]: block.slotNumber.toString(), })) public async validateBlockProposal(peerId: PeerId, block: BlockProposal): Promise { const severity = await this.blockProposalValidator.validate(block); diff --git a/yarn-project/p2p/src/services/service.ts b/yarn-project/p2p/src/services/service.ts index f2ef55a58d49..0079565fc0fc 100644 --- a/yarn-project/p2p/src/services/service.ts +++ b/yarn-project/p2p/src/services/service.ts @@ -1,6 +1,6 @@ import type { EthAddress } from '@aztec/foundation/eth-address'; import type { PeerInfo } from '@aztec/stdlib/interfaces/server'; -import type { BlockAttestation, BlockProposal, Gossipable } from '@aztec/stdlib/p2p'; +import type { BlockProposal, CheckpointAttestation, CheckpointProposal, Gossipable } from '@aztec/stdlib/p2p'; import type { Tx } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; @@ -22,10 +22,17 @@ export enum PeerDiscoveryState { STOPPED = 'stopped', } -export type P2PBlockReceivedCallback = ( - block: BlockProposal, +/** + * Callback for when a block proposal is received. + * Validators validate but DO NOT attest to individual blocks - attestations are only for checkpoints. + * @returns true if the proposal is valid, false otherwise + */ +export type P2PBlockReceivedCallback = (block: BlockProposal, sender: PeerId) => Promise; + +export type P2PCheckpointReceivedCallback = ( + checkpoint: CheckpointProposal, sender: PeerId, -) => Promise; +) => Promise; export type AuthReceivedCallback = (peerId: PeerId, authRequest: AuthRequest) => Promise; @@ -70,6 +77,8 @@ export interface P2PService { // Leaky abstraction: fix https://github.com/AztecProtocol/aztec-packages/issues/7963 registerBlockReceivedCallback(callback: P2PBlockReceivedCallback): void; + registerCheckpointReceivedCallback(callback: P2PCheckpointReceivedCallback): void; + getEnr(): ENR | undefined; getPeers(includePending?: boolean): PeerInfo[]; diff --git a/yarn-project/p2p/src/services/tx_provider.test.ts b/yarn-project/p2p/src/services/tx_provider.test.ts index f4d2df7690d8..c93e32fc5fd0 100644 --- a/yarn-project/p2p/src/services/tx_provider.test.ts +++ b/yarn-project/p2p/src/services/tx_provider.test.ts @@ -1,11 +1,8 @@ import { BlockNumber } from '@aztec/foundation/branded-types'; import { times } from '@aztec/foundation/collection'; -import { Fr } from '@aztec/foundation/curves/bn254'; -import { Signature } from '@aztec/foundation/eth-signature'; import { P2PClient, type PeerId, type TxPool, TxProvider } from '@aztec/p2p'; -import { BlockProposal, ConsensusPayload } from '@aztec/stdlib/p2p'; -import { CheckpointHeader } from '@aztec/stdlib/rollup'; -import { mockTx } from '@aztec/stdlib/testing'; +import type { BlockProposal } from '@aztec/stdlib/p2p'; +import { makeBlockProposal, mockTx } from '@aztec/stdlib/testing'; import { Tx, type TxHash } from '@aztec/stdlib/tx'; import { jest } from '@jest/globals'; @@ -36,9 +33,8 @@ describe('TxProvider', () => { return await Promise.all(times(numTxs, () => mockTx())); }; - const buildProposal = (txs: Tx[], txHashes: TxHash[]) => { - const payload = new ConsensusPayload(CheckpointHeader.empty(), Fr.random()); - return new BlockProposal(payload, Signature.empty(), txHashes, txs); + const buildProposal = (txs: Tx[], txHashes: TxHash[]): BlockProposal => { + return makeBlockProposal({ txHashes, txs }); }; const setupTxPools = (txsInPool: number, txsOnP2P: number, txs: Tx[]) => { 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 0b21a46d972f..718d169800b6 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 @@ -20,8 +20,8 @@ import type { WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import { BlockProposal, ConsensusPayload } from '@aztec/stdlib/p2p'; -import { GlobalVariables } from '@aztec/stdlib/tx'; +import { BlockProposal } from '@aztec/stdlib/p2p'; +import { GlobalVariables, type Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; import type { ValidatorClient } from '@aztec/validator-client'; @@ -190,29 +190,18 @@ describe('CheckpointProposalJob', () => { validatorClient = mock(); validatorClient.collectAttestations.mockImplementation(() => Promise.resolve([])); - validatorClient.createBlockProposal.mockImplementation((_blockNumber, checkpointHeader, archiveRoot, txs) => { - // Create a block proposal directly with the checkpoint header instead of using fromBlock - // which would require a full L2Block with toCheckpointHeader method - const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot); - return Promise.resolve( - new BlockProposal( - consensusPayload, - mockedSig, - (txs ?? []).map((tx: any) => tx.txHash), - ), - ); - }); - validatorClient.createCheckpointProposal.mockImplementation((checkpointHeader, archiveRoot, txs) => { - // Create a minimal BlockProposal for the checkpoint - const consensusPayload = new ConsensusPayload(checkpointHeader, archiveRoot); - return Promise.resolve( - new BlockProposal( - consensusPayload, - mockedSig, - (txs ?? []).map(tx => tx.txHash), - ), - ); - }); + validatorClient.createBlockProposal.mockImplementation( + async (blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txs) => { + const txHashes = await Promise.all((txs ?? []).map((tx: Tx) => tx.getTxHash())); + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, mockedSig); + }, + ); + validatorClient.createCheckpointProposal.mockImplementation( + async (blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txs) => { + const txHashes = await Promise.all((txs ?? []).map((tx: Tx) => tx.getTxHash())); + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, mockedSig); + }, + ); validatorClient.signAttestationsAndSigners.mockImplementation(() => Promise.resolve(getSignatures()[0].signature)); validatorClient.getCoinbaseForAttestor.mockReturnValue(coinbase); validatorClient.getFeeRecipientForAttestor.mockReturnValue(feeRecipient); 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 d380ee2bf95a..aafca999c25e 100644 --- a/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts +++ b/yarn-project/sequencer-client/src/sequencer/checkpoint_proposal_job.ts @@ -2,6 +2,7 @@ import { BLOBS_PER_CHECKPOINT, FIELDS_PER_BLOB } from '@aztec/constants'; import type { EpochCache } from '@aztec/epoch-cache'; import { BlockNumber, CheckpointNumber, EpochNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { randomInt } from '@aztec/foundation/crypto/random'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; import { Signature } from '@aztec/foundation/eth-signature'; import { filter } from '@aztec/foundation/iterator'; @@ -25,10 +26,9 @@ import type { ResolvedSequencerConfig, WorldStateSynchronizer, } from '@aztec/stdlib/interfaces/server'; -import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; +import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; import type { BlockProposal, BlockProposalOptions } from '@aztec/stdlib/p2p'; import { orderAttestations } from '@aztec/stdlib/p2p'; -import { CheckpointHeader } from '@aztec/stdlib/rollup'; import type { L2BlockBuiltStats } from '@aztec/stdlib/stats'; import { type FailedTx, Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; @@ -150,8 +150,9 @@ export class CheckpointProposalJob { this.slot, ); - // Collect L1 to L2 messages for the checkpoint + // Collect L1 to L2 messages for the checkpoint and compute their hash const l1ToL2Messages = await this.l1ToL2MessageSource.getL1ToL2Messages(this.checkpointNumber); + const inHash = computeInHashFromL1ToL2Messages(l1ToL2Messages); // Create a long-lived forked world state for the checkpoint builder using fork = await this.worldState.fork(this.syncedToBlockNumber, { closeDelayMs: 12_000 }); @@ -174,6 +175,7 @@ export class CheckpointProposalJob { const { blocksInCheckpoint, pendingBroadcast } = await this.buildBlocksForCheckpoint( checkpointBuilder, checkpointGlobalVariables.timestamp, + inHash, blockProposalOptions, ); @@ -204,8 +206,12 @@ export class CheckpointProposalJob { } // TODO(palla/mbps): Wire this to the new p2p API once available, including the pendingBroadcast.block + // Use the last block in the checkpoint for the proposal header + const lastBlock = pendingBroadcast?.block ?? checkpoint.blocks[checkpoint.blocks.length - 1]; const proposal = await this.validatorClient.createCheckpointProposal( - checkpoint.header, + lastBlock.header, + lastBlock.indexWithinCheckpoint, + inHash, checkpoint.archive.root, pendingBroadcast?.txs ?? [], this.proposer, @@ -243,6 +249,7 @@ export class CheckpointProposalJob { private async buildBlocksForCheckpoint( checkpointBuilder: CheckpointBuilder, timestamp: bigint, + inHash: Fr, blockProposalOptions: BlockProposalOptions, ): Promise<{ blocksInCheckpoint: L2BlockNew[]; @@ -330,10 +337,10 @@ export class CheckpointProposalJob { // For non-last blocks, broadcast the block proposal (unless we're in fisherman mode) // If the block is the last one, we'll broadcast it along with the checkpoint at the end of the loop if (!this.config.fishermanMode) { - // TODO(palla/mbps): Wire this to the new p2p API once available const proposal = await this.validatorClient.createBlockProposal( - block.header.globalVariables.blockNumber, - (await checkpointBuilder.getCheckpoint()).header, + block.header, + block.indexWithinCheckpoint, + inHash, block.archive.root, usedTxs, this.proposer, @@ -565,8 +572,7 @@ export class CheckpointProposalJob { // Manipulate the attestations if we've been configured to do so if (this.config.injectFakeAttestation || this.config.shuffleAttestationOrdering) { - const checkpoint = proposal.payload.header; - return this.manipulateAttestations(checkpoint, epoch, seed, committee, sorted); + return this.manipulateAttestations(proposal.slotNumber, epoch, seed, committee, sorted); } return new CommitteeAttestationsAndSigners(sorted); @@ -582,7 +588,7 @@ export class CheckpointProposalJob { /** Breaks the attestations before publishing based on attack configs */ private manipulateAttestations( - checkpoint: CheckpointHeader, + slotNumber: SlotNumber, epoch: EpochNumber, seed: bigint, committee: EthAddress[], @@ -590,7 +596,6 @@ export class CheckpointProposalJob { ) { // Compute the proposer index in the committee, since we dont want to tweak it. // Otherwise, the L1 rollup contract will reject the block outright. - const { slotNumber } = checkpoint; const proposerIndex = Number( this.epochCache.computeProposerIndex(slotNumber, epoch, seed, BigInt(committee.length)), ); diff --git a/yarn-project/sequencer-client/src/test/utils.ts b/yarn-project/sequencer-client/src/test/utils.ts index ebaed1a77ed3..d0e7c6f0d3b8 100644 --- a/yarn-project/sequencer-client/src/test/utils.ts +++ b/yarn-project/sequencer-client/src/test/utils.ts @@ -99,10 +99,15 @@ function createCheckpointHeaderFromBlock(block: L2BlockNew): CheckpointHeader { * Creates a block proposal from a block and signature */ export function createBlockProposal(block: L2BlockNew, signature: Signature): BlockProposal { - const checkpointHeader = createCheckpointHeaderFromBlock(block); - const consensusPayload = new ConsensusPayload(checkpointHeader, block.archive.root); const txHashes = block.body.txEffects.map(tx => tx.txHash); - return new BlockProposal(consensusPayload, signature, txHashes); + return new BlockProposal( + block.header, + block.indexWithinCheckpoint, + Fr.ZERO, // inHash - using zero for testing + block.archive.root, + txHashes, + signature, + ); } /** diff --git a/yarn-project/stdlib/src/interfaces/validator.ts b/yarn-project/stdlib/src/interfaces/validator.ts index 2c01511abb40..3789576ebff8 100644 --- a/yarn-project/stdlib/src/interfaces/validator.ts +++ b/yarn-project/stdlib/src/interfaces/validator.ts @@ -4,14 +4,19 @@ import type { EthAddress } from '@aztec/foundation/eth-address'; import type { Signature } from '@aztec/foundation/eth-signature'; import { schemas, zodFor } from '@aztec/foundation/schemas'; import type { SequencerConfig, SlasherConfig } from '@aztec/stdlib/interfaces/server'; -import type { BlockAttestation, BlockProposal, BlockProposalOptions } from '@aztec/stdlib/p2p'; -import type { Tx } from '@aztec/stdlib/tx'; +import type { + BlockAttestation, + BlockProposal, + BlockProposalOptions, + CheckpointAttestation, + CheckpointProposal, +} from '@aztec/stdlib/p2p'; +import type { BlockHeader, Tx } from '@aztec/stdlib/tx'; import type { PeerId } from '@libp2p/interface'; import { z } from 'zod'; import type { CommitteeAttestationsAndSigners } from '../block/index.js'; -import type { CheckpointHeader } from '../rollup/checkpoint_header.js'; import { AllowedElementSchema } from './allowed_element.js'; /** @@ -84,16 +89,35 @@ export interface Validator { // Block validation responsibilities createBlockProposal( - blockNumber: number, - header: CheckpointHeader, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, archive: Fr, txs: Tx[], proposerAddress: EthAddress | undefined, options: BlockProposalOptions, ): Promise; - attestToProposal(proposal: BlockProposal, sender: PeerId): Promise; + + /** + * Validate a block proposal from a peer. + * Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals. + * @returns true if the proposal is valid, false otherwise + */ + validateBlockProposal(proposal: BlockProposal, sender: PeerId): Promise; + + /** + * Validate and attest to a checkpoint proposal from a peer. + * @returns Checkpoint attestations if valid, undefined otherwise + */ + attestToCheckpointProposal( + proposal: CheckpointProposal, + sender: PeerId, + ): Promise; broadcastBlockProposal(proposal: BlockProposal): Promise; + + // Attestation collection - still uses BlockAttestation for pool compatibility + // TODO(palla/mbps): Update to use CheckpointAttestation once attestation pool is updated collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise; signAttestationsAndSigners( attestationsAndSigners: CommitteeAttestationsAndSigners, diff --git a/yarn-project/stdlib/src/p2p/block_proposal.test.ts b/yarn-project/stdlib/src/p2p/block_proposal.test.ts index 695bdd4a233f..a4266d31f8d8 100644 --- a/yarn-project/stdlib/src/p2p/block_proposal.test.ts +++ b/yarn-project/stdlib/src/p2p/block_proposal.test.ts @@ -1,32 +1,9 @@ // Serde test for the block proposal type import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; -import { Signature } from '@aztec/foundation/eth-signature'; -import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import { makeBlockProposal } from '../tests/mocks.js'; import { Tx } from '../tx/tx.js'; -import { TxHash } from '../tx/tx_hash.js'; import { BlockProposal } from './block_proposal.js'; -import { ConsensusPayload } from './consensus_payload.js'; - -class BackwardsCompatibleBlockProposal extends BlockProposal { - constructor(payload: ConsensusPayload, signature: Signature) { - super(payload, signature, [], undefined); - } - - oldToBuffer(): Buffer { - return serializeToBuffer([this.payload, this.signature, 0, []]); - } - - static oldFromBuffer(buf: Buffer | BufferReader): BlockProposal { - const reader = BufferReader.asReader(buf); - return new BlockProposal( - reader.readObject(ConsensusPayload), - reader.readObject(Signature), - reader.readArray(0, TxHash), - ); - } -} describe('Block Proposal serialization / deserialization', () => { const checkEquivalence = (serialized: BlockProposal, deserialized: BlockProposal) => { @@ -43,24 +20,29 @@ describe('Block Proposal serialization / deserialization', () => { checkEquivalence(proposal, deserialized); }); - it('Should serialize / deserialize with or without included txs', async () => { - const txs = await Promise.all([Tx.random(), Tx.random()]); - const proposalWithTxs = makeBlockProposal({ txs }); + it('Should serialize / deserialize without txs', () => { + const proposal = makeBlockProposal(); - const oldProposal = new BackwardsCompatibleBlockProposal(proposalWithTxs.payload, proposalWithTxs.signature); + const serialized = proposal.toBuffer(); + const deserialized = BlockProposal.fromBuffer(serialized); - const serializedWithTxs = proposalWithTxs.toBuffer(); - const serializedWithoutTxs = oldProposal.oldToBuffer(); + expect(deserialized.archive).toEqual(proposal.archive); + expect(deserialized.blockHeader.equals(proposal.blockHeader)).toBe(true); + expect(deserialized.txHashes).toEqual(proposal.txHashes); + expect(deserialized.txs).toBeUndefined(); + }); - const deserializedWithTxs = BlockProposal.fromBuffer(serializedWithTxs); - const deserializedWithoutTxs = BlockProposal.fromBuffer(serializedWithoutTxs); + it('Should serialize / deserialize with txs', async () => { + const txs = await Promise.all([Tx.random(), Tx.random()]); + const proposal = makeBlockProposal({ txs }); - const oldDeserializedWithTxs = BackwardsCompatibleBlockProposal.oldFromBuffer(serializedWithTxs); - const oldDeserializedWithoutTxs = BackwardsCompatibleBlockProposal.oldFromBuffer(serializedWithoutTxs); + const serialized = proposal.toBuffer(); + const deserialized = BlockProposal.fromBuffer(serialized); - expect(deserializedWithTxs.archive).toEqual(deserializedWithoutTxs.archive); - expect(deserializedWithoutTxs.archive).toEqual(oldDeserializedWithTxs.archive); - expect(oldDeserializedWithTxs.archive).toEqual(oldDeserializedWithoutTxs.archive); + expect(deserialized.archive).toEqual(proposal.archive); + expect(deserialized.blockHeader.equals(proposal.blockHeader)).toBe(true); + expect(deserialized.txHashes).toEqual(proposal.txHashes); + expect(deserialized.txs?.length).toEqual(txs.length); }); it('Should serialize / deserialize + recover sender', async () => { @@ -77,4 +59,11 @@ describe('Block Proposal serialization / deserialization', () => { const sender = deserialized.getSender(); expect(sender).toEqual(account.address); }); + + it('Should expose block info via accessor methods', () => { + const proposal = makeBlockProposal(); + + expect(proposal.slotNumber).toBe(proposal.blockHeader.getSlot()); + expect(proposal.blockNumber).toBe(proposal.blockHeader.getBlockNumber()); + }); }); diff --git a/yarn-project/stdlib/src/p2p/block_proposal.ts b/yarn-project/stdlib/src/p2p/block_proposal.ts index 8a8e9ecc1531..deab033b9490 100644 --- a/yarn-project/stdlib/src/p2p/block_proposal.ts +++ b/yarn-project/stdlib/src/p2p/block_proposal.ts @@ -1,4 +1,4 @@ -import { SlotNumber } from '@aztec/foundation/branded-types'; +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; import { Buffer32 } from '@aztec/foundation/buffer'; import { keccak256 } from '@aztec/foundation/crypto/keccak'; import { tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; @@ -8,9 +8,9 @@ import { Signature } from '@aztec/foundation/eth-signature'; import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; import type { L2BlockInfo } from '../block/l2_block_info.js'; +import { BlockHeader } from '../tx/block_header.js'; import { TxHash } from '../tx/index.js'; import { Tx } from '../tx/tx.js'; -import { ConsensusPayload } from './consensus_payload.js'; import { Gossipable } from './gossipable.js'; import { SignatureDomainSeparator, @@ -34,8 +34,9 @@ export type BlockProposalOptions = { /** * BlockProposal * - * A block proposal is created by the leader of the chain proposing a sequence of transactions to - * be included in the head of the chain + * A block proposal is created by the leader of the chain proposing a sequence of transactions + * to be included in a block within a checkpoint. This is used for non-last blocks in a slot. + * The last block is sent as part of a CheckpointProposal. */ export class BlockProposal extends Gossipable { static override p2pTopic = TopicType.block_proposal; @@ -43,15 +44,24 @@ export class BlockProposal extends Gossipable { private sender: EthAddress | undefined; constructor( - /** The payload of the message, and what the signature is over */ - public readonly payload: ConsensusPayload, + /** The per-block header containing block state and global variables */ + public readonly blockHeader: BlockHeader, - /** The signer of the BlockProposal over the header of the new block*/ - public readonly signature: Signature, + /** Index of this block within the checkpoint (0-indexed) */ + public readonly indexWithinCheckpoint: number, + + /** Hash of L1 to L2 messages for this checkpoint (constant across all blocks in checkpoint) */ + public readonly inHash: Fr, + + /** Archive root after this block is applied */ + public readonly archiveRoot: Fr, /** The sequence of transactions in the block */ public readonly txHashes: TxHash[], + /** The proposer's signature over the block data */ + public readonly signature: Signature, + // Note(md): this is placed after the txs payload in order to be backwards compatible with previous versions /** The transactions in the block */ public readonly txs?: Tx[], @@ -64,43 +74,76 @@ export class BlockProposal extends Gossipable { } get archive(): Fr { - return this.payload.archive; + return this.archiveRoot; } get slotNumber(): SlotNumber { - return this.payload.header.slotNumber; + return this.blockHeader.getSlot(); + } + + get blockNumber(): BlockNumber { + return this.blockHeader.getBlockNumber(); } toBlockInfo(): Omit { return { slotNumber: this.slotNumber, - lastArchive: this.payload.header.lastArchiveRoot, - timestamp: this.payload.header.timestamp, - archive: this.archive, + lastArchive: this.blockHeader.lastArchive.root, + timestamp: this.blockHeader.globalVariables.timestamp, + archive: this.archiveRoot, txCount: this.txHashes.length, }; } + /** + * Get the payload to sign for this block proposal. + * The signature is over: blockHeader + indexWithinCheckpoint + inHash + archiveRoot + txHashes + */ + getPayloadToSign(domainSeparator: SignatureDomainSeparator): Buffer { + return serializeToBuffer([ + domainSeparator, + this.blockHeader, + this.indexWithinCheckpoint, + this.inHash, + this.archiveRoot, + this.txHashes.length, + this.txHashes, + ]); + } + static async createProposalFromSigner( - payload: ConsensusPayload, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, + archiveRoot: Fr, txHashes: TxHash[], - // Note(md): Provided separately to tx hashes such that this function can be optional txs: Tx[] | undefined, payloadSigner: (payload: Buffer32) => Promise, ) { - const hashed = getHashedSignaturePayload(payload, SignatureDomainSeparator.blockProposal); + // Create a temporary proposal to get the payload to sign + const tempProposal = new BlockProposal( + blockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + Signature.empty(), + txs, + ); + + const hashed = getHashedSignaturePayload(tempProposal, SignatureDomainSeparator.blockProposal); const sig = await payloadSigner(hashed); - return new BlockProposal(payload, sig, txHashes, txs); + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, sig, txs); } - /**Get Sender + /** * Lazily evaluate the sender of the proposal; result is cached * @returns The sender address, or undefined if signature recovery fails */ getSender(): EthAddress | undefined { if (!this.sender) { - const hashed = getHashedSignaturePayloadEthSignedMessage(this.payload, SignatureDomainSeparator.blockProposal); + const hashed = getHashedSignaturePayloadEthSignedMessage(this, SignatureDomainSeparator.blockProposal); // Cache the sender for later use this.sender = tryRecoverAddress(hashed, this.signature); } @@ -109,11 +152,19 @@ export class BlockProposal extends Gossipable { } getPayload() { - return this.payload.getPayloadToSign(SignatureDomainSeparator.blockProposal); + return this.getPayloadToSign(SignatureDomainSeparator.blockProposal); } toBuffer(): Buffer { - const buffer: any[] = [this.payload, this.signature, this.txHashes.length, this.txHashes]; + const buffer: any[] = [ + this.blockHeader, + this.indexWithinCheckpoint, + this.inHash, + this.archiveRoot, + this.signature, + this.txHashes.length, + this.txHashes, + ]; if (this.txs) { buffer.push(this.txs.length); buffer.push(this.txs); @@ -124,25 +175,57 @@ export class BlockProposal extends Gossipable { static fromBuffer(buf: Buffer | BufferReader): BlockProposal { const reader = BufferReader.asReader(buf); - const payload = reader.readObject(ConsensusPayload); - const sig = reader.readObject(Signature); + const blockHeader = reader.readObject(BlockHeader); + const indexWithinCheckpoint = reader.readNumber(); + const inHash = reader.readObject(Fr); + const archiveRoot = reader.readObject(Fr); + const signature = reader.readObject(Signature); const txHashes = reader.readArray(reader.readNumber(), TxHash); if (!reader.isEmpty()) { const txs = reader.readArray(reader.readNumber(), Tx); - return new BlockProposal(payload, sig, txHashes, txs); + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, signature, txs); } - return new BlockProposal(payload, sig, txHashes); + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, signature); } getSize(): number { return ( - this.payload.getSize() + + this.blockHeader.getSize() + + 4 /* indexWithinCheckpoint */ + + this.inHash.size + + this.archiveRoot.size + this.signature.getSize() + 4 /* txHashes.length */ + this.txHashes.length * TxHash.SIZE + (this.txs ? 4 /* txs.length */ + this.txs.reduce((acc, tx) => acc + tx.getSize(), 0) : 0) ); } + + static empty(): BlockProposal { + return new BlockProposal(BlockHeader.empty(), 0, Fr.ZERO, Fr.ZERO, [], Signature.empty()); + } + + static random(): BlockProposal { + return new BlockProposal( + BlockHeader.random(), + Math.floor(Math.random() * 5), + Fr.random(), + Fr.random(), + [TxHash.random(), TxHash.random()], + Signature.random(), + ); + } + + toInspect() { + return { + blockHeader: this.blockHeader.toInspect(), + indexWithinCheckpoint: this.indexWithinCheckpoint, + inHash: this.inHash.toString(), + archiveRoot: this.archiveRoot.toString(), + signature: this.signature.toString(), + txHashes: this.txHashes.map(h => h.toString()), + }; + } } diff --git a/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts new file mode 100644 index 000000000000..7bcd4907acaa --- /dev/null +++ b/yarn-project/stdlib/src/p2p/checkpoint_attestation.ts @@ -0,0 +1,214 @@ +import { SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { keccak256 } from '@aztec/foundation/crypto/keccak'; +import { tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { EthAddress } from '@aztec/foundation/eth-address'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; + +import { z } from 'zod'; + +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; +import type { ZodFor } from '../schemas/index.js'; +import { Gossipable } from './gossipable.js'; +import { SignatureDomainSeparator, getHashedSignaturePayloadEthSignedMessage } from './signature_utils.js'; +import { TopicType } from './topic_type.js'; + +export class CheckpointAttestationHash extends Buffer32 { + constructor(hash: Buffer) { + super(hash); + } +} + +/** + * CheckpointAttestationPayload + * + * The payload that validators attest to for a checkpoint. + * This is the consensus-critical data: the checkpoint header and final archive root. + */ +export class CheckpointAttestationPayload { + private size: number | undefined; + + constructor( + /** The checkpoint header containing aggregated data for all blocks in the slot */ + public readonly header: CheckpointHeader, + /** The archive root after all blocks in the checkpoint are applied */ + public readonly archive: Fr, + ) {} + + static get schema() { + return z + .object({ + header: CheckpointHeader.schema, + archive: z.instanceof(Fr), + }) + .transform(obj => new CheckpointAttestationPayload(obj.header, obj.archive)); + } + + getPayloadToSign(domainSeparator: SignatureDomainSeparator): Buffer { + return serializeToBuffer([domainSeparator, this.header, this.archive]); + } + + toBuffer(): Buffer { + return serializeToBuffer([this.header, this.archive]); + } + + public equals(other: CheckpointAttestationPayload): boolean { + return this.header.equals(other.header) && this.archive.equals(other.archive); + } + + static fromBuffer(buf: Buffer | BufferReader): CheckpointAttestationPayload { + const reader = BufferReader.asReader(buf); + return new CheckpointAttestationPayload(reader.readObject(CheckpointHeader), reader.readObject(Fr)); + } + + static empty(): CheckpointAttestationPayload { + return new CheckpointAttestationPayload(CheckpointHeader.empty(), Fr.ZERO); + } + + static random(): CheckpointAttestationPayload { + return new CheckpointAttestationPayload(CheckpointHeader.random(), Fr.random()); + } + + getSize(): number { + if (this.size) { + return this.size; + } + this.size = this.toBuffer().length; + return this.size; + } + + toInspect() { + return { + header: this.header.toInspect(), + archive: this.archive.toString(), + }; + } + + toString() { + return `header: ${this.header.toString()}, archive: ${this.archive.toString()}}`; + } +} + +/** + * CheckpointAttestation + * + * A validator that has attested to seeing all blocks in a checkpoint + * will produce a checkpoint attestation over the checkpoint header. + * This replaces BlockAttestation for the multi-block-per-slot model. + */ +export class CheckpointAttestation extends Gossipable { + static override p2pTopic = TopicType.checkpoint_attestation; + + private sender: EthAddress | undefined; + private proposer: EthAddress | undefined; + + constructor( + /** The payload of the message, and what the signature is over */ + public readonly payload: CheckpointAttestationPayload, + + /** The signature of the checkpoint attester */ + public readonly signature: Signature, + + /** The signature from the checkpoint proposer */ + public readonly proposerSignature: Signature, + ) { + super(); + } + + static get schema(): ZodFor { + return z + .object({ + payload: CheckpointAttestationPayload.schema, + signature: Signature.schema, + proposerSignature: Signature.schema, + }) + .transform(obj => new CheckpointAttestation(obj.payload, obj.signature, obj.proposerSignature)); + } + + override generateP2PMessageIdentifier(): Promise { + return Promise.resolve(new CheckpointAttestationHash(keccak256(this.signature.toBuffer()))); + } + + get archive(): Fr { + return this.payload.archive; + } + + get slotNumber(): SlotNumber { + return this.payload.header.slotNumber; + } + + /** + * Lazily evaluate and cache the signer of the attestation + * @returns The signer of the attestation, or undefined if signature recovery fails + */ + getSender(): EthAddress | undefined { + if (!this.sender) { + // Recover the sender from the attestation + const hashed = getHashedSignaturePayloadEthSignedMessage( + this.payload, + SignatureDomainSeparator.checkpointAttestation, + ); + // Cache the sender for later use + this.sender = tryRecoverAddress(hashed, this.signature); + } + + return this.sender; + } + + /** + * Lazily evaluate and cache the proposer of the checkpoint + * @returns The proposer of the checkpoint + */ + getProposer(): EthAddress | undefined { + if (!this.proposer) { + // Recover the proposer from the proposal signature + const hashed = getHashedSignaturePayloadEthSignedMessage( + this.payload, + SignatureDomainSeparator.checkpointProposal, + ); + // Cache the proposer for later use + this.proposer = tryRecoverAddress(hashed, this.proposerSignature); + } + + return this.proposer; + } + + getPayload(): Buffer { + return this.payload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation); + } + + toBuffer(): Buffer { + return serializeToBuffer([this.payload, this.signature, this.proposerSignature]); + } + + static fromBuffer(buf: Buffer | BufferReader): CheckpointAttestation { + const reader = BufferReader.asReader(buf); + return new CheckpointAttestation( + reader.readObject(CheckpointAttestationPayload), + reader.readObject(Signature), + reader.readObject(Signature), + ); + } + + static empty(): CheckpointAttestation { + return new CheckpointAttestation(CheckpointAttestationPayload.empty(), Signature.empty(), Signature.empty()); + } + + static random(): CheckpointAttestation { + return new CheckpointAttestation(CheckpointAttestationPayload.random(), Signature.random(), Signature.random()); + } + + getSize(): number { + return this.payload.getSize() + this.signature.getSize() + this.proposerSignature.getSize(); + } + + toInspect() { + return { + payload: this.payload.toInspect(), + signature: this.signature.toString(), + proposerSignature: this.proposerSignature.toString(), + }; + } +} diff --git a/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts new file mode 100644 index 000000000000..129f21d6ba29 --- /dev/null +++ b/yarn-project/stdlib/src/p2p/checkpoint_proposal.ts @@ -0,0 +1,268 @@ +import { BlockNumber, SlotNumber } from '@aztec/foundation/branded-types'; +import { Buffer32 } from '@aztec/foundation/buffer'; +import { keccak256 } from '@aztec/foundation/crypto/keccak'; +import { tryRecoverAddress } from '@aztec/foundation/crypto/secp256k1-signer'; +import { Fr } from '@aztec/foundation/curves/bn254'; +import type { EthAddress } from '@aztec/foundation/eth-address'; +import { Signature } from '@aztec/foundation/eth-signature'; +import { BufferReader, serializeToBuffer } from '@aztec/foundation/serialize'; + +import type { L2BlockInfo } from '../block/l2_block_info.js'; +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; +import { BlockHeader } from '../tx/block_header.js'; +import { TxHash } from '../tx/index.js'; +import { Tx } from '../tx/tx.js'; +import { Gossipable } from './gossipable.js'; +import { + SignatureDomainSeparator, + getHashedSignaturePayload, + getHashedSignaturePayloadEthSignedMessage, +} from './signature_utils.js'; +import { TopicType } from './topic_type.js'; + +export class CheckpointProposalHash extends Buffer32 { + constructor(hash: Buffer) { + super(hash); + } +} + +export type CheckpointProposalOptions = { + publishFullTxs: boolean; + /** Whether to generate an invalid checkpoint proposal for broadcasting. Use only for testing. */ + broadcastInvalidCheckpointProposal?: boolean; +}; + +/** + * CheckpointProposal + * + * A checkpoint proposal is created by the leader of the chain for the last block in a checkpoint. + * It includes the last block's header and transactions, plus the aggregated checkpoint header + * that validators will attest to. This marks the completion of a slot's worth of blocks. + */ +export class CheckpointProposal extends Gossipable { + static override p2pTopic = TopicType.checkpoint_proposal; + + private sender: EthAddress | undefined; + + constructor( + /** The per-block header for the last block in the checkpoint */ + public readonly lastBlockHeader: BlockHeader, + + /** Index of this block within the checkpoint (should be the last index, e.g., numBlocks - 1) */ + public readonly indexWithinCheckpoint: number, + + /** Hash of L1 to L2 messages for this checkpoint (constant across all blocks in checkpoint) */ + public readonly inHash: Fr, + + /** Archive root after this block is applied */ + public readonly archiveRoot: Fr, + + /** The sequence of transactions in the last block */ + public readonly txHashes: TxHash[], + + /** The aggregated checkpoint header for consensus */ + public readonly checkpointHeader: CheckpointHeader, + + /** The proposer's signature over the checkpoint payload (checkpointHeader + archiveRoot) */ + public readonly signature: Signature, + + // Note(md): this is placed after the txs payload in order to be backwards compatible with previous versions + /** The transactions in the last block */ + public readonly txs?: Tx[], + ) { + super(); + } + + override generateP2PMessageIdentifier(): Promise { + return Promise.resolve(new CheckpointProposalHash(keccak256(this.signature.toBuffer()))); + } + + get archive(): Fr { + return this.archiveRoot; + } + + get slotNumber(): SlotNumber { + return this.checkpointHeader.slotNumber; + } + + get blockNumber(): BlockNumber { + return this.lastBlockHeader.getBlockNumber(); + } + + toBlockInfo(): Omit { + return { + slotNumber: this.slotNumber, + lastArchive: this.lastBlockHeader.lastArchive.root, + timestamp: this.lastBlockHeader.globalVariables.timestamp, + archive: this.archiveRoot, + txCount: this.txHashes.length, + }; + } + + /** + * Get the payload to sign for this checkpoint proposal. + * The signature is over the checkpoint header + archive root (for consensus). + */ + getPayloadToSign(domainSeparator: SignatureDomainSeparator): Buffer { + return serializeToBuffer([domainSeparator, this.checkpointHeader, this.archiveRoot]); + } + + static async createProposalFromSigner( + lastBlockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, + archiveRoot: Fr, + txHashes: TxHash[], + checkpointHeader: CheckpointHeader, + txs: Tx[] | undefined, + payloadSigner: (payload: Buffer32) => Promise, + ) { + // Create a temporary proposal to get the payload to sign + const tempProposal = new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + Signature.empty(), + txs, + ); + + const hashed = getHashedSignaturePayload(tempProposal, SignatureDomainSeparator.checkpointProposal); + const sig = await payloadSigner(hashed); + + return new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + sig, + txs, + ); + } + + /** + * Lazily evaluate the sender of the proposal; result is cached + * @returns The sender address, or undefined if signature recovery fails + */ + getSender(): EthAddress | undefined { + if (!this.sender) { + const hashed = getHashedSignaturePayloadEthSignedMessage(this, SignatureDomainSeparator.checkpointProposal); + // Cache the sender for later use + this.sender = tryRecoverAddress(hashed, this.signature); + } + + return this.sender; + } + + getPayload() { + return this.getPayloadToSign(SignatureDomainSeparator.checkpointProposal); + } + + toBuffer(): Buffer { + const buffer: any[] = [ + this.lastBlockHeader, + this.indexWithinCheckpoint, + this.inHash, + this.archiveRoot, + this.signature, + this.txHashes.length, + this.txHashes, + this.checkpointHeader, + ]; + if (this.txs) { + buffer.push(this.txs.length); + buffer.push(this.txs); + } + return serializeToBuffer(buffer); + } + + static fromBuffer(buf: Buffer | BufferReader): CheckpointProposal { + const reader = BufferReader.asReader(buf); + + const lastBlockHeader = reader.readObject(BlockHeader); + const indexWithinCheckpoint = reader.readNumber(); + const inHash = reader.readObject(Fr); + const archiveRoot = reader.readObject(Fr); + const signature = reader.readObject(Signature); + const txHashes = reader.readArray(reader.readNumber(), TxHash); + const checkpointHeader = reader.readObject(CheckpointHeader); + + if (!reader.isEmpty()) { + const txs = reader.readArray(reader.readNumber(), Tx); + return new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + signature, + txs, + ); + } + + return new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + signature, + ); + } + + getSize(): number { + return ( + this.lastBlockHeader.getSize() + + 4 /* indexWithinCheckpoint */ + + this.inHash.size + + this.archiveRoot.size + + this.signature.getSize() + + 4 /* txHashes.length */ + + this.txHashes.length * TxHash.SIZE + + this.checkpointHeader.toBuffer().length + + (this.txs ? 4 /* txs.length */ + this.txs.reduce((acc, tx) => acc + tx.getSize(), 0) : 0) + ); + } + + static empty(): CheckpointProposal { + return new CheckpointProposal( + BlockHeader.empty(), + 0, + Fr.ZERO, + Fr.ZERO, + [], + CheckpointHeader.empty(), + Signature.empty(), + ); + } + + static random(): CheckpointProposal { + return new CheckpointProposal( + BlockHeader.random(), + Math.floor(Math.random() * 5), + Fr.random(), + Fr.random(), + [TxHash.random(), TxHash.random()], + CheckpointHeader.random(), + Signature.random(), + ); + } + + toInspect() { + return { + lastBlockHeader: this.lastBlockHeader.toInspect(), + indexWithinCheckpoint: this.indexWithinCheckpoint, + inHash: this.inHash.toString(), + archiveRoot: this.archiveRoot.toString(), + checkpointHeader: this.checkpointHeader.toInspect(), + signature: this.signature.toString(), + txHashes: this.txHashes.map(h => h.toString()), + }; + } +} diff --git a/yarn-project/stdlib/src/p2p/index.ts b/yarn-project/stdlib/src/p2p/index.ts index 6d5ae19b262b..7517c94d0975 100644 --- a/yarn-project/stdlib/src/p2p/index.ts +++ b/yarn-project/stdlib/src/p2p/index.ts @@ -1,6 +1,8 @@ export * from './attestation_utils.js'; export * from './block_attestation.js'; export * from './block_proposal.js'; +export * from './checkpoint_attestation.js'; +export * from './checkpoint_proposal.js'; export * from './consensus_payload.js'; export * from './gossipable.js'; export * from './interface.js'; diff --git a/yarn-project/stdlib/src/p2p/signature_utils.ts b/yarn-project/stdlib/src/p2p/signature_utils.ts index da33fe2d32b2..c519e39dfb77 100644 --- a/yarn-project/stdlib/src/p2p/signature_utils.ts +++ b/yarn-project/stdlib/src/p2p/signature_utils.ts @@ -4,8 +4,11 @@ import { makeEthSignDigest } from '@aztec/foundation/crypto/secp256k1-signer'; export enum SignatureDomainSeparator { blockProposal = 0, + /** @deprecated Use checkpointAttestation instead */ blockAttestation = 1, attestationsAndSigners = 2, + checkpointProposal = 3, + checkpointAttestation = 4, } export interface Signable { diff --git a/yarn-project/stdlib/src/p2p/topic_type.ts b/yarn-project/stdlib/src/p2p/topic_type.ts index e59fb14495bd..ceb84d036bd4 100644 --- a/yarn-project/stdlib/src/p2p/topic_type.ts +++ b/yarn-project/stdlib/src/p2p/topic_type.ts @@ -23,6 +23,9 @@ export function getTopicFromString(topicStr: string): TopicType | undefined { export enum TopicType { tx = 'tx', block_proposal = 'block_proposal', + checkpoint_proposal = 'checkpoint_proposal', + checkpoint_attestation = 'checkpoint_attestation', + /** @deprecated Use checkpoint_attestation instead */ block_attestation = 'block_attestation', } diff --git a/yarn-project/stdlib/src/tests/mocks.ts b/yarn-project/stdlib/src/tests/mocks.ts index 166bbfb3505b..e013af286e14 100644 --- a/yarn-project/stdlib/src/tests/mocks.ts +++ b/yarn-project/stdlib/src/tests/mocks.ts @@ -13,6 +13,7 @@ import { padArrayEnd, times } from '@aztec/foundation/collection'; import { randomBytes } from '@aztec/foundation/crypto/random'; import { Secp256k1Signer } from '@aztec/foundation/crypto/secp256k1-signer'; import { Fr } from '@aztec/foundation/curves/bn254'; +import { Signature } from '@aztec/foundation/eth-signature'; import type { ContractArtifact } from '../abi/abi.js'; import { PublicTxEffect } from '../avm/avm.js'; @@ -47,10 +48,12 @@ import { PublicCallRequestArrayLengths } from '../kernel/public_call_request.js' import { computeInHashFromL1ToL2Messages } from '../messaging/in_hash.js'; import { BlockAttestation } from '../p2p/block_attestation.js'; import { BlockProposal } from '../p2p/block_proposal.js'; +import { CheckpointProposal } from '../p2p/checkpoint_proposal.js'; import { ConsensusPayload } from '../p2p/consensus_payload.js'; import { SignatureDomainSeparator, getHashedSignaturePayloadEthSignedMessage } from '../p2p/signature_utils.js'; import { ChonkProof } from '../proofs/chonk_proof.js'; import { ProvingRequestType } from '../proofs/proving_request_type.js'; +import { CheckpointHeader } from '../rollup/checkpoint_header.js'; import { AppendOnlyTreeSnapshot } from '../trees/append_only_tree_snapshot.js'; import { BlockHeader, @@ -487,6 +490,28 @@ export interface MakeConsensusPayloadOptions { txs?: Tx[]; } +export interface MakeBlockProposalOptions { + signer?: Secp256k1Signer; + blockHeader?: L2BlockHeader; + indexWithinCheckpoint?: number; + inHash?: Fr; + archiveRoot?: Fr; + txHashes?: TxHash[]; + txs?: Tx[]; +} + +export interface MakeCheckpointProposalOptions { + signer?: Secp256k1Signer; + lastBlockHeader?: L2BlockHeader; + indexWithinCheckpoint?: number; + inHash?: Fr; + archiveRoot?: Fr; + checkpointHeader?: CheckpointHeader; + txHashes?: TxHash[]; + txs?: Tx[]; +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars const makeAndSignConsensusPayload = ( domainSeparator: SignatureDomainSeparator, options?: MakeConsensusPayloadOptions, @@ -516,10 +541,69 @@ export const makeAndSignCommitteeAttestationsAndSigners = ( return signer.sign(hash); }; -export const makeBlockProposal = (options?: MakeConsensusPayloadOptions): BlockProposal => { - const { payload, signature } = makeAndSignConsensusPayload(SignatureDomainSeparator.blockProposal, options); +export const makeBlockProposal = (options?: MakeBlockProposalOptions): BlockProposal => { + const l2BlockHeader = options?.blockHeader ?? makeL2BlockHeader(1); + const blockHeader = l2BlockHeader.toBlockHeader(); + const indexWithinCheckpoint = options?.indexWithinCheckpoint ?? 0; + const inHash = options?.inHash ?? Fr.random(); + const archiveRoot = options?.archiveRoot ?? Fr.random(); + const txHashes = options?.txHashes ?? [0, 1, 2, 3, 4, 5].map(() => TxHash.random()); + const txs = options?.txs; + const signer = options?.signer ?? Secp256k1Signer.random(); + + // Create a temporary proposal to get the payload to sign + const tempProposal = new BlockProposal( + blockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + Signature.empty(), + txs, + ); + + const hash = getHashedSignaturePayloadEthSignedMessage(tempProposal, SignatureDomainSeparator.blockProposal); + const signature = signer.sign(hash); + + return new BlockProposal(blockHeader, indexWithinCheckpoint, inHash, archiveRoot, txHashes, signature, txs); +}; + +export const makeCheckpointProposal = (options?: MakeCheckpointProposalOptions): CheckpointProposal => { + const l2BlockHeader = options?.lastBlockHeader ?? makeL2BlockHeader(1); + const lastBlockHeader = l2BlockHeader.toBlockHeader(); + const indexWithinCheckpoint = options?.indexWithinCheckpoint ?? 4; // Last block in a 5-block checkpoint + const inHash = options?.inHash ?? Fr.random(); + const archiveRoot = options?.archiveRoot ?? Fr.random(); + const checkpointHeader = options?.checkpointHeader ?? l2BlockHeader.toCheckpointHeader(); const txHashes = options?.txHashes ?? [0, 1, 2, 3, 4, 5].map(() => TxHash.random()); - return new BlockProposal(payload, signature, txHashes, options?.txs ?? []); + const txs = options?.txs; + const signer = options?.signer ?? Secp256k1Signer.random(); + + // Create a temporary proposal to get the payload to sign + const tempProposal = new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + Signature.empty(), + txs, + ); + + const hash = getHashedSignaturePayloadEthSignedMessage(tempProposal, SignatureDomainSeparator.checkpointProposal); + const signature = signer.sign(hash); + + return new CheckpointProposal( + lastBlockHeader, + indexWithinCheckpoint, + inHash, + archiveRoot, + txHashes, + checkpointHeader, + signature, + txs, + ); }; // TODO(https://github.com/AztecProtocol/aztec-packages/issues/8028) diff --git a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts index ff4c72e73c23..e06be13db745 100644 --- a/yarn-project/txe/src/state_machine/dummy_p2p_client.ts +++ b/yarn-project/txe/src/state_machine/dummy_p2p_client.ts @@ -4,6 +4,7 @@ import type { ENR, P2P, P2PBlockReceivedCallback, + P2PCheckpointReceivedCallback, P2PConfig, P2PSyncState, PeerId, @@ -14,7 +15,7 @@ import type { } from '@aztec/p2p'; import type { EthAddress, L2BlockStreamEvent, L2Tips } from '@aztec/stdlib/block'; import type { PeerInfo } from '@aztec/stdlib/interfaces/server'; -import type { BlockAttestation, BlockProposal } from '@aztec/stdlib/p2p'; +import type { BlockAttestation, BlockProposal, CheckpointAttestation, CheckpointProposal } from '@aztec/stdlib/p2p'; import type { Tx, TxHash } from '@aztec/stdlib/tx'; export class DummyP2P implements P2P { @@ -46,10 +47,22 @@ export class DummyP2P implements P2P { throw new Error('DummyP2P does not implement "broadcastProposal"'); } + public broadcastCheckpointProposal(_proposal: CheckpointProposal): Promise { + throw new Error('DummyP2P does not implement "broadcastCheckpointProposal"'); + } + + public broadcastCheckpointAttestations(_attestations: CheckpointAttestation[]): Promise { + throw new Error('DummyP2P does not implement "broadcastCheckpointAttestations"'); + } + public registerBlockProposalHandler(_handler: P2PBlockReceivedCallback): void { throw new Error('DummyP2P does not implement "registerBlockProposalHandler"'); } + public registerCheckpointProposalHandler(_handler: P2PCheckpointReceivedCallback): void { + throw new Error('DummyP2P does not implement "registerCheckpointProposalHandler"'); + } + public requestTxs(_txHashes: TxHash[]): Promise<(Tx | undefined)[]> { throw new Error('DummyP2P does not implement "requestTxs"'); } diff --git a/yarn-project/validator-client/src/block_proposal_handler.ts b/yarn-project/validator-client/src/block_proposal_handler.ts index c6bc928fb363..fa2f50b9f193 100644 --- a/yarn-project/validator-client/src/block_proposal_handler.ts +++ b/yarn-project/validator-client/src/block_proposal_handler.ts @@ -12,7 +12,7 @@ import type { L2Block, L2BlockSource } from '@aztec/stdlib/block'; import { getTimestampForSlot } from '@aztec/stdlib/epoch-helpers'; import type { IFullNodeBlockBuilder, ValidatorClientFullConfig } from '@aztec/stdlib/interfaces/server'; import { type L1ToL2MessageSource, computeInHashFromL1ToL2Messages } from '@aztec/stdlib/messaging'; -import { type BlockProposal, ConsensusPayload } from '@aztec/stdlib/p2p'; +import type { BlockProposal } from '@aztec/stdlib/p2p'; import { BlockHeader, type FailedTx, GlobalVariables, type Tx } from '@aztec/stdlib/tx'; import { ReExFailedTxsError, @@ -80,7 +80,9 @@ export class BlockProposalHandler { } registerForReexecution(p2pClient: P2P): BlockProposalHandler { - const handler = async (proposal: BlockProposal, proposalSender: PeerId) => { + // Non-validator handler that re-executes for monitoring but does not attest. + // Returns boolean indicating whether the proposal was valid. + const handler = async (proposal: BlockProposal, proposalSender: PeerId): Promise => { try { const result = await this.handleBlockProposal(proposal, proposalSender, true); if (result.isValid) { @@ -90,16 +92,18 @@ export class BlockProposalHandler { totalManaUsed: result.reexecutionResult?.totalManaUsed, numTxs: result.reexecutionResult?.block?.body?.txEffects?.length ?? 0, }); + return true; } else { this.log.warn(`Non-validator reexecution failed for slot ${proposal.slotNumber}`, { blockNumber: result.blockNumber, reason: result.reason, }); + return false; } } catch (error) { this.log.error('Error processing block proposal in non-validator handler', error); + return false; } - return undefined; // Non-validator nodes don't return attestations }; p2pClient.registerBlockProposalHandler(handler); @@ -177,7 +181,7 @@ export class BlockProposalHandler { CheckpointNumber.fromBlockNumber(blockNumber), ); const computedInHash = computeInHashFromL1ToL2Messages(l1ToL2Messages); - const proposalInHash = proposal.payload.header.contentCommitment.inHash; + const proposalInHash = proposal.inHash; if (!computedInHash.equals(proposalInHash)) { this.log.warn(`L1 to L2 messages in hash mismatch, skipping processing`, { proposalInHash: proposalInHash.toString(), @@ -211,7 +215,7 @@ export class BlockProposalHandler { } private async getParentBlock(proposal: BlockProposal): Promise<'genesis' | BlockHeader | undefined> { - const parentArchive = proposal.payload.header.lastArchiveRoot; + const parentArchive = proposal.blockHeader.lastArchive.root; const slot = proposal.slotNumber; const config = this.blockBuilder.getConfig(); const { genesisArchiveRoot } = await this.blockSource.getGenesisValues(); @@ -271,8 +275,7 @@ export class BlockProposalHandler { txs: Tx[], l1ToL2Messages: Fr[], ): Promise { - const { header } = proposal.payload; - const { txHashes } = proposal; + const { blockHeader, txHashes } = proposal; // If we do not have all of the transactions, then we should fail if (txs.length !== txHashes.length) { @@ -287,18 +290,18 @@ export class BlockProposalHandler { // We source most global variables from the proposal const globalVariables = GlobalVariables.from({ - slotNumber: proposal.payload.header.slotNumber, // checked in the block proposal validator - coinbase: proposal.payload.header.coinbase, // set arbitrarily by the proposer - feeRecipient: proposal.payload.header.feeRecipient, // set arbitrarily by the proposer - gasFees: proposal.payload.header.gasFees, // validated by the rollup contract + slotNumber: blockHeader.globalVariables.slotNumber, // checked in the block proposal validator + coinbase: blockHeader.globalVariables.coinbase, // set arbitrarily by the proposer + feeRecipient: blockHeader.globalVariables.feeRecipient, // set arbitrarily by the proposer + gasFees: blockHeader.globalVariables.gasFees, // validated by the rollup contract blockNumber, // computed from the parent block and checked it does not exist in archiver - timestamp: header.timestamp, // checked in the rollup contract against the slot number + timestamp: blockHeader.globalVariables.timestamp, // checked in the rollup contract against the slot number chainId: new Fr(config.l1ChainId), version: new Fr(config.rollupVersion), }); const { block, failedTxs } = await this.blockBuilder.buildBlock(txs, l1ToL2Messages, globalVariables, { - deadline: this.getReexecutionDeadline(proposal.payload.header.slotNumber, config), + deadline: this.getReexecutionDeadline(proposal.slotNumber, config), }); const numFailedTxs = failedTxs.length; @@ -321,11 +324,11 @@ export class BlockProposalHandler { } // Throw a ReExStateMismatchError error if state updates do not match - const blockPayload = ConsensusPayload.fromBlock(block); - if (!blockPayload.equals(proposal.payload)) { + // Compare the archive root from the built block with the proposal's archive + if (!block.archive.root.equals(proposal.archive)) { this.log.warn(`Re-execution state mismatch for slot ${slot}`, { - expected: blockPayload.toInspect(), - actual: proposal.payload.toInspect(), + expectedArchive: block.archive.root.toString(), + actualArchive: proposal.archive.toString(), }); this.metrics?.recordFailedReexecution(proposal); throw new ReExStateMismatchError(proposal.archive, block.archive.root); diff --git a/yarn-project/validator-client/src/duties/validation_service.test.ts b/yarn-project/validator-client/src/duties/validation_service.test.ts index 2b8549d5cc21..a6acd7d91a53 100644 --- a/yarn-project/validator-client/src/duties/validation_service.test.ts +++ b/yarn-project/validator-client/src/duties/validation_service.test.ts @@ -1,7 +1,8 @@ import { getAddressFromPrivateKey } from '@aztec/ethereum/account'; import { Buffer32 } from '@aztec/foundation/buffer'; +import { Fr } from '@aztec/foundation/curves/bn254'; import { EthAddress } from '@aztec/foundation/eth-address'; -import { makeBlockProposal } from '@aztec/stdlib/testing'; +import { makeCheckpointProposal, makeL2BlockHeader } from '@aztec/stdlib/testing'; import { Tx } from '@aztec/stdlib/tx'; import { generatePrivateKey } from 'viem/accounts'; @@ -22,35 +23,53 @@ describe('ValidationService', () => { service = new ValidationService(store); }); - it('creates a proposal with txs appended', async () => { + it('creates a block proposal with txs appended', async () => { const txs = await Promise.all([Tx.random(), Tx.random()]); - const { - payload: { header, archive }, - } = makeBlockProposal({ txs }); - const proposal = await service.createBlockProposal(header, archive, txs, addresses[0], { - publishFullTxs: true, - }); + const l2BlockHeader = makeL2BlockHeader(1, 2, 3); + const blockHeader = l2BlockHeader.toBlockHeader(); + const indexWithinCheckpoint = 0; + const inHash = Fr.random(); + const archive = Fr.random(); + + const proposal = await service.createBlockProposal( + blockHeader, + indexWithinCheckpoint, + inHash, + archive, + txs, + addresses[0], + { publishFullTxs: true }, + ); expect(proposal.getSender()).toEqual(store.getAddress(0)); expect(proposal.txs).toBeDefined(); expect(proposal.txs).toBe(txs); }); - it('creates a proposal without txs appended', async () => { + it('creates a block proposal without txs appended', async () => { const txs = await Promise.all([Tx.random(), Tx.random()]); - const { - payload: { header, archive }, - } = makeBlockProposal({ txs }); - const proposal = await service.createBlockProposal(header, archive, txs, addresses[0], { - publishFullTxs: false, - }); + const l2BlockHeader = makeL2BlockHeader(1, 2, 3); + const blockHeader = l2BlockHeader.toBlockHeader(); + const indexWithinCheckpoint = 0; + const inHash = Fr.random(); + const archive = Fr.random(); + + const proposal = await service.createBlockProposal( + blockHeader, + indexWithinCheckpoint, + inHash, + archive, + txs, + addresses[0], + { publishFullTxs: false }, + ); expect(proposal.getSender()).toEqual(addresses[0]); expect(proposal.txs).toBeUndefined(); }); - it('attests to proposal', async () => { + it('attests to checkpoint proposal', async () => { const txs = await Promise.all([Tx.random(), Tx.random()]); - const proposal = makeBlockProposal({ txs }); - const attestations = await service.attestToProposal(proposal, addresses); + const proposal = makeCheckpointProposal({ txs }); + const attestations = await service.attestToCheckpointProposal(proposal, addresses); expect(attestations.length).toBe(2); expect(attestations[0].getSender()).toEqual(addresses[0]); expect(attestations[1].getSender()).toEqual(addresses[1]); diff --git a/yarn-project/validator-client/src/duties/validation_service.ts b/yarn-project/validator-client/src/duties/validation_service.ts index abef96da0a05..938010c729c5 100644 --- a/yarn-project/validator-client/src/duties/validation_service.ts +++ b/yarn-project/validator-client/src/duties/validation_service.ts @@ -9,11 +9,14 @@ import { BlockAttestation, BlockProposal, type BlockProposalOptions, + CheckpointAttestation, + CheckpointAttestationPayload, + type CheckpointProposal, ConsensusPayload, SignatureDomainSeparator, } from '@aztec/stdlib/p2p'; -import type { CheckpointHeader } from '@aztec/stdlib/rollup'; -import type { Tx } from '@aztec/stdlib/tx'; +import { CheckpointHeader } from '@aztec/stdlib/rollup'; +import { type BlockHeader, ContentCommitment, type Tx } from '@aztec/stdlib/tx'; import type { ValidatorKeyStore } from '../key_store/interface.js'; @@ -26,15 +29,19 @@ export class ValidationService { /** * Create a block proposal with the given header, archive, and transactions * - * @param header - The block header + * @param blockHeader - The block header + * @param indexWithinCheckpoint - Index of this block within the checkpoint (0-indexed) + * @param inHash - Hash of L1 to L2 messages for this checkpoint * @param archive - The archive of the current block * @param txs - TxHash[] ordered list of transactions * @param options - Block proposal options (including broadcastInvalidBlockProposal for testing) * - * @returns A block proposal signing the above information (not the current implementation!!!) + * @returns A block proposal signing the above information */ async createBlockProposal( - header: CheckpointHeader, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, archive: Fr, txs: Tx[], proposerAttesterAddress: EthAddress | undefined, @@ -54,11 +61,14 @@ export class ValidationService { // For testing: change the new archive to trigger state_mismatch validation failure if (options.broadcastInvalidBlockProposal) { archive = Fr.random(); - this.log.warn(`Creating INVALID block proposal for slot ${header.slotNumber}`); + this.log.warn(`Creating INVALID block proposal for slot ${blockHeader.globalVariables.slotNumber}`); } return BlockProposal.createProposalFromSigner( - new ConsensusPayload(header, archive), + blockHeader, + indexWithinCheckpoint, + inHash, + archive, txHashes, options.publishFullTxs ? txs : undefined, payloadSigner, @@ -66,23 +76,64 @@ export class ValidationService { } /** - * Attest with selection of validators to the given block proposal, constructed by the current sequencer + * Attest with selection of validators to the given block proposal. * * NOTE: This is just a blind signing. * We assume that the proposal is valid and DA guarantees have been checked previously. * - * @param proposal - The proposal to attest to + * TODO(palla/mbps): This method is deprecated and will be removed once the attestation pool + * is updated to use CheckpointAttestation. Use attestToCheckpointProposal instead. + * + * @param proposal - The block proposal to attest to * @param attestors - The validators to attest with - * @returns attestations + * @returns block attestations */ async attestToProposal(proposal: BlockProposal, attestors: EthAddress[]): Promise { + // Create a ConsensusPayload from the block proposal for backward compatibility. + // We build a minimal CheckpointHeader from the BlockHeader fields. + const blockHeader = proposal.blockHeader; + const checkpointHeader = new CheckpointHeader( + blockHeader.lastArchive.root, + Fr.ZERO, // blockHeadersHash - not available from single block + ContentCommitment.empty(), // contentCommitment - not available from block header + blockHeader.getSlot(), + blockHeader.globalVariables.timestamp, + blockHeader.globalVariables.coinbase, + blockHeader.globalVariables.feeRecipient, + blockHeader.globalVariables.gasFees, + blockHeader.totalManaUsed, + ); + const payload = new ConsensusPayload(checkpointHeader, proposal.archive); + const buf = Buffer32.fromBuffer(keccak256(payload.getPayloadToSign(SignatureDomainSeparator.blockAttestation))); + const signatures = await Promise.all( + attestors.map(attestor => this.keyStore.signMessageWithAddress(attestor, buf)), + ); + return signatures.map(sig => new BlockAttestation(payload, sig, proposal.signature)); + } + + /** + * Attest with selection of validators to the given checkpoint proposal + * + * NOTE: This is just a blind signing. + * We assume that the proposal is valid and DA guarantees have been checked previously. + * + * @param proposal - The checkpoint proposal to attest to + * @param attestors - The validators to attest with + * @returns checkpoint attestations + */ + async attestToCheckpointProposal( + proposal: CheckpointProposal, + attestors: EthAddress[], + ): Promise { + // Create the attestation payload from the checkpoint proposal + const payload = new CheckpointAttestationPayload(proposal.checkpointHeader, proposal.archive); const buf = Buffer32.fromBuffer( - keccak256(proposal.payload.getPayloadToSign(SignatureDomainSeparator.blockAttestation)), + keccak256(payload.getPayloadToSign(SignatureDomainSeparator.checkpointAttestation)), ); const signatures = await Promise.all( attestors.map(attestor => this.keyStore.signMessageWithAddress(attestor, buf)), ); - return signatures.map(sig => new BlockAttestation(proposal.payload, sig, proposal.signature)); + return signatures.map(sig => new CheckpointAttestation(payload, sig, proposal.signature)); } async signAttestationsAndSigners( diff --git a/yarn-project/validator-client/src/validator.test.ts b/yarn-project/validator-client/src/validator.test.ts index 665c0b859176..8b7cdb0c4077 100644 --- a/yarn-project/validator-client/src/validator.test.ts +++ b/yarn-project/validator-client/src/validator.test.ts @@ -117,12 +117,16 @@ describe('ValidatorClient', () => { describe('createBlockProposal', () => { it('should create a valid block proposal without txs', async () => { const header = makeL2BlockHeader(); + const blockHeader = header.toBlockHeader(); + const indexWithinCheckpoint = 0; + const inHash = Fr.random(); const archive = Fr.random(); const txs = await Promise.all([1, 2, 3, 4, 5].map(() => mockTx())); const blockProposal = await validatorClient.createBlockProposal( - header.globalVariables.blockNumber, - header.toCheckpointHeader(), + blockHeader, + indexWithinCheckpoint, + inHash, archive, txs, EthAddress.fromString(validatorAccounts[0].address), @@ -154,7 +158,7 @@ describe('ValidatorClient', () => { const archive = Fr.random(); const txHashes = [0, 1, 2, 3, 4, 5].map(() => TxHash.random()); - const proposal = makeBlockProposal({ signer, archive, txHashes }); + const proposal = makeBlockProposal({ signer, archiveRoot: archive, txHashes }); // Mock the attestations to be returned const expectedAttestations = [ @@ -163,7 +167,7 @@ describe('ValidatorClient', () => { makeBlockAttestation({ signer: attestor2, archive, txHashes }), ]; p2pClient.getAttestationsForSlot.mockImplementation((slot, proposalId) => { - if (proposal.payload.header.slotNumber === slot && proposalId === proposal.archive.toString()) { + if (proposal.slotNumber === slot && proposalId === proposal.archive.toString()) { return Promise.resolve(expectedAttestations); } return Promise.resolve([]); @@ -202,14 +206,14 @@ describe('ValidatorClient', () => { const archive = Fr.random(); const txHashes = [0, 1, 2, 3, 4, 5].map(() => TxHash.random()); - const proposal = makeBlockProposal({ signer, archive, txHashes }); + const proposal = makeBlockProposal({ signer, archiveRoot: archive, txHashes }); // Create attestations - one with matching payload, one with mismatched const validAttestation = makeBlockAttestation({ signer: attestor1, archive, txHashes }); const invalidAttestation = makeBlockAttestation({ signer: attestor2, archive: Fr.random(), txHashes }); p2pClient.getAttestationsForSlot.mockImplementation((slot, proposalId) => - proposal.payload.header.slotNumber === slot && proposalId === proposal.archive.toString() + proposal.slotNumber === slot && proposalId === proposal.archive.toString() ? Promise.resolve([validAttestation, invalidAttestation]) : Promise.resolve([]), ); @@ -224,7 +228,7 @@ describe('ValidatorClient', () => { }); }); - describe('attestToProposal', () => { + describe('validateBlockProposal', () => { let proposal: BlockProposal; let blockNumber: BlockNumber; let sender: PeerId; @@ -242,7 +246,7 @@ describe('ValidatorClient', () => { const contentCommitment = new ContentCommitment(Fr.random(), emptyInHash, Fr.random()); const blockHeader = makeL2BlockHeader(1, 100, 100, { contentCommitment }); blockNumber = BlockNumber(blockHeader.getBlockNumber()); - proposal = makeBlockProposal({ header: blockHeader }); + proposal = makeBlockProposal({ blockHeader, inHash: emptyInHash }); // Set the current time to the start of the slot of the proposal const genesisTime = 1n; const slotTime = genesisTime + BigInt(proposal.slotNumber) * BigInt(blockBuilder.getConfig().slotDuration); @@ -294,11 +298,10 @@ describe('ValidatorClient', () => { }; }); - it('should attest to proposal', async () => { + it('should validate block proposal', async () => { epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations).toBeDefined(); - expect(attestations?.length).toBe(1); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); }); it('should wait for previous block to sync', async () => { @@ -306,27 +309,26 @@ describe('ValidatorClient', () => { blockSource.getBlockHeaderByArchive.mockResolvedValueOnce(undefined); blockSource.getBlockHeaderByArchive.mockResolvedValueOnce(undefined); blockSource.getBlockHeaderByArchive.mockResolvedValueOnce(undefined); - const attestations = await validatorClient.attestToProposal(proposal, sender); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); expect(blockSource.getBlockHeaderByArchive).toHaveBeenCalledTimes(4); - expect(attestations).toBeDefined(); - expect(attestations?.length).toBe(1); + expect(isValid).toBe(true); }); - it('should re-execute and attest to proposal', async () => { + it('should re-execute and validate proposal', async () => { enableReexecution(); - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations?.length).toBeGreaterThan(0); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); }); - it('should not attest to proposal if roots do not match and should emit WANT_TO_SLASH_EVENT', async () => { + it('should not validate proposal if roots do not match and should emit WANT_TO_SLASH_EVENT', async () => { // Block builder returns a block with a different root const emitSpy = jest.spyOn(validatorClient, 'emit'); enableReexecution(); blockBuildResult.block.archive.root = Fr.random(); - // We should not attest to the proposal - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations).toBeUndefined(); + // Proposal should be invalid + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); // We should emit WANT_TO_SLASH_EVENT const proposer = proposal.getSender(); @@ -341,21 +343,21 @@ describe('ValidatorClient', () => { ]); }); - it('should not attest to proposal if a random field in the proposal does not match', async () => { + it('should not validate proposal if a random field in the proposal does not match', async () => { // Block builder returns a block with a different archive root enableReexecution(); blockBuildResult.block.archive.root = Fr.random(); - // We should not attest to the proposal - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations).toBeUndefined(); + // Proposal should be invalid + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); - it('should not attest to proposal if the proposed block number is taken', async () => { + it('should not validate proposal if the proposed block number is taken', async () => { enableReexecution(); blockSource.getBlockHeader.mockResolvedValue({} as BlockHeader); - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); expect(blockSource.getBlockHeader).toHaveBeenCalledWith(blockNumber); }); @@ -366,14 +368,14 @@ describe('ValidatorClient', () => { enableReexecution(); blockBuildResult.block.archive.root = Fr.random(); - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); expect(emitSpy).not.toHaveBeenCalled(); }); - it('should request txs for attesting pinning the sender', async () => { - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeDefined(); + it('should request txs for validating pinning the sender', async () => { + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); expect(txProvider.getTxsForBlockProposal).toHaveBeenCalledWith( proposal, @@ -382,11 +384,11 @@ describe('ValidatorClient', () => { ); }); - it('should request txs even if not attestor in this slot', async () => { + it('should request txs even if not in committee in this slot', async () => { epochCache.filterInCommittee.mockResolvedValue([]); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); expect(txProvider.getTxsForBlockProposal).toHaveBeenCalledWith( proposal, @@ -395,7 +397,7 @@ describe('ValidatorClient', () => { ); }); - it('should throw an error if the transactions are not available', async () => { + it('should return false if the transactions are not available', async () => { txProvider.getTxsForBlockProposal.mockImplementation(proposal => Promise.resolve({ txs: [], @@ -403,28 +405,28 @@ describe('ValidatorClient', () => { }), ); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); - it('should not return an attestation if re-execution fails', async () => { + it('should return false if re-execution fails', async () => { enableReexecution(); blockBuilder.buildBlock.mockImplementation(() => { throw new Error('Failed to build block'); }); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); - it('should not return an attestation if no validators are in the committee', async () => { + it('should still validate if no validators are in the committee', async () => { epochCache.filterInCommittee.mockResolvedValueOnce([]); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); }); - it('should not return an attestation if the proposer is not the current proposer', async () => { + it('should return false if the proposer is not the current proposer', async () => { epochCache.getProposerAttesterAddressInCurrentOrNextSlot.mockImplementation(() => Promise.resolve({ currentProposer: EthAddress.random(), @@ -434,20 +436,20 @@ describe('ValidatorClient', () => { }), ); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); - it('should attest with all validator keys that are in the committee', async () => { + it('should validate with any validators in the committee', async () => { epochCache.filterInCommittee.mockResolvedValueOnce( validatorAccounts.map(account => EthAddress.fromString(account.address)), ); - const attestations = await validatorClient.attestToProposal(proposal, sender); - expect(attestations?.length).toBe(validatorAccounts.length); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(true); }); - it('should not return an attestation if the proposal is not for the current or next slot', async () => { + it('should return false if the proposal is not for the current or next slot', async () => { epochCache.getProposerAttesterAddressInCurrentOrNextSlot.mockResolvedValue({ currentProposer: proposal.getSender(), nextProposer: proposal.getSender(), @@ -455,16 +457,16 @@ describe('ValidatorClient', () => { nextSlot: SlotNumber(proposal.slotNumber + 21), }); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); - it('should not return an attestation if messages do not match', async () => { + it('should return false if messages do not match', async () => { enableReexecution(); l1ToL2MessageSource.getL1ToL2Messages.mockResolvedValue([Fr.random()]); - const attestation = await validatorClient.attestToProposal(proposal, sender); - expect(attestation).toBeUndefined(); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); + expect(isValid).toBe(false); }); describe('filestore blob upload', () => { @@ -500,9 +502,9 @@ describe('ValidatorClient', () => { const mockBlobFields = [Fr.random(), Fr.random()]; (blockBuildResult.block as any).getCheckpointBlobFields = jest.fn().mockReturnValue(mockBlobFields); - const attestations = await validatorWithFileStore.attestToProposal(proposal, sender); + const isValid = await validatorWithFileStore.validateBlockProposal(proposal, sender); - expect(attestations).toBeDefined(); + expect(isValid).toBe(true); // Wait for fire-and-forget upload (1ms is enough since mock resolves immediately) await sleep(1); @@ -515,14 +517,14 @@ describe('ValidatorClient', () => { enableReexecution(); epochCache.filterInCommittee.mockResolvedValue([EthAddress.fromString(validatorAccounts[0].address)]); - const attestations = await validatorClient.attestToProposal(proposal, sender); + const isValid = await validatorClient.validateBlockProposal(proposal, sender); - expect(attestations).toBeDefined(); + expect(isValid).toBe(true); // No upload should happen since there's no filestore client expect(mockFileStoreBlobClient.saveBlobs).not.toHaveBeenCalled(); }); - it('should not fail attestation when blob upload fails', async () => { + it('should not fail validation when blob upload fails', async () => { mockFileStoreBlobClient.saveBlobs.mockRejectedValue(new Error('Upload failed')); const validatorWithFileStore = createValidatorWithFileStore(); validatorWithFileStore.updateConfig({ validatorReexecute: true }); @@ -533,11 +535,10 @@ describe('ValidatorClient', () => { const mockBlobFields = [Fr.random(), Fr.random()]; (blockBuildResult.block as any).getCheckpointBlobFields = jest.fn().mockReturnValue(mockBlobFields); - // Should still return attestations even if upload fails - const attestations = await validatorWithFileStore.attestToProposal(proposal, sender); + // Should still return valid even if upload fails + const isValid = await validatorWithFileStore.validateBlockProposal(proposal, sender); - expect(attestations).toBeDefined(); - expect(attestations?.length).toBeGreaterThan(0); + expect(isValid).toBe(true); }); it('should trigger re-execution when filestore is configured even if validatorReexecute is false', async () => { @@ -551,9 +552,9 @@ describe('ValidatorClient', () => { const mockBlobFields = [Fr.random(), Fr.random()]; (blockBuildResult.block as any).getCheckpointBlobFields = jest.fn().mockReturnValue(mockBlobFields); - const attestations = await validatorWithFileStore.attestToProposal(proposal, sender); + const isValid = await validatorWithFileStore.validateBlockProposal(proposal, sender); - expect(attestations).toBeDefined(); + expect(isValid).toBe(true); // Wait for fire-and-forget upload (1ms is enough since mock resolves immediately) await sleep(1); @@ -571,9 +572,9 @@ describe('ValidatorClient', () => { // Make validation fail by returning mismatched archive blockBuildResult.block.archive.root = Fr.random(); - const attestations = await validatorWithFileStore.attestToProposal(proposal, sender); + const isValid = await validatorWithFileStore.validateBlockProposal(proposal, sender); - expect(attestations).toBeUndefined(); + expect(isValid).toBe(false); // No upload because validation failed expect(mockFileStoreBlobClient.saveBlobs).not.toHaveBeenCalled(); }); @@ -592,12 +593,14 @@ describe('ValidatorClient', () => { // Spy on addAttestations to verify attestations are NOT added to the pool const addAttestationsSpy = jest.spyOn(p2pClient, 'addAttestations'); - const attestations = await validatorClient.attestToProposal(proposal, sender); + // In the new model, validateBlockProposal returns a boolean + // Fisherman mode re-executes to validate but doesn't attest (that's for checkpoint proposals) + const isValid = await validatorClient.validateBlockProposal(proposal, sender); - // In fisherman mode, no attestations should be created or returned - expect(attestations).toBeUndefined(); + // Validation should still succeed even though we're not in the committee + expect(isValid).toBe(true); - // Attestations should NOT be added to the p2p pool + // Attestations should NOT be added to the p2p pool (block proposals don't create attestations) expect(addAttestationsSpy).not.toHaveBeenCalled(); }); }); diff --git a/yarn-project/validator-client/src/validator.ts b/yarn-project/validator-client/src/validator.ts index 66f6cf019c39..da03e81b1042 100644 --- a/yarn-project/validator-client/src/validator.ts +++ b/yarn-project/validator-client/src/validator.ts @@ -17,9 +17,14 @@ import type { AztecAddress } from '@aztec/stdlib/aztec-address'; import type { CommitteeAttestationsAndSigners, L2BlockSource } from '@aztec/stdlib/block'; import type { IFullNodeBlockBuilder, Validator, ValidatorClientFullConfig } from '@aztec/stdlib/interfaces/server'; import type { L1ToL2MessageSource } from '@aztec/stdlib/messaging'; -import type { BlockAttestation, BlockProposal, BlockProposalOptions } from '@aztec/stdlib/p2p'; -import type { CheckpointHeader } from '@aztec/stdlib/rollup'; -import type { Tx } from '@aztec/stdlib/tx'; +import type { + BlockAttestation, + BlockProposal, + BlockProposalOptions, + CheckpointAttestation, + CheckpointProposal, +} from '@aztec/stdlib/p2p'; +import type { BlockHeader, Tx } from '@aztec/stdlib/tx'; import { AttestationTimeoutError } from '@aztec/stdlib/validators'; import { type TelemetryClient, type Tracer, getTelemetryClient } from '@aztec/telemetry-client'; @@ -253,9 +258,17 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.hasRegisteredHandlers = true; this.log.debug(`Registering validator handlers for p2p client`); - const handler = (block: BlockProposal, proposalSender: PeerId): Promise => - this.attestToProposal(block, proposalSender); - this.p2pClient.registerBlockProposalHandler(handler); + // Block proposal handler - validates but does NOT attest (validators only attest to checkpoints) + const blockHandler = (block: BlockProposal, proposalSender: PeerId): Promise => + this.validateBlockProposal(block, proposalSender); + this.p2pClient.registerBlockProposalHandler(blockHandler); + + // Checkpoint proposal handler - validates and creates attestations + const checkpointHandler = ( + checkpoint: CheckpointProposal, + proposalSender: PeerId, + ): Promise => this.attestToCheckpointProposal(checkpoint, proposalSender); + this.p2pClient.registerCheckpointProposalHandler(checkpointHandler); const myAddresses = this.getValidatorAddresses(); this.p2pClient.registerThisValidatorAddresses(myAddresses); @@ -264,29 +277,33 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) } } - async attestToProposal(proposal: BlockProposal, proposalSender: PeerId): Promise { + /** + * Validate a block proposal from a peer. + * Note: Validators do NOT attest to individual blocks - attestations are only for checkpoint proposals. + * @returns true if the proposal is valid, false otherwise + */ + async validateBlockProposal(proposal: BlockProposal, proposalSender: PeerId): Promise { const slotNumber = proposal.slotNumber; const proposer = proposal.getSender(); // Reject proposals with invalid signatures if (!proposer) { - this.log.warn(`Received proposal with invalid signature for slot ${slotNumber}`); - return undefined; + this.log.warn(`Received block proposal with invalid signature for slot ${slotNumber}`); + return false; } - // Check that I have any address in current committee before attesting + // Check if we're in the committee (for metrics purposes) const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses()); const partOfCommittee = inCommittee.length > 0; const proposalInfo = { ...proposal.toBlockInfo(), proposer: proposer.toString() }; - this.log.info(`Received proposal for slot ${slotNumber}`, { + this.log.info(`Received block proposal for slot ${slotNumber}`, { ...proposalInfo, txHashes: proposal.txHashes.map(t => t.toString()), fishermanMode: this.config.fishermanMode || false, }); - // Reexecute txs if we are part of the committee so we can attest, or if slashing is enabled so we can slash - // invalid proposals even when not in the committee, or if we are configured to always reexecute for monitoring purposes. + // Reexecute txs if we are part of the committee, or if slashing is enabled, or if we are configured to always reexecute. // In fisherman mode, we always reexecute to validate proposals. const { validatorReexecute, slashBroadcastedInvalidBlockPenalty, alwaysReexecuteBlockProposals, fishermanMode } = this.config; @@ -304,7 +321,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) ); if (!validationResult.isValid) { - this.log.warn(`Proposal validation failed: ${validationResult.reason}`, proposalInfo); + this.log.warn(`Block proposal validation failed: ${validationResult.reason}`, proposalInfo); const reason = validationResult.reason || 'unknown'; // Classify failure reason: bad proposal vs node issue @@ -319,7 +336,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) if (badProposalReasons.includes(reason as BlockProposalValidationFailureReason)) { this.metrics.incFailedAttestationsBadProposal(1, reason, partOfCommittee); } else { - // Node issues so we can't attest + // Node issues so we can't validate this.metrics.incFailedAttestationsNodeIssue(1, reason, partOfCommittee); } @@ -332,25 +349,15 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) this.log.warn(`Slashing proposer for invalid block proposal`, proposalInfo); this.slashInvalidBlock(proposal); } - return undefined; + return false; } - // Check that I have any address in current committee before attesting - // In fisherman mode, we still create attestations for validation even if not in committee - if (!partOfCommittee && !this.config.fishermanMode) { - this.log.verbose(`No validator in the current committee, skipping attestation`, proposalInfo); - return undefined; - } - - // Provided all of the above checks pass, we can attest to the proposal - this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} proposal for slot ${slotNumber}`, { + this.log.info(`Validated block proposal for slot ${slotNumber}`, { ...proposalInfo, inCommittee: partOfCommittee, fishermanMode: this.config.fishermanMode || false, }); - this.metrics.incSuccessfulAttestations(inCommittee.length); - // Upload blobs to filestore after successful re-execution (fire-and-forget) if (validationResult.reexecutionResult?.block && this.fileStoreBlobUploadClient) { void Promise.resolve().then(async () => { @@ -365,7 +372,61 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) }); } - // If the above function does not throw an error, then we can attest to the proposal + return true; + } + + /** + * Validate and attest to a checkpoint proposal from a peer. + * @returns Checkpoint attestations if valid, undefined otherwise + */ + async attestToCheckpointProposal( + proposal: CheckpointProposal, + _proposalSender: PeerId, + ): Promise { + const slotNumber = proposal.slotNumber; + const proposer = proposal.getSender(); + + // Reject proposals with invalid signatures + if (!proposer) { + this.log.warn(`Received checkpoint proposal with invalid signature for slot ${slotNumber}`); + return undefined; + } + + // Check that I have any address in current committee before attesting + const inCommittee = await this.epochCache.filterInCommittee(slotNumber, this.getValidatorAddresses()); + const partOfCommittee = inCommittee.length > 0; + + const proposalInfo = { + slotNumber, + archive: proposal.archive.toString(), + proposer: proposer.toString(), + txCount: proposal.txHashes.length, + }; + this.log.info(`Received checkpoint proposal for slot ${slotNumber}`, { + ...proposalInfo, + txHashes: proposal.txHashes.map(t => t.toString()), + fishermanMode: this.config.fishermanMode || false, + }); + + // TODO(palla/mbps): Add validation for checkpoint proposals (similar to block proposals) + // For now, we just check that we're in the committee and create attestations + + // Check that I have any address in current committee before attesting + // In fisherman mode, we still create attestations for validation even if not in committee + if (!partOfCommittee && !this.config.fishermanMode) { + this.log.verbose(`No validator in the current committee, skipping attestation`, proposalInfo); + return undefined; + } + + // Provided all of the above checks pass, we can attest to the proposal + this.log.info(`${partOfCommittee ? 'Attesting to' : 'Validated'} checkpoint proposal for slot ${slotNumber}`, { + ...proposalInfo, + inCommittee: partOfCommittee, + fishermanMode: this.config.fishermanMode || false, + }); + + this.metrics.incSuccessfulAttestations(inCommittee.length); + // Determine which validators should attest let attestors: EthAddress[]; if (partOfCommittee) { @@ -384,13 +445,24 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) if (this.config.fishermanMode) { // bail out early and don't save attestations to the pool in fisherman mode - this.log.info(`Creating attestations for proposal for slot ${slotNumber}`, { + this.log.info(`Creating checkpoint attestations for slot ${slotNumber}`, { ...proposalInfo, attestors: attestors.map(a => a.toString()), }); return undefined; } - return this.createBlockAttestationsFromProposal(proposal, attestors); + + return this.createCheckpointAttestationsFromProposal(proposal, attestors); + } + + private async createCheckpointAttestationsFromProposal( + proposal: CheckpointProposal, + attestors: EthAddress[] = [], + ): Promise { + const attestations = await this.validationService.attestToCheckpointProposal(proposal, attestors); + // TODO(palla/mbps): Add checkpoint attestations to pool once it supports them + // await this.p2pClient.addCheckpointAttestations(attestations); + return attestations; } private slashInvalidBlock(proposal: BlockProposal) { @@ -420,40 +492,52 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) ]); } - // TODO(palla/mbps): Block proposal should not require a checkpoint proposal async createBlockProposal( - blockNumber: BlockNumber, - header: CheckpointHeader, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, archive: Fr, txs: Tx[], proposerAddress: EthAddress | undefined, options: BlockProposalOptions, ): Promise { // TODO(palla/mbps): Prevent double proposals properly - // if (this.previousProposal?.slotNumber === header.slotNumber) { + // if (this.previousProposal?.slotNumber === blockHeader.globalVariables.slotNumber) { // this.log.verbose(`Already made a proposal for the same slot, skipping proposal`); // return Promise.resolve(undefined); // } - this.log.info(`Assembling block proposal for block ${blockNumber} slot ${header.slotNumber}`); - const newProposal = await this.validationService.createBlockProposal(header, archive, txs, proposerAddress, { - ...options, - broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal, - }); + this.log.info( + `Assembling block proposal for block ${blockHeader.globalVariables.blockNumber} slot ${blockHeader.globalVariables.slotNumber}`, + ); + const newProposal = await this.validationService.createBlockProposal( + blockHeader, + indexWithinCheckpoint, + inHash, + archive, + txs, + proposerAddress, + { + ...options, + broadcastInvalidBlockProposal: this.config.broadcastInvalidBlockProposal, + }, + ); this.previousProposal = newProposal; return newProposal; } // TODO(palla/mbps): Effectively create a checkpoint proposal different from a block proposal createCheckpointProposal( - header: CheckpointHeader, + blockHeader: BlockHeader, + indexWithinCheckpoint: number, + inHash: Fr, archive: Fr, txs: Tx[], proposerAddress: EthAddress | undefined, options: BlockProposalOptions, ): Promise { - this.log.info(`Assembling checkpoint proposal for slot ${header.slotNumber}`); - return this.createBlockProposal(0 as BlockNumber, header, archive, txs, proposerAddress, options); + this.log.info(`Assembling checkpoint proposal for slot ${blockHeader.globalVariables.slotNumber}`); + return this.createBlockProposal(blockHeader, indexWithinCheckpoint, inHash, archive, txs, proposerAddress, options); } async broadcastBlockProposal(proposal: BlockProposal): Promise { @@ -468,7 +552,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) } async collectOwnAttestations(proposal: BlockProposal): Promise { - const slot = proposal.payload.header.slotNumber; + const slot = proposal.slotNumber; const inCommittee = await this.epochCache.filterInCommittee(slot, this.getValidatorAddresses()); this.log.debug(`Collecting ${inCommittee.length} self-attestations for slot ${slot}`, { inCommittee }); const attestations = await this.createBlockAttestationsFromProposal(proposal, inCommittee); @@ -484,7 +568,7 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) async collectAttestations(proposal: BlockProposal, required: number, deadline: Date): Promise { // Wait and poll the p2pClient's attestation pool for this block until we have enough attestations - const slot = proposal.payload.header.slotNumber; + const slot = proposal.slotNumber; this.log.debug(`Collecting ${required} attestations for slot ${slot} with deadline ${deadline.toISOString()}`); if (+deadline < this.dateProvider.now()) { @@ -501,14 +585,14 @@ export class ValidatorClient extends (EventEmitter as new () => WatcherEmitter) let attestations: BlockAttestation[] = []; while (true) { - // Filter out attestations with a mismatching payload. This should NOT happen since we have verified + // Filter out attestations with a mismatching archive. This should NOT happen since we have verified // the proposer signature (ie our own) before accepting the attestation into the pool via the p2p client. const collectedAttestations = (await this.p2pClient.getAttestationsForSlot(slot, proposalId)).filter( attestation => { - if (!attestation.payload.equals(proposal.payload)) { + if (!attestation.archive.equals(proposal.archive)) { this.log.warn( - `Received attestation for slot ${slot} with mismatched payload from ${attestation.getSender()?.toString()}`, - { attestationPayload: attestation.payload, proposalPayload: proposal.payload }, + `Received attestation for slot ${slot} with mismatched archive from ${attestation.getSender()?.toString()}`, + { attestationArchive: attestation.archive.toString(), proposalArchive: proposal.archive.toString() }, ); return false; }