diff --git a/packages/client/lib/blockchain/chain.ts b/packages/client/lib/blockchain/chain.ts index da67941c872..a5fbe9388b9 100644 --- a/packages/client/lib/blockchain/chain.ts +++ b/packages/client/lib/blockchain/chain.ts @@ -36,6 +36,18 @@ export interface ChainBlocks { */ latest: Block | null + /** + * The block as signalled `finalized` in the fcU + * This corresponds to the last finalized beacon block + */ + finalized: Block | null + + /** + * The block as signalled `safe` in the fcU + * This corresponds to the last justified beacon block + */ + safe: Block | null + /** * The total difficulty of the blockchain */ @@ -56,6 +68,18 @@ export interface ChainHeaders { */ latest: BlockHeader | null + /** + * The block as signalled `finalized` in the fcU + * This corresponds to the last finalized beacon block + */ + finalized: BlockHeader | null + + /** + * The block as signalled `safe` in the fcU + * This corresponds to the last justified beacon block + */ + safe: BlockHeader | null + /** * The total difficulty of the headerchain */ @@ -79,12 +103,16 @@ export class Chain { private _headers: ChainHeaders = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } private _blocks: ChainBlocks = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } @@ -136,11 +164,15 @@ export class Chain { private reset() { this._headers = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } this._blocks = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } @@ -221,17 +253,28 @@ export class Chain { const headers: ChainHeaders = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } const blocks: ChainBlocks = { latest: null, + finalized: null, + safe: null, td: BigInt(0), height: BigInt(0), } headers.latest = await this.getCanonicalHeadHeader() + // finalized and safe are always blocks since they have to have valid execution + // before they can be saved in chain + headers.finalized = (await this.getCanonicalFinalizedBlock()).header + headers.safe = (await this.getCanonicalSafeBlock()).header + blocks.latest = await this.getCanonicalHeadBlock() + blocks.finalized = await this.getCanonicalFinalizedBlock() + blocks.safe = await this.getCanonicalSafeBlock() headers.height = headers.latest.number blocks.height = blocks.latest.header.number @@ -316,16 +359,31 @@ export class Chain { * @param fromEngine pass true to process post-merge blocks, otherwise they will be skipped * @returns number of blocks added */ - async putBlocks(blocks: Block[], fromEngine = false): Promise { + async putBlocks(blocks: Block[], fromEngine = false, skipUpdateEmit = false): Promise { if (!this.opened) throw new Error('Chain closed') if (blocks.length === 0) return 0 let numAdded = 0 - for (const [i, b] of blocks.entries()) { + // filter out finalized blocks + const newBlocks = [] + for (const block of blocks) { + if (this.headers.finalized !== null && block.header.number <= this.headers.finalized.number) { + const canonicalBlock = await this.getBlock(block.header.number) + if (!canonicalBlock.hash().equals(block.hash())) { + throw Error( + `Invalid putBlock for block=${block.header.number} before finalized=${this.headers.finalized.number}` + ) + } + } else { + newBlocks.push(block) + } + } + + for (const [i, b] of newBlocks.entries()) { if (!fromEngine && this.config.chainCommon.gteHardfork(Hardfork.Merge)) { if (i > 0) { // emitOnLast below won't be reached, so run an update here - await this.update(true) + await this.update(!skipUpdateEmit) } break } @@ -343,8 +401,8 @@ export class Chain { await this.blockchain.putBlock(block) numAdded++ - const emitOnLast = blocks.length === numAdded - await this.update(emitOnLast) + const emitOnLast = newBlocks.length === numAdded + await this.update(emitOnLast && !skipUpdateEmit) } return numAdded } @@ -414,6 +472,22 @@ export class Chain { return this.blockchain.getCanonicalHeadBlock() } + /** + * Gets the latest block in the canonical chain + */ + async getCanonicalSafeBlock(): Promise { + if (!this.opened) throw new Error('Chain closed') + return this.blockchain.getIteratorHead('safe') + } + + /** + * Gets the latest block in the canonical chain + */ + async getCanonicalFinalizedBlock(): Promise { + if (!this.opened) throw new Error('Chain closed') + return this.blockchain.getIteratorHead('finalized') + } + /** * Gets total difficulty for a block * @param hash the block hash diff --git a/packages/client/lib/execution/vmexecution.ts b/packages/client/lib/execution/vmexecution.ts index a7afc62726d..b355c5be642 100644 --- a/packages/client/lib/execution/vmexecution.ts +++ b/packages/client/lib/execution/vmexecution.ts @@ -161,19 +161,46 @@ export class VMExecution extends Execution { * Should only be used after {@link VMExecution.runWithoutSetHead} * @param blocks Array of blocks to save pending receipts and set the last block as the head */ - async setHead(blocks: Block[]): Promise { + async setHead( + blocks: Block[], + { finalizedBlock, safeBlock }: { finalizedBlock?: Block; safeBlock?: Block } = {} + ): Promise { return this.runWithLock(async () => { const vmHeadBlock = blocks[blocks.length - 1] - if (!(await this.vm.stateManager.hasStateRoot(vmHeadBlock.header.stateRoot))) { - // If we set blockchain iterator to somewhere where we don't have stateroot - // execution run will always fail + const chainPointers: [string, Block | null][] = [ + ['vmHeadBlock', vmHeadBlock], + // if safeBlock is not provided, the current safeBlock of chain should be used + // which is genesisBlock if it has never been set for e.g. + ['safeBlock', safeBlock ?? this.chain.blocks.safe], + ['finalizedBlock', finalizedBlock ?? this.chain.blocks.finalized], + ] + + let isSortedDesc = true + let lastBlock = vmHeadBlock + for (const [blockName, block] of chainPointers) { + if (block === null) { + continue + } + if (!(await this.vm.stateManager.hasStateRoot(block.header.stateRoot))) { + // If we set blockchain iterator to somewhere where we don't have stateroot + // execution run will always fail + throw Error( + `${blockName}'s stateRoot not found number=${block.header.number} root=${short( + block.header.stateRoot + )}` + ) + } + isSortedDesc = isSortedDesc && lastBlock.header.number >= block.header.number + lastBlock = block + } + + if (isSortedDesc === false) { throw Error( - `vmHeadBlock's stateRoot not found number=${vmHeadBlock.header.number} root=${short( - vmHeadBlock.header.stateRoot - )}` + `headBlock=${vmHeadBlock?.header.number} should be >= safeBlock=${safeBlock?.header.number} should be >= finalizedBlock=${finalizedBlock?.header.number}` ) } - await this.chain.putBlocks(blocks, true) + // skip emitting the chain update event as we will manually do it + await this.chain.putBlocks(blocks, true, true) for (const block of blocks) { const receipts = this.pendingReceipts?.get(block.hash().toString('hex')) if (receipts) { @@ -181,7 +208,25 @@ export class VMExecution extends Execution { this.pendingReceipts?.delete(block.hash().toString('hex')) } } + + // check if the head, safe and finalized are now canonical + for (const [blockName, block] of chainPointers) { + if (block === null) { + continue + } + const blockByNumber = await this.chain.getBlock(block.header.number) + if (!blockByNumber.hash().equals(block.hash())) { + throw Error(`${blockName} not in canonical chain`) + } + } await this.chain.blockchain.setIteratorHead('vm', vmHeadBlock.hash()) + if (safeBlock !== undefined) { + await this.chain.blockchain.setIteratorHead('safe', safeBlock.hash()) + } + if (finalizedBlock !== undefined) { + await this.chain.blockchain.setIteratorHead('finalized', finalizedBlock.hash()) + } + await this.chain.update(true) }) } diff --git a/packages/client/lib/rpc/modules/engine.ts b/packages/client/lib/rpc/modules/engine.ts index d12b11a6a2b..41f12fe9090 100644 --- a/packages/client/lib/rpc/modules/engine.ts +++ b/packages/client/lib/rpc/modules/engine.ts @@ -17,6 +17,8 @@ import type { FullEthereumService } from '../../service' import type { HeaderData } from '@ethereumjs/block' import type { VM } from '@ethereumjs/vm' +const zeroBlockHash = zeros(32) + export enum Status { ACCEPTED = 'ACCEPTED', INVALID = 'INVALID', @@ -721,6 +723,16 @@ export class Engine { const { headBlockHash, finalizedBlockHash, safeBlockHash } = params[0] const payloadAttributes = params[1] + const safe = toBuffer(safeBlockHash) + const finalized = toBuffer(finalizedBlockHash) + + if (!finalized.equals(zeroBlockHash) && safe.equals(zeroBlockHash)) { + throw { + code: INVALID_PARAMS, + message: 'safe block can not be zero if finalized is not zero', + } + } + if (this.config.synchronized) { this.connectionManager.newForkchoiceLog() } @@ -804,6 +816,46 @@ export class Engine { return response } + /* + * Process safe and finalized block since headBlock has been found to be executed + * Allowed to have zero value while transition block is finalizing + */ + let safeBlock, finalizedBlock + + if (!safe.equals(zeroBlockHash)) { + if (safe.equals(headBlock.hash())) { + safeBlock = headBlock + } else { + try { + // Right now only check if the block is available, canonicality check is done + // in setHead after chain.putBlocks so as to reflect latest canonical chain + safeBlock = await this.chain.getBlock(safe) + } catch (_error: any) { + throw { + code: INVALID_PARAMS, + message: 'safe block not available', + } + } + } + } else { + safeBlock = undefined + } + + if (!finalized.equals(zeroBlockHash)) { + try { + // Right now only check if the block is available, canonicality check is done + // in setHead after chain.putBlocks so as to reflect latest canonical chain + finalizedBlock = await this.chain.getBlock(finalized) + } catch (error: any) { + throw { + message: 'finalized block not available', + code: INVALID_PARAMS, + } + } + } else { + finalizedBlock = undefined + } + const vmHeadHash = this.chain.headers.latest!.hash() if (!vmHeadHash.equals(headBlock.hash())) { let parentBlocks: Block[] = [] @@ -826,7 +878,14 @@ export class Engine { } const blocks = [...parentBlocks, headBlock] - await this.execution.setHead(blocks) + try { + await this.execution.setHead(blocks, { safeBlock, finalizedBlock }) + } catch (error) { + throw { + message: (error as Error).message, + code: INVALID_PARAMS, + } + } this.service.txPool.removeNewBlockTxs(blocks) const isPrevSynced = this.chain.config.synchronized @@ -835,45 +894,6 @@ export class Engine { this.service.txPool.checkRunState() } } - /* - * Process safe and finalized block - * Allowed to have zero value while transition block is finalizing - */ - const zeroBlockHash = zeros(32) - const safe = toBuffer(safeBlockHash) - if (!safe.equals(headBlock.hash()) && !safe.equals(zeroBlockHash)) { - const msg = 'Safe block not in canonical chain' - try { - const safeBlock = await this.chain.getBlock(safe) - const canonical = await this.chain.getBlock(safeBlock.header.number) - if (!canonical.hash().equals(safe)) { - throw new Error(msg) - } - } catch (error: any) { - const message = error.message === msg ? msg : 'safe block not available' - throw { - code: INVALID_PARAMS, - message, - } - } - } - const finalized = toBuffer(finalizedBlockHash) - if (!finalized.equals(zeroBlockHash)) { - const msg = 'Finalized block not in canonical chain' - try { - const finalizedBlock = await this.chain.getBlock(finalized) - const canonical = await this.chain.getBlock(finalizedBlock.header.number) - if (!canonical.hash().equals(finalized)) { - throw new Error(msg) - } - } catch (error: any) { - const message = error.message === msg ? msg : 'finalized block not available' - throw { - message, - code: INVALID_PARAMS, - } - } - } /* * If payloadAttributes is present, start building block and return payloadId diff --git a/packages/client/lib/rpc/modules/eth.ts b/packages/client/lib/rpc/modules/eth.ts index 04c2140ab8a..0ad46d3f0a4 100644 --- a/packages/client/lib/rpc/modules/eth.ts +++ b/packages/client/lib/rpc/modules/eth.ts @@ -190,24 +190,33 @@ const getBlockByOption = async (blockOpt: string, chain: Chain) => { let block: Block const latest = chain.blocks.latest ?? (await chain.getCanonicalHeadBlock()) - if (blockOpt === 'latest') { - block = latest - } else if (blockOpt === 'earliest') { - block = await chain.getBlock(BigInt(0)) - } else { - const blockNumber = BigInt(blockOpt) - if (blockNumber === latest.header.number) { + switch (blockOpt) { + case 'earliest': + block = await chain.getBlock(BigInt(0)) + break + case 'latest': block = latest - } else if (blockNumber > latest.header.number) { - throw { - code: INVALID_PARAMS, - message: 'specified block greater than current height', + break + case 'safe': + block = chain.blocks.safe ?? (await chain.getCanonicalSafeBlock()) + break + case 'finalized': + block = chain.blocks.finalized ?? (await chain.getCanonicalFinalizedBlock()) + break + default: { + const blockNumber = BigInt(blockOpt) + if (blockNumber === latest.header.number) { + block = latest + } else if (blockNumber > latest.header.number) { + throw { + code: INVALID_PARAMS, + message: 'specified block greater than current height', + } + } else { + block = await chain.getBlock(blockNumber) } - } else { - block = await chain.getBlock(blockNumber) } } - return block } diff --git a/packages/client/lib/rpc/validation.ts b/packages/client/lib/rpc/validation.ts index 7ad6224a6ca..800935f8f00 100644 --- a/packages/client/lib/rpc/validation.ts +++ b/packages/client/lib/rpc/validation.ts @@ -236,7 +236,7 @@ export const validators = { const blockOption = params[index] - if (!['latest', 'earliest', 'pending'].includes(blockOption)) { + if (!['latest', 'finalized', 'safe', 'earliest', 'pending'].includes(blockOption)) { if (blockOption.substr(0, 2) === '0x') { const hash = this.blockHash([blockOption], 0) // todo: make integer validator? diff --git a/packages/client/test/integration/miner.spec.ts b/packages/client/test/integration/miner.spec.ts index 1bef7bdef8b..372fedf55c6 100644 --- a/packages/client/test/integration/miner.spec.ts +++ b/packages/client/test/integration/miner.spec.ts @@ -20,9 +20,14 @@ import { destroy, setup } from './util' import type { CliqueConsensus } from '@ethereumjs/blockchain' tape('[Integration:Miner]', async (t) => { + // Schedule london at 0 and also unset any past scheduled timestamp hardforks that might collide with test const hardforks = new Common({ chain: ChainCommon.Goerli }) .hardforks() - .map((h) => (h.name === Hardfork.London ? { ...h, block: 0 } : h)) + .map((h) => + h.name === Hardfork.London + ? { ...h, block: 0, timestamp: undefined } + : { ...h, timestamp: undefined } + ) const common = Common.custom( { hardforks, diff --git a/packages/client/test/rpc/engine/forkchoiceUpdatedV1.spec.ts b/packages/client/test/rpc/engine/forkchoiceUpdatedV1.spec.ts index c7c5c1f121b..69471e4cc06 100644 --- a/packages/client/test/rpc/engine/forkchoiceUpdatedV1.spec.ts +++ b/packages/client/test/rpc/engine/forkchoiceUpdatedV1.spec.ts @@ -301,6 +301,19 @@ tape(`${method}: latest block after reorg`, async (t) => { } await baseRequest(t, server, req, 200, expectRes, false) + // check safe and finalized + req = params('eth_getBlockByNumber', ['finalized', false]) + expectRes = (res: any) => { + t.equal(res.body.result.number, '0x0', 'finalized should be set to genesis') + } + await baseRequest(t, server, req, 200, expectRes, false) + + req = params('eth_getBlockByNumber', ['safe', false]) + expectRes = (res: any) => { + t.equal(res.body.result.number, '0x1', 'safe should be set to first block') + } + await baseRequest(t, server, req, 200, expectRes, false) + req = params(method, [ { headBlockHash: blocks[1].blockHash, @@ -352,7 +365,7 @@ tape(`${method}: validate safeBlockHash is part of canonical chain`, async (t) = const expectRes = (res: any) => { t.equal(res.body.error.code, -32602) - t.ok(res.body.error.message.includes('Safe')) + t.ok(res.body.error.message.includes('safeBlock')) t.ok(res.body.error.message.includes('canonical')) } await baseRequest(t, server, req, 200, expectRes) @@ -395,7 +408,7 @@ tape(`${method}: validate finalizedBlockHash is part of canonical chain`, async const expectRes = (res: any) => { t.equal(res.body.error.code, -32602) - t.ok(res.body.error.message.includes('Finalized')) + t.ok(res.body.error.message.includes('finalizedBlock')) t.ok(res.body.error.message.includes('canonical')) } await baseRequest(t, server, req, 200, expectRes) diff --git a/packages/client/test/rpc/helpers.ts b/packages/client/test/rpc/helpers.ts index 0dd850c632f..ec48e15abbc 100644 --- a/packages/client/test/rpc/helpers.ts +++ b/packages/client/test/rpc/helpers.ts @@ -167,7 +167,8 @@ export function createClient(clientOpts: Partial = {}) { export function baseSetup(clientOpts: any = {}) { const client = createClient(clientOpts) const manager = createManager(client) - const server = startRPC(manager.getMethods(clientOpts.engine === true)) + const engineMethods = clientOpts.engine === true ? manager.getMethods(true) : {} + const server = startRPC({ ...manager.getMethods(), ...engineMethods }) server.once('close', () => { client.config.events.emit(Event.CLIENT_SHUTDOWN) }) @@ -236,7 +237,8 @@ export async function setupChain(genesisFile: any, chainName = 'dev', clientOpts enableMetaDB: true, }) const manager = createManager(client) - const server = startRPC(manager.getMethods(clientOpts.engine)) + const engineMethods = clientOpts.engine === true ? manager.getMethods(true) : {} + const server = startRPC({ ...manager.getMethods(), ...engineMethods }) server.once('close', () => { client.config.events.emit(Event.CLIENT_SHUTDOWN) })