indexer

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Midnight Indexer Skill

Midnight Indexer 使用指南

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

Midnight Indexer 暴露了一个GraphQL API,可索引链上生成的所有数据:区块、交易、合约操作和UTXO事件。它是DApp前端读取公开链上状态的唯一方式。
主要参考资料:
  • docs.midnight.network/api-reference/midnight-indexer
    — 官方v4 API参考文档
  • github.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql
    — 权威Schema定义
  • webisoftSoftware/1AM-starter-template
    中的
    midnight.ts
    — 实际修复后的实现示例

1) Endpoints

1) 端点

NetworkHTTP (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
.

网络环境HTTP(查询/变更)WebSocket(订阅)
undeployed
(本地)
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
重要提示: 本地
undeployed
索引器使用
/api/v3/graphql
而非v4。在本地环境使用v4会返回404错误。
所有查询均使用
POST
方法,
Content-Type
application/json
。订阅使用WebSocket,协议为
graphql-transport-ws

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

2)
offset: null
漏洞(预览/预生产环境)

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.

预览和预生产环境的托管索引器存在一个GraphQL漏洞:调用
contractAction
queryContractState
不传入offset参数(即“获取最新状态”)会触发
offset: null
相关的内部错误。SDK默认的
queryContractState()
调用会触发此问题。
解决方案: 始终使用显式自定义查询,而非依赖SDK默认的无offset路径。1AM启动模板中实现了一个修复后的
PublicDataProvider
作为示例。
typescript
// 存在问题的路径(SDK默认调用 — 触发null offset漏洞)
await providers.publicDataProvider.queryContractState(contractAddress);
// ❌ 在预览/预生产环境会触发GraphQL错误

// 修复后的路径 — 使用完全不含offset字段的显式查询
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;
}
漏洞适用场景: 仅当调用时不传入offset参数时触发。若传入
offset: { blockOffset: { height: N } }
,则SDK路径可正常工作。如需“获取最新状态”,请使用上述自定义查询。

3) Contract State — Query and Deserialize

3) 合约状态 — 查询与反序列化

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.
}
索引器返回的合约状态为十六进制编码的
ContractState
二进制数据。如需获取类型化的账本字段,需使用编译合约生成的
ledger()
函数进行反序列化。
typescript
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { Counter } from './managed/counter'; // 由compact编译器生成

// 辅助函数:十六进制字符串 → 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;

  // 反序列化:原始十六进制 → ContractState → 类型化账本状态
  const contractState = ContractState.deserialize(fromHex(action.state));
  const ledgerState = Counter.ledger(contractState.data);

  return ledgerState; // 完全类型化:ledgerState.round, ledgerState.message 等
}

Deserializing ZSwap + Contract State Together

同时反序列化ZSwap与合约状态

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];
}

当需要
ZswapChainState
LedgerParameters
(部分SDK函数必需)时:
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

4) 所有查询类型

Latest Block

最新区块

graphql
query {
  block {
    hash
    height
    timestamp
    protocolVersion
    ledgerParameters
    transactions {
      id
      hash
    }
  }
}
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
  }
}
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)

合约操作 — 最新状态(使用自定义查询,而非SDK默认)

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
    }
  }
}
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
  }
}
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
  }
}
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
    }
  }
}
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.
graphql
query($address: HexEncoded!) {
  contractAction(address: $address) {
    unshieldedBalances {
      tokenType   # 十六进制编码的代币类型标识符
      amount      # 字符串类型(支持u128)
    }
  }
}
注意:
ContractDeploy
始终返回空余额。仅
ContractCall
ContractUpdate
会返回有意义的余额数据。

DUST Generation Status

DUST生成状态

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.

graphql
query {
  dustGenerationStatus(
    cardanoRewardAddresses: ["stake_test1uq..."]
  ) {
    cardanoRewardAddress
    dustAddress
    registered
    nightBalance
    generationRate
    currentCapacity
    maxCapacity
  }
}
currentCapacity
仅在首次DUST费用支付前准确(费用支付为屏蔽交易 — 索引器无法追踪)。仅将其作为近似值;如需精准的付费后DUST余额,请直接查询钱包SDK。

5) Patched PublicDataProvider

5) 修复后的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.

生产环境中,请包装
indexerPublicDataProvider
以绕过三个受影响方法的
offset: null
漏洞。以下是1AM启动模板
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) {
      // 若提供config,SDK的offset路径可正常工作 — 直接转发
      if (config) return base.queryContractState(contractAddress, config);

      // 无config时 → 触发null offset漏洞 → 使用手动查询
      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) }));
    },
  };
}
当目标环境为预览或预生产时,请使用此方法替代直接调用
indexerPublicDataProvider

6) Real-Time Subscriptions

6) 实时订阅

