.md

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.

You getA table you define, plus a REST API over it
You writeOne file: sources, schema, handlers

A subgraph , , and . The ; new blocks stream in after. This one reads a pool's reserves map, so anyone can rebuild its rows.

subgraphs/pool-reserves.tsts
1import { defineSubgraph } from "@secondlayer/subgraphs";
2
3export default defineSubgraph({
4 name: "pool-reserves",
5 startBlock: 1230000,
6 sources: {
7 reserves: {
8 type: "map_set",
9 contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-vault-v2-01",
10 map: "reserve",
11 },
12 },
13 schema: {
14 reserves: {
15 columns: { token: { type: "principal" }, balance: { type: "uint" } },
16 uniqueKeys: [["token"]],
17 },
18 },
19 handlers: {
20 reserves: (event, ctx) => {
21 ctx.upsert("reserves", { token: event.key as string }, { balance: event.value as bigint });
22 },
23 },
24});

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.

~/pool-reserves
$secondlayer subgraphs deploy subgraphs/pool-reserves.ts
✓ Subgraph "pool-reserves" created → v1.0.0
verification state · pin 7f3c…a91e
unproven tx attribution of writes: event.tx, _tx_id (needs event proofs)
  • Set a start block. Without one it reindexes from genesis, and every matching event and transaction row it reads bills (what it costs).
  • The verification line is the level your sources earn; pin hashes 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:

~/pool-reservesexit 0
$secondlayer verify subgraph pool-reserves --replay --from 1230000 --to 1231000
✓ pin 7f3c…a91e handler, schema and startBlock match the deploy
✓ blocks 1,001 proven from the checkpoint (2 MARF proofs, then parent links)
✓ txs 4,812 transactions match their blocks' tx merkle roots
✓ inputs 1,001 diffs fully named; 37 writes to amm-vault-v2-01
✓ handlers 37 events, 0 handler errors, deterministic realm
✓ rows reserves: 14 equal digest 9a1f…03be

Each handler gets the matched event and a context:

On ctxFor
tx, blockThe event's transaction and block
insert, update, upsert, delete, incrementWrites
findOne, findManyReading rows back
clientRead-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:

import { defineSchema, defineSubgraph, type TypedSubgraphContext } from "@secondlayer/subgraphs";
export const schema = defineSchema({
balances: {
columns: { holder: { type: "principal" }, amount: { type: "uint" } },
uniqueKeys: [["holder"]],
},
});
function credit(ctx: TypedSubgraphContext<typeof schema>, holder: string) {
ctx.increment("balances", { holder }, { amount: 1n });
}
export default defineSubgraph({ name: "balances", schema, sources, handlers });

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.

sources: {
swaps: { type: "print_event", contractId: [ROUTER, POOL_A, POOL_B], topic: "swap" },
}

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:

sources: {
registry: { type: "print_event", contractId: REGISTRY, topic: "pool-created" },
swaps: {
type: "print_event",
topic: "swap",
factory: { from: "registry", field: "data.pool" },
},
}
  • 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.

sources: {
// every SIP-010 token transfer on-chain
tokens: { type: "ft_transfer", trait: "sip-010" },
},
traitStandard
sip-009NFT
sip-010Fungible token
sip-013Semi-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:

$secondlayer subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
$secondlayer subgraphs test subgraphs/bns-names.ts --offline # replay the cassette
  • 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:

import { buildEvent, createTestContext } from "@secondlayer/subgraphs/testing";
const ctx = createTestContext(bns.schema, { block: { height: 167_484 } });
await bns.handlers.bns!(buildEvent(bns.sources.bns, { topic: "name-register", data }), ctx);
expect(await ctx.rows("names")).toMatchInlineSnapshot();

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.

ctx.increment("balances", { holder }, { amount: -amount });
  • Tip-first deploys and backfill revisit blocks, so they refuse handlers that call increment, update, findOne or findMany.
  • 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.

WriteSystem columns
Upsert or increment an existing keyUnchanged; your columns take the last write
Delete a key, then write it againA 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.

import { readContractAt } from "@secondlayer/subgraphs";
const SIP010 = { functions: [ /* get-decimals, get-symbol … */ ] } as const;
handlers: {
transfer: async (event, ctx) => {
const token = readContractAt(ctx, contractId, SIP010, {
cache: "contract-constant",
});
const decimals = await token.read.getDecimals({}); // bigint — typed from the ABI
},
},
  • 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 contract err answer fails only that event.
Where it runsReads come fromLimits
HostedSecondlayer's node, pinned to the block, no setupAbout 3,000 reads a minute per account, 5 s per read. Over the limit, reads wait and retry
Your own boxYour node: set STACKS_NODE_RPC_URL on the processorYour 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:

sources: {
registry: {
type: "print_event",
contractId: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry",
topic: "completed-deposit",
prints: {
"completed-deposit": { bitcoinTxid: "text", amount: "uint" },
},
},
},
handlers: {
registry: (event, ctx) => {
event.data.bitcoinTxid; // string — typed, no casting
event.data.amount; // bigint
},
},

Don't guess fields: a wrong one is silently null forever. Scaffold from the contract's observed history:

