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

Preprod에서 프로그래밍 방식으로 DUST 생성하기

빈 프로젝트 설정부터 지갑 생성 또는 복원, tNIGHT 충전, DUST 생성을 위한 토큰 등록까지 전체 과정을 안내합니다. 완료하면 지갑을 설정하고 DUST 생성을 시작하는 TypeScript 스크립트를 갖게 됩니다.

사전 요구사항

Midnight 개발은 **macOS, Linux, Windows(WSL 사용)**에서 지원됩니다.

다음이 설치되어 있는지 확인하세요:

설치를 확인합니다:

node --version    # should print v18.x.x or higher
npm --version # should print 9.x.x or higher
docker --version # should print Docker version 2x.x.x or higher

Docker가 필요한 이유

Midnight는 데이터 프라이버시를 위해 영지식 증명을 사용합니다. Proof server는 이 증명을 생성하는 서비스로, 로컬 머신에서 실행됩니다. 프라이빗 데이터가 컴퓨터 밖으로 나가지 않습니다. Docker를 사용하면 하나의 명령어로 이미지를 풀하고 시작할 수 있어 간편합니다.

Step 1: 프로젝트 생성

프로젝트용 빈 디렉토리를 만듭니다. 이 튜토리얼에서 생성하는 모든 파일이 이 안에 들어갑니다.

mkdir midnight-dust-tutorial
cd midnight-dust-tutorial

Step 2: package.json 생성 및 의존성 설치

모든 Node.js 프로젝트에는 의존성과 스크립트를 선언하는 package.json 파일이 필요합니다.

touch package.json

열어서 아래 내용을 붙여넣습니다.

{
"type": "module",
"scripts": {
"start": "tsx src/index.ts"
},
"dependencies": {
"@midnight-ntwrk/ledger-v8": "^8.0.0",
"@midnight-ntwrk/midnight-js-network-id": "^4.0.0",
"@midnight-ntwrk/midnight-js-utils": "^4.0.0",
"@midnight-ntwrk/wallet-sdk-address-format": "^3.0.0",
"@midnight-ntwrk/wallet-sdk-dust-wallet": "^3.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-unshielded-wallet": "^2.0.0",
"rxjs": "^7.8.1",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/ws": "^8.18.1",
"tsx": "^4.19.0"
}
}

각 항목의 역할은 다음과 같습니다:

  • "type": "module": Node.js가 최신 ES module import(import/export)를 사용하도록 설정합니다
  • "scripts" > "start": npm start의 동작을 정의합니다. tsx src/index.ts를 실행하여 TypeScript 파일을 한 번에 컴파일하고 실행합니다
  • "dependencies": 스크립트가 런타임에 import하는 Midnight wallet SDK 패키지입니다
  • "devDependencies": tsx는 별도의 컴파일 단계 없이 TypeScript 파일을 직접 실행하며, @types/ws는 WebSocket 라이브러리의 타입 정의를 제공합니다

Midnight wallet SDK 패키지는 호환성 매트릭스에 따라 버전을 맞추어야 합니다.

이제 모든 것을 설치합니다:

npm install

Midnight SDK 패키지와 의존성이 다운로드됩니다.

Step 3: proof server 설정 파일 생성

Proof server는 Docker에서 실행됩니다. 나중에 한 명령어로 시작할 수 있도록 작은 Compose 파일에 실행 방법을 정의합니다.

touch proof-server.yml

proof-server.yml을 열고 아래 내용을 붙여넣습니다:

services:
proof-server:
image: 'midnightntwrk/proof-server:8.0.3'
command: ['midnight-proof-server -v']
ports:
- '6300:6300'
environment:
RUST_BACKTRACE: 'full'

Docker에 proof server 실행 방법을 알려주는 설정입니다. 공식 Midnight proof server 이미지를 다운로드하고 포트 6300으로 노출하여 스크립트가 http://localhost:6300으로 증명을 요청할 수 있게 합니다.

Step 4: src/index.ts 생성

모든 스크립트 로직은 src/index.ts에 들어갑니다. 디렉토리와 파일을 생성한 뒤, 이 단계의 나머지에서 섹션별로 채웁니다.

mkdir src
touch src/index.ts

src/index.ts를 열고 아래 코드 블록을 모두 붙여넣거나, 페이지 하단의 전체 스크립트를 사용하세요. 각 섹션에 역할을 설명하는 주석이 달려 있습니다:

Import

스크립트에는 여러 패키지가 필요합니다: WebSocket polyfill(Node.js에 내장되어 있지 않음), shielded/unshielded/dust 지갑용 Midnight wallet SDK 패키지, HD 키 파생 라이브러리, 주소 포매팅 유틸리티 등입니다. 먼저 전역 WebSocket을 설정하여 wallet SDK가 indexer에 연결할 수 있게 합니다.

// The Midnight wallet SDK communicates with the indexer over WebSockets.
// Node.js doesn't have a built-in WebSocket like browsers do, so we import
// one and set it globally before any wallet code runs.
import { WebSocket } from 'ws';
(globalThis as any).WebSocket = WebSocket;

