Build / Subgraphs

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 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 — 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-registry

Scaffolds 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.ts
Read:      https://api.secondlayer.tools/v1/subgraphs/gamma-sales/sales

With 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-url deploys default private.
  • Private reads need the owner's sk-sl_ bearer; anon gets 404, 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 again

SDK: 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 generate

Generators: --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.