Skip to main content
For the complete documentation index, see llms.txt

Deploying and operating a contract

A contract that passes its off-chain tests is ready for a network. Getting it there, and keeping it working afterwards, is one workflow with four parts: wire up the providers that Midnight.js uses to reach the network, deploy the contract and hold onto its address, observe its on-chain state, and operate the maintenance authority that lets you update its circuits later.

This guide walks that workflow in order. The examples deploy the private-bid contract from midnight-examples, the contract that How to build a private smart contract writes, to the local network the example starts in Docker, so every step runs with nothing at risk. The same code moves to Preprod by swapping endpoints and funding a real wallet.

Prerequisites​

These apply to every procedure in this guide:

  • A fully compiled contract: compact compile without skip flags, so the output contains keys/ and zkir/ alongside contract/. Using Compact contracts from JavaScript covers the compile and the generated module. The samples here compile private-bid with Compact toolchain 0.31.1, which pairs with @midnight-ntwrk/compact-runtime 0.16.0, and run it with @midnight-ntwrk/midnight-js-* 4.1.1 and @midnight-ntwrk/wallet-sdk 1.2.0, the versions midnight-examples pins.
  • A running network. The procedures use the local network the private-bid example starts with yarn env:up: a node, an indexer, and a proof server in Docker, on the same ports as Running a local network. The example also provides the wallet class the verification tests import.
  • A wallet holding NIGHT and registered for DUST generation, since every transaction costs DUST. On the local network the genesis wallet is pre-funded and holds spendable DUST within seconds of the first block; on Preprod, follow Funding a wallet.
  • Node.js 22 or later, with the @midnight-ntwrk/midnight-js-* packages at the version the support matrix pairs with your compiler.

The transaction pipeline and its providers​

Submitting a circuit call is a pipeline, and each stage has a pluggable component. When a DApp calls a deployed contract, Midnight.js executes the contract logic locally, generates a zero-knowledge proof of that execution, balances the transaction with DUST to cover its fee, and submits the result to the network. Reading, rather than writing, follows the same shape: contract state comes from an indexer, and private state never leaves the machine.

The MidnightProviders object names one provider per capability, and every deploy or call takes the whole object:

  • privateStateProvider stores the contract's private state, and the signing keys the SDK generates, on the local device.
  • publicDataProvider queries and subscribes to on-chain contract state through the indexer's GraphQL API.
  • zkConfigProvider supplies the prover key, verifier key, and ZKIR artifacts from the compile.
  • proofProvider sends proof requests to a proof server.
  • walletProvider exposes the wallet's receiving keys and balances transactions.
  • midnightProvider submits the finalized transaction.

An optional seventh slot, loggerProvider, accepts a logger; the six above are required.

The type is generic over your contract, so the compiler catches a circuit name or private state shape that does not match:

import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';

export type PrivateBidPrivateState = { readonly secretKey: Uint8Array };
export type PrivateBidProviders = MidnightProviders<'placeBid' | 'revealBid', 'privateBidPrivateState', PrivateBidPrivateState>;

Keep these aliases in a shared module, such as a common-types.ts, when your API and UI packages use the same contract types. The private-bid example derives the circuit union from the generated module instead, as keyof ImpureCircuits<unknown>, so a renamed circuit cannot leave the alias stale.

Which implementation fills each slot depends on where the code runs; the Provider implementations reference lists the options. The procedures below use the Node.js implementations.

Configuring providers for a contract​

Construct the six providers and assemble them into the object every deploy and call takes. The code below targets the local network; for a public network, swap the endpoints from the environment reference.