// Buffer: converts hex strings to/from binary data (used for seeds and keys)
// readline: reads user input from the terminal (for the wallet create/restore prompt)
// rxjs: reactive streams library — the wallet SDK emits state updates as observables
import { Buffer } from 'buffer';
import * as readline from 'readline';
import * as Rx from 'rxjs';

// HD wallet — derives multiple key pairs from a single seed phrase
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';

// Utility to convert binary data to hex strings
import { toHex } from '@midnight-ntwrk/midnight-js-utils';

// Ledger types — defines tokens, keys, and on-chain parameters
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';

// WalletFacade — combines the three sub-wallets into a single interface
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';

// ShieldedWallet — handles private (zero-knowledge) transactions
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';

// DustWallet — generates and spends DUST for transaction fees
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';

// UnshieldedWallet — handles transparent (public) transactions like receiving tNight
import {
createKeystore,
InMemoryTransactionHistoryStorage,
PublicKey,
UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import type { UnshieldedKeystore } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';

// Network ID — tells the SDK which network to connect to (preprod, preview, etc.)
import { setNetworkId, getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';

// Address formatting — encodes wallet keys into human-readable bech32m addresses
import {
DustAddress,
MidnightBech32m,
ShieldedAddress,
ShieldedCoinPublicKey,
ShieldedEncryptionPublicKey,
} from '@midnight-ntwrk/wallet-sdk-address-format';

설정

네트워크 엔드포인트를 한 곳에 정의합니다.

// Preprod network endpoints. The indexer and RPC node are public services hosted
// by Midnight. The proof server runs locally on your machine (via Docker) so that
// your private data never leaves your computer.

const CONFIG = {
networkId: 'preprod' as const,
indexerHttpUrl: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWsUrl: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preprod.midnight.network',
proofServer: 'http://localhost:6300',
faucetUrl: 'https://faucet.preprod.midnight.network',
};

잔액 포매팅

Midnight 토큰 잔액은 bigint 원자 단위로 저장됩니다. 사람이 읽기 쉬운 형태로 표시하기 위해, NIGHT와 DUST 각각에 대해 정수를 소수 문자열로 변환하는 간단한 헬퍼 함수 두 개를 사용합니다.

// NIGHT is divided into 10^6 STAR. A raw value of 1,000,000,000 equals 1,000.000000 tNight.
// DUST is divided into 10^15 SPECK, so it uses a more granular decimal representation.

const formatNight = (raw: bigint): string => {
const whole = raw / 1_000_000n;
const fraction = (raw % 1_000_000n).toString().padStart(6, '0');
return `${whole.toLocaleString()}.${fraction}`;
};

const formatDust = (raw: bigint): string => {
const whole = raw / 1_000_000_000_000_000n;
const fraction = (raw % 1_000_000_000_000_000n).toString().padStart(15, '0');
return `${whole.toLocaleString()}.${fraction}`;
};

시계 스피너

지갑 빌드, 네트워크 동기화, NIGHT 등록 등 이 스크립트의 여러 작업은 완료까지 몇 초가 걸립니다. 사용자에게 시각적 피드백을 주기 위해 장시간 실행되는 각 작업을 스피너 애니메이션으로 감쌉니다.

// Shows a rotating clock animation in the terminal while an async operation runs.

const withStatus = async <T>(message: string, fn: () => Promise<T>): Promise<T> => {
const clocks = ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛'];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r ${clocks[i++ % clocks.length]} ${message}`);
}, 150);
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;
}
};

사용자 입력 프롬프트

스크립트는 사용자에게 몇 가지를 대화형으로 질문합니다(지갑 생성/복원 여부, 지정할 dust 주소 등). Node의 내장 readline 모듈을 Promise로 감싸는 작은 prompt 헬퍼로 사용자 입력을 await할 수 있게 합니다.

const prompt = (question: string): Promise<string> => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
};

Dust 주소 검증 및 프롬프트

DUST 생성을 위해 dust 주소를 지정할 때, 실수로 shielded나 unshielded 주소를 붙여넣지 않았는지 확인해야 합니다. 이 헬퍼는 bech32m 형식을 검증하고, 사용자가 유효한 값을 입력하거나 Enter를 눌러 자신의 dust 주소를 사용할 때까지 반복합니다.

// Dust addresses use the bech32m prefix "mn_dust_". This validates that the
// user hasn't accidentally pasted a shielded or unshielded address.

const isValidDustAddress = (addr: string): boolean => {
if (!addr.startsWith('mn_dust_')) return false;
try {
MidnightBech32m.parse(addr).decode(DustAddress, getNetworkId());
return true;
} catch {
return false;
}
};

const promptForDustAddress = async (ownDustAddress: string): Promise<string> => {
while (true) {
const input = await prompt(` Paste your Dust address to designate (Enter for this wallet's): `);
const target = input || ownDustAddress;

if (isValidDustAddress(target)) {
if (target !== ownDustAddress) {
console.log(`\n Using external dust address: ${target}\n`);
} else {
console.log('');
}
return target;
}

console.log(' ❌ Invalid dust address. Dust addresses start with "mn_dust_" followed by the network.');
console.log(' Make sure you\'re not pasting a shielded or unshielded address.\n');
}
};

