Build / Streams

Streams

The raw firehose, for when you want to decode it yourself. Most people want Index; this is what Index is built on.

Auth

Loopback needs no token; an API published past loopback needs INSTANCE_TOKEN on every read. Cold history beyond what your instance holds restores from the signed archive (archive credits).

events.consume is the checkpointed consumer with automatic reorg rewind; see Index. Reorgs rewind to the fork point and re-deliver, inclusive of the fork block; delivery is at-least-once, so keep writes idempotent. Only forks at or below your checkpoint roll back (a fork above it has nothing to undo), a rewind deeper than maxRollbackDepth (default 1000 blocks) is refused before anything is deleted, and on empty pages the loop polls reorgs.list so a fork that lands while idle at the tip is caught too, at the cost of one extra request per idle poll. A fork at genesis hands onReorg a null cursor.

For cold history, events.replay backfills from the signed bulk dumps, then tails live from the manifest's latest_finalized_cursor, no gap or dupe at the seam:

await streams.events.replay({
  from: "genesis",
  async onDumpFile(file, { from }) {
    const bytes = await streams.dumps.download(file); // sha256-verified
    await ingestParquet(bytes, { after: from }); // file-granular, at-least-once: skip rows at or below `from`
  },
  async onBatch(events, envelope) {
    await db.insertEvents(events);
    return envelope.next_cursor;
  },
});

from: "genesis" covers every window the dumps hold; the manifest's coverage reports that range, which starts where the dump program began, not chain block 1. Pass a cursor for a partial backfill.

Dumps + DuckDB

Plain parquet. Pull a verified range and query it locally:

secondlayer streams dumps --to ./dump   # sha256-verified against the signed manifest

duckdb -c "SELECT event_type, count(*) AS events
           FROM read_parquet('./dump/**/*.parquet')
           GROUP BY 1 ORDER BY 2 DESC;"
┌──────────────┬────────┐
  event_type events
├──────────────┼────────┤
 print 610417
 nft_mint 107479
 stx_transfer 104690
 ft_transfer  40294

└──────────────┴────────┘

Manifest signature is the root of trust

The bulk manifest is itself ed25519-signed and verified before any per-file sha256 is trusted. verifyDumpsManifest defaults to true (dumps.list() and events.replay() both enforce it); opt out with verifyDumpsManifest: false.

Where to run it: Deploy.

curl "http://127.0.0.1:3800/v1/streams/events?types=ft_transfer&limit=100"

Pass the response's next_cursor back as cursor. Narrow with types, not_types, contract_id, sender, recipient, asset_identifier, filters, and from_height/to_height: exact-match, and event types without a given field don't match. Full grammar: API reference.

Two concerns, one loop

A flat filter is one AND-ed set, so tracking sBTC mints and treasury spends meant two consume loops, two cursors, two checkpoints, or fetching the union and throwing half of it away. filters names each concern and the server ORs them into a single scan:

await sl.streams.events.consume({
  filters: {
    peg: { types: ["ft_transfer"], assetIdentifier: `${SBTC}::sbtc-token` },
    treasury: { types: ["stx_transfer"], sender: TREASURY },
  },
  on: {
    peg: (events) => events.map((e) => e.payload.asset_identifier),
    treasury: (events) => events.map((e) => e.payload.sender),
  },
});

Each label's declared types narrows its handler, so event.payload is typed without an event_type guard, and a label added to filters is a compile error until on handles it. A label's own events arrive in cursor order; events of different labels don't interleave within a page. Add onBatch for the whole page when you need strict global order.

One cursor covers all of it. The position is computed over the block's full event set, not the filtered one, so you can add or drop labels between restarts and keep the checkpoint.

Over REST it's one JSON query param, and each event echoes the labels it hit:

curl -G --data-urlencode 'filters={"peg":{"types":["ft_transfer"]}}' \
  "http://127.0.0.1:3800/v1/streams/events"
{ "cursor": "8054704:12", "event_type": "ft_transfer", "matched": ["peg"] }

SSE takes the same param, so subscribe narrows the same way.

Deliveries are idempotent, so retries never double-count. Fully-finalized pages are served Cache-Control: public, immutable with an ETag (304 on If-None-Match); the moving tip stays short-lived.

curl -N "http://127.0.0.1:3800/v1/streams/events/stream?types=ft_transfer"

GET /v1/streams/events/stream takes the same filters as /events, a start position via cursor (or from_cursor) or from_height, and a ping keepalive every 20s. With none it live-tails from the reorg-clamped tip. Wire contract: REST API.

Near-real-time, not instant

The server polls roughly every 1.5s from a tip held a couple of blocks back for reorg safety, so events land seconds after they appear on chain.

client.events.subscribe(...) auto-reconnects from the last delivered cursor and verifies per-frame signatures; see the SDK. Fan out to your backend instead with Subscriptions.

Per-frame signatures

Each frame body is { event, sig, key_id }; sig is an ed25519 signature over JSON.stringify(event). The SDK verifies every frame by default against the key at /public/streams/signing-key. Lenient, strict, skip, and pinned-key modes: SDK.

Dated changes land in the changelog.