关于 indexer
面向 Midnight DApp 开发者的技术技能,介绍链索引器的 GraphQL 查询和 WebSocket 订阅。还涵盖网络端点、合约状态解码及托管索引器问题的规避方法。
主要功能
- 01查询区块、交易和合约操作
- 02订阅区块与交易事件
- 03解码序列化的合约及账本状态
- 04使用各网络对应的 GraphQL 端点
- 05规避托管索引器的 offset 错误
- 06轮询交易和状态更新
适用场景
- 01在 Midnight DApp 中读取公开链上数据
- 02在界面中跟踪合约操作或新区块
- 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/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。
所有查询都使用带有 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,否则无法在运行时辨别类型。