지갑 시드 생성 또는 복원

모든 Midnight 지갑은 32바이트 시드에서 파생됩니다. 이 함수는 새 지갑 생성(새로운 랜덤 시드 생성)과 기존 지갑 복원(기존 시드 입력) 중 하나를 선택하게 합니다. 어느 경우든 시드를 hex 문자열로 반환합니다.

const getOrCreateSeed = async (): Promise<string> => {
const choice = await prompt(' Create a new wallet or restore an existing one? (n/r): ');

if (choice.toLowerCase() === 'r') {
const seed = await prompt(' Enter your seed: ');
if (!seed || seed.length < 32) {
throw new Error('Invalid seed. The seed should be a 64-character hex string.');
}
console.log(' Restoring wallet from seed...\n');
return seed;
}

// Generate a brand new seed
const seed = toHex(Buffer.from(generateRandomSeed()));
console.log('\n Created new wallet.');
console.log(' ⚠️ Save this seed — it is the ONLY way to restore your wallet:\n');
console.log(` ${seed}\n`);
return seed;
};

시드에서 키 파생

하나의 시드에서 HD(hierarchical deterministic) 파생을 통해 shielded 트랜잭션용, unshielded 트랜잭션용, DUST용 세 세트의 키를 생성합니다. 이 함수는 HD wallet 라이브러리에 세 키를 한꺼번에 파생하도록 요청한 뒤, 민감한 키 데이터를 메모리에서 삭제합니다.

// The HD wallet derives three sets of keys from a single seed:
// - Zswap: for shielded (private) transactions
// - NightExternal: for unshielded (transparent) transactions
// - Dust: for DUST fee management

const deriveKeys = (seed: string) => {
const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
if (hdWallet.type !== 'seedOk') {
throw new Error('Failed to initialize HDWallet from seed. Is the seed a valid hex string?');
}

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 from seed.');
}

// Clear sensitive key material from memory
hdWallet.hdWallet.clear();

return derivationResult.keys;
};

지갑 빌드

세 개의 서브 지갑을 하나로 결합하는 단계입니다. 각 서브 지갑은 고유한 설정으로 생성된 뒤 WalletFacade.init()에 전달되어 하나의 통합 인터페이스가 됩니다. 이 facade가 나머지 스크립트에서 사용하는 대상이며, 내부적으로 세 지갑이 함께 동작하는 것을 추상화합니다.

// Midnight uses three sub-wallets, each handling a different type of transaction.
// The WalletFacade ties them together into a single interface.

const buildWallet = async (keys: ReturnType<typeof deriveKeys>) => {
setNetworkId(CONFIG.networkId);

const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], getNetworkId());

// Build the three sub-wallet configs. WalletFacade.init() takes a single
// configuration object — we spread the per-wallet configs into one and let
// the factory functions receive the relevant subset.
const shieldedConfig = {
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: CONFIG.indexerHttpUrl,
indexerWsUrl: CONFIG.indexerWsUrl,
},
provingServerUrl: new URL(CONFIG.proofServer),
relayURL: new URL(CONFIG.node.replace(/^http/, 'ws')),
};

const unshieldedConfig = {
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: CONFIG.indexerHttpUrl,
indexerWsUrl: CONFIG.indexerWsUrl,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
};

const dustConfig = {
...shieldedConfig,
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
};

// ── Create and start the unified facade via static factory ──
const wallet = await WalletFacade.init({
configuration: { ...shieldedConfig, ...unshieldedConfig, ...dustConfig },
shielded: (cfg) => ShieldedWallet(cfg).startWithSecretKeys(shieldedSecretKeys),
unshielded: (cfg) => UnshieldedWallet(cfg).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
dust: (cfg) =>
DustWallet(cfg).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
});
await wallet.start(shieldedSecretKeys, dustSecretKey);

return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
};

지갑 동기화 대기

지갑을 빌드한 후 사용하기 전에 네트워크와 동기화해야 합니다. 지갑은 RxJS observable로 상태 업데이트를 방출하므로, isSynced가 true인 첫 번째 상태를 기다립니다.

const waitForSync = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((state) => state.isSynced),
),
);

자금 수신 대기

사용자가 faucet에서 tNight를 요청한 후, 도착을 기다립니다. waitForSync와 같은 패턴이지만, 이번에는 unshielded NIGHT 잔액이 0이 아닌 값이 되는 것을 필터링합니다.

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

DUST 생성을 위한 NIGHT 토큰 등록

스크립트의 핵심 부분입니다. NIGHT 토큰은 자동으로 DUST를 생성하지 않으며, 온체인 트랜잭션으로 명시적으로 등록해야 합니다. 이 함수는 아직 등록되지 않은 NIGHT UTXO를 찾아 등록 트랜잭션을 구성하고, unshielded keystore로 서명한 뒤 제출합니다. 선택적 targetDustAddress 매개변수로 생성된 DUST를 다른 지갑으로 보낼 수 있습니다(지정하지 않으면 스크립트 자체의 dust 주소로 전송).

// Your NIGHT tokens don't produce DUST until you explicitly register them via an
// on-chain transaction. The targetDustAddress parameter specifies which dust
// address will receive the generated DUST.

