Claude
Skills
Sign in
Back

zama-subgraph

Included with Lifetime
$97 forever

Best practices for building app-facing subgraphs and GraphQL integrations in this monorepo. Use when: (1) Setting up a new subgraph package, (2) Designing `schema.graphql` entities and relationships, (3) Choosing manifest strategy (`subgraph.yaml`, `networks.json`, or templating), (4) Writing AssemblyScript mappings and entity ID helpers, (5) Deciding when to use snapshots, immutable entities, templates, or helper entities, (6) Wiring app-side GraphQL queries against the subgraph, (7) Reviewing subgraph performance, maintainability, or testing strategy. Derived from studying production DeFi subgraphs and aligned to Zama's local stack.

Backend & APIs

What this skill does


# Subgraph Best Practices

## Core Model

### A Subgraph Has Two Jobs

1. Maintain a small set of **current-state entities** that the app can query cheaply.
2. Record **event/history entities** only when the product needs auditability, timelines, or
   activity feeds.

That framing should drive almost every design choice.

If a field is needed to render the current app state, keep it on a mutable entity. If a record
exists only because an event happened, make it an immutable event entity.

### Optimize For Query Shape, Not Indexing Cleverness

The best subgraph is not the one with the fanciest mapping layer. It is the one whose schema matches
the actual product queries.

Keep a current-state `Batch`, `Position`, `Vault`, `Market`, or equivalent entity. Add event/history
entities only where the UI or operators need them.

## Configuration Strategy

### Default: Static `subgraph.yaml` + `networks.json`

This should be the default for app-facing subgraphs.

Use it when:

- the same data sources exist on every network
- only addresses and start blocks vary
- you want simple builds like `graph build --network sepolia`

This is the right choice for a fixed-contract subgraph.

### Use Manifest Templating Only When Topology Changes

Use Mustache or similar templating when:

- some data sources only exist on some networks
- the manifest shape changes per deployment
- you need conditional handlers, grafting, or chain-specific manifest blocks

Do not introduce templating just because multiple networks exist. If `networks.json` is enough, use
it.

### Use Runtime Templates For Factory-Created Contracts

If contracts are created after indexing starts, use Graph templates.

```yaml
templates:
  - kind: ethereum/contract
    name: Vault
    network: mainnet
    source:
      abi: Vault
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      file: ./src/mappings.ts
      entities:
        - Vault
      abis:
        - name: Vault
          file: ./abis/Vault.json
      eventHandlers:
        - event: Deposit(indexed address,uint256)
          handler: handleDeposit
```

```typescript
import { Vault as VaultTemplate } from "../generated/templates";

export function handleVaultCreated(event: VaultCreated): void {
  VaultTemplate.create(event.params.vault);
}
```

## Schema Design

### Current-State Entities vs Event Entities

Use **mutable entities** for current state:

```graphql
type Batch @entity {
  id: ID!
  state: BatchState!
  exchangeRate: BigInt
  finalizedAtBlock: BigInt
  memberships: [PositionMembership!]! @derivedFrom(field: "batch")
}
```

Use **immutable entities** for event records:

```graphql
type BatchFinalizedEvent @entity(immutable: true) {
  id: Bytes!
  batch: Batch!
  exchangeRate: BigInt!
  blockNumber: BigInt!
  txHash: Bytes!
}
```

Rule:

- if the entity should change over time, it is mutable
- if the entity is a record of one event occurrence, it should usually be immutable

### `@derivedFrom` Is The Default For Reverse Relations

Do not store arrays of related entities directly when the relation can be expressed from the child
side.

```graphql
type Batch @entity {
  id: ID!
  memberships: [PositionMembership!]! @derivedFrom(field: "batch")
}

type PositionMembership @entity {
  id: ID!
  batch: Batch!
  account: Bytes!
}
```

Why:

- cleaner writes
- smaller mutable state surface
- better alignment with how Graph relationships are meant to be modeled

Important nuance:

- this applies to **entity relationship arrays**
- it does not mean "never use arrays anywhere"

Small scalar arrays can be fine. Relationship arrays should almost always be `@derivedFrom`.

