indexer
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseMidnight 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:
- — official v4 API reference
docs.midnight.network/api-reference/midnight-indexer - — authoritative schema
github.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql - in
midnight.ts— real-world patched implementationwebisoftSoftware/1AM-starter-template
Midnight Indexer 暴露了一个GraphQL API,可索引链上生成的所有数据:区块、交易、合约操作和UTXO事件。它是DApp前端读取公开链上状态的唯一方式。
主要参考资料:
- — 官方v4 API参考文档
docs.midnight.network/api-reference/midnight-indexer - — 权威Schema定义
github.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql - 中的
webisoftSoftware/1AM-starter-template— 实际修复后的实现示例midnight.ts
1) Endpoints
1) 端点
| Network | HTTP (queries/mutations) | WebSocket (subscriptions) |
|---|---|---|
| | |
| | |
| | |
| | |
Critical: The local indexer uses — not v4. Using v4 against local will 404.
undeployed/api/v3/graphqlAll queries use with . Subscriptions use WebSocket with protocol .
POSTContent-Type: application/jsongraphql-transport-ws| 网络环境 | HTTP(查询/变更) | WebSocket(订阅) |
|---|---|---|
| | |
| | |
| | |
| | |
重要提示: 本地索引器使用 — 而非v4。在本地环境使用v4会返回404错误。
undeployed/api/v3/graphql所有查询均使用方法,为。订阅使用WebSocket,协议为。
POSTContent-Typeapplication/jsongraphql-transport-ws2) The offset: null
Bug (Preview/Preprod)
offset: null2) offset: null
漏洞(预览/预生产环境)
offset: nullThe hosted indexers on preview and preprod have a GraphQL bug: calling or without an offset (i.e., "give me the latest state") triggers an internal error around . The SDK's default call hits this path.
contractActionqueryContractStateoffset: nullqueryContractState()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 .
PublicDataProvidertypescript
// 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 the SDK path works fine. For "get latest state" use the custom query above.
offset: { blockOffset: { height: N } }预览和预生产环境的托管索引器存在一个GraphQL漏洞:调用或时不传入offset参数(即“获取最新状态”)会触发相关的内部错误。SDK默认的调用会触发此问题。
contractActionqueryContractStateoffset: nullqueryContractState()解决方案: 始终使用显式自定义查询,而非依赖SDK默认的无offset路径。1AM启动模板中实现了一个修复后的作为示例。
PublicDataProvidertypescript
// 存在问题的路径(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参数时触发。若传入,则SDK路径可正常工作。如需“获取最新状态”,请使用上述自定义查询。
offset: { blockOffset: { height: N } }3) Contract State — Query and Deserialize
3) 合约状态 — 查询与反序列化
The indexer returns contract state as a hex-encoded blob. To get typed ledger fields, deserialize it using the generated function from your compiled contract.
ContractStateledger()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.
}索引器返回的合约状态为十六进制编码的二进制数据。如需获取类型化的账本字段,需使用编译合约生成的函数进行反序列化。
ContractStateledger()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 and (required by some SDK functions):
ZswapChainStateLedgerParameterstypescript
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];
}当需要和(部分SDK函数必需)时:
ZswapChainStateLedgerParameterstypescript
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: always returns empty balances. Only and reflect meaningful balances.
ContractDeployContractCallContractUpdategraphql
query($address: HexEncoded!) {
contractAction(address: $address) {
unshieldedBalances {
tokenType # 十六进制编码的代币类型标识符
amount # 字符串类型(支持u128)
}
}
}注意:始终返回空余额。仅和会返回有意义的余额数据。
ContractDeployContractCallContractUpdateDUST Generation Status
DUST生成状态
graphql
query {
dustGenerationStatus(
cardanoRewardAddresses: ["stake_test1uq..."]
) {
cardanoRewardAddress
dustAddress
registered
nightBalance
generationRate
currentCapacity
maxCapacity
}
}currentCapacitygraphql
query {
dustGenerationStatus(
cardanoRewardAddresses: ["stake_test1uq..."]
) {
cardanoRewardAddress
dustAddress
registered
nightBalance
generationRate
currentCapacity
maxCapacity
}
}currentCapacity5) Patched PublicDataProvider
5) 修复后的PublicDataProvider
For production use, wrap to bypass the bug on all three affected methods. This is the pattern from the 1AM starter's :
indexerPublicDataProvideroffset: nullmidnight.tstypescript
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 directly when targeting preview or preprod.
indexerPublicDataProvider生产环境中,请包装以绕过三个受影响方法的漏洞。以下是1AM启动模板中的实现模式:
indexerPublicDataProvideroffset: nullmidnight.tstypescript
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) }));
},
};
}当目标环境为预览或预生产时,请使用此方法替代直接调用。
indexerPublicDataProvider6) Real-Time Subscriptions
6) 实时订阅
Subscriptions use WebSocket with the protocol. The npm package handles this cleanly.
graphql-transport-wsgraphql-wsbash
npm install graphql-ws ws订阅使用WebSocket协议。 npm包可便捷处理此逻辑。
graphql-transport-wsgraphql-wsbash
npm install graphql-ws wsSubscribe 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 from the mutation. This is used internally by the wallet SDK — you rarely need to call it directly unless building a custom wallet sync.
sessionIdconnecttypescript
// 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}") }`,
}),
});需要从变更获取。此功能主要由钱包SDK内部使用 — 除非构建自定义钱包同步逻辑,否则很少需要直接调用。
connectsessionIdtypescript
// 步骤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 , the indexer is not synchronous with chain finality. For most DApp flows, poll with exponential backoff rather than using a long-lived subscription:
submitTxtypescript
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');调用后,索引器与链上最终性并非同步。对于大多数DApp流程,使用指数退避轮询而非长期订阅更为合适:
submitTxtypescript
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?)
TransactionResult8) 读取TransactionResult
(交易是否成功?)
TransactionResulttypescript
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: nullcontractActioncreatePatchedPublicDataProviderundeployedLocal indexer uses v3 not v4 — for , for all live networks. Wrong version = 404.
/api/v3/graphqlundeployed/api/v4/graphqlState is not immediately available after — the indexer is asynchronous with chain finality. Always poll or subscribe rather than querying immediately. Typical lag: 2–10 seconds on preprod/preview.
submitTxContractDeployunshieldedBalancesContractCallContractUpdatecurrentCapacitydustGenerationStatusamountContractBalanceStringBigInt(amount)parseIntNumberblock.ledgerParametersLedgerParameters.initialParameters()Subscription connection drops silently — the client does not automatically reconnect by default. Configure and in options for production:
graphql-wsretryAttemptsshouldRetrycreateClienttypescript
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__typename... on ContractDeploy / ContractCall / ContractUpdate预览/预生产环境的漏洞 — 在托管索引器上调用时不传入offset参数会触发GraphQL错误。请始终使用修复后的或自定义手动查询。此漏洞不影响本地索引器。
offset: nullcontractActioncreatePatchedPublicDataProviderundeployed本地索引器使用v3而非v4 — 环境使用,所有线上网络使用。版本错误会导致404。
undeployed/api/v3/graphql/api/v4/graphqlsubmitTxContractDeployunshieldedBalancesContractCallContractUpdatedustGenerationStatuscurrentCapacityContractBalanceamountBigInt(amount)parseIntNumber旧区块的可能为null — 当为null时,如修复后的提供者示例所示,回退使用。
block.ledgerParametersLedgerParameters.initialParameters()订阅连接会静默断开 — 客户端默认不会自动重连。生产环境中请在选项中配置和:
graphql-wscreateClientretryAttemptsshouldRetrytypescript
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