const registerForDustGeneration = async (
wallet: WalletFacade,
unshieldedKeystore: UnshieldedKeystore,
targetDustAddress: string,
isExternalAddress: boolean = false,
): Promise<void> => {
const state = await Rx.firstValueFrom(
wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);

// Check: Do we already have DUST from a previous session?
if (state.dust.availableCoins.length > 0) {
const dustBalance = state.dust.balance(new Date());
console.log(` DUST already available: ${formatDust(dustBalance)}\n`);
return;
}

// Find NIGHT UTXOs that haven't been registered yet
const unregisteredCoins = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);

if (unregisteredCoins.length === 0) {
console.log(' All NIGHT already registered. Waiting for DUST to generate...');
} else {
// Decode the bech32m dust address string into a DustAddress object
const dustReceiver = MidnightBech32m.parse(targetDustAddress).decode(DustAddress, getNetworkId());

// Submit the registration transaction
await withStatus(
`Registering NIGHT for dust generation → ${targetDustAddress}`,
async () => {
const recipe = await wallet.registerNightUtxosForDustGeneration(
unregisteredCoins,
unshieldedKeystore.getPublicKey(),
(payload) => unshieldedKeystore.signData(payload),
dustReceiver,
);
const finalized = await wallet.finalizeRecipe(recipe);
await wallet.submitTransaction(finalized);
},
);
}

// Wait for DUST balance to become non-zero (only if DUST is going to this wallet)
if (!isExternalAddress) {
await withStatus('Waiting for DUST to generate (this may take 1–2 minutes)', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.balance(new Date()) > 0n),
),
),
);
}
};

DUST 잔액 확인

DUST는 시간이 지남에 따라 지속적으로 쌓이므로 현재 잔액을 수시로 확인하면 유용합니다. 최신 동기화 상태를 가져와 현재 시점의 dust 잔액을 반환합니다.

// DUST generates continuously over time. This function checks the current balance.

const checkDustBalance = async (wallet: WalletFacade): Promise<bigint> => {
const state = await Rx.firstValueFrom(
wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);
return state.dust.balance(new Date());
};

메인 스크립트

마지막으로, main 함수가 모든 것을 하나로 엮습니다. 전체 흐름을 순서대로 실행합니다:

  • 시드 생성 또는 로드
  • 시드에서 키 파생
  • 지갑 빌드 및 동기화
  • 파생된 주소 표시
  • 필요한 경우 자금 수신 대기
  • 사용자에게 DUST 주소 입력 요청
  • DUST 생성을 위한 NIGHT 등록
  • DUST 잔액 재확인 또는 프로그램 종료 루프 진입
const main = async () => {
// 1. Get or create a wallet seed
console.log('');
const seed = await getOrCreateSeed();

// 2. Derive HD keys
const keys = deriveKeys(seed);

// 3. Build the wallet
const { wallet, unshieldedKeystore } = await withStatus('Building wallet', () => buildWallet(keys));

// 4. Display all wallet addresses immediately (derived from keys, available before sync)
const initialState = await Rx.firstValueFrom(wallet.state());
const networkId = getNetworkId();

const coinPubKey = ShieldedCoinPublicKey.fromHexString(initialState.shielded.coinPublicKey.toHexString());
const encPubKey = ShieldedEncryptionPublicKey.fromHexString(initialState.shielded.encryptionPublicKey.toHexString());
const shieldedAddress = MidnightBech32m.encode(networkId, new ShieldedAddress(coinPubKey, encPubKey)).toString();
const unshieldedAddress = unshieldedKeystore.getBech32Address();
const dustAddress = MidnightBech32m.encode(networkId, initialState.dust.address).toString();

console.log('');
console.log(' Wallet Addresses:');
console.log(` Shielded: ${shieldedAddress}`);
console.log(` Unshielded: ${unshieldedAddress} ← send tNight here`);
console.log(` Dust: ${dustAddress}`);
console.log('');
console.log(` Faucet: ${CONFIG.faucetUrl}`);
console.log('');

// 5. Sync with the network
await withStatus('Syncing wallet with network', () => waitForSync(wallet));

const state = await Rx.firstValueFrom(wallet.state());
const nightBalance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;
const dustBalance = state.dust.balance(new Date());

// 6. Handle three scenarios based on current balances
let usedExternalAddress = false;

if (nightBalance > 0n && dustBalance > 0n) {
// Scenario 3: Has both NIGHT and DUST — show balances and go to monitor
console.log(` tNight Balance: ${formatNight(nightBalance)}`);
console.log(` DUST Balance: ${formatDust(dustBalance)}\n`);
console.log(' Your wallet is already generating DUST. No action needed.');
} else if (nightBalance > 0n && dustBalance === 0n) {
// Scenario 2: Has NIGHT but no DUST — register for DUST generation
console.log(` tNight Balance: ${formatNight(nightBalance)}`);
console.log(' DUST Balance: 0\n');
console.log(' You have tNight but no DUST yet. Let\'s register for DUST generation.\n');

const targetDustAddress = await promptForDustAddress(dustAddress);
usedExternalAddress = targetDustAddress !== dustAddress;
await registerForDustGeneration(wallet, unshieldedKeystore, targetDustAddress, usedExternalAddress);
} else {
// Scenario 1: No NIGHT (and therefore no DUST) — wait for faucet funds
console.log(' Waiting for tNight — copy the unshielded address above and paste it into the faucet.');
console.log(' ⚠️ Make sure you copy only the address with no extra spaces.\n');
const balance = await withStatus('Waiting for incoming tNight', () => waitForFunds(wallet));
console.log(` tNight Balance: ${formatNight(balance)}\n`);

const targetDustAddress = await promptForDustAddress(dustAddress);
usedExternalAddress = targetDustAddress !== dustAddress;
await registerForDustGeneration(wallet, unshieldedKeystore, targetDustAddress, usedExternalAddress);
}

// 7. Show DUST balance and enter monitor loop
if (usedExternalAddress) {
console.log('');
console.log(' DUST is being generated to the external address you designated.');
console.log(' Because DUST is a shielded token, only the wallet holding that dust');
console.log(' secret key can see the balance. Check the receiving wallet to verify');
console.log(' DUST is accruing.');
} else {
const currentDust = await checkDustBalance(wallet);
console.log('');
console.log(` DUST Balance: ${formatDust(currentDust)}`);
console.log(' DUST generates continuously over time.');
console.log(' Press Enter to re-check, or type "q" to quit.\n');

// 8. Let the user check the balance repeatedly
let running = true;
while (running) {
const answer = await prompt(' > ');
if (answer.toLowerCase() === 'q' || answer.toLowerCase() === 'quit' || answer.toLowerCase() === 'exit') {
running = false;
} else {
const updated = await checkDustBalance(wallet);
const time = new Date().toLocaleTimeString();
console.log(` [${time}] DUST Balance: ${formatDust(updated)}\n`);
}
}
}

console.log('');
console.log(' To restore this wallet later, run the script again and choose "r".');
console.log('');

await wallet.stop();
process.exit(0);
};

