Counter CLI
이 튜토리얼에서는 counter 컨트랙트 구축 튜토리얼에서 생성한 counter 스마트 컨트랙트와 상호작용하는 명령줄 인터페이스를 구축하는 방법을 설명합니다. 이 CLI는 Midnight 네트워크에서의 지갑 관리, 컨트랙트 배포 및 트랜잭션 제출을 보여줍니다.
Prerequisites
시작하기 전에 다음 사항을 확인하세요:
- counter 컨트랙트 구축 튜토리얼을 완료하고 컨트랙트가
contract/src/managed/counter/에 컴파일되어 있어야 합니다 - proof 서버를 실행하기 위해 Docker Desktop이 설치되어 있어야 합니다
- Node.js 버전 22 이상
Project structure
완성된 example-counter 프로젝트는 npm workspaces를 사용하는 모노레포 구조를 사용합니다:
example-counter/
├── package.json # 루트 패키지 (workspaces 포함)
├── contract/ # Compact 컨트랙트
│ ├── src/
│ │ ├── counter.compact
│ │ ├── managed/
│ │ └── index.ts
│ └── package.json
└── counter-cli/ # Counter CLI
├── src/
│ ├── config.ts # 네트워크 설정
│ ├── common-types.ts # 타입 정의
│ ├── api.ts # 컨트랙트 상호작용
│ ├── cli.ts # 사용자 인터페이스
│ ├── logger-utils.ts # 로깅 설정
│ ├── preprod.ts # 진입점
│ └── index.ts # 재내보내기
└── package.json
모노레포 구조에서 workspace 참조를 사용하면 패키지 간 코드를 쉽게 공유할 수 있습니다. counter-cli는 npm에 별도로 게시하지 않고도 contract 패키지에 의존합니다.
Set up the root package
이 섹 션에서는 루트 패키지를 설정하는 과정을 설명합니다.
Create the root configuration
example-counter 루트 디렉터리에서 package.json을 생성하거나 업데이트합니다:
{
"name": "example-counter",
"version": "2.0.2",
"private": true,
"type": "module",
"workspaces": [
"counter-cli",
"contract"
],
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^25.2.0",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.52.0",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"testcontainers": "^11.11.0",
"ts-node": "^10.9.2",
"typescript": "^5.9.3",
"vitest": "^4.0.18"
},
"dependencies": {
"@midnight-ntwrk/compact-runtime": "0.15.0",
"@midnight-ntwrk/ledger-v8": "^8.0.0",
"@midnight-ntwrk/midnight-js-contracts": "^4.0.0",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "^4.0.0",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.0",
"@midnight-ntwrk/midnight-js-level-private-state-provider": "^4.0.0",
"@midnight-ntwrk/midnight-js-network-id": "^4.0.0",
"@midnight-ntwrk/midnight-js-node-zk-config-provider": "^4.0.0",
"@midnight-ntwrk/midnight-js-types": "^4.0.0",
"@midnight-ntwrk/wallet-sdk-facade": "^3.0.0",
"@midnight-ntwrk/wallet-sdk-hd": "^3.0.0",
"@midnight-ntwrk/wallet-sdk-shielded": "^2.0.0",
"@midnight-ntwrk/wallet-sdk-dust-wallet": "^3.0.0",
"@midnight-ntwrk/wallet-sdk-address-format": "^3.0.0",
"@midnight-ntwrk/wallet-sdk-unshielded-wallet": "^2.0.0",
"pino": "^10.3.0",
"pino-pretty": "^13.1.3",
"ws": "^8.19.0"
}
}
workspaces 설정은 npm에게 contract과 counter-cli를 연결된 패키지로 관리하도록 지시합니다. 루트 수준에서 정의된 의존성은 모든 workspace에서 공유되어 중복을 줄이고 버전 일관성을 보장합니다.
Install root dependencies
루트에서 모든 의 존성을 설치합니다:
npm install
이 명령은 루트 패키지와 모든 workspace 패키지의 의존성을 설치하고, 로컬 개발을 위한 심볼릭 링크를 생성합니다.
Set up the CLI package
이 섹션에서는 CLI DApp을 설정하는 과정을 설명합니다.
Create the CLI directory
루트에서 counter-cli 구조를 생성합니다:
mkdir -p counter-cli/src
cd counter-cli
Configure the CLI package
counter-cli/package.json을 생성합니다:
{
"name": "@midnight-ntwrk/counter-cli",
"version": "0.1.0",
"license": "Apache-2.0",
"private": true,
"type": "module",
"scripts": {
"preprod": "node --experimental-specifier-resolution=node --loader ts-node/esm src/preprod.ts",
"undeployed": "node --experimental-specifier-resolution=node --loader ts-node/esm src/undeployed.ts",
"build": "rm -rf dist && tsc --project tsconfig.build.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@midnight-ntwrk/counter-contract": "*"
}
}
이 패키지는 와일드카드 버전 *을 사용하여 @midnight-ntwrk/counter-contract에 의존하며, 이는 로컬 workspace 패키지로 확인됩니다. 이를 통해 npm에 게시하지 않고도 컴파일된 컨트랙트 코드를 임포트할 수 있습니다.
Configure TypeScript
counter-cli/tsconfig.json을 생성합니다:
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true
},
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
TypeScript 설정은 ES 모듈과 엄격한 타입 검사를 활성화합니다. ts-node 섹션은 개발 중 별도의 빌드 단계 없이 TypeScript 파일을 직접 실행할 수 있게 합니다.
Implement configuration
설정 파일은 네트워크 엔드포인트와 컨트랙트 설정을 정의합니다.
counter-cli/src/config.ts를 생성합니다:
import path from 'node:path';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
export const currentDir = path.resolve(new URL(import.meta.url).pathname, '..');
export const contractConfig = {
privateStateStoreName: 'counter-private-state',
zkConfigPath: path.resolve(currentDir, '..', '..', 'contract', 'src', 'managed', 'counter'),
};
export interface Config {
readonly logDir: string;
readonly indexer: string;
readonly indexerWS: string;
readonly node: string;
readonly proofServer: string;
}
export class PreprodConfig implements Config {
logDir = path.resolve(currentDir, '..', 'logs', 'preprod', `${new Date().toISOString()}.log`);
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';
proofServer = 'http://127.0.0.1:6300';
constructor() {
setNetworkId('preprod');
}
}
export class UndeployedConfig implements Config {
logDir = path.resolve(currentDir, '..', 'logs', 'undeployed', `${new Date().toISOString()}.log`);
indexer = 'http://localhost:8088/api/v4/graphql';
indexerWS = 'ws://localhost:8088/api/v4/graphql/ws';
node = 'ws://localhost:9944';
proofServer = 'http://localhost:6300';
constructor() {
setNetworkId('undeployed');
}
}
설정은 네트워크별 설정을 애플리케이션 로직과 분리합니다.
PreprodConfig 클래스는 Midnight이 호스팅하는 인프라에 대한 엔드포인트를 제공합니다.
contractConfig 객체는 컴파일된 컨트랙트 아티팩트의 위치와 private 상태를 로컬에 저장할 위치를 지정합니다.
생성자의 setNetworkId 호출은 Midnight.js 라이브러리가 자동으로 사용하는 글로벌 네트워크 컨텍스트를 설정합니다.
이를 통해 모든 SDK 구성 요소가 매 API 호출마다 네트워크 ID를 요구하지 않고도 올바른 네트워크에서 동작합니다.
Define common types
타입 정의는 타입 안전성을 제공하고 코드 가독성을 향상시킵니다.
counter-cli/src/common-types.ts를 생성합니다:
import { Counter, type CounterPrivateState } from '@midnight-ntwrk/counter-contract';
import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import type { DeployedContract, FoundContract } from '@midnight-ntwrk/midnight-js-contracts';
import type { ImpureCircuitId } from '@midnight-ntwrk/compact-js';
export type CounterCircuits = ImpureCircuitId<Counter.Contract<CounterPrivateState>>;
export const CounterPrivateStateId = 'counterPrivateState';
export type CounterProviders = MidnightProviders<CounterCircuits, typeof CounterPrivateStateId, CounterPrivateState>;
export type CounterContract = Counter.Contract<CounterPrivateState>;
export type DeployedCounterContract = DeployedContract<CounterContract> | FoundContract<CounterContract>;
이러한 타입 정의는 복잡한 제네릭 타입에 대한 별칭을 생성합니다. CounterCircuits 타입은 컨트랙트에서 circuit 식별자를 추출합니다. CounterProviders 타입은 적절한 타입 매개변수로 프로바이더 인터페이스를 지정합니다. DeployedCounterContract 타입 유니온은 새로 배포된 컨트랙트와 참여를 통해 찾은 컨트랙트를 모두 처리합니다.
Implement logging utilities
로깅 유틸리티는 콘솔과 파일 모두에 대한 구조화된 로깅을 제공합니다.
counter-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 환경 변수를 통해 제어할 수 있으며, 기본값은 'info'입니다.
멀티스트림 접근 방식은 로그가 실행 중에 보이면서도 디버깅을 위해 보존되도록 합니다.
Implement the API layer
API 계층은 Midnight 네트워크와의 모든 상호작용을 처리합니다. 이 파일에는 지갑 생성, 프로바이더 설정, 컨트랙트 배포 및 트랜잭션 제출 기능이 포함됩니다.
Create the API file
counter-cli 디렉터리에서 src/api.ts 파일을 생성하고 다음 코드를 추가합니다:
import { type ContractAddress } from '@midnight-ntwrk/compact-runtime';
import { Counter, type CounterPrivateState, witnesses } from '@midnight-ntwrk/counter-contract';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { type FinalizedTxData, type MidnightProvider, type WalletProvider } from '@midnight-ntwrk/midnight-js-types';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
createKeystore,
InMemoryTransactionHistoryStorage,
PublicKey,
UnshieldedWallet,
type UnshieldedKeystore,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { type Logger } from 'pino';
import * as Rx from 'rxjs';
import { WebSocket } from 'ws';
import {
type CounterCircuits,
type CounterContract,
type CounterPrivateStateId,
type CounterProviders,
type DeployedCounterContract,
} from './common-types';
import { type Config, contractConfig } from './config';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { assertIsContractAddress, toHex } from '@midnight-ntwrk/midnight-js-utils';
import { getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { CompiledContract } from '@midnight-ntwrk/compact-js';
import { Buffer } from 'buffer';
import {
MidnightBech32m,
ShieldedAddress,
ShieldedCoinPublicKey,
ShieldedEncryptionPublicKey,
} from '@midnight-ntwrk/wallet-sdk-address-format';
let logger: Logger;
// Node.js에서 GraphQL 구독(지갑 동기화)이 작동하는 데 필요
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;
// counter 컨트랙트를 ZK circuit 에셋과 함께 사전 컴파일
const counterCompiledContract = CompiledContract.make('counter', Counter.Contract).pipe(
CompiledContract.withVacantWitnesses,
CompiledContract.withCompiledFileAssets(contractConfig.zkConfigPath),
);
export interface WalletContext {
wallet: WalletFacade;
shieldedSecretKeys: ledger.ZswapSecretKeys;
dustSecretKey: ledger.DustSecretKey;
unshieldedKeystore: UnshieldedKeystore;
}
이 코드는 Counter DApp에 필요한 임포트와 설정을 구성합니다:
- 임포트: 파일은 지갑 관리(
@midnight-ntwrk/wallet-sdk-*), 네트워크 프로바이더(@midnight-ntwrk/midnight-js-*), 컴파일된 Counter 컨트랙트를 포함한 여러 Midnight 패키지에서 기능을 임포트합니다. - WebSocket 설정:
globalThis.WebSocket = WebSocket;할당은 WebSocket 생성자를 전역으로 사용 가능하게 합니다. Apollo 클라이언트(지갑 동기화에 사용)가 전역 범위에서WebSocket을 찾을 것으로 예상하므로, Node.js에서 GraphQL 구독이 작동하는 데 필요합니다. - 컨트랙트 사전 컴파일:
counterCompiledContract상수는 ZK circuit 에셋과 함께 컨트 랙트 정의를 사전 컴파일합니다. 이 최적화는 모든 컨트랙트 상호작용마다 재컴파일하는 대신 시작 시 circuit 파일을 한 번만 로드하여 성능을 향상시킵니다.
Derive wallet key pairs
counterCompiledContract 상수 아래에 지갑 키 쌍을 파생하기 위한 deriveKeysFromSeed 함수를 생성합니다:
/**
* BIP-44 스타일 파생을 사용하여 16진수 인코딩된 시드에서
* 세 가지 역할(Zswap, NightExternal, Dust)에 대한
* HD 지갑 키를 계정 0, 인덱스 0에서 파생합니다.
*/
const deriveKeysFromSeed = (seed: string) => {
const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
if (hdWallet.type !== 'seedOk') {
throw new Error('Failed to initialize HDWallet from seed');
}
const derivationResult = hdWallet.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
.deriveKeysAt(0);
if (derivationResult.type !== 'keysDerived') {
throw new Error('Failed to derive keys');
}
hdWallet.hdWallet.clear();
return derivationResult.keys;
};
계층적 결정론적(HD) 지갑은 BIP-44 스타일 파생 경로를 사용하여 단일 시드에서 여러 키 쌍을 파생합니다.
세 가지 역할은 서로 다른 지갑 기능에 대응합니다: 차폐 트랜잭션을 위한 Zswap, 비차폐 트랜잭션을 위한 NightExternal, 수수료 관리를 위한 Dust.
clear 메서드는 파생 후 메모리에서 민감한 키 자료를 안전하게 삭제합니다.
Utility functions for formatting and status display
deriveKeysFromSeed 함수 아래에 표시용 토큰 잔액 포맷팅을 위한 formatBalance 함수를 생성합니다:
/**
* 토큰 잔액을 표시용으로 포맷합니다 (예: 1000000000 -> "1,000,000,000").
*/
const formatBalance = (balance: bigint): string => balance.toLocaleString();
/**
* 콘솔에 애니메이션 스피너와 함께 비동기 작업을 실행합니다.
* 실행 중에는 회전 애니메이션을 표시하고, 성공 시 체크 마크, 실패 시 X를 표시합니다.
*/
export const withStatus = async <T>(message: string, fn: () => Promise<T>): Promise<T> => {
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r ${frames[i++ % frames.length]} ${message}`);
}, 80);
try {
const result = await fn();
clearInterval(interval);
process.stdout.write(`\r ✓ ${message}\n`);
return result;
} catch (e) {
clearInterval(interval);
process.stdout.write(`\r ✗ ${message}\n`);
throw e;
}
};
withStatus 함수는 장시간 실행되는 작업에 대한 시각적 피드백을 제공합니다.
스피너 애니메이션은 부드러운 회전 효과를 만드는 유니코드 점자 패턴을 사용합니다.
함수는 성공 시 체크 마크를, 실패 시 X를 표시하여 깔끔한 콘솔 출력을 유지합니다.
Wallet configuration builders
지갑 설정 빌더를 추가합니다:
const buildShieldedConfig = ({ indexer, indexerWS, node, proofServer }: Config) => ({
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
});
const buildUnshieldedConfig = ({ indexer, indexerWS }: Config) => ({
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
});
const buildDustConfig = ({ indexer, indexerWS, node, proofServer }: Config) => ({
networkId: getNetworkId(),
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
indexerClientConnection: {
indexerHttpUrl: indexer,
indexerWsUrl: indexerWS,
},
provingServerUrl: new URL(proofServer),
relayURL: new URL(node.replace(/^http/, 'ws')),
});
각 지갑 타입에는 자체 설정 객체가 필요합니다. 차 폐 지갑은 ZK proof 생성을 위해 proof 서버 접근이 필요합니다. 비차폐 지갑은 proof가 필요하지 않으므로 인메모리 트랜잭션 이력 저장소를 사용합니다. dust 지갑은 추가 오버헤드 버퍼와 안전을 위한 블록 마진으로 수수료 추정을 구성하는 비용 매개변수를 포함합니다.
Wallet synchronization and funding functions
지갑 동기화 및 자금 지원 함수를 추가합니다:
/** 지갑이 네트워크와 완전히 동기화될 때까지 대기합니다. 동기화된 상태를 반환합니다. */
export const waitForSync = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((state) => state.isSynced),
),
);
/** 지갑에 0이 아닌 비차폐 잔액이 생길 때까지 대기합니다. 잔액을 반환합니다. */
export const waitForFunds = (wallet: WalletFacade): Promise<bigint> =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(10_000),
Rx.filter((state) => state.isSynced),
Rx.map((s) => s.unshielded.balances[unshieldedToken().raw] ?? 0n),
Rx.filter((balance) => balance > 0n),
),
);
이 함수들은 RxJS 연산자를 사용하여 지갑 상태 변경을 반응적으로 관찰합니다.
throttleTime 연산자는 업데이트를 인터벌당 한 번으로 제한하여 과도한 처리를 방지합니다.
filter 연산자는 특정 조건을 충족하는 상태만 선택합니다. firstValueFrom 함수는 observable을 첫 번째 일치하는 값으로 확인되는 promise로 변환합니다.
DUST generation registration
waitForFunds 함수 아래에 비차폐 NIGHT UTXO를 DUST 생성에 등록하기 위한 registerForDustGeneration 함수를 생성합니다:
/**
* 비차폐 NIGHT UTXO를 dust 생성에 등록합니다.
*
* Preprod/Undeployed에서 NIGHT 토큰은 시간이 지남에 따라 DUST를 생성하지만,
* UTXO가 온체인 트랜잭션을 통해 dust 생성 용도로 명시적으로
* 지정된 후에만 가능합니다. DUST는 Midnight 네트워크에서 트랜잭션을
* 처리하는 데 사용되는 양도 불가능한 네트워크 리소스입니다.
*/
const registerForDustGeneration = async (
wallet: WalletFacade,
unshieldedKeystore: UnshieldedKeystore,
): Promise<void> => {
const state = await Rx.firstValueFrom(wallet.state().pipe(Rx.filter((s) => s.isSynced)));
// dust가 이미 사용 가능한지 확인 (예: 이전 지정에서)
if (state.dust.availableCoins.length > 0) {
const dustBal = state.dust.walletBalance(new Date());
console.log(` ✓ Dust tokens already available (${formatBalance(dustBal)} DUST)`);
return;
}
// 아직 지정되지 않은 코인만 등록
const nightUtxos = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);
if (nightUtxos.length === 0) {
// 모든 코인이 이미 등록됨 — dust가 생성될 때까지 대기
await withStatus('Waiting for dust tokens to generate', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.walletBalance(new Date()) > 0n),
),
),
);
return;
}
await withStatus(`Registering ${nightUtxos.length} NIGHT UTXO(s) for dust generation`, async () => {
const recipe = await wallet.registerNightUtxosForDustGeneration(
nightUtxos,
unshieldedKeystore.getPublicKey(),
(payload) => unshieldedKeystore.signData(payload),
);
const finalized = await wallet.finalizeRecipe(recipe);
await wallet.submitTransaction(finalized);
});
// dust가 실제로 생성될 때까지 대기 (잔액 > 0), 코인만 나타나는 것이 아님
await withStatus('Waiting for dust tokens to generate', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.walletBalance(new Date()) > 0n),
),
),
);
};
DUST 생성은 등록 후 시간이 지남에 따라 자동으로 DUST 토큰을 생성하도록 tNIGHT 토큰을 지정합니다. 등록 프로세스는 특정 UTXO를 DUST 생성 용도로 지정하는 온체인 트랜잭션을 생성합니다.
이 두 단계 프로세스(등록 + 생성)는 DUST를 위한 별도의 faucet 없이도 사용자가 트랜잭션 수수료를 위한 DUST를 보유할 수 있도록 합니다.
Wallet summary display
registerForDustGeneration 함수 아래에 지갑 요약을 표시하기 위한 printWalletSummary 함수를 생성합니다:
/**
* 세 가지 지갑 타입(차폐, 비차폐, Dust)의 주소와 잔액을
* 보여주는 포맷된 지갑 요약을 콘솔에 출력합니다.
*/
const printWalletSummary = (seed: string, state: any, unshieldedKeystore: UnshieldedKeystore) => {
const networkId = getNetworkId();
const unshieldedBalance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;
// coin + encryption 공개 키에서 bech32m 차폐 주소를 구성
const coinPubKey = ShieldedCoinPublicKey.fromHexString(state.shielded.coinPublicKey.toHexString());
const encPubKey = ShieldedEncryptionPublicKey.fromHexString(state.shielded.encryptionPublicKey.toHexString());
const shieldedAddress = MidnightBech32m.encode(networkId, new ShieldedAddress(coinPubKey, encPubKey)).toString();
const DIV = '───── ─────────────────────────────────────────────────────────';
console.log(`
${DIV}
Wallet Overview Network: ${networkId}
${DIV}
Seed: ${seed}
${DIV}
Shielded (ZSwap)
└─ Address: ${shieldedAddress}
Unshielded
├─ Address: ${unshieldedKeystore.getBech32Address()}
└─ Balance: ${formatBalance(unshieldedBalance)} tNight
Dust
└─ Address: ${state.dust.dustAddress}
${DIV}`);
};
지갑 요약은 세 가지 주소 타입과 현재 잔액을 모두 표시합니다:
- 차폐 주소: 두 개의 공개 키(coin 및 encryption)로 구성되어 Bech32m 형식으로 인코딩됩니다. Midnight 네트워크에서 private 트랜잭션에 사용됩니다.
- 비차폐 주소: keystore에서 파생되며 투명한 Midnight 주소를 나타냅니다. 공개 트랜잭션 및 faucet에서 자금 수령에 사용됩니다.
- Dust 주소: 자동으로 생성되며 수수료 관리에 사용됩니다. 트랜잭션 수수료에 필요한 DUST 토큰을 보유합니다.
Main wallet building function
메인 지갑 빌드 함수를 추가합니다:
/**
* 16진수 시드에서 지갑을 빌드(또는 복원)한 후,
* 지갑이 동기화되고 자금을 수령할 때까지 대기하고 반환합니다.
*/
export const buildWalletAndWaitForFunds = async (config: Config, seed: string): Promise<WalletContext> => {
console.log('');
// HD 키를 파생하고 세 개의 서브 지갑을 초기화
const { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore } = await withStatus(
'Building wallet',
async () => {
const keys = deriveKeysFromSeed(seed);
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], getNetworkId());
const shieldedWallet = ShieldedWallet(buildShieldedConfig(config)).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet = UnshieldedWallet(buildUnshieldedConfig(config)).startWithPublicKey(
PublicKey.fromKeyStore(unshieldedKeystore),
);
const dustWallet = DustWallet(buildDustConfig(config)).startWithSecretKey(
dustSecretKey,
ledger.LedgerParameters.initialParameters().dust,
);
const wallet = new WalletFacade(shieldedWallet, unshieldedWallet, dustWallet);
await wallet.start(shieldedSecretKeys, dustSecretKey);
return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
},
);
// 동기화 중 사용자가 faucet을 통해 자금을 조달할 수 있도록 시드와 비차폐 주소를 즉시 표시
const networkId = getNetworkId();
const DIV = '──────────────────────────────────────────────────────────────';
console.log(`
${DIV}
Wallet Overview Network: ${networkId}
${DIV}
Seed: ${seed}
Unshielded Address (send tNight here):
${unshieldedKeystore.getBech32Address()}
Fund your wallet with tNight from the Preprod faucet:
https://faucet.preprod.midnight.network/
${DIV}
`);
// 지갑이 네트워크와 동기화될 때까지 대기
const syncedState = await withStatus('Syncing with network', () => waitForSync(wallet));
// 모든 주소와 잔액이 포함된 전체 지갑 요약 표시
printWalletSummary(seed, syncedState, unshieldedKeystore);
// 지갑에 자금이 있는지 확인; 없으면 입금될 토큰 대기
const balance = syncedState.unshielded.balances[unshieldedToken().raw] ?? 0n;
if (balance === 0n) {
const fundedBalance = await withStatus('Waiting for incoming tokens', () => waitForFunds(wallet));
console.log(` Balance: ${formatBalance(fundedBalance)} tNight\n`);
}
// Preprod/Undeployed에서 tx 수수료를 위해 NIGHT UTXO를 dust 생성에 등록 (필수)
await registerForDustGeneration(wallet, unshieldedKeystore);
return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
};
export const buildFreshWallet = async (config: Config): Promise<WalletContext> =>
await buildWalletAndWaitForFunds(config, toHex(Buffer.from(generateRandomSeed())));
지갑 빌드 프로세스는 다음 주요 단계를 따릅니다:
- 비차폐 주소 표시: 지갑이 백그라운드에서 동기화되는 동안 사용자가 faucet 토큰을 요청할 수 있도록 비차폐 주소를 즉시 출력합니다.
- 네트워크 동기화:
waitForSync함수는 지갑이 블록체인을 따라잡을 때까지 동기화 진행률을 모니터링합니다. 이를 통해 지갑이 정확한 잔액 정보를 갖도록 합니다. - 지갑 요약 표시: 동기화 후
printWalletSummary가 세 가지 주소 타입(차폐, 비차폐, dust)과 현재 잔액을 모두 표시합니다. - 자금 확인 및 대기: 비차폐 잔액이 0이 아닌지 확인합니다. 잔액이 0이면
waitForFunds를 호출하여 faucet에서 토큰이 도착할 때까지 지갑 상태를 모니터링합니다. - dust 생성 등록: 지갑의 NIGHT UTXO를 dust 생성 서비스에 등록하는 단계로, Preprod 및 Undeployed 네트워크에서 트랜잭션 수수료에 필요합니다.
이 함수는 동기화와 자금 지원이 모두 완료될 때까지 대기하여 지갑이 트랜잭션에 준비되도록 합니다.
Transaction signing helper
buildWalletAndWaitForFunds 함수 아래에 트랜잭션 인텐트에 서명하기 위한 signTransactionIntents 함수를 생성합니다:
/**
* Intent.deserialize에 올바른 proof 마커를 사용하여
* 트랜잭션의 인텐트에 있는 모든 비차폐 오퍼에 서명합니다.
* 지갑 SDK의 signRecipe가 'pre-proof'를 하드코딩하는 버그를 해결합니다.
* 해당 버그로 인해 'proof' 데이터가 포함된 증명된(UnboundTransaction)
* 인텐트 처리에 실패하는 문제가 있습니다.
*/
const signTransactionIntents = (
tx: { intents?: Map<number, any> },
signFn: (payload: Uint8Array) => ledger.Signature,
proofMarker: 'proof' | 'pre-proof',
): void => {
if (!tx.intents || tx.intents.size === 0) return;
for (const segment of tx.intents.keys()) {
const intent = tx.intents.get(segment);
if (!intent) continue;
// 올바른 proof 마커로 인텐트를 복제
const cloned = ledger.Intent.deserialize<ledger.SignatureEnabled, ledger.Proofish, ledger.PreBinding>(
'signature',
proofMarker,
'pre-binding',
intent.serialize(),
);
const sigData = cloned.signatureData(segment);
const signature = signFn(sigData);
if (cloned.fallibleUnshieldedOffer) {
const sigs = cloned.fallibleUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) => cloned.fallibleUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.fallibleUnshieldedOffer = cloned.fallibleUnshieldedOffer.addSignatures(sigs);
}
if (cloned.guaranteedUnshieldedOffer) {
const sigs = cloned.guaranteedUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) => cloned.guaranteedUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.guaranteedUnshieldedOffer = cloned.guaranteedUnshieldedOffer.addSignatures(sigs);
}
tx.intents.set(segment, cloned);
}
};
이 함수는 signRecipe 메서드의 지갑 SDK 제한 사항을 해결합니다. 이 메서드는 proof 마커를 'pre-proof'로 하드코딩합니다.
문제: 이미 증명된 트랜잭션(UnboundTransaction)에 서명할 때 인텐트에는 'proof' 데이터가 포함되어 있지만, signRecipe는 'pre-proof'로 역직렬화를 시도하여 역직렬화 실패를 유발합니다.
해결책: 이 함수는 다음 단계를 수행합니다:
- 올바른 proof 마커로 인텐트를 역직렬화합니다. 증명된 트랜잭션에는
'proof'를, 미증명 트랜잭션에는'pre-proof'를 사용합니다. - 인텐트의 서명 데이터에 대한 서명을 생성합니다.
- 인텐트 내의 fallible 및 guaranteed 비차폐 오퍼 모두에 서명을 추가합니다.
- 올바르게 서명된 인텐트로 트랜잭션의 인텐트 맵을 업데이트합니다.
이를 통해 트랜잭션이 pre-proof 또는 post-proof 상태에 관계없이 서명이 유효하도록 보장합니다.
Provider creation
프로바이더 생성을 추가합니다:
/**
* midnight-js를 위한 통합 WalletProvider 및 MidnightProvider를 생성합니다.
* 밸런스, 서명, 최종화, 제출 작업을 구현하여
* wallet-sdk-facade를 midnight-js contract API에 연결합니다.
*/
export const createWalletAndMidnightProvider = async (
ctx: WalletContext,
): Promise<WalletProvider & MidnightProvider> => {
const state = await Rx.firstValueFrom(ctx.wallet.state().pipe(Rx.filter((s) => s.isSynced)));
return {
getCoinPublicKey() {
return state.shielded.coinPublicKey.toHexString();
},
getEncryptionPublicKey() {
return state.shielded.encryptionPublicKey.toHexString();
},
async balanceTx(tx, ttl?) {
const recipe = await ctx.wallet.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys: ctx.shieldedSecretKeys, dustSecretKey: ctx.dustSecretKey },
{ ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
);
// 지갑 SDK 버그 해결: signRecipe가 인텐트를 복제할 때
// 하드코딩된 'pre-proof' 마커를 사용하지만, 증명된
// (UnboundTransaction) 인텐트에는 'proof' 데이터가 있어
// "Failed to clone intent"가 발생합니다. 올바른 proof 마커로
// 수동으로 서명합니다.
const signFn = (payload: Uint8Array) => ctx.unshieldedKeystore.signData(payload);
signTransactionIntents(recipe.baseTransaction, signFn, 'proof');
if (recipe.balancingTransaction) {
signTransactionIntents(recipe.balancingTransaction, signFn, 'pre-proof');
}
return ctx.wallet.finalizeRecipe(recipe);
},
submitTx(tx) {
return ctx.wallet.submitTransaction(tx) as any;
},
};
};
이 함수는 Wallet SDK를 Midnight.js contracts API에 연결하는 통합 프로바이더를 생성합니다. 두 가지 주요 인터페이스를 구현합니다:
WalletProvider interface
getCoinPublicKey(): 지갑의 차폐 coin 공개 키를 16진수 문자열로 반환하며, 차폐 자금 수령에 사용됩니다.getEncryptionPublicKey(): 지갑의 암호화 공개 키를 16진수 문자열로 반환하며, 트랜잭션 데이터 복호화에 사용됩니다.balanceTx(): 바인딩되지 않은 트랜잭션에 입출력을 추가하여 트랜잭션 수수료를 충당하고 잔돈을 생성합니다. TTL(time-to-live) 매개변수는 기본 30분이며, 이후 미제출 트랜잭션은 만료됩니다. 이 메서드는 증명된 트랜잭션에 대한 트랜잭션 서명 해결책도 적용합니다.
MidnightProvider interface submitTx()
- 최종화된 트랜잭션을 Midnight 네트워크에 제출하고 확인을 기다립니다.
- 검증자가 proof를 검증하고 ledger 상태를 업데이트하면 확인되는 promise를 반환합니다.
Provider configuration
프로바이더 생성 함수 아래에 프로바이더를 설정하기 위한 configureProviders 함수를 생성합니다:
/**
* 컨트랙트 배포 및 상호작용에 필요한 모든 midnight-js 프로바이더를 설정합니다.
* 지갑, proof 서버, indexer 및 private 상태 저장소를 연결합니다.
*/
export const configureProviders = async (ctx: WalletContext, config: Config) => {
const walletAndMidnightProvider = await createWalletAndMidnightProvider(ctx);
const zkConfigProvider = new NodeZkConfigProvider<CounterCircuits>(contractConfig.zkConfigPath);
return {
privateStateProvider: levelPrivateStateProvider<typeof CounterPrivateStateId>({
privateStateStoreName: contractConfig.privateStateStoreName,
walletProvider: walletAndMidnightProvider,
}),
publicDataProvider: indexerPublicDataProvider(config.indexer, config.indexerWS),
zkConfigProvider,
proofProvider: httpClientProofProvider(config.proofServer, zkConfigProvider),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
};
이 함수는 컨트랙트 배포 및 상호작용에 필요한 모든 프로바이더를 조합합니다. 각 프로바이더는 특정 목적을 수행합니다:
- privateStateProvider: LevelDB를 사용하여 로컬 파일 시스템에 private 컨트랙트 상태를 영구적으로 저장합니다. 이를 통해 DApp이 블록체인에 노출하지 않고 세션 간 오프체인 데이터를 유지할 수 있습니다.
- publicDataProvider: Midnight Indexer에 연결하여 온체인 데이터를 쿼리 합니다. 이 프로바이더는 GraphQL 쿼리를 통해 컨트랙트 상태, 트랜잭션 이력 및 기타 블록체인 정보를 가져옵니다.
- zkConfigProvider: 컴파일된 컨트랙트 디렉터리에서 ZK circuit 매개변수를 로드합니다. 이 매개변수에는 ZK proof 생성에 필요한 circuit 설정, proving 키, verifying 키가 포함됩니다.
- proofProvider: proof 서버(로컬 또는 원격)에 연결하여 circuit 실행에 대한 영지식 proof를 생성합니다. 이 프로바이더는 circuit 입력을 proof 서버에 보내고 암호학적 proof를 받습니다.
- walletProvider와 midnightProvider: 둘 다
createWalletAndMidnightProvider로 생성된 동일한 통합 프로바이더를 참조하며, 트랜잭션 밸런싱, 서명 및 제출을 처리합니다.
Contract interaction functions
프로바이더 설정 함수 아래에 컨트랙트 상호작용 함수를 생성합니다:
export const counterContractInstance: CounterContract = new Counter.Contract(witnesses);
export const getCounterLedgerState = async (
providers: CounterProviders,
contractAddress: ContractAddress,
): Promise<bigint | null> => {
assertIsContractAddress(contractAddress);
logger.info('Checking contract ledger state...');
const state = await providers.publicDataProvider
.queryContractState(contractAddress)
.then((contractState) => (contractState != null ? Counter.ledger(contractState.data).round : null));
logger.info(`Ledger state: ${state}`);
return state;
};
export const joinContract = async (
providers: CounterProviders,
contractAddress: string,
): Promise<DeployedCounterContract> => {
const counterContract = await findDeployedContract(providers, {
contractAddress,
compiledContract: counterCompiledContract,
privateStateId: 'counterPrivateState',
initialPrivateState: { privateCounter: 0 },
});
logger.info(`Joined contract at address: ${counterContract.deployTxData.public.contractAddress}`);
return counterContract;
};
export const deploy = async (
providers: CounterProviders,
privateState: CounterPrivateState,
): Promise<DeployedCounterContract> => {
logger.info('Deploying counter contract...');
const counterContract = await deployContract(providers, {
compiledContract: counterCompiledContract,
privateStateId: 'counterPrivateState',
initialPrivateState: privateState,
});
logger.info(`Deployed contract at address: ${counterContract.deployTxData.public.contractAddress}`);
return counterContract;
};
export const increment = async (counterContract: DeployedCounterContract): Promise<FinalizedTxData> => {
logger.info('Incrementing...');
const finalizedTxData = await counterContract.callTx.increment();
logger.info(`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`);
return finalizedTxData.public;
};
export const displayCounterValue = async (
providers: CounterProviders,
counterContract: DeployedCounterContract,
): Promise<{ counterValue: bigint | null; contractAddress: string }> => {
const contractAddress = counterContract.deployTxData.public.contractAddress;
const counterValue = await getCounterLedgerState(providers, contractAddress);
if (counterValue === null) {
logger.info(`There is no counter contract deployed at ${contractAddress}.`);
} else {
logger.info(`Current counter value: ${Number(counterValue)}`);
}
return { contractAddress, counterValue };
};
이 함수들은 컨트랙트 상호작용을 위한 고수준 작업을 제공합니다:
- counterContractInstance: 생성된
Counter.Contract클래스와 witness 구현을 사용하여 컨트랙트 인스턴스를 생성합니다. 이 인스턴스는 배포된 모든 counter 컨트랙트의 템플릿 역할을 합니다. - getCounterLedgerState: 블록체인에서 배포된 컨트랙트의 현재 상태를 쿼리합니다.
publicDataProvider를 사용하여 컨트랙트 상태를 가져온 다음, 생성된Counter.ledger()함수를 적용하여 상태 데이터를 역직렬화하고round값(카운터의 현재 값)을 추출합니다. 컨트랙트가 존재하지 않으면null을 반환합니다. - joinContract: 컨트랙트 주소를 사용하여 기존 배포된 컨트랙트에 연결합니다. Midnight.js의
findDeployedContract()를 호출하여 컨트랙트가 존재하는지 확인하고, 로컬 private 상태를 초기화하며, 컨트랙트와 상호작용하는 데 사용할 수 있는DeployedCounterContract객체를 반환합니다. - deploy: 블록체인에 counter 컨트랙트의 새 인스턴스를 배포합니다. 컴파일된 컨트랙트, private 상태 설정 및 초기 private 상태와 함께 Midnight.js의
deployContract()를 호출합니다. 새 컨트랙트의 주소를 포함한 배포 트랜잭션 데이터가 있는DeployedCounterContract객체를 반환합니다. - increment: 배포된 컨트랙트에서
incrementcircuit를 호출하는 트랜잭션을 제출합니다. 배포된 컨트랙트 인스턴스의callTx.increment()메서드를 사용하여 영지식 proof를 생성하고, 트랜잭션을 제출하며, 최종화를 기다립니다. 트랜잭션 ID와 블록 높이를 포함한 최종화된 트랜잭션 데이터를 반환합니다. - displayCounterValue: 배포된 컨트랙트의 현재 카운터 값을 가져와 표시합니다. 내부적으로
getCounterLedgerState()를 호출하여 값을 가져오고 콘솔에 로깅합니다. 추가 처리를 위해 카운터 값과 컨트랙트 주소를 모두 반환합니다.
DUST monitoring
컨트랙트 상호작용 함수 아래에 DUST 모니터링 함수를 생성합니다:
/**
* 지갑 상태에서 현재 DUST 잔액을 가져옵니다.
*/
export const getDustBalance = async (
wallet: WalletFacade,
): Promise<{ available: bigint; pending: bigint; availableCoins: number; pendingCoins: number }> => {
const state = await Rx.firstValueFrom(wallet.state().pipe(Rx.filter((s) => s.isSynced)));
const available = state.dust.walletBalance(new Date());
const availableCoins = state.dust.availableCoins.length;
const pendingCoins = state.dust.pendingCoins.length;
const pending = state.dust.pendingCoins.reduce((sum, c) => sum + c.initialValue, 0n);
return { available, pending, availableCoins, pendingCoins };
};
/**
* 실시간으로 업데이트되는 DUST 잔액을 모니터링합니다.
* 5초마다 잔액, 코인 수, 상태를 보여주는 상태 줄을 출력합니다.
* 사용자가 Enter를 누르면 (제공된 signal을 통해) 완료됩니다.
*/
export const monitorDustBalance = async (wallet: WalletFacade, stopSignal: Promise<void>): Promise<void> => {
let stopped = false;
void stopSignal.then(() => {
stopped = true;
});
const sub = wallet
.state()
.pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
)
.subscribe((state) => {
if (stopped) return;
const now = new Date();
const available = state.dust.walletBalance(now);
const availableCoins = state.dust.availableCoins.length;
const pendingCoins = state.dust.pendingCoins.length;
const registeredNight = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration === true,
).length;
const totalNight = state.unshielded.availableCoins.length;
let status = '';
if (pendingCoins > 0 && availableCoins === 0) {
status = '⚠ locked by pending tx';
} else if (available > 0n) {
status = '✓ ready to deploy';
} else if (availableCoins > 0) {
status = 'accruing...';
} else if (registeredNight > 0) {
status = 'waiting for generation...';
} else {
status = 'no NIGHT registered';
}
const time = now.toLocaleTimeString();
console.log(
` [${time}] DUST: ${formatBalance(available)} (${availableCoins} coins, ${pendingCoins} pending) | NIGHT: ${totalNight} UTXOs, ${registeredNight} registered | ${status}`,
);
});
await stopSignal;
sub.unsubscribe();
};
export function setLogger(_logger: Logger) {
logger = _logger;
}
DUST 모니터링 함수는 수수료 토큰 가용성에 대한 실시간 피드백을 제공합니다. 사용 가능한 잔액, 코인 수, 대기 중인 트랜잭션 및 등록 상태를 표시합니다. 모니터는 5초마다 업데이트되며 사용자가 Enter 키를 누를 때까지 실행되어 DUST 생성 진행 상황에 대한 가시성을 제공합니다.
Implement the CLI interface
CLI는 사용자가 counter 컨트랙트와 상호작용할 수 있는 대화형 메뉴 시스템을 제공합니다.
counter-cli/src/cli.ts를 생성하고 다음 코드를 추가합니다:
import { type WalletContext } from './api';
import { stdin as input, stdout as output } from 'node:process';
import { createInterface, type Interface } from 'node:readline/promises';
import { type Logger } from 'pino';
import { type Config } from './config'
import { type StartedDockerComposeEnvironment, type DockerComposeEnvironment } from 'testcontainers';
import { type CounterProviders, type DeployedCounterContract } from './common-types';
import * as api from './api';
let logger: Logger;
const GENESIS_MINT_WALLET_SEED = '0000000000000000000000000000000000000000000000000000000000000001';
const BANNER = `
╔══════════════════════════════════════════════════════════════╗
║ ║
║ Midnight Counter Example ║
║ ───────────────────── ║
║ A privacy-preserving smart contract demo ║
║ ║
╚══════════════════════════════════════════════════════════════╝
`;
const DIVIDER = '──────────────────────────────────────────────────────────────';
const WALLET_MENU = `
${DIVIDER}
Wallet Setup
${DIVIDER}
[1] Create a new wallet
[2] Restore wallet from seed
[3] Exit
${'─'.repeat(62)}
> `;
const contractMenu = (dustBalance: string) => `
${DIVIDER}
Contract Actions${dustBalance ? ` DUST: ${dustBalance}` : ''}
${DIVIDER}
[1] Deploy a new counter contract
[2] Join an existing counter contract
[3] Monitor DUST balance
[4] Exit
${'─'.repeat(62)}
> `;
const counterMenu = (dustBalance: string) => `
${DIVIDER}
Counter Actions${dustBalance ? ` DUST: ${dustBalance}` : ''}
${DIVIDER}
[1] Increment counter
[2] Display current counter value
[3] Exit
${'─'.repeat(62)}
> `;
메뉴 상수는 사용자 상호작용을 위한 시각적 구조를 정의합니다. DUST 잔액은 메뉴 헤더에 동적으로 표시되어 수수료 토큰 가용성에 대한 지속적인 피드백을 제공합니다.
Set up the wallet
메뉴 상수 아래에 지갑 설정 함수를 생성합니다:
const buildWalletFromSeed = async (config: Config, rli: Interface): Promise<WalletContext> => {
const seed = await rli.question('Enter your wallet seed: ');
return await api.buildWalletAndWaitForFunds(config, seed);
};
const buildWallet = async (config: Config, rli: Interface): Promise<WalletContext | null> => {
while (true) {
const choice = await rli.question(WALLET_MENU);
switch (choice.trim()) {
case '1':
return await api.buildFreshWallet(config);
case '2':
return await buildWalletFromSeed(config, rli);
case '3':
return null;
default:
logger.error(`Invalid choice: ${choice}`);
}
}
};
이 함수는 세 가지 옵션이 있는 대화형 메뉴를 표시합니다:
api.buildFreshWallet메서드를 사용하여 임의로 생성된 시드로 새 지갑을 생성합니다.buildWalletFromSeed함수를 사용하여 시드 구문에서 기존 지갑을 복원합니다.null을 반환하여 애플리케이션을 종료합니다.
Contract interaction helpers
지갑 설정 함수 아래에 DUST 잔액 표시, 컨트랙트 참여, DUST 모니터링 시작을 위한 헬퍼 함수를 생성합니다:
const getDustLabel = async (wallet: api.WalletContext['wallet']): Promise<string> => {
try {
const dust = await api.getDustBalance(wallet);
return dust.available.toLocaleString();
} catch {
return '';
}
};
const joinContract = async (providers: CounterProviders, rli: Interface): Promise<DeployedCounterContract> => {
const contractAddress = await rli.question('Enter the contract address (hex): ');
return await api.joinContract(providers, contractAddress);
};
const startDustMonitor = async (wallet: api.WalletContext['wallet'], rli: Interface): Promise<void> => {
console.log('');
const stopPromise = rli.question(' Press Enter to return to menu...\n').then(() => {});
await api.monitorDustBalance(wallet, stopPromise);
console.log('');
};
이 헬퍼 함수들은 컨트랙트 상호작용을 단순화합니다:
getDustLabel()은 메뉴 헤더에 표시하기 위해 DUST 잔액을 가져와 포맷합니다.joinContract()는 사용자에게 컨트랙트 주소를 입력하라고 요청하고 기존 컨트랙트에 연결합니다.startDustMonitor()는 DUST 잔액 모니터를 시작하며, 사용자가 Enter를 누르면 메뉴로 돌아갈 때까지 실시간 업데이트를 표시합니다.
Contract deployment flow
컨트랙트 상호작용 헬퍼 아래에 컨트랙트 배포 또는 참여를 위한 함수를 생성합니다:
const deployOrJoin = async (
providers: CounterProviders,
walletCtx: api.WalletContext,
rli: Interface,
): Promise<DeployedCounterContract | null> => {
while (true) {
const dustLabel = await getDustLabel(walletCtx.wallet);
const choice = await rli.question(contractMenu(dustLabel));
switch (choice.trim()) {
case '1':
try {
const contract = await api.withStatus('Deploying counter contract', () =>
api.deploy(providers, { privateCounter: 0 }),
);
console.log(` Contract deployed at: ${contract.deployTxData.public.contractAddress}\n`);
return contract;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.log(`\n ✗ Deploy failed: ${msg}`);
if (e instanceof Error && e.cause) {
let cause: unknown = e.cause;
let depth = 0;
while (cause && depth < 5) {
const causeMsg =
cause instanceof Error
? `${cause.message}\n ${cause.stack?.split('\n').slice(1, 3).join('\n ') ?? ''}`
: String(cause);
console.log(` cause: ${causeMsg}`);
cause = cause instanceof Error ? cause.cause : undefined;
depth++;
}
}
if (msg.toLowerCase().includes('dust') || msg.toLowerCase().includes('no dust')) {
console.log(' Insufficient DUST for transaction fees. Use option [3] to monitor your balance.');
}
console.log('');
}
break;
case '2':
try {
return await joinContract(providers, rli);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.log(` ✗ Failed to join contract: ${msg}\n`);
}
break;
case '3':
await startDustMonitor(walletCtx.wallet, rli);
break;
case '4':
return null;
default:
console.log(` Invalid choice: ${choice}`);
}
}
};
배포 흐름은 메시지와 원인 체인을 표시하여 오류를 정상적으로 처리합니다. DUST 관련 오류는 사용자에게 모니터링 옵션으로 안내하는 유용한 메시지와 함께 특별한 처리를 받습니다.
Main interaction loop
배포 흐름 아래에 메인 상호작용 루프를 생성합니다:
const mainLoop = async (providers: CounterProviders, walletCtx: api.WalletContext, rli: Interface): Promise<void> => {
const counterContract = await deployOrJoin(providers, walletCtx, rli);
if (counterContract === null) {
return;
}
while (true) {
const dustLabel = await getDustLabel(walletCtx.wallet);
const choice = await rli.question(counterMenu(dustLabel));
switch (choice.trim()) {
case '1':
try {
await api.withStatus('Incrementing counter', () => api.increment(counterContract));
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.log(` ✗ Increment failed: ${msg}\n`);
}
break;
case '2':
await api.displayCounterValue(providers, counterContract);
break;
case '3':
return;
default:
console.log(` Invalid choice: ${choice}`);
}
}
};
메인 루프는 성공적인 컨트랙트 설정 후 counter 작업을 처리합니다. increment 작업 중 오류는 루프를 종료하지 않고 캐치되어 표시되므로 사용자가 재시도하거나 DUST 잔액을 확인할 수 있습니다.
Main entry point
컨트랙트 배포 흐름 아래에 메인 진입점으로 run 함수를 생성합니다:
export const run = async (config: Config, _logger: Logger): Promise<void> => {
logger = _logger;
api.setLogger(_logger);
console.log(BANNER);
const rli = createInterface({ input, output, terminal: true });
try {
const walletCtx = await buildWallet(config, rli);
if (walletCtx === null) {
return;
}
try {
const providers = await api.withStatus('Configuring providers', () => api.configureProviders(walletCtx, config));
console.log('');
await mainLoop(providers, walletCtx, rli);
} catch (e) {
if (e instanceof Error) {
logger.error(`Error: ${e.message}`);
logger.debug(`${e.stack}`);
} else {
throw e;
}
} finally {
try {
await walletCtx.wallet.stop();
} catch (e) {
logger.error(`Error stopping wallet: ${e}`);
}
}
} finally {
rli.close();
rli.removeAllListeners();
logger.info('Goodbye.');
}
};
run 함수는 적절한 리소스 정리와 함께 전체 CLI 워크플로우를 처리합니다:
- 외부 try-finally 블록: 오류가 발생하더라도 readline 인터페이스가 닫히고 리스너가 제거되도록 합니다. 이를 통해 메모리 누수를 방지하고 깔끔한 프로세스 종료를 보장합니다.
- 내부 try-finally 블록: 지갑이 적절히 중지되어 모든 네트워크 연결을 닫고 상태를 영구화하도록 보장합니다. 지갑 정리는 readline 정리 전에 발생하여 모든 트랜잭션이 완료되도록 합니다.
- 오류 처리: 프로바이더 설정 또는 메인 루프 실행 중 오류를 캐치하고 로깅합니다. 갑작스러운 충돌 없이 정상적으로 종료됩니다.
Create entry point and exports
- preprod
- undeployed
counter-cli/src/preprod.ts를 생성합니다:
import { createLogger } from './logger-utils.js';
import { run } from './cli.js';
import { PreprodConfig } from './config.js';
const config = new PreprodConfig();
const logger = await createLogger(config.logDir);
await run(config, logger);
counter-cli/src/undeployed.ts를 생성합니다:
import { createLogger } from './logger-utils.js';
import { run } from './cli.js';
import { UndeployedConfig } from './config.js';
const config = new UndeployedConfig();
const logger = await createLogger(config.logDir);
await run(config, logger);
counter-cli/src/index.ts를 생성합니다:
export * from './api';
export * from './cli';
진입점은 로거와 설정을 초기화한 다음 CLI를 시작합니다. index 파일은 패키지를 위한 깔끔한 내보내기 인터페이스를 제공합니다.
Run the complete application
이 섹션에서는 전체 Counter DApp을 실행하는 과정을 설명합니다.
Start the proof server
터미널을 열고 아직 실행 중이 아니라면 proof 서버를 시작합니다:
docker run -p 6300:6300 midnightntwrk/proof-server:latest midnight-proof-server -v
세션 내내 이 터미널을 열 어 두세요.
Launch the CLI
새 터미널을 열고, counter-cli 디렉터리로 이동하여 애플리케이션을 시작합니다:
cd counter-cli
npm run preprod
CLI는 배너를 표시하고 지갑 설정 메뉴를 제공합니다:
╔══════════════════════════════════════════════════════════════╗
║ ║
║ Midnight Counter Example ║
║ ─────────────────────────────────────────────────║
║ A privacy-preserving smart contract demo ║
║ ║
╚══════════════════════════════════════════════════════════════╝
──────────────────────────────────────────────────────────────
Wallet Setup
──────────────────────────────────────────────────────────────
[1] Create a new wallet
[2] Restore wallet from seed
[3] Exit
──────────────────────────────────────────────────────────────
>
CLI와의 상호작용에 대해 자세히 알아보려면 counter DApp 예제를 참조하세요.
Next steps
완전한 Counter CLI를 구축했습니다. 다중 사용자 상호작용과 private 상태가 있는 더 복잡한 컨트랙트는 게시판 DApp 예제를 살펴보세요.