Loading...
Loading...
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 skill4agent add kali-decoder/midnight-skills indexerdocs.midnight.network/api-reference/midnight-indexergithub.com/midnightntwrk/midnight-indexer/blob/v4.0.1/indexer-api/graphql/schema-v4.graphqlmidnight.tswebisoftSoftware/1AM-starter-template| Network | HTTP (queries/mutations) | WebSocket (subscriptions) |
|---|---|---|
| | |
| | |
| | |
| | |
undeployed/api/v3/graphqlPOSTContent-Type: application/jsongraphql-transport-wsoffset: nullcontractActionqueryContractStateoffset: nullqueryContractState()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 } }ContractStateledger()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.
}ZswapChainStateLedgerParametersimport { 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)
}
}
}ContractDeployContractCallContractUpdatequery {
dustGenerationStatus(
cardanoRewardAddresses: ["stake_test1uq..."]
) {
cardanoRewardAddress
dustAddress
registered
nightBalance
generationRate
currentCapacity
maxCapacity
}
}currentCapacityindexerPublicDataProvideroffset: nullmidnight.tsimport { 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) }));
},
};
}indexerPublicDataProvidergraphql-transport-wsgraphql-wsnpm 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 },sessionIdconnect// 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}") }`,
}),
});submitTxasync 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');TransactionResultconst 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." }] }offset: nullcontractActioncreatePatchedPublicDataProviderundeployed/api/v3/graphqlundeployed/api/v4/graphqlsubmitTxContractDeployunshieldedBalancesContractCallContractUpdatecurrentCapacitydustGenerationStatusamountContractBalanceStringBigInt(amount)parseIntNumberblock.ledgerParametersLedgerParameters.initialParameters()graphql-wsretryAttemptsshouldRetrycreateClientconst 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