# Indexer

> Query and subscribe to Midnight blockchain data via the Indexer GraphQL API v4. Covers contract state reads, transaction lookups, block queries, real-time subscriptions (contractActions, blocks, unshielded/shielded transactions), state deserialization, the offset/null bug workaround, and TypeScript helper patterns. Use when a user needs to read on-chain state after a transaction, watch contract events in real time, look up blocks or transactions, query unshielded balances, or monitor DUST generation status.

- Skill: `kali-decoder/indexer` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kali-decoder/indexer`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kali-decoder/indexer/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: kali-decoder (https://skillmd.com/u/kali-decoder)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kali-decoder/indexer

---


# Midnight Indexer Skill

The Midnight Indexer exposes a GraphQL API that indexes everything the chain produces: blocks, transactions, contract actions, and UTXO events. It is the only way to read public on-chain state from a DApp frontend.

**Primary references:**
- `docs.midnight.network/api-reference/midnight-indexer` — official v4 API reference
- `github.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql` — authoritative schema
- `midnight.ts` in `webisoftSoftware/1AM-starter-template` — real-world patched implementation

---

## 1) Endpoints

| Network | HTTP (queries/mutations) | WebSocket (subscriptions) |
|---|---|---|
| `undeployed` (local) | `http://localhost:8088/api/v3/graphql` | `ws://localhost:8088/api/v3/graphql/ws` |
| `preview` | `https://indexer.preview.midnight.network/api/v4/graphql` | `wss://indexer.preview.midnight.network/api/v4/graphql/ws` |
| `preprod` | `https://indexer.preprod.midnight.network/api/v4/graphql` | `wss://indexer.preprod.midnight.network/api/v4/graphql/ws` |
| `mainnet` | `https://indexer.mainnet.midnight.network/api/v4/graphql` | `wss://indexer.mainnet.midnight.network/api/v4/graphql/ws` |

**Critical:** The local `undeployed` indexer uses `/api/v3/graphql` — **not v4**. Using v4 against local will 404.

All queries use `POST` with `Content-Type: application/json`. Subscriptions use WebSocket with protocol `graphql-transport-ws`.

---

## 2) The `offset: null` Bug (Preview/Preprod)

The hosted indexers on preview and preprod have a GraphQL bug: calling `contractAction` or `queryContractState` **without** an offset (i.e., "give me the latest state") triggers an internal error around `offset: null`. The SDK's default `queryContractState()` call hits this path.

**The fix:** Always query with an explicit custom query instead of relying on the SDK's default no-offset path. The 1AM starter implements this as a patched `PublicDataProvider`.

```typescript
// The broken path (SDK default — hits the null offset bug)
await providers.publicDataProvider.queryContractState(contractAddress);
// ❌ Fails on preview/preprod with GraphQL error

// The working path — explicit query with no offset field at all
async function queryLatestContractState(
  indexerUrl: string,
  contractAddress: string,
): Promise<string | null> {
  const res = await fetch(indexerUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      query: `
        query LATEST_STATE($address: HexEncoded!) {
          contractAction(address: $address) {
            state
          }
        }
      `,
      variables: { address: contractAddress },
    }),
  });
  const payload = await res.json();
  if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
  return payload.data?.contractAction?.state ?? null;
}
```

**When does the bug apply?** Only when calling without an offset argument. If you pass `offset: { blockOffset: { height: N } }` the SDK path works fine. For "get latest state" use the custom query above.

---

## 3) Contract State — Query and Deserialize

The indexer returns contract state as a hex-encoded `ContractState` blob. To get typed ledger fields, deserialize it using the generated `ledger()` function from your compiled contract.

```typescript
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { Counter } from './managed/counter'; // generated by compact compiler

// Helper: hex string → Uint8Array
function fromHex(hex: string): Uint8Array {
  const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
  const bytes = new Uint8Array(normalized.length / 2);
  for (let i = 0; i < normalized.length; i += 2) {
    bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
  }
  return bytes;
}

async function getContractLedgerState(
  indexerUrl: string,
  contractAddress: string,
) {
  const res = await fetch(indexerUrl, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      query: `
        query($address: HexEncoded!) {
          contractAction(address: $address) {
            state
            zswapState
            transaction {
              block { ledgerParameters }
            }
          }
        }
      `,
      variables: { address: contractAddress },
    }),
  });

  const payload = await res.json();
  if (payload.errors?.length) throw new Error(payload.errors[0].message);

  const action = payload.data?.contractAction;
  if (!action) return null;

  // Deserialize raw hex → ContractState → typed ledger state
  const contractState = ContractState.deserialize(fromHex(action.state));
  const ledgerState = Counter.ledger(contractState.data);

  return ledgerState; // fully typed: ledgerState.round, ledgerState.message, etc.
}
```

