Skip to content
s1ns3nz0 | Known Unknowns
Go back

Lineth L2 Transaction Lifecycle (1) - Following an L2 Transaction Through Besu and Maru

5 min read

Part of the Lineth L2 Transaction Lifecycle series. The component map is in part 0.

A transaction being accepted, included in a block, and served by an RPC node are three separate events. An RPC call that returns a transaction hash proves only the first.

I traced one block through my local Lineth L2 lab with Kubernetes logs and JSON-RPC queries. The path runs from Sequencer Besu, through Maru, to RPC Besu, and ends at a transaction receipt.

The main example is L2 block 23,851. A second example, at height 23,736, shows how Maru handles an empty proposal.

All log timestamps are UTC.

The components

The lab runs two Besu processes with different jobs, plus Maru.

ComponentRole
RPC BesuAccepts user RPC requests, keeps its own transaction pool, serves chain queries
Sequencer BesuSelects transactions from its pool, executes them, builds execution payloads
MaruRuns L2 consensus and drives block building, validation, and head updates through the Engine API

The path a transaction takes:

Signed transaction submitted
    ↓
RPC Besu: validation, local transaction pool
    ↓ P2P propagation
Sequencer Besu: selection and execution
    ↓ Engine API
Maru: proposal validation and consensus
    ↓ Engine API
RPC Besu: payload import, head update
    ↓
Receipt available

The two Besu nodes keep separate pools. A hash returned by RPC Besu doesn’t mean the sequencer has the transaction, let alone included it.

Lab helpers

A shell helper selects the local cluster and wraps RPC calls:

export KUBECONFIG="$HOME/.kube/lineth-local.yaml"
source ~/lineth-l2-gitops/scripts/labq.sh
lab_pf

It can send the same query to either node:

# RPC Besu
rpc eth_blockNumber

# Sequencer Besu
rpc eth_blockNumber '[]' seq

Both nodes are on the same L2 chain. Each answers with the head it knows locally.

The sequencer produces two candidates at one height

I searched the sequencer logs for block 23,851:

k -n lineth-l2 logs sequencer-0 --since=24h \
  | grep -E 'Produced #23,851'

The first entry is an empty candidate:

2026-10-14 03:54:13.633+0000 | vert.x-worker-thread-0 | INFO  | EngineGetPayloadV5 | Produced #23,851  (25a36.....f011f)|    0 tx | 0 ws | 0 (0.0%) gas in 0.000s | Timing(started at 2026-10-13T16:10:08.189200759Z,empty-block-created=0ms, awaitingRetrieval=0ms) | PayloadId 0x7d24a0ce2f301ce6

The second is the same candidate again:

2026-10-14 03:54:15.137+0000 | vert.x-worker-thread-0 | INFO  | EngineGetPayloadV5 | Produced #23,851  (25a36.....f011f)|    0 tx | 0 ws | 0 (0.0%) gas in 0.000s | Timing(started at 2026-10-13T16:10:08.189200759Z,empty-block-created=0ms, awaitingRetrieval=0ms) | PayloadId 0x7d24a0ce2f301ce6

Same height, same hash 25a36.....f011f, same payload ID 0x7d24a0ce2f301ce6, zero transactions. That’s one candidate retrieved twice, not two blocks.

Produced is logged by EngineGetPayloadV5, which means Besu handed out an execution payload. On its own it says nothing about whether that payload became the head.

The empty candidate’s started at points to the previous day. I haven’t checked what that field means in Besu’s code, so I leave it out of the timing below.

The third entry is a different candidate at the same height:

2026-10-14 03:54:15.643+0000 | vert.x-worker-thread-0 | INFO  | EngineGetPayloadV5 | Produced #23,851  (adcdb.....8271a)|    1 tx | 0 ws | 59,437 (0.0%) gas in 0.501s | Timing(started at 2026-10-14T03:54:15.141719011Z, duplicateWorldState=0ms, preTxsSelection=0ms, txsSelection=2ms, selectedTxsEvaluation=2ms, blockAssembled=0ms, awaitingRetrieval=498ms) | PayloadId 0x7d24a0ce2f301ce7
FieldEmpty candidateWith a transaction
Height23,85123,851
Hash25a36.....f011fadcdb.....8271a
Transactions01
Gas059,437
Payload ID suffix…1ce6…1ce7

Height is a position in the chain. Hash is the content of one block. Several candidates can compete for the same position.

The 501ms is mostly waiting

The candidate with the transaction reports:

txsSelection=2ms
selectedTxsEvaluation=2ms
awaitingRetrieval=498ms

Of the roughly 501ms, 498ms is the payload waiting to be retrieved. Calling it “501ms of execution” would be wrong. The fields cover different stages of preparing and retrieving the payload, and I wouldn’t assume they add up cleanly.

Maru rejects an empty proposal and keeps going

Height 23,736 shows what Maru does with an empty candidate:

k -n lineth-l2 logs pod/maru-0 -c maru --since=24h \
  | grep -E 'number=23736|Sequence=23736|elBlockNumber=23736'

It rejected it:

2026-10-14 03:44:52.083+0000 | qbft-event-loop-c5675ab5 | INFO  | ProposalPayloadValidator | Invalid Proposal Payload: block did not pass validation. Reason Optional[BlockValidationError(message=Block is empty number=23736 executionPayloadBlockNumber=23736 hash=0x908c44f1c922644845a9438014cf1e147c0d2dd950ca9de0d170d65e247de511)]

The lab’s Maru config doesn’t allow empty blocks:

allow-empty-blocks = false

1.4 seconds later, Maru imported a different proposal at the same height:

