kali-decoder/midnight-skills

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 gener…

First seen Jul 2, 2026

Installation

$ npx skills add kali-decoder/midnight-skills --skill indexer

Summary

  • 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.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from kali-decoder/midnight-skills · top by installs.

npx skills add kali-decoder/midnight-skills

Browse all from kali-decoder/midnight-skills

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 16
License LICENSE
Default branch main
Open issues 5
Status Active

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 24,174 B
  • docs SUMMARY.md 527 B

History

  1. First seen on skills.sh
  2. First recorded snapshot · 115 installs

SKILL.md

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/graphqlnot 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.

// 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 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'; // 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.
}

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   # hex-encoded token type identifier
      amount      # string (supports u128)
    }
  }
}

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 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 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'; // 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):

variables: { address: contractAddress },
// Pass offset in the query to replay from block 100:
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.

// 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) 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;
}

// 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) 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') {
  // Some segments succeeded, some didn't
  const failed = result.segments?.filter(s => !s.success);
  console.warn('Partial success. Failed segments:', failed);
}

9) TypeScript Helpers

// 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) 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.