Skip to main content

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 && cp -R ../contract/src/managed dist/contract/src/managed",
"ci": "npm run typecheck && npm run lint && npm run build",
"lint": "eslint src",
"preprod": "node --experimental-specifier-resolution=node --loader ts-node/esm src/launcher/preprod.ts",
"undeployed": "node --experimental-specifier-resolution=node --loader ts-node/esm src/launcher/undeployed.ts",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"@types/node": "^25.2.0"
}
}

preprod 스크립트는 CLI 애플리케이션을 실행하고 Midnight Preprod 테스트넷에 연결하며, undeployed 스크립트는 CLI 애플리케이션을 실행하고 로컬 머신에서 실행 중인 로컬 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": "node",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}

Network configuration

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

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 requestFaucetTokens: boolean;
readonly generateDust: boolean;
}

export const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');

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

  • Preprod: preprod.midnight.network에서 Midnight이 호스팅하는 테스트넷에 연결합니다
  • Undeployed: 로컬 머신에서 실행 중인 로컬 Midnight 네트워크에 연결합니다.
bboard-cli/src/config.ts
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');
requestFaucetTokens = false;
generateDust = true;
}

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://faucet.preprod.midnight.network/api/request-tokens',
proofServer: this.getProofServerUrl(),
};
}
}

두 설정 모두 ZK proof 생성을 위해 로컬 Docker proof 서버를 사용합니다. 로컬 proof 서버는 더 나은 성능을 제공하며 proof 생성을 위해 외부 네트워크 접근이 필요하지 않습니다.

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/ledger-v8';
import { type FacadeState, type WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import { type UnshieldedWallet, 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 getInitialState = async (wallet: ShieldedWallet | UnshieldedWallet) => {
if (wallet instanceof ShieldedWallet) {
return Rx.firstValueFrom((wallet as ShieldedWallet).state);
} else {
return Rx.firstValueFrom((wallet as UnshieldedWallet).state);
}
};

export const getInitialShieldedState = async (logger: Logger, wallet: ShieldedWallet) => {
logger.info('Getting initial state of wallet...');
return Rx.firstValueFrom(wallet.state);
};

export const getInitialUnshieldedState = async (logger: Logger, wallet: UnshieldedWallet) => {
logger.info('Getting initial state of wallet...');
return Rx.firstValueFrom(wallet.state);
};

이 함수들은 지갑 상태에 대한 타입 안전 접근을 제공합니다:

  • getInitialState(): 런타임 타입 검사를 사용하여 차폐(shielded) 및 비차폐(unshielded) 지갑을 모두 처리하는 제네릭 함수
  • getInitialShieldedState(): 차폐 지갑 상태를 가져오고 작업을 로깅
  • getInitialUnshieldedState(): 비차폐 지갑 상태를 가져오고 작업을 로깅

Sync wallet

syncWallet 함수는 세 가지 구성 요소(차폐, 비차폐, 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)();
};

export const syncWallet = (logger: Logger, wallet: WalletFacade, throttleTime = 2_000, timeout = 90_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) =>
isProgressStrictlyComplete(state.shielded.state.progress) &&
isProgressStrictlyComplete(state.dust.state.progress) &&
isProgressStrictlyComplete(state.unshielded.progress),
),
Rx.tap(() => logger.info('Sync complete')),
Rx.tap((state: FacadeState) => {
const shieldedBalances = state.shielded.balances || {};
const unshieldedBalances = state.unshielded.balances || {};
const dustBalances = state.dust.walletBalance(new Date(Date.now())) || 0n;

logger.info(
`Wallet balances after sync - Shielded: ${JSON.stringify(shieldedBalances)}, Unshielded: ${JSON.stringify(unshieldedBalances)}, Dust: ${dustBalances}`,
);
}),
Rx.timeout({
each: timeout,
with: () => Rx.throwError(() => new Error(`Wallet sync timeout after ${timeout}ms`)),
}),
),
);
};

Wait for unshielded funds

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

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

