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

Bulletin board CLI implementation

이 튜토리얼에서는 지갑 관리, DUST 생성, 대화형 명령줄 인터페이스가 포함된 CLI 패키지를 구현합니다.

Prerequisites

시작하기 전에 게시판 API 구현 튜토리얼을 완료했는지 확인하세요.

Create the CLI directory

루트에서 bboard-cli 구조를 생성합니다:

cd ..
mkdir -p bboard-cli/src/launcher
cd bboard-cli

Configure the CLI package

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"
}
}

각 런처 스크립트는 서로 다른 네트워크를 대상으로 합니다:

  • preprod-remote: Midnight이 호스팅하는 Preprod 테스트넷에 연결합니다
  • preview-remote: Midnight이 호스팅하는 Preview 테스트넷에 연결합니다
  • standalone: genesis-mint 지갑 시드를 사용해 로컬 머신에서 실행 중인 로컬 Midnight 네트워크에 연결합니다
info

로컬 Midnight 네트워크 설정에 대한 자세한 정보는 Midnight 로컬 네트워크 문서를 참조하세요.

Configure TypeScript

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
}
}

프로덕션 빌드에서 테스트를 제외하도록 bboard-cli/tsconfig.build.json을 만드세요:

{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts"],
"compilerOptions": {}
}

Network configuration

설정 모듈은 다양한 Midnight 환경에 연결하기 위한 네트워크별 설정을 정의합니다. 공통 Config 인터페이스를 통해 네트워크 엔드포인트 접근, private 상태 저장소 관리, DUST 생성 설정을 표준화합니다.

bboard-cli/src/config.ts를 생성하고 공유 설정 인터페이스로 시작합니다:

bboard-cli/src/config.ts
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, '..');

CLI는 세 가지 네트워크 환경을 지원합니다:

  • Standalone: 로컬 머신에서 실행 중인 로컬 Midnight 네트워크에 연결하며 genesis-mint 지갑 시드를 사용합니다. genesis 지갑이 이미 tDUST를 보유하고 있으므로 DUST 생성은 비활성화됩니다.
  • Preview: Midnight이 호스팅하는 Preview 테스트넷에 연결합니다.
  • Preprod: Midnight이 호스팅하는 Preprod 테스트넷에 연결합니다.
bboard-cli/src/config.ts
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(),
};
}
}

세 가지 설정 모두 영지식(ZK) 증명 생성을 위해 로컬 Docker proof server를 사용합니다. 로컬 proof server는 더 나은 성능을 제공하며 증명 생성에 외부 네트워크 접근이 필요하지 않습니다.

Implement logging utilities

bboard-cli/src/logger-utils.ts를 생성합니다:

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 },
]),
);
};

로거는 두 개의 출력 스트림을 생성합니다: 개발용 보기 좋은 콘솔 스트림과 영구 로그를 위한 파일 스트림. 로그 수준은 DEBUG_LEVEL 환경 변수를 통해 제어할 수 있습니다.

Implement wallet utilities

지갑 유틸리티는 지갑 상태 동기화 및 자금 지원 작업을 관리합니다. 컨트랙트와 상호작용하기 전에 지갑이 블록체인과 동기화되었는지, 자금이 충분한지 확인하는 함수들입니다.

bboard-cli/src/wallet-utils.ts를 생성합니다:

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

지갑 동기화를 시작하기 전에 주소를 가져오고 잔액을 확인하기 위해 현재 상태에 접근해야 합니다. 이 헬퍼 함수들은 RxJS firstValueFrom()을 사용하여 지갑의 상태 observable을 처음 발행된 값으로 확인되는 Promise로 변환합니다.

초기 상태 함수를 추가합니다:

bboard-cli/src/wallet-utils.ts
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);
};

이 함수들은 지갑 상태에 타입 안전하게 접근합니다:

  • getInitialShieldedState(): ShieldedWalletAPI에서 shielded 지갑 상태를 가져오고 작업을 로깅합니다
  • getInitialUnshieldedState(): UnshieldedWalletAPI에서 unshielded 지갑 상태를 가져오고 작업을 로깅합니다

Sync wallet

syncWallet 함수는 세 가지 구성 요소(shielded, unshielded, DUST) 전체에서 지갑의 동기화 진행률을 모니터링하고 블록체인과 완전히 동기화될 때까지 대기합니다. 지갑 작업을 수행하기 전에 이것이 필수적입니다.

