Block headers on /headers
The /headers stream sends up to three binary records per Robinhood Chain block: a Lead as the block starts, a Footer as it completes, and a Header with the block's L1 message header. It is the free way to verify FirstLight's timing from your own host.
Plan
/headers is the Free plan. Ask for a free key on Telegram.
Timing
A block's Lead record arrives at about the time of the block's first raw transaction on the transactions stream (Ultra); which of the two a reader receives first depends on the network. Its Footer arrives within about 0.1 ms of the complete block on the feed (Pro). A block can have no Lead.
Endpoint
wss://headers.first-light.stream/{KEY}The host selects the stream, and the key is the whole path. The key must name the headers stream. See keys.
Three records per block
- Lead (kind 7)
- Sent as the block starts, at about the time of the block's first raw transaction on the transactions stream. It gives the L1 kind, the L1 block number and the timestamp. A transaction reads the timestamp as
block.timestamp, and the L1 block number as Arbitrum'sblock.number. - Footer (kind 8)
- Sent as the block completes, within about 0.1 ms of the complete block on the feed. Its first 44 payload bytes are the same as the EntryComplete record of
/tx. - Header (kind 5)
- Sent when the block's sequencer message is complete. It holds the L1 message header,
delayedMessagesReadand the block hash that the sequencer claims. For a block that is not only signed transactions, it also holds the entry's JSON, verbatim.
For each sequence, the order is Lead, Footer, Header, where each exists. A block can have no Lead. Each block that the feed completes has one Header, or a Lost record covers it.
The record
Each record is one binary WebSocket frame, never fragmented, little-endian. The 28-byte header is the same as on /tx, so one reader serves both streams.
| Offset | Type | Field | Meaning |
|---|---|---|---|
| 0 | u8 | version | 1 |
| 1 | u8 | kind | 4 Gap, 5 Header, 6 Lost, 7 Lead, 8 Footer |
| 2 | u8 | flags | Kind 5: bit 0 raw (the entry's JSON follows), bit 1 malformed. Zero for the other kinds. |
| 3 | u8 | 0 | |
| 4 | u32 | 0 | |
| 8 | u64 | sequence | The feed's sequence number. Kinds 4 and 6: the first sequence after the hole. |
| 16 | u64 | received_unix_ns | The relay's stamp in nanoseconds. Kind 5: the read that completed the message. Kind 7: the read that carried the timestamp. Kind 8: the same as its EntryComplete. 0 for a malformed entry and for kinds 4 and 6. |
| 24 | u32 | payload_len | The length of the payload in bytes. |
Lead payload (kind 7, 32 bytes)
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | l1_kind: the L1 message kind. 3 is an L2 message. |
| 1 | u8 | 0 |
| 2 | u16 | present: bit 0 l1_block_number, bit 1 sequencer_timestamp. Both are set in version 1. |
| 4 | u32 | 0 |
| 8 | u64 | l1_block_number |
| 16 | u64 | sequencer_timestamp, whole seconds |
| 24 | u64 | published_unix_ns: the relay's clock when it made the record |
Footer payload (kind 8, 52 bytes)
| Offset | Type | Field |
|---|---|---|
| 0 | u32 | tx_count: the signed transactions in the block |
| 4 | u64 | sequencer_timestamp, whole seconds |
| 12 | [u8; 32] | block_hash, zero where the sequencer sent none |
| 44 | u64 | published_unix_ns |
Header payload (kind 5, 148 bytes, then the raw entry if bit 0)
| Offset | Type | Field |
|---|---|---|
| 0 | u8 | l1_kind |
| 1 | u8 | 0 |
| 2 | u16 | present: bit 0 block_hash, bit 1 poster, bit 2 l1_block_number, bit 3 l1_base_fee, bit 4 request_id, bit 5 delayed_messages_read |
| 4 | u32 | tx_count |
| 8 | u64 | sequencer_timestamp, whole seconds |
| 16 | u64 | l1_block_number |
| 24 | u64 | delayed_messages_read |
| 32 | [u8; 20] | poster: the header's sender |
| 52 | [u8; 32] | l1_base_fee, big-endian, as an EVM word |
| 84 | [u8; 32] | request_id |
| 116 | [u8; 32] | block_hash |
| 148 | [u8] | If bit 0: the entry's JSON object, from { to }, as the sequencer sent it |
A field whose present bit is clear is zero bytes. The raw entry follows for every block that is not only signed transactions (deposits, retryables, batch posting reports), for a malformed entry, and where the fixed fields cannot hold the entry. The raw entry is the sequencer's own bytes, so its signatureV2 verifies.
Gap and Lost
- Kind 4, Gap
- 8 bytes:
u64 expected. The relay never saw the sequences fromexpectedup to, but not including,sequence. - Kind 6, Lost
- 9 bytes:
u64 expected,u8 cause. The relay made no Header for these sequences. Read the feed if you need them. A cause that you do not know still means that the sequences are not coming.
What you can rely on
- For each EntryComplete on
/tx,/headerssends one Header with the samesequence,received_unix_ns,tx_count,sequencer_timestampandblock_hash, or a Lost record covers that sequence. - Header sequences increase by one, unless a Gap or a Lost comes between two of them. A Lost with cause 1 means that the relay's queue was full.
- Leads of different sequences usually come in increasing order. Key your state by sequence.
- A Lead's sequence can fail to complete. A Gap, a Lost or a malformed Header then covers it.
- A new connection receives the records of blocks that complete after it connected. Its first record can be any kind. If your first record is a Lead and the next records belong to another sequence, treat that Lead's sequence as not completed for you.
published_unix_nsandreceived_unix_nsare on the relay's clock, not on yours. Use your own clock to measure arrival.- A Header is not an executed block header. It is the L1 message header that the block is built from, and the hash that the sequencer claims.
Response headers
The 101 answer carries X-Rhc-Record-Version: 1, X-Rhc-Records: headers, X-Rhc-Record-Kinds: 4,5,6,7,8 and X-Rhc-Relay-Epoch. Check the first two and the version byte of each record. Skip a kind that you do not know, by payload_len.
Connect
websocat
websocat -b 'wss://headers.first-light.stream/{KEY}' | xxd | headNode.js: time each block
import WebSocket from "ws";
const ws = new WebSocket(`wss://headers.first-light.stream/${process.env.KEY}`);
ws.on("upgrade", (res) => {
if (res.headers["x-rhc-records"] !== "headers") throw new Error("not /headers");
});
ws.on("message", (b, isBinary) => {
if (!isBinary || b[0] !== 1) return;
const now = process.hrtime.bigint(); // your clock, not the relay's
const kind = b[1];
const sequence = b.readBigUInt64LE(8);
const p = b.subarray(28, 28 + b.readUInt32LE(24));
if (kind === 7) {
console.log("lead", sequence, "l1", p.readBigUInt64LE(8), "ts", p.readBigUInt64LE(16), now);
} else if (kind === 8) {
console.log("footer", sequence, "txs", p.readUInt32LE(0), now);
} else if (kind === 5) {
console.log("header", sequence, "hash", p.subarray(116, 148).toString("hex"), now);
} else if (kind === 4 || kind === 6) {
console.log("hole", p.readBigUInt64LE(0), "to", sequence);
}
});To compare FirstLight with another feed, run both clients on one host and stamp both with one clock. See how FirstLight is measured.
Limits and errors
The /headers stream has the same refusals, connection limits, pings and close codes as the feed. It does not negotiate permessage-deflate. A client that does not read fast enough is closed with code 1013. On a reconnect, you start with the blocks that complete next.