diff --git a/.github/workflows/comptests.yml b/.github/workflows/comptests.yml new file mode 100644 index 000000000000..d1f1ff03849f --- /dev/null +++ b/.github/workflows/comptests.yml @@ -0,0 +1,40 @@ +name: Fork-choice compliance tests + +# Don't cancel an in-flight run when the next cron fires +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +on: + schedule: + - cron: "0 3 * * *" # Runs at 3am UTC everyday + workflow_dispatch: + +jobs: + comptests: + name: Fork-choice compliance tests + # Don't run scheduled copies of this workflow on forks. + if: ${{ github.event_name != 'schedule' || github.repository == 'ChainSafe/lodestar' }} + runs-on: warp-ubuntu-2204-x64-4x + # Single vitest worker (one test file): ~10 min per fork locally, 6 pre-gloas forks. + timeout-minutes: 150 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: "./.github/actions/setup-and-build" + with: + node: 24 + + - name: Restore compliance vectors cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: packages/beacon-node/spec-tests-comptests + key: comptest-data-${{ hashFiles('spec-tests-version.json') }} + - name: Download compliance vectors + run: pnpm download-comptests + + - name: Fork-choice compliance tests + run: pnpm test:comptest + working-directory: packages/beacon-node diff --git a/AGENTS.md b/AGENTS.md index 6fdad787f8ae..0e3725319f25 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,12 @@ pnpm download-spec-tests 2026-04-14 # latest successf pnpm download-spec-tests latest /consensus-specs # fork pnpm download-spec-tests latest /consensus-specs # fork + branch +# Fork-choice compliance tests (model-generated vectors, standalone flow; +# runs nightly in CI, not per-PR). Same version pin as the spec tests. +pnpm download-comptests +pnpm test:comptest +SPEC_FILTER_FORK=deneb pnpm test:comptest # single fork (from packages/beacon-node) + # Run e2e tests (requires docker environment) ./scripts/run_e2e_env.sh start pnpm test:e2e diff --git a/CLAUDE.md b/CLAUDE.md index 535f1fb86767..d40e872fb640 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,10 @@ pnpm vitest run --project unit test/unit/path/to/test.test.ts pnpm download-spec-tests pnpm test:spec +# Fork-choice compliance tests (standalone, nightly CI; download first) +pnpm download-comptests +pnpm test:comptest + # Docs lint (markdown) pnpm docs:lint pnpm docs:lint:fix diff --git a/configs/vitest.config.spec.ts b/configs/vitest.config.spec.ts index c66eaa2670e7..00e05d42bbc9 100644 --- a/configs/vitest.config.spec.ts +++ b/configs/vitest.config.spec.ts @@ -5,6 +5,7 @@ export const specProjectMinimal = defineProject({ test: { name: "spec-minimal", include: ["**/test/spec/**/*.test.ts"], + exclude: ["**/test/spec/comptest/**"], setupFiles: [ path.join(__dirname, "../scripts/vitest/setupFiles/customMatchers.ts"), path.join(__dirname, "../scripts/vitest/setupFiles/dotenv.ts"), @@ -26,6 +27,7 @@ export const specProjectMainnet = defineProject({ test: { name: "spec-mainnet", include: ["**/test/spec/**/*.test.ts"], + exclude: ["**/test/spec/comptest/**"], setupFiles: [ path.join(__dirname, "../scripts/vitest/setupFiles/customMatchers.ts"), path.join(__dirname, "../scripts/vitest/setupFiles/dotenv.ts"), @@ -42,3 +44,24 @@ export const specProjectMainnet = defineProject({ }, }, }); + +// Fork-choice compliance suite (`pnpm test:comptest`) — deliberately its own project so the +// regular spec projects never pick it up. +// Fixtures via `pnpm download-comptests`. +export const specProjectComptest = defineProject({ + test: { + name: "comptest", + include: ["**/test/spec/comptest/**/*.test.ts"], + setupFiles: [ + path.join(__dirname, "../scripts/vitest/setupFiles/customMatchers.ts"), + path.join(__dirname, "../scripts/vitest/setupFiles/dotenv.ts"), + path.join(__dirname, "../scripts/vitest/setupFiles/lodestarPreset.ts"), + ], + testTimeout: 1000 * 60 * 15, + hookTimeout: 1000 * 60 * 15, + pool: "forks", + env: { + LODESTAR_PRESET: "minimal", + }, + }, +}); diff --git a/package.json b/package.json index a807b5adc9e8..905118972dde 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,8 @@ "test:browsers": "vitest run --project browser", "test:e2e": "vitest run --project e2e --project e2e-mainnet", "download-spec-tests": "pnpm -r download-spec-tests", + "download-comptests": "pnpm -r download-comptests", + "test:comptest": "vitest run --project comptest", "test:spec": "vitest run --project spec-minimal --project spec-mainnet", "benchmark": "pnpm benchmark:files 'packages/*/test/perf/**/*.test.ts'", "benchmark:files": "NODE_OPTIONS='--max-old-space-size=8192 --loader=ts-node/esm' benchmark --config .benchrc.yaml --defaultBranch unstable", diff --git a/packages/beacon-node/package.json b/packages/beacon-node/package.json index bec1bb2ab2c1..44440491553d 100644 --- a/packages/beacon-node/package.json +++ b/packages/beacon-node/package.json @@ -100,6 +100,8 @@ "test:sim": "vitest run test/sim/**/*.test.ts", "test:sim:blobs": "vitest run test/sim/4844-interop.test.ts", "download-spec-tests": "node --loader=ts-node/esm test/spec/downloadTests.ts", + "download-comptests": "node --loader=ts-node/esm test/spec/downloadComptests.ts", + "test:comptest": "vitest run --project comptest", "test:spec:bls": "vitest run --project spec-minimal test/spec/bls/", "test:spec:general": "vitest run --project spec-minimal test/spec/general/", "test:spec:minimal": "vitest run --project spec-minimal test/spec/presets/", diff --git a/packages/beacon-node/test/spec/comptest/fork_choice_compliance.test.ts b/packages/beacon-node/test/spec/comptest/fork_choice_compliance.test.ts new file mode 100644 index 000000000000..94f58b3b9a0e --- /dev/null +++ b/packages/beacon-node/test/spec/comptest/fork_choice_compliance.test.ts @@ -0,0 +1,23 @@ +import fs from "node:fs"; +import path from "node:path"; +import {ACTIVE_PRESET} from "@lodestar/params"; +import {comptestsSpecTests} from "../specTestVersioning.js"; +import {forkChoiceTestRunner} from "../utils/forkChoiceTestRunner.js"; +import {specTestIterator} from "../utils/specTestIterator.js"; +import {RunnerType} from "../utils/types.js"; + +// Fork-choice compliance suite (`pnpm test:comptest`) — a standalone flow parallel to the +// standard spec tests: its own vitest project, its own fixture directory +// (`pnpm download-comptests`), sharing only the fork-choice test runner. + +const presetDir = path.join(comptestsSpecTests.outputDir, "tests", ACTIVE_PRESET); +const hasComplianceFixtures = + fs.existsSync(presetDir) && + fs.readdirSync(presetDir).some((fork) => fs.existsSync(path.join(presetDir, fork, "fork_choice_compliance"))); +if (!hasComplianceFixtures) { + throw Error("No fork_choice_compliance fixtures found — run `pnpm download-comptests` first"); +} + +specTestIterator(presetDir, { + fork_choice_compliance: {type: RunnerType.default, fn: forkChoiceTestRunner({onlyPredefinedResponses: false})}, +}); diff --git a/packages/beacon-node/test/spec/downloadComptests.ts b/packages/beacon-node/test/spec/downloadComptests.ts new file mode 100644 index 000000000000..01576b57d7ba --- /dev/null +++ b/packages/beacon-node/test/spec/downloadComptests.ts @@ -0,0 +1,7 @@ +import {downloadTests} from "@lodestar/spec-test-util/downloadTests"; +import {comptestsSpecTests} from "./specTestVersioning.js"; + +await downloadTests(comptestsSpecTests, console.log).catch((e: Error) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/beacon-node/test/spec/presets/fork_choice.test.ts b/packages/beacon-node/test/spec/presets/fork_choice.test.ts index bdc72a6b4e61..3cd1bf9d4cf8 100644 --- a/packages/beacon-node/test/spec/presets/fork_choice.test.ts +++ b/packages/beacon-node/test/spec/presets/fork_choice.test.ts @@ -1,914 +1,11 @@ import path from "node:path"; -import {generateKeyPair} from "@libp2p/crypto/keys"; -import {expect} from "vitest"; -import {toHexString} from "@chainsafe/ssz"; -import {createBeaconConfig} from "@lodestar/config"; -import {getConfig} from "@lodestar/config/test-utils"; -import {CheckpointWithHex, ExecutionStatus, ForkChoice} from "@lodestar/fork-choice"; -import {testLogger} from "@lodestar/logger/test-utils"; -import { - ACTIVE_PRESET, - ForkPostDeneb, - ForkPostFulu, - ForkPostGloas, - ForkPreDeneb, - ForkPreFulu, - ForkPreGloas, - ForkSeq, -} from "@lodestar/params"; -import {InputType} from "@lodestar/spec-test-util"; -import { - BeaconStateAllForks, - BeaconStateView, - DataAvailabilityStatus, - IBeaconStateViewGloas, - createCachedBeaconState, - createPubkeyCache, - createSingleSignatureSetFromComponents, - getPayloadAttestationDataSigningRoot, - isExecutionStateType, - isGloasStateType, - signedBlockToSignedHeader, - syncPubkeys, -} from "@lodestar/state-transition"; -import { - Attestation, - AttesterSlashing, - BeaconBlock, - RootHex, - SignedBeaconBlock, - deneb, - fulu, - gloas, - ssz, - sszTypesFor, -} from "@lodestar/types"; -import {PayloadAttestationMessage} from "@lodestar/types/gloas"; -import {bnToNum, fromHex, toHex, toRootHex} from "@lodestar/utils"; -import { - BlockInputBlobs, - BlockInputColumns, - BlockInputNoData, - BlockInputPreData, - BlockInputSource, -} from "../../../src/chain/blocks/blockInput/index.js"; -import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; -import { - verifyExecutionPayloadEnvelope, - verifyExecutionPayloadEnvelopeSignature, -} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js"; -import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; -import {defaultChainOptions} from "../../../src/chain/options.js"; -import {RegenCaller} from "../../../src/chain/regen/index.js"; -import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; -import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; -import {ExecutionPayloadStatus} from "../../../src/execution/engine/interface.js"; -import {ExecutionEngineMockBackend} from "../../../src/execution/engine/mock.js"; -import {getExecutionEngineFromBackend} from "../../../src/execution/index.js"; -import {computePreFuluKzgCommitmentsInclusionProof} from "../../../src/util/blobs.js"; -import {ClockEvent} from "../../../src/util/clock.js"; -import {ClockStopped} from "../../mocks/clock.js"; -import {getMockedBeaconDb} from "../../mocks/mockedBeaconDb.js"; -import {assertCorrectProgressiveBalances} from "../config.js"; +import {ACTIVE_PRESET} from "@lodestar/params"; import {ethereumConsensusSpecsTests} from "../specTestVersioning.js"; +import {forkChoiceTestRunner} from "../utils/forkChoiceTestRunner.js"; import {specTestIterator} from "../utils/specTestIterator.js"; -import {RunnerType, TestRunnerFn} from "../utils/types.js"; - -const ANCHOR_STATE_FILE_NAME = "anchor_state"; -const ANCHOR_BLOCK_FILE_NAME = "anchor_block"; -const BLOCK_FILE_NAME = "^(block)_([0-9a-zA-Z]+)$"; -const BLOBS_FILE_NAME = "^(blobs)_([0-9a-zA-Z]+)$"; -const COLUMN_FILE_NAME = "^(column)_([0-9a-zA-Z]+)$"; -const EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME = "^(execution_payload_envelope)_([0-9a-zA-Z]+)$"; -const ATTESTATION_FILE_NAME = "^(attestation)_([0-9a-zA-Z])+$"; -const ATTESTER_SLASHING_FILE_NAME = "^(attester_slashing)_([0-9a-zA-Z])+$"; -const PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME = "^(payload_attestation_message)_([0-9a-zA-Z])+$"; - -const logger = testLogger("spec-test"); - -const forkChoiceTest = - (opts: {onlyPredefinedResponses: boolean}): TestRunnerFn => - (fork) => { - return { - testFunction: async (testcase, _directoryName, testCaseName) => { - const {steps, anchorState} = testcase; - const currentSlot = anchorState.slot; - const config = getConfig(fork); - // const state = createCachedBeaconStateTest(anchorState, config); - - /** This is to track test's tickTime to be used in proposer boost */ - let tickTime = 0; - const clock = new ClockStopped(currentSlot); - const executionEngineBackend = new ExecutionEngineMockBackend({ - onlyPredefinedResponses: opts.onlyPredefinedResponses, - genesisBlockHash: isGloasStateType(anchorState) - ? toHexString(anchorState.latestBlockHash) - : isExecutionStateType(anchorState) - ? toHexString(anchorState.latestExecutionPayloadHeader.blockHash) - : ZERO_HASH_HEX, - }); - - const controller = new AbortController(); - const executionEngine = getExecutionEngineFromBackend(executionEngineBackend, { - signal: controller.signal, - logger: testLogger("executionEngine"), - }); - - const beaconConfig = createBeaconConfig(config, anchorState.genesisValidatorsRoot); - const pubkeyCache = createPubkeyCache(); - syncPubkeys(pubkeyCache, anchorState.validators.getAllReadonlyValues()); - const cachedState = createCachedBeaconState( - anchorState, - { - config: beaconConfig, - pubkeyCache, - }, - {skipSyncPubkeys: true} - ); - - const chain = new BeaconChain( - { - ...defaultChainOptions, - // Do not start workers - blsVerifyAllMainThread: true, - // Do not run any archiver tasks - disableArchiveOnCheckpoint: true, - // Since the tests have deep-reorgs attested data is not available often printing lots of error logs. - // While this function is only called for head blocks, best to disable. - disableLightClientServerOnImportBlockHead: true, - // No need to log BlockErrors, the spec test runner will only log them if not not expected - // Otherwise spec tests logs get cluttered with expected errors - disableOnBlockError: true, - // PrepareNextSlot scheduler is used to precompute epoch transition and prepare for the next payload - // we don't use these in fork choice spec tests - disablePrepareNextSlot: true, - assertCorrectProgressiveBalances, - proposerBoost: true, - proposerBoostReorg: true, - }, - { - privateKey: await generateKeyPair("secp256k1"), - config: beaconConfig, - pubkeyCache, - db: getMockedBeaconDb(), - dataDir: ".", - dbName: ",", - logger, - processShutdownCallback: () => {}, - clock, - metrics: null, - validatorMonitor: null, - anchorState: new BeaconStateView(cachedState), - isAnchorStateFinalized: true, - executionEngine, - executionBuilder: undefined, - } - ); - - // The handler of `ChainEvent.forkChoiceFinalized` access `db.block` and raise error if not found. - chain.emitter.removeAllListeners(ChainEvent.forkChoiceFinalized); - - const stepsLen = steps.length; - logger.debug("Fork choice test", {steps: stepsLen}); - - try { - for (const [i, step] of steps.entries()) { - if (isTick(step)) { - tickTime = bnToNum(step.tick); - const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); - logger.debug(`Step ${i}/${stepsLen} tick`, {currentSlot, valid: Boolean(step.valid), time: tickTime}); - clock.emit(ClockEvent.slot, currentSlot); - clock.setSlot(currentSlot); - } - - // attestation step - else if (isAttestation(step)) { - const isValid = Boolean(step.valid ?? true); - logger.debug(`Step ${i}/${stepsLen} attestation`, {root: step.attestation, valid: isValid}); - const attestation = testcase.attestations.get(step.attestation); - if (!attestation) throw Error(`No attestation ${step.attestation}`); - const headState = chain.getHeadState() as BeaconStateView; - const attDataRootHex = toHexString(sszTypesFor(fork).AttestationData.hashTreeRoot(attestation.data)); - const indexedAttestation = headState.cachedState.epochCtx.getIndexedAttestation( - ForkSeq[fork], - attestation - ); - try { - chain.forkChoice.onAttestation(indexedAttestation, attDataRootHex); - if (!isValid) throw Error("Expect error since this is a negative test"); - } catch (e) { - if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; - } - } - - // attester slashing step - else if (isAttesterSlashing(step)) { - logger.debug(`Step ${i}/${stepsLen} attester slashing`, { - root: step.attester_slashing, - valid: Boolean(step.valid), - }); - const attesterSlashing = testcase.attesterSlashings.get(step.attester_slashing); - if (!attesterSlashing) throw Error(`No attester slashing ${step.attester_slashing}`); - chain.forkChoice.onAttesterSlashing(attesterSlashing); - } - - // payload attestation message step - else if (isPayloadAttestationMessage(step)) { - const isValid = Boolean(step.valid ?? true); - logger.debug(`Step ${i}/${stepsLen} payload attestation message`, { - root: step.payload_attestation_message, - valid: isValid, - }); - const payloadAttestationMessage = testcase.payloadAttestationMessages.get( - step.payload_attestation_message - ); - if (!payloadAttestationMessage) - throw Error(`No payload attestation message ${step.payload_attestation_message}`); - try { - const blockRoot = toRootHex(payloadAttestationMessage.data.beaconBlockRoot); - const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(blockRoot); - if (!protoBlock) { - throw Error(`Block not found for root ${blockRoot}`); - } - - if (protoBlock.slot === payloadAttestationMessage.data.slot) { - const blockState = await chain.regen.getBlockSlotState( - protoBlock, - payloadAttestationMessage.data.slot, - {dontTransferCache: true}, - RegenCaller.processBlock - ); - - const ptcIndices = (blockState as IBeaconStateViewGloas).getIndicesInPayloadTimelinessCommittee( - payloadAttestationMessage.validatorIndex, - payloadAttestationMessage.data.slot - ); - - // Slot check, matching the `validateGossipPayloadAttestationMessage` flow - if (clock.currentSlot !== payloadAttestationMessage.data.slot) { - throw Error( - `Message slot ${payloadAttestationMessage.data.slot} is not current slot ${clock.currentSlot}` - ); - } - - // Signature verification, matching the `validateGossipPayloadAttestationMessage` flow - const validatorPubkey = pubkeyCache.get(payloadAttestationMessage.validatorIndex); - if (!validatorPubkey) { - throw Error(`Unknown validator index ${payloadAttestationMessage.validatorIndex}`); - } - const signatureSet = createSingleSignatureSetFromComponents( - validatorPubkey, - getPayloadAttestationDataSigningRoot(beaconConfig, payloadAttestationMessage.data), - payloadAttestationMessage.signature - ); - let signatureValidity: boolean; - try { - signatureValidity = await chain.bls.verifySignatureSets([signatureSet], { - verifyOnMainThread: true, - batchable: true, - priority: true, - }); - } catch { - signatureValidity = false; - } - if (!signatureValidity) throw Error("Invalid payload attestation signature"); - - chain.forkChoice.notifyPtcMessages( - blockRoot, - payloadAttestationMessage.data.slot, - ptcIndices, - payloadAttestationMessage.data.payloadPresent, - payloadAttestationMessage.data.blobDataAvailable - ); - } - } catch (e) { - if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; - } - } - - // block step - else if (isBlock(step)) { - const isValid = Boolean(step.valid ?? true); - const signedBlock = testcase.blocks.get(step.block); - if (!signedBlock) { - throw Error(`No block ${step.block}`); - } - - // Post-Deneb and pre-Fulu, `columns` should not be present. Post-Fulu `blobs` and - // `proofs` should not be present. - let blobs: deneb.Blob[] | undefined; - let proofs: deneb.KZGProof[] | undefined; - let columns: fulu.DataColumnSidecar[] | undefined; - if (step.blobs !== undefined) { - blobs = testcase.blobs.get(step.blobs); - } - if (step.proofs !== undefined) { - proofs = step.proofs.map((proof) => ssz.deneb.KZGProof.deserialize(fromHex(proof))); - } - if (step.columns !== undefined) { - columns = []; - for (const columnName of step.columns) { - const column = testcase.columns.get(columnName); - if (column === undefined) { - throw Error(`Malformed spec test. Column file with name ${columnName} not found.`); - } - columns.push(column); - } - } - - const {slot} = signedBlock.message; - // Log the BeaconBlock root instead of the SignedBeaconBlock root, forkchoice references BeaconBlock roots - const blockRoot = config - .getForkTypes(signedBlock.message.slot) - .BeaconBlock.hashTreeRoot(signedBlock.message); - const blockRootHex = toHex(blockRoot); - logger.debug(`Step ${i}/${stepsLen} block`, { - slot, - id: step.block, - root: toHexString(blockRoot), - parentRoot: toHexString(signedBlock.message.parentRoot), - isValid, - }); - - try { - let blockImport; - const forkSeq = config.getForkSeq(slot); - - if (forkSeq >= ForkSeq.gloas) { - // Gloas (ePBS) blocks don't carry blobs/columns directly on the block body. - // Blob KZG commitments are nested inside signedExecutionPayloadBid. - // Use BlockInputNoData since DA is handled separately via execution payload envelopes. - blockImport = BlockInputNoData.createFromBlock({ - forkName: fork, - block: signedBlock as SignedBeaconBlock, - blockRootHex, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - daOutOfRange: false, - }); - // importBlock requires a PayloadEnvelopeInput to exist for gloas blocks; in - // production this is seeded by gossip / by-root / by-range / API producers. - // Spec tests bypass those, so seed it here to mirror the gossip-handler path. - chain.seenPayloadEnvelopeInputCache.add({ - blockRootHex, - block: signedBlock as SignedBeaconBlock, - forkName: fork, - sampledColumns: chain.custodyConfig.sampledColumns, - custodyColumns: chain.custodyConfig.custodyColumns, - timeCreatedSec: Date.now() / 1000, - }); - } else if (forkSeq >= ForkSeq.fulu) { - if (columns === undefined) { - columns = []; - } - - await validateFuluBlockDataColumnSidecars( - chain, - slot, - blockRoot, - (signedBlock as SignedBeaconBlock).message.body.blobKzgCommitments - .length, - columns, - chain.metrics?.peerDas - ); - - blockImport = BlockInputColumns.createFromBlock({ - forkName: fork, - block: signedBlock as SignedBeaconBlock, - blockRootHex, - custodyColumns: - // in most test case instances we do not want to assign any custody as there are no columns provided - // with the test case. For on_block_peerdas__not_available the exact situation that is being tested - // is no availability so block processing should fail. For this one test case add some default - // custody so that the await will fail in verifyBlocksDataAvailability.ts - testCaseName !== "on_block_peerdas__not_available" ? columns.map((c) => c.index) : [2, 4, 6, 8], - sampledColumns: - testCaseName !== "on_block_peerdas__not_available" - ? columns.map((c) => c.index) - : [2, 4, 6, 8, 10, 12, 14, 16], - source: BlockInputSource.gossip, - seenTimestampSec: 0, - daOutOfRange: false, - }); - for (const column of columns) { - blockImport.addColumn({ - blockRootHex, - columnSidecar: column, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - }); - } - // getBlockInput.availableData(config, signedBlock, BlockSource.gossip, blockData); - } else if (forkSeq >= ForkSeq.deneb && forkSeq < ForkSeq.fulu) { - if (blobs === undefined) { - // seems like some deneb tests don't have this and we are supposed to assume empty - // throw Error("Missing blobs for the deneb+ block"); - blobs = []; - } - if (proofs === undefined) { - // seems like some deneb tests don't have this and we are supposed to assume empty - // throw Error("proofs for the deneb+ block"); - proofs = []; - } - // the kzg lib for validation of minimal setup is not yet integrated, lets just verify lengths - // post integration use validateBlobsAndProofs - const commitments = (signedBlock as deneb.SignedBeaconBlock).message.body.blobKzgCommitments; - if (blobs.length !== commitments.length || proofs.length !== commitments.length) { - throw Error("Invalid blobs or proofs lengths"); - } - - const blobSidecars: deneb.BlobSidecars = blobs.map((blob, index) => { - return { - index, - blob, - kzgCommitment: commitments[index], - kzgProof: (proofs ?? [])[index], - signedBlockHeader: signedBlockToSignedHeader(config, signedBlock), - kzgCommitmentInclusionProof: computePreFuluKzgCommitmentsInclusionProof( - fork, - signedBlock.message.body, - index - ), - }; - }); - - blockImport = BlockInputBlobs.createFromBlock({ - forkName: fork, - block: signedBlock as SignedBeaconBlock, - blockRootHex, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - daOutOfRange: false, - }); - for (const blob of blobSidecars) { - blockImport.addBlob({ - blockRootHex, - blobSidecar: blob, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - }); - } - } else { - blockImport = BlockInputPreData.createFromBlock({ - forkName: fork, - block: signedBlock as SignedBeaconBlock, - blockRootHex, - source: BlockInputSource.gossip, - seenTimestampSec: 0, - daOutOfRange: false, - }); - } - - await chain.processBlock(blockImport, { - seenTimestampSec: tickTime, - validBlobSidecars: BlobSidecarValidation.Full, - importAttestations: AttestationImportOpt.Force, - }); - if (!isValid) throw Error("Expect error since this is a negative test"); - } catch (e) { - if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; - } - } - - // execution_payload step for Gloas (ePBS) tests - else if (isExecutionPayload(step)) { - const isValid = Boolean(step.valid ?? true); - logger.debug(`Step ${i}/${stepsLen} execution_payload`, { - envelope: step.execution_payload, - valid: isValid, - }); - const envelope = testcase.executionPayloadEnvelopes.get(step.execution_payload); - if (!envelope) throw Error(`No execution payload envelope ${step.execution_payload}`); - - try { - const beaconBlockRoot = toHex(envelope.message.beaconBlockRoot); - const blockHash = toHex(envelope.message.payload.blockHash); - const blockNumber = envelope.message.payload.blockNumber; - const gasLimit = envelope.message.payload.gasLimit; - - // Verify envelope against the state - const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(beaconBlockRoot); - if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); - const blockState = await chain.regen.getBlockSlotState( - protoBlock, - protoBlock.slot, - {dontTransferCache: true}, - RegenCaller.processBlock - ); - verifyExecutionPayloadEnvelope(beaconConfig, blockState as IBeaconStateViewGloas, envelope.message); - - // Verify signature - const sigValid = await verifyExecutionPayloadEnvelopeSignature( - beaconConfig, - blockState as IBeaconStateViewGloas, - pubkeyCache, - envelope, - blockState.latestBlockHeader.proposerIndex, - chain.bls - ); - if (!sigValid) throw Error("Invalid execution payload envelope signature"); - - // Add predefined VALID status for the payload's block hash so the EL mock accepts it - executionEngineBackend.addPredefinedPayloadStatus(blockHash, { - status: ExecutionPayloadStatus.VALID, - latestValidHash: null, - validationError: null, - }); - - (chain.forkChoice as ForkChoice).onExecutionPayload( - beaconBlockRoot, - blockHash, - blockNumber, - gasLimit, - ExecutionStatus.Valid, - DataAvailabilityStatus.Available - ); - if (!isValid) throw Error("Expect error since this is a negative test"); - } catch (e) { - if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; - } - } - - // Optional step for optimistic sync tests. - else if (isOnPayloadInfoStep(step)) { - logger.debug(`Step ${i}/${stepsLen} payload_status`, {blockHash: step.block_hash}); - const status = ExecutionPayloadStatus[step.payload_status.status]; - if (status === undefined) { - throw Error(`Unknown payload_status.status: ${step.payload_status.status}`); - } - executionEngineBackend.addPredefinedPayloadStatus(step.block_hash, { - status, - latestValidHash: step.payload_status.latest_valid_hash, - validationError: step.payload_status.validation_error, - }); - } - - // checks step - else if (isCheck(step)) { - logger.debug(`Step ${i}/${stepsLen} check`); - - // Forkchoice head is computed lazily only on request - const head = (chain.forkChoice as ForkChoice).updateHead(); - const proposerBootRoot = (chain.forkChoice as ForkChoice).getProposerBoostRoot(); - - if (step.checks.head !== undefined) { - expect({slot: head.slot, root: head.blockRoot}).toEqualWithMessage( - {slot: bnToNum(step.checks.head.slot), root: step.checks.head.root}, - `Invalid head at step ${i}` - ); - } - if (step.checks.proposer_boost_root !== undefined) { - expect(proposerBootRoot).toEqualWithMessage( - step.checks.proposer_boost_root, - `Invalid proposer boost root at step ${i}` - ); - } - // time in spec mapped to Slot in our forkchoice implementation. - // Compare in slots because proposer boost steps doesn't always come on - // slot boundary. - if (step.checks.time !== undefined && step.checks.time > 0) - expect(chain.forkChoice.getTime()).toEqualWithMessage( - Math.floor(bnToNum(step.checks.time) / (config.SLOT_DURATION_MS / 1000)), - `Invalid forkchoice time at step ${i}` - ); - if (step.checks.justified_checkpoint) { - expect(toSpecTestCheckpoint(chain.forkChoice.getJustifiedCheckpoint())).toEqualWithMessage( - step.checks.justified_checkpoint, - `Invalid justified checkpoint at step ${i}` - ); - } - if (step.checks.finalized_checkpoint) { - expect(toSpecTestCheckpoint(chain.forkChoice.getFinalizedCheckpoint())).toEqualWithMessage( - step.checks.finalized_checkpoint, - `Invalid finalized checkpoint at step ${i}` - ); - } - if (step.checks.get_proposer_head) { - const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); - const {proposerHead, notReorgedReason} = (chain.forkChoice as ForkChoice).getProposerHead( - head, - tickTime % (config.SLOT_DURATION_MS / 1000), - currentSlot - ); - logger.debug(`Not reorged reason ${notReorgedReason} at step ${i}`); - expect(proposerHead.blockRoot).toEqualWithMessage( - step.checks.get_proposer_head, - `Invalid proposer head at step ${i}` - ); - } - if (step.checks.head_payload_status !== undefined) { - // Map our PayloadStatus enum to spec's numbering: - // Spec: EMPTY=0, FULL=1, PENDING=2 - // Ours: PENDING=0, EMPTY=1, FULL=2 - const payloadStatusToSpec: Record = {0: 2, 1: 0, 2: 1}; - expect(payloadStatusToSpec[head.payloadStatus]).toEqualWithMessage( - bnToNum(step.checks.head_payload_status), - `Invalid head payload status at step ${i}` - ); - } - if (step.checks.should_override_forkchoice_update) { - const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); - const result = chain.forkChoice.shouldOverrideForkChoiceUpdate( - head, - tickTime % (config.SLOT_DURATION_MS / 1000), - currentSlot - ); - if (result.shouldOverrideFcu === false) { - logger.debug(`Not override fcu reason ${result.reason} at step ${i}`); - } - expect({result: result.shouldOverrideFcu, validator_is_connected: true}).toEqualWithMessage( - step.checks.should_override_forkchoice_update, - `Invalid should override fcu result at step ${i}` - ); - } - if (step.checks.payload_timeliness_vote) { - expect( - chain.forkChoice.getPayloadTimelinessVotes(step.checks.payload_timeliness_vote.block_root) - ).toEqualWithMessage( - step.checks.payload_timeliness_vote.votes, - `Invalid payload timeliness votes at step ${i}` - ); - } - if (step.checks.payload_data_availability_vote) { - expect( - chain.forkChoice.getPayloadDataAvailabilityVotes( - step.checks.payload_data_availability_vote.block_root - ) - ).toEqualWithMessage( - step.checks.payload_data_availability_vote.votes, - `Invalid payload data availability votes at step ${i}` - ); - } - } - - // None of the above - else { - throw Error(`Unknown step ${i}/${stepsLen}: ${JSON.stringify(Object.keys(step))}`); - } - } - } finally { - await chain.close(); - } - }, - - options: { - inputTypes: { - meta: InputType.YAML, - steps: InputType.YAML, - }, - sszTypes: { - [ANCHOR_STATE_FILE_NAME]: ssz[fork].BeaconState, - [ANCHOR_BLOCK_FILE_NAME]: ssz[fork].BeaconBlock, - [BLOCK_FILE_NAME]: ssz[fork].SignedBeaconBlock, - [BLOBS_FILE_NAME]: ssz.deneb.Blobs, - [COLUMN_FILE_NAME]: ssz.fulu.DataColumnSidecar, - [EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME]: ssz.gloas.SignedExecutionPayloadEnvelope, - [ATTESTATION_FILE_NAME]: sszTypesFor(fork).Attestation, - [ATTESTER_SLASHING_FILE_NAME]: sszTypesFor(fork).AttesterSlashing, - [PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME]: ssz.gloas.PayloadAttestationMessage, - }, - mapToTestCase: (t: Record) => { - // t has input file name as key - const blocks = new Map(); - const blobs = new Map(); - const columns = new Map(); - const executionPayloadEnvelopes = new Map(); - const attestations = new Map(); - const attesterSlashings = new Map(); - const payloadAttestationMessages = new Map(); - for (const key in t) { - if (!Object.prototype.hasOwnProperty.call(t, key)) continue; - - const blockMatch = key.match(BLOCK_FILE_NAME); - if (blockMatch) { - blocks.set(key, t[key]); - } - const blobsMatch = key.match(BLOBS_FILE_NAME); - if (blobsMatch) { - blobs.set(key, t[key]); - } - const columnMatch = key.match(COLUMN_FILE_NAME); - if (columnMatch) { - columns.set(key, t[key]); - } - const envelopeMatch = key.match(EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME); - if (envelopeMatch) { - executionPayloadEnvelopes.set(key, t[key]); - } - const attMatch = key.match(ATTESTATION_FILE_NAME); - if (attMatch) { - attestations.set(key, t[key]); - } - const attesterSlashingMatch = key.match(ATTESTER_SLASHING_FILE_NAME); - if (attesterSlashingMatch) { - attesterSlashings.set(key, t[key]); - } - const payloadAttestationMessageMatch = key.match(PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME); - if (payloadAttestationMessageMatch) { - payloadAttestationMessages.set(key, t[key]); - } - } - return { - meta: t["meta"] as ForkChoiceTestCase["meta"], - anchorState: t[ANCHOR_STATE_FILE_NAME] as ForkChoiceTestCase["anchorState"], - anchorBlock: t[ANCHOR_BLOCK_FILE_NAME] as ForkChoiceTestCase["anchorBlock"], - steps: t["steps"] as ForkChoiceTestCase["steps"], - blocks, - blobs, - columns, - executionPayloadEnvelopes, - attestations, - attesterSlashings, - payloadAttestationMessages, - }; - }, - // timeout needs to be set longer than BLOB_AVAILABILITY_TIMEOUT so that on_block_peerdas__not_available fails - timeout: 15000, - expectFunc: () => {}, - // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts - // EXCEPTION : this test skipped here because prefix match can't be don't for this particular test - // as testId for the entire directory is same : `deneb/fork_choice/on_block/pyspec_tests` and - // we just want to skip this one particular test because we don't have minimal kzg lib integrated - // - // This skip can be removed once a kzg lib with run-time minimal blob size setup is released and - // integrated - shouldSkip: (_testcase, name, _index) => - name.includes("invalid_incorrect_proof") || - // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 - (name.includes("gloas") && - (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || - name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_previous_epoch") || - name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))), - }, - }; - }; - -function toSpecTestCheckpoint(checkpoint: CheckpointWithHex): SpecTestCheckpoint { - return { - epoch: BigInt(checkpoint.epoch), - root: checkpoint.rootHex, - }; -} - -type Step = - | OnTick - | OnAttestation - | OnAttesterSlashing - | OnPayloadAttestationMessage - | OnBlock - | OnExecutionPayloadEnvelope - | OnPayloadInfo - | Checks; - -type SpecTestCheckpoint = {epoch: bigint; root: string}; - -// This test executes steps in sequence. There may be multiple items of the following types: -// on_tick execution step - -type OnTick = { - /** to execute `on_tick(store, time)` */ - tick: bigint; - /** optional, default to `true`. */ - valid?: number; -}; - -type OnAttestation = { - /** the name of the `attestation_<32-byte-root>.ssz_snappy` file. To execute `on_attestation(store, attestation)` */ - attestation: string; - /** optional, default to `true`. */ - valid?: number; -}; - -type OnAttesterSlashing = { - /** - * the name of the `attester_slashing_<32-byte-root>.ssz_snappy` file. - * To execute `on_attester_slashing(store, attester_slashing)` with the given attester slashing. - */ - attester_slashing: string; - /** optional, default to `true` */ - valid?: number; -}; - -type OnPayloadAttestationMessage = { - /** - * the name of the `payload_attestation_message_<32-byte-root>.ssz_snappy` file. - * To execute `on_payload_attestation_message(store, payload_attestation_message)`. - */ - payload_attestation_message: string; - /** optional, default to `true` */ - valid?: number; -}; - -type OnBlock = { - /** the name of the `block_<32-byte-root>.ssz_snappy` file. To execute `on_block(store, block)` */ - block: string; - blobs?: string; - proofs?: string[]; - columns?: string[]; - /** optional, default to `true`. */ - valid?: number; -}; - -type OnExecutionPayloadEnvelope = { - /** the name of the execution_payload_envelope file */ - execution_payload: string; - /** optional, default to `true`. */ - valid?: number; -}; - -type OnPayloadInfo = { - /** Encoded 32-byte value of payload's block hash. */ - block_hash: string; - payload_status: { - status: "VALID" | "INVALID" | "SYNCING" | "ACCEPTED" | "INVALID_BLOCK_HASH"; - /** Encoded 32-byte value of the latest valid block hash, may be `null`. */ - latest_valid_hash: string; - /** Message providing additional details on the validation error, may be `null`. */ - validation_error: string; - }; -}; - -type Checks = { - /** Value in the ForkChoice store to verify it's correct after being mutated by another step */ - checks: { - head?: { - slot: bigint; - root: string; - }; - time?: bigint; - justified_checkpoint?: SpecTestCheckpoint; - finalized_checkpoint?: SpecTestCheckpoint; - proposer_boost_root?: RootHex; - head_payload_status?: bigint; - get_proposer_head?: string; - should_override_forkchoice_update?: { - validator_is_connected: boolean; - result: boolean; - }; - /** Gloas: PTC timeliness votes per PTC position (`null` = member has not attested). */ - payload_timeliness_vote?: { - block_root: RootHex; - votes: (boolean | null)[]; - }; - /** Gloas: PTC data-availability votes per PTC position (`null` = member has not attested). */ - payload_data_availability_vote?: { - block_root: RootHex; - votes: (boolean | null)[]; - }; - }; -}; - -type ForkChoiceTestCase = { - meta?: { - description?: string; - bls_setting: bigint; - }; - anchorState: BeaconStateAllForks; - anchorBlock: BeaconBlock; - steps: Step[]; - blocks: Map; - blobs: Map; - columns: Map; - executionPayloadEnvelopes: Map; - attestations: Map; - attesterSlashings: Map; - payloadAttestationMessages: Map; -}; - -function isTick(step: Step): step is OnTick { - return (step as OnTick).tick >= 0; -} - -function isAttestation(step: Step): step is OnAttestation { - return typeof (step as OnAttestation).attestation === "string"; -} - -function isAttesterSlashing(step: Step): step is OnAttesterSlashing { - return typeof (step as OnAttesterSlashing).attester_slashing === "string"; -} - -function isPayloadAttestationMessage(step: Step): step is OnPayloadAttestationMessage { - return typeof (step as OnPayloadAttestationMessage).payload_attestation_message === "string"; -} - -function isBlock(step: Step): step is OnBlock { - return typeof (step as OnBlock).block === "string"; -} - -function isExecutionPayload(step: Step): step is OnExecutionPayloadEnvelope { - return typeof (step as OnExecutionPayloadEnvelope).execution_payload === "string"; -} - -function isOnPayloadInfoStep(step: Step): step is OnPayloadInfo { - return typeof (step as OnPayloadInfo).block_hash === "string"; -} - -function isCheck(step: Step): step is Checks { - return typeof (step as Checks).checks === "object"; -} +import {RunnerType} from "../utils/types.js"; specTestIterator(path.join(ethereumConsensusSpecsTests.outputDir, "tests", ACTIVE_PRESET), { - fork_choice: {type: RunnerType.default, fn: forkChoiceTest({onlyPredefinedResponses: false})}, - sync: {type: RunnerType.default, fn: forkChoiceTest({onlyPredefinedResponses: true})}, + fork_choice: {type: RunnerType.default, fn: forkChoiceTestRunner({onlyPredefinedResponses: false})}, + sync: {type: RunnerType.default, fn: forkChoiceTestRunner({onlyPredefinedResponses: true})}, }); diff --git a/packages/beacon-node/test/spec/specTestVersioning.ts b/packages/beacon-node/test/spec/specTestVersioning.ts index d4726ca26e23..dafbdae8d8cd 100644 --- a/packages/beacon-node/test/spec/specTestVersioning.ts +++ b/packages/beacon-node/test/spec/specTestVersioning.ts @@ -13,3 +13,12 @@ export const blsSpecTests = { ...specTestVersions.blsSpecTests, outputDir: path.join(__dirname, "../../", specTestVersions.blsSpecTests.outputDirBase), }; + +// Even though comptests is run indepdently from spec test, it still shares the same +// version and repo url with spec test +export const comptestsSpecTests = { + ...specTestVersions.comptestsSpecTests, + specVersion: specTestVersions.ethereumConsensusSpecsTests.specVersion, + specTestsRepoUrl: specTestVersions.ethereumConsensusSpecsTests.specTestsRepoUrl, + outputDir: path.join(__dirname, "../../", specTestVersions.comptestsSpecTests.outputDirBase), +}; diff --git a/packages/beacon-node/test/spec/utils/forkChoiceTestRunner.ts b/packages/beacon-node/test/spec/utils/forkChoiceTestRunner.ts new file mode 100644 index 000000000000..25a4a3ebb6ca --- /dev/null +++ b/packages/beacon-node/test/spec/utils/forkChoiceTestRunner.ts @@ -0,0 +1,1069 @@ +import {generateKeyPair} from "@libp2p/crypto/keys"; +import {expect} from "vitest"; +import {toHexString} from "@chainsafe/ssz"; +import {createBeaconConfig} from "@lodestar/config"; +import {getConfig} from "@lodestar/config/test-utils"; +import {CheckpointWithHex, ExecutionStatus, ForkChoice, getCommitteeFraction} from "@lodestar/fork-choice"; +import {testLogger} from "@lodestar/logger/test-utils"; +import { + EFFECTIVE_BALANCE_INCREMENT, + ForkPostDeneb, + ForkPostFulu, + ForkPostGloas, + ForkPreDeneb, + ForkPreFulu, + ForkPreGloas, + ForkSeq, + SLOTS_PER_EPOCH, +} from "@lodestar/params"; +import {InputType} from "@lodestar/spec-test-util"; +import { + BeaconStateAllForks, + BeaconStateView, + DataAvailabilityStatus, + IBeaconStateViewGloas, + computeEpochAtSlot, + createCachedBeaconState, + createPubkeyCache, + createSingleSignatureSetFromComponents, + getIndexedAttestation, + getPayloadAttestationDataSigningRoot, + isExecutionStateType, + isGloasStateType, + signedBlockToSignedHeader, + syncPubkeys, +} from "@lodestar/state-transition"; +import { + Attestation, + AttesterSlashing, + BeaconBlock, + RootHex, + SignedBeaconBlock, + deneb, + fulu, + gloas, + ssz, + sszTypesFor, +} from "@lodestar/types"; +import {PayloadAttestationMessage} from "@lodestar/types/gloas"; +import {bnToNum, fromHex, toHex, toRootHex} from "@lodestar/utils"; +import { + BlockInputBlobs, + BlockInputColumns, + BlockInputNoData, + BlockInputPreData, + BlockInputSource, +} from "../../../src/chain/blocks/blockInput/index.js"; +import {AttestationImportOpt, BlobSidecarValidation} from "../../../src/chain/blocks/types.js"; +import { + verifyExecutionPayloadEnvelope, + verifyExecutionPayloadEnvelopeSignature, +} from "../../../src/chain/blocks/verifyExecutionPayloadEnvelope.js"; +import {BlockError, BlockErrorCode} from "../../../src/chain/errors/blockError.js"; +import {BeaconChain, ChainEvent} from "../../../src/chain/index.js"; +import {defaultChainOptions} from "../../../src/chain/options.js"; +import {RegenCaller} from "../../../src/chain/regen/index.js"; +import {getShufflingForAttestationVerification} from "../../../src/chain/validation/attestation.js"; +import {validateFuluBlockDataColumnSidecars} from "../../../src/chain/validation/dataColumnSidecar.js"; +import {ZERO_HASH_HEX} from "../../../src/constants/constants.js"; +import {ExecutionPayloadStatus} from "../../../src/execution/engine/interface.js"; +import {ExecutionEngineMockBackend} from "../../../src/execution/engine/mock.js"; +import {getExecutionEngineFromBackend} from "../../../src/execution/index.js"; +import {computePreFuluKzgCommitmentsInclusionProof} from "../../../src/util/blobs.js"; +import {ClockEvent} from "../../../src/util/clock.js"; +import {ClockStopped} from "../../mocks/clock.js"; +import {getMockedBeaconDb} from "../../mocks/mockedBeaconDb.js"; +import {assertCorrectProgressiveBalances} from "../config.js"; +import {TestRunnerFn} from "./types.js"; + +const ANCHOR_STATE_FILE_NAME = "anchor_state"; +const ANCHOR_BLOCK_FILE_NAME = "anchor_block"; +const BLOCK_FILE_NAME = "^(block)_([0-9a-zA-Z]+)$"; +const BLOBS_FILE_NAME = "^(blobs)_([0-9a-zA-Z]+)$"; +const COLUMN_FILE_NAME = "^(column)_([0-9a-zA-Z]+)$"; +const EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME = "^(execution_payload_envelope)_([0-9a-zA-Z]+)$"; +const ATTESTATION_FILE_NAME = "^(attestation)_([0-9a-zA-Z])+$"; +const ATTESTER_SLASHING_FILE_NAME = "^(attester_slashing)_([0-9a-zA-Z])+$"; +const PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME = "^(payload_attestation_message)_([0-9a-zA-Z])+$"; + +const logger = testLogger("spec-test"); + +export const forkChoiceTestRunner = + (opts: {onlyPredefinedResponses: boolean}): TestRunnerFn => + (fork) => { + return { + testFunction: async (testcase, _directoryName, testCaseName) => { + const {steps, anchorState} = testcase; + const currentSlot = anchorState.slot; + const config = getConfig(fork); + // const state = createCachedBeaconStateTest(anchorState, config); + + /** This is to track test's tickTime to be used in proposer boost */ + let tickTime = 0; + const clock = new ClockStopped(currentSlot); + const executionEngineBackend = new ExecutionEngineMockBackend({ + onlyPredefinedResponses: opts.onlyPredefinedResponses, + genesisBlockHash: isGloasStateType(anchorState) + ? toHexString(anchorState.latestBlockHash) + : isExecutionStateType(anchorState) + ? toHexString(anchorState.latestExecutionPayloadHeader.blockHash) + : ZERO_HASH_HEX, + }); + + const controller = new AbortController(); + const executionEngine = getExecutionEngineFromBackend(executionEngineBackend, { + signal: controller.signal, + logger: testLogger("executionEngine"), + }); + + const beaconConfig = createBeaconConfig(config, anchorState.genesisValidatorsRoot); + const pubkeyCache = createPubkeyCache(); + syncPubkeys(pubkeyCache, anchorState.validators.getAllReadonlyValues()); + const cachedState = createCachedBeaconState( + anchorState, + { + config: beaconConfig, + pubkeyCache, + }, + {skipSyncPubkeys: true} + ); + + const chain = new BeaconChain( + { + ...defaultChainOptions, + // Do not start workers + blsVerifyAllMainThread: true, + // Do not run any archiver tasks + disableArchiveOnCheckpoint: true, + // Since the tests have deep-reorgs attested data is not available often printing lots of error logs. + // While this function is only called for head blocks, best to disable. + disableLightClientServerOnImportBlockHead: true, + // No need to log BlockErrors, the spec test runner will only log them if not not expected + // Otherwise spec tests logs get cluttered with expected errors + disableOnBlockError: true, + // PrepareNextSlot scheduler is used to precompute epoch transition and prepare for the next payload + // we don't use these in fork choice spec tests + disablePrepareNextSlot: true, + assertCorrectProgressiveBalances, + proposerBoost: true, + proposerBoostReorg: true, + }, + { + privateKey: await generateKeyPair("secp256k1"), + config: beaconConfig, + pubkeyCache, + db: getMockedBeaconDb(), + dataDir: ".", + dbName: ",", + logger, + processShutdownCallback: () => {}, + clock, + metrics: null, + validatorMonitor: null, + anchorState: new BeaconStateView(cachedState), + isAnchorStateFinalized: true, + executionEngine, + executionBuilder: undefined, + } + ); + + // The handler of `ChainEvent.forkChoiceFinalized` access `db.block` and raise error if not found. + chain.emitter.removeAllListeners(ChainEvent.forkChoiceFinalized); + + const stepsLen = steps.length; + logger.debug("Fork choice test", {steps: stepsLen}); + + try { + for (const [i, step] of steps.entries()) { + if (isTick(step)) { + tickTime = bnToNum(step.tick); + const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); + logger.debug(`Step ${i}/${stepsLen} tick`, {currentSlot, valid: Boolean(step.valid), time: tickTime}); + clock.emit(ClockEvent.slot, currentSlot); + clock.setSlot(currentSlot); + } + + // attestation step + else if (isAttestation(step)) { + const isValid = Boolean(step.valid ?? true); + logger.debug(`Step ${i}/${stepsLen} attestation`, {root: step.attestation, valid: isValid}); + const attestation = testcase.attestations.get(step.attestation); + if (!attestation) throw Error(`No attestation ${step.attestation}`); + const attDataRootHex = toHexString(sszTypesFor(fork).AttestationData.hashTreeRoot(attestation.data)); + + // Spec `validate_on_attestation` requires `get_current_slot(store) >= attestation.data.slot + 1` + // (an attestation may only influence fork choice from the slot AFTER it was created). Lodestar + // enforces this 1-slot delay in the gossip/attestation-pool layer, not in `forkChoice.onAttestation`, + // so replicate the precondition here since the runner calls `onAttestation` directly. + // `clock.currentSlot` is initialized from the anchor state slot and advanced by tick + // steps — matching `get_current_slot(store)` even before the first tick. + if (clock.currentSlot < attestation.data.slot + 1) { + if (isValid) { + throw Error(`Attestation not yet 1 slot old but marked valid at step ${i}`); + } + logger.debug( + `Step ${i}/${stepsLen} skip attestation: not yet 1 slot old (spec on_attestation rejects)`, + { + attSlot: attestation.data.slot, + currentSlot: clock.currentSlot, + } + ); + continue; + } + + // `on_attestation` decodes aggregation_bits with the shuffling at the attestation's + // target checkpoint, not the head state — resolve it via ShufflingCache + regen so + // cross-epoch fork attestations (surfaced by the compliance suite) decode correctly. + // The resolution runs inside the try so that errors on `valid: false` steps (e.g. + // attesting to a future block) count as the expected rejection. + const attHeadBlock = chain.forkChoice.getBlockHexDefaultStatus(toHex(attestation.data.beaconBlockRoot)); + if (attHeadBlock === null && isValid) { + throw Error(`Attestation beacon block root unknown to fork choice at step ${i}`); + } + try { + if (attHeadBlock === null) throw Error("Unknown attestation head block (expected rejection)"); + const shuffling = await getShufflingForAttestationVerification( + chain, + computeEpochAtSlot(attestation.data.slot), + attHeadBlock, + RegenCaller.validateGossipAttestation + ); + const indexedAttestation = getIndexedAttestation(shuffling, ForkSeq[fork], attestation); + chain.forkChoice.onAttestation(indexedAttestation, attDataRootHex); + if (!isValid) throw Error("Expect error since this is a negative test"); + } catch (e) { + if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; + } + } + + // attester slashing step + else if (isAttesterSlashing(step)) { + logger.debug(`Step ${i}/${stepsLen} attester slashing`, { + root: step.attester_slashing, + valid: Boolean(step.valid), + }); + const attesterSlashing = testcase.attesterSlashings.get(step.attester_slashing); + if (!attesterSlashing) throw Error(`No attester slashing ${step.attester_slashing}`); + chain.forkChoice.onAttesterSlashing(attesterSlashing); + } + + // payload attestation message step + else if (isPayloadAttestationMessage(step)) { + const isValid = Boolean(step.valid ?? true); + logger.debug(`Step ${i}/${stepsLen} payload attestation message`, { + root: step.payload_attestation_message, + valid: isValid, + }); + const payloadAttestationMessage = testcase.payloadAttestationMessages.get( + step.payload_attestation_message + ); + if (!payloadAttestationMessage) + throw Error(`No payload attestation message ${step.payload_attestation_message}`); + try { + const blockRoot = toRootHex(payloadAttestationMessage.data.beaconBlockRoot); + const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(blockRoot); + if (!protoBlock) { + throw Error(`Block not found for root ${blockRoot}`); + } + + // "PTC votes can only change the vote for their assigned beacon block, return + // early otherwise" — a slot mismatch is a no-op SUCCESS, not a rejection. + // https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/gloas/fork-choice.md#on_payload_attestation_message + if (protoBlock.slot === payloadAttestationMessage.data.slot) { + const blockState = await chain.regen.getBlockSlotState( + protoBlock, + payloadAttestationMessage.data.slot, + {dontTransferCache: true}, + RegenCaller.processBlock + ); + + const ptcIndices = (blockState as IBeaconStateViewGloas).getIndicesInPayloadTimelinessCommittee( + payloadAttestationMessage.validatorIndex, + payloadAttestationMessage.data.slot + ); + + // Spec asserts the validator is a PTC member for the slot + if (ptcIndices.length === 0) { + throw Error( + `Validator ${payloadAttestationMessage.validatorIndex} not in PTC for slot ${payloadAttestationMessage.data.slot}` + ); + } + + // Slot check, matching the `validateGossipPayloadAttestationMessage` flow + if (clock.currentSlot !== payloadAttestationMessage.data.slot) { + throw Error( + `Message slot ${payloadAttestationMessage.data.slot} is not current slot ${clock.currentSlot}` + ); + } + + // Signature verification (matching `validateGossipPayloadAttestationMessage`) — skipped for + // bls_setting !== 1: compliance fixtures use placeholder signatures (bls_setting: 2), so the + // spec reference runner does not verify. Mirror the block-import accommodation (`validSignatures`). + if (testcase.meta?.bls_setting === BigInt(1)) { + const validatorPubkey = pubkeyCache.get(payloadAttestationMessage.validatorIndex); + if (!validatorPubkey) { + throw Error(`Unknown validator index ${payloadAttestationMessage.validatorIndex}`); + } + const signatureSet = createSingleSignatureSetFromComponents( + validatorPubkey, + getPayloadAttestationDataSigningRoot(beaconConfig, payloadAttestationMessage.data), + payloadAttestationMessage.signature + ); + let signatureValidity: boolean; + try { + signatureValidity = await chain.bls.verifySignatureSets([signatureSet], { + verifyOnMainThread: true, + batchable: true, + priority: true, + }); + } catch { + signatureValidity = false; + } + if (!signatureValidity) throw Error("Invalid payload attestation signature"); + } + + chain.forkChoice.notifyPtcMessages( + blockRoot, + payloadAttestationMessage.data.slot, + ptcIndices, + payloadAttestationMessage.data.payloadPresent, + payloadAttestationMessage.data.blobDataAvailable + ); + } + if (!isValid) throw Error("Expect error since this is a negative test"); + } catch (e) { + if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; + } + } + + // block step + else if (isBlock(step)) { + const isValid = Boolean(step.valid ?? true); + const signedBlock = testcase.blocks.get(step.block); + if (!signedBlock) { + throw Error(`No block ${step.block}`); + } + + // Post-Deneb and pre-Fulu, `columns` should not be present. Post-Fulu `blobs` and + // `proofs` should not be present. + let blobs: deneb.Blob[] | undefined; + let proofs: deneb.KZGProof[] | undefined; + let columns: fulu.DataColumnSidecar[] | undefined; + if (step.blobs !== undefined) { + blobs = testcase.blobs.get(step.blobs); + } + if (step.proofs !== undefined) { + proofs = step.proofs.map((proof) => ssz.deneb.KZGProof.deserialize(fromHex(proof))); + } + if (step.columns !== undefined) { + columns = []; + for (const columnName of step.columns) { + const column = testcase.columns.get(columnName); + if (column === undefined) { + throw Error(`Malformed spec test. Column file with name ${columnName} not found.`); + } + columns.push(column); + } + } + + const {slot} = signedBlock.message; + // Log the BeaconBlock root instead of the SignedBeaconBlock root, forkchoice references BeaconBlock roots + const blockRoot = config + .getForkTypes(signedBlock.message.slot) + .BeaconBlock.hashTreeRoot(signedBlock.message); + const blockRootHex = toHex(blockRoot); + logger.debug(`Step ${i}/${stepsLen} block`, { + slot, + id: step.block, + root: toHexString(blockRoot), + parentRoot: toHexString(signedBlock.message.parentRoot), + isValid, + }); + + try { + let blockImport; + const forkSeq = config.getForkSeq(slot); + + if (forkSeq >= ForkSeq.gloas) { + // Gloas (ePBS) blocks don't carry blobs/columns directly on the block body. + // Blob KZG commitments are nested inside signedExecutionPayloadBid. + // Use BlockInputNoData since DA is handled separately via execution payload envelopes. + blockImport = BlockInputNoData.createFromBlock({ + forkName: fork, + block: signedBlock as SignedBeaconBlock, + blockRootHex, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + daOutOfRange: false, + }); + // importBlock requires a PayloadEnvelopeInput to exist for gloas blocks; in + // production this is seeded by gossip / by-root / by-range / API producers. + // Spec tests bypass those, so seed it here to mirror the gossip-handler path. + chain.seenPayloadEnvelopeInputCache.add({ + blockRootHex, + block: signedBlock as SignedBeaconBlock, + forkName: fork, + sampledColumns: chain.custodyConfig.sampledColumns, + custodyColumns: chain.custodyConfig.custodyColumns, + timeCreatedSec: Date.now() / 1000, + }); + } else if (forkSeq >= ForkSeq.fulu) { + if (columns === undefined) { + columns = []; + } + + await validateFuluBlockDataColumnSidecars( + chain, + slot, + blockRoot, + (signedBlock as SignedBeaconBlock).message.body.blobKzgCommitments + .length, + columns, + chain.metrics?.peerDas + ); + + blockImport = BlockInputColumns.createFromBlock({ + forkName: fork, + block: signedBlock as SignedBeaconBlock, + blockRootHex, + custodyColumns: + // in most test case instances we do not want to assign any custody as there are no columns provided + // with the test case. For on_block_peerdas__not_available the exact situation that is being tested + // is no availability so block processing should fail. For this one test case add some default + // custody so that the await will fail in verifyBlocksDataAvailability.ts + testCaseName !== "on_block_peerdas__not_available" ? columns.map((c) => c.index) : [2, 4, 6, 8], + sampledColumns: + testCaseName !== "on_block_peerdas__not_available" + ? columns.map((c) => c.index) + : [2, 4, 6, 8, 10, 12, 14, 16], + source: BlockInputSource.gossip, + seenTimestampSec: 0, + daOutOfRange: false, + }); + for (const column of columns) { + blockImport.addColumn({ + blockRootHex, + columnSidecar: column, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + }); + } + // getBlockInput.availableData(config, signedBlock, BlockSource.gossip, blockData); + } else if (forkSeq >= ForkSeq.deneb && forkSeq < ForkSeq.fulu) { + if (blobs === undefined) { + // seems like some deneb tests don't have this and we are supposed to assume empty + // throw Error("Missing blobs for the deneb+ block"); + blobs = []; + } + if (proofs === undefined) { + // seems like some deneb tests don't have this and we are supposed to assume empty + // throw Error("proofs for the deneb+ block"); + proofs = []; + } + // the kzg lib for validation of minimal setup is not yet integrated, lets just verify lengths + // post integration use validateBlobsAndProofs + const commitments = (signedBlock as deneb.SignedBeaconBlock).message.body.blobKzgCommitments; + if (blobs.length !== commitments.length || proofs.length !== commitments.length) { + throw Error("Invalid blobs or proofs lengths"); + } + + const blobSidecars: deneb.BlobSidecars = blobs.map((blob, index) => { + return { + index, + blob, + kzgCommitment: commitments[index], + kzgProof: (proofs ?? [])[index], + signedBlockHeader: signedBlockToSignedHeader(config, signedBlock), + kzgCommitmentInclusionProof: computePreFuluKzgCommitmentsInclusionProof( + fork, + signedBlock.message.body, + index + ), + }; + }); + + blockImport = BlockInputBlobs.createFromBlock({ + forkName: fork, + block: signedBlock as SignedBeaconBlock, + blockRootHex, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + daOutOfRange: false, + }); + for (const blob of blobSidecars) { + blockImport.addBlob({ + blockRootHex, + blobSidecar: blob, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + }); + } + } else { + blockImport = BlockInputPreData.createFromBlock({ + forkName: fork, + block: signedBlock as SignedBeaconBlock, + blockRootHex, + source: BlockInputSource.gossip, + seenTimestampSec: 0, + daOutOfRange: false, + }); + } + + await chain.processBlock(blockImport, { + seenTimestampSec: tickTime, + validBlobSidecars: BlobSidecarValidation.Full, + importAttestations: AttestationImportOpt.Force, + validSignatures: testcase.meta?.bls_setting !== BigInt(1), + }); + if (!isValid) throw Error("Expect error since this is a negative test"); + } catch (e) { + // Runner accommodation with a known limitation: the spec re-processes a duplicate + // block (re-runs the state transition and may refresh timeliness/boost/checkpoint + // state), while lodestar's production import path rejects duplicates with + // ALREADY_KNOWN. Treat as success; a vector that relies on duplicate-block side + // effects would diverge here. + if (isValid && e instanceof BlockError && e.type.code === BlockErrorCode.ALREADY_KNOWN) { + logger.debug(`Step ${i}/${stepsLen} block already known — treating as no-op success`, { + id: step.block, + }); + } else if (isValid || (e as Error).message === "Expect error since this is a negative test") { + throw e; + } + } + } + + // execution_payload step for Gloas (ePBS) tests + else if (isExecutionPayload(step)) { + const isValid = Boolean(step.valid ?? true); + logger.debug(`Step ${i}/${stepsLen} execution_payload`, { + envelope: step.execution_payload, + valid: isValid, + }); + const envelope = testcase.executionPayloadEnvelopes.get(step.execution_payload); + if (!envelope) throw Error(`No execution payload envelope ${step.execution_payload}`); + + try { + const beaconBlockRoot = toHex(envelope.message.beaconBlockRoot); + const blockHash = toHex(envelope.message.payload.blockHash); + const blockNumber = envelope.message.payload.blockNumber; + const gasLimit = envelope.message.payload.gasLimit; + + // Verify envelope against the state + const protoBlock = chain.forkChoice.getBlockHexDefaultStatus(beaconBlockRoot); + if (!protoBlock) throw Error(`Block not found for root ${beaconBlockRoot}`); + const blockState = await chain.regen.getBlockSlotState( + protoBlock, + protoBlock.slot, + {dontTransferCache: true}, + RegenCaller.processBlock + ); + verifyExecutionPayloadEnvelope(beaconConfig, blockState as IBeaconStateViewGloas, envelope.message); + + // Verify signature — skipped for bls_setting !== 1: compliance fixtures use placeholder + // signatures (bls_setting: 2), so the spec reference runner does not verify. Mirror the + // block-import accommodation (`validSignatures` above). + if (testcase.meta?.bls_setting === BigInt(1)) { + const sigValid = await verifyExecutionPayloadEnvelopeSignature( + beaconConfig, + blockState as IBeaconStateViewGloas, + pubkeyCache, + envelope, + blockState.latestBlockHeader.proposerIndex, + chain.bls + ); + if (!sigValid) throw Error("Invalid execution payload envelope signature"); + } + + // Add predefined VALID status for the payload's block hash so the EL mock accepts it + executionEngineBackend.addPredefinedPayloadStatus(blockHash, { + status: ExecutionPayloadStatus.VALID, + latestValidHash: null, + validationError: null, + }); + + (chain.forkChoice as ForkChoice).onExecutionPayload( + beaconBlockRoot, + blockHash, + blockNumber, + gasLimit, + ExecutionStatus.Valid, + DataAvailabilityStatus.Available + ); + if (!isValid) throw Error("Expect error since this is a negative test"); + } catch (e) { + if (isValid || (e as Error).message === "Expect error since this is a negative test") throw e; + } + } + + // Optional step for optimistic sync tests. + else if (isOnPayloadInfoStep(step)) { + logger.debug(`Step ${i}/${stepsLen} payload_status`, {blockHash: step.block_hash}); + const status = ExecutionPayloadStatus[step.payload_status.status]; + if (status === undefined) { + throw Error(`Unknown payload_status.status: ${step.payload_status.status}`); + } + executionEngineBackend.addPredefinedPayloadStatus(step.block_hash, { + status, + latestValidHash: step.payload_status.latest_valid_hash, + validationError: step.payload_status.validation_error, + }); + } + + // checks step + else if (isCheck(step)) { + logger.debug(`Step ${i}/${stepsLen} check`); + + // Forkchoice head is computed lazily only on request + const head = (chain.forkChoice as ForkChoice).updateHead(); + const proposerBootRoot = (chain.forkChoice as ForkChoice).getProposerBoostRoot(); + // Spec: EMPTY=0, FULL=1, PENDING=2; Ours: PENDING=0, EMPTY=1, FULL=2 + const payloadStatusToSpec: Record = {0: 2, 1: 0, 2: 1}; + + if (step.checks.head !== undefined) { + expect({slot: head.slot, root: head.blockRoot}).toEqualWithMessage( + {slot: bnToNum(step.checks.head.slot), root: step.checks.head.root}, + `Invalid head at step ${i}` + ); + // Gloas and later: payload_status is nested inside the head check + if (step.checks.head.payload_status !== undefined) { + expect(payloadStatusToSpec[head.payloadStatus]).toEqualWithMessage( + bnToNum(step.checks.head.payload_status), + `Invalid head payload status at step ${i}` + ); + } + } + if (step.checks.proposer_boost_root !== undefined) { + expect(proposerBootRoot).toEqualWithMessage( + step.checks.proposer_boost_root, + `Invalid proposer boost root at step ${i}` + ); + } + // time in spec mapped to Slot in our forkchoice implementation. + // Compare in slots because proposer boost steps doesn't always come on + // slot boundary. + if (step.checks.time !== undefined && step.checks.time > 0) + expect(chain.forkChoice.getTime()).toEqualWithMessage( + Math.floor(bnToNum(step.checks.time) / (config.SLOT_DURATION_MS / 1000)), + `Invalid forkchoice time at step ${i}` + ); + if (step.checks.justified_checkpoint) { + expect(toSpecTestCheckpoint(chain.forkChoice.getJustifiedCheckpoint())).toEqualWithMessage( + step.checks.justified_checkpoint, + `Invalid justified checkpoint at step ${i}` + ); + } + if (step.checks.finalized_checkpoint) { + expect(toSpecTestCheckpoint(chain.forkChoice.getFinalizedCheckpoint())).toEqualWithMessage( + step.checks.finalized_checkpoint, + `Invalid finalized checkpoint at step ${i}` + ); + } + if (step.checks.get_proposer_head) { + const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); + const {proposerHead, notReorgedReason} = (chain.forkChoice as ForkChoice).getProposerHead( + head, + tickTime % (config.SLOT_DURATION_MS / 1000), + currentSlot + ); + logger.debug(`Not reorged reason ${notReorgedReason} at step ${i}`); + expect(proposerHead.blockRoot).toEqualWithMessage( + step.checks.get_proposer_head, + `Invalid proposer head at step ${i}` + ); + } + if (step.checks.viable_for_head_roots_and_weights !== undefined) { + // Entries are identified by (root, payload_status, weight). + // gloas EMPTY/FULL variants of one block root are separate entries. + // Pre-gloas vectors omit payload_status (every pre-gloas node is FULL internally). + const isGloas = ForkSeq[fork] >= ForkSeq.gloas; + const expected = step.checks.viable_for_head_roots_and_weights + .map((entry) => ({ + root: entry.root, + payloadStatus: entry.payload_status !== undefined ? bnToNum(entry.payload_status) : undefined, + weightGwei: entry.weight, + })) + .sort(cmpViableHead); + const actual = (chain.forkChoice as ForkChoice) + .getViableHeads() + .map(({root, payloadStatus, weight}) => ({ + root, + payloadStatus: isGloas ? payloadStatusToSpec[payloadStatus] : undefined, + weightGwei: BigInt(weight) * BigInt(EFFECTIVE_BALANCE_INCREMENT), + })) + .sort(cmpViableHead); + + // The set of viable heads is determined by justified/finalized epochs, not weight, + // so identity must match exactly. Comparing the full sets (not a subset) also + // rejects a degenerate empty result. + expect(actual.map(({root, payloadStatus}) => ({root, payloadStatus}))).toEqualWithMessage( + expected.map(({root, payloadStatus}) => ({root, payloadStatus})), + `Invalid viable head roots at step ${i}` + ); + + // Exact weight comparison with boost emulation. Lodestar tracks weights in + // EFFECTIVE_BALANCE_INCREMENT units: attestation weight is exact (effective + // balances are increment multiples) but the proposer-boost score is double-floored + // to whole ETH (getCommitteeFraction) while the spec keeps Gwei precision — a + // known production divergence, bounded by + // (100 - gcd(PROPOSER_SCORE_BOOST, 100) + PROPOSER_SCORE_BOOST) / 100 = 1.2 ETH. + // Rather than compare within a tolerance (which would mask sub-1.2-ETH weight + // bugs), normalize the expected weight of the boosted entry by + // `- spec_boost + lodestar_boost` and compare exactly. + // + // TODO: remove this normalization when https://github.com/ChainSafe/lodestar/issues/9694 + // is resolved. It is a workaround for the root cause: `getCommitteeFraction` + // floors in increment units instead of the spec's Gwei-precision `get_proposer_score` + // (https://github.com/ethereum/consensus-specs/blob/v1.7.0-alpha.12/specs/phase0/fork-choice.md#get_proposer_score). + // Computing the boost in Gwei (bigint) and flooring once to increments would shrink + // the divergence to <1 ETH, but exact spec weights require Gwei-precision weight + // tracking in protoarray (increments exist because Gwei-scale totals overflow 2^53). + // + // Ordering rules (so normalization cannot mask a wrong boost root): + // - `checks.proposer_boost_root` was already asserted above, unadjusted. + // - Normalize using the VECTOR's expected boost root, never our actual one. + // - Zero boost root or absent field => no adjustment. + const expectedBoostRoot = step.checks.proposer_boost_root; + let specBoostGwei = BigInt(0); + let lodestarBoostGwei = BigInt(0); + if (expectedBoostRoot !== undefined && expectedBoostRoot !== ZERO_HASH_HEX) { + const totalBalanceByIncrement = ( + chain.forkChoice as ForkChoice + ).getJustifiedTotalActiveBalanceByIncrement(); + const totalBalanceGwei = BigInt(totalBalanceByIncrement) * BigInt(EFFECTIVE_BALANCE_INCREMENT); + // Spec: ((total_active_balance // SLOTS_PER_EPOCH) * PROPOSER_SCORE_BOOST) // 100, in Gwei + specBoostGwei = + ((totalBalanceGwei / BigInt(SLOTS_PER_EPOCH)) * BigInt(config.PROPOSER_SCORE_BOOST)) / BigInt(100); + // Lodestar: same formula double-floored in increment units — use the production + // function directly so the emulation cannot drift from the implementation. + lodestarBoostGwei = + BigInt( + getCommitteeFraction(totalBalanceByIncrement, { + slotsPerEpoch: SLOTS_PER_EPOCH, + committeePercent: config.PROPOSER_SCORE_BOOST, + }) + ) * BigInt(EFFECTIVE_BALANCE_INCREMENT); + } + for (const [k, act] of actual.entries()) { + const exp = expected[k]; + // TODO GLOAS: boost attribution across payload-status variants of the boosted + // root should be handled when we set up gloas compliance test. + // Pre-gloas each root has exactly one entry. + const isBoosted = exp.root === expectedBoostRoot; + const expectedAdjusted = isBoosted + ? exp.weightGwei - specBoostGwei + lodestarBoostGwei + : exp.weightGwei; + expect(act.weightGwei).toEqualWithMessage( + expectedAdjusted, + `Invalid viable head weight for ${act.root} at step ${i}` + + (isBoosted ? ` (boost-normalized: spec=${specBoostGwei} lodestar=${lodestarBoostGwei})` : "") + ); + } + } + if (step.checks.should_override_forkchoice_update) { + const currentSlot = Math.floor(tickTime / (config.SLOT_DURATION_MS / 1000)); + const result = chain.forkChoice.shouldOverrideForkChoiceUpdate( + head, + tickTime % (config.SLOT_DURATION_MS / 1000), + currentSlot + ); + if (result.shouldOverrideFcu === false) { + logger.debug(`Not override fcu reason ${result.reason} at step ${i}`); + } + expect({result: result.shouldOverrideFcu, validator_is_connected: true}).toEqualWithMessage( + step.checks.should_override_forkchoice_update, + `Invalid should override fcu result at step ${i}` + ); + } + if (step.checks.payload_timeliness_vote) { + expect( + chain.forkChoice.getPayloadTimelinessVotes(step.checks.payload_timeliness_vote.block_root) + ).toEqualWithMessage( + step.checks.payload_timeliness_vote.votes, + `Invalid payload timeliness votes at step ${i}` + ); + } + if (step.checks.payload_data_availability_vote) { + expect( + chain.forkChoice.getPayloadDataAvailabilityVotes( + step.checks.payload_data_availability_vote.block_root + ) + ).toEqualWithMessage( + step.checks.payload_data_availability_vote.votes, + `Invalid payload data availability votes at step ${i}` + ); + } + } + + // None of the above + else { + throw Error(`Unknown step ${i}/${stepsLen}: ${JSON.stringify(Object.keys(step))}`); + } + } + } finally { + await chain.close(); + } + }, + + options: { + inputTypes: { + meta: InputType.YAML, + steps: InputType.YAML, + }, + sszTypes: { + [ANCHOR_STATE_FILE_NAME]: ssz[fork].BeaconState, + [ANCHOR_BLOCK_FILE_NAME]: ssz[fork].BeaconBlock, + [BLOCK_FILE_NAME]: ssz[fork].SignedBeaconBlock, + [BLOBS_FILE_NAME]: ssz.deneb.Blobs, + [COLUMN_FILE_NAME]: ssz.fulu.DataColumnSidecar, + [EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME]: ssz.gloas.SignedExecutionPayloadEnvelope, + [ATTESTATION_FILE_NAME]: sszTypesFor(fork).Attestation, + [ATTESTER_SLASHING_FILE_NAME]: sszTypesFor(fork).AttesterSlashing, + [PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME]: ssz.gloas.PayloadAttestationMessage, + }, + mapToTestCase: (t: Record) => { + // t has input file name as key + const blocks = new Map(); + const blobs = new Map(); + const columns = new Map(); + const executionPayloadEnvelopes = new Map(); + const attestations = new Map(); + const attesterSlashings = new Map(); + const payloadAttestationMessages = new Map(); + for (const key in t) { + if (!Object.prototype.hasOwnProperty.call(t, key)) continue; + + const blockMatch = key.match(BLOCK_FILE_NAME); + if (blockMatch) { + blocks.set(key, t[key]); + } + const blobsMatch = key.match(BLOBS_FILE_NAME); + if (blobsMatch) { + blobs.set(key, t[key]); + } + const columnMatch = key.match(COLUMN_FILE_NAME); + if (columnMatch) { + columns.set(key, t[key]); + } + const envelopeMatch = key.match(EXECUTION_PAYLOAD_ENVELOPE_FILE_NAME); + if (envelopeMatch) { + executionPayloadEnvelopes.set(key, t[key]); + } + const attMatch = key.match(ATTESTATION_FILE_NAME); + if (attMatch) { + attestations.set(key, t[key]); + } + const attesterSlashingMatch = key.match(ATTESTER_SLASHING_FILE_NAME); + if (attesterSlashingMatch) { + attesterSlashings.set(key, t[key]); + } + const payloadAttestationMessageMatch = key.match(PAYLOAD_ATTESTATION_MESSAGE_FILE_NAME); + if (payloadAttestationMessageMatch) { + payloadAttestationMessages.set(key, t[key]); + } + } + return { + meta: t["meta"] as ForkChoiceTestCase["meta"], + anchorState: t[ANCHOR_STATE_FILE_NAME] as ForkChoiceTestCase["anchorState"], + anchorBlock: t[ANCHOR_BLOCK_FILE_NAME] as ForkChoiceTestCase["anchorBlock"], + steps: t["steps"] as ForkChoiceTestCase["steps"], + blocks, + blobs, + columns, + executionPayloadEnvelopes, + attestations, + attesterSlashings, + payloadAttestationMessages, + }; + }, + // timeout needs to be set longer than BLOB_AVAILABILITY_TIMEOUT so that on_block_peerdas__not_available fails + timeout: 15000, + expectFunc: () => {}, + // Do not manually skip tests here, do it in packages/beacon-node/test/spec/presets/index.test.ts + // EXCEPTION : this test skipped here because prefix match can't be don't for this particular test + // as testId for the entire directory is same : `deneb/fork_choice/on_block/pyspec_tests` and + // we just want to skip this one particular test because we don't have minimal kzg lib integrated + // + // This skip can be removed once a kzg lib with run-time minimal blob size setup is released and + // integrated + shouldSkip: (_testcase, name, _index) => + name.includes("invalid_incorrect_proof") || + // TODO GLOAS: These tests will be unskipped by https://github.com/ChainSafe/lodestar/pull/9233 + (name.includes("gloas") && + (name.includes("simple_attempted_reorg_without_enough_ffg_votes") || + name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_current_epoch") || + name.includes("include_votes_another_empty_chain_with_enough_ffg_votes_previous_epoch") || + name.includes("include_votes_another_empty_chain_without_enough_ffg_votes_current_epoch"))), + }, + }; + }; + +function toSpecTestCheckpoint(checkpoint: CheckpointWithHex): SpecTestCheckpoint { + return { + epoch: BigInt(checkpoint.epoch), + root: checkpoint.rootHex, + }; +} + +type Step = + | OnTick + | OnAttestation + | OnAttesterSlashing + | OnPayloadAttestationMessage + | OnBlock + | OnExecutionPayloadEnvelope + | OnPayloadInfo + | Checks; + +type SpecTestCheckpoint = {epoch: bigint; root: string}; + +// This test executes steps in sequence. There may be multiple items of the following types: +// on_tick execution step + +type OnTick = { + /** to execute `on_tick(store, time)` */ + tick: bigint; + /** optional, default to `true`. */ + valid?: number; +}; + +type OnAttestation = { + /** the name of the `attestation_<32-byte-root>.ssz_snappy` file. To execute `on_attestation(store, attestation)` */ + attestation: string; + /** optional, default to `true`. */ + valid?: number; +}; + +type OnAttesterSlashing = { + /** + * the name of the `attester_slashing_<32-byte-root>.ssz_snappy` file. + * To execute `on_attester_slashing(store, attester_slashing)` with the given attester slashing. + */ + attester_slashing: string; + /** optional, default to `true` */ + valid?: number; +}; + +type OnPayloadAttestationMessage = { + /** + * the name of the `payload_attestation_message_<32-byte-root>.ssz_snappy` file. + * To execute `on_payload_attestation_message(store, payload_attestation_message)`. + */ + payload_attestation_message: string; + /** optional, default to `true` */ + valid?: number; +}; + +type OnBlock = { + /** the name of the `block_<32-byte-root>.ssz_snappy` file. To execute `on_block(store, block)` */ + block: string; + blobs?: string; + proofs?: string[]; + columns?: string[]; + /** optional, default to `true`. */ + valid?: number; +}; + +type OnExecutionPayloadEnvelope = { + /** the name of the execution_payload_envelope file */ + execution_payload: string; + /** optional, default to `true`. */ + valid?: number; +}; + +type OnPayloadInfo = { + /** Encoded 32-byte value of payload's block hash. */ + block_hash: string; + payload_status: { + status: "VALID" | "INVALID" | "SYNCING" | "ACCEPTED" | "INVALID_BLOCK_HASH"; + /** Encoded 32-byte value of the latest valid block hash, may be `null`. */ + latest_valid_hash: string; + /** Message providing additional details on the validation error, may be `null`. */ + validation_error: string; + }; +}; + +type Checks = { + /** Value in the ForkChoice store to verify it's correct after being mutated by another step */ + checks: { + head?: { + slot: bigint; + root: string; + /** Gloas and later */ + payload_status?: bigint; + }; + time?: bigint; + justified_checkpoint?: SpecTestCheckpoint; + finalized_checkpoint?: SpecTestCheckpoint; + proposer_boost_root?: RootHex; + get_proposer_head?: string; + should_override_forkchoice_update?: { + validator_is_connected: boolean; + result: boolean; + }; + /** Gloas: PTC timeliness votes per PTC position (`null` = member has not attested). */ + payload_timeliness_vote?: { + block_root: RootHex; + votes: (boolean | null)[]; + }; + /** Gloas: PTC data-availability votes per PTC position (`null` = member has not attested). */ + payload_data_availability_vote?: { + block_root: RootHex; + votes: (boolean | null)[]; + }; + viable_for_head_roots_and_weights?: {root: RootHex; weight: bigint; payload_status?: bigint}[]; + }; +}; + +/** Sort by (root, payload_status) — the spec fixes no order; gloas variants share a root. */ +function cmpViableHead(a: {root: string; payloadStatus?: number}, b: {root: string; payloadStatus?: number}): number { + return a.root.localeCompare(b.root) || (a.payloadStatus ?? 0) - (b.payloadStatus ?? 0); +} + +type ForkChoiceTestCase = { + meta?: { + description?: string; + bls_setting: bigint; + }; + anchorState: BeaconStateAllForks; + anchorBlock: BeaconBlock; + steps: Step[]; + blocks: Map; + blobs: Map; + columns: Map; + executionPayloadEnvelopes: Map; + attestations: Map; + attesterSlashings: Map; + payloadAttestationMessages: Map; +}; + +function isTick(step: Step): step is OnTick { + return (step as OnTick).tick >= 0; +} + +function isAttestation(step: Step): step is OnAttestation { + return typeof (step as OnAttestation).attestation === "string"; +} + +function isAttesterSlashing(step: Step): step is OnAttesterSlashing { + return typeof (step as OnAttesterSlashing).attester_slashing === "string"; +} + +function isPayloadAttestationMessage(step: Step): step is OnPayloadAttestationMessage { + return typeof (step as OnPayloadAttestationMessage).payload_attestation_message === "string"; +} + +function isBlock(step: Step): step is OnBlock { + return typeof (step as OnBlock).block === "string"; +} + +function isExecutionPayload(step: Step): step is OnExecutionPayloadEnvelope { + return typeof (step as OnExecutionPayloadEnvelope).execution_payload === "string"; +} + +function isOnPayloadInfoStep(step: Step): step is OnPayloadInfo { + return typeof (step as OnPayloadInfo).block_hash === "string"; +} + +function isCheck(step: Step): step is Checks { + return typeof (step as Checks).checks === "object"; +} diff --git a/packages/beacon-node/test/spec/utils/specTestIterator.ts b/packages/beacon-node/test/spec/utils/specTestIterator.ts index ff8abaa6eec9..4531c63824f8 100644 --- a/packages/beacon-node/test/spec/utils/specTestIterator.ts +++ b/packages/beacon-node/test/spec/utils/specTestIterator.ts @@ -34,6 +34,7 @@ const coveredTestRunners = [ "finality", "fork", "fork_choice", + "fork_choice_compliance", "sync", "fork", "genesis", @@ -85,10 +86,16 @@ export const defaultSkipOpts: SkipOpts = { /^gloas\/fork_choice\/on_payload_attestation_message\/.*$/, // TODO GLOAS: Unskip in #9606 /^gloas\/operations\/builder_deposit_request\/.*$/, + // TODO GLOAS: enable this after gloas fork choice is ready + /^gloas\/fork_choice_compliance\/.*/, ], skippedTests: [ // TODO-GLOAS: re-enable after gloas light client is implemented /\/gloas_fork$/, + // TODO GLOAS: Proposer-boost dependent-root gate uses stale cached head across epoch-boundary ticks; + // boost wrongly denied. Fails identically on every pre-gloas fork. + // Enable this after https://github.com/ChainSafe/lodestar/issues/9666 is resolved + /fork_choice_compliance\/block_tree_test\/pyspec_tests\/block_tree_test_16_201284350_1$/, // TODO GLOAS: Unskip in #9606 /^gloas\/operations\/builder_deposit_request\/.*$/, /\/fork_builder_deposit_followed_by_non_builder_credentials$/, @@ -196,9 +203,15 @@ export function specTestIterator( // Generic testRunner else { const {testFunction, options} = testRunner.fn(fork, testHandler, testSuite); - if (opts.skippedTests && options.shouldSkip === undefined) { - options.shouldSkip = (_testCase: any, name: string, _index: number): boolean => { - return opts?.skippedTests?.some((skippedMatch) => name.match(skippedMatch)) ?? false; + if (opts.skippedTests) { + // Compose with any runner-local shouldSkip — overwriting it would silently + // disable SkipOpts.skippedTests for runners that define their own (fork_choice). + const runnerShouldSkip = options.shouldSkip; + options.shouldSkip = (testCase: any, name: string, index: number): boolean => { + return ( + (runnerShouldSkip?.(testCase, name, index) ?? false) || + (opts.skippedTests?.some((skippedMatch) => name.match(skippedMatch)) ?? false) + ); }; } describeDirectorySpecTest(testId, testSuiteDirpath, testFunction, options); diff --git a/packages/fork-choice/src/forkChoice/forkChoice.ts b/packages/fork-choice/src/forkChoice/forkChoice.ts index 9ad6c1533bfa..49041c4d1f83 100644 --- a/packages/fork-choice/src/forkChoice/forkChoice.ts +++ b/packages/fork-choice/src/forkChoice/forkChoice.ts @@ -598,6 +598,22 @@ export class ForkChoice implements IForkChoice { return this.protoArray.nodes.filter((node) => node.bestChild === undefined); } + /** + * weight is in EFFECTIVE_BALANCE_INCREMENTS not gwei. + * For compliance test use only + */ + getViableHeads(): {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] { + return this.protoArray.getViableHeads(this.fcStore.currentSlot); + } + + /** + * The cached justified total active balance, in EFFECTIVE_BALANCE_INCREMENT units. + * For compliance test use only + */ + getJustifiedTotalActiveBalanceByIncrement(): number { + return this.fcStore.justified.totalBalance; + } + /** This is for the debug API only */ getAllNodes(): ProtoNode[] { return this.protoArray.nodes; diff --git a/packages/fork-choice/src/index.ts b/packages/fork-choice/src/index.ts index 9d5f8a54b3eb..312f245ee796 100644 --- a/packages/fork-choice/src/index.ts +++ b/packages/fork-choice/src/index.ts @@ -17,7 +17,7 @@ export { type IFastConfirmationStore, getFastConfirmationMetrics, } from "./forkChoice/fastConfirmation/fastConfirmationRule.ts"; -export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt} from "./forkChoice/forkChoice.js"; +export {ForkChoice, type ForkChoiceOpts, UpdateHeadOpt, getCommitteeFraction} from "./forkChoice/forkChoice.js"; export { type AncestorResult, AncestorStatus, diff --git a/packages/fork-choice/src/protoArray/protoArray.ts b/packages/fork-choice/src/protoArray/protoArray.ts index 81ff0a1956d4..6b76a4dfd539 100644 --- a/packages/fork-choice/src/protoArray/protoArray.ts +++ b/packages/fork-choice/src/protoArray/protoArray.ts @@ -1563,6 +1563,38 @@ export class ProtoArray { return correctJustified && correctFinalized; } + /** Weights are in EFFECTIVE_BALANCE_INCREMENT units (NOT Gwei); callers scale as needed. */ + getViableHeads(currentSlot: Slot): {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] { + // Mirror the spec's `get_filtered_block_tree`, which is rooted at the store's justified + // checkpoint: a viable head is a leaf (no viable descendant, i.e. `bestChild === undefined`) + // that descends from the justified checkpoint block AND is itself viable for head. Iterating + // all nodes without the justified-descendant filter would wrongly include FFG-viable leaves + // that hang off the finalized checkpoint on a branch not under the current justified checkpoint. + const justifiedVariant = this.getDefaultVariant(this.justifiedRoot); + // Gloas payload-status variants of one blockRoot are distinct nodes in the spec's filtered + // tree, identified by (root, payload_status, weight) — emit one entry per variant. + const heads: {root: RootHex; payloadStatus: PayloadStatus; weight: number}[] = []; + for (const node of this.nodes) { + if (node.bestChild !== undefined || !this.nodeIsViableForHead(node, currentSlot)) { + continue; + } + if (this.justifiedEpoch !== GENESIS_EPOCH) { + // Same-root short-circuit: every payload-status variant of the justified block itself is + // in the filtered tree, but `isDescendant` from the default (PENDING) variant does not + // reach the sibling EMPTY/FULL variants of the same root. + const descendsFromJustified = + node.blockRoot === this.justifiedRoot || + (justifiedVariant !== undefined && + this.isDescendant(this.justifiedRoot, justifiedVariant, node.blockRoot, node.payloadStatus)); + if (!descendsFromJustified) { + continue; + } + } + heads.push({root: node.blockRoot, payloadStatus: node.payloadStatus, weight: node.weight}); + } + return heads; + } + /** * Return `true` if `node` is equal to or a descendant of the finalized node. * This function helps improve performance of nodeIsViableForHead a lot by avoiding diff --git a/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts new file mode 100644 index 000000000000..6cf67cd99250 --- /dev/null +++ b/packages/fork-choice/test/unit/protoArray/getViableHeads.test.ts @@ -0,0 +1,105 @@ +import {describe, expect, it} from "vitest"; +import {DataAvailabilityStatus} from "@lodestar/state-transition"; +import {ExecutionStatus, PayloadStatus, ProtoArray} from "../../../src/index.js"; + +/** Block metadata shared by every test node */ +function blockFields(overrides: { + slot: number; + blockRoot: string; + parentRoot: string; + payloadStatus?: PayloadStatus; +}): Parameters[0] { + return { + slot: overrides.slot, + blockRoot: overrides.blockRoot, + parentRoot: overrides.parentRoot, + stateRoot: "0", + targetRoot: "1", + + justifiedEpoch: 0, + justifiedRoot: "0", + finalizedEpoch: 0, + finalizedRoot: "0", + unrealizedJustifiedEpoch: 0, + unrealizedJustifiedRoot: "0", + unrealizedFinalizedEpoch: 0, + unrealizedFinalizedRoot: "0", + + timeliness: false, + + ...{executionPayloadBlockHash: null, executionStatus: ExecutionStatus.PreMerge}, + dataAvailabilityStatus: DataAvailabilityStatus.PreData, + + parentBlockHash: null, + payloadStatus: overrides.payloadStatus ?? PayloadStatus.FULL, + }; +} + +function initProtoArray(): ProtoArray { + return ProtoArray.initialize( + { + ...blockFields({slot: 0, blockRoot: "1", parentRoot: "1"}), + stateRoot: "0", + } as Parameters[0], + 0 + ); +} + +describe("ProtoArray.getViableHeads", () => { + it("returns every viable leaf with increment-unit weight", () => { + const fc = initProtoArray(); + // 1 <- 2 <- 3 and 1 <- 4 (two competing leaves) + fc.onBlock(blockFields({slot: 1, blockRoot: "2", parentRoot: "1"}), 1, null); + fc.onBlock(blockFields({slot: 2, blockRoot: "3", parentRoot: "2"}), 2, null); + fc.onBlock(blockFields({slot: 1, blockRoot: "4", parentRoot: "1"}), 2, null); + + // Non-leaf "2" must be excluded; genesis "1" has viable children => excluded + fc.applyScoreChanges({ + deltas: [0, 0, 30, 12], + proposerBoost: null, + justifiedEpoch: 0, + justifiedRoot: "0", + finalizedEpoch: 0, + finalizedRoot: "0", + currentSlot: 2, + }); + + const heads = fc.getViableHeads(2).sort((a, b) => a.root.localeCompare(b.root)); + expect(heads).toEqual([ + {root: "3", payloadStatus: PayloadStatus.FULL, weight: 30}, + {root: "4", payloadStatus: PayloadStatus.FULL, weight: 12}, + ]); + }); + + it("emits gloas payload-status variants of one root as separate entries", () => { + const fc = initProtoArray(); + // A gloas block (parentBlockHash !== null) creates PENDING and EMPTY variants sharing + // blockRoot "2" at insertion + fc.onBlock( + { + ...blockFields({slot: 1, blockRoot: "2", parentRoot: "1", payloadStatus: PayloadStatus.PENDING}), + ...{ + executionPayloadBlockHash: "0xeb", + executionPayloadNumber: 1, + executionPayloadGasLimit: 30_000_000, + executionStatus: ExecutionStatus.Valid, + }, + parentBlockHash: "0xea", + }, + 1, + null + ); + + // Before the envelope arrives only the EMPTY variant is a leaf (PENDING's bestChild + // points at it, so PENDING itself is not a leaf) + expect(fc.getViableHeads(1)).toEqual([{root: "2", payloadStatus: PayloadStatus.EMPTY, weight: 0}]); + + // Revealing the payload creates the FULL variant as a sibling leaf of EMPTY + fc.onExecutionPayload("2", 1, "0xeb", 1, 30_000_000, null, ExecutionStatus.Valid, DataAvailabilityStatus.Available); + + // Every leaf variant is reported separately, never deduped by root (consensus-specs #5393) + const heads = fc.getViableHeads(1); + const variantsOfRoot2 = heads.filter((h) => h.root === "2"); + expect(variantsOfRoot2.map((h) => h.payloadStatus).sort()).toEqual([PayloadStatus.EMPTY, PayloadStatus.FULL]); + }); +}); diff --git a/spec-tests-version.json b/spec-tests-version.json index 96a969e1f642..a4142de08a75 100644 --- a/spec-tests-version.json +++ b/spec-tests-version.json @@ -16,5 +16,11 @@ "testsToDownload": [ "bls_tests_yaml" ] + }, + "comptestsSpecTests": { + "outputDirBase": "spec-tests-comptests", + "testsToDownload": [ + "comptests" + ] } } diff --git a/vitest.config.ts b/vitest.config.ts index d312ab364bca..7e173b8ebcbd 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import path from "node:path"; import {TestUserConfig, defineConfig} from "vitest/config"; import {browserTestProject} from "./configs/vitest.config.browser.js"; import {e2eMainnetProject, e2eMinimalProject} from "./configs/vitest.config.e2e.js"; -import {specProjectMainnet, specProjectMinimal} from "./configs/vitest.config.spec.js"; +import {specProjectComptest, specProjectMainnet, specProjectMinimal} from "./configs/vitest.config.spec.js"; import {typesTestProject} from "./configs/vitest.config.types.js"; import {unitTestMainnetProject, unitTestMinimalProject} from "./configs/vitest.config.unit.js"; import {esmCjsInteropPlugin} from "./scripts/vite/plugins/esmCjsInteropPlugin.js"; @@ -46,6 +46,10 @@ export default defineConfig({ extends: true, ...specProjectMainnet, }, + { + extends: true, + ...specProjectComptest, + }, { extends: true, ...typesTestProject,