// Run
main().catch((err) => {
console.error('\n ❌ Error:', err.message || err);
process.exit(1);
});

전체 스크립트

각 섹션을 개별적으로 붙여넣는 대신 전체 스크립트를 한 번에 복사하려면, 아래 블록을 펼쳐서 전체 src/index.ts를 확인하세요.

전체 src/index.ts 펼치기
// This file is part of midnight-dust-generator.
// Copyright (C) 2025 Midnight Foundation
// SPDX-License-Identifier: Apache-2.0
// Licensed under the Apache License, Version 2.0 (the "License");
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
* Midnight DUST Generation Tutorial
*
* This script:
* 1. Creates a new wallet or restores an existing one
* 2. Displays all wallet addresses (shielded, unshielded, dust)
* 3. Waits for you to send tNight from the faucet
* 4. Registers your NIGHT tokens for DUST generation
* 5. Monitors your DUST balance as it accrues
*/

// ─── Imports ───────────────────────────────────────────────────────────────────

import { WebSocket } from 'ws';
(globalThis as any).WebSocket = WebSocket;

import { Buffer } from 'buffer';
import * as readline from 'readline';
import * as Rx from 'rxjs';

import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { toHex } from '@midnight-ntwrk/midnight-js-utils';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import {
createKeystore,
InMemoryTransactionHistoryStorage,
PublicKey,
UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import type { UnshieldedKeystore } from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { setNetworkId, getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import {
DustAddress,
MidnightBech32m,
ShieldedAddress,
ShieldedCoinPublicKey,
ShieldedEncryptionPublicKey,
} from '@midnight-ntwrk/wallet-sdk-address-format';


// ─── Configuration ─────────────────────────────────────────────────────────────

const CONFIG = {
networkId: 'preprod' as const,
indexerHttpUrl: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWsUrl: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
node: 'https://rpc.preprod.midnight.network',
proofServer: 'http://localhost:6300',
faucetUrl: 'https://faucet.preprod.midnight.network',
};


// ─── Helpers: Format raw balances to human-readable ────────────────────────────
// NIGHT is divided into 10^6 STAR. DUST is divided into 10^15 SPECK.

const formatNight = (raw: bigint): string => {
const whole = raw / 1_000_000n;
const fraction = (raw % 1_000_000n).toString().padStart(6, '0');
return `${whole.toLocaleString()}.${fraction}`;
};

const formatDust = (raw: bigint): string => {
const whole = raw / 1_000_000_000_000_000n;
const fraction = (raw % 1_000_000_000_000_000n).toString().padStart(15, '0');
return `${whole.toLocaleString()}.${fraction}`;
};


// ─── Helper: Clock Spinner ─────────────────────────────────────────────────────

const withStatus = async <T>(message: string, fn: () => Promise<T>): Promise<T> => {
const clocks = ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛'];
let i = 0;
const interval = setInterval(() => {
process.stdout.write(`\r ${clocks[i++ % clocks.length]} ${message}`);
}, 150);
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;
}
};


// ─── Helper: Prompt for user input ─────────────────────────────────────────────

const prompt = (question: string): Promise<string> => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim());
});
});
};


// ─── Prompt for a valid Dust address ────────────────────────────────────────────