### Snapshots Are A Product Feature

Add snapshots only when the app or analytics layer needs:

- time-series charts
- daily or hourly rollups
- point-in-time financial metrics
- unique-user or usage metrics by interval

Do not add snapshots just because other subgraphs have them.

For a simple app-facing state machine, snapshots are often unnecessary complexity.

If you need snapshots, make them deterministic:

```typescript
import { Bytes } from "@graphprotocol/graph-ts";

export function makeDailySnapshotId(entityId: Bytes, timestamp: i32): Bytes {
  return entityId.concat(Bytes.fromI32(timestamp / 86400));
}
```

## ID Strategy

### Use `Bytes!` When The Identity Is Naturally Binary

Use `Bytes!` for:

- addresses
- tx-hash + log-index event IDs
- binary composite IDs built from addresses and fixed-width values

```typescript
import { Bytes, ethereum } from "@graphprotocol/graph-ts";

export function makeEventId(event: ethereum.Event): Bytes {
  return event.transaction.hash.concatI32(event.logIndex.toI32());
}
```

This should be the default for event IDs.

### Use `ID!`/string When The Identity Is Semantic

Use string IDs when:

- the entity key is semantic rather than binary
- you need readable or versioned composites
- the identifier contains mixed domains like chain ID + address + protocol-side counter

```typescript
import { Address, BigInt } from "@graphprotocol/graph-ts";

export function makeBatchEntityId(chainId: i32, batcher: Address, batchId: BigInt): string {
  return `${chainId.toString()}-${batcher.toHexString()}-${batchId.toString()}`;
}
```

Do not force `Bytes!` everywhere. Use the ID shape that makes collisions impossible and the model
maintainable.

## Mapping Structure

### Default To Flat Helpers

For most app-facing subgraphs, flat helpers are the right starting point.

Typical layout:

```text
src/
├── mappings.ts
├── entity-ids.ts
└── helpers.ts
```

Use flat helpers when:

- handlers touch only a few entities
- the state machine is small
- there is little reuse across handlers

### Introduce Managers Only When The Domain Demands It

Manager classes are justified when handlers repeatedly update many entities with tightly coupled
logic.

The trigger is a single handler that updates current state, counters, snapshots, event entities, and
lifecycle/versioning helpers all at once. For a simple subgraph, managers are usually
over-architecture.

### Keep Handlers Thin

Handlers should do four things:

1. Decode event intent.
2. Load or create the required entities.
3. Update current state and write event entities.
4. Delegate repeated logic to helpers.

```typescript
export function handleJoined(event: Joined): void {
  const batch = getOrCreateBatch(event);
  const membership = getOrCreateMembership(event, batch);

  membership.status = "active";
  membership.joinedAtBlock = event.block.number;
  membership.save();

  batch.state = "pending";
  batch.save();
}
```

### Helper Entities Are Fine When They Buy Determinism

Use helper entities for things like:

- unique-account counting by interval
- lifecycle counters
- versioned position reopening

Do not add helper entities unless they remove ambiguity from the model.

## Performance And Manifest Knobs

### Pruning Is A Workload Decision

Use:

```yaml
indexerHints:
  prune: auto
```

when the subgraph is primarily app-facing and you care about current-state reads more than
historical entity versions.

Use:

```yaml
indexerHints:
  prune: never
```

when historical state retention matters.

Do not treat `prune: auto` as a universal best practice. It is a default for app-facing products,
not for every subgraph.

### Enable Receipts Only When Needed

```yaml
eventHandlers:
  - event: LiquidationCall(...)
    handler: handleLiquidationCall
    receipt: true
```

Do not enable `receipt: true` globally. It is a targeted feature.

### Prefer Event-Complete Contracts

The cleanest subgraph is one whose contracts emit the data the mappings need.

Contracts should emit enough data for the mapping to update state directly. Selective `eth_call` for
metadata or unavoidable derived state is acceptable.

## Testing

### Matchstick Is Required Here

The external repos are inconsistent. We should not copy that.

For this repo, Matchstick tests are part of the standard.

Test

Related in Backend & APIs