Procedure​

  1. Install the provider packages, letting the support matrix set the versions. The private-bid example already lists them in its package.json:

    npm install @midnight-ntwrk/midnight-js-types @midnight-ntwrk/midnight-js-contracts \
    @midnight-ntwrk/midnight-js-network-id @midnight-ntwrk/midnight-js-level-private-state-provider \
    @midnight-ntwrk/midnight-js-indexer-public-data-provider @midnight-ntwrk/midnight-js-node-zk-config-provider \
    @midnight-ntwrk/midnight-js-http-client-proof-provider @midnight-ntwrk/midnight-js-utils
  2. Set the network ID before touching any provider. Node.js 22 ships a global WebSocket, which the wallet SDK's indexer client uses; on older Node.js versions assign one from ws first. The public data provider ships its own implementation either way. Midnight.js reads the network ID when it normalizes addresses and builds transactions:

    import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
    import { WebSocket } from 'ws';

    setNetworkId('undeployed'); // local network; 'preprod', 'preview', or 'mainnet' for public networks
    globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; // only needed before Node.js 22
  3. Create the private state provider. It persists private state and signing keys to an encrypted LevelDB store on the local device. Two of its options are enforced at runtime: accountId is required, so stored state is scoped to one wallet and cannot leak across accounts, and the encryption password must be at least 16 characters with at least three of the four character classes (uppercase, lowercase, digits, special characters):

    import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';

    const privateStateProvider = levelPrivateStateProvider({
    privateStateStoreName: 'private-bid-private-state',
    signingKeyStoreName: 'private-bid-signing-keys',
    privateStoragePasswordProvider: () => 'Docs-Verify-2026',
    accountId: walletAddress, // the wallet's Bech32m address, or another per-account identifier
    });
    Derive the password, do not hardcode it

    The password encrypts private state and signing keys at rest. In production, derive it from wallet credentials or a key management system rather than shipping a literal in your source.

    Browser environments

    levelPrivateStateProvider works in browser environments. The SDK resolves the crypto backend automatically: it uses Web Crypto where crypto.subtle is available and falls back to a pure-JS implementation where it is not. Leaving cryptoBackend unset is the safer default, because requesting 'webcrypto' explicitly turns an unavailable Web Crypto API into a thrown error rather than a fallback. That matters in non-secure contexts, such as a page served over plain http:// on a non-loopback host, and in React Native.

    The level package resolves to browser-level (IndexedDB) in browser bundles through its package.json browser field, which Vite and webpack honor automatically, so you do not need extra storage configuration. Keep private state access on the client: code that runs outside a browser bundle, such as Next.js server components, API routes, or Edge functions, resolves to the native classic-level instead.

  4. Create the public data provider with your indexer's query and subscription endpoints:

    import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';

    const publicDataProvider = indexerPublicDataProvider(
    'http://127.0.0.1:8088/api/v4/graphql',
    'ws://127.0.0.1:8088/api/v4/graphql/ws',
    );
  5. Create the ZK config provider, pointed at the compile output directory that holds keys/ and zkir/. The type parameter is the union of your circuit names. In the example, contract/index.ts exports this directory as zkConfigPath:

    import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';

    const zkConfigProvider = new NodeZkConfigProvider<'placeBid' | 'revealBid'>('/path/to/managed/private-bid');
  6. Create the proof provider from the proof server URL and the ZK config provider, which supplies the artifacts proving needs:

    import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';

    const proofProvider = httpClientProofProvider('http://127.0.0.1:6300', zkConfigProvider);
  7. Implement the wallet and submission providers. One class can serve both slots, backed by a synced WalletFacade. Import the ledger types from @midnight-ntwrk/midnight-js-protocol/ledger, not from the ledger package directly; the protocol package re-exports the exact copies Midnight.js compiles against, and pulling in a second copy of the ledger package fails to type-check:

    import {
    type CoinPublicKey,
    type EncPublicKey,
    type FinalizedTransaction,
    ZswapSecretKeys,
    DustSecretKey,
    } from '@midnight-ntwrk/midnight-js-protocol/ledger';
    import {
    type WalletProvider,
    type MidnightProvider,
    type UnboundTransaction,
    } from '@midnight-ntwrk/midnight-js-types';
    import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
    import { type WalletFacade, type UnshieldedKeystore } from '@midnight-ntwrk/wallet-sdk';

    class MidnightWalletProvider implements WalletProvider, MidnightProvider {
    constructor(
    private readonly wallet: WalletFacade,
    private readonly zswapSecretKeys: ZswapSecretKeys,
    private readonly dustSecretKey: DustSecretKey,
    readonly unshieldedKeystore: UnshieldedKeystore,
    ) {}

    getCoinPublicKey(): CoinPublicKey {
    return this.zswapSecretKeys.coinPublicKey;
    }

    getEncryptionPublicKey(): EncPublicKey {
    return this.zswapSecretKeys.encryptionPublicKey;
    }

    async balanceTx(tx: UnboundTransaction, ttl: Date = ttlOneHour()): Promise<FinalizedTransaction> {
    const recipe = await this.wallet.balanceUnboundTransaction(
    tx,
    { shieldedSecretKeys: this.zswapSecretKeys, dustSecretKey: this.dustSecretKey },
    { ttl },
    );
    const signed = await this.wallet.signRecipe(recipe, (payload) => this.unshieldedKeystore.signData(payload));
    return await this.wallet.finalizeRecipe(signed);
    }

    submitTx(tx: FinalizedTransaction): Promise<string> {
    return this.wallet.submitTransaction(tx);
    }
    }

    getCoinPublicKey and getEncryptionPublicKey expose the keys that receive and decrypt shielded outputs; balanceTx selects DUST to cover the fee, signs any unshielded inputs that balancing added with the unshielded keystore, and finalizes the transaction; submitTx sends it to the network. The private-bid circuits spend nothing but DUST, so signRecipe has nothing to sign here. The node rejects a transaction that moves NIGHT, for example into a contract, when those signatures are missing, which is why the example's wallet provider always includes the step. Funding a wallet covers building and syncing the WalletFacade and deriving the secret keys. The wallet SDK ships under two names, @midnightntwrk/wallet-sdk and @midnight-ntwrk/wallet-sdk, and both have a 1.2.0 release. The hyphenated name's latest tag still points at 1.1.0, so pin 1.2.0 explicitly. The example pins the hyphenated one at 1.2.0 in its package.json and in the repository's root resolutions. Its src/wallet.ts builds this class from a hex seed through the FluentWalletBuilder in @midnight-ntwrk/testkit-js, which also hands back the keystore and both secret keys.

  8. Assemble the object, passing the same instance as both walletProvider and midnightProvider:

    const walletProvider = new MidnightWalletProvider(wallet, zswapSecretKeys, dustSecretKey, unshieldedKeystore);

    const providers: PrivateBidProviders = {
    privateStateProvider,
    publicDataProvider,
    zkConfigProvider,
    proofProvider,
    walletProvider,
    midnightProvider: walletProvider,
    };

