Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Infrastructure & Operations14 min read

Driving a Base Node with the Engine API: newPayload and forkchoiceUpdated

A technical deep-dive into the OP-Stack Engine API contract that connects op-node to op-geth on Base, with runnable examples and a troubleshooting playbook.

TL;DR

The Engine API is the authenticated JSON-RPC interface that connects an OP-Stack consensus client (op-node) to an execution engine (op-geth or op-reth) on Base. It is not the public eth RPC; it runs on a local, secret port protected by JWT authentication. The three core method families are engine_newPayloadV{n} (validate and import a payload), engine_forkchoiceUpdatedV{n} (set head/safe/finalized and optionally start block building), and engine_getPayloadV{n} (retrieve a built payload). Understanding payloadStatus values (VALID, INVALID, SYNCING, ACCEPTED) and latestValidHash is essential for diagnosing sync and derivation issues. This article provides runnable examples, a results table for self-measurement, and a troubleshooting playbook.

The OP-Stack two-client architecture on Base

Base is an OP-Stack rollup. Its node software is split into two cooperating processes: a consensus/derivation layer called op-node, and an execution engine such as op-geth, op-reth, or op-erigon. The op-node derives the canonical chain from L1 data (the sequencer batches and state roots posted to Ethereum), while the execution engine maintains the EVM state, executes transactions, and serves the public eth JSON-RPC methods.

These two processes communicate over the Engine API, a separate JSON-RPC interface that runs on a local, secret port (commonly 8551) and is protected by JWT authentication. This is deliberately distinct from the public eth RPC port (commonly 8545), which is what wallets, indexers, and dApps connect to. The Engine API is an operator/cluster control plane, not a public data plane.

The Base OP Stack node sync status and the Engine API article explains how to read sync status from the op-node side. This article goes deeper: it documents the engine_* method contract itself, the payloadStatus semantics, and the sequencing that op-node follows to drive the execution engine.

  • op-node: consensus/derivation layer, derives chain from L1, drives the execution engine.
  • Execution engine (op-geth/op-reth/op-erigon): EVM state, transaction execution, public eth RPC.
  • Engine API: authenticated JSON-RPC on a local port (e.g., 8551), JWT-protected.
  • Public eth RPC: unauthenticated, serves dApps and wallets; must never expose engine_* methods.

Why engine_* methods must never be exposed publicly

The Engine API can change the canonical head, import arbitrary payloads, and start block building. If exposed on a public interface, an attacker could force the node to accept invalid payloads, reorganize the chain, or halt block production. The JWT requirement is a hard security boundary, not a convenience.

On Base, the op-node and execution engine typically run on the same host or a private network. The engine port should be bound to localhost or a private interface and protected by a firewall. The JWT secret is a 32-byte hex string shared between op-node and the execution engine, passed via a file path (e.g., --authrpc.jwtsecret).

For integrators who only need to observe the chain, the op-node's public admin RPC (opt_ and admin_ namespaces) is the read-only surface to prefer. It exposes sync status, rollup config, and derivation information without the risk of mutating the execution engine.

  • Engine API can mutate canonical head and import payloads — treat it as a control plane.
  • Bind engine port to localhost or private interface; never expose to the internet.
  • Use JWT authentication (--authrpc.jwtsecret) shared between op-node and execution engine.
  • For observation, prefer op-node admin RPC (opt_/admin_) over engine_* methods.

The three core Engine API method families and their versioning

The Engine API is defined in the Ethereum execution-apis specification. Three method families matter for driving an OP-Stack execution engine: engine_newPayloadV{n}, engine_forkchoiceUpdatedV{n}, and engine_getPayloadV{n}. The version suffix (V1, V2, V3) is pinned to the fork; the caller must use the version that matches the chain's current fork and the execution client's supported set.

engine_newPayloadV{n}(payload, expectedBlobVersionedHashes, parentBeaconBlockRoot) validates and imports an execution payload. It returns a payloadStatus object with status, latestValidHash, and validationError. On OP-Stack chains, the parentBeaconBlockRoot parameter is typically null because there is no beacon chain; the exact parameter set varies by version and client.