지갑 동기화 함수를 추가합니다:

bboard-cli/src/wallet-utils.ts
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}`,
);
}),
),
);
};

isFacadeStateSynced 헬퍼는 세 가지(shielded, unshielded, dust) 진행 상태 확인을 한곳에 모아 waitForUnshieldedFunds에서 재사용할 수 있게 합니다. DUST 잔액은 state.dust.balance(...)로 읽습니다. 기존 walletBalance(...) API는 @midnight-ntwrk/wallet-sdk 1.0에서 제거됐습니다.

Wait for unshielded funds

컨트랙트를 배포하거나 트랜잭션을 제출하기 전에, 지갑은 네트워크 수수료를 지불하기 위한 DUST를 생성하기 위해 unshielded tNIGHT 토큰이 필요합니다. 이 함수는 충분한 자금이 사용 가능한지 확인하며, 선택적으로 테스트를 위해 faucet에서 토큰을 요청합니다.

자금 지원 함수를 추가합니다:

bboard-cli/src/wallet-utils.ts
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는 지갑이 완전히 동기화되고 그리고 요청한 토큰 유형의 unshielded 잔액이 0보다 커질 때까지 기다립니다 — 이렇게 하면 지갑이 초기 sync를 끝낸 뒤에야 faucet 입금이 관찰되는 race를 피할 수 있습니다.

Implement DUST generation

bboard-cli/src/generate-dust.ts를 생성합니다:

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 생성은 tNIGHT 토큰을 지정해 트랜잭션 수수료용 DUST를 자동으로 생성합니다. getUnshieldedSeed 함수는 NightExternal 역할을 사용해 HD 지갑 시드에서 unshielded 지갑 키를 파생합니다.

generateDust 함수는 다음을 수행합니다:

  1. 이미 DUST 생성에 등록된 UTXO를 걸러냅니다
  2. walletFacade.registerNightUtxosForDustGeneration()로 등록 recipe를 만들며, 각 intent를 unshielded keystore로 서명하는 콜백을 전달합니다
  3. recipe를 확정하고 트랜잭션을 네트워크에 제출합니다
  4. DUST 잔액 observable이 0이 아닌 값을 발행할 때까지 대기합니다

wallet SDK 1.0 facade는 이전 지갑 패키지에서 필요했던 intent 구성과 서명 연결을 숨기므로, CLI는 서명 콜백만 제공하면 나머지는 지갑이 처리합니다.

Implement wallet provider

지갑 provider는 Wallet SDK와 Midnight.js contracts API를 연결하며, 컨트랙트 배포 및 트랜잭션 시스템에 필요한 인터페이스를 구현합니다. 암호학적 키, 트랜잭션 밸런싱, 지갑 생명주기 작업을 관리합니다.

bboard-cli/src/midnight-wallet-provider.ts를 생성하고 다음 임포트 및 클래스 정의를 추가합니다:

bboard-cli/src/midnight-wallet-provider.ts
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;
}

이 클래스는 wallet facade, (트랜잭션 밸런싱 시 intent 페이로드 서명에 사용되는) unshielded keystore, shielded 및 DUST 작업용 암호학적 키, 환경 설정을 저장합니다. ledger 타입을 @midnight-ntwrk/midnight-js-protocol/ledger 서브경로로 임포트하면 별도의 @midnight-ntwrk/ledger-v8 패키지를 대체합니다.

Implement key provider methods

이 메서드들은 shielded 자금 수신 및 트랜잭션 데이터 복호화에 필요한 공개 키를 반환합니다:

bboard-cli/src/midnight-wallet-provider.ts
getCoinPublicKey(): CoinPublicKey {
return this.zswapSecretKeys.coinPublicKey;
}

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

getCoinPublicKey() 메서드는 shielded 토큰을 수신하기 위한 주소를 반환하고, getEncryptionPublicKey()는 이 지갑으로 전송된 shielded 트랜잭션 데이터를 복호화하는 키를 반환합니다.

Implement transaction methods

이 메서드들은 밸런싱에서 제출까지의 트랜잭션 생명주기를 처리합니다:

bboard-cli/src/midnight-wallet-provider.ts
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);
}

balanceTx() 메서드는 (입출력이 선택되지 않은) 바인딩되지 않은 트랜잭션을 받아 수수료를 충당할 적절한 UTXO를 선택하고 잔돈 출력을 추가해 밸런싱합니다. 그런 다음 recipe를 확정하기 전에 unshielded keystore로 unshielded intent 페이로드에 서명하도록 지갑에 요청합니다. submitTx() 메서드는 확정된 트랜잭션을 네트워크에 제출하고 트랜잭션 해시를 반환합니다.

Implement lifecycle methods

이 메서드들은 지갑의 시작과 종료를 제어합니다:

bboard-cli/src/midnight-wallet-provider.ts
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();
}

start() 메서드는 암호학적 키로 지갑을 초기화하고 블록체인과 동기화를 시작합니다. stop() 메서드는 지갑을 정상적으로 종료하고 리소스를 정리합니다. 애플리케이션이 종료되기 전에 항상 stop()을 호출하세요.

Implement the build factory method

팩토리 메서드는 적절한 DUST 설정으로 지갑 인스턴스를 생성하고 구성합니다:

bboard-cli/src/midnight-wallet-provider.ts
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,
);
}
}

팩토리 메서드는 네트워크에 따라 달라지는 additionalFeeOverhead로 DUST 옵션을 구성합니다. @midnight-ntwrk/testkit-js가 제공하는 기본값(500_000_000_000_000_000n)은 undeployed(standalone) 네트워크에서 필요하며, 더 낮은 값을 쓰면 노드 측에서 BalanceCheckOverspend로 실패할 수 있습니다. 원격 테스트넷에서는 이 오버헤드가 지갑이 보통 보유한 것보다 많은 DUST를 요구하므로, CLI는 이를 1_000n으로 재정의합니다.

시드가 제공되면 빌더는 기존 지갑을 복원하고, 그렇지 않으면 새로운 랜덤 시드를 생성합니다. 빌더 결과는 unshielded intent 서명을 위해 미리 구성된 keystore도 함께 제공합니다. 이 메서드는 사용자 참조를 위해 시드와 shielded 주소를 로깅하고 모든 암호학적 자료를 provider에 저장합니다.

Implement the main CLI logic

메인 CLI 모듈은 지갑 설정, 컨트랙트 상호작용, 대화형 사용자 인터페이스를 하나로 묶어 게시판 애플리케이션 전체를 관리합니다. 환경 시작부터 정상 종료까지의 애플리케이션 생명주기를 처리합니다.

bboard-cli/src/index.ts를 생성하고 다음 임포트를 추가합니다:

bboard-cli/src/index.ts
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;

globalThis.WebSocket 할당은 브라우저 호환 API를 위한 Node.js WebSocket 구현을 설정합니다. 이를 통해 Indexer 및 Node RPC 서비스에 WebSocket으로 연결할 수 있습니다.

Query ledger state

이 헬퍼 함수는 배포된 컨트랙트에서 현재 공개 ledger 상태를 가져옵니다:

bboard-cli/src/index.ts
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

이 함수는 사용자가 새 게시판을 배포하거나 기존 게시판에 연결할 수 있는 메뉴를 표시합니다:

bboard-cli/src/index.ts
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

이 함수들은 게시판 상태의 다양한 뷰를 표시합니다. 공개 ledger 상태, private 상태 및 파생 상태를 시각화하기 위해 추가합니다:

bboard-cli/src/index.ts
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'}'`);
}
};