2026-10-14 03:44:53.514+0000 | qbft-event-loop-c5675ab5 | INFO  | QbftRound | Importing proposed block to chain. round=ConsensusRoundIdentifier{Sequence=23736, Round=1}, hash=0x2316e2c865edb01285a8b550deb2a60c63aaa3eeb52aebf253bcade7bdf52f48

And started building the next block:

2026-10-14 03:44:53.514+0000 | qbft-event-loop-c5675ab5 | INFO  | BlockBuildingBeaconBlockImporter | importing block and starting build next block: elBlockNumber=23736 elBlockTimestamp=1791949493 elNextBlockTimestamp=1791949494 beaconBlockHeader=BeaconBlockHeader(number=23736, round=1, timestamp=1791949493, proposer=Validator(address=0x083e11f14b68d5ebeb4be3e3e0237ebbc5675ab5), parentRoot=0xcd4cd32009523c0ea0a4fb561b8b561d82406f5629777b4ff0b14625f3d25862, stateRoot=0x346d4980bb777e1a697f3371abd2b84e510b13976152c24409e2aef0160b5244, bodyRoot=0x179a56368e984429e4a7b12b529020b5f640fd79283991719843cd3ffa49f827)

In Sequence=23736, Round=1, Sequence is the height and Round is the consensus attempt at that height.

So an Invalid Proposal Payload line on its own doesn’t mean the chain stopped. Check whether an import for the same sequence follows.

This is a different height from 23,851, so it shows how Maru handles proposals, not the consensus timeline of 23,851 itself. Maru’s consensus block hashes and Besu’s execution block hashes are also different identifiers. Don’t expect them to match.

RPC Besu imports the same payload

RPC Besu logged:

2026-10-14 03:54:15.652+0000 | vert.x-worker-thread-0 | INFO  | EngineNewPayloadV4 | Imported #23,851  (adcdb.....8271a)|    1 tx|  0 ws|  0 blobs|      7 wei bfee|      59,437 (  0.0%) gas used| 5.0ms exec|  11.94 Mgas/s|  1 peers

Height, hash, transaction count, and gas all match the sequencer’s second candidate. Then a fork-choice update:

2026-10-14 03:54:15.655+0000 | vert.x-worker-thread-0 | INFO  | EngineForkchoiceUpdatedV3 | FCU(VALID) | head: adcdb.....8271a | safe: 782ea.....0a22e | finalized: 782ea.....0a22e

The head is the same hash, which ties the import to the head update.

5.0ms exec is RPC Besu’s own processing time. It doesn’t cover submission, time in the pools, or propagation.

Timing across components

EventUTC
Sequencer hands out the payload03:54:15.643
RPC Besu imports it03:54:15.652
RPC Besu accepts it as head03:54:15.655

That’s 9ms from payload to import and 12ms to head update. These numbers compare clocks on different components, so they assume the clocks agree. The gap also holds whatever Maru and RPC Besu did in between, so it isn’t a network latency figure.

The block query names the transaction

Block 23,851 is 0x5d2b in hex:

rpc eth_getBlockByNumber '["0x5d2b",true]' seq \
  | jq '{
      number,
      hash,
      transactions: [
        .transactions[] |
        {hash, from, to, nonce, blockNumber}
      ]
    }'
{
  "number": "0x5d2b",
  "hash": "0xadcdb669f1e059c935e73363a23a0de36db65d87dbd63fec914e8e1ca2c8271a",
  "transactions": [
    {
      "hash": "0x2c991a9f89566e395bba83d34b885fa88151ab7313c791cdb54ca912641d8a7d",
      "from": "0xb3c150df38e91149e260c3233f3121810d4d2976",
      "to": "0xe537d669ca013d86ebef1d64e40fc74cadc91987",
      "nonce": "0x7c6",
      "blockNumber": "0x5d2b"
    }
  ]
}

The full hash matches adcdb.....8271a from both Besu logs. The one transaction went to the lab’s L2MessageService contract. Which function it called would take decoding its input; the address alone doesn’t say.

The receipt

rpc eth_getTransactionReceipt \
  '["0x2c991a9f89566e395bba83d34b885fa88151ab7313c791cdb54ca912641d8a7d"]' \
  | jq '{transactionHash, blockNumber, blockHash, status, gasUsed}'
{
  "transactionHash": "0x2c991a9f89566e395bba83d34b885fa88151ab7313c791cdb54ca912641d8a7d",
  "blockNumber": "0x5d2b",
  "blockHash": "0xadcdb669f1e059c935e73363a23a0de36db65d87dbd63fec914e8e1ca2c8271a",
  "status": "0x1",
  "gasUsed": "0xe82d"
}
FieldWhat it shows
blockNumber: 0x5d2bIncluded in block 23,851
blockHash: 0xadcdb…8271aSame block the sequencer produced and RPC Besu imported
status: 0x1The transaction succeeded
gasUsed: 0xe82d59,437 gas, same as the block logs

A valid block can still hold a failed transaction, so the import logs aren’t enough. status is what says it succeeded.

What the evidence covers

Sequencer handed out payload adcdb…8271a
    ↓
RPC Besu imported the same payload
    ↓
RPC Besu accepted it as head
    ↓
Block query named the transaction
    ↓
Receipt showed status 0x1

That proves the transaction was included on L2 and executed successfully. It stops at L2: proof generation, settlement on L1, and L1 finality are separate stages with their own evidence. The next post starts down that path: how the Coordinator groups blocks into proof work.


Share this post:

Previous Post
Lineth L2 Transaction Lifecycle (2) - From L2 Blocks to Execution Proofs: Trace and Conflation in the Coordinator
Next Post
Lineth L2 Transaction Lifecycle (0) - The Lab Architecture