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 bulletin board contract to the local network from Networks and environments, 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.
  • A running network. The procedures use the local stack from Running a local network; its clone also provides the wallet helpers 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; 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 BBoardPrivateState = { readonly secretKey: Uint8Array };
export type BBoardProviders = MidnightProviders<'post' | 'takeDown', 'bboardPrivateState', BBoardPrivateState>;

Keep these aliases in a shared module, such as a common-types.ts, when your API and UI packages use the same contract types.

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:

    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, and polyfill WebSocket in Node.js so the wallet SDK's indexer connection works; the public data provider ships its own implementation. 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;
  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: 'bboard-private-state',
    signingKeyStoreName: 'bboard-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.

  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:

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

    const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>('/path/to/managed/bboard');
  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 } from '@midnight-ntwrk/wallet-sdk';

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

    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 },
    );
    return await this.wallet.finalizeRecipe(recipe);
    }

    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 and finalizes the transaction, and submitTx sends it to the network. Funding a wallet covers building and syncing the WalletFacade and deriving the secret keys.

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

    const walletProvider = new BBoardWalletProvider(wallet, zswapSecretKeys, dustSecretKey);

    const providers: BBoardProviders = {
    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 midnight-local-dev clone from the local network setup, importing its wallet helpers. Save it under docs-tests/ in the clone and run:

npx vitest run docs-tests --testTimeout=600000 --hookTimeout=600000
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 { 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 { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, closeWallet, type WalletContext } from '../src/wallet.js';

const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';

describe('configuring providers', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;

beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
});
afterAll(async () => {
await closeWallet(ctx);
});

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 zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);

const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};

const providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};

expect(Object.keys(providers).sort()).toEqual([
'midnightProvider',
'privateStateProvider',
'proofProvider',
'publicDataProvider',
'walletProvider',
'zkConfigProvider',
]);

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

Test Files 1 passed (1)
Tests 2 passed (2)

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, and withCompiledFileAssets points at the compile output directory:

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

    const witnesses = {
    localSecretKey: ({ privateState }) => [privateState, privateState.secretKey],
    };

    const compiled = CompiledContract.withCompiledFileAssets(
    CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),
    '/path/to/managed/bboard',
    );
  2. Deploy, supplying the private state identifier and the initial private state. The call executes the contract's constructor, proves it, balances the fee, submits, and resolves once the transaction is finalized:

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

    const deployed = await deployContract(providers, {
    compiledContract: compiled,
    privateStateId: 'bboardPrivateState',
    initialPrivateState: { secretKey },
    });

    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:

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

    const found = await findDeployedContract(providers, {
    contractAddress,
    compiledContract: compiled,
    privateStateId: 'bboardPrivateState',
    initialPrivateState: { secretKey },
    });
  4. Call circuits through callTx. Each call builds, proves, balances, and submits a transaction, resolving with its identifiers:

    const call = await found.callTx.post('Hello from the network!');
    // 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:

docs-tests/deploy.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
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 { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { Contract, State, ledger } from '../managed-bboard/contract/index.js';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, registerNightForDust, closeWallet, type WalletContext } from '../src/wallet.js';

const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';
const secretKey = new Uint8Array(32);

const witnesses = {
localSecretKey: ({ privateState }: any) => [privateState, privateState.secretKey],
};

describe('deploying a contract', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;
let providers: any;
let compiled: any;
let contractAddress: string;

beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
await registerNightForDust(ctx);

const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};
providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};

compiled = CompiledContract.withCompiledFileAssets(
CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),
BBOARD,
);
});
afterAll(async () => {
await closeWallet(ctx);
});

it('deploys and returns the contract address', async () => {
const deployed = await deployContract(providers, {
compiledContract: compiled,
privateStateId: 'bboardPrivateState',
initialPrivateState: { secretKey },
});
contractAddress = deployed.deployTxData.public.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: 'bboardPrivateState',
initialPrivateState: { secretKey },
});
const call = await found.callTx.post('Deployed from the guide verification');
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 board = ledger(state.data);
expect(board.state).toBe(State.OCCUPIED);
expect(board.message.value).toBe('Deployed from the guide verification');
});
});
✓ docs-tests/deploy.test.ts > deploying a contract > deploys and returns the contract address 20427ms
✓ docs-tests/deploy.test.ts > deploying a contract > reconnects with findDeployedContract and calls a circuit 18675ms
✓ docs-tests/deploy.test.ts > deploying a contract > reads the deployed state through the indexer 8ms