이 표시 함수들은 다음을 보여줍니다:

  • ledger 상태: 모든 네트워크 참여자에게 보이는 공개 온체인 데이터
  • private 상태: 온체인에 절대 나타나지 않는 로컬 비밀 키
  • 파생 상태: 공개 및 private 데이터를 결합하여 소유권을 계산

Main interaction loop

메인 루프는 게시판 작업을 위한 대화형 메뉴를 제공합니다:

bboard-cli/src/index.ts
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();
}
};

루프는 먼저 deployOrJoin()을 호출해 컨트랙트 연결을 초기화합니다. 트랜잭션이 컨트랙트를 수정할 때마다 자동으로 업데이트를 받기 위해 반응형 상태 observable을 구독합니다. 각 메뉴 동작은 자체 try/catch를 가지므로, circuit 호출이 실패해도(예: 이미 점유된 게시판에 글을 올리는 경우) 종료하지 않고 오류를 로깅한 뒤 메인 메뉴로 돌아갑니다. 바깥쪽 finally 블록은 루프가 끝날 때 구독이 정리되도록 보장합니다.

Wallet setup menu

이 함수는 지갑 시드 초기화를 처리합니다. CLI가 로컬 standalone 네트워크에서 실행될 때는 프롬프트를 건너뛰고 genesis-mint 지갑 시드를 반환하는데, 이 시드는 로컬 노드의 genesis 블록에서 이미 tNIGHT과 tDUST를 보유하고 있습니다.