const isValidDustAddress = (addr: string): boolean => {
if (!addr.startsWith('mn_dust_')) return false;
try {
MidnightBech32m.parse(addr).decode(DustAddress, getNetworkId());
return true;
} catch {
return false;
}
};

const promptForDustAddress = async (ownDustAddress: string): Promise<string> => {
while (true) {
const input = await prompt(` Paste your Dust address to designate (Enter for this wallet's): `);
const target = input || ownDustAddress;

if (isValidDustAddress(target)) {
if (target !== ownDustAddress) {
console.log(`\n Using external dust address: ${target}\n`);
} else {
console.log('');
}
return target;
}

console.log(' ❌ Invalid dust address. Dust addresses start with "mn_dust_" followed by the network.');
console.log(' Make sure you\'re not pasting a shielded or unshielded address.\n');
}
};


// ─── Create or Restore a Wallet Seed ───────────────────────────────────────────

const getOrCreateSeed = async (): Promise<string> => {
const choice = await prompt(' Create a new wallet or restore an existing one? (n/r): ');
if (choice.toLowerCase() === 'r') {
const seed = await prompt(' Enter your seed: ');
if (!seed || seed.length < 32) {
throw new Error('Invalid seed. The seed should be a 64-character hex string.');
}
console.log(' Restoring wallet from seed...\n');
return seed;
}
const seed = toHex(Buffer.from(generateRandomSeed()));
console.log('\n Created new wallet.');
console.log(' ⚠️ Save this seed — it is the ONLY way to restore your wallet:\n');
console.log(` ${seed}\n`);
return seed;
};


// ─── Derive Keys from the Seed ─────────────────────────────────────────────────

const deriveKeys = (seed: string) => {
const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
if (hdWallet.type !== 'seedOk') {
throw new Error('Failed to initialize HDWallet from seed. Is the seed a valid hex string?');
}
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 from seed.');
}
hdWallet.hdWallet.clear();
return derivationResult.keys;
};


// ─── Build the Wallet ──────────────────────────────────────────────────────────

const buildWallet = async (keys: ReturnType<typeof deriveKeys>) => {
setNetworkId(CONFIG.networkId);
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], getNetworkId());
const shieldedConfig = {
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: CONFIG.indexerHttpUrl,
indexerWsUrl: CONFIG.indexerWsUrl,
},
provingServerUrl: new URL(CONFIG.proofServer),
relayURL: new URL(CONFIG.node.replace(/^http/, 'ws')),
};
const unshieldedConfig = {
networkId: getNetworkId(),
indexerClientConnection: {
indexerHttpUrl: CONFIG.indexerHttpUrl,
indexerWsUrl: CONFIG.indexerWsUrl,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
};
const dustConfig = {
...shieldedConfig,
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
};
const wallet = await WalletFacade.init({
configuration: { ...shieldedConfig, ...unshieldedConfig, ...dustConfig },
shielded: (cfg) => ShieldedWallet(cfg).startWithSecretKeys(shieldedSecretKeys),
unshielded: (cfg) => UnshieldedWallet(cfg).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
dust: (cfg) =>
DustWallet(cfg).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
});
await wallet.start(shieldedSecretKeys, dustSecretKey);
return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore };
};


// ─── Wait for the Wallet to Sync ───────────────────────────────────────────────

const waitForSync = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((state) => state.isSynced),
),
);


// ─── Wait for Incoming Funds ───────────────────────────────────────────────────

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


// ─── Register NIGHT Tokens for DUST Generation ─────────────────────────────────

const registerForDustGeneration = async (
wallet: WalletFacade,
unshieldedKeystore: UnshieldedKeystore,
targetDustAddress: string,
isExternalAddress: boolean = false,
): Promise<void> => {
const state = await Rx.firstValueFrom(
wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);
if (state.dust.availableCoins.length > 0) {
const dustBalance = state.dust.balance(new Date());
console.log(` DUST already available: ${formatDust(dustBalance)}\n`);
return;
}
const unregisteredCoins = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);
if (unregisteredCoins.length === 0) {
console.log(' All NIGHT already registered. Waiting for DUST to generate...');
} else {
const dustReceiver = MidnightBech32m.parse(targetDustAddress).decode(DustAddress, getNetworkId());
await withStatus(
`Registering NIGHT for dust generation → ${targetDustAddress}`,
async () => {
const recipe = await wallet.registerNightUtxosForDustGeneration(
unregisteredCoins,
unshieldedKeystore.getPublicKey(),
(payload) => unshieldedKeystore.signData(payload),
dustReceiver,
);
const finalized = await wallet.finalizeRecipe(recipe);
await wallet.submitTransaction(finalized);
},
);
}
if (!isExternalAddress) {
await withStatus('Waiting for DUST to generate (this may take 1–2 minutes)', () =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s) => s.isSynced),
Rx.filter((s) => s.dust.balance(new Date()) > 0n),
),
),
);
}
};


// ─── Check DUST Balance ────────────────────────────────────────────────────────

const checkDustBalance = async (wallet: WalletFacade): Promise<bigint> => {
const state = await Rx.firstValueFrom(
wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);
return state.dust.balance(new Date());
};