engine_forkchoiceUpdatedV{n}(forkchoiceState, payloadAttributes) sets the head, safe, and finalized block hashes. When payloadAttributes are present, it also starts block building and returns a payloadId. engine_getPayloadV{n}(payloadId) retrieves the built payload, which the caller then inserts via the next newPayload call.

The version suffix must match the chain's current fork. This is documented behavior that varies by fork and client version; consult the execution client's release notes and the execution-apis specification for the exact mapping.

  • engine_newPayloadV{n}: validate and import an execution payload; returns payloadStatus.
  • engine_forkchoiceUpdatedV{n}: set head/safe/finalized; with payloadAttributes, start building and return payloadId.
  • engine_getPayloadV{n}: retrieve built payload by payloadId for insertion via newPayload.
  • Version suffix (V1/V2/V3) is fork-pinned; must match chain fork and client support.

Reading a payloadStatus: VALID, INVALID, SYNCING, ACCEPTED

Every engine_newPayloadV{n} call returns a payloadStatus object. As specified in the Ethereum execution-apis Engine API specification, the status field is one of VALID, INVALID, SYNCING, or ACCEPTED, and the object also carries latestValidHash and validationError. VALID means the payload was validated and imported. INVALID means the payload failed validation; latestValidHash names the last valid ancestor so the caller can roll back to it, and validationError carries the reason. SYNCING means the execution engine is missing the parent and cannot yet validate. ACCEPTED means the payload was accepted but not fully validated (typically because the parent is unknown and the engine is optimistic).

For an INVALID result, latestValidHash is the critical field. The op-node uses it to reset its forkchoice to the last valid ancestor, discarding the invalid branch. If latestValidHash is null or zero, the engine could not determine a valid ancestor, which usually indicates a deeper state or configuration problem.

validationError is a human-readable string that often contains the exact reason: bad block hash, invalid state root, gas limit mismatch, or timestamp error. Logging this field is essential for troubleshooting derivation failures.

  • VALID: payload validated and imported.
  • INVALID: payload failed validation; latestValidHash names last valid ancestor; validationError carries reason.
  • SYNCING: execution engine missing parent; cannot validate yet.
  • ACCEPTED: payload accepted but not fully validated (optimistic).
  • latestValidHash null/zero on INVALID indicates a deeper problem.

The op-node sequencing: forkchoiceUpdated, getPayload, newPayload

When op-node builds a new block (as sequencer or during derivation), it follows a specific sequence. First, it calls engine_forkchoiceUpdatedV{n} with the current forkchoice state and payloadAttributes describing the block to build. The execution engine starts building and returns a payloadId. Second, op-node calls engine_getPayloadV{n}(payloadId) to retrieve the built payload. Third, op-node calls engine_newPayloadV{n} with that payload to validate and import it. Finally, op-node calls engine_forkchoiceUpdatedV{n} again, this time with the new head hash and no payloadAttributes, to make the block canonical.

Calling engine_getPayloadV{n} before a build has produced a payload returns an unknown-payload error. The payloadId is only valid after a forkchoiceUpdated call with payloadAttributes has initiated building. This sequencing is documented in the OP Stack Rollup Node specification.

On Base, the safe and finalized heads are derived differently than on L1. The safe head corresponds to the L1-derived chain that op-node has processed, while the finalized head corresponds to L1 finality. This interaction with L1 derivation is covered in Base OP Stack finality, safe and finalized blocks and Base OP Stack L1 derivation and timestamp.

  • Step 1: forkchoiceUpdated with payloadAttributes → payloadId.
  • Step 2: getPayload(payloadId) → built payload.
  • Step 3: newPayload(payload) → payloadStatus.
  • Step 4: forkchoiceUpdated with new head, no payloadAttributes → canonical.
  • getPayload before building returns unknown-payload error.

Runnable example: engine_forkchoiceUpdatedV3 over the authenticated port

The following Node.js example issues engine_forkchoiceUpdatedV3 with no payloadAttributes and prints the resulting status. It connects to the authenticated engine port (default 8551) using a JWT secret. This example is illustrative of the schema; run it against a node you operate with a matching JWT auth token.