bboard-cli/src/index.ts
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}`);
}
}
};

원격 네트워크에서는 이 함수가 세 가지 옵션을 제공합니다:

  • 랜덤 시드로 새 지갑 생성
  • 기존 시드에서 지갑 복원
  • 애플리케이션 종료

Run function

run() 함수는 전체 애플리케이션 생명주기를 조정합니다:

bboard-cli/src/index.ts
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);
}
}
}
};

이 함수는 다음 워크플로우를 실행합니다:

  1. 환경 시작: 네트워크에 대한 엔드포인트 설정을 가져옵니다
  2. 지갑 빌드: 시드에서 지갑을 생성하거나 복원합니다(standalone에서는 genesis-mint 시드를 사용)
  3. 자금 대기: 지갑에 수수료용 tNIGHT 토큰이 있는지 확인합니다
  4. DUST 생성: DUST 생성을 위해 UTXO를 등록합니다(이미 DUST를 보유한 standalone에서는 건너뜀)
  5. provider 구성: 필요한 6개의 provider(private 상태, 공개 데이터, ZK 설정, 증명, 지갑, midnight)를 모두 설정합니다. Midnight.js 4.x는 private 상태를 컨트랙트 주소별로 범위화하며, (지갑 시드로 설정되는) accountId 옵션이 LevelDB 저장소의 키가 되어 같은 머신의 여러 사용자가 상태를 공유하지 않도록 합니다.
  6. 메인 루프 진입: 대화형 게시판 세션을 시작합니다
  7. 정리: 중첩된 finally 블록으로 오류가 발생해도 리소스가 반드시 정리됩니다

Error logging utility

오류 로깅을 위한 헬퍼 함수를 추가합니다:

bboard-cli/src/index.ts
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)`);
}
}

이 함수는 오류가 Error 인스턴스인지 확인하여 메시지와 스택 트레이스에 접근하며, 표준 JavaScript 오류 패턴을 따르지 않는 경우에도 항상 오류가 로깅되도록 합니다.

Create the application entry points

각 launcher는 그에 맞는 Config 인스턴스를 만들고, logger를 초기화하고, 테스트 환경을 가져온 뒤 CLI 애플리케이션을 시작합니다. launcher들은 비동기 초기화를 위해 top-level await를 사용합니다.

bboard-cli/src/launcher/preprod.ts를 생성합니다:

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);

Configure the proof server

로컬 개발용으로 bboard-cli/proof-server-local.yml을 생성합니다. 이 파일은 proof server를 고정된 호스트 포트로 노출하므로, 한 번 시작해 두고 CLI 실행 사이에 재사용할 수 있습니다:

bboard-cli/proof-server-local.yml
services:
proof-server:
image: midnightntwrk/proof-server:8.0.3
command: ['midnight-proof-server', '-v']
container_name: "proof-server-local"
ports:
- '6300:6300'

testkit 컨테이너 매니저가 사용할 bboard-cli/proof-server.yml도 함께 생성합니다. testkit은 임시 호스트 포트(0:6300)를 선택하므로 여러 테스트 실행이 충돌하지 않습니다:

bboard-cli/proof-server.yml
services:
proof-server:
image: midnightntwrk/proof-server:8.0.3
command: ['midnight-proof-server', '-v']
container_name: "proof-server_$TESTCONTAINERS_UID"
ports:
- '0:6300'

CLI를 실행하기 전에 오래 유지되는 로컬 proof server를 시작합니다:

docker compose -f proof-server-local.yml up -d

Next steps

두 구현 가이드를 모두 완료했습니다. 애플리케이션을 실행하고 다음 단계를 탐색하려면 메인 CLI 가이드로 돌아가세요.