Subgraphs
One TypeScript file, and you get a table plus a REST API over it. You don't pick the API shape; if you need your own, use Index.
A subgraph , , and . The ; new blocks stream in after. This one reads a pool's reserves map, so anyone can rebuild its rows.
8State writes only, so the rows can be recomputed.
21Same input, same row, on every run.
Accumulating a value, reading contract state, and typing a print payload each have a rule worth knowing: Handlers.
Hosted is the default: one private stack per account, nothing to operate. Create a key at /account/keys, set the two variables from the Introduction, and deploy with CLI 19.1 or later.
- Set a start block. Without one it reindexes from genesis, and every matching event and transaction row it reads bills (what it costs).
- The
verificationline is the level your sources earn;pinhashes what you deployed. See Rows anyone can recompute.
Recompute the rows on your machine from proven blocks and compare them with what the API serves:
Each handler gets the matched event and a context:
On ctx | For |
|---|---|
tx, block | The event's transaction and block |
insert, update, upsert, delete, increment | Writes |
findOne, findMany | Reading rows back |
client | Read-only contract calls (Reading the chain) |
A contract_call source with a function name gets event.input typed from the contract's ABI, and deploy fetches the ABI for you. Pass the ABI yourself for wildcard, multi-contract or trait sources, and for local deploys.
Map and variable write sources get typed, decoded keys and values when you pass an abi and name the map or variable. Without one they stay unknown.
Pull the schema out with defineSchema() and your helpers stay fully typed:
With only the definition in scope, InferContext<typeof def> gives the same ctx.
A fixed set is one source: contractId takes an array, so a router and its pools share one handler.
A print source also takes where, so the handler only runs for prints that carry your values.
A set that grows (new pools, launchpad tokens, registry entries) uses a factory, which reads addresses from another source's events:
- A contract discovered in block N gets its own block-N events.
- A reorg rolls the discovered set back, so an address from an orphaned fork stops matching.
trait matches every contract Secondlayer classifies as that standard, including ones deployed after you ship.
trait | Standard |
|---|---|
sip-009 | NFT |
sip-010 | Fungible token |
sip-013 | Semi-fungible token |
A reindex backfills each contract from its deploy block and bills the rows it reads. To list the set, see Find contracts by standard.
Real chain events, your local handler code, no deploy:
- The first run records a cassette, so later runs are free and offline.
- Changing a source filter discards the cassette instead of passing on stale data.
- If events arrive and no rows are written, the test fails. That's the mapping bug that ships an empty subgraph.
For unit tests, @secondlayer/subgraphs/testing gives you the runtime's context, backed by memory:
Chain reads are stubbed: pass reads: { "<contract>.<function-name>": value } to createTestContext. An unstubbed read throws and names the key it wanted.
A handler turns one matched event into rows. Three things behave differently than they look.
Counters and balances: use increment
ctx.increment applies a delta in one atomic statement, upserting on the table's unique keys.
- Tip-first deploys and
backfillrevisit blocks, so they refuse handlers that callincrement,update,findOneorfindMany. - Those handlers deploy with the default blocking backfill and rebuild with
reindex.
Why not read, compute, upsert
It looks equivalent and isn't. Two events on the same row in one block both read the stale value, and the second write wins. Deltas commute within a block, and a reorg reverses them cleanly.
System columns
Every row carries _block_height and _tx_id: the block and transaction that created it.
| Write | System columns |
|---|---|
| Upsert or increment an existing key | Unchanged; your columns take the last write |
| Delete a key, then write it again | A new row, with the new writer's block and transaction |
Reading the chain from a handler
Some facts live in contract state, not events: a token's decimals, a pool's reserves, an NFT's metadata URI. ctx.client reads them with read-only calls.
- Every read is pinned to the block being processed, so a reindex gives byte-identical rows.
- A block whose id was never stored throws instead of reading the node's tip.
- A read that can't complete (no node, node down) halts the subgraph at that block with status
error, instead of skipping the event. A contracterranswer fails only that event.
| Where it runs | Reads come from | Limits |
|---|---|---|
| Hosted | Secondlayer's node, pinned to the block, no setup | About 3,000 reads a minute per account, 5 s per read. Over the limit, reads wait and retry |
| Your own box | Your node: set STACKS_NODE_RPC_URL on the processor | Your node's |
Declare what can't change
Reads cache per block. For values that never change, cache: "contract-constant" fetches once, ever, which makes a full backfill affordable. It's opt-in: on a value that does change, it pins the first answer forever.
Typed print payloads
Clarity ABIs don't describe print payloads, so a prints map types event.data per topic:
Don't guess fields: a wrong one is silently null forever. Scaffold from the contract's observed history:
- One source per topic with its
printsmap, plus one wide table.--table-per-topicsplits it. secondlayer codegen prints <file>writes a.d.tsof the payload types.- Deploy warns when a handler reads a field never seen for its topic (sources pinned to one contract). With a declared
printsmap, that's an error and the deploy is refused.
The scaffold reads the print-schema endpoint.
Rows anyone can recompute
A subgraph whose sources are all state writes can be rebuilt from block headers alone, so its handlers must give the same rows every run. The level comes from your sources and a scan of the bundle; you don't set it.
verification.level | Sources | Handler findings |
|---|---|---|
state | State writes only | Deploy refused: 422 NONDETERMINISTIC_HANDLER, each as file:line:column |
events | Events, prints, contract calls, map_insert, or any chain read | Advice in verification.reasons; deploys as before |
none | Trait scope, or tip-first | Advice |
Refused at state | Use instead |
|---|---|
Date, performance, timers, fetch, process, crypto, Intl, toLocaleString | Values from event, ctx.block, ctx.tx |
Math.random, Math.sin and other approximated Math | bigint math; Math.abs/min/max/floor/ceil/trunc/sign/round |
| Non-integer or unsafe numbers in a row | bigint |
findOne calls in parallel (Promise.all) | Await each read in turn |
- The same list throws at runtime, and the block retries instead of skipping the event.
- Most event subgraphs stay advisory until event proofs ship.
pinhashes schema, bundle, start block, network and runtime version, so a handler-only redeploy changes it even whenschemaHashdoes not.- State writes come from the node's
state_writesonce the instance holds them from the start block, and fromvm_eventsuntil then. Nothing to configure.
Check rows
Recompute a state subgraph and compare it with the served rows, as in Check it. Each line proves one link:
| Line | Proves |
|---|---|
pin | The served bundle, schema and start block are the ones the pin hashes |
blocks | Every block in the range, from the checkpoint |
txs | Each block's transactions hash to its header's tx merkle root |
inputs | Each block's writes are named in full, nothing hidden |
handlers | The bundle runs in the deterministic realm over those writes |
rows | Served rows equal the recomputed ones on declared columns and _block_height |
_tx_idisn't compared: which transaction made a write is unproven, as are writes overwritten within one block.- Starting after the start block is inconclusive (exit
2) once a handler reads or merges earlier rows. - Reads are free. Witnesses run at 2 per second, so 1,000 blocks take 8 minutes or more.
- A node that doesn't deliver
state_writescan't be checked: exit2.
--tip-first (or backfillMode: "concurrent") goes live at the tip, so rows serve in seconds while history backfills.
| Handler writes with | Tip-first |
|---|---|
insert, full-row upsert | Allowed |
update, increment, findOne, findMany | Refused: TIP_FIRST_NON_REPLAYABLE_HANDLER |
Refused handlers deploy with the default blocking backfill. The same rule applies to secondlayer subgraphs backfill; rebuild with reindex.
The table serves reads while the backfill drains:
| Hosted | |
|---|---|
| Sources | Event filters, contract_call, contract_deploy. Anything else is 422 SOURCE_NOT_HOSTABLE, naming the source |
| Isolation | A gVisor sandbox with 512 MB, reaching only Secondlayer's API and the public internet |
Contract reads (ctx.client) | Yes, pinned to the block, rate limited per account (details) |
| Visibility | Private. No public catalog, fork or marketplace |
A processor that stalls or runs out of memory restarts. After 3 deaths at one height the subgraph is marked error: fix the handler and redeploy. Your own box runs any source.
| Read | Where |
|---|---|
| Pages over REST | /v1/subgraphs/<name>/<table> |
| A live stream | …/stream, Server-Sent Events |
| A typed ORM schema | Your instance's Postgres, self-host only |
Pages, cursors, and sorting
| Param | Does |
|---|---|
cursor | Resumes from the last next_cursor |
_order | asc or desc |
_sort | Sorts by one column; the cursor adds _id as a tiebreaker |
Read your table
From the SDK (it reads the two variables itself) or over REST, with wildcard CORS.
Cursor and sort rules
- Don't hand-build a sorted cursor. Replaying it under another sort or order is a
400. - Also
400: sorting by several columns,jsonbcolumns,_offset. - Limits, fields, search and filters work as on every read: REST API.
Indexed and searchable columns
| Column flag | Gives |
|---|---|
indexed: true | An index in page order, so deep sorted pages stay fast. Existing tables pick it up on reindex. |
search: true | _search on that column. With none marked, _search is a 400. |
Open reads
Reads send your key as a bearer token, like Index and Streams. There's no per-subgraph visibility flag: hosted subgraphs are private to your account, and on your own box your proxy decides who can reach it (instance token). Every route: API reference.
Stream rows in real time
Tail a table from the tip, or from since to replay a height first.
subscribe wraps it with the same filters and returns an unsubscribe. It needs a global EventSource: browsers, or Node 22 and up. Wire contract: Streaming (SSE).
React to your rows
Webhooks fire on chain events. To act on your own rows, watch the table.
On your own box. Tables live in schema subgraph_<name>, dashes as underscores. A trigger tells your app when a block's rows commit:
- You never act on a row that rolled back: the notification goes out on commit.
- Send the id, not the row; a payload is capped at 8,000 bytes.
- Nothing listening means the notification is gone. On start, read past the last id you handled, then listen.
- A reindex or destructive redeploy drops your trigger. Create it from the script that deploys.
Hosted. Read over the API: poll, and keep the last next_cursor.
- is null only on an empty page, so falling back to the old cursor keeps your place.
- A reorg rewrites rows under new ids. Wait until a row is a few blocks behind
tipbefore you move money on it.
Typed ORM schemas (codegen)
Codegen needs direct database access, so it's self-host only.
--target takes prisma, drizzle or kysely. Secondlayer owns the DDL, so treat the tables as read-only: check with prisma db pull, never migrate.