The JWT secret is a 32-byte hex string. The example reads it from a file path, generates a signed token, and sends the JSON-RPC request. Replace the forkchoice state hashes with values from your own node's current head, safe, and finalized blocks.

const fs = require('fs');
const jwt = require('jsonwebtoken');
const fetch = require('node-fetch');

const JWT_SECRET = fs.readFileSync('/path/to/jwt.hex', 'utf8').trim();
const ENGINE_URL = 'http://127.0.0.1:8551';

function makeToken() {
  const payload = { iat: Math.floor(Date.now() / 1000) };
  return jwt.sign(payload, Buffer.from(JWT_SECRET, 'hex'), { algorithm: 'HS256' });
}

async function forkchoiceUpdatedV3() {
  const token = makeToken();
  const body = {
    jsonrpc: '2.0',
    id: 1,
    method: 'engine_forkchoiceUpdatedV3',
    params: [
      {
        headBlockHash: '0x...',      // replace with your current head
        safeBlockHash: '0x...',      // replace with your safe head
        finalizedBlockHash: '0x...'  // replace with your finalized head
      },
      null // no payloadAttributes: do not start building
    ]
  };
  const res = await fetch(ENGINE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  console.log(JSON.stringify(json, null, 2));
  // Expect: { result: { payloadStatus: { status: 'VALID', latestValidHash: '0x...', validationError: null }, payloadId: null } }
}

forkchoiceUpdatedV3().catch(console.error);

Runnable example: engine_newPayloadV3 JSON shape and interpretation

The following example shows the JSON shape of an engine_newPayloadV3 call and how to interpret a VALID versus INVALID payloadStatus. The payload object contains the execution payload fields: parentHash, feeRecipient, stateRoot, receiptsRoot, logsBloom, prevRandao, blockNumber, gasLimit, gasUsed, timestamp, extraData, baseFeePerGas, blockHash, and transactions. The second parameter is expectedBlobVersionedHashes (an array, often empty on OP-Stack), and the third is parentBeaconBlockRoot (null on OP-Stack).

After sending the request, inspect the payloadStatus. If status is VALID, the payload was imported. If status is INVALID, read latestValidHash to find the last valid ancestor and validationError for the reason. This example is illustrative; run it against a node you operate.

const fs = require('fs');
const jwt = require('jsonwebtoken');
const fetch = require('node-fetch');

const JWT_SECRET = fs.readFileSync('/path/to/jwt.hex', 'utf8').trim();
const ENGINE_URL = 'http://127.0.0.1:8551';

function makeToken() {
  const payload = { iat: Math.floor(Date.now() / 1000) };
  return jwt.sign(payload, Buffer.from(JWT_SECRET, 'hex'), { algorithm: 'HS256' });
}

async function newPayloadV3() {
  const token = makeToken();
  const body = {
    jsonrpc: '2.0',
    id: 1,
    method: 'engine_newPayloadV3',
    params: [
      {
        parentHash: '0x...',
        feeRecipient: '0x...',
        stateRoot: '0x...',
        receiptsRoot: '0x...',
        logsBloom: '0x...',
        prevRandao: '0x...',
        blockNumber: '0x...',
        gasLimit: '0x...',
        gasUsed: '0x...',
        timestamp: '0x...',
        extraData: '0x...',
        baseFeePerGas: '0x...',
        blockHash: '0x...',
        transactions: []
      },
      [],   // expectedBlobVersionedHashes
      null  // parentBeaconBlockRoot (null on OP-Stack)
    ]
  };
  const res = await fetch(ENGINE_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`
    },
    body: JSON.stringify(body)
  });
  const json = await res.json();
  console.log(JSON.stringify(json, null, 2));
  // If VALID: payload imported.
  // If INVALID: inspect latestValidHash and validationError.
}

newPayloadV3().catch(console.error);

Results table: measuring Engine API behavior on your own node

Because Engine API behavior varies by fork, client version, and node configuration, the most reliable approach is to measure against your own node. Use the table below to record observations. Fill it in per node, per method version, and per run. Do not rely on third-party numbers; your node's behavior is the ground truth for your deployment.

Run each engine_* method against your authenticated port and record the returned status, any latestValidHash on INVALID, and the observed head vs safe vs finalized. Repeat after a fork upgrade to confirm the method version still matches.

  • Engine method version (e.g., V3): record the exact suffix used.
  • Returned status (VALID/INVALID/SYNCING/ACCEPTED): record the payloadStatus.status.
  • latestValidHash on any INVALID: record the hash or null.
  • Observed head vs safe vs finalized: record the three hashes from your node.
  • validationError: record the string if present.
  • Client version and fork: record op-geth/op-reth version and chain fork.

Troubleshooting: common Engine API failures and their causes

Most Engine API failures fall into a few categories: authentication, version mismatch, sequencing errors, and payload validation failures. Authentication failures return HTTP 401 or a JSON-RPC error about unauthorized; check that the JWT secret file matches between op-node and the execution engine and that the token is not expired.

Version mismatch errors occur when the method suffix does not match the chain's current fork. For example, calling engine_newPayloadV2 on a chain that requires V3 returns a method-not-found or invalid-params error. Check the execution client's release notes and the execution-apis specification for the correct version.

Sequencing errors include calling engine_getPayloadV{n} before a build has produced a payload, which returns an unknown-payload error. Ensure a forkchoiceUpdated with payloadAttributes has been called first. Payload validation failures return INVALID with a validationError; read latestValidHash to roll back to the last valid ancestor.

For sync-related issues, the Base OP Stack node sync status and the Engine API article covers how to read unsafe/safe/finalized heads. For monitoring, see Monitoring RPC endpoints.

  • 401/unauthorized: JWT secret mismatch or expired token.
  • Method not found / invalid params: version suffix does not match fork.
  • Unknown payload: getPayload called before building produced a payload.
  • INVALID with validationError: read latestValidHash and validationError for cause.
  • SYNCING: execution engine missing parent; wait for sync or check L1 derivation.

Limitations and tradeoffs of the Engine API

The Engine API is authenticated and version-pinned. It is not a public indexing interface; it belongs on a node you run. The JWT requirement means you cannot simply point a public RPC client at the engine port. The version suffix must match the chain's current fork, and this mapping is documented behavior that varies by fork and client version.

Calling engine_* methods is an operator/cluster action, not a public indexing action. For observation, the op-node's public admin RPC (opt_/admin_ namespaces) is the read-only surface an integrator should prefer. It exposes sync status and derivation information without the risk of mutating the execution engine.

On Base, the safe and finalized heads are derived differently than on L1, which affects how forkchoiceUpdated interprets them. This interaction with L1 derivation is a key consideration for operators. For network-specific details, see Base.

  • Authenticated: JWT on the engine port; not for public exposure.
  • Version-pinned: method suffix must match chain fork; varies by fork and client.
  • Operator action: not a public indexing interface; run on your own node.
  • Observation: prefer op-node admin RPC (opt_/admin_) for read-only access.
  • Safe/finalized derivation on Base differs from L1; affects forkchoice semantics.

Next steps: integrating Engine API awareness into your Base operations

If you operate a Base node, ensure your op-node and execution engine are configured with matching JWT secrets and that the engine port is bound to localhost or a private interface. Use the results table to baseline your node's Engine API behavior and re-measure after fork upgrades.

For integrators who need to observe Base without running a node, the Base RPC endpoint (RPC Assistant) provides a managed public RPC surface. For infrastructure planning, see RPC pricing and API service. The OnFinality Learn hub collects related guides on OP-Stack operations, finality, and derivation.

Remember: the Engine API is a control plane. Use it to drive your own node, not to serve public traffic. For public observation, use the eth RPC or the op-node admin RPC.

  • Configure matching JWT secrets and bind engine port privately.
  • Baseline Engine API behavior with the results table; re-measure after forks.
  • Use managed RPC for public observation; see Base RPC endpoint (RPC Assistant).
  • Explore more guides on the OnFinality Learn hub.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started