Verification​

The providers assemble against real compile artifacts and endpoints, and the private state provider enforces its accountId requirement. This file, like every test in this guide, runs inside the private-bid example in a clone of midnight-examples, which supplies the compiled contract, a fuller version of the wallet class from step 7 as src/wallet.ts, the endpoints as src/config.ts, and the local network. Set that up once, with Node.js 22 and Corepack enabled as the repository's README asks:

git clone https://github.com/midnightntwrk/midnight-examples.git
cd midnight-examples
yarn install
cd examples/private-bid
compact compile +0.31.1 contract/private-bid.compact contract/managed/private-bid
yarn env:up # node, indexer, and proof server in Docker; yarn env:down stops them

Save the shared setup and each test file under src/docs-tests/, then run the files one at a time, since they all spend from the same wallet:

yarn vitest run src/docs-tests --no-file-parallelism --reporter=verbose

The setup builds the wallet from the genesis seed, syncs it, waits until it holds spendable DUST, and assembles the providers exactly as the procedure does:

src/docs-tests/setup.ts
// Shared by the docs-tests: a synced, funded wallet from the local network's
// genesis seed, and the six providers assembled for the private-bid contract.
import { WebSocket } from 'ws';
import pino from 'pino';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { type MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import { type EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js';
import { waitForDust } from '@midnight-ntwrk/example-fast-sync';
import { LOCAL_CONFIG } from '../config.js';
import { MidnightWalletProvider, syncWallet } from '../wallet.js';
import { zkConfigPath } from '../../contract/index.js';
import { type PrivateBidPrivateState } from '../../contract/witnesses.js';

// Only needed on Node.js versions before 22, which have no global WebSocket
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;

export type PrivateBidCircuits = 'placeBid' | 'revealBid';
export type PrivateBidProviders = MidnightProviders<PrivateBidCircuits, 'privateBidPrivateState', PrivateBidPrivateState>;

export const logger = pino({ level: 'info', transport: { target: 'pino-pretty' } });

// Alice: funded at genesis on the local network, never on a public one
const ALICE_SEED = '0'.repeat(63) + '1';

export async function buildWallet(): Promise<MidnightWalletProvider> {
setNetworkId(LOCAL_CONFIG.networkId);
const env: EnvironmentConfiguration = { walletNetworkId: LOCAL_CONFIG.networkId, ...LOCAL_CONFIG };
const wallet = await MidnightWalletProvider.build(logger, env, { kind: 'seed', value: ALICE_SEED });
await wallet.start();
await syncWallet(logger, wallet.wallet);
await waitForDust(logger, wallet.wallet); // synced is not funded
return wallet;
}

export function buildProviders(wallet: MidnightWalletProvider): PrivateBidProviders {
const zkConfigProvider = new NodeZkConfigProvider<PrivateBidCircuits>(zkConfigPath);
return {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'private-bid-private-state',
signingKeyStoreName: 'private-bid-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: wallet.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(LOCAL_CONFIG.indexer, LOCAL_CONFIG.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(LOCAL_CONFIG.proofServer, zkConfigProvider),
walletProvider: wallet,
midnightProvider: wallet,
};
}
src/docs-tests/providers.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { zkConfigPath } from '../../contract/index.js';
import { type MidnightWalletProvider } from '../wallet.js';
import { buildWallet, buildProviders, type PrivateBidCircuits } from './setup.js';

describe('configuring providers', () => {
let wallet: MidnightWalletProvider;

beforeAll(async () => {
wallet = await buildWallet();
});
afterAll(async () => {
await wallet.stop();
});

it('rejects a private-state store configured without an accountId', () => {
expect(() =>
(levelPrivateStateProvider as any)({
privateStateStoreName: 'docs-no-account-state',
signingKeyStoreName: 'docs-no-account-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
}),
).toThrow('accountId is required');
});

it('assembles all six providers against real artifacts and endpoints', async () => {
const providers = buildProviders(wallet);
expect(Object.keys(providers).sort()).toEqual([
'midnightProvider',
'privateStateProvider',
'proofProvider',
'publicDataProvider',
'walletProvider',
'zkConfigProvider',
]);

const zkConfigProvider = new NodeZkConfigProvider<PrivateBidCircuits>(zkConfigPath);
const verifierKey = await zkConfigProvider.getVerifierKey('placeBid');
expect(verifierKey.length).toBeGreaterThan(0);
});
});
✓ src/docs-tests/providers.test.ts > configuring providers > rejects a private-state store configured without an accountId 1ms
✓ src/docs-tests/providers.test.ts > configuring providers > assembles all six providers against real artifacts and endpoints 7ms

Deploying a contract​

Deploy by pairing the generated contract module with its compile artifacts, then calling deployContract. The contract address that comes back is how everything else reaches the contract, so capture it.

Prerequisites​

Procedure​

  1. Build a CompiledContract from the generated module. make names the contract and takes the generated Contract class, withWitnesses attaches your witness implementations (for private-bid, the one localSecretKey function from witnesses.ts), and withCompiledFileAssets points at the compile output directory. Chain them with pipe: the combinators also accept the compiled contract as a first argument, but that nested form fails to type-check against the generated witness type with Midnight.js 4.1.1, even though it still runs:

    import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
    import { Contract } from './managed/private-bid/contract/index.js';
    import { witnesses } from './witnesses.js';

    const compiled = CompiledContract.make('private-bid', Contract).pipe(
    CompiledContract.withWitnesses(witnesses),
    CompiledContract.withCompiledFileAssets('/path/to/managed/private-bid'),
    );
  2. Deploy, supplying the private state identifier, the initial private state, and the constructor's arguments. Private-bid's constructor takes the minimum bid, which the deploy writes into the sealed minimumBid ledger field for the life of the contract. The call executes the constructor, proves it, balances the fee, submits, and resolves once the transaction is finalized:

    import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';
    import { createPrivateBidPrivateState } from './witnesses.js';

    const privateState = createPrivateBidPrivateState(); // 32 random bytes as the bidder's secret key

    const deployed = await deployContract(providers, {
    compiledContract: compiled,
    privateStateId: 'privateBidPrivateState',
    initialPrivateState: privateState,
    args: [100n], // constructor(minimum: Uint<64>)
    });

    const contractAddress = deployed.deployTxData.public.contractAddress;

    When you do not pass a signingKey, deployContract samples a fresh one, sets it as the contract's maintenance authority, and stores it in your private state provider under the contract's address. Operating a maintenance authority covers supplying your own, and DeployContractOptions lists every option.

  3. Store contractAddress wherever your application needs it. Anyone reconnecting to the contract, from a CLI, a server, or a frontend, does so by address. Passing initialPrivateState stores it under privateStateId, replacing whatever the provider held there, so pass the same state you deployed with. To keep what the provider already holds, for example when a bidder reconnects on the device that placed the bid, pass privateStateId alone:

    import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';

    const found = await findDeployedContract(providers, {
    contractAddress,
    compiledContract: compiled,
    privateStateId: 'privateBidPrivateState',
    initialPrivateState: privateState,
    });
  4. Call circuits through callTx. Each call builds, proves, balances, and submits a transaction, resolving with its identifiers. The argument is the bid amount, which the circuit checks against the minimum and stores only as a commitment:

    const call = await found.callTx.placeBid(250n);
    // call.public.txId, call.public.blockHeight

Verification​

A real deploy to the local network returns an address, the reconnected contract accepts a circuit call, and the indexer serves the resulting state: the minimum bid, one commitment, and no revealed bid.

src/docs-tests/deploy.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import { Contract, ledger, zkConfigPath } from '../../contract/index.js';
import { witnesses, createPrivateBidPrivateState } from '../../contract/witnesses.js';
import { type MidnightWalletProvider } from '../wallet.js';
import { buildWallet, buildProviders, logger, type PrivateBidProviders } from './setup.js';

const MINIMUM_BID = 100n;
const BID = 250n;
const privateState = createPrivateBidPrivateState();

const compiled = CompiledContract.make('private-bid', Contract).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(zkConfigPath),
);

describe('deploying a contract', () => {
let wallet: MidnightWalletProvider;
let providers: PrivateBidProviders;
let contractAddress: string;

beforeAll(async () => {
wallet = await buildWallet();
providers = buildProviders(wallet);
});
afterAll(async () => {
await wallet.stop();
});

it('deploys and returns the contract address', async () => {
const deployed = await deployContract(providers, {
compiledContract: compiled,
privateStateId: 'privateBidPrivateState',
initialPrivateState: privateState,
args: [MINIMUM_BID],
});
contractAddress = deployed.deployTxData.public.contractAddress;
logger.info(`deployed at ${contractAddress}`);
expect(contractAddress).toMatch(/^[0-9a-f]{64}$/);
});

it('reconnects with findDeployedContract and calls a circuit', async () => {
const found = await findDeployedContract(providers, {
contractAddress,
compiledContract: compiled,
privateStateId: 'privateBidPrivateState',
initialPrivateState: privateState,
});
const call = await found.callTx.placeBid(BID);
logger.info(`placeBid tx ${call.public.txId} in block ${call.public.blockHeight}`);
expect(call.public.txId).toBeDefined();
expect(call.public.blockHeight).toBeGreaterThan(0);
});

it('reads the deployed state through the indexer', async () => {
const state = await providers.publicDataProvider.queryContractState(contractAddress);
const bids = ledger(state!.data);
expect(bids.minimumBid).toBe(MINIMUM_BID);
expect(bids.bidCommitments.size()).toBe(1n);
expect(bids.revealedBids.isEmpty()).toBe(true);
});
});
✓ src/docs-tests/deploy.test.ts > deploying a contract > deploys and returns the contract address 21725ms
✓ src/docs-tests/deploy.test.ts > deploying a contract > reconnects with findDeployedContract and calls a circuit 17266ms
✓ src/docs-tests/deploy.test.ts > deploying a contract > reads the deployed state through the indexer 8ms

Promoting a deployment to Preprod​

Promote the deploy you ran in Deploying a contract to Preprod, the public testnet where your contract is visible to other developers and explorers. The code does not change; only the configuration does. The private-bid example packages this flow as yarn test:preprod, with the shared wallet harness its repository describes in FAST-SYNC.md; the steps behind the script are the ones below.

Prerequisites​

Procedure​

  1. Fund a Preprod wallet: request tNIGHT from the faucet, then register it for DUST generation, following Funding a wallet. Without DUST, balancing fails with Wallet.InsufficientFunds.

  2. Keep the wallet secret out of your source. Load the seed or mnemonic from an environment file that your version control ignores. The example reads its Alice wallet from MIDNIGHT_PREPROD_ALICE_SEED or MIDNIGHT_PREPROD_ALICE_MNEMONIC in a .env.preprod at the repository root. yarn wallets:new mints four wallets and writes their _SEED and _BIRTHDAY lines there. Preprod traffic goes through Blockfrost, so the same file needs your BLOCKFROST_PROJECT_ID; Getting a Blockfrost project token shows where to get one. Without it the example stops with BLOCKFROST_PROJECT_ID is not set:

    # .env.preprod (listed in .gitignore; never commit or share it)
    MIDNIGHT_PREPROD_ALICE_SEED=... # 64 hex characters, or MIDNIGHT_PREPROD_ALICE_MNEMONIC with a 24-word phrase
    BLOCKFROST_PROJECT_ID=... # from your Blockfrost dashboard
  3. Point the configuration at Preprod: setNetworkId('preprod'), the Preprod indexer and node endpoints from the environment reference, and a proof server you run yourself, following Run the proof server. The proof server sees witness data in the clear, so it stays local even when the network is public.

  4. Run the same deploy code from Deploying a contract against the new configuration. The first sync of a fresh Preprod wallet walks the chain's history and takes substantially longer than the local network's.

Verification​

Look up the printed contract address on a Preprod explorer, such as midnightexplorer.com or subscan.io. The explorer shows the deploy transaction and each subsequent call transaction at that address.

Observing contract and chain state​

Watch what your deployed contract does from the outside: read its public state through the indexer, and follow blocks and events at the node when you need the chain-level view.

Procedure​

  1. Read contract state through the public data provider and decode it with the generated ledger() function. Pass the state's data field; the surrounding ContractState itself is not accepted. Each Map field of the contract becomes a read-only view with isEmpty(), size(), member(key), lookup(key), and iteration. Everything in it is public: for private-bid, a bidder key and a commitment per bid, and the amount only once the bidder reveals it:

    const state = await providers.publicDataProvider.queryContractState(contractAddress);
    const bids = ledger(state.data);
    // bids.minimumBid, bids.bidCommitments.size(), bids.revealedBids.lookup(bidderKey): typed getters, no manual decoding
  2. For the chain-level view, connect to the node's RPC endpoint with @polkadot/api, which speaks to any Substrate-based chain, including Midnight:

    import { ApiPromise, WsProvider } from '@polkadot/api';

    const api = await ApiPromise.create({
    provider: new WsProvider('ws://127.0.0.1:9944'),
    });
  3. Subscribe to new blocks. The callback fires once per block with its header:

    api.rpc.chain.subscribeNewHeads((lastHeader) => {
    console.log(`\nBlock #${lastHeader.number} has been added`);
    });
  4. Extract transactions and events from a block. On Substrate chains a transaction is an extrinsic: data arriving from outside the chain asking it to act, whether signed by a user, unsigned, or inherent system data such as the block timestamp:

    const blockHash = await api.rpc.chain.getBlockHash(blockNumber);
    const signedBlock = await api.rpc.chain.getBlock(blockHash);

    signedBlock.block.extrinsics.forEach((extrinsic, index) => {
    console.log(`\nExtrinsic ${index}: ${extrinsic.method.section}.${extrinsic.method.method}`);
    });

    const apiAt = await api.at(blockHash);
    const events = await apiAt.query.system.events();
    events.forEach(({ event, phase }) => {
    console.log(`\nEvent: ${event.section}.${event.method}, phase: ${phase.toString()}`);
    console.log(`Data: ${event.data.toString()}`);
    });

Verification​

A live node delivers block headers to the subscription, and a block gives up its extrinsics and events. The example does not depend on @polkadot/api, so add it first with yarn add @polkadot/api@17.0.2; the @polkadot/api-augment import gives events() its typed shape under the example's strict tsc settings:

src/docs-tests/observe-chain.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import '@polkadot/api-augment';
import { ApiPromise, WsProvider } from '@polkadot/api';

describe('observing the chain', () => {
let api: ApiPromise;

beforeAll(async () => {
api = await ApiPromise.create({
provider: new WsProvider('ws://127.0.0.1:9944'),
noInitWarn: true,
});
});
afterAll(async () => {
await api.disconnect();
});

it('delivers new block headers to a subscription', async () => {
const header = await new Promise<any>((resolve) => {
const unsubscribe = api.rpc.chain.subscribeNewHeads((lastHeader) => {
unsubscribe.then((stop) => stop());
resolve(lastHeader);
});
});
expect(header.number.toNumber()).toBeGreaterThan(0);
});

it('extracts extrinsics and events from a block', async () => {
const header = await api.rpc.chain.getHeader();
const blockHash = await api.rpc.chain.getBlockHash(header.number.toNumber());
const signedBlock = await api.rpc.chain.getBlock(blockHash);
expect(signedBlock.block.extrinsics.length).toBeGreaterThan(0);

const apiAt = await api.at(blockHash);
const events = await apiAt.query.system.events();
expect(events.length).toBeGreaterThan(0);
expect(events[0].event.section).toBeDefined();
});
});
✓ src/docs-tests/observe-chain.test.ts > observing the chain > delivers new block headers to a subscription 46ms
✓ src/docs-tests/observe-chain.test.ts > observing the chain > extracts extrinsics and events from a block 17ms

Contract updatability and the maintenance authority​

A deployed contract's circuits are bound to the proof system that compiled them. As Midnight's proving stack evolves, proving schemes, the circuit intermediate representation, or verifier key formats can change, and a circuit deployed under the old rules can reach a point where its proofs can no longer be generated or verified. Updatability is how a contract survives that, and it is a decision you make at deployment, not after.

The mechanism is verifier key management. Each circuit is represented on-chain by a verifier key per proof system version, and a contract's designated authority can change them after deployment. That authority is the contract maintenance authority, a committee of public keys with a signature threshold, and it can do exactly three things:

  • Insert a verifier key for a circuit at a proof system version, restoring or adding functionality. A key must not already exist at that version; remove the old one first.
  • Remove a verifier key, after which the network rejects transactions using that circuit at that version.
  • Replace the authority itself, transferring control to a new committee, or relinquishing control entirely by setting an empty one.

At the ledger level, a contract deployed with no authority configured is permanently non-upgradable: the default is an empty committee with a threshold of one, a condition no signature set can satisfy. Deploying through Midnight.js changes that picture: deployContract installs a single-signature authority for you, sampling a fresh signing key and storing it in your private state provider unless you supply your own. Whichever path you take, decide deliberately. A contract holding long-term state that cannot practically migrate, such as vesting schedules or an identity registry, needs a maintained authority; a contract meant to be immutable needs the authority relinquished so no key can ever change it.

Whoever holds the authority's keys can rewrite what the contract accepts, so custody is a security decision: distribute control across independent parties and follow the key custody guidance in the security guide. Track proof system changes through the support matrix and plan circuit updates before an incompatibility arrives; where you relinquished updatability, plan contract migration instead.

Operating a maintenance authority​

Exercise the three maintenance operations against a deployed contract: disable a circuit by removing its verifier key, re-enable it by inserting one, and hand control to a new authority.

Prerequisites​

  • A deployed contract and its providers, from Deploying a contract.
  • The maintenance authority's signing key, held by your private state provider or supplied at deploy time.

Procedure​

  1. To control the authority explicitly, generate a signing key and pass it at deploy time. Reusing one key across deployments gives several contracts the same authority:

    import { sampleSigningKey } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';

    const signingKey = sampleSigningKey();
    const deployed = await deployContract(providers, {
    compiledContract: compiled,
    privateStateId: 'privateBidPrivateState',
    initialPrivateState: privateState,
    args: [100n],
    signingKey,
    });
  2. Remove a circuit's verifier key through the per-circuit maintenance interface. Both DeployedContract and FoundContract expose circuitMaintenanceTx, with one CircuitMaintenanceTxInterface per circuit. Once the transaction lands, the network rejects calls to that circuit, so nobody can reveal a bid placed before this point until the key is back:

    await deployed.circuitMaintenanceTx.revealBid.removeVerifierKey();
  3. Insert a verifier key to re-enable the circuit. The ZK config provider reads it from the compile output; after a compiler upgrade, this is how the freshly compiled key replaces the stranded one:

    const verifierKey = await zkConfigProvider.getVerifierKey('revealBid');
    await deployed.circuitMaintenanceTx.revealBid.insertVerifierKey(verifierKey);
  4. Replace the authority when control should move, passing the new committee's signing key:

    await deployed.contractMaintenanceTx.replaceAuthority(sampleSigningKey());

Verification​

Removing the verifier key makes the network reject the circuit, inserting it restores the circuit, and the authority transfer succeeds, all against a real deployment that holds one bid. The summary at the end counts all four files of the one run:

src/docs-tests/maintenance.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { deployContract, type DeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import { sampleSigningKey } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { Contract, zkConfigPath } from '../../contract/index.js';
import { witnesses, createPrivateBidPrivateState } from '../../contract/witnesses.js';
import { type MidnightWalletProvider } from '../wallet.js';
import { buildWallet, buildProviders, logger, type PrivateBidCircuits } from './setup.js';

const MINIMUM_BID = 100n;
const BID = 250n;

const compiled = CompiledContract.make('private-bid', Contract).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(zkConfigPath),
);

describe('operating a maintenance authority', () => {
let wallet: MidnightWalletProvider;
let deployed: DeployedContract<Contract>;

beforeAll(async () => {
wallet = await buildWallet();
deployed = await deployContract(buildProviders(wallet), {
compiledContract: compiled,
privateStateId: 'privateBidPrivateState',
initialPrivateState: createPrivateBidPrivateState(),
args: [MINIMUM_BID],
signingKey: sampleSigningKey(),
});
logger.info(`maintenance drill contract at ${deployed.deployTxData.public.contractAddress}`);
await deployed.callTx.placeBid(BID);
});
afterAll(async () => {
await wallet.stop();
});

it('removing a verifier key disables the circuit on-chain', async () => {
await deployed.circuitMaintenanceTx.revealBid.removeVerifierKey();
await expect(deployed.callTx.revealBid(BID)).rejects.toThrow(
"Operation 'revealBid' is undefined for contract state",
);
});

it('inserting the verifier key re-enables the circuit', async () => {
const zkConfigProvider = new NodeZkConfigProvider<PrivateBidCircuits>(zkConfigPath);
const verifierKey = await zkConfigProvider.getVerifierKey('revealBid');
await deployed.circuitMaintenanceTx.revealBid.insertVerifierKey(verifierKey);
const call = await deployed.callTx.revealBid(BID);
expect(call.public.txId).toBeDefined();
});

it('replaces the contract maintenance authority', async () => {
await expect(
deployed.contractMaintenanceTx.replaceAuthority(sampleSigningKey()),
).resolves.not.toThrow();
});
});
✓ src/docs-tests/maintenance.test.ts > operating a maintenance authority > removing a verifier key disables the circuit on-chain 17362ms
✓ src/docs-tests/maintenance.test.ts > operating a maintenance authority > inserting the verifier key re-enables the circuit 36088ms
✓ src/docs-tests/maintenance.test.ts > operating a maintenance authority > replaces the contract maintenance authority 18758ms

Test Files 4 passed (4)
Tests 10 passed (10)
Duration 158.33s

Provider implementations​

Which implementation fills each provider slot, by environment. The Midnight.js packages live under the @midnight-ntwrk/ scope. The wallet SDK ships as both @midnightntwrk/wallet-sdk and @midnight-ntwrk/wallet-sdk. The private-bid example uses the second, pinned to 1.2.0 because that name's latest tag still resolves to 1.1.0. Take the versions from the support matrix.

SlotImplementationPackageNotes
privateStateProviderlevelPrivateStateProvidermidnight-js-level-private-state-providerEncrypted LevelDB on the local device. Requires accountId; password needs 16 or more characters and three of four character classes
publicDataProviderindexerPublicDataProvidermidnight-js-indexer-public-data-providerTakes the indexer HTTP and WebSocket URLs. Queries run in Node.js and browsers; for browser subscriptions, pass the native WebSocket as its third argument
zkConfigProviderNodeZkConfigProvidermidnight-js-node-zk-config-providerReads keys/ and zkir/ from the local filesystem
zkConfigProviderFetchZkConfigProvidermidnight-js-fetch-zk-config-providerFetches the same artifacts over HTTP, for browsers and hosted setups
proofProviderhttpClientProofProvidermidnight-js-http-client-proof-providerTakes the proof server URL and the zkConfigProvider
walletProvider, midnightProvideryour class over WalletFacadewallet-sdkOne instance can fill both slots, as in Configuring providers for a contract

Verifying passwords early with a canary value​

Without an explicit check, a wrong password is not caught until the app attempts to decrypt real private state data, which may happen deep inside a transaction flow. The failure also looks different depending on the active backend: Web Crypto rejects with a DOMException named OperationError, while the pure-JS backend throws a plain Error.

A canary value written at account creation lets you catch a wrong password at unlock time with a clear message. Split provisioning from verification: a missing canary and a wrong password are indistinguishable at read time, so bootstrapping the canary from a null read would certify the failure it exists to catch.

import type { PrivateStateProvider } from '@midnight-ntwrk/midnight-js-types';

const CANARY_KEY = '__canary__';
const CANARY_VALUE = 'midnight-verified';

// Call once, when the account's store is created, using a password you know is correct.
// Never call this to repair a missing canary: at that point a missing canary and a wrong
// password look identical, and you would be storing the wrong password as the reference.
async function provisionCanary(provider: PrivateStateProvider): Promise<void> {
await provider.set(CANARY_KEY, CANARY_VALUE);
}

// Call on every later unlock.
async function verifyPassword(provider: PrivateStateProvider): Promise<void> {
let stored: unknown;
try {
stored = await provider.get(CANARY_KEY);
} catch (cause) {
// Decryption failed, so the AES-GCM authentication tag did not verify.
// Other faults reach here too, such as an unavailable crypto backend,
// so keep the original error attached.
throw new Error('Incorrect password: private state could not be decrypted', { cause });
}
if (stored === null) {
throw new Error('No canary stored for this account. Provision one when the store is created.');
}
if (stored !== CANARY_VALUE) {
throw new Error('Canary mismatch: private state may be corrupt.');
}
}

Deployment troubleshooting​

Failures you are likely to hit on this guide's path, and their fixes. Every quoted message comes from a real run.

ErrorCauseFix
accountId is required. Provide an account identifierlevelPrivateStateProvider called without accountIdPass a per-account identifier, such as the wallet's Bech32m address
PasswordValidationError: Password must contain at least 3 of: uppercase letters, lowercase letters, digits, special charactersThe private-state encryption password is too weakProvide a password with at least three of the four character classes
Password is shorter than 16 charactersThe private-state encryption password fails the length ruleUse 16 characters or more
Wallet.InsufficientFundsBalancing found no spendable DUST to pay the fee, even though the wallet holds NIGHTRegister NIGHT for DUST generation and wait for a spendable coin; see Funding a wallet
failed assert: Bid is below the minimum (or another failed assert: message)An assert in the circuit failed while Midnight.js executed it locally, before proving, so nothing reached the networkCall with inputs the contract accepts; the message is the assert's own text
tsc: a ledger type is not assignable across two copies of @midnight-ntwrk/ledger-v8, for example Types have separate declarations of a private property 'type_' or The types of 'encryptionSecretKey.test' are incompatibleLedger types imported from the ledger package directly, so a second copy sits beside the one Midnight.js compiles againstImport ZswapSecretKeys, DustSecretKey, and transaction types from @midnight-ntwrk/midnight-js-protocol/ledger
tsc: Argument of type 'Witnesses<PrivateBidPrivateState>' is not assignable to parameter of type 'never'CompiledContract.withWitnesses called in the nested form, with the compiled contract as its first argumentChain the combinators with pipe, as in Deploying a contract
expected instance of ContractMaintenanceAuthority (or another expected instance of error at deploy)The generated contract module and Midnight.js resolve different copies of the runtime, so class checks fail across the treesKeep the contract's managed output and Midnight.js in the same package tree with one node_modules
Operation 'revealBid' is undefined for contract state ...The circuit's verifier key was removed, or none exists at the current proof system version; the message ends with a dump of the contract stateInsert a verifier key for the circuit; see Operating a maintenance authority
expected instance of ChargedStateledger() was passed the whole ContractState from the indexerPass its data field: ledger(state.data)
Indexer exits on first start of a fresh local chainStartup race documented with the local stackSee Local network troubleshooting; the example's compose.yml waits for block 1 before starting the indexer

Additional resources​