Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -256,3 +256,4 @@ xcode
yaml
yamux
yml
zkvm
50 changes: 50 additions & 0 deletions packages/api/src/beacon/routes/beacon/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ArrayOf,
AttesterSlashing,
CommitteeIndex,
ExecutionProof,
SingleAttestation,
Slot,
capella,
Expand Down Expand Up @@ -37,6 +38,7 @@ const ProposerSlashingListType = ArrayOf(ssz.phase0.ProposerSlashing);
const SignedVoluntaryExitListType = ArrayOf(ssz.phase0.SignedVoluntaryExit);
const SignedBLSToExecutionChangeListType = ArrayOf(ssz.capella.SignedBLSToExecutionChange);
const SyncCommitteeMessageListType = ArrayOf(ssz.altair.SyncCommitteeMessage);
const ExecutionProofListType = ArrayOf(ssz.eip8025.ExecutionProof);

type AttestationListPhase0 = ValueOf<typeof AttestationListTypePhase0>;
type AttestationListElectra = ValueOf<typeof AttestationListTypeElectra>;
Expand All @@ -50,6 +52,7 @@ type ProposerSlashingList = ValueOf<typeof ProposerSlashingListType>;
type SignedVoluntaryExitList = ValueOf<typeof SignedVoluntaryExitListType>;
type SignedBLSToExecutionChangeList = ValueOf<typeof SignedBLSToExecutionChangeListType>;
type SyncCommitteeMessageList = ValueOf<typeof SyncCommitteeMessageListType>;
type ExecutionProofList = ValueOf<typeof ExecutionProofListType>;

export type Endpoints = {
/**
Expand Down Expand Up @@ -244,6 +247,26 @@ export type Endpoints = {
EmptyResponseData,
EmptyMeta
>;

/**
* Get ExecutionProofs from operations pool (EIP-8025)
* Retrieves execution proofs known by the node, optionally filtered by slot.
*/
getPoolExecutionProofs: Endpoint<"GET", {slot?: Slot}, {query: {slot?: number}}, ExecutionProofList, EmptyMeta>;

/**
* Submit an ExecutionProof to the node's pool (EIP-8025)
* Submits an execution proof to the beacon node.
* The proof will be validated and stored in the execution proof pool.
* If valid, the proof will be published to the gossip network.
*/
submitPoolExecutionProofs: Endpoint<
"POST",
{executionProof: ExecutionProof},
{body: unknown},
EmptyResponseData,
EmptyMeta
>;
};

export function getDefinitions(config: ChainForkConfig): RouteDefinitions<Endpoints> {
Expand Down Expand Up @@ -499,5 +522,32 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions<Endpoi
},
resp: EmptyResponseCodec,
},
getPoolExecutionProofs: {
url: "/eth/v1/beacon/pool/execution_proofs",
method: "GET",
req: {
writeReq: ({slot}) => ({query: {slot}}),
parseReq: ({query}) => ({slot: query.slot}),
schema: {query: {slot: Schema.Uint}},
},
resp: {
data: ExecutionProofListType,
meta: EmptyMetaCodec,
},
},
submitPoolExecutionProofs: {
url: "/eth/v1/beacon/pool/execution_proofs",
method: "POST",
req: {
writeReqJson: ({executionProof}) => ({body: ssz.eip8025.ExecutionProof.toJson(executionProof)}),
parseReqJson: ({body}) => ({executionProof: ssz.eip8025.ExecutionProof.fromJson(body)}),
writeReqSsz: ({executionProof}) => ({body: ssz.eip8025.ExecutionProof.serialize(executionProof)}),
parseReqSsz: ({body}) => ({executionProof: ssz.eip8025.ExecutionProof.deserialize(body)}),
schema: {
body: Schema.Object,
},
},
resp: EmptyResponseCodec,
},
};
}
8 changes: 8 additions & 0 deletions packages/api/test/unit/beacon/testData/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export const testData: GenericServerTestCases<Endpoints> = {
args: {signatures: [ssz.altair.SyncCommitteeMessage.defaultValue()]},
res: undefined,
},
getPoolExecutionProofs: {
args: {slot: 1},
res: {data: [ssz.eip8025.ExecutionProof.defaultValue()]},
},
submitPoolExecutionProofs: {
args: {executionProof: ssz.eip8025.ExecutionProof.defaultValue()},
res: undefined,
},

