Kourier Was Right

In 2021 I wanted to show a list: proposals for a DAO, with vote tallies, newest first. All of it was public and on-chain. There was no way to ask for it, because blockchains are bad at lists and this list only mattered to my app.

Hiro's API gets close. Balances, transactions, a contract's events, page by page. "All proposals across three contracts, joined to their votes" isn't an endpoint, and nobody was going to add it for me.

What I did instead:

  • fetch each contract's event pages and join them in the browser
  • poll so it feels live, until the rate limit
  • move the polling to a small server
  • watch that server miss an hour of events, with no way to ask what it missed

The other option is running a node and listening to everything it emits. That works and is a part-time job. Both roads end at the same place: rebuilding a database someone already has.

Fig. 1Two ways to get a list

poll the hosted API

consume someone else's derived views

  1. GET /contract/A/events, /B/events, /C/events, page by page
  2. join the pages in the browser
  3. poll on an interval so it feels live
  4. hit the rate limit → move polling to a little server
  5. server misses an hour of events → re-walk every page, dedupe

you are now maintaining a fragile shadow database

run your own node

capture the raw log yourself

  1. operate a Stacks node
  2. subscribe to its event firehose
  3. write ingestion, stand up Postgres
  4. handle reorgs correctly (harder than it sounds)
  5. maintain all of the above, forever

you are now a data-infrastructure team

Color key for the post: sky is the raw log, purple is a derived view. The API path consumes someone else's views; the node path captures the log yourself.

I spent the next three years at Hiro, on the other side of that API. Every serious team had done one of the two.

The proposal

May 2022, Thomas Osmonson (aulneau) publishes Project Kourier. His studio had built stacking.club and the first Gamma, so they'd built this database enough times to be precise about it.

The diagnosis: "Any sufficiently advanced application is going to have unique data requirements that a generalized API like the Hiro API won't be able to solve." The prescription is layers. Raw events at the bottom, fork history settled in the middle, every app deriving its own views on top.

Fig. 2The Kourier stack, as proposed
Hover or tap a layer.
In May 2022, only the bottom layer existed.

No repository ever appeared. I waited for someone else to build it, same as everyone.

What I built

secondlayer is that stack, running, on your hardware. An indexer sits next to a Stacks node, writes down every event raw, and never edits a row. Everything else is derived from that log into a Postgres you operate.

The list I wanted in 2021 is now one file.

export default defineSubgraph({
name: "sbtc-flows",
sources: {
registry: {
type: "print_event",
contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry",
startBlock: 328228,
},
},
schema: {
deposits: {
columns: {
request_id: { type: "uint", indexed: true },
amount: { type: "uint" },
status: { type: "text" },
},
uniqueKeys: [["request_id"]],
},
},
handlers: {
registry: async (event, ctx) => {
const v = event.value;
if (v.topic === "completed-deposit") {
ctx.upsert("deposits", { request_id: v["request-id"] }, {
amount: v.amount,
status: "completed",
});
}
},
},
});

Deploy it and the table fills itself. Backfilled from history, current with the tip, corrected when the chain replaces a recent block.

Reindex

I shipped this subgraph wrong the first time. No backfill.

[ sbtc-flows, day one ]
table 887 deposits
chain 5,321 deposits
fix: drop table · rewind to 328228 · replay

With a polling wrapper that's a lost weekend. Here it's one command and the handler doesn't change, because the raw log is still there and the table was only ever a view of it.

Fig. 3Reindex
the table said
887
deployed at the tip, no backfill
the chain had
5,321
deposits since block 328,228
after one command
5,398
and counting, reads this today
step 1 of 4
$ secondlayer subgraphs deploy sbtc-flows # ships at the tip$ secondlayer subgraphs reindex sbtc-flows # drop + reset cursorreplaying raw log from 328,228 … # adaptive batchescaught up, cursor rides the tip
The subgraph only sees new blocks, so it has consumed a sliver of the log near the tip. The chain has 5,321 deposits; the table has 887. In the polling-wrapper world, this is where the recovery plan is “re-walk a paginated API and hope.”
Step through it. Arrow keys work once the stage has focus.

Rule I keep from this: never let a handler write anything the log can't regenerate.

Three views

Once the log is the source of truth, the products are just views of it.

ViewWhat it is
Indexdecoded rows behind a REST API, on your box
Subgraphscustom views, the file above
Streamsthe log itself, read from any point or held open live, tells you where to resume
Fig. 4The stack that shipped
Hover or tap a layer.
Fig. 2, but running. Purple is what you use today; sky is the log everything above it can be rebuilt from.

Kourier's throwaway line was that all of it should be "fully open source, so anyone can run them if they choose". That's the part I took most literally.

PieceWho runs itCost
indexer, postgres, apiyou, beside your nodeMIT, free
signed archivemethe only part that costs money

docker compose up is the whole stack. The archive makes bootstrap take hours instead of a two-week sync, and you can verify what it hands you. If it vanished tomorrow, your database keeps serving.

Skipped here, separate notes:

  • what a raw event looks like on the wire
  • how the database stays honest when the chain rewrites its recent past
  • what verifying the archive actually proves

Open question: how many replays before 5k events from block 328228 gets slow enough to want snapshots. Haven't hit it yet.