New: the Free plan. Check FirstLight's timing against your own feed
All docs pages

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's block.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, delayedMessagesRead and 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.

OffsetTypeFieldMeaning
0u8version1
1u8kind4 Gap, 5 Header, 6 Lost, 7 Lead, 8 Footer
2u8flagsKind 5: bit 0 raw (the entry's JSON follows), bit 1 malformed. Zero for the other kinds.
3u80
4u320
8u64sequenceThe feed's sequence number. Kinds 4 and 6: the first sequence after the hole.
16u64received_unix_nsThe 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.
24u32payload_lenThe length of the payload in bytes.

Lead payload (kind 7, 32 bytes)

OffsetTypeField
0u8l1_kind: the L1 message kind. 3 is an L2 message.
1u80
2u16present: bit 0 l1_block_number, bit 1 sequencer_timestamp. Both are set in version 1.
4u320
8u64l1_block_number
16u64sequencer_timestamp, whole seconds
24u64published_unix_ns: the relay's clock when it made the record
OffsetTypeField
0u32tx_count: the signed transactions in the block
4u64sequencer_timestamp, whole seconds
12[u8; 32]block_hash, zero where the sequencer sent none
44u64published_unix_ns

Header payload (kind 5, 148 bytes, then the raw entry if bit 0)

OffsetTypeField
0u8l1_kind
1u80
2u16present: 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
4u32tx_count
8u64sequencer_timestamp, whole seconds
16u64l1_block_number
24u64delayed_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 from expected up 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, /headers sends one Header with the same sequence, received_unix_ns, tx_count, sequencer_timestamp and block_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_ns and received_unix_ns are 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 | head

Node.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.

Need a key? Ask on TelegramMessage @idatsy, direct to the operator. Start with a free key for the headers stream.