// ─── Main ──────────────────────────────────────────────────────────────────────

const main = async () => {
console.log('');
const seed = await getOrCreateSeed();
const keys = deriveKeys(seed);
const { wallet, unshieldedKeystore } = await withStatus('Building wallet', () => buildWallet(keys));

const initialState = await Rx.firstValueFrom(wallet.state());
const networkId = getNetworkId();
const coinPubKey = ShieldedCoinPublicKey.fromHexString(initialState.shielded.coinPublicKey.toHexString());
const encPubKey = ShieldedEncryptionPublicKey.fromHexString(initialState.shielded.encryptionPublicKey.toHexString());
const shieldedAddress = MidnightBech32m.encode(networkId, new ShieldedAddress(coinPubKey, encPubKey)).toString();
const unshieldedAddress = unshieldedKeystore.getBech32Address();
const dustAddress = MidnightBech32m.encode(networkId, initialState.dust.address).toString();

console.log('');
console.log(' Wallet Addresses:');
console.log(` Shielded: ${shieldedAddress}`);
console.log(` Unshielded: ${unshieldedAddress} ← send tNight here`);
console.log(` Dust: ${dustAddress}`);
console.log('');
console.log(` Faucet: ${CONFIG.faucetUrl}`);
console.log('');

await withStatus('Syncing wallet with network', () => waitForSync(wallet));

const state = await Rx.firstValueFrom(wallet.state());
const nightBalance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;
const dustBalance = state.dust.balance(new Date());

let usedExternalAddress = false;

if (nightBalance > 0n && dustBalance > 0n) {
console.log(` tNight Balance: ${formatNight(nightBalance)}`);
console.log(` DUST Balance: ${formatDust(dustBalance)}\n`);
console.log(' Your wallet is already generating DUST. No action needed.');
} else if (nightBalance > 0n && dustBalance === 0n) {
console.log(` tNight Balance: ${formatNight(nightBalance)}`);
console.log(' DUST Balance: 0\n');
console.log(' You have tNight but no DUST yet. Let\'s register for DUST generation.\n');
const targetDustAddress = await promptForDustAddress(dustAddress);
usedExternalAddress = targetDustAddress !== dustAddress;
await registerForDustGeneration(wallet, unshieldedKeystore, targetDustAddress, usedExternalAddress);
} else {
console.log(' Waiting for tNight — copy the unshielded address above and paste it into the faucet.');
console.log(' ⚠️ Make sure you copy only the address with no extra spaces.\n');
const balance = await withStatus('Waiting for incoming tNight', () => waitForFunds(wallet));
console.log(` tNight Balance: ${formatNight(balance)}\n`);
const targetDustAddress = await promptForDustAddress(dustAddress);
usedExternalAddress = targetDustAddress !== dustAddress;
await registerForDustGeneration(wallet, unshieldedKeystore, targetDustAddress, usedExternalAddress);
}

if (usedExternalAddress) {
console.log('');
console.log(' DUST is being generated to the external address you designated.');
console.log(' Because DUST is a shielded token, only the wallet holding that dust');
console.log(' secret key can see the balance. Check the receiving wallet to verify');
console.log(' DUST is accruing.');
} else {
const currentDust = await checkDustBalance(wallet);
console.log('');
console.log(` DUST Balance: ${formatDust(currentDust)}`);
console.log(' DUST generates continuously over time.');
console.log(' Press Enter to re-check, or type "q" to quit.\n');
let running = true;
while (running) {
const answer = await prompt(' > ');
if (answer.toLowerCase() === 'q' || answer.toLowerCase() === 'quit' || answer.toLowerCase() === 'exit') {
running = false;
} else {
const updated = await checkDustBalance(wallet);
const time = new Date().toLocaleTimeString();
console.log(` [${time}] DUST Balance: ${formatDust(updated)}\n`);
}
}
}

console.log('');
console.log(' To restore this wallet later, run the script again and choose "r".');
console.log('');
await wallet.stop();
process.exit(0);
};

main().catch((err) => {
console.error('\n ❌ Error:', err.message || err);
process.exit(1);
});

Step 5: proof server 시작

스크립트를 준비했으니, 영지식 증명을 처리할 proof server를 시작합니다. 두 번째 터미널 창을 열고 프로젝트 폴더로 이동한 뒤 proof server를 시작하세요:

info

proof server를 시작하기 전에 Docker Desktop이 열려 있고 실행 중이어야 합니다.

cd midnight-dust-tutorial
docker compose -f proof-server.yml up

처음 실행 시 Docker가 proof server 이미지(~2 GB)를 다운로드합니다. 출력에 listening on: 0.0.0.0:6300이 나타나면 proof server가 준비된 것입니다.

이 터미널은 실행 중인 상태로 두고 첫 번째 터미널로 돌아갑니다.

Step 6: 스크립트 실행

proof server가 실행 중이고 코드가 준비되었으므로, 튜토리얼 스크립트를 실행할 수 있습니다.

npm start

새 지갑을 생성할지 기존 지갑을 복원할지 묻습니다:

Create a new wallet or restore an existing one? (n/r):