Subscriptions use WebSocket with the
graphql-transport-ws
protocol. The
graphql-ws
npm package handles this cleanly.
bash
npm install graphql-ws ws
订阅使用WebSocket协议
graphql-transport-ws
graphql-ws
npm包可便捷处理此逻辑。
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 }) { ... }
}`
这是DApp UI最常用的订阅类型 — 每当合约被调用或更新时触发:
typescript
import { createClient } from 'graphql-ws';
import { WebSocket } from 'ws'; // 仅Node.js环境需要

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;

      // 将原始状态反序列化为类型化账本
      const contractState = ContractState.deserialize(fromHex(action.state));
      const ledgerState = YourContract.ledger(contractState.data);

      console.log('新状态:', ledgerState);
      console.log('入口点:', action.entryPoint); // 被调用的电路
    },
    error(err) { console.error('订阅错误:', err); },
    complete() { console.log('订阅已关闭'); },
  },
);

// 清理资源
unsubscribe();
从指定区块偏移开始订阅(从已知点重放数据):
typescript
variables: { address: contractAddress },
// 在查询中传入offset以从区块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() {},
  },
);
typescript
client.subscribe(
  {
    query: `
      subscription {
        blocks {
          hash height timestamp
          transactions { id hash }
        }
      }
    `,
  },
  {
    next(data) { console.log('新区块:', 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 },
监控特定地址的传入/传出未屏蔽UTXO:
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('创建的UTXO:', event.createdUtxos);
        console.log('消耗的UTXO:', event.spentUtxos);
      }
    },
    error(err) { console.error(err); },
    complete() {},
  },
);
从指定交易ID恢复订阅(避免从创世区块重放):
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}") }`,
  }),
});

需要从
connect
变更获取
sessionId
。此功能主要由钱包SDK内部使用 — 除非构建自定义钱包同步逻辑,否则很少需要直接调用。
typescript
// 步骤1:获取会话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;

// 步骤2:发起订阅
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) { /* 处理钱包同步事件 */ },
    error(err) { console.error(err); },
    complete() {},
  },
);

// 步骤3:完成后清理会话
await fetch(indexerHttpUrl, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    query: `mutation { disconnect(sessionId: "${sessionId}") }`,
  }),
});

7) Poll After Submit (Practical Pattern)

7) 提交后轮询(实用模式)

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');

调用
submitTx
后,索引器与链上最终性并非同步。对于大多数DApp流程,使用指数退避轮询而非长期订阅更为合适:
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;
}

// 部署后的使用示例
const deployed = await deployContract(providers, { ... });
const address = deployed.deployTxData.public.contractAddress;
const stateHex = await pollForContractState(config.indexerHttp, address);
if (!stateHex) throw new Error('部署后未找到合约 — 索引器延迟');

8) Reading
TransactionResult
(Did It Succeed?)

8) 读取
TransactionResult
(交易是否成功?)

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);
}

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('交易失败');
} else if (result?.status === 'PARTIAL_SUCCESS') {
  // 部分段成功,部分失败
  const failed = result.segments?.filter(s => !s.success);
  console.warn('部分成功。失败的段:', failed);
}

9) TypeScript Helpers

9) TypeScript辅助工具

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);
}

typescript
// src/indexer.ts

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

export interface RawContractAction {
  __typename: ContractActionType;
  address: string;
  state: string;
  zswapState: string;
  entryPoint?: string;  // 仅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('无效的十六进制字符串');
  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('');
}

// 从原始十六进制生成类型化账本状态(使用合约生成的ledger()函数)
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

10) 查询限制

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.

索引器服务器对查询复杂度施加限制。若触发限制,会返回:
json
{ "errors": [{ "message": "Query has too many fields: 20. Max fields: 10." }] }
将深度查询拆分为多个较小的查询。不要在单个查询中选择完整的交易图 — 仅请求所需数据。

11) Common Pitfalls

11) 常见陷阱

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.
预览/预生产环境的
offset: null
漏洞
— 在托管索引器上调用
contractAction
时不传入offset参数会触发GraphQL错误。请始终使用修复后的
createPatchedPublicDataProvider
或自定义手动查询。此漏洞不影响本地
undeployed
索引器。
本地索引器使用v3而非v4
undeployed
环境使用
/api/v3/graphql
,所有线上网络使用
/api/v4/graphql
。版本错误会导致404。
submitTx
后状态不会立即可用
— 索引器与链上最终性不同步。请始终使用轮询或订阅,而非立即查询。预生产/预览环境的典型延迟为2–10秒。
ContractDeploy
始终返回空
unshieldedBalances
— 合约部署时余额为零。请查询
ContractCall
ContractUpdate
操作以获取有意义的余额数据。
dustGenerationStatus
中的
currentCapacity
在费用支付后失效
— DUST费用为屏蔽交易;索引器无法追踪。如需精准的付费后DUST余额,请使用钱包SDK。
ContractBalance
中的
amount
是字符串类型,而非数字
— 它支持超出JavaScript数字类型范围的u128值。请始终使用
BigInt(amount)
解析,切勿使用
parseInt
Number
旧区块的
block.ledgerParameters
可能为null
— 当为null时,如修复后的提供者示例所示,回退使用
LedgerParameters.initialParameters()
订阅连接会静默断开
graphql-ws
客户端默认不会自动重连。生产环境中请在
createClient
选项中配置
retryAttempts
shouldRetry
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
查询返回数组
— 即使按哈希查询,也会返回
[Transaction!]!
。请始终索引到
[0]
联合/接口片段需要
__typename
— 使用
... on ContractDeploy / ContractCall / ContractUpdate
片段时,始终请求
__typename
,否则无法在运行时区分类型。