Subgraphs
Your schema on our indexer: declare contract events in one file, deploy hosted Postgres tables on the public /v1 read API.
One config file: named source filters, a table schema, handlers keyed by source name (or "*" for a catch-all).
import { defineSubgraph } from "@secondlayer/subgraphs";
export default defineSubgraph({
name: "sbtc-flows",
startBlock: 8000000,
sources: {
transfers: {
type: "ft_transfer",
assetIdentifier: "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token::sbtc-token",
},
},
schema: {
transfers: {
columns: {
tx_id: { type: "string" },
block_height: { type: "uint" },
amount: { type: "uint" },
sender: { type: "string" },
recipient: { type: "string" },
},
},
},
handlers: {
transfers: (event, ctx) => {
ctx.insert("transfers", {
tx_id: ctx.tx.txId,
block_height: ctx.block.height,
amount: event.amount,
sender: event.sender,
recipient: event.recipient,
});
},
},
});Each handler gets (event, ctx): ctx.tx / ctx.block for metadata, ctx.insert / update / upsert / delete / patch / increment for writes.
Counters and balances: use increment
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.
Hoisting the schema
Pull schema out of the call with defineSchema() when a handler grows
enough to factor a helper — the helper stays 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 });contractId takes an array, so a router plus its pools is one source and one
handler instead of twelve of each:
sources: {
swaps: { type: "print_event", contractId: [ROUTER, POOL_A, POOL_B], topic: "swap" },
}For a set that grows — pools created after you deploy, launchpad-minted
tokens, registry entries — use a factory. It extracts 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" },
},
}Two guarantees: a pool discovered in block N receives its own block-N events (discovery runs before matching), and the discovered set is rolled back on a reorg like any other chain-derived state — an address announced on an orphaned fork stops matching.
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 — ABI in, camelCased read methods
out — minus call: a handler indexes the chain, it never writes to it. It is a
free function rather than a method on ctx so the ABI generic resolves at the
call site, where it is concrete and costs the compiler nothing.
Every read is pinned to the block being processed. The call goes out with
that block's index_block_hash, so a handler stays a pure function of its
block: reprocessing the same blocks produces byte-identical rows. Reading at the node's
tip instead would make the same reindex return different values, so a block
whose id was never persisted throws rather than falling back.
Declare what can't change
Results are cached in Postgres. By default that key includes the block, which
dedupes reads across events but still costs one call per block. For values that
genuinely cannot change — SIP-010 decimals, symbol — 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 — the node you
already run, not a new service. See self-host.
sl subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
sl subgraphs test subgraphs/bns-names.ts --offline # replay the cassetteReal 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 it rather than passing against data the subgraph would no longer request. If events arrive and your handlers write nothing, the command fails — that is the shape of a field-mapping bug that would otherwise ship a 0-row subgraph.
For unit tests, @secondlayer/subgraphs/testing gives you the same context
the runtime uses, 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();Handlers that read the chain get stubbed reads instead of a node — pass
reads: { "<contract>.<function-name>": value } to createTestContext. An
unstubbed read throws naming the key it wanted.
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.
sl 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). sl subgraphs codegen <file> --payloads 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.
First deploy backfills from startBlock; new blocks stream in after. Needs a paid plan or a 14-day trial — free accounts get 403 PLAN_REQUIRED.
sl subgraphs deploy ./subgraph.config.tsRead: https://api.secondlayer.tools/v1/subgraphs/gamma-sales/salesWith the x402 rail on, POST /v1/subgraphs takes a payment instead of a key — wallet-owned, forward-only, expiring. See x402.
Tip-first deploys
--tip-first (or backfillMode: "concurrent") goes live at the tip — rows queryable in seconds while history backfills. Only for out-of-order-tolerant handlers: commutative counters and balances, insert-only tables. Latest-value-wins handlers keep the default blocking backfill.
sl subgraphs status — backfill draining while the table already serves reads:
Status reindexing
Sync reindexing — 14.2% (1174885 / 8263594 blocks, target #8263594)
Reindex Remaining 7088709
Gaps none
Rows Indexed 4,874
Table Rows balances: 4874- Managed deploys default public — anon-readable on
/v1. --database-urldeploys default private.- Private reads need the owner's
sk-sl_bearer; anon gets404, no existence leak. - Public names are one global namespace, claimed on publish; taken →
409 PUBLIC_NAME_TAKEN.
sl subgraphs deploy ./subgraph.config.ts --visibility private
sl subgraphs publish sbtc-flows # make public
sl subgraphs unpublish sbtc-flows # make private againSDK: sl.subgraphs.publish(name) / unpublish(name). MCP: subgraphs_publish / subgraphs_unpublish, plus visibility on subgraphs_deploy.
Typed from the SDK, or REST at /v1/subgraphs with wildcard CORS. Pages are _id-keyset: ?cursor=<next_cursor> resumes, _order=asc|desc sets direction. Sort by one column instead with ?_sort=<column>&_order=asc|desc — the cursor pairs that column with _id as a tiebreaker, so pages stay stable even when values tie. Multi-column _sort=a,b and jsonb columns are rejected with 400, and so is _offset. Full grammar: REST API.
# top holders by amount, paginated
curl https://api.secondlayer.tools/v1/subgraphs/asset-holdings/holdings \
-G -d "_limit=5" -d "_sort=amount" -d "_order=desc"Mark a column indexed: true if you plan to sort on it. Sorted reads get a
(column, _id) index that matches the page order, so deep pages stay flat
instead of re-sorting every tie group. Existing tables pick that index up on
their next reindex or schema change.
const { rows, next_cursor, tip } = await sl.subgraphs.rows(
"sbtc-flows",
"transfers",
{ limit: 10, order: "desc" },
);curl https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers \
-G -d "_limit=10" -d "_order=desc"{
"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 }
}GET /v1/subgraphs lists public subgraphs (yours too with a bearer); GET /v1/subgraphs/sbtc-flows returns metadata. Row-by-id /:table/:id, counts /:table/count, rollups /:table/aggregate — see aggregates and the API reference.
trait resolves to 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" },
},Traits: sip-009, sip-010, sip-013. On paid plans a reindex backfills each contract from its deploy block; without a plan it clamps to the registered start. Contract discovery queries the set directly.
Tail a table over Server-Sent Events from the tip, or ?since=<block_height> to replay a height then tail live. Column filters work as query params.
curl -N https://api.secondlayer.tools/v1/subgraphs/sbtc-flows/transfers/stream \
-G -d "since=8050000"subscribe wraps it with the same where filters as findMany and returns an unsubscribe:
const unsubscribe = subgraph.transfers.subscribe(
(row) => console.log(row),
{ where: { sender: "SP3PE7Q9...X44J" }, since: 8050000, onError: console.error },
);It uses the global EventSource — browser or Node ≥ 22 only. Wire contract: Streaming (SSE).
--database-url lands rows in your Postgres, then codegen a typed ORM schema:
sl subgraphs deploy ./subgraph.config.ts --database-url "$DATABASE_URL"
sl subgraphs codegen ./subgraph.config.ts --target prisma -o prisma/schema.prisma
npx prisma generateGenerators: --target prisma|drizzle|kysely. Secondlayer owns the DDL — treat the tables as read-only, verify with prisma db pull, never migrate. For pushes instead of polling, bind a Subscription.
Breaking changes on your own database
Breaking changes never drop your data
A breaking change (removed table/column, changed column type, forced reindex) auto-reindexes managed subgraphs by dropping and rebuilding the schema. On a BYO database that drop would destroy your data, so the deploy is refused (422) — nothing on your database is touched.
The refusal carries the plan — reasons plus DDL to run yourself, then re-deploy:
✗ Refusing breaking schema change on BYO subgraph (no data dropped).
Breaking changes:
✗ transfers: removed columns [amount]
To rebuild manually, run on YOUR database:
DROP SCHEMA IF EXISTS "sg_my_subgraph" CASCADE;
CREATE SCHEMA "sg_my_subgraph";
CREATE TABLE …The SDK throws a typed ByoBreakingChangeError exposing details.reasons and details.plan (dropStatement, statements, grantScript).
No --force on BYO
A destructive --force rebuild on your database is not yet supported; run the DROP manually.