npx skills add ...
npx skills add kali-decoder/midnight-skills --skill indexer
Query and subscribe to Midnight blockchain data via the Indexer GraphQL API v4. Covers contract state reads, transaction lookups, block queries, real-time subscriptions (contractActions, blocks, unshielded/shielded transactions), state deserialization, the offset/null bug workaround, and TypeScript helper patterns. Use when a user needs to read on-chain state after a transaction, watch contract events in real time, look up blocks or transactions, query unshielded balances, or monitor DUST generation status.
npx skills add kali-decoder/midnight-skills --skill indexer
The Midnight Indexer exposes a GraphQL API that indexes everything the chain produces: blocks, transactions, contract actions, and UTXO events. It is the only way to read public on-chain state from a DApp frontend.
Primary references:
docs.midnight.network/api-reference/midnight-indexer — official v4 API referencegithub.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql — authoritative schemamidnight.ts in webisoftSoftware/1AM-starter-template — real-world patched implementationreferences/volatile.md — resolve current @midnight-ntwrk/* and schema versions live| Network | HTTP (queries/mutations) | WebSocket (subscriptions) |
|---|---|---|
undeployed (local) | http://localhost:8088/api/v3/graphql | ws://localhost:8088/api/v3/graphql/ws |
preview | https://indexer.preview.midnight.network/api/v4/graphql | wss://indexer.preview.midnight.network/api/v4/graphql/ws |
preprod | https://indexer.preprod.midnight.network/api/v4/graphql | wss://indexer.preprod.midnight.network/api/v4/graphql/ws |
mainnet | https://indexer.mainnet.midnight.network/api/v4/graphql | wss://indexer.mainnet.midnight.network/api/v4/graphql/ws |
Critical: The local undeployed indexer uses /api/v3/graphql — not v4. Using v4 against local will 404.
All queries use POST with Content-Type: application/json. Subscriptions use WebSocket with protocol graphql-transport-ws.
offset: null Bug (Preview/Preprod)The hosted indexers on preview and preprod have a GraphQL bug: calling contractAction or queryContractState without an offset (i.e., "give me the latest state") triggers an internal error around offset: null. The SDK's default queryContractState() call hits this path.
The fix: Always query with an explicit custom query instead of relying on the SDK's default no-offset path. The 1AM starter implements this as a patched PublicDataProvider.
When does the bug apply? Only when calling without an offset argument. If you pass offset: { blockOffset: { height: N } } the SDK path works fine. For "get latest state" use the custom query above.
The indexer returns contract state as a hex-encoded ContractState blob. To get typed ledger fields, deserialize it using the generated ledger() function from your compiled contract.
When you need ZswapChainState and LedgerParameters (required by some SDK functions):
Note: ContractDeploy always returns empty balances. Only ContractCall and ContractUpdate reflect meaningful balances.
currentCapacity is accurate only until the first DUST fee payment (fee payments are shielded — indexer can't track them). Use it as an approximation; query the wallet SDK directly for precise post-payment DUST balance.
For production use, wrap indexerPublicDataProvider to bypass the offset: null bug on all three affected methods. This is the pattern from the 1AM starter's midnight.ts:
Use this instead of indexerPublicDataProvider directly when targeting preview or preprod.
Subscriptions use WebSocket with the graphql-transport-ws protocol. The graphql-ws npm package handles this cleanly.
The most common subscription for DApp UIs — fires every time your contract is called or updated:
Start from a block offset (replay from a known point):
Watch for incoming/outgoing unshielded UTXOs for a specific address:
Resume from a transaction ID (to avoid replaying from genesis):
Requires a sessionId from the connect mutation. This is used internally by the wallet SDK — you rarely need to call it directly unless building a custom wallet sync.
After calling submitTx, the indexer is not synchronous with chain finality. For most DApp flows, poll with exponential backoff rather than using a long-lived subscription:
TransactionResult (Did It Succeed?)The indexer server enforces limits on query complexity. If you hit them:
Split deep queries into multiple smaller queries. Don't select the full transaction graph in a single query — request only what you need.
offset: null bug on preview/preprod — calling contractAction without an offset hits a GraphQL error on the hosted indexer. Always use the patched createPatchedPublicDataProvider or your own manual query. The bug does not affect the local undeployed indexer.
Local indexer uses v3 not v4 — /api/v3/graphql for undeployed, /api/v4/graphql for all live networks. Wrong version = 404.
State is not immediately available after submitTx — the indexer is asynchronous with chain finality. Always poll or subscribe rather than querying immediately. Typical lag: 2–10 seconds on preprod/preview.
ContractDeploy always returns empty unshieldedBalances — contracts are deployed with zero balance. Query a ContractCall or ContractUpdate action for meaningful balance data.
currentCapacity in dustGenerationStatus is stale after fee payments — DUST fees are shielded transactions; the indexer cannot track them. Use the wallet SDK for accurate post-payment DUST balance.
amount in ContractBalance is a String, not a number — it supports u128 values that overflow JavaScript's number type. Always parse with BigInt(amount), never parseInt or Number.
block.ledgerParameters may be null on old blocks — fall back to LedgerParameters.initialParameters() when null, as shown in the patched provider.
Subscription connection drops silently — the graphql-ws client does not automatically reconnect by default. Configure retryAttempts and shouldRetry in createClient options for production:
transactions query returns an array — even querying by hash returns [Transaction!]!. Always index into [0].
__typename required for union/interface fragments — always request __typename when using ... on ContractDeploy / ContractCall / ContractUpdate fragments or you won't be able to discriminate the type at runtime.
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.
}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];
}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
}
}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)
}
}
}query {
dustGenerationStatus(
cardanoRewardAddresses: ["stake_test1uq..."]
) {
cardanoRewardAddress
dustAddress
registered
nightBalance
generationRate
currentCapacity
maxCapacity
}
}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) }));
},
};
}npm install graphql-ws wsimport { 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() {},
},
);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() {},
},
);variables: { address: 'mn_addr_preprod1...', transactionId: 12345 },// 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}") }`,
}),
});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');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);
}// 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);
}{ "errors": [{ "message": "Query has too many fields: 20. Max fields: 10." }] }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)));
},
});