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 compilewithout skip flags, so the output containskeys/andzkir/alongsidecontract/. 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:
privateStateProviderstores the contract's private state, and the signing keys the SDK generates, on the local device.publicDataProviderqueries and subscribes to on-chain contract state through the indexer's GraphQL API.zkConfigProvidersupplies the prover key, verifier key, and ZKIR artifacts from the compile.proofProvidersends proof requests to a proof server.walletProviderexposes the wallet's receiving keys and balances transactions.midnightProvidersubmits 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
-
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 -
Set the network ID before touching any provider, and polyfill
WebSocketin 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 networksglobalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; -
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:
accountIdis 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 itThe 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.
-
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',); -
Create the ZK config provider, pointed at the compile output directory that holds
keys/andzkir/. 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'); -
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); -
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);}}getCoinPublicKeyandgetEncryptionPublicKeyexpose the keys that receive and decrypt shielded outputs,balanceTxselects DUST to cover the fee and finalizes the transaction, andsubmitTxsends it to the network. Funding a wallet covers building and syncing theWalletFacadeand deriving the secret keys. -
Assemble the object, passing the same instance as both
walletProviderandmidnightProvider: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
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
- The providers object from Configuring providers for a contract.
Procedure
-
Build a
CompiledContractfrom the generated module.makenames the contract and takes the generatedContractclass,withWitnessesattaches your witness implementations, andwithCompiledFileAssetspoints 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',); -
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,deployContractsamples 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, andDeployContractOptionslists every option. -
Store
contractAddresswherever 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 },}); -
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:
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
- A deploy working against the local network, from Deploying a contract.
Procedure
-
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. -
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 -
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. -
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
-
Read contract state through the public data provider and decode it with the generated
ledger()function. Pass the state'sdatafield; the surroundingContractStateitself 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 -
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'),}); -
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`);}); -
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:
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
-
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,}); -
Remove a circuit's verifier key through the per-circuit maintenance interface. Both
DeployedContractandFoundContractexposecircuitMaintenanceTx, with oneCircuitMaintenanceTxInterfaceper circuit. Once the transaction lands, the network rejects calls to that circuit:await deployed.circuitMaintenanceTx.takeDown.removeVerifierKey(); -
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); -
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:
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.
| Slot | Implementation | Package | Notes |
|---|---|---|---|
privateStateProvider | levelPrivateStateProvider | midnight-js-level-private-state-provider | Encrypted LevelDB on the local device. Requires accountId; password needs 16 or more characters and three of four character classes |
publicDataProvider | indexerPublicDataProvider | midnight-js-indexer-public-data-provider | Takes the indexer HTTP and WebSocket URLs. Queries run in Node.js and browsers; for browser subscriptions, pass the native WebSocket as its third argument |
zkConfigProvider | NodeZkConfigProvider | midnight-js-node-zk-config-provider | Reads keys/ and zkir/ from the local filesystem |
zkConfigProvider | FetchZkConfigProvider | midnight-js-fetch-zk-config-provider | Fetches the same artifacts over HTTP, for browsers and hosted setups |
proofProvider | httpClientProofProvider | midnight-js-http-client-proof-provider | Takes the proof server URL and the zkConfigProvider |
walletProvider, midnightProvider | your class over WalletFacade | wallet-sdk | One 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.
| Error | Cause | Fix |
|---|---|---|
accountId is required. Provide an account identifier | levelPrivateStateProvider called without accountId | Pass 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 characters | The private-state encryption password is too weak | Provide a password with at least three of the four character classes |
Password is shorter than 16 characters | The private-state encryption password fails the length rule | Use 16 characters or more |
Wallet.InsufficientFunds | The wallet has NIGHT but no spendable DUST | Register 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 against | Import 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 trees | Keep the contract's managed output and Midnight.js in the same package tree with one node_modules |
Operation 'takeDown' is undefined for contract state | The circuit's verifier key was removed, or none exists at the current proof system version | Insert a verifier key for the circuit; see Operating a maintenance authority |
expected instance of ChargedState | ledger() was passed the whole ContractState from the indexer | Pass its data field: ledger(state.data) |
| Indexer exits on first start of a fresh local chain | Startup race documented with the local stack | See Local network troubleshooting |
Additional resources
- Using Compact contracts from JavaScript: the generated module, witnesses, and the off-chain test suite that proves contract logic before you spend DUST on it.
- Funding a wallet: faucet tNIGHT and DUST registration, in Lace and with the wallet SDK.
- Networks and environments: the local stack, every endpoint, and the mainnet readiness checklist.
- Security and best practices: the pre-deployment checklist, including upgrade-key custody.
- Midnight.js API reference:
deployContract,findDeployedContract, and the provider interfaces in full. - Support matrix: which compiler, runtime, and SDK versions pair.