Products / Subgraphs

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 cassette

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 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.ts
Read:      http://127.0.0.1:3800/v1/subgraphs/gamma-sales/sales

Tip-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: 4874

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