// state

Expand Down
41 changes: 41 additions & 0 deletions packages/beacon-node/src/api/impl/beacon/pool/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@ import {routes} from "@lodestar/api";
import {ApplicationMethods} from "@lodestar/api/server";
import {ForkPostElectra, ForkPreElectra, SYNC_COMMITTEE_SUBNET_SIZE, isForkPostElectra} from "@lodestar/params";
import {Attestation, Epoch, SingleAttestation, isElectraAttestation, ssz, sszTypesFor} from "@lodestar/types";
import {toRootHex} from "@lodestar/utils";
import {
AttestationError,
AttestationErrorCode,
GossipAction,
SyncCommitteeError,
} from "../../../../chain/errors/index.js";
import {InsertOutcome} from "../../../../chain/opPools/types.js";
import {validateApiAttesterSlashing} from "../../../../chain/validation/attesterSlashing.js";
import {validateApiBlsToExecutionChange} from "../../../../chain/validation/blsToExecutionChange.js";
import {toElectraSingleAttestation, validateApiAttestation} from "../../../../chain/validation/index.js";
Expand Down Expand Up @@ -310,5 +312,44 @@ export function getBeaconPoolApi({
throw new IndexedError("Error processing sync committee signatures", failures);
}
},

async getPoolExecutionProofs() {
// Return all proofs in the pool
return {data: chain.executionProofPool.getAllProofs()};
},

async submitPoolExecutionProofs({executionProof}) {
const newPayloadRequestRootHex = toRootHex(executionProof.publicInput.newPayloadRequestRoot);
const {proofType} = executionProof;

// Check for duplicates
if (chain.executionProofPool.has(newPayloadRequestRootHex, proofType)) {
logger.debug("Ignoring known execution proof", {newPayloadRequestRoot: newPayloadRequestRootHex, proofType});
return {};
}

// TODO EIP-8025: Add full proof verification (dummy accept for devnet)

const insertOutcome = chain.executionProofPool.add(executionProof);
logger.info("Execution proof submitted via API", {
newPayloadRequestRoot: newPayloadRequestRootHex,
proofType,
insertOutcome,
});

// Only publish to gossip if the proof was actually accepted
if (insertOutcome === InsertOutcome.NewData) {
try {
await network.publishExecutionProof(executionProof);
} catch (e) {
logger.debug("Failed to publish execution proof to gossip", {proofType}, e as Error);
}

// EIP-8025: In zkvm mode, check if we now have enough proofs to validate this block
chain.maybeTransitionToValidOnProofArrival(executionProof);
}

return {};
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import {ExecutionPayloadStatus, IExecutionEngine} from "../../execution/engine/i
import {Metrics} from "../../metrics/metrics.js";
import {IClock} from "../../util/clock.js";
import {BlockError, BlockErrorCode} from "../errors/index.js";
import {ExecutionProofPool} from "../opPools/executionProofPool.js";
import {BlockProcessOpts} from "../options.js";
import {IZkvmExecutionProofVerifier, defaultZkvmExecutionProofVerifier} from "../validation/executionProofVerifier.js";
import {isBlockInputBlobs, isBlockInputColumns, isBlockInputNoData} from "./blockInput/blockInput.js";
import {IBlockInput} from "./blockInput/types.js";
import {ImportBlockOpts} from "./types.js";
Expand All @@ -27,6 +29,14 @@ export type VerifyBlockExecutionPayloadModules = {
metrics: Metrics | null;
forkChoice: IForkChoice;
config: ChainForkConfig;
/** EIP-8025: When true, use execution proofs instead of EL for payload verification */
activateZkvm?: boolean;
/** EIP-8025: Pool of execution proofs for proof-driven verification */
executionProofPool?: ExecutionProofPool;
/** EIP-8025: Minimum distinct proof types required for a block to be considered valid */
minProofsRequired?: number;
/** EIP-8025: Verifier for execution proofs — defaults to dummy verifier if not provided */
executionProofVerifier?: IZkvmExecutionProofVerifier;
};

type ExecAbortType = {blockIndex: number; execError: BlockError};
Expand Down Expand Up @@ -136,7 +146,8 @@ export async function verifyBlocksExecutionPayload(
}

/**
* Verifies a single block execution payload by sending it to the EL client (via HTTP).
* Verifies a single block execution payload by sending it to the EL client (via HTTP),
* or via execution proof verification when in zkvm mode (EIP-8025).
*/
export async function verifyBlockExecutionPayload(
chain: VerifyBlockExecutionPayloadModules,
Expand All @@ -163,6 +174,11 @@ export async function verifyBlockExecutionPayload(
return {executionStatus: ExecutionStatus.PreMerge, lvhResponse: undefined, execError: null};
}

// EIP-8025: Proof-driven execution mode — skip EL, use execution proofs for validation
if (chain.activateZkvm) {
return verifyBlockExecutionPayloadByProof(chain, blockInput, executionPayloadEnabled);
}

// TODO: Handle better notifyNewPayload() returning error is syncing
const fork = blockInput.forkName;
const versionedHashes =
Expand Down Expand Up @@ -242,6 +258,66 @@ export async function verifyBlockExecutionPayload(
}
}

/**
* EIP-8025: Verify execution payload using execution proofs instead of EL.
*
* When activateZkvm is enabled, the beacon node skips EL newPayload calls and instead:
* - Checks if sufficient execution proofs exist in the pool for this block
* - If enough valid proofs → payload considered Valid (propagates in fork choice)
* - If not enough proofs → payload imported optimistically (Syncing status)
*
* Blocks are never rejected due to missing proofs — they are imported optimistically.
* Proofs arriving later via gossip will trigger fork choice updates to mark blocks valid.
* This matches the Lighthouse approach where blocks without proofs get Syncing status.
*/
function verifyBlockExecutionPayloadByProof(
chain: VerifyBlockExecutionPayloadModules,
blockInput: IBlockInput,
executionPayload: bellatrix.ExecutionPayload
): VerifyBlockExecutionResponse {
const blockRootHex = blockInput.blockRootHex;
const execBlockHash = toRootHex(executionPayload.blockHash);
const minRequired = chain.minProofsRequired ?? 1;

const logCtx = {
slot: blockInput.slot,
blockRoot: blockRootHex,
executionBlockHash: execBlockHash,
executionBlock: executionPayload.blockNumber,
};

const proofs = chain.executionProofPool?.getAllProofs() ?? [];
const verifier = chain.executionProofVerifier ?? defaultZkvmExecutionProofVerifier;
const verification = verifier.verifyProofs({
proofs,
minProofsRequired: minRequired,
});

if (!verification.ok) {
// Not enough proofs yet — import optimistically (Syncing).
// Proofs arriving later via gossip will call forkChoice.validateLatestHash()
// to transition the block from Syncing → Valid.
chain.logger.debug("Importing block optimistically, insufficient execution proofs (zkvm mode)", {
...logCtx,
reason: verification.error,
proofsAvailable: proofs.length,
minRequired,
});

return {executionStatus: ExecutionStatus.Syncing, execError: null};
}

chain.logger.debug("Execution payload verified by zkvm proofs (dummy verifier)", {
...logCtx,
distinctProofTypes: verification.distinctProofTypes,
minRequired,
});

const executionStatus = ExecutionStatus.Valid as const;
const lvhResponse: LVHValidResponse = {executionStatus, latestValidExecHash: execBlockHash};
return {executionStatus, lvhResponse, execError: null};
}

function getSegmentErrorResponse(
{verifyResponse, blockIndex}: {verifyResponse: VerifyExecutionErrorResponse; blockIndex: number},
parentBlock: ProtoBlock,
Expand Down
Loading