n을 입력하면 새 지갑을 생성하고, r을 입력하면 저장된 시드로 복원합니다.

새 지갑을 생성하면 시드가 표시됩니다. 안전한 곳에 저장하세요. 나중에 지갑을 복원할 수 있는 유일한 방법입니다. 그 다음 세 가지 지갑 주소가 표시됩니다:

  Created new wallet.
⚠️ Save this seed — it is the ONLY way to restore your wallet:

a1b2c3d4e5f6... (your unique 64-character hex seed)

✅ Building wallet

Wallet Addresses:
Shielded: mn_shield-addr_preprod1q...
Unshielded: mn_addr_preprod1q... ← send tNight here
Dust: mn_dust_preprod1w...

Faucet: https://faucet.preprod.midnight.network

🕐 Syncing wallet with network

지갑이 동기화되는 동안 unshielded 주소를 복사하여 faucet에서 자금을 충전할 수 있습니다.

지갑에 자금을 충전합니다:

  1. unshielded 주소(← send tNight here 표시)를 복사합니다. 주소 앞뒤에 공백이 들어가지 않도록 주의하세요. 공백이 있으면 faucet에서 거부됩니다.
  2. 브라우저에서 faucet 링크를 엽니다: https://faucet.preprod.midnight.network
  3. faucet에 주소를 붙여넣고 tNight 토큰을 요청합니다.
  4. 기다리면 스크립트가 자동으로 입금을 감지합니다.

자금이 도착하면 dust 주소를 지정하라는 프롬프트가 나타납니다. Enter를 눌러 이 지갑 자체의 dust 주소를 사용하거나, 다른 지갑의 dust 주소(mn_dust_로 시작)를 붙여넣어 해당 지갑으로 DUST를 생성할 수 있습니다:

  tNight Balance: 1,000.000000

Paste your Dust address to designate (Enter for this wallet's):

지정 후 스크립트가 NIGHT 토큰을 등록하고 DUST가 쌓이기 시작할 때까지 기다립니다:

  ✅ Registering NIGHT for dust generation → mn_dust_preprod1w...
✅ Waiting for DUST to generate (this may take 1–2 minutes)

DUST Balance: 0.000405083000000
DUST generates continuously over time.
Press Enter to re-check, or type "q" to quit.

Enter를 누르면 언제든 업데이트된 DUST 잔액을 확인할 수 있습니다. DUST는 지속적으로 쌓이므로 확인할 때마다 숫자가 커집니다.

외부 dust 주소를 지정한 경우, 스크립트가 등록을 확인하고 종료됩니다. DUST는 shielded 토큰이므로 해당 dust secret key를 보유한 지갑만 잔액을 확인할 수 있습니다.

최종 프로젝트 구조

위의 모든 단계를 완료하면 프로젝트 디렉토리는 다음과 같아야 합니다:

midnight-dust-tutorial/
├── node_modules/ ← created by npm install
├── src/
│ └── index.ts ← main script (Step 4)
├── package.json ← dependencies and start script (Step 2)
└── proof-server.yml ← Docker config for proof server (Step 3)

문제 해결

npm start 실행 시 "Cannot find module" 오류: npm install을 먼저 실행했는지 확인하세요. 여전히 오류가 발생하면 node_modulespackage-lock.json을 삭제한 뒤 npm install을 다시 실행하세요.

포트 6300 연결 거부: proof server가 실행되지 않고 있습니다. 다른 터미널에서 docker compose -f proof-server.yml up이 실행 중이고 listening on: 0.0.0.0:6300이 표시되는지 확인하세요.

Proof server 시작 실패 또는 멈춤: Docker Desktop이 먼저 열려 있고 실행 중인지 확인하세요.

Faucet에서 주소가 유효하지 않다고 표시: 주소 문자만 복사하고 앞뒤에 공백이 없는지 확인하세요. 주소는 mn_addr_preprod1로 시작하며 공백이 포함되지 않아야 합니다.

Faucet 사용 후 지갑 잔액이 0으로 표시: 30~60초 정도 기다려 주세요. 지갑이 주기적으로 네트워크를 폴링합니다. 몇 분이 지나도 0이면 faucet에 올바른 주소를 붙여넣었는지 다시 확인하세요.

등록 후 DUST 잔액이 계속 0: 초기 DUST 생성에 1~2분이 걸릴 수 있습니다. 더 오래 걸리면 proof server가 http://localhost:6300에서 실행 중이고 접근 가능한지 확인하세요.

유효하지 않은 dust 주소 오류: Dust 주소는 mn_dust_로 시작하고 네트워크가 뒤따릅니다(예: mn_dust_preprod1...). shielded(mn_shield-addr_...) 또는 unshielded(mn_addr_...) 주소를 붙여넣지 않았는지 확인하세요.

다음 단계

DUST 생성이 활성화되면 지갑이 트랜잭션 수수료를 지불할 준비가 된 것입니다. Midnight에 스마트 컨트랙트 배포하기를 탐색하거나, 자체 애플리케이션의 지갑 설정 흐름에 DUST 생성을 통합할 수 있습니다. Awesome DApps 리포지토리Midnight 문서에서 더 많은 정보를 확인하세요.