Test Files 1 passed (1)
Tests 3 passed (3)

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. If you started from the hello world tutorial instead, its repository packages this whole flow as yarn test:preprod, with the same steps behind the script.

Prerequisites

Procedure

  1. Fund a Preprod wallet: request tNIGHT from the faucet, then register it for DUST generation, following Funding a wallet. Without DUST, submission 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:

    # .env.preprod (listed in .gitignore; never commit or share it)
    MIDNIGHT_PREPROD_SEED=... # 64 hex characters, or a 24-word mnemonic variable
  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 a 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:

    const state = await providers.publicDataProvider.queryContractState(contractAddress);
    const board = ledger(state.data);
    // board.state, board.message.value, board.sequence: 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:

docs-tests/observe-chain.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
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();
});
});
✓ docs-tests/observe-chain.test.ts > observing the chain > delivers new block headers to a subscription 50ms
✓ docs-tests/observe-chain.test.ts > observing the chain > extracts extrinsics and events from a block 58ms

Test Files 1 passed (1)
Tests 2 passed (2)

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: 'bboardPrivateState',
    initialPrivateState: { secretKey },
    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:

    await deployed.circuitMaintenanceTx.takeDown.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('takeDown');
    await deployed.circuitMaintenanceTx.takeDown.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:

docs-tests/maintenance.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
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 { deployContract } 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 { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { Contract } from '../managed-bboard/contract/index.js';
import { StandaloneConfig } from '../src/config.js';
import { buildWalletFromHexSeed, registerNightForDust, closeWallet, type WalletContext } from '../src/wallet.js';

const BBOARD = new URL('../managed-bboard', import.meta.url).pathname;
const GENESIS_SEED = '0'.repeat(63) + '1';
const secretKey = new Uint8Array(32);

const witnesses = {
localSecretKey: ({ privateState }: any) => [privateState, privateState.secretKey],
};

describe('operating a maintenance authority', () => {
const config = new StandaloneConfig();
let ctx: WalletContext;
let deployed: any;

beforeAll(async () => {
ctx = await buildWalletFromHexSeed(config, GENESIS_SEED);
await registerNightForDust(ctx);

const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(BBOARD);
const walletAndMidnightProvider = {
getCoinPublicKey: () => ctx.shieldedSecretKeys.coinPublicKey,
getEncryptionPublicKey: () => ctx.shieldedSecretKeys.encryptionPublicKey,
balanceTx: async (tx: any, ttl: Date = ttlOneHour()) => {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl },
);
return await ctx.wallet.finalizeRecipe(recipe);
},
submitTx: (tx: any) => ctx.wallet.submitTransaction(tx),
};
const providers = {
privateStateProvider: levelPrivateStateProvider({
privateStateStoreName: 'docs-bboard-private-state',
signingKeyStoreName: 'docs-bboard-signing-keys',
privateStoragePasswordProvider: () => 'Docs-Verify-2026',
accountId: ctx.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};

const compiled = CompiledContract.withCompiledFileAssets(
CompiledContract.withWitnesses(CompiledContract.make('bboard', Contract), witnesses),
BBOARD,
);

deployed = await deployContract(providers, {
compiledContract: compiled,
privateStateId: 'bboardPrivateState',
initialPrivateState: { secretKey },
signingKey: sampleSigningKey(),
});
await deployed.callTx.post('Maintenance drill');
});
afterAll(async () => {
await closeWallet(ctx);
});

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

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

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

Test Files 1 passed (1)
Tests 3 passed (3)

Provider implementations

Which implementation fills each provider slot, by environment. Packages live under the @midnight-ntwrk/ scope at the versions the support matrix lists.

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

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.InsufficientFundsThe wallet has NIGHT but no spendable DUSTRegister NIGHT for DUST generation and wait for a spendable coin; see Funding a wallet
tsc: Types have separate declarations of a private property 'type_'Ledger types imported from the ledger package directly, clashing with the copies Midnight.js compiles againstImport ZswapSecretKeys, DustSecretKey, and transaction types from @midnight-ntwrk/midnight-js-protocol/ledger
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 'takeDown' is undefined for contract stateThe circuit's verifier key was removed, or none exists at the current proof system versionInsert 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

Additional resources