mcpField
I
indexer

indexer

github.com/kali-decoder数据与数据库18124 次安装4 天前更新
代码仓库

关于 indexer

面向 Midnight DApp 开发者的技术技能,介绍链索引器的 GraphQL 查询和 WebSocket 订阅。还涵盖网络端点、合约状态解码及托管索引器问题的规避方法。

主要功能

  1. 01查询区块、交易和合约操作
  2. 02订阅区块与交易事件
  3. 03解码序列化的合约及账本状态
  4. 04使用各网络对应的 GraphQL 端点
  5. 05规避托管索引器的 offset 错误
  6. 06轮询交易和状态更新

适用场景

  1. 01在 Midnight DApp 中读取公开链上数据
  2. 02在界面中跟踪合约操作或新区块
  3. 03提交交易后获取合约状态

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/kali-decoder/midnight-skills --skill indexer

安装后在 Claude Code 中输入/indexer确认已加载。

详细介绍

Midnight Indexer 技能

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 — 权威架构
  • webisoftSoftware/1AM-starter-template 中的 midnight.ts — 实际应用中的修补实现
  • references/volatile.md — 在线解析当前的 @midnight-ntwrk/* 和架构版本

1) 端点

网络HTTP(查询/变更)WebSocket(订阅)
undeployed(本地)http://localhost:8088/api/v3/graphqlws://localhost:8088/api/v3/graphql/ws
previewhttps://indexer.preview.midnight.network/api/v4/graphqlwss://indexer.preview.midnight.network/api/v4/graphql/ws
preprodhttps://indexer.preprod.midnight.network/api/v4/graphqlwss://indexer.preprod.midnight.network/api/v4/graphql/ws
mainnethttps://indexer.mainnet.midnight.network/api/v4/graphqlwss://indexer.mainnet.midnight.network/api/v4/graphql/ws

重要: 本地 undeployed 索引器使用 /api/v3/graphql,不是 v4。在本地使用 v4 会返回 404。

所有查询都使用带有 Content-Type: application/json 的 POST 请求。订阅使用基于 graphql-transport-ws 协议的 WebSocket。


2) offset: null 错误(Preview/Preprod)

Preview 和 preprod 上的托管索引器存在 GraphQL 错误:调用 contractAction 或 queryContractState 时不传入偏移量(即“给我最新状态”)会因 offset: null 附近的内部错误而失败。SDK 默认的 queryContractState() 调用会触发此问题。

解决方法: 始终使用显式自定义查询,而不要依赖 SDK 默认的不带偏移量的路径。1AM starter 通过修补版 PublicDataProvider 实现了这一方法。

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

此错误何时会出现? 仅在不传入偏移量参数时。如果传入 offset: { blockOffset: { height: N } },SDK 路径即可正常工作。若要“获取最新状态”,请使用上面的自定义查询。


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

索引器会将合约状态作为十六进制编码的 ContractState 数据块返回。要获取带类型的账本字段,请使用编译后合约生成的 ledger() 函数进行反序列化。

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.
}
同时反序列化 ZSwap 和合约状态

需要 ZswapChainState 和 LedgerParameters(某些 SDK 函数必需)时:

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) 所有查询类型

最新区块
query {
  block {
    hash
    height
    timestamp
    protocolVersion
    ledgerParameters
    transactions {
      id
      hash
    }
  }
}
按高度或哈希查询区块
query {
  block(offset: { height: 42 }) {
    hash height timestamp
  }
}

query {
  block(offset: { hash: "3031323..." }) {
    hash height timestamp
  }
}
合约操作 — 最新(使用自定义查询,不要使用 SDK 默认方式)
query($address: HexEncoded!) {
  contractAction(address: $address) {
    __typename
    address
    state
    zswapState
    transaction {
      hash
      block { height ledgerParameters }
      fees { paidFees estimatedFees }
    }
    unshieldedBalances {
      tokenType
      amount
    }
    ... on ContractCall {
      entryPoint
    }
  }
}
查询指定区块中的合约操作
query($address: HexEncoded!) {
  contractAction(
    address: $address,
    offset: { blockOffset: { height: 100 } }
  ) {
    state
    zswapState
  }
}
查询指定交易中的合约操作
query($address: HexEncoded!, $txHash: HexEncoded!) {
  contractAction(
    address: $address,
    offset: { transactionOffset: { hash: $txHash } }
  ) {
    state
  }
}
按哈希或标识符查询交易
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
    }
  }
}
合约的非隐私余额
query($address: HexEncoded!) {
  contractAction(address: $address) {
    unshieldedBalances {
      tokenType   # hex-encoded token type identifier
      amount      # string (supports u128)
    }
  }
}

注意:ContractDeploy 始终返回空余额。只有 ContractCall 和 ContractUpdate 才会反映有意义的余额。

DUST 生成状态
query {
  dustGenerationStatus(
    cardanoRewardAddresses: ["stake_test1uq..."]
  ) {
    cardanoRewardAddress
    dustAddress
    registered
    nightBalance
    generationRate
    currentCapacity
    maxCapacity
  }
}

currentCapacity 仅在首次支付 DUST 费用之前准确(费用支付采用隐私方式,索引器无法跟踪)。请将其用作近似值;若要获取支付后的精确 DUST 余额,请直接查询钱包 SDK。


5) 修补版 PublicDataProvider

在生产环境中,请封装 indexerPublicDataProvider,以绕过三个受影响方法中的 offset: null 错误。以下是 1AM starter 的 midnight.ts 中使用的模式:

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

针对 preview 或 preprod 时,请使用此方法,而不要直接使用 indexerPublicDataProvider。


6) 实时订阅

订阅使用基于 graphql-transport-ws 协议的 WebSocket。graphql-ws npm 包可以轻松处理此协议。

npm install graphql-ws ws
订阅合约操作

DApp UI 最常用的订阅——每次调用或更新合约时都会触发:

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

从区块偏移量开始(从已知位置重放):

variables: { address: contractAddress },
// Pass offset in the query to replay from block 100:
query: `subscription($address: HexEncoded!) {
  contractActions(address: $address, offset: { height: 100 }) { ... }
}`
订阅新区块
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() {},
  },
);
订阅未屏蔽交易

监视特定地址收到或发出的未屏蔽 UTXO:

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() {},
  },
);

从交易 ID 恢复(避免从创世区块开始重放):

variables: { address: 'mn_addr_preprod1...', transactionId: 12345 },
订阅已屏蔽交易(钱包同步)

需要从 connect mutation 获取 sessionId。钱包 SDK 内部会用到此功能——除非你要构建自定义钱包同步功能,否则很少需要直接调用。

// 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) 提交后轮询(实用模式)

调用 submitTx 后,索引器不会与链的最终确定性同步。对于大多数 DApp 工作流,应使用指数退避进行轮询,而非使用长连接订阅:

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) 读取 TransactionResult(是否成功?)

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 辅助工具

// 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) 查询限制

索引器服务会限制查询复杂度。如果遇到限制:

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

将深层查询拆分成多个较小的查询。不要在单个查询中选择完整的交易图——只请求所需内容。


11) 常见问题

预览/预发布环境中的 offset: null 错误——调用 contractAction 时不提供偏移量,会在托管索引器上触发 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 是 String,而非数字——它支持会超出 JavaScript 数字类型范围的 u128 值。请始终使用 BigInt(amount) 解析,绝不要使用 parseInt 或 Number。

旧区块中的 block.ledgerParameters 可能为 null——如果为 null,请像已修补的 provider 中所示,回退到 LedgerParameters.initialParameters()。

订阅连接会静默断开——graphql-ws 客户端默认不会自动重连。生产环境中,请在 createClient 选项中配置 retryAttempts 和 shouldRetry:

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,否则无法在运行时辨别类型。

评论

支持 Markdown