| name | indexer |
| description | 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. |
Midnight Indexer Skill
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 reference
github.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphql — authoritative schema
midnight.ts in webisoftSoftware/1AM-starter-template — real-world patched implementation
1) Endpoints
| 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.
2) The 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.
await providers.publicDataProvider.queryContractState(contractAddress);
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 offset: { blockOffset: { height: N } } the SDK path works fine. For "get latest state" use the custom query above.
3) Contract State — Query and Deserialize
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.
import { ContractState } from '@midnight-ntwrk/compact-runtime';
import { Counter } from './managed/counter';
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;
const contractState = ContractState.deserialize(fromHex(action.state));
const ledgerState = Counter.ledger(contractState.data);
return ledgerState;
}
Deserializing ZSwap + Contract State Together
When you need ZswapChainState and LedgerParameters (required by some SDK functions):
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
Latest Block
query {
block {
hash
height
timestamp
protocolVersion
ledgerParameters
transactions {
id
hash
}
}
}
Block by Height or Hash
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)
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
query($address: HexEncoded!) {
contractAction(
address: $address,
offset: { blockOffset: { height: 100 } }
) {
state
zswapState
}
}
Contract Action at a Specific Transaction
query($address: HexEncoded!, $txHash: HexEncoded!) {
contractAction(
address: $address,
offset: { transactionOffset: { hash: $txHash } }
) {
state
}
}
Transactions by Hash or Identifier
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
query($address: HexEncoded!) {
contractAction(address: $address) {
unshieldedBalances {
tokenType
amount
}
}
}
Note: ContractDeploy always returns empty balances. Only ContractCall and ContractUpdate reflect meaningful balances.
DUST Generation Status
query {
dustGenerationStatus(
cardanoRewardAddresses: ["stake_test1uq..."]
) {
cardanoRewardAddress
dustAddress
registered
nightBalance
generationRate
currentCapacity
maxCapacity
}
}
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.
5) Patched PublicDataProvider
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:
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) return base.queryContractState(contractAddress, config);
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 indexerPublicDataProvider directly when targeting preview or preprod.
6) Real-Time Subscriptions
Subscriptions use WebSocket with the graphql-transport-ws protocol. The graphql-ws npm package handles this cleanly.
npm install graphql-ws ws
Subscribe to Contract Actions
The most common subscription for DApp UIs — fires every time your contract is called or updated:
import { createClient } from 'graphql-ws';
import { WebSocket } from 'ws';
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('New state:', ledgerState);
console.log('Entry point:', action.entryPoint);
},
error(err) { console.error('Subscription error:', err); },
complete() { console.log('Subscription closed'); },
},
);
unsubscribe();
Start from a block offset (replay from a known point):
variables: { address: contractAddress },
query: `subscription($address: HexEncoded!) {
contractActions(address: $address, offset: { height: 100 }) { ... }
}`
Subscribe to New Blocks
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() {},
},
);
Subscribe to Unshielded Transactions
Watch for incoming/outgoing unshielded UTXOs for a specific address:
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):
variables: { address: 'mn_addr_preprod1...', transactionId: 12345 },
Subscribe to Shielded Transactions (Wallet Sync)
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.
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;
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() {},
},
);
await fetch(indexerHttpUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
query: `mutation { disconnect(sessionId: "${sessionId}") }`,
}),
});
7) Poll After Submit (Practical Pattern)
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:
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('Contract not found after deploy — indexer lag');
8) Reading TransactionResult (Did It Succeed?)
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') {
const failed = result.segments?.filter(s => !s.success);
console.warn('Partial success. Failed segments:', failed);
}
9) TypeScript Helpers
export type ContractActionType = 'ContractDeploy' | 'ContractCall' | 'ContractUpdate';
export interface RawContractAction {
__typename: ContractActionType;
address: string;
state: string;
zswapState: string;
entryPoint?: string;
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('');
}
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
The indexer server enforces limits on query complexity. If you hit them:
{ "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.
11) Common Pitfalls
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:
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 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.