### Deserializing ZSwap + Contract State Together

When you need `ZswapChainState` and `LedgerParameters` (required by some SDK functions):

```typescript
import { LedgerParameters, ZswapChainState } from '@midnight-ntwrk/ledger-v8';

const action = payload.data?.contractAction;
if (action?.zswapState) {
  const zswapState = ZswapChainState.deserialize(fromHex(action.zswapState));
  const contractState = ContractState.deserialize(fromHex(action.state));
  const ledgerParams = action.transaction?.block?.ledgerParameters
    ? LedgerParameters.deserialize(fromHex(action.transaction.block.ledgerParameters))
    : LedgerParameters.initialParameters();

  return [zswapState, contractState, ledgerParams];
}
```

---

## 4) All Query Types

### Latest Block

```graphql
query {
  block {
    hash
    height
    timestamp
    protocolVersion
    ledgerParameters
    transactions {
      id
      hash
    }
  }
}
```

### Block by Height or Hash

```graphql
query {
  block(offset: { height: 42 }) {
    hash height timestamp
  }
}

query {
  block(offset: { hash: "3031323..." }) {
    hash height timestamp
  }
}
```

### Contract Action — Latest (use custom query, not SDK default)

```graphql
query($address: HexEncoded!) {
  contractAction(address: $address) {
    __typename
    address
    state
    zswapState
    transaction {
      hash
      block { height ledgerParameters }
      fees { paidFees estimatedFees }
    }
    unshieldedBalances {
      tokenType
      amount
    }
    ... on ContractCall {
      entryPoint
    }
  }
}
```

### Contract Action at a Specific Block

```graphql
query($address: HexEncoded!) {
  contractAction(
    address: $address,
    offset: { blockOffset: { height: 100 } }
  ) {
    state
    zswapState
  }
}
```

### Contract Action at a Specific Transaction

```graphql
query($address: HexEncoded!, $txHash: HexEncoded!) {
  contractAction(
    address: $address,
    offset: { transactionOffset: { hash: $txHash } }
  ) {
    state
  }
}
```

### Transactions by Hash or Identifier

```graphql
query($hash: HexEncoded!) {
  transactions(offset: { hash: $hash }) {
    id hash
    block { height hash }
    transactionResult {
      status
      segments { id success }
    }
    fees { paidFees estimatedFees }
    contractActions {
      __typename
      address
      state
      ... on ContractDeploy { address }
      ... on ContractCall { entryPoint }
    }
    unshieldedCreatedOutputs {
      owner value tokenType intentHash outputIndex
    }
    unshieldedSpentOutputs {
      owner value tokenType intentHash outputIndex
    }
  }
}
```

### Unshielded Balances for a Contract

```graphql
query($address: HexEncoded!) {
  contractAction(address: $address) {
    unshieldedBalances {
      tokenType   # hex-encoded token type identifier
      amount      # string (supports u128)
    }
  }
}
```

Note: `ContractDeploy` always returns empty balances. Only `ContractCall` and `ContractUpdate` reflect meaningful balances.

### DUST Generation Status

```graphql
query {
  dustGenerationStatus(
    cardanoRewardAddresses: ["stake_test1uq..."]
  ) {
    cardanoRewardAddress
    dustAddress
    registered
    nightBalance
    generationRate
    currentCapacity
    maxCapacity
  }
}
```

