For the complete documentation index, see llms.txt
Bulletin board CLI implementation
This tutorial shows how to implement the CLI package that provides wallet management, DUST generation, and the interactive command-line interface.
Prerequisites
Before you begin, ensure that you have completed the Bulletin board API implementation tutorial.
Create the CLI directory
From the root, create the bboard-cli structure:
cd ..
mkdir -p bboard-cli/src/launcher
cd bboard-cli
Configure the CLI package
Create bboard-cli/package.json:
{
"name": "@midnight-ntwrk/bboard-cli",
"version": "0.1.0",
"author": "IOG",
"license": "MIT",
"private": true,
"type": "module",
"scripts": {
"build": "rm -rf dist && tsc --project tsconfig.build.json && cp -R ../contract/src/managed dist/contract/src/managed",
"ci": "npm run typecheck && npm run lint && npm run build",
"lint": "eslint src",
"prepack": "npm run build",
"standalone": "node --experimental-specifier-resolution=node --loader ts-node/esm src/launcher/standalone.ts",
"preview-remote": "node --experimental-specifier-resolution=node --loader ts-node/esm src/launcher/preview.ts",
"preprod-remote": "node --experimental-specifier-resolution=node --loader ts-node/esm src/launcher/preprod.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"@types/node": "^25.3.0"
}
}
Each launcher script targets a different network:
preprod-remote: Connects to the hosted Preprod testnetpreview-remote: Connects to the hosted Preview testnetstandalone: Connects to a local Midnight network running on your machine using the genesis-mint wallet seed
For more information on setting up a local Midnight network, see the Midnight local network documentation.
Configure TypeScript
Create bboard-cli/tsconfig.json:
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
Create bboard-cli/tsconfig.build.json to exclude tests from production builds:
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts"],
"compilerOptions": {}
}
Network configuration
The configuration module defines network-specific settings for connecting to different Midnight environments. It provides a common Config interface that standardizes how the CLI accesses network endpoints, manages private state storage, and configures DUST generation settings.
Create bboard-cli/src/config.ts and start with the shared configuration interface:
import path from 'node:path';
import {
EnvironmentConfiguration,
getTestEnvironment,
RemoteTestEnvironment,
TestEnvironment,
} from '@midnight-ntwrk/testkit-js';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { Logger } from 'pino';
export interface Config {
readonly privateStateStoreName: string;
readonly logDir: string;
readonly zkConfigPath: string;
getEnvironment(logger: Logger): TestEnvironment;
readonly generateDust: boolean;
}
export const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');
The CLI supports three network environments:
- Standalone: Connects to a local Midnight network running on your machine and uses the genesis-mint wallet seed; DUST generation is disabled because the genesis wallet already holds tDUST.
- Preview: Connects to the hosted Preview testnet.
- Preprod: Connects to the hosted Preprod testnet.
export class StandaloneConfig implements Config {
getEnvironment(logger: Logger): TestEnvironment {
return getTestEnvironment(logger) as TestEnvironment;
}
privateStateStoreName = 'bboard-private-state';
logDir = path.resolve(currentDir, '..', 'logs', 'standalone', `${new Date().toISOString()}.log`);
zkConfigPath = path.resolve(currentDir, '..', '..', 'contract', 'src', 'managed', 'bboard');
generateDust = false;
}
export class PreviewRemoteConfig implements Config {
getEnvironment(logger: Logger): TestEnvironment {
setNetworkId('preview');
return new PreviewTestEnvironment(logger);
}
privateStateStoreName = 'bboard-private-state';
logDir = path.resolve(currentDir, '..', 'logs', 'preview-remote', `${new Date().toISOString()}.log`);
zkConfigPath = path.resolve(currentDir, '..', '..', 'contract', 'src', 'managed', 'bboard');
generateDust = true;
}
export class PreprodRemoteConfig implements Config {
getEnvironment(logger: Logger): TestEnvironment {
setNetworkId('preprod');
return new PreprodTestEnvironment(logger);
}
privateStateStoreName = 'bboard-private-state';
logDir = path.resolve(currentDir, '..', 'logs', 'preprod-remote', `${new Date().toISOString()}.log`);
zkConfigPath = path.resolve(currentDir, '..', '..', 'contract', 'src', 'managed', 'bboard');
generateDust = true;
}
export class PreviewTestEnvironment extends RemoteTestEnvironment {
constructor(logger: Logger) {
super(logger);
}
private getProofServerUrl(): string {
const container = this.proofServerContainer as { getUrl(): string } | undefined;
if (!container) {
throw new Error('Proof server container is not available.');
}
return container.getUrl();
}
getEnvironmentConfiguration(): EnvironmentConfiguration {
return {
walletNetworkId: 'preview',
networkId: 'preview',
indexer: 'https://indexer.preview.midnight.network/api/v4/graphql',
indexerWS: 'wss://indexer.preview.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preview.midnight.network',
nodeWS: 'wss://rpc.preview.midnight.network',
faucet: 'https://midnight-tmnight-preview.nethermind.dev/',
proofServer: this.getProofServerUrl(),
};
}
}
export class PreprodTestEnvironment extends RemoteTestEnvironment {
constructor(logger: Logger) {
super(logger);
}
private getProofServerUrl(): string {
const container = this.proofServerContainer as { getUrl(): string } | undefined;
if (!container) {
throw new Error('Proof server container is not available.');
}
return container.getUrl();
}
getEnvironmentConfiguration(): EnvironmentConfiguration {
return {
walletNetworkId: 'preprod',
networkId: 'preprod',
indexer: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWS: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preprod.midnight.network',
nodeWS: 'wss://rpc.preprod.midnight.network',
faucet: 'https://midnight-tmnight-preprod.nethermind.dev/',
proofServer: this.getProofServerUrl(),
};
}
}
All three configurations rely on the local Docker proof server for generating zero-knowledge (ZK) proofs. The local proof server provides better performance and doesn't require external network access for proof generation.
Implement logging utilities
Create bboard-cli/src/logger-utils.ts:
import * as path from 'node:path';
import * as fs from 'node:fs/promises';
import pinoPretty from 'pino-pretty';
import pino from 'pino';
import { createWriteStream } from 'node:fs';
export const createLogger = async (logPath: string): Promise<pino.Logger> => {
await fs.mkdir(path.dirname(logPath), { recursive: true });
const pretty: pinoPretty.PrettyStream = pinoPretty({
colorize: true,
sync: true,
});
const level =
process.env.DEBUG_LEVEL !== undefined && process.env.DEBUG_LEVEL !== null && process.env.DEBUG_LEVEL !== ''
? process.env.DEBUG_LEVEL
: 'info';
return pino(
{
level,
depthLimit: 20,
},
pino.multistream([
{ stream: pretty, level },
{ stream: createWriteStream(logPath), level },
]),
);
};
The logger creates two output streams: a pretty-printed console stream for development and a file stream for persistent logs. The log level can be controlled through the DEBUG_LEVEL environment variable.
Implement wallet utilities
Wallet utilities manage wallet state synchronization and funding operations. These functions ensure the wallet is properly synced with the blockchain and has sufficient funds before attempting contract interactions.
Create bboard-cli/src/wallet-utils.ts:
import { UnshieldedTokenType } from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { type FacadeState, type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { type ShieldedWalletAPI, type ShieldedWalletState } from '@midnight-ntwrk/wallet-sdk-shielded';
import { type UnshieldedWalletAPI, type UnshieldedWalletState } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import * as Rx from 'rxjs';
import { FaucetClient, type EnvironmentConfiguration } from '@midnight-ntwrk/testkit-js';
import { Logger } from 'pino';
import { UnshieldedAddress } from '@midnight-ntwrk/wallet-sdk-address-format';
import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
Get initial wallet state
Before starting wallet synchronization, you need to access the current state to retrieve addresses and check balances. These helper functions use RxJS firstValueFrom() to convert the wallet's state observable into a Promise that resolves with the first emitted value.
Add the initial state functions:
export const getInitialShieldedState = async (
logger: Logger,
wallet: ShieldedWalletAPI,
): Promise<ShieldedWalletState> => {
logger.info('Getting initial state of wallet...');
return Rx.firstValueFrom(wallet.state);
};
export const getInitialUnshieldedState = async (
logger: Logger,
wallet: UnshieldedWalletAPI,
): Promise<UnshieldedWalletState> => {
logger.info('Getting initial state of wallet...');
return Rx.firstValueFrom(wallet.state);
};
These functions provide type-safe access to wallet state:
getInitialShieldedState(): Retrieves shielded wallet state from aShieldedWalletAPIand logs the operationgetInitialUnshieldedState(): Retrieves unshielded wallet state from anUnshieldedWalletAPIand logs the operation
Sync wallet
The syncWallet function monitors the wallet's synchronization progress across all three components (shielded, unshielded, and DUST) and waits until they are fully synced with the blockchain. This is essential before performing any wallet operations.
Add the sync wallet function:
const isProgressStrictlyComplete = (progress: unknown): boolean => {
if (!progress || typeof progress !== 'object') {
return false;
}
const candidate = progress as { isStrictlyComplete?: unknown };
if (typeof candidate.isStrictlyComplete !== 'function') {
return false;
}
return (candidate.isStrictlyComplete as () => boolean)();
};
const isFacadeStateSynced = (state: FacadeState): boolean =>
isProgressStrictlyComplete(state.shielded.state.progress) &&
isProgressStrictlyComplete(state.dust.state.progress) &&
isProgressStrictlyComplete(state.unshielded.progress);
export const syncWallet = (logger: Logger, wallet: WalletFacade, throttleTime = 2_000) => {
logger.info('Syncing wallet...');
return Rx.firstValueFrom(
wallet.state().pipe(
Rx.tap((state: FacadeState) => {
const shieldedSynced = isProgressStrictlyComplete(state.shielded.state.progress);
const unshieldedSynced = isProgressStrictlyComplete(state.unshielded.progress);
const dustSynced = isProgressStrictlyComplete(state.dust.state.progress);
logger.debug(
`Wallet synced state emission: { shielded=${shieldedSynced}, unshielded=${unshieldedSynced}, dust=${dustSynced} }`,
);
}),
Rx.throttleTime(throttleTime),
Rx.tap((state: FacadeState) => {
const shieldedSynced = isProgressStrictlyComplete(state.shielded.state.progress);
const unshieldedSynced = isProgressStrictlyComplete(state.unshielded.progress);
const dustSynced = isProgressStrictlyComplete(state.dust.state.progress);
const isSynced = shieldedSynced && dustSynced && unshieldedSynced;
logger.debug(
`Wallet synced state emission (synced=${isSynced}): { shielded=${shieldedSynced}, unshielded=${unshieldedSynced}, dust=${dustSynced} }`,
);
}),
Rx.filter((state: FacadeState) => isFacadeStateSynced(state)),
Rx.tap(() => logger.info('Sync complete')),
Rx.tap((state: FacadeState) => {
const shieldedBalances = state.shielded.balances || {};
const unshieldedBalances = state.unshielded.balances || {};
const dustBalances = state.dust.balance(new Date(Date.now())) || 0n;
logger.info(
`Wallet balances after sync - Shielded: ${JSON.stringify(shieldedBalances)}, Unshielded: ${JSON.stringify(unshieldedBalances)}, Dust: ${dustBalances}`,
);
}),
),
);
};
The isFacadeStateSynced helper centralizes the three-way progress check (shielded, unshielded, dust) so is reusable by waitForUnshieldedFunds. The DUST balance is read via state.dust.balance(...) — the older walletBalance(...) API is removed in @midnight-ntwrk/wallet-sdk 1.0.
Wait for unshielded funds
Before deploying contracts or submitting transactions, the wallet needs unshielded tNIGHT tokens to generate DUST for paying network fees. This function ensures sufficient funds are available, optionally requesting tokens from the faucet for testing.
Add the funding function:
export const waitForUnshieldedFunds = async (
logger: Logger,
wallet: WalletFacade,
env: EnvironmentConfiguration,
tokenType: UnshieldedTokenType,
fundFromFaucet = false,
throttleTime = 2_000,
): Promise<UnshieldedWalletState> => {
const initialState = await getInitialUnshieldedState(logger, wallet.unshielded);
const unshieldedAddress = UnshieldedAddress.codec.encode(getNetworkId(), initialState.address);
logger.info(`Using unshielded address: ${unshieldedAddress.toString()} waiting for funds...`);
if (fundFromFaucet && env.faucet) {
logger.info('Requesting tokens from faucet...');
await new FaucetClient(env.faucet, logger).requestTokens(unshieldedAddress.toString());
}
const initialBalance = initialState.balances[tokenType.raw];
if (initialBalance === undefined || initialBalance === 0n) {
logger.info(`Your wallet initial balance is: 0 (not yet initialized)`);
logger.info(`Waiting to receive tokens...`);
return Rx.firstValueFrom(
wallet.state().pipe(
Rx.tap((state: FacadeState) => {
const balance = state.unshielded.balances[tokenType.raw] ?? 0n;
logger.debug(
`Wallet funds state emission: { synced=${isFacadeStateSynced(state)}, balance=${balance.toString()} }`,
);
}),
Rx.throttleTime(throttleTime),
Rx.filter(
(state: FacadeState) => isFacadeStateSynced(state) && (state.unshielded.balances[tokenType.raw] ?? 0n) > 0n,
),
Rx.tap(() => logger.info('Sync complete')),
Rx.tap((state: FacadeState) => {
const shieldedBalances = state.shielded.balances || {};
const unshieldedBalances = state.unshielded.balances || {};
const dustBalances = state.dust.balance(new Date(Date.now())) || 0n;
logger.info(
`Wallet balances after sync - Shielded: ${JSON.stringify(shieldedBalances)}, Unshielded: ${JSON.stringify(unshieldedBalances)}, Dust: ${dustBalances}`,
);
}),
Rx.map((state: FacadeState) => state.unshielded),
),
);
}
return initialState;
};
waitForUnshieldedFunds waits until the wallet is fully synced and the unshielded balance for the requested token type is greater than zero — this avoids races where the wallet finishes its initial sync before the faucet credit is observed.
Implement DUST generation
Create bboard-cli/src/generate-dust.ts:
import { type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { createKeystore, UnshieldedWalletState } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { Logger } from 'pino';
import { HDWallet, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import * as rx from 'rxjs';
export const getUnshieldedSeed = (seed: string): Uint8Array<ArrayBufferLike> => {
const seedBuffer = Buffer.from(seed, 'hex');
const hdWalletResult = HDWallet.fromSeed(seedBuffer);
const { hdWallet } = hdWalletResult as {
type: 'seedOk';
hdWallet: HDWallet;
};
const derivationResult = hdWallet.selectAccount(0).selectRole(Roles.NightExternal).deriveKeyAt(0);
if (derivationResult.type === 'keyOutOfBounds') {
throw new Error('Key derivation out of bounds');
}
return derivationResult.key;
};
export const generateDust = async (
logger: Logger,
walletSeed: string,
unshieldedState: UnshieldedWalletState,
walletFacade: WalletFacade,
) => {
const dustState = await walletFacade.dust.waitForSyncedState();
const networkId = getNetworkId();
const unshieldedKeystore = createKeystore(getUnshieldedSeed(walletSeed), networkId);
const utxos = unshieldedState.availableCoins.filter((coin) => !coin.meta.registeredForDustGeneration);
if (utxos.length === 0) {
logger.info('No unregistered UTXOs found for dust generation.');
return;
}
logger.info(`Generating dust with ${utxos.length} UTXOs...`);
const recipe = await walletFacade.registerNightUtxosForDustGeneration(
utxos,
unshieldedKeystore.getPublicKey(),
(payload) => unshieldedKeystore.signData(payload),
dustState.address,
);
const transaction = await walletFacade.finalizeRecipe(recipe);
const txId = await walletFacade.submitTransaction(transaction);
const dustBalance = await rx.firstValueFrom(
walletFacade.state().pipe(
rx.filter((s) => s.dust.balance(new Date()) > 0n),
rx.map((s) => s.dust.balance(new Date())),
),
);
logger.info(`Dust generation transaction submitted with txId: ${txId}`);
logger.info(`Receiver dust balance after generation: ${dustBalance}`);
return txId;
};
DUST generation designates tNIGHT tokens to automatically produce DUST for transaction fees.
The getUnshieldedSeed function derives an unshielded wallet key from the HD wallet seed using the NightExternal role.
The generateDust function:
- Filters out UTXOs that are already registered for DUST generation
- Builds a registration recipe via
walletFacade.registerNightUtxosForDustGeneration(), passing a callback that signs each intent with the unshielded keystore - Finalizes the recipe and submits the transaction to the network
- Waits for the DUST balance observable to emit a non-zero value
The wallet SDK 1.0 facade hides the intent construction and signature wiring that earlier wallet packages required, so the CLI just supplies a signing callback and the wallet does the rest.
Implement wallet provider
The wallet provider bridges the Wallet SDK to the Midnight.js contracts API, implementing the interfaces required by the contract deployment and transaction system. It manages cryptographic keys, transaction balancing, and wallet lifecycle operations.
Create bboard-cli/src/midnight-wallet-provider.ts and add the following imports and class definition:
import {
type CoinPublicKey,
DustSecretKey,
type EncPublicKey,
type FinalizedTransaction,
LedgerParameters,
ZswapSecretKeys,
} from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { type MidnightProvider, type UnboundTransaction, type WalletProvider } from '@midnight-ntwrk/midnight-js-types';
import { ttlOneHour } from '@midnight-ntwrk/midnight-js-utils';
import { type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import type { Logger } from 'pino';
import { getInitialShieldedState } from './wallet-utils';
import { type DustWalletOptions, type EnvironmentConfiguration, FluentWalletBuilder } from '@midnight-ntwrk/testkit-js';
type UnshieldedKeystore = {
getPublicKey(): unknown;
signData(payload: Uint8Array): string;
};
export class MidnightWalletProvider implements MidnightProvider, WalletProvider {
logger: Logger;
readonly env: EnvironmentConfiguration;
readonly wallet: WalletFacade;
readonly unshieldedKeystore: UnshieldedKeystore;
readonly zswapSecretKeys: ZswapSecretKeys;
readonly dustSecretKey: DustSecretKey;
private constructor(
logger: Logger,
environmentConfiguration: EnvironmentConfiguration,
wallet: WalletFacade,
zswapSecretKeys: ZswapSecretKeys,
dustSecretKey: DustSecretKey,
unshieldedKeystore: UnshieldedKeystore,
) {
this.logger = logger;
this.env = environmentConfiguration;
this.wallet = wallet;
this.zswapSecretKeys = zswapSecretKeys;
this.dustSecretKey = dustSecretKey;
this.unshieldedKeystore = unshieldedKeystore;
}
The class stores the wallet facade, the unshielded keystore (used to sign intent payloads when balancing transactions), cryptographic keys for shielded and DUST operations, and environment configuration. Importing the ledger types through the @midnight-ntwrk/midnight-js-protocol/ledger subpath replaces the standalone @midnight-ntwrk/ledger-v8 package.
Implement key provider methods
These methods expose the public keys needed for receiving shielded funds and decrypting transaction data:
getCoinPublicKey(): CoinPublicKey {
return this.zswapSecretKeys.coinPublicKey;
}
getEncryptionPublicKey(): EncPublicKey {
return this.zswapSecretKeys.encryptionPublicKey;
}
The getCoinPublicKey() method returns the address for receiving shielded tokens, while getEncryptionPublicKey() provides the key for decrypting shielded transaction data sent to this wallet.
Implement transaction methods
These methods handle the transaction lifecycle from balancing through submission:
async balanceTx(tx: UnboundTransaction, ttl: Date = ttlOneHour()): Promise<FinalizedTransaction> {
const recipe = await this.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: this.zswapSecretKeys, dustSecretKey: this.dustSecretKey },
{ ttl },
);
const signedRecipe = await this.wallet.signRecipe(recipe, (payload) => this.unshieldedKeystore.signData(payload));
return this.wallet.finalizeRecipe(signedRecipe);
}
submitTx(tx: FinalizedTransaction): Promise<string> {
return this.wallet.submitTransaction(tx);
}
The balanceTx() method takes an unbound transaction (without inputs/outputs selected) and balances it by selecting appropriate UTXOs to cover fees and adding change outputs. It then asks the wallet to sign any unshielded intent payloads using the unshielded keystore before finalizing the recipe. The submitTx() method submits the finalized transaction to the network and returns the transaction hash.
Implement lifecycle methods
These methods control wallet startup and shutdown:
async start(): Promise<void> {
this.logger.info('Starting wallet...');
await this.wallet.start(this.zswapSecretKeys, this.dustSecretKey);
}
async stop(): Promise<void> {
return this.wallet.stop();
}
The start() method initializes the wallet with cryptographic keys and begins syncing with the blockchain. The stop() method gracefully shuts down the wallet and cleans up resources. Always call stop() before the application exits.
Implement the build factory method
The factory method creates and configures a wallet instance with proper DUST settings:
static async build(logger: Logger, env: EnvironmentConfiguration, seed?: string): Promise<MidnightWalletProvider> {
const dustOptions: DustWalletOptions = {
ledgerParams: LedgerParameters.initialParameters(),
additionalFeeOverhead: env.walletNetworkId === 'undeployed' ? 500_000_000_000_000_000n : 1_000n,
feeBlocksMargin: 5,
};
const builder = FluentWalletBuilder.forEnvironment(env).withDustOptions(dustOptions);
const buildResult = seed
? await builder.withSeed(seed).buildWithoutStarting()
: await builder.withRandomSeed().buildWithoutStarting();
const { wallet, seeds, keystore } = buildResult as unknown as {
wallet: WalletFacade;
seeds: { masterSeed: string; shielded: Uint8Array; dust: Uint8Array };
keystore: UnshieldedKeystore;
};
const initialState = await getInitialShieldedState(logger, wallet.shielded);
logger.info(
`Your wallet seed is: ${seeds.masterSeed} and your address is: ${initialState.address.coinPublicKeyString()}`,
);
return new MidnightWalletProvider(
logger,
env,
wallet,
ZswapSecretKeys.fromSeed(seeds.shielded),
DustSecretKey.fromSeed(seeds.dust),
keystore,
);
}
}
The factory method configures DUST options with a network-dependent additionalFeeOverhead. The default value provided by @midnight-ntwrk/testkit-js (500_000_000_000_000_000n) is required on the undeployed (standalone) network — lower values can fail with BalanceCheckOverspend on the node side. On remote testnets, that overhead would require more DUST than the wallet typically holds, so the CLI overrides it to 1_000n.
If a seed is provided, the builder restores an existing wallet; otherwise it generates a new random seed. The builder result also exposes a pre-constructed keystore for signing unshielded intents. The method logs the seed and shielded address for user reference and stores all cryptographic material in the provider.
Implement the main CLI logic
The main CLI module manages the entire bulletin board application, tying together wallet setup, contract interactions, and the interactive user interface. It manages the application lifecycle from environment startup through graceful shutdown.
Create bboard-cli/src/index.ts and add the following imports:
import { createInterface, type Interface } from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { WebSocket } from 'ws';
import {
BBoardAPI,
type BBoardDerivedState,
bboardPrivateStateKey,
type BBoardProviders,
type DeployedBBoardContract,
type PrivateStateId,
} from '../../api/src/index';
import { type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ledger, type Ledger, State } from '../../contract/src/managed/bboard/contract/index.js';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { type Logger } from 'pino';
import { type Config, StandaloneConfig } from './config.js';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { type ContractAddress } from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import { assertIsContractAddress, toHex } from '@midnight-ntwrk/midnight-js-utils';
import { TestEnvironment } from '@midnight-ntwrk/testkit-js';
import { MidnightWalletProvider } from './midnight-wallet-provider';
import { randomBytes } from '../../api/src/utils';
import { unshieldedToken } from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { syncWallet, waitForUnshieldedFunds } from './wallet-utils';
import { generateDust } from './generate-dust';
import { BBoardPrivateState } from '../../contract/src/witnesses.js';
// @ts-expect-error: It's needed to enable WebSocket usage through apollo
globalThis.WebSocket = WebSocket;
The globalThis.WebSocket assignment configures the Node.js WebSocket implementation for browser-compatible APIs, enabling WebSocket connections to the Indexer and Node RPC services.
Query ledger state
This helper function retrieves the current public ledger state from a deployed contract:
export const getBBoardLedgerState = async (
providers: BBoardProviders,
contractAddress: ContractAddress,
): Promise<Ledger | null> => {
assertIsContractAddress(contractAddress);
const contractState = await providers.publicDataProvider.queryContractState(contractAddress);
return contractState != null ? ledger(contractState.data) : null;
};
Deploy or join contract menu
This function presents a menu allowing users to deploy a new bulletin board or connect to an existing one:
const DEPLOY_OR_JOIN_QUESTION = `
You can do one of the following:
1. Deploy a new bulletin board contract
2. Join an existing bulletin board contract
3. Exit
Which would you like to do? `;
const deployOrJoin = async (providers: BBoardProviders, rli: Interface, logger: Logger): Promise<BBoardAPI | null> => {
let api: BBoardAPI | null = null;
while (true) {
const choice = await rli.question(DEPLOY_OR_JOIN_QUESTION);
switch (choice) {
case '1':
api = await BBoardAPI.deploy(providers, logger);
logger.info(`Deployed contract at address: ${api.deployedContractAddress}`);
return api;
case '2':
api = await BBoardAPI.join(providers, await rli.question('What is the contract address (in hex)? '), logger);
logger.info(`Joined contract at address: ${api.deployedContractAddress}`);
return api;
case '3':
logger.info('Exiting...');
return null;
default:
logger.error(`Invalid choice: ${choice}`);
}
}
};
Display state helper functions
These functions display different views of the bulletin board state. Add them to visualize public ledger state, private state, and derived state:
const displayLedgerState = async (
providers: BBoardProviders,
deployedBBoardContract: DeployedBBoardContract,
logger: Logger,
): Promise<void> => {
const contractAddress = deployedBBoardContract.deployTxData.public.contractAddress;
const ledgerState = await getBBoardLedgerState(providers, contractAddress);
if (ledgerState === null) {
logger.info(`There is no bulletin board contract deployed at ${contractAddress}`);
} else {
const boardState = ledgerState.state === State.OCCUPIED ? 'occupied' : 'vacant';
const latestMessage = !ledgerState.message.is_some ? 'none' : ledgerState.message.value;
logger.info(`Current state is: '${boardState}'`);
logger.info(`Current message is: '${latestMessage}'`);
logger.info(`Current sequence is: ${ledgerState.sequence}`);
logger.info(`Current owner is: '${toHex(ledgerState.owner)}'`);
}
};
const displayPrivateState = async (providers: BBoardProviders, logger: Logger): Promise<void> => {
const privateState = await providers.privateStateProvider.get(bboardPrivateStateKey);
if (privateState === null) {
logger.info(`There is no existing bulletin board private state`);
} else {
logger.info(`Current secret key is: ${toHex(privateState.secretKey)}`);
}
};
const displayDerivedState = (ledgerState: BBoardDerivedState | undefined, logger: Logger) => {
if (ledgerState === undefined) {
logger.info(`No bulletin board state currently available`);
} else {
const boardState = ledgerState.state === State.OCCUPIED ? 'occupied' : 'vacant';
const latestMessage = ledgerState.state === State.OCCUPIED ? ledgerState.message : 'none';
logger.info(`Current state is: '${boardState}'`);
logger.info(`Current message is: '${latestMessage}'`);
logger.info(`Current sequence is: ${ledgerState.sequence}`);
logger.info(`Current owner is: '${ledgerState.isOwner ? 'you' : 'not you'}'`);
}
};
These display functions show:
- Ledger state: Public on-chain data visible to all network participants
- Private state: Local secret key that never appears on-chain
- Derived state: Combines public and private data to compute ownership
Main interaction loop
The main loop provides the interactive menu for bulletin board operations:
const MAIN_LOOP_QUESTION = `
You can do one of the following:
1. Post a message
2. Take down your message
3. Display the current ledger state (known by everyone)
4. Display the current private state (known only to this DApp instance)
5. Display the current derived state (known only to this DApp instance)
6. Exit
Which would you like to do? `;
const mainLoop = async (providers: BBoardProviders, rli: Interface, logger: Logger): Promise<void> => {
const bboardApi = await deployOrJoin(providers, rli, logger);
if (bboardApi === null) {
return;
}
let currentState: BBoardDerivedState | undefined;
const stateObserver = {
next: (state: BBoardDerivedState) => (currentState = state),
};
const subscription = bboardApi.state$.subscribe(stateObserver);
try {
while (true) {
const choice = await rli.question(MAIN_LOOP_QUESTION);
try {
switch (choice) {
case '1': {
const message = await rli.question(`What message do you want to post? `);
await bboardApi.post(message);
break;
}
case '2':
await bboardApi.takeDown();
break;
case '3':
await displayLedgerState(providers, bboardApi.deployedContract, logger);
break;
case '4':
await displayPrivateState(providers, logger);
break;
case '5':
displayDerivedState(currentState, logger);
break;
case '6':
logger.info('Exiting...');
return;
default:
logger.error(`Invalid choice: ${choice}`);
}
} catch (e) {
logError(logger, e);
logger.info('Returning to main menu...');
}
}
} finally {
subscription.unsubscribe();
}
};
The loop first calls deployOrJoin() to initialize the contract connection. It subscribes to the reactive state observable to automatically receive updates whenever transactions modify the contract. Each menu action has its own try/catch so that a failed circuit invocation (for example, posting to an occupied board) logs the error and returns to the main menu instead of exiting. The outer finally block ensures the subscription is clean when the loop exits.
Wallet setup menu
This function handles wallet seed initialization. When the CLI runs against the local standalone network, the function skips the prompt and returns the genesis-mint wallet seed, which already holds tNIGHT and tDUST in the local node's genesis block.
const GENESIS_MINT_WALLET_SEED = '0000000000000000000000000000000000000000000000000000000000000001';
const WALLET_LOOP_QUESTION = `
You can do one of the following:
1. Build a fresh wallet
2. Build wallet from a seed
3. Exit
Which would you like to do? `;
const buildWallet = async (config: Config, rli: Interface, logger: Logger): Promise<string | undefined> => {
if (config instanceof StandaloneConfig) {
return GENESIS_MINT_WALLET_SEED;
}
while (true) {
const choice = await rli.question(WALLET_LOOP_QUESTION);
switch (choice) {
case '1':
return toHex(randomBytes(32));
case '2':
return await rli.question('Enter your wallet seed: ');
case '3':
logger.info('Exiting...');
return undefined;
default:
logger.error(`Invalid choice: ${choice}`);
}
}
};
On remote networks, the function provides three options:
- Generate a new wallet with a random seed
- Restore your wallet from an existing seed
- Exit the application
Run function
The run() function coordinates the entire application lifecycle:
export const run = async (config: Config, testEnv: TestEnvironment, logger: Logger): Promise<void> => {
const rli = createInterface({ input, output, terminal: true });
const providersToBeStopped: MidnightWalletProvider[] = [];
try {
const envConfiguration = await testEnv.start();
logger.info(`Environment started with configuration: ${JSON.stringify(envConfiguration)}`);
const seed = await buildWallet(config, rli, logger);
if (seed === undefined) {
return;
}
const walletProvider = await MidnightWalletProvider.build(logger, envConfiguration, seed);
providersToBeStopped.push(walletProvider);
const walletFacade: WalletFacade = walletProvider.wallet;
await walletProvider.start();
const unshieldedState = await waitForUnshieldedFunds(logger, walletFacade, envConfiguration, unshieldedToken());
const nightBalance = unshieldedState.balances[unshieldedToken().raw];
if (nightBalance === undefined) {
logger.info('No funds received, exiting...');
return;
}
logger.info(`Your NIGHT wallet balance is: ${nightBalance}`);
if (config.generateDust) {
const dustGeneration = await generateDust(logger, seed, unshieldedState, walletFacade);
if (dustGeneration) {
logger.info(`Submitted dust generation registration transaction: ${dustGeneration}`);
await syncWallet(logger, walletFacade);
}
}
const zkConfigProvider = new NodeZkConfigProvider<'post' | 'takeDown'>(config.zkConfigPath);
const providers: BBoardProviders = {
privateStateProvider: levelPrivateStateProvider<PrivateStateId, BBoardPrivateState>({
privateStateStoreName: config.privateStateStoreName,
signingKeyStoreName: `${config.privateStateStoreName}-signing-keys`,
privateStoragePasswordProvider: () => {
return 'Bboard-Test-2026!';
},
accountId: seed,
}),
publicDataProvider: indexerPublicDataProvider(envConfiguration.indexer, envConfiguration.indexerWS),
zkConfigProvider: zkConfigProvider,
proofProvider: httpClientProofProvider(envConfiguration.proofServer, zkConfigProvider),
walletProvider: walletProvider,
midnightProvider: walletProvider,
};
await mainLoop(providers, rli, logger);
} catch (e) {
logError(logger, e);
logger.info('Exiting...');
} finally {
try {
rli.close();
rli.removeAllListeners();
} catch (e) {
logError(logger, e);
} finally {
try {
for (const wallet of providersToBeStopped) {
logger.info('Stopping wallet...');
await wallet.stop();
}
if (testEnv) {
logger.info('Stopping test environment...');
await testEnv.shutdown();
}
} catch (e) {
logError(logger, e);
}
}
}
};
The function executes the following workflow:
- Start environment: Retrieves endpoint configuration for the network
- Build wallet: Creates or restores a wallet from seed (or uses the genesis-mint seed on standalone)
- Wait for funds: Ensures the wallet has tNIGHT tokens for fees
- Generate DUST: Registers UTXOs for DUST generation (skipped on standalone, which already has DUST)
- Configure providers: Sets up all six required providers (private state, public data, ZK config, proof, wallet, midnight). Midnight.js 4.x scopes private state per contract address — the
accountIdoption (set to the wallet seed) keys the LevelDB store so multiple users on the same machine don't share state. - Enter main loop: Starts the interactive bulletin board session
- Cleanup: Nested
finallyblocks guarantee cleanup of resources even if errors occur
Error logging utility
Add a helper function for logging errors:
function logError(logger: Logger, e: unknown) {
if (e instanceof Error) {
logger.error(`Found error '${e.message}'`);
logger.debug(`${e.stack}`);
} else {
logger.error(`Found error (unknown type)`);
}
}
This function checks if the error is an Error instance to access the message and stack trace, ensuring errors are always logged even when they don't follow standard JavaScript error patterns.
Create the application entry points
Each launcher creates the matching Config instance, initializes the logger, gets the test environment, and starts the CLI application. The launchers use top-level await for asynchronous initialization.
- preprod
- preview
- standalone
Create bboard-cli/src/launcher/preprod.ts:
import { createLogger } from '../logger-utils.js';
import { run } from '../index.js';
import { PreprodRemoteConfig } from '../config.js';
const config = new PreprodRemoteConfig();
const logger = await createLogger(config.logDir);
const testEnvironment = config.getEnvironment(logger);
await run(config, testEnvironment, logger);
Create bboard-cli/src/launcher/preview.ts:
import { createLogger } from '../logger-utils.js';
import { run } from '../index.js';
import { PreviewRemoteConfig } from '../config.js';
const config = new PreviewRemoteConfig();
const logger = await createLogger(config.logDir);
const testEnvironment = config.getEnvironment(logger);
await run(config, testEnvironment, logger);
Create bboard-cli/src/launcher/standalone.ts:
import { createLogger } from '../logger-utils.js';
import { run } from '../index.js';
import { StandaloneConfig } from '../config.js';
const config = new StandaloneConfig();
const logger = await createLogger(config.logDir);
const testEnvironment = config.getEnvironment(logger);
await run(config, testEnvironment, logger);
The standalone launcher connects to a local Midnight network running on your machine. For more information, see Midnight local network.
Configure the proof server
Create bboard-cli/proof-server-local.yml for local development. This file exposes the proof server on a fixed host port so you can start it once and reuse it between CLI runs:
services:
proof-server:
image: midnightntwrk/proof-server:8.0.3
command: ['midnight-proof-server', '-v']
container_name: "proof-server-local"
ports:
- '6300:6300'
Also create bboard-cli/proof-server.yml for use by the testkit container manager. The testkit picks an ephemeral host port (0:6300) so multiple test runs don't conflict:
services:
proof-server:
image: midnightntwrk/proof-server:8.0.3
command: ['midnight-proof-server', '-v']
container_name: "proof-server_$TESTCONTAINERS_UID"
ports:
- '0:6300'
Start the long-lived local proof server before running the CLI:
docker compose -f proof-server-local.yml up -d
Next steps
You have now completed both implementation guides. Return to the main CLI guide to run the application and explore next steps.