Writing handlers
A handler turns one matched event into rows. Accumulating a value, reading contract state, and typing a print payload each behave differently than they look.
ctx.increment(table, key, deltas) applies a delta in one atomic
statement (upserting the row on the table's uniqueKeys). Reach for it
whenever a value accumulates:
ctx.increment("balances", { holder }, { amount: -amount });The read-modify-write it replaces (findOne → compute → upsert) looks
equivalent and isn't. Two events touching the same row in one block both read
the stale value and the second write wins, so updates are silently lost. It is
also not replay-safe: under backfillMode: "concurrent" the history fill
revisits blocks, and a non-commutative handler double-counts. Deltas commute,
so order doesn't matter, and a reorg rewind reverses them cleanly.
Some facts aren't in any event. A token's decimals, a pool's reserves, an
NFT's metadata URI: those live in contract state, and without them amount is
a bare integer nobody can render. ctx.client calls read-only functions for
you:
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
},
},Same shape as getContract, minus call: a handler indexes the
chain, it never writes to it.
Every read is pinned to the block being processed. The call carries that
block's index_block_hash, so reprocessing the same blocks produces
byte-identical rows. A block whose id was never persisted throws rather
than falling back to the node's tip, which would make the same reindex return
different values.
Declare what can't change
Results are cached in Postgres, keyed by block, which dedupes reads across
events but still costs one call per block. For values that genuinely cannot
change, pass cache: "contract-constant" and it is fetched once, ever.
That is what makes a full backfill affordable. Declaring it on a value that
does change pins the first answer forever, so it is opt-in and never inferred.
Self-hosting: set STACKS_NODE_RPC_URL on the subgraph processor, pointing at
the node you already run rather than a new service. See
self-host.
Clarity ABIs don't describe print payloads, so event.data is untyped. A prints map types it, discriminated on event.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 print fields
Payload shape varies per topic, and a guessed field silently nulls forever. Scaffold from the contract's observed history instead.
secondlayer subgraphs create sbtc-registry \
--from-contract SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registryScaffolds from the print-schema endpoint: one print_event source per topic with its prints map, plus one wide table (--table-per-topic splits it per topic). secondlayer codegen prints <file> emits a .d.ts of the payload types.
Deploys return advisory warnings when a handler reads an event.data field never observed for its topic (pinned contractId sources only). If the source declares a prints map, the same finding is an error and the deploy is refused: you stated the shape, so a mismatch is a defect rather than a hint.