bboard-cli/src/wallet-utils.ts
export const waitForUnshieldedFunds = async (
logger: Logger,
wallet: WalletFacade,
env: EnvironmentConfiguration,
tokenType: UnshieldedTokenType,
fundFromFaucet = false,
): 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...`);
const facadeState = await syncWallet(logger, wallet);
return facadeState.unshielded;
}
return initialState;
};

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 { UtxoWithMeta as UtxoWithMetaDust } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
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 ttlIn10min = new Date(Date.now() + 10 * 60 * 1000);
const dustState = await walletFacade.dust.waitForSyncedState();
const networkId = getNetworkId();
const unshieldedKeystore = createKeystore(getUnshieldedSeed(walletSeed), networkId);
const utxos: UtxoWithMetaDust[] = unshieldedState.availableCoins
.filter((coin) => !coin.meta.registeredForDustGeneration)
.map((utxo) => ({ ...utxo.utxo, ctime: new Date(utxo.meta.ctime) }));

if (utxos.length === 0) {
logger.info('No unregistered UTXOs found for dust generation.');
return;
}

logger.info(`Generating dust with ${utxos.length} UTXOs...`);

const registerForDustTransaction = await walletFacade.dust.createDustGenerationTransaction(
new Date(),
ttlIn10min,
utxos,
unshieldedKeystore.getPublicKey(),
dustState.dustAddress,
);

const intent = registerForDustTransaction.intents?.get(1);
const intentSignatureData = intent!.signatureData(1);
const signature = unshieldedKeystore.signData(intentSignatureData);
const recipe = await walletFacade.dust.addDustGenerationSignature(registerForDustTransaction, signature);

const transaction = await walletFacade.finalizeTransaction(recipe);
const txId = await walletFacade.submitTransaction(transaction);

const dustBalance = await rx.firstValueFrom(
walletFacade.state().pipe(
rx.filter((s) => s.dust.walletBalance(new Date()) > 0n),
rx.map((s) => s.dust.walletBalance(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 지갑 시드에서 비차폐 지갑 키를 파생합니다.

generateDust 함수는:

  1. 미등록 UTXO를 필터링합니다
  2. DUST 생성 트랜잭션을 생성합니다
  3. 비차폐 keystore로 서명합니다
  4. 네트워크에 제출합니다
  5. DUST 잔액이 업데이트될 때까지 대기합니다

Implement wallet 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/ledger-v8';
import { type MidnightProvider, 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 { DustWalletOptions, EnvironmentConfiguration, FluentWalletBuilder } from '@midnight-ntwrk/testkit-js';
import { NetworkId } from '@midnight-ntwrk/wallet-sdk-abstractions';

export class MidnightWalletProvider implements MidnightProvider, WalletProvider {
logger: Logger;
readonly env: EnvironmentConfiguration;
readonly wallet: WalletFacade;
readonly zswapSecretKeys: ZswapSecretKeys;
readonly dustSecretKey: DustSecretKey;

private constructor(
logger: Logger,
environmentConfiguration: EnvironmentConfiguration,
wallet: WalletFacade,
zswapSecretKeys: ZswapSecretKeys,
dustSecretKey: DustSecretKey,
) {
this.logger = logger;
this.env = environmentConfiguration;
this.wallet = wallet;
this.zswapSecretKeys = zswapSecretKeys;
this.dustSecretKey = dustSecretKey;
}

이 클래스는 지갑 facade, 차폐 및 DUST 작업을 위한 암호학적 키, 환경 설정을 저장합니다.

Implement key provider methods

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

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

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

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

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

submitTx(tx: FinalizedTransaction): Promise<string> {
return this.wallet.submitTransaction(tx);
}

balanceTx() 메서드는 바인딩되지 않은 트랜잭션(입출력이 선택되지 않은)을 받아 수수료를 충당할 적절한 UTXO를 선택하고 잔돈 출력을 추가하여 밸런싱합니다. 차폐 트랜잭션 수수료를 지불하기 위해 DUST 토큰을 사용합니다. 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: 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 } = buildResult as {
wallet: WalletFacade;
seeds: { masterSeed: string; shielded: Uint8Array; dust: Uint8Array };
};

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

팩토리 메서드는 테스트 네트워크에 적합한 낮은 additionalFeeOverhead(1000n)로 DUST 옵션을 구성합니다. 시드가 제공되면 기존 지갑을 복원하고, 그렇지 않으면 새로운 랜덤 시드를 생성합니다. 이 메서드는 사용자 참조를 위해 시드와 차폐 주소를 로깅하고, 시드에서 파생된 모든 필요한 암호학적 키로 프로바이더를 생성합니다.

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 } from './config.js';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { type ContractAddress } from '@midnight-ntwrk/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/ledger-v8';
import { syncWallet, waitForUnshieldedFunds } from './wallet-utils';
import { generateDust } from './generate-dust';
import { BBoardPrivateState } from '@midnight-ntwrk/bboard-contract';

globalThis.WebSocket = WebSocket as unknown as typeof globalThis.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);
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}`);
}
}
} finally {
subscription.unsubscribe();
}
};

루프는 먼저 deployOrJoin()을 호출하여 컨트랙트 연결을 초기화합니다. 트랜잭션이 컨트랙트를 수정할 때마다 자동으로 업데이트를 받기 위해 반응형 상태 observable을 구독합니다. finally 블록은 루프가 종료될 때 구독이 정리되도록 보장합니다.

Wallet setup menu

이 함수는 지갑 시드 초기화를 처리합니다:

bboard-cli/src/index.ts
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> => {
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(),
config.requestFaucetTokens,
);
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 'key-just-for-testing-here!';
},
}),
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. 지갑 빌드: 시드에서 지갑을 생성하거나 복원합니다
  3. 자금 대기: 지갑에 수수료를 위한 tNIGHT 토큰이 있는지 확인합니다
  4. DUST 생성: DUST 생성을 위해 UTXO를 등록합니다 (활성화된 경우)
  5. 프로바이더 구성: 필요한 6개의 프로바이더(private 상태, 공개 데이터, ZK 설정, proof, 지갑, midnight)를 모두 설정합니다
  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 point

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

이 런처는 PreprodRemoteConfig 인스턴스를 생성하고, 로거를 초기화하며, 테스트 환경을 가져오고, CLI 애플리케이션을 시작합니다. 런처는 비동기 초기화를 위해 top-level await를 사용합니다.

Configure proof server

bboard-cli/proof-server.yml을 생성합니다:

bboard-cli/proof-server.yml
services:
proof-server:
image: 'midnightntwrk/proof-server:latest'
container_name: "proof-server_$TESTCONTAINERS_UID"
ports:
- "0:6300"
environment:
EXTRA_ARGS: -v
RUST_BACKTRACE: "full"
note

이 튜토리얼에서 사용되는 proof 서버는 Brick Towers가 유지 관리하는 커뮤니티 Docker 이미지입니다.

이 Docker Compose 파일은 proof 서버를 구성합니다. testkit은 현재 디렉터리에서 proof-server.yml 파일을 확인하여 이 컨테이너의 생명주기를 자동으로 관리합니다.

Next steps

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