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.
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: {
amount: { type: "uint" },
sender: { type: "principal" },
recipient: { type: "principal" },
},
},
},
handlers: {
transfers: (event, ctx) => {
ctx.insert("transfers", {
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. Accumulating a value, reading contract state, and typing a print payload each have a rule worth knowing: Writing handlers.
Hoisting the schema
Pull schema out of the call with defineSchema() when a handler grows
enough to factor a helper, and 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, so an address announced on an orphaned fork stops matching.
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. A reindex backfills each contract from its deploy block; history below your bootstrap floor is a metered archive backfill. Contract discovery queries the set directly.
secondlayer subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
secondlayer 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, which 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, so pass
reads: { "<contract>.<function-name>": value } to createTestContext. An
unstubbed read throws naming the key it wanted.
First deploy backfills from startBlock; new blocks stream in after. History below your instance's bootstrap floor is a metered archive backfill.
secondlayer subgraphs deploy ./subgraph.config.tsRead: http://127.0.0.1:3800/v1/subgraphs/gamma-sales/salesTip-first deploys
--tip-first (or backfillMode: "concurrent") goes live at the tip, so rows are 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.
secondlayer subgraphs status, with 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: 4874Typed from the SDK, or REST at /v1/subgraphs with wildcard CORS.
const { rows, next_cursor, tip } = await sl.subgraphs.rows(
"sbtc-flows",
"transfers",
{ limit: 10, order: "desc" },
);curl http://127.0.0.1:3800/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 }
}Paging, sorting, live streams, webhooks, and typed ORM schemas: Reading rows.