`currentCapacity` is accurate only until the first DUST fee payment (fee payments are shielded — indexer can't track them). Use it as an approximation; query the wallet SDK directly for precise post-payment DUST balance.

---

## 5) Patched PublicDataProvider

For production use, wrap `indexerPublicDataProvider` to bypass the `offset: null` bug on all three affected methods. This is the pattern from the 1AM starter's `midnight.ts`:

```typescript
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { LedgerParameters, ZswapChainState } from '@midnight-ntwrk/ledger-v8';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { PublicDataProvider } from '@midnight-ntwrk/midnight-js-types';

function fromHex(hex: string): Uint8Array {
  const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
  const bytes = new Uint8Array(normalized.length / 2);
  for (let i = 0; i < normalized.length; i += 2) {
    bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
  }
  return bytes;
}

async function gqlQuery(url: string, query: string, variables: Record<string, unknown>) {
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ query, variables }),
  });
  if (!res.ok) throw new Error(`Indexer HTTP ${res.status}`);
  const payload = await res.json();
  if (payload.errors?.length) throw new Error(payload.errors.map((e: any) => e.message).join('; '));
  return payload.data;
}

export function createPatchedPublicDataProvider(
  queryUrl: string,
  subscriptionUrl: string,
): PublicDataProvider {
  const base = indexerPublicDataProvider(queryUrl, subscriptionUrl);

  return {
    ...base,

    async queryContractState(contractAddress: string, config?: any) {
      // If config is provided, the SDK offset path works — pass through
      if (config) return base.queryContractState(contractAddress, config);

      // Without config → null offset bug → use manual query
      const data = await gqlQuery(queryUrl, `
        query LATEST_STATE($address: HexEncoded!) {
          contractAction(address: $address) { state }
        }
      `, { address: contractAddress });

      return data?.contractAction
        ? ContractState.deserialize(fromHex(data.contractAction.state))
        : null;
    },

    async queryZSwapAndContractState(contractAddress: string, config?: any) {
      if (config) return base.queryZSwapAndContractState(contractAddress, config);

      const data = await gqlQuery(queryUrl, `
        query LATEST_BOTH($address: HexEncoded!) {
          contractAction(address: $address) {
            state
            zswapState
            transaction { block { ledgerParameters } }
          }
        }
      `, { address: contractAddress });

      const action = data?.contractAction;
      if (!action?.zswapState) return null;

      return [
        ZswapChainState.deserialize(fromHex(action.zswapState)),
        ContractState.deserialize(fromHex(action.state)),
        action.transaction?.block?.ledgerParameters
          ? LedgerParameters.deserialize(fromHex(action.transaction.block.ledgerParameters))
          : LedgerParameters.initialParameters(),
      ] as [ZswapChainState, ContractState, LedgerParameters];
    },

    async queryUnshieldedBalances(contractAddress: string, config?: any) {
      if (config) return base.queryUnshieldedBalances(contractAddress, config);

      const data = await gqlQuery(queryUrl, `
        query LATEST_BALANCES($address: HexEncoded!) {
          contractAction(address: $address) {
            ... on ContractDeploy { unshieldedBalances { tokenType amount } }
            ... on ContractCall   { unshieldedBalances { tokenType amount } }
            ... on ContractUpdate { unshieldedBalances { tokenType amount } }
          }
        }
      `, { address: contractAddress });

      const action = data?.contractAction;
      if (!action) return null;
      const raw: Array<{ tokenType: string; amount: string }> =
        action.unshieldedBalances ?? [];
      return raw.map(e => ({ tokenType: e.tokenType, balance: BigInt(e.amount) }));
    },
  };
}
```

Use this instead of `indexerPublicDataProvider` directly when targeting preview or preprod.

---

## 6) Real-Time Subscriptions

Subscriptions use WebSocket with the `graphql-transport-ws` protocol. The `graphql-ws` npm package handles this cleanly.

```bash
npm install graphql-ws ws
```

### Subscribe to Contract Actions

The most common subscription for DApp UIs — fires every time your contract is called or updated:

```typescript
import { createClient } from 'graphql-ws';
import { WebSocket } from 'ws'; // Node.js only

const client = createClient({
  url: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
  webSocketImpl: typeof window === 'undefined' ? WebSocket : undefined,
});

const unsubscribe = client.subscribe(
  {
    query: `
      subscription WatchContract($address: HexEncoded!) {
        contractActions(address: $address) {
          __typename
          address
          state
          zswapState
          transaction {
            hash
            block { height timestamp }
            fees { paidFees }
          }
          ... on ContractCall {
            entryPoint
          }
        }
      }
    `,
    variables: { address: contractAddress },
  },
  {
    next(data) {
      const action = data.data?.contractActions;
      if (!action) return;

      // Deserialize raw state to typed ledger
      const contractState = ContractState.deserialize(fromHex(action.state));
      const ledgerState = YourContract.ledger(contractState.data);

      console.log('New state:', ledgerState);
      console.log('Entry point:', action.entryPoint); // which circuit was called
    },
    error(err) { console.error('Subscription error:', err); },
    complete() { console.log('Subscription closed'); },
  },
);

// Cleanup
unsubscribe();
```

**Start from a block offset** (replay from a known point):

```typescript
variables: { address: contractAddress },
// Pass offset in the query to replay from block 100:
query: `subscription($address: HexEncoded!) {
  contractActions(address: $address, offset: { height: 100 }) { ... }
}`
```

### Subscribe to New Blocks

```typescript
client.subscribe(
  {
    query: `
      subscription {
        blocks {
          hash height timestamp
          transactions { id hash }
        }
      }
    `,
  },
  {
    next(data) { console.log('New block:', data.data?.blocks?.height); },
    error(err) { console.error(err); },
    complete() {},
  },
);
```

### Subscribe to Unshielded Transactions

Watch for incoming/outgoing unshielded UTXOs for a specific address:

```typescript
client.subscribe(
  {
    query: `
      subscription WatchAddress($address: UnshieldedAddress!) {
        unshieldedTransactions(address: $address) {
          __typename
          ... on UnshieldedTransaction {
            transaction { hash block { height } }
            createdUtxos { owner value tokenType intentHash outputIndex }
            spentUtxos  { owner value tokenType intentHash outputIndex }
          }
          ... on UnshieldedTransactionsProgress {
            highestTransactionId
          }
        }
      }
    `,
    variables: { address: 'mn_addr_preprod1...' },
  },
  {
    next(data) {
      const event = data.data?.unshieldedTransactions;
      if (event?.__typename === 'UnshieldedTransaction') {
        console.log('Created UTXOs:', event.createdUtxos);
        console.log('Spent UTXOs:', event.spentUtxos);
      }
    },
    error(err) { console.error(err); },
    complete() {},
  },
);
```

**Resume from a transaction ID** (to avoid replaying from genesis):

```typescript
variables: { address: 'mn_addr_preprod1...', transactionId: 12345 },
```

### Subscribe to Shielded Transactions (Wallet Sync)

Requires a `sessionId` from the `connect` mutation. This is used internally by the wallet SDK — you rarely need to call it directly unless building a custom wallet sync.

```typescript
// Step 1: get a session ID
const sessionRes = await fetch(indexerHttpUrl, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    query: `mutation { connect(viewingKey: "mn_shield-esk1...") }`,
  }),
});
const { data } = await sessionRes.json();
const sessionId = data.connect;

// Step 2: subscribe
client.subscribe(
  {
    query: `
      subscription($sessionId: HexEncoded!, $index: Int) {
        shieldedTransactions(sessionId: $sessionId, index: $index) {
          __typename
          ... on RelevantTransaction {
            transaction { id hash }
            collapsedMerkleTree { startIndex endIndex update protocolVersion }
          }
          ... on ShieldedTransactionsProgress {
            highestEndIndex
            highestCheckedEndIndex
            highestRelevantEndIndex
          }
        }
      }
    `,
    variables: { sessionId, index: 0 },
  },
  {
    next(data) { /* process wallet sync events */ },
    error(err) { console.error(err); },
    complete() {},
  },
);

// Step 3: cleanup session when done
await fetch(indexerHttpUrl, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    query: `mutation { disconnect(sessionId: "${sessionId}") }`,
  }),
});
```

---

## 7) Poll After Submit (Practical Pattern)

After calling `submitTx`, the indexer is not synchronous with chain finality. For most DApp flows, poll with exponential backoff rather than using a long-lived subscription:

```typescript
async function pollForContractState(
  indexerUrl: string,
  contractAddress: string,
  options: { maxAttempts?: number; intervalMs?: number } = {},
): Promise<string | null> {
  const { maxAttempts = 30, intervalMs = 2000 } = options;

  for (let i = 0; i < maxAttempts; i++) {
    const data = await gqlQuery(indexerUrl, `
      query($address: HexEncoded!) {
        contractAction(address: $address) { state }
      }
    `, { address: contractAddress });

    if (data?.contractAction?.state) return data.contractAction.state;

    await new Promise(r => setTimeout(r, intervalMs));
  }
  return null;
}

// Usage after deploy
const deployed = await deployContract(providers, { ... });
const address = deployed.deployTxData.public.contractAddress;
const stateHex = await pollForContractState(config.indexerHttp, address);
if (!stateHex) throw new Error('Contract not found after deploy — indexer lag');
```

---

## 8) Reading `TransactionResult` (Did It Succeed?)

```typescript
const data = await gqlQuery(indexerUrl, `
  query($hash: HexEncoded!) {
    transactions(offset: { hash: $hash }) {
      transactionResult {
        status          # SUCCESS | PARTIAL_SUCCESS | FAILURE
        segments {
          id
          success
        }
      }
      fees { paidFees estimatedFees }
    }
  }
`, { hash: txHash });

const result = data?.transactions?.[0]?.transactionResult;
if (result?.status === 'FAILURE') {
  console.error('Transaction failed');
} else if (result?.status === 'PARTIAL_SUCCESS') {
  // Some segments succeeded, some didn't
  const failed = result.segments?.filter(s => !s.success);
  console.warn('Partial success. Failed segments:', failed);
}
```

---

## 9) TypeScript Helpers

```typescript
// src/indexer.ts

export type ContractActionType = 'ContractDeploy' | 'ContractCall' | 'ContractUpdate';

export interface RawContractAction {
  __typename: ContractActionType;
  address: string;
  state: string;
  zswapState: string;
  entryPoint?: string;  // only on ContractCall
  transaction: {
    hash: string;
    block: { height: number; timestamp: number; ledgerParameters?: string };
    fees?: { paidFees: string; estimatedFees: string };
  };
  unshieldedBalances: Array<{ tokenType: string; amount: string }>;
}

export function parseUnshieldedBalances(
  balances: Array<{ tokenType: string; amount: string }>,
): Map<string, bigint> {
  return new Map(balances.map(b => [b.tokenType, BigInt(b.amount)]));
}

export function fromHex(hex: string): Uint8Array {
  const normalized = hex.startsWith('0x') ? hex.slice(2) : hex;
  if (normalized.length % 2 !== 0) throw new Error('Invalid hex string');
  const bytes = new Uint8Array(normalized.length / 2);
  for (let i = 0; i < normalized.length; i += 2) {
    bytes[i / 2] = parseInt(normalized.slice(i, i + 2), 16);
  }
  return bytes;
}

export function toHex(bytes: Uint8Array): string {
  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}

// Typed ledger state from raw hex (using your contract's generated ledger() fn)
export function deserializeLedgerState<T>(
  stateHex: string,
  ledgerFn: (data: any) => T,
): T {
  const { ContractState } = require('@midnight-ntwrk/compact-runtime');
  const contractState = ContractState.deserialize(fromHex(stateHex));
  return ledgerFn(contractState.data);
}
```

---

## 10) Query Limits

The indexer server enforces limits on query complexity. If you hit them:

```json
{ "errors": [{ "message": "Query has too many fields: 20. Max fields: 10." }] }
```

Split deep queries into multiple smaller queries. Don't select the full transaction graph in a single query — request only what you need.

---

## 11) Common Pitfalls

**`offset: null` bug on preview/preprod** — calling `contractAction` without an offset hits a GraphQL error on the hosted indexer. Always use the patched `createPatchedPublicDataProvider` or your own manual query. The bug does not affect the local `undeployed` indexer.

**Local indexer uses v3 not v4** — `/api/v3/graphql` for `undeployed`, `/api/v4/graphql` for all live networks. Wrong version = 404.

**State is not immediately available after `submitTx`** — the indexer is asynchronous with chain finality. Always poll or subscribe rather than querying immediately. Typical lag: 2–10 seconds on preprod/preview.

**`ContractDeploy` always returns empty `unshieldedBalances`** — contracts are deployed with zero balance. Query a `ContractCall` or `ContractUpdate` action for meaningful balance data.

**`currentCapacity` in `dustGenerationStatus` is stale after fee payments** — DUST fees are shielded transactions; the indexer cannot track them. Use the wallet SDK for accurate post-payment DUST balance.

**`amount` in `ContractBalance` is a `String`, not a number** — it supports u128 values that overflow JavaScript's number type. Always parse with `BigInt(amount)`, never `parseInt` or `Number`.

**`block.ledgerParameters` may be null on old blocks** — fall back to `LedgerParameters.initialParameters()` when null, as shown in the patched provider.

**Subscription connection drops silently** — the `graphql-ws` client does not automatically reconnect by default. Configure `retryAttempts` and `shouldRetry` in `createClient` options for production:

```typescript
const client = createClient({
  url: wsUrl,
  retryAttempts: Infinity,
  shouldRetry: () => true,
  retryWait: async (retries) => {
    await new Promise(r => setTimeout(r, Math.min(1000 * 2 ** retries, 30_000)));
  },
});
```

**`transactions` query returns an array** — even querying by hash returns `[Transaction!]!`. Always index into `[0]`.

**`__typename` required for union/interface fragments** — always request `__typename` when using `... on ContractDeploy / ContractCall / ContractUpdate` fragments or you won't be able to discriminate the type at runtime.