$secondlayer subgraphs create sbtc-registry \
--from-contract SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry
  • One source per topic with its prints map, plus one wide table. --table-per-topic splits it.
  • secondlayer codegen prints <file> writes a .d.ts of the payload types.
  • Deploy warns when a handler reads a field never seen for its topic (sources pinned to one contract). With a declared prints map, 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.levelSourcesHandler findings
stateState writes onlyDeploy refused: 422 NONDETERMINISTIC_HANDLER, each as file:line:column
eventsEvents, prints, contract calls, map_insert, or any chain readAdvice in verification.reasons; deploys as before
noneTrait scope, or tip-firstAdvice
Refused at stateUse instead
Date, performance, timers, fetch, process, crypto, Intl, toLocaleStringValues from event, ctx.block, ctx.tx
Math.random, Math.sin and other approximated Mathbigint math; Math.abs/min/max/floor/ceil/trunc/sign/round
Non-integer or unsafe numbers in a rowbigint
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.
  • pin hashes schema, bundle, start block, network and runtime version, so a handler-only redeploy changes it even when schemaHash does not.
  • State writes come from the node's state_writes once the instance holds them from the start block, and from vm_events until 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:

LineProves
pinThe served bundle, schema and start block are the ones the pin hashes
blocksEvery block in the range, from the checkpoint
txsEach block's transactions hash to its header's tx merkle root
inputsEach block's writes are named in full, nothing hidden
handlersThe bundle runs in the deterministic realm over those writes
rowsServed rows equal the recomputed ones on declared columns and _block_height
  • _tx_id isn'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_writes can't be checked: exit 2.

--tip-first (or backfillMode: "concurrent") goes live at the tip, so rows serve in seconds while history backfills.

Handler writes withTip-first
insert, full-row upsertAllowed
update, increment, findOne, findManyRefused: 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:

~/balances
$secondlayer subgraphs status balances
Verification events · ft_transfer source "balances" needs event proofs
Status reindexing
Sync reindexing — 14.2% (1174885 / 8263594 blocks, target #8263594)
Reindex Remaining 7088709
Gaps none
Rows Indexed 4,874
Table Rows balances: 4874
Hosted
SourcesEvent filters, contract_call, contract_deploy. Anything else is 422 SOURCE_NOT_HOSTABLE, naming the source
IsolationA 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)
VisibilityPrivate. 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.

ReadWhere
Pages over REST/v1/subgraphs/<name>/<table>
A live stream…/stream, Server-Sent Events
A typed ORM schemaYour instance's Postgres, self-host only

Pages, cursors, and sorting

ParamDoes
cursorResumes from the last next_cursor
_orderasc or desc
_sortSorts by one column; the cursor adds _id as a tiebreaker
# top holders by amount, paginated
$curl "$SECONDLAYER_API_URL/v1/subgraphs/asset-holdings/holdings" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-G -d "_limit=5" -d "_sort=amount" -d "_order=desc"

Read your table

From the SDK (it reads the two variables itself) or over REST, with wildcard CORS.

$curl "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-G -d "_limit=10" -d "_order=desc"
200response.jsonjson
{
"rows": [
{
"_id": 48138,
"block_height": 8054704,
"tx_id": "0x5ee90c4f8a21d25b",
"amount": 697078828,
"sender": "SP3PE7Q9...X44J"
}
],
"next_cursor": "48137",
"tip": { "block_height": 8054704, "subgraph_height": 8054704, "blocks_behind": 0 }
}

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, jsonb columns, _offset.
  • Limits, fields, search and filters work as on every read: REST API.

Indexed and searchable columns

Column flagGives
indexed: trueAn 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.

$curl -N "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers/stream" \
-H "Authorization: Bearer $SECONDLAYER_API_KEY" \
-G -d "since=8050000"

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

const unsubscribe = subgraph.transfers.subscribe(
(row) => console.log(row),
{ where: { sender: "SP3PE7Q9...X44J" }, since: 8050000, onError: console.error },
);

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:

CREATE OR REPLACE FUNCTION notify_sbtc_transfers() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('sbtc_transfers', NEW._id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sbtc_transfers_notify
AFTER INSERT OR UPDATE ON subgraph_sbtc_flows.transfers
FOR EACH ROW EXECUTE FUNCTION notify_sbtc_transfers();
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!); // your instance's Postgres
await sql.listen("sbtc_transfers", async (id) => {
const [row] = await sql`SELECT * FROM subgraph_sbtc_flows.transfers WHERE _id = ${id}`;
if (row) await handle(row);
});
  • 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.

let cursor = await loadCursor(); // last next_cursor saved, or undefined
for (;;) {
const page = await sl.subgraphs.rows("sbtc-flows", "transfers", { cursor, order: "asc", limit: 100 });
for (const row of page.rows) await handle(row);
cursor = page.next_cursor ?? cursor;
await saveCursor(cursor);
if (page.rows.length < 100) await new Promise((resolve) => setTimeout(resolve, 5_000));
}
  • 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 tip before you move money on it.

Typed ORM schemas (codegen)

Codegen needs direct database access, so it's self-host only.

$secondlayer codegen subgraph ./subgraph.config.ts --target prisma -o prisma/schema.prisma
$npx prisma generate

--target takes prisma, drizzle or kysely. Secondlayer owns the DDL, so treat the tables as read-only: check with prisma db pull, never migrate.

Edit on GitHub