indexer について
Midnight の DApp 開発者向けに、チェーンインデクサーの GraphQL クエリと WebSocket サブスクリプションを解説する技術スキルです。ネットワーク別エンドポイント、コントラクト状態のデコード、ホスト型インデクサーの問題への対処も扱います。
主な機能
- 01ブロック、トランザクション、コントラクトアクションの取得
- 02ブロックやトランザクションイベントの購読
- 03シリアライズされたコントラクト状態と台帳状態のデコード
- 04ネットワーク別 GraphQL エンドポイントの利用
- 05ホスト型インデクサーの offset エラーへの対処
- 06トランザクションと状態更新のポーリング
活用シーン
- 01Midnight DApp で公開オンチェーンデータを読む
- 02UI でコントラクトアクションや新しいブロックを追跡
- 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) コントラクト状態 — クエリとデシリアライズ
インデクサーは、16 進数エンコードされた ContractState の blob としてコントラクト状態を返します。型付きの台帳フィールドを取得するには、コンパイル済みコントラクトから生成された 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 とコントラクト状態をまとめてデシリアライズする
一部の SDK 関数で必要となる ZswapChainState と LedgerParameters を取得する場合:
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 をラップして、影響を受ける 3 つのメソッドすべてで 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 を使用します。npm パッケージの graphql-ws を使うと簡単に処理できます。
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 ミューテーションから取得した 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." }] }
深いクエリは、複数の小さなクエリに分割してください。1 つのクエリですべてのトランザクショングラフを選択しないでください。必要なものだけをリクエストしてください。
11) よくある落とし穴
プレビュー/プレプロッドでの offset: null バグ — オフセットを指定せずに contractAction を呼び出すと、ホスト型インデクサーで GraphQL エラーが発生します。必ずパッチ適用済みの createPatchedPublicDataProvider または独自の手動クエリを使用してください。このバグはローカルの undeployed インデクサーには影響しません。
ローカルのインデクサーは v4 ではなく v3 を使用 — 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 の場合は 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 をリクエストしてください。そうしないと、実行時に型を判別できません。