<!-- source: https://secondlayer.tools/docs/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](/docs/index).

## Define a subgraph

A subgraph names its sources, declares a table, and writes one handler per source. The first run backfills from startBlock; new blocks stream in after. This one reads a pool's reserves map, so anyone can rebuild its rows.

```ts subgraphs/pool-reserves.ts figure

  name: "pool-reserves",
  startBlock: 1230000,
  sources: {
    reserves: {
      // State writes only, so the rows can be recomputed.
      type: "map_set",
      contractId: "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-vault-v2-01",
      map: "reserve",
    },
  },
  schema: {
    reserves: {
      columns: { token: { type: "principal" }, balance: { type: "uint" } },
      uniqueKeys: [["token"]],
    },
  },
  handlers: {
    reserves: (event, ctx) => {
      // Same input, same row, on every run.
      ctx.upsert("reserves", { token: event.key as string }, { balance: event.value as bigint });
    },
  },
});
```

Accumulating a value, reading contract state, and typing a `print` payload each have a rule worth knowing: [Handlers](#handlers).

## Deploy

Hosted is the default: one private stack per account, nothing to operate. Create a key at [/account/keys](/account/keys), set the two variables from the [Introduction](/docs#get-started), and deploy with CLI 19.1 or later.

```text ~/pool-reserves
$ secondlayer subgraphs deploy subgraphs/pool-reserves.ts
✓ Subgraph "pool-reserves" created → v1.0.0
  verification  state · pin 7f3c…a91e
  unproven      tx attribution of writes: event.tx, _tx_id (needs event proofs)
```

- Set a start block. Without one it reindexes from genesis, and every matching event and transaction row it reads bills (what it costs).
- The `verification` line is the level your sources earn; `pin` hashes what you deployed. See [Rows anyone can recompute](#rows-anyone-can-recompute).

## Check it

Recompute the rows on your machine from proven blocks and compare them with what the API serves:

```text ~/pool-reserves exit=0
$ secondlayer verify subgraph pool-reserves --replay --from 1230000 --to 1231000
✓ pin       7f3c…a91e  handler, schema and startBlock match the deploy
✓ blocks    1,001 proven from the checkpoint (2 MARF proofs, then parent links)
✓ txs       4,812 transactions match their blocks' tx merkle roots
✓ inputs    1,001 diffs fully named; 37 writes to amm-vault-v2-01
✓ handlers  37 events, 0 handler errors, deterministic realm
✓ rows      reserves: 14 equal  digest 9a1f…03be
```

## Handler context

Each handler gets the matched event and a context:

| On `ctx` | For |
| --- | --- |
| `tx`, `block` | The event's transaction and block |
| `insert`, `update`, `upsert`, `delete`, `increment` | Writes |
| `findOne`, `findMany` | Reading rows back |
| `client` | Read-only contract calls ([Reading the chain](#reading-the-chain-from-a-handler)) |

A `contract_call` source with a function name gets `event.input` typed from the contract's ABI, and deploy fetches the ABI for you. Pass the ABI yourself for wildcard, multi-contract or trait sources, and for local deploys.

Map and variable write sources get typed, decoded keys and values when you pass an `abi` and name the map or variable. Without one they stay unknown.

## Hoisting the schema

Pull the schema out with `defineSchema()` and your helpers stay fully typed:

```ts

  balances: {
    columns: { holder: { type: "principal" }, amount: { type: "uint" } },
    uniqueKeys: [["holder"]],
  },
});

function credit(ctx: TypedSubgraphContext<typeof schema>, holder: string) {
  ctx.increment("balances", { holder }, { amount: 1n });
}

```

With only the definition in scope, `InferContext<typeof def>` gives the same `ctx`.

## Indexing a contract set

A fixed set is one source: `contractId` takes an array, so a router and its pools share one handler.

```ts
sources: {
  swaps: { type: "print_event", contractId: [ROUTER, POOL_A, POOL_B], topic: "swap" },
}
```

A print source also takes [`where`](/docs/webhooks#print-fields), so the handler only runs for prints that carry your values.

A set that grows (new pools, launchpad tokens, registry entries) uses a `factory`, which reads addresses from another source's events:

```ts
sources: {
  registry: { type: "print_event", contractId: REGISTRY, topic: "pool-created" },
  swaps: {
    type: "print_event",
    topic: "swap",
    factory: { from: "registry", field: "data.pool" },
  },
}
```

- A contract discovered in block N gets its own block-N events.
- A reorg rolls the discovered set back, so an address from an orphaned fork stops matching.

## Index by standard, not by address

`trait` matches every contract Secondlayer classifies as that standard, including ones deployed after you ship.

```ts
sources: {
  // every SIP-010 token transfer on-chain
  tokens: { type: "ft_transfer", trait: "sip-010" },
},
```

| `trait` | Standard |
| --- | --- |
| `sip-009` | NFT |
| `sip-010` | Fungible token |
| `sip-013` | Semi-fungible token |

A reindex backfills each contract from its deploy block and bills the rows it reads. To list the set, see [Find contracts by standard](/docs/index#find-contracts-by-standard).

## Test before you deploy

Real chain events, your local handler code, no deploy:

```bash
secondlayer subgraphs test subgraphs/bns-names.ts --from 167484 --to 167600
secondlayer subgraphs test subgraphs/bns-names.ts --offline   # replay the cassette
```

- The first run records a cassette, so later runs are free and offline.
- Changing a source filter discards the cassette instead of passing on stale data.
- If events arrive and no rows are written, the test fails. That's the mapping bug that ships an empty subgraph.

For unit tests, `@secondlayer/subgraphs/testing` gives you the runtime's context, backed by memory:

```ts

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();
```

Chain reads are stubbed: pass `reads: { "<contract>.<function-name>": value }` to `createTestContext`. An unstubbed read throws and names the key it wanted.

## Handlers

A handler turns one matched event into rows. Three things behave differently than they look.

### Counters and balances: use `increment`

`ctx.increment` applies a delta in one atomic statement, upserting on the table's unique keys.

```ts
ctx.increment("balances", { holder }, { amount: -amount });
```

- Tip-first deploys and `backfill` revisit blocks, so they refuse handlers that call `increment`, `update`, `findOne` or `findMany`.
- Those handlers deploy with the default blocking backfill and rebuild with `reindex`.

#### Why not read, compute, upsert

It looks equivalent and isn't. Two events on the same row in one block both read the stale value, and the second write wins. Deltas commute within a block, and a reorg reverses them cleanly.

#### System columns

Every row carries `_block_height` and `_tx_id`: the block and transaction that created it.

| Write | System columns |
| --- | --- |
| Upsert or increment an existing key | Unchanged; your columns take the last write |
| Delete a key, then write it again | A new row, with the new writer's block and transaction |

#### Reading the chain from a handler

Some facts live in contract state, not events: a token's decimals, a pool's reserves, an NFT's metadata URI. `ctx.client` reads them with read-only calls.

```ts

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
  },
},
```

- Every read is pinned to the block being processed, so a reindex gives byte-identical rows.
- A block whose id was never stored throws instead of reading the node's tip.
- A read that can't complete (no node, node down) halts the subgraph at that block with status `error`, instead of skipping the event. A contract `err` answer fails only that event.

| Where it runs | Reads come from | Limits |
| --- | --- | --- |
| Hosted | Secondlayer's node, pinned to the block, no setup | About 3,000 reads a minute per account, 5 s per read. Over the limit, reads wait and retry |
| Your own box | [Your node](/docs/self-host#config): set `STACKS_NODE_RPC_URL` on the processor | Your node's |

> **Declare what can't change**
>

Reads cache per block. For values that never change, `cache: "contract-constant"` fetches once, ever, which makes a full backfill affordable. It's opt-in: on a value that does change, it pins the first answer forever.

#### Typed print payloads

Clarity ABIs don't describe print payloads, so a `prints` map types `event.data` per topic:

```ts
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 fields: a wrong one is silently null forever. Scaffold from the contract's observed history:

```bash
secondlayer subgraphs create sbtc-registry \
  --from-contract SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-registry
```

- One source per topic with its `prints` map, plus one wide table. `--table-per-topic` splits it.
- `secondlayer codegen prints <file>` writes a `.d.ts` of the payload types.
- Deploy warns when a handler reads a field never seen for its topic (sources pinned to one contract). With a declared `prints` map, that's an error and the deploy is refused.

The scaffold reads the print-schema endpoint.

#### Rows anyone can recompute

A subgraph whose sources are all state writes can be rebuilt from block headers alone, so its handlers must give the same rows every run. The level comes from your sources and a scan of the bundle; you don't set it.

| `verification.level` | Sources | Handler findings |
| --- | --- | --- |
| `state` | State writes only | Deploy refused: `422 NONDETERMINISTIC_HANDLER`, each as `file:line:column` |
| `events` | Events, prints, contract calls, `map_insert`, or any chain read | Advice in `verification.reasons`; deploys as before |
| `none` | Trait scope, or tip-first | Advice |

| Refused at `state` | Use instead |
| --- | --- |
| `Date`, `performance`, timers, `fetch`, `process`, `crypto`, `Intl`, `toLocaleString` | Values from `event`, `ctx.block`, `ctx.tx` |
| `Math.random`, `Math.sin` and other approximated `Math` | `bigint` math; `Math.abs/min/max/floor/ceil/trunc/sign/round` |
| Non-integer or unsafe numbers in a row | `bigint` |
| `findOne` calls in parallel (`Promise.all`) | Await each read in turn |

- The same list throws at runtime, and the block retries instead of skipping the event.
- Most event subgraphs stay advisory until event proofs ship.
- `pin` hashes schema, bundle, start block, network and runtime version, so a handler-only redeploy changes it even when `schemaHash` does not.
- State writes come from the node's `state_writes` once the instance holds them from the start block, and from `vm_events` until then. Nothing to configure.

#### Check rows

Recompute a state subgraph and compare it with the served rows, as in [Check it](#check-it). Each line proves one link:

| Line | Proves |
| --- | --- |
| `pin` | The served bundle, schema and start block are the ones the pin hashes |
| `blocks` | Every block in the range, from the checkpoint |
| `txs` | Each block's transactions hash to its header's tx merkle root |
| `inputs` | Each block's writes are named in full, nothing hidden |
| `handlers` | The bundle runs in the deterministic realm over those writes |
| `rows` | Served rows equal the recomputed ones on declared columns and `_block_height` |

- `_tx_id` isn't compared: which transaction made a write is unproven, as are writes overwritten within one block.
- Starting after the start block is inconclusive (exit `2`) once a handler reads or merges earlier rows.
- Reads are free. Witnesses run at 2 per second, so 1,000 blocks take 8 minutes or more.
- A node that doesn't deliver `state_writes` can't be checked: exit `2`.

## Tip-first deploys

`--tip-first` (or `backfillMode: "concurrent"`) goes live at the tip, so rows serve in seconds while history backfills.

| Handler writes with | Tip-first |
| --- | --- |
| `insert`, full-row `upsert` | Allowed |
| `update`, `increment`, `findOne`, `findMany` | Refused: `TIP_FIRST_NON_REPLAYABLE_HANDLER` |

Refused handlers deploy with the default blocking backfill. The same rule applies to `secondlayer subgraphs backfill`; rebuild with `reindex`.

## Check status

The table serves reads while the backfill drains:

```text ~/balances
$ secondlayer subgraphs status balances
Verification       events · ft_transfer source "balances" needs event proofs
Status             reindexing
Sync               reindexing — 14.2% (1174885 / 8263594 blocks, target #8263594)
Reindex Remaining  7088709
Gaps               none
Rows Indexed       4,874
Table Rows         balances: 4874
```

## What runs hosted

| | Hosted |
| --- | --- |
| Sources | Event filters, `contract_call`, `contract_deploy`. Anything else is `422 SOURCE_NOT_HOSTABLE`, naming the source |
| Isolation | A gVisor sandbox with 512 MB, reaching only Secondlayer's API and the public internet |
| Contract reads (`ctx.client`) | Yes, pinned to the block, rate limited per account ([details](#reading-the-chain-from-a-handler)) |
| Visibility | Private. No public catalog, fork or marketplace |

A processor that stalls or runs out of memory restarts. After 3 deaths at one height the subgraph is marked `error`: fix the handler and redeploy. [Your own box](/docs/self-host#what-differs-on-your-box) runs any source.

## Read rows

| Read | Where |
| --- | --- |
| Pages over REST | `/v1/subgraphs/<name>/<table>` |
| A live stream | `…/stream`, Server-Sent Events |
| A typed ORM schema | Your instance's Postgres, self-host only |

### Pages, cursors, and sorting

| Param | Does |
| --- | --- |
| `cursor` | Resumes from the last `next_cursor` |
| `_order` | `asc` or `desc` |
| `_sort` | Sorts by one column; the cursor adds `_id` as a tiebreaker |

```bash
# top holders by amount, paginated
curl "$SECONDLAYER_API_URL/v1/subgraphs/asset-holdings/holdings" \
  -H "Authorization: Bearer $SECONDLAYER_API_KEY" \
  -G -d "_limit=5" -d "_sort=amount" -d "_order=desc"
```

#### Read your table

From the SDK (it reads the two variables itself) or over REST, with wildcard CORS.

```bash
curl "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers" \
  -H "Authorization: Bearer $SECONDLAYER_API_KEY" \
  -G -d "_limit=10" -d "_order=desc"
```

```ts
const { rows, next_cursor, tip } = await sl.subgraphs.rows(
  "sbtc-flows",
  "transfers",
  { limit: 10, order: "desc" },
);
```

```json response.json status=200
{
  "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 }
}
```

#### Cursor and sort rules

- Don't hand-build a sorted cursor. Replaying it under another sort or order is a `400`.
- Also `400`: sorting by several columns, `jsonb` columns, `_offset`.
- Limits, fields, search and filters work as on every read: [REST API](/docs/rest-api).

#### Indexed and searchable columns

| Column flag | Gives |
| --- | --- |
| `indexed: true` | An index in page order, so deep sorted pages stay fast. Existing tables pick it up on reindex. |
| `search: true` | `_search` on that column. With none marked, `_search` is a `400`. |

#### Open reads

Reads send your key as a bearer token, like Index and Streams. There's no per-subgraph visibility flag: hosted subgraphs are private to your account, and on your own box your proxy decides who can reach it (instance token). Every route: [API reference](/docs/api/subgraphs#list-subgraph-rows).

#### Stream rows in real time

Tail a table from the tip, or from `since` to replay a height first.

```bash
curl -N "$SECONDLAYER_API_URL/v1/subgraphs/sbtc-flows/transfers/stream" \
  -H "Authorization: Bearer $SECONDLAYER_API_KEY" \
  -G -d "since=8050000"
```

`subscribe` wraps it with the same filters and returns an unsubscribe. It needs a global EventSource: browsers, or Node 22 and up. Wire contract: [Streaming (SSE)](/docs/rest-api#streaming-sse).

```ts
const unsubscribe = subgraph.transfers.subscribe(
  (row) => console.log(row),
  { where: { sender: "SP3PE7Q9...X44J" }, since: 8050000, onError: console.error },
);
```

#### React to your rows

Webhooks fire on chain events. To act on your own rows, watch the table.

**On your own box.** Tables live in schema `subgraph_<name>`, dashes as underscores. A trigger tells your app when a block's rows commit:

```sql
CREATE OR REPLACE FUNCTION notify_sbtc_transfers() RETURNS trigger AS $$
BEGIN
  PERFORM pg_notify('sbtc_transfers', NEW._id::text);
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sbtc_transfers_notify
  AFTER INSERT OR UPDATE ON subgraph_sbtc_flows.transfers
  FOR EACH ROW EXECUTE FUNCTION notify_sbtc_transfers();
```

```ts

const sql = postgres(process.env.DATABASE_URL!); // your instance's Postgres

await sql.listen("sbtc_transfers", async (id) => {
  const [row] = await sql`SELECT * FROM subgraph_sbtc_flows.transfers WHERE _id = ${id}`;
  if (row) await handle(row);
});
```

- You never act on a row that rolled back: the notification goes out on commit.
- Send the id, not the row; a payload is capped at 8,000 bytes.
- Nothing listening means the notification is gone. On start, read past the last id you handled, then listen.
- A reindex or destructive redeploy drops your trigger. Create it from the script that deploys.

**Hosted.** Read over the API: poll, and keep the last `next_cursor`.

```ts
let cursor = await loadCursor(); // last next_cursor saved, or undefined

for (;;) {
  const page = await sl.subgraphs.rows("sbtc-flows", "transfers", { cursor, order: "asc", limit: 100 });
  for (const row of page.rows) await handle(row);
  cursor = page.next_cursor ?? cursor;
  await saveCursor(cursor);
  if (page.rows.length < 100) await new Promise((resolve) => setTimeout(resolve, 5_000));
}
```

- `next_cursor` is null only on an empty page, so falling back to the old cursor keeps your place.
- A reorg rewrites rows under new ids. Wait until a row is a few blocks behind `tip` before you move money on it.

#### Typed ORM schemas (codegen)

Codegen needs direct database access, so it's self-host only.

```bash
secondlayer codegen subgraph ./subgraph.config.ts --target prisma -o prisma/schema.prisma
npx prisma generate
```

`--target` takes prisma, drizzle or kysely. Secondlayer owns the DDL, so treat the tables as read-only: check with `prisma db pull`, never migrate.

## Notes for agents

- Scaffold with 'secondlayer subgraphs scaffold'; use named object sources and event.data fields; then 'secondlayer subgraphs deploy' and query recent rows.
- Typed ORM schema: 'secondlayer codegen subgraph --target prisma|drizzle|kysely'. Treat the tables as read-only. It needs direct Postgres access, so self-host only; hosted subgraphs are read over REST.
- Hosted deploy: set SECONDLAYER_API_URL and SECONDLAYER_API_KEY, set a startBlock so the reindex is bounded (event and transaction rows bill), then read '/v1/subgraphs/<name>/<table>' with the key as a bearer token.
- Hosted sources must be hostable (event filters, contract_call, contract_deploy); anything else fails with SOURCE_NOT_HOSTABLE.
- A hosted processor that stalls or exceeds 512 MB is restarted, and marked 'error' after 3 deaths at the same height. Find the handler at that block, fix it, redeploy. Check with 'secondlayer subgraphs status <name>'.
- Reads serve while the genesis backfill drains; watch it with 'secondlayer subgraphs status <name>'.
- Hosted handlers can call readContractAt with no node setup. Use cache: "contract-constant" for values that never change (decimals, symbol) so a backfill reads them once instead of once per block.

## Setup

Ensure setup once, skipping any step already done:
- Skill: `bunx skills add ryanwaits/secondlayer --skill secondlayer -y`
- CLI: `bun add -g @secondlayer/cli`
- Instance: `secondlayer init` writes `INSTANCE_TOKEN`. Loopback `/v1` reads need no key.
- Hosted/archive: export `SECONDLAYER_API_KEY` (`sk-sl_*`) or buy credits with `secondlayer credits buy` (`balance`, `refill`), then `secondlayer whoami`
