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 compilewithout skip flags, so the output containskeys/andzkir/alongsidecontract/. 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-runtime0.16.0, and run it with@midnight-ntwrk/midnight-js-*4.1.1 and@midnight-ntwrk/wallet-sdk1.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:
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 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
-
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 -
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 fromwsfirst. 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 networksglobalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket; // only needed before Node.js 22 -
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: '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 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.
Browser environmentslevelPrivateStateProviderworks in browser environments. The SDK resolves the crypto backend automatically: it uses Web Crypto wherecrypto.subtleis available and falls back to a pure-JS implementation where it is not. LeavingcryptoBackendunset 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 plainhttp://on a non-loopback host, and in React Native.The
levelpackage resolves tobrowser-level(IndexedDB) in browser bundles through its package.jsonbrowserfield, 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 nativeclassic-levelinstead. -
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. In the example,contract/index.tsexports this directory aszkConfigPath:import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';const zkConfigProvider = new NodeZkConfigProvider<'placeBid' | 'revealBid'>('/path/to/managed/private-bid'); -
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, 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);}}getCoinPublicKeyandgetEncryptionPublicKeyexpose the keys that receive and decrypt shielded outputs;balanceTxselects DUST to cover the fee, signs any unshielded inputs that balancing added with the unshielded keystore, and finalizes the transaction;submitTxsends it to the network. The private-bid circuits spend nothing but DUST, sosignRecipehas 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 theWalletFacadeand deriving the secret keys. The wallet SDK ships under two names,@midnightntwrk/wallet-sdkand@midnight-ntwrk/wallet-sdk, and both have a 1.2.0 release. The hyphenated name'slatesttag still points at 1.1.0, so pin 1.2.0 explicitly. The example pins the hyphenated one at 1.2.0 in itspackage.jsonand in the repository's rootresolutions. Itssrc/wallet.tsbuilds this class from a hex seed through theFluentWalletBuilderin@midnight-ntwrk/testkit-js, which also hands back the keystore and both secret keys. -
Assemble the object, passing the same instance as both
walletProviderandmidnightProvider: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:
// 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,
};
}
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
- 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 (for private-bid, the onelocalSecretKeyfunction fromwitnesses.ts), andwithCompiledFileAssetspoints at the compile output directory. Chain them withpipe: 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'),); -
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
minimumBidledger 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 keyconst 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,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. PassinginitialPrivateStatestores it underprivateStateId, 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, passprivateStateIdalone:import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';const found = await findDeployedContract(providers, {contractAddress,compiledContract: compiled,privateStateId: 'privateBidPrivateState',initialPrivateState: privateState,}); -
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.
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
- 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, balancing 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. The example reads its Alice wallet from
MIDNIGHT_PREPROD_ALICE_SEEDorMIDNIGHT_PREPROD_ALICE_MNEMONICin a.env.preprodat the repository root.yarn wallets:newmints four wallets and writes their_SEEDand_BIRTHDAYlines there. Preprod traffic goes through Blockfrost, so the same file needs yourBLOCKFROST_PROJECT_ID; Getting a Blockfrost project token shows where to get one. Without it the example stops withBLOCKFROST_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 phraseBLOCKFROST_PROJECT_ID=... # from your Blockfrost dashboard -
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. -
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. EachMapfield of the contract becomes a read-only view withisEmpty(),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 -
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. 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:
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
-
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,}); -
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, so nobody can reveal a bid placed before this point until the key is back:await deployed.circuitMaintenanceTx.revealBid.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('revealBid');await deployed.circuitMaintenanceTx.revealBid.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 that holds one bid. The summary at the end counts all four files of the one run:
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.
| 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 |
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.
| 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 | Balancing found no spendable DUST to pay the fee, even though the wallet holds NIGHT | Register 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 network | Call 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 incompatible | Ledger types imported from the ledger package directly, so a second copy sits beside the one Midnight.js compiles against | Import 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 argument | Chain 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 trees | Keep 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 state | 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; the example's compose.yml waits for block 1 before starting the indexer |
Additional resources
- private-bid in midnight-examples: the contract, the wallet class, the network configuration, and the test harness this guide's samples run in.
- How to build a private smart contract: the private-bid contract line by line, and why each value is public or private.
- 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.
- Deploying and calling a contract from a browser DApp: the same workflow from a web page, with the wallet extension balancing and submitting.
- 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.