Skip to content
Merged
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
58 changes: 58 additions & 0 deletions packages/api/src/beacon/routes/beacon/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,20 @@ export type Endpoints = {
EmptyMeta
>;

/**
* Publish signed execution payload bid.
* Instructs the beacon node to broadcast a signed execution payload bid to the network,
* to be gossiped for potential inclusion in block building. A success response (20x) indicates
* that the bid passed gossip validation and was successfully broadcast onto the network.
*/
publishExecutionPayloadBid: Endpoint<
"POST",
{signedExecutionPayloadBid: gloas.SignedExecutionPayloadBid},
{body: unknown; headers: {[MetaHeader.Version]: string}},
EmptyResponseData,
EmptyMeta
>;

/**
* Get signed execution payload envelope.
* Retrieves signed execution payload envelope for a given block id.
Expand Down Expand Up @@ -646,6 +660,50 @@ export function getDefinitions(config: ChainForkConfig): RouteDefinitions<Endpoi
requestWireFormat: WireFormat.ssz,
},
},
publishExecutionPayloadBid: {
url: "/eth/v1/beacon/execution_payload_bid",
method: "POST",
req: {
writeReqJson: ({signedExecutionPayloadBid}) => {
const fork = config.getForkName(signedExecutionPayloadBid.message.slot);
return {
body: getPostGloasForkTypes(fork).SignedExecutionPayloadBid.toJson(signedExecutionPayloadBid),
headers: {
[MetaHeader.Version]: fork,
},
};
},
parseReqJson: ({body, headers}) => {
const fork = toForkName(fromHeaders(headers, MetaHeader.Version));
return {
signedExecutionPayloadBid: getPostGloasForkTypes(fork).SignedExecutionPayloadBid.fromJson(body),
};
},
writeReqSsz: ({signedExecutionPayloadBid}) => {
const fork = config.getForkName(signedExecutionPayloadBid.message.slot);
return {
body: getPostGloasForkTypes(fork).SignedExecutionPayloadBid.serialize(signedExecutionPayloadBid),
headers: {
[MetaHeader.Version]: fork,
},
};
},
parseReqSsz: ({body, headers}) => {
const fork = toForkName(fromHeaders(headers, MetaHeader.Version));
return {
signedExecutionPayloadBid: getPostGloasForkTypes(fork).SignedExecutionPayloadBid.deserialize(body),
};
},
schema: {
body: Schema.Object,
headers: {[MetaHeader.Version]: Schema.String},
},
},
resp: EmptyResponseCodec,
init: {
requestWireFormat: WireFormat.ssz,
},
},
getSignedExecutionPayloadEnvelope: {
url: "/eth/v1/beacon/execution_payload_envelope/{block_id}",
method: "GET",
Expand Down
2 changes: 0 additions & 2 deletions packages/api/test/unit/beacon/oapiSpec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ const ignoredOperations = [
/* missing route */
"getDepositSnapshot", // Won't fix for now, see https://github.com/ChainSafe/lodestar/issues/5697
"getNextWithdrawals", // https://github.com/ChainSafe/lodestar/issues/5696
// TODO GLOAS: required by v5.0.0-alpha.1
"publishExecutionPayloadBid",
"getExecutionPayloadBid",
"getSignedExecutionPayloadEnvelope",
];
Expand Down
4 changes: 4 additions & 0 deletions packages/api/test/unit/beacon/testData/beacon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export const testData: GenericServerTestCases<Endpoints> = {
args: {signedExecutionPayloadEnvelope: ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue()},
res: undefined,
},
publishExecutionPayloadBid: {
args: {signedExecutionPayloadBid: ssz.gloas.SignedExecutionPayloadBid.defaultValue()},
res: undefined,
},
getSignedExecutionPayloadEnvelope: {
args: {blockId: "head"},
res: {
Expand Down
36 changes: 36 additions & 0 deletions packages/beacon-node/src/api/impl/beacon/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
ProduceFullGloas,
} from "../../../../chain/produceBlock/index.js";
import {validateGossipBlock} from "../../../../chain/validation/block.js";
import {validateApiExecutionPayloadBid} from "../../../../chain/validation/executionPayloadBid.js";
import {validateApiExecutionPayloadEnvelope} from "../../../../chain/validation/executionPayloadEnvelope.js";
import {OpSource} from "../../../../chain/validatorMonitor.js";
import {
Expand Down Expand Up @@ -825,6 +826,41 @@ export function getBeaconBlockApi({
});
},

async publishExecutionPayloadBid({signedExecutionPayloadBid}) {
const bid = signedExecutionPayloadBid.message;
const slot = bid.slot;
const fork = config.getForkName(slot);

if (!isForkPostGloas(fork)) {
throw new ApiError(400, `publishExecutionPayloadBid not supported for pre-gloas fork=${fork}`);
}

await validateApiExecutionPayloadBid(chain, signedExecutionPayloadBid);

try {
const insertOutcome = chain.executionPayloadBidPool.add(signedExecutionPayloadBid);
metrics?.opPool.executionPayloadBidPool.apiInsertOutcome.inc({insertOutcome});

@ensi321 ensi321 May 21, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think in the mainnet scenario, we probably don't need to insert to bid pool when publishing a bid because builder only bids if the proposer is not himself. Because if builder === proposer for a slot, then proposer will just self build, so no need to store in the op pool.

We only need to insert to bid pool in the cases where builder sends publish bid request to proposer's bn and they are not the same entity. For example, in devnets, eth panda ops's buildoor calls this endpoint on one of the bn participants, so inserting here is necessary. I can't think of a case like this in mainnet

} catch (e) {
chain.logger.error("Error adding to executionPayloadBid pool", {}, e as Error);
}

const sentPeers = await network.publishSignedExecutionPayloadBid(signedExecutionPayloadBid);

chain.emitter.emit(routes.events.EventType.executionPayloadBid, {
version: fork,
data: signedExecutionPayloadBid,
});

chain.logger.info("Published execution payload bid", {
slot,
builderIndex: bid.builderIndex,
blockHash: toRootHex(bid.blockHash),
parentBlockHash: toRootHex(bid.parentBlockHash),
value: bid.value,
sentPeers,
});
},

async getSignedExecutionPayloadEnvelope({blockId}, context) {
const {block, executionOptimistic, finalized} = await getBlockResponse(chain, blockId);
const slot = block.message.slot;
Expand Down
128 changes: 90 additions & 38 deletions packages/beacon-node/src/api/impl/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -914,65 +914,117 @@ export function getValidatorApi(
notWhileSyncing();
await waitForSlot(slot);

// TODO GLOAS: support producing blocks from builder bids
const source = ProducedBlockSource.engine;

// TODO GLOAS: needs to be updated after fork choice changes are merged
const parentBlock = chain.getProposerHead(slot);
const {blockRoot: parentBlockRootHex, slot: parentSlot} = parentBlock;
const parentBlockRoot = fromHex(parentBlockRootHex);
notOnOutOfRangeData(parentBlockRoot);
metrics?.blockProductionSlotDelta.set(slot - parentSlot);
metrics?.blockProductionRequests.inc({source});

const graffitiBytes = toGraffitiBytes(
graffiti ?? getDefaultGraffiti(getLodestarClientVersion(opts), chain.executionEngine.clientVersion, opts)
);

// TODO GLOAS: respect builderSelection (MaxProfit, BuilderAlways, ExecutionAlways, etc.) to let
// the user control bid source preferences and value comparison. Also add external builder api
// support when it is implemented.
const builderBid = chain.executionPayloadBidPool.getBestBid(
slot,
parentBlock.executionPayloadBlockHash,
parentBlockRootHex
);

const logCtx = {
slot,
parentSlot,
parentBlockRoot: parentBlockRootHex,
parentBlockHash: parentBlock.executionPayloadBlockHash,
fork,
...(builderBid !== null
? {
bidValue: builderBid.message.value,
builderIndex: builderBid.message.builderIndex,
bidBlockHash: toRootHex(builderBid.message.blockHash),
}
: {}),
};

const commonBlockBodyPromise = chain.produceCommonBlockBody({
slot,
parentBlock,
randaoReveal,
graffiti: graffitiBytes,
});

let timer: undefined | ((opts: {source: ProducedBlockSource}) => number);
try {
timer = metrics?.blockProductionTime.startTimer();
const {block, executionPayloadValue, consensusBlockValue} = await chain.produceBlock({
slot,
parentBlock,
randaoReveal,
graffiti: graffitiBytes,
feeRecipient,
commonBlockBodyPromise,
});
const baseAttrs = {
slot,
parentBlock,
randaoReveal,
graffiti: graffitiBytes,
feeRecipient,
commonBlockBodyPromise,
};

metrics?.blockProductionSuccess.inc({source});
metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length);
metrics?.blockProductionConsensusBlockValue.observe({source}, Number(formatWeiToEth(consensusBlockValue)));
metrics?.blockProductionExecutionPayloadValue.observe({source}, Number(formatWeiToEth(executionPayloadValue)));
metrics?.blockProductionRequests.inc({source: ProducedBlockSource.engine});
if (builderBid !== null) {
metrics?.blockProductionRequests.inc({source: ProducedBlockSource.builder});
}

const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block));
logger.verbose("Produced block", {
slot,
executionPayloadValue,
consensusBlockValue,
root: blockRoot,
});
if (chain.opts.persistProducedBlocks) {
void chain.persistBlock(block, "produced_engine_block");
const timed = <T>(source: ProducedBlockSource, fn: () => Promise<T>): Promise<T> => {
const t = metrics?.blockProductionTime.startTimer();
return fn().finally(() => t?.({source}));
};

// Always build local block. If builder bid available, also build with it in parallel and prefer it.
const [engineResult, bidResult] = await Promise.allSettled([
timed(ProducedBlockSource.engine, () => chain.produceBlock(baseAttrs)),
builderBid !== null
? timed(ProducedBlockSource.builder, () => chain.produceBlock({...baseAttrs, builderBid}))
: Promise.reject(),
]);

let bestResult: typeof engineResult | null = null;
let source: ProducedBlockSource = ProducedBlockSource.engine;
if (builderBid !== null && bidResult.status === "fulfilled") {
source = ProducedBlockSource.builder;
bestResult = bidResult;
logger.info("Selected builder bid block", logCtx);
} else if (engineResult.status === "fulfilled") {
source = ProducedBlockSource.engine;
bestResult = engineResult;
if (builderBid !== null) {
logger.warn("Builder bid block production failed, using local block", logCtx);
}
}

return {
data: block as gloas.BeaconBlock,
meta: {
version: fork,
consensusBlockValue,
},
};
} finally {
timer?.({source});
if (bestResult === null || bestResult.status !== "fulfilled") {
const engineReason = engineResult.status === "rejected" ? engineResult.reason : undefined;
const bidReason = builderBid !== null && bidResult.status === "rejected" ? bidResult.reason : undefined;
logger.error("Block production failed", {...logCtx, engineReason, bidReason});
throw Error(`Block production failed: engine=${engineReason ?? "n/a"} builder=${bidReason ?? "n/a"}`);
}

const {block, executionPayloadValue, consensusBlockValue} = bestResult.value;

metrics?.blockProductionSuccess.inc({source});
metrics?.blockProductionNumAggregated.observe({source}, block.body.attestations.length);
metrics?.blockProductionConsensusBlockValue.observe({source}, Number(formatWeiToEth(consensusBlockValue)));
metrics?.blockProductionExecutionPayloadValue.observe({source}, Number(formatWeiToEth(executionPayloadValue)));

const blockRoot = toRootHex(config.getForkTypes(slot).BeaconBlock.hashTreeRoot(block));
logger.verbose("Produced block", {
...logCtx,
executionPayloadValue,
consensusBlockValue,
root: blockRoot,
});
if (chain.opts.persistProducedBlocks) {
void chain.persistBlock(block, "produced_engine_block");
}

return {
data: block as gloas.BeaconBlock,
meta: {version: fork, consensusBlockValue},
};
},

async produceAttestationData({committeeIndex, slot}) {
Expand Down
2 changes: 2 additions & 0 deletions packages/beacon-node/src/chain/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,7 @@ export class BeaconChain implements IBeaconChain {
feeRecipient,
commonBlockBodyPromise,
parentBlock,
builderBid,
}: BlockAttributes & {commonBlockBodyPromise: Promise<CommonBlockBody>}
): Promise<{
block: AssembledBlockType<T>;
Expand Down Expand Up @@ -1078,6 +1079,7 @@ export class BeaconChain implements IBeaconChain {
proposerIndex,
proposerPubKey,
commonBlockBodyPromise,
builderBid,
}
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ type BlockRootHex = string;
type BlockHashHex = string;

/**
* Store the best execution payload bid per slot / (parent block root, parent block hash).
* Store the best signed execution payload bid per slot / (parent block root, parent block hash).
*/
export class ExecutionPayloadBidPool {
private readonly bidByParentHashByParentRootBySlot = new MapDef<
Slot,
MapDef<BlockRootHex, Map<BlockHashHex, gloas.ExecutionPayloadBid>>
>(() => new MapDef<BlockRootHex, Map<BlockHashHex, gloas.ExecutionPayloadBid>>(() => new Map()));
MapDef<BlockRootHex, Map<BlockHashHex, gloas.SignedExecutionPayloadBid>>
>(() => new MapDef<BlockRootHex, Map<BlockHashHex, gloas.SignedExecutionPayloadBid>>(() => new Map()));
private lowestPermissibleSlot = 0;

get size(): number {
Expand All @@ -31,8 +31,8 @@ export class ExecutionPayloadBidPool {
return count;
}

add(bid: gloas.ExecutionPayloadBid): InsertOutcome {
const {slot, parentBlockRoot, parentBlockHash, value} = bid;
add(bid: gloas.SignedExecutionPayloadBid): InsertOutcome {
const {slot, parentBlockRoot, parentBlockHash, value} = bid.message;
const lowestPermissibleSlot = this.lowestPermissibleSlot;

if (slot < lowestPermissibleSlot) {
Expand All @@ -45,7 +45,7 @@ export class ExecutionPayloadBidPool {
const existing = bidByParentHash.get(parentHashHex);

if (existing) {
const existingValue = existing.value;
const existingValue = existing.message.value;
const newValue = value;
if (newValue > existingValue) {
bidByParentHash.set(parentHashHex, bid);
Expand All @@ -59,14 +59,15 @@ export class ExecutionPayloadBidPool {
}

/**
* Return the highest-value bid matching slot, parent block hash, and parent block root.
* Return the highest-value signed bid matching slot, parent block hash, and parent block root.
* Used for gossip validation and block production.
*/
getBestBid(
slot: Slot,
parentBlockHash: BlockHashHex,
parentBlockHash: BlockHashHex | null,
parentBlockRoot: BlockRootHex
): gloas.ExecutionPayloadBid | null {
): gloas.SignedExecutionPayloadBid | null {
if (parentBlockHash === null) return null;
const bidByParentHash = this.bidByParentHashByParentRootBySlot.get(slot)?.get(parentBlockRoot);
return bidByParentHash?.get(parentBlockHash) ?? null;
}
Expand Down
Loading
Loading