For the complete documentation index, see llms.txt
CLI and end-to-end testing
스마트 컨트랙트는 컴파일됐고, attestation API는 신용 데이터에 서명할 수 있으며, proof server는 영지식(ZK) 증명을 생성할 수 있습니다. 이제 이 셋과 모두 대화할 방법이 필요합니다.
Part 1에서는 Compact smart contract와 witness 레이어를 만들었고, Part 2에서는 attestation API를 만들고 proof server를 설정했습니다. 이 마지막 파트에서는 모든 것을 하나로 묶는 CLI를 구축한 뒤, 로컬 Midnight 네트워크에서 전체 흐름을 end-to-end로 실행합니다. entry point 스크립트만 다르게 실행하면 같은 CLI로 Midnight의 Preprod 네트워크를 대상으로 삼을 수도 있습니다.
다음 두 가지를 만듭니다:
-
CLI: 지갑 생성, 스마트 컨트랙트 배포, attestation provider 등록, 대출 요청, on-chain 상태 조회를 처리하는 대화형 명령줄 도구.
-
End-to-end 테스트: 전체 시스템을 직접 돌려 보는 과정입니다. 충전된 지갑을 만들고, 스마트 컨트랙트를 배포하고, provider를 등록하고, 비공개 신용 데이터로 대출을 요청한 뒤, on-chain에는 결과만 드러나는지 확인합니다.
Prerequisites
- Part 1과 Part 2를 완료해야 합니다
- 컴파일된 스마트 컨트랙트 패키지가
contract/dist/에 있어야 합니다 - attestation API를 시작할 준비가 되어 있어야 합니다
- Docker proof server가
6300포트에서 실행 중이어야 합니다 - Node.js v22 이상 — 노드 20에서는 지갑이 시작되는 듯 보이다가 동기화 중
TypeError: state.pendingOutputs.values.map is not a function오류로 죽습니다(Part 1의 주의 사항을 참고하세요)
Build the CLI
CLI는 지갑 작업, 스마트 컨트랙트 상호작용, attestation 요청을 조율하는 TypeScript 애플리케이션입니다. 각각 역할이 분명한 9개 소스 파일로 구성됩니다.
Configuration
CLI는 대상 네트워크가 Midnight의 원격 Preprod 인프라든 로컬 개발 네트워크든, 해당 네트워크의 indexer, 블록체인 노드, proof server 위치를 알아야 합니다. 또한 Part 1에서 컴파일한 circuit 산출물 경로도 필요합니다.
이 설정 파일은 그 모든 엔드포인트와 경로를 한곳에 모아 둡니다.
zkloan-credit-scorer-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: "zkloan-credit-scorer-private-state",
zkConfigPath: path.resolve(
currentDir,
"..",
"..",
"contract",
"src",
"managed",
"zkloan-credit-scorer",
),
};
export interface Config {
readonly logDir: string;
readonly indexer: string;
readonly indexerWS: string;
readonly node: string;
readonly proofServer: string;
readonly networkId: 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 = "wss://rpc.preprod.midnight.network";
proofServer = "http://127.0.0.1:6300";
networkId = "preprod";
}
export class StandaloneConfig implements Config {
logDir = path.resolve(
currentDir,
"..",
"logs",
"standalone",
`${new Date().toISOString()}.log`,
);
indexer = "http://127.0.0.1:8088/api/v4/graphql";
indexerWS = "ws://127.0.0.1:8088/api/v4/graphql/ws";
node = "http://127.0.0.1:9944";
proofServer = "http://127.0.0.1:6300";
networkId = "undeployed";
constructor() {
setNetworkId("undeployed");
}
}
이 파일은 두 네트워크를 모두 다룹니다. PreprodConfig는 indexer와 노드를 Midnight의 원격 인프라로 향하게 하고, StandaloneConfig는 로컬 스택으로 향하게 합니다. 두 경우 모두 proof server는 로컬 6300 포트에서 실행됩니다. Preprod라면 Part 2에서 띄운 Docker 컨테이너이고, 아니면 Midnight Local Dev에 포함된 컨테이너입니다.
주요 항목:
-
zkConfigPath는 Part 1에서 Compact 컴파일러가 생성한 circuit 산출물(proving key, verifying key, ZKIR 파일)을 가리킵니다. -
privateStateStoreName은 사용자의 private state(신용 데이터와 attestation 서명)가 CLI 세션 사이에 로컬로 보존되는 LevelDB store 이름입니다. -
indexer는 두 가지 연결을 제공합니다. 쿼리용 HTTP와 ledger 상태 변경을 실시간으로 구독하는 WebSocket입니다.
-
StandaloneConfig는 Midnight Local Dev에 연결합니다. 이는 Midnight node, indexer, proof server를 로컬에서 실행하는 독립 실행형 Docker 기반 개발 환경입니다.undeployed네트워크 ID를 사용하며 모든 서비스가localhost에서 동작합니다. 이 설정의 constructor는 곧바로setNetworkId('undeployed')를 호출하므로, config가 인스턴스화되는 즉시 주소 파생이 올바른 네트워크 ID를 사용합니다.
Type definitions
Midnight JS SDK는 타입이 매우 촘촘합니다. 스마트 컨트랙트와 상호작용할 때마다 circuit, private state, provider 번들에 대한 구체적인 타입 매개변수가 필요합니다. 이 타입들을 파일마다 반복하는 대신, 이 모듈에서 한 번만 정의하고 export해 CLI 전반에서 사용합니다.
zkloan-credit-scorer-cli/src/common-types.ts를 생성하세요:
import {
ZKLoanCreditScorer,
type ZKLoanCreditScorerPrivateState,
} from 'zkloan-credit-scorer-contract';
import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
import type {
DeployedContract,
FoundContract,
} from '@midnight-ntwrk/midnight-js-contracts';
export type ZKLoanCreditScorerCircuits =
| 'requestLoan'
| 'changePin'
| 'blacklistUser'
| 'removeBlacklistUser'
| 'rotateAdmin'
| 'respondToLoan'
| 'registerProvider'
| 'removeProvider';
export const ZKLoanCreditScorerPrivateStateId =
'zkLoanCreditScorerPrivateState';
export type ZKLoanCreditScorerProviders = MidnightProviders<
ZKLoanCreditScorerCircuits,
typeof ZKLoanCreditScorerPrivateStateId,
ZKLoanCreditScorerPrivateState
>;
export type ZKLoanCreditScorerContract =
ZKLoanCreditScorer.Contract<ZKLoanCreditScorerPrivateState>;
export type DeployedZKLoanCreditScorerContract =
| DeployedContract<ZKLoanCreditScorerContract>
| FoundContract<ZKLoanCreditScorerContract>;
-
ZKLoanCreditScorerCircuits는 CLI가 호출할 수 있는 모든 circuit 이름의 union 타입입니다. proof provider와 ZK config provider가 각 트랜잭션에 맞는 proving key를 불러올 때 이 타입을 사용합니다. -
ZKLoanCreditScorerProviders는 Midnight JS SDK가 요구하는 6개 provider 타입을 묶습니다. 지갑, midnight(트랜잭션 제출), 증명, ZK config, public data(indexer), private state(LevelDB)입니다. -
DeployedZKLoanCreditScorerContract가 union인 이유는, 새 스마트 컨트랙트를 배포하거나(DeployedContract반환) 기존 스마트 컨트랙트에 합류할 수(FoundContract반환) 있기 때문입니다. 둘 다 동일한callTx인터페이스를 노출합니다.
Mock user profiles
CLI가 대출을 요청할 때는 attestation API에 서명을 받기 위해 신용 프로필(credit score, 월 소득, 재직 기간)이 필요합니다.
실제 서비스에서는 이 데이터가 실제 신용평가기관이나 은행 provider에서 옵니다. 이 튜토리얼에서는 Tier 1 승인부터 완전 거절까지 4개 자격 등급을 모두 아우르는 mock 프로필 묶음을 사용합니다. 덕분에 실제 금융 데이터 없이도 각 결과를 테스트할 수 있습니다.
zkloan-credit-scorer-cli/src/state.utils.ts를 생성하세요:
import { type ZKLoanCreditScorerPrivateState } from "zkloan-credit-scorer-contract";
export const userProfiles = [
{
applicantId: "user-001",
creditScore: 720,
monthlyIncome: 2500,
monthsAsCustomer: 24,
},
{
applicantId: "user-002",
creditScore: 650,
monthlyIncome: 1800,
monthsAsCustomer: 11,
},
{
applicantId: "user-003",
creditScore: 580,
monthlyIncome: 2200,
monthsAsCustomer: 36,
},
{
applicantId: "user-004",
creditScore: 710,
monthlyIncome: 1900,
monthsAsCustomer: 5,
},
{
applicantId: "user-005",
creditScore: 520,
monthlyIncome: 3000,
monthsAsCustomer: 48,
},
{
applicantId: "user-006",
creditScore: 810,
monthlyIncome: 4500,
monthsAsCustomer: 60,
},
{
applicantId: "user-007",
creditScore: 639,
monthlyIncome: 2100,
monthsAsCustomer: 18,
},
{
applicantId: "user-008",
creditScore: 680,
monthlyIncome: 1450,
monthsAsCustomer: 30,
},
{
applicantId: "user-009",
creditScore: 750,
monthlyIncome: 2100,
monthsAsCustomer: 23,
},
{
applicantId: "user-010",
creditScore: 579,
monthlyIncome: 1900,
monthsAsCustomer: 12,
},
];
import { webcrypto } from 'node:crypto';
// 새로운 32바이트 user secret을 생성합니다. 이 값 하나가 contract 안의 모든 신원을
// 결정합니다. PIN에 묶인 사용자별 신원(`deriveUserPublicKey(secret, pin)`)과
// admin 역할(`deriveAdminPublicKey(secret)`)이 그것입니다. 이것이 유일하게 신뢰되는
// 호출자 신원입니다 — `ownPublicKey()`는 prover가 제공하는 값이라 사용하지 않습니다.
function generateUserSecret(): Uint8Array {
const bytes = new Uint8Array(32);
webcrypto.getRandomValues(bytes);
return bytes;
}
export function getUserProfile(
index?: number,
userSecretKey: Uint8Array = generateUserSecret(),
): ZKLoanCreditScorerPrivateState {
let profile;
if (index !== undefined) {
if (index < 0 || index >= userProfiles.length) {
throw new Error(
`Index ${index} is out of bounds. Must be between 0 and ${userProfiles.length - 1}.`,
);
}
profile = userProfiles[index];
} else {
const randomIndex = Math.floor(Math.random() * userProfiles.length);
profile = userProfiles[randomIndex];
}
return {
creditScore: BigInt(profile.creditScore),
monthlyIncome: BigInt(profile.monthlyIncome),
monthsAsCustomer: BigInt(profile.monthsAsCustomer),
attestationSignature: {
announcement: { x: 0n, y: 0n },
response: 0n,
},
attestationProviderId: 0n,
userSecretKey,
};
}
userSecretKey 필드는 호출자의 사용자별 pubkey(PIN 포함)와 admin pubkey(PIN 없음) 양쪽의 32바이트 preimage입니다. 배포한 사람이 이 secret을 가짐으로써 admin이 됩니다. admin 역할을 넘기려면 다음 admin이 자기 secret을 직접 생성하고, 거기서 파생한 admin public key만 공유하면 됩니다.
이 프로필들은 Part 1의 스마트 컨트랙트에 정의된 자격 등급과 대응됩니다:
| Profile | Credit score | Income | Tenure | Expected tier |
|---|---|---|---|---|
| user-001 | 720 | $2,500 | 24 months | Tier 1 ($10,000) |
| user-002 | 650 | $1,800 | 11 months | Tier 2 ($7,000) |
| user-003 | 580 | $2,200 | 36 months | Tier 3 ($3,000) |
| user-005 | 520 | $3,000 | 48 months | Rejected |
| user-010 | 579 | $1,900 | 12 months | Rejected (Tier 3에서 1점 모자람) |
getUserProfile 함수는 attestation 필드를 0으로 초기화한 ZKLoanCreditScorerPrivateState를 반환합니다. 이 필드들은 나중에 CLI가 대출 요청을 제출하기 전에 API에서 실제 attestation을 받아 오면서 채워집니다.
Logger utility
Midnight 트랜잭션은 proof server가 ZK proof를 생성하는 동안 finalize까지 1분 넘게 걸리기도 합니다. 로깅이 없으면 그 대기 시간 동안 CLI가 무엇을 하는지 알 수 없습니다.
이 유틸리티는 콘솔(색상 포맷 적용)과 타임스탬프가 찍힌 파일 양쪽에 기록하는 logger를 만듭니다. 덕분에 진행 상황을 실시간으로 확인하고, 나중에 문제를 디버깅할 수 있습니다.
zkloan-credit-scorer-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=debug를 설정하세요.
Core API implementation
이 핵심 모듈은 CLI를 Midnight SDK에 연결합니다. 네 가지 일을 맡습니다. BIP-39 mnemonic이나 hex seed로 지갑을 만들고 충전하기, 설정된 네트워크에서 스마트 컨트랙트를 배포하거나 합류하기, Part 2에서 만든 API로부터 Schnorr attestation 받아 오기, 그리고 각 smart contract circuit 호출(대출 요청, PIN 변경, admin 작업)을 대화형 CLI가 부를 수 있는 함수로 감싸기입니다. 분량이 크므로 논리 단위로 나누고 블록마다 설명을 덧붙입니다.
zkloan-credit-scorer-cli/src/api.ts를 생성하고 다음 섹션들을 순서대로 추가하세요.
Imports and global setup
이 블록은 Midnight SDK 모듈, 지갑 라이브러리, 프로젝트 전용 타입을 import합니다. 또한 SDK의 GraphQL 구독이 Node.js에서 동작하도록 전역 WebSocket 생성자를 패치합니다:
import 'dotenv/config';
import {
type ContractAddress,
transientHash,
CompactTypeBytes,
} from '@midnight-ntwrk/midnight-js-protocol/compact-runtime';
import {
ZKLoanCreditScorer,
type ZKLoanCreditScorerPrivateState,
witnesses,
} from 'zkloan-credit-scorer-contract';
import * as ledger from '@midnight-ntwrk/midnight-js-protocol/ledger';
import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
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 { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import {
type FinalizedTxData,
type MidnightProvider,
type WalletProvider,
type UnboundTransaction,
} from '@midnight-ntwrk/midnight-js-types';
import { assertIsContractAddress } from '@midnight-ntwrk/midnight-js-utils';
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
// Wallet SDK imports are consolidated under the @midnight-ntwrk/wallet-sdk
// barrel (introduced in wallet-sdk 1.1.0, alongside Midnight JS 4.1.x).
import {
HDWallet,
Roles,
WalletFacade,
ShieldedWallet,
DustWallet,
UnshieldedWallet,
createKeystore,
InMemoryTransactionHistoryStorage,
WalletEntrySchema,
PublicKey as UnshieldedPublicKey,
type UnshieldedKeystore,
} from '@midnight-ntwrk/wallet-sdk';
import * as bip39 from '@scure/bip39';
import { wordlist as english } from '@scure/bip39/wordlists/english.js';
import { webcrypto } from 'crypto';
import { type Logger } from 'pino';
import * as Rx from 'rxjs';
import { WebSocket } from 'ws';
import { Buffer } from 'buffer';
import {
type ZKLoanCreditScorerContract,
type ZKLoanCreditScorerPrivateStateId,
type ZKLoanCreditScorerProviders,
type DeployedZKLoanCreditScorerContract,
type ZKLoanCreditScorerCircuits,
} from './common-types';
import { type Config, contractConfig } from './config';
import { getUserProfile } from './state.utils';
let logger: Logger;
// @ts-expect-error: Needed to enable WebSocket usage through Apollo
globalThis.WebSocket = WebSocket;
WebSocket 할당이 필요한 이유는 Midnight SDK의 GraphQL 구독(indexer가 사용)이 전역 WebSocket 생성자를 기대하기 때문입니다. Node.js는 이를 기본으로 제공하지 않습니다.
- Protocol 패키지는 ACL 패키지를 거칩니다. 직접 작성하는
ledger,compact-runtime,compact-js,onchain-runtime,platform-jsimport는 버전에 구애받지 않는@midnight-ntwrk/midnight-js-protocol패키지의 subpath import로 바뀝니다(예:@midnight-ntwrk/midnight-js-protocol/compact-runtime).@midnight-ntwrk/ledger-v8과@midnight-ntwrk/compact-js를 직접 import하는 코드는 이제 애플리케이션 코드에 두지 않습니다. - Wallet SDK는 barrel 하나로 통합됩니다.
wallet-sdk-facade,wallet-sdk-hd,wallet-sdk-shielded,wallet-sdk-dust-wallet,wallet-sdk-unshielded-wallet은@midnight-ntwrk/wallet-sdk에서 재export됩니다(1.1.0에서 도입되었고, npm의latest태그가 아직1.1.0으로 해석되기 때문에 이 저장소는1.2.0을 정확히 고정합니다). 다섯 군데가 아니라 한 군데에서 import하세요.
두 변경 모두 tsconfig.json의 moduleResolution이 bundler, node16, nodenext 중 하나여야 합니다. 레거시 node 리졸버는 protocol 패키지의 exports subpath 맵을 읽지 못합니다. 이 섹션 뒷부분의 tsconfig 블록을 참고하세요.
Wallet context and ledger state
WalletContext 인터페이스와 getZKLoanLedgerState 함수는 CLI가 지갑 상태와 상호작용하고 on-chain contract 데이터를 읽는 방식을 정의합니다:
export interface WalletContext {
wallet: WalletFacade;
shieldedSecretKeys: ledger.ZswapSecretKeys;
dustSecretKey: ledger.DustSecretKey;
unshieldedKeystore: UnshieldedKeystore;
}
export const getZKLoanLedgerState = async (
providers: ZKLoanCreditScorerProviders,
contractAddress: ContractAddress,
): Promise<ZKLoanCreditScorer.Ledger | null> => {
assertIsContractAddress(contractAddress);
logger.info('Checking contract ledger state...');
const state = await providers.publicDataProvider
.queryContractState(contractAddress)
.then((contractState) =>
contractState != null
? ZKLoanCreditScorer.ledger(contractState.data)
: null,
);
return state;
};
WalletContext는 네 가지 지갑 구성 요소를 묶습니다:
wallet— shielded, unshielded, dust 지갑을 조율하는 facade.shieldedSecretKeys— ZK 트랜잭션에 사용.dustSecretKey— 트랜잭션 수수료 지불에 사용.unshieldedKeystore— DUST 등록 같은 transparent 작업에 사용.
getZKLoanLedgerState는 indexer에 스마트 컨트랙트의 현재 on-chain 상태를 질의합니다. ZKLoanCreditScorer.ledger() 함수는 원시 컨트랙트 상태를 contractAdmin, loans, providers, blacklist 같은 필드를 가진 타입이 지정된 Ledger 객체로 역직렬화합니다.
Compiled smart contract and deploy/join
이 섹션은 컴파일된 컨트랙트 산출물을 witness 구현과 결합한 뒤, 새 컨트랙트를 배포하거나 기존 컨트랙트에 합류하는 함수를 제공합니다:
export const zkLoanCompiledContract =
CompiledContract.make<ZKLoanCreditScorerContract>(
'ZKLoanCreditScorer',
ZKLoanCreditScorer.Contract,
).pipe(
CompiledContract.withWitnesses(witnesses),
CompiledContract.withCompiledFileAssets(contractConfig.zkConfigPath),
);
export const joinContract = async (
providers: ZKLoanCreditScorerProviders,
contractAddress: string,
): Promise<DeployedZKLoanCreditScorerContract> => {
const contract = await findDeployedContract(providers as any, {
contractAddress,
compiledContract: zkLoanCompiledContract,
privateStateId: 'zkLoanCreditScorerPrivateState',
initialPrivateState: getUserProfile(),
});
logger.info(
`Joined contract at address: ${contract.deployTxData.public.contractAddress}`,
);
return contract as any;
};
export const deploy = async (
providers: ZKLoanCreditScorerProviders,
privateState: ZKLoanCreditScorerPrivateState,
): Promise<DeployedZKLoanCreditScorerContract> => {
logger.info('Deploying ZKLoan Credit Scorer contract...');
const contract = await deployContract(providers as any, {
compiledContract: zkLoanCompiledContract,
privateStateId: 'zkLoanCreditScorerPrivateState',
initialPrivateState: privateState,
});
logger.info(
`Deployed contract at address: ${contract.deployTxData.public.contractAddress}`,
);
return contract as any;
};
위 코드는 세 가지 주요 export를 정의합니다:
-
zkLoanCompiledContract은 세 가지를 결합합니다. 생성된 TypeScript smart contract 인터페이스, Part 1의 witness 구현, 그리고 컴파일된 circuit 자산(proving key와 ZKIR 파일)입니다. -
deploy는 설정된 네트워크에 스마트 컨트랙트의 새 인스턴스를 만듭니다. ZK proof가 포함된 배포 트랜잭션을 일으키는데, 이 증명은 proof server가 생성하며 약 1분이 걸립니다. -
joinContract은 주소로 이미 배포된 스마트 컨트랙트에 연결합니다. 두 번째 사용자(또는 새 세션의 같은 사용자)가 다른 사람이 배포한 스마트 컨트랙트와 상호작용하는 방법입니다.
args는 조건부 타입입니다deployContract의 args 옵션은 이제 컨트랙트 생성자 시그니처에 따라 조건부로 타입이 정해집니다. 이 컨트랙트의 생성자는 인자를 받지 않으므로 args는 아예 생략해야 합니다. args: []를 넘기면 더 이상 타입 검사를 통과하지 못합니다. 컨트랙트 생성자에 매개변수가 있다면 args는 필수가 되고 그 매개변수에 맞는 타입이 됩니다.
Attestation and loan request logic
이 섹션은 핵심 대출 흐름을 처리합니다. 사용자의 public key 해시 계산, API에서 Schnorr attestation 받아 오기, private state에 저장, 그리고 대출 요청 트랜잭션 제출입니다:
const bytes32Type = new CompactTypeBytes(32);
const { pureCircuits } = ZKLoanCreditScorer;
// 로컬 user secret과 PIN으로 사용자별 public key를 off-chain에서 파생합니다.
// contract이 on-chain에서 쓰는 것과 동일한 pure circuit을 사용합니다. user secret은
// private state에서 옵니다 — `ownPublicKey()`는 prover가 주장하는 값이라 쓰지 않으므로,
// 이것이 호출자의 유일하게 신뢰되는 신원입니다.
export const deriveUserPublicKey = (userSecretKey: Uint8Array, pin: bigint): Uint8Array => {
return pureCircuits.deriveUserPublicKey(userSecretKey, pin);
};
// attestation 메시지에 쓸 userPubKeyHash를 계산합니다. contract이 `requestLoan`
// 안에서 `transientHash(deriveUserPublicKey(secret, pin))`로 계산하는 값과 일치합니다.
export const computeUserPubKeyHash = (
userSecretKey: Uint8Array,
pin: bigint,
): bigint => {
const pubKey = deriveUserPublicKey(userSecretKey, pin);
return transientHash(bytes32Type, pubKey);
};
export const fetchAttestation = async (
attestationApiUrl: string,
creditScore: number,
monthlyIncome: number,
monthsAsCustomer: number,
userPubKeyHash: bigint,
): Promise<{
announcement: { x: bigint; y: bigint };
response: bigint;
}> => {
const res = await fetch(`${attestationApiUrl}/attest`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
creditScore,
monthlyIncome,
monthsAsCustomer,
userPubKeyHash: userPubKeyHash.toString(),
}),
});
if (!res.ok) {
throw new Error(
`Attestation API error: ${res.status} ${await res.text()}`,
);
}
const data = (await res.json()) as {
signature: {
announcement: { x: string; y: string };
response: string;
};
};
return {
announcement: {
x: BigInt(data.signature.announcement.x),
y: BigInt(data.signature.announcement.y),
},
response: BigInt(data.signature.response),
};
};
export const requestLoan = async (
contract: DeployedZKLoanCreditScorerContract,
providers: ZKLoanCreditScorerProviders,
amountRequested: bigint,
secretPin: bigint,
attestationApiUrl: string,
): Promise<FinalizedTxData> => {
// 1. 현재 private state 가져오기 (`userSecretKey`가 들어 있어야 함)
const currentState = await providers.privateStateProvider.get(
'zkLoanCreditScorerPrivateState',
);
if (!currentState) {
throw new Error('No private state found');
}
// 2. user secret으로 user pub key 해시 계산 (circuit과 동일)
const userPubKeyHash = computeUserPubKeyHash(
currentState.userSecretKey,
secretPin,
);
logger.info(`Computed userPubKeyHash for attestation`);
// 3. API에서 attestation 서명 받아 오기
logger.info(`Fetching attestation from ${attestationApiUrl}...`);
const signature = await fetchAttestation(
attestationApiUrl,
Number(currentState.creditScore),
Number(currentState.monthlyIncome),
Number(currentState.monthsAsCustomer),
userPubKeyHash,
);
// 4. provider 정보 가져오기
const providerRes = await fetch(`${attestationApiUrl}/provider-info`);
const providerInfo = (await providerRes.json()) as {
providerId: number;
};
// 5. attestation 데이터로 private state 갱신
const updatedState: ZKLoanCreditScorerPrivateState = {
...currentState,
attestationSignature: signature,
attestationProviderId: BigInt(providerInfo.providerId),
};
await providers.privateStateProvider.set(
'zkLoanCreditScorerPrivateState',
updatedState,
);
logger.info(
`Private state updated with attestation (provider ${providerInfo.providerId})`,
);
// 6. circuit 호출
logger.info(
`Requesting loan for $${amountRequested} (USD) with PIN...`,
);
const finalizedTxData = await contract.callTx.requestLoan(
amountRequested,
secretPin,
);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
이것이 CLI 관점에서 본 핵심 대출 흐름입니다. requestLoan은 순서대로 네 가지를 합니다:
-
로컬 private state에서
userSecretKey를 읽고,computeUserPubKeyHash(userSecretKey, pin)로 사용자의 public key 해시를 파생합니다. 이 함수는 더 이상zwapKeyBytes를 받지 않습니다 — 신원은 지갑의 coin public key가 아니라 witness secret에서 옵니다. 지갑은 여전히 표준 Zswap balancing 흐름으로 트랜잭션 비용을 내지만, 사용자를 식별하지는 않습니다. -
신용 데이터를 attestation API에 보내고, API는 Schnorr 서명을 반환합니다.
-
서명과 provider ID를 로컬 private state에 저장합니다.
-
스마트 컨트랙트의
requestLoan을 호출합니다. proof server가 private state(attestation과 user secret 포함)를 읽어 ZK proof를 생성합니다.
computeUserPubKeyHash 함수는 스마트 컨트랙트가 on-chain에서 쓰는 것과 동일한 deriveUserPublicKey pure circuit을 사용합니다. 생성된 pureCircuits.deriveUserPublicKey를 TypeScript에서 호출하면 off-chain 해시와 in-circuit 해시가 일치하도록 보장되므로, attestation 메시지와 증명 안의 신원이 맞아떨어집니다.
Circuit call wrappers and state display
아래 각 wrapper 함수는 단일 smart contract circuit에 대응합니다. 모두 같은 패턴을 따릅니다. 동작을 로그로 남기고, contract.callTx.<circuitName>()을 호출한 뒤, finalize된 트랜잭션 데이터를 반환합니다. displayContractState 함수는 indexer에 현재 on-chain ledger 상태를 질의합니다.
export const changePin = async (
contract: DeployedZKLoanCreditScorerContract,
oldPin: bigint,
newPin: bigint,
): Promise<FinalizedTxData> => {
logger.info('Changing PIN...');
const finalizedTxData = await contract.callTx.changePin(oldPin, newPin);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
// 사용자를 파생된 `UserPublicKey`로 blacklist에 올립니다(contract이
// `assert(!blacklist.member(deriveUserPublicKey(...)))` 안에서 확인하는 값).
// admin은 이 32바이트 값을 별도 경로로 얻어야 합니다 — 보통 이미 상호작용한 사용자의
// on-chain `loans` map 키를 읽거나, 대상에게 파생 pubkey를 직접 공유해 달라고 요청합니다.
// `ownPublicKey()`는 contract이 신뢰하지 않으므로 admin은 지갑 주소로 blacklist에 올릴 수
// 없습니다.
export const blacklistUser = async (
contract: DeployedZKLoanCreditScorerContract,
userPublicKey: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Blacklisting user public key...');
const finalizedTxData = await contract.callTx.blacklistUser(userPublicKey);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
export const removeBlacklistUser = async (
contract: DeployedZKLoanCreditScorerContract,
userPublicKey: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Removing user public key from blacklist...');
const finalizedTxData = await contract.callTx.removeBlacklistUser(userPublicKey);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
// 새 admin의 파생 public key를 ledger에 기록해 admin 역할을 넘깁니다. 새 admin은
// secret을 로컬에서 생성하고 `deriveAdminPublicKey(userSecret)`을 off-chain에서
// 계산합니다. 그 결과인 32바이트 public key만 전송됩니다. private key는 절대
// 전송되지 않습니다.
export const rotateAdmin = async (
contract: DeployedZKLoanCreditScorerContract,
newAdminPublicKey: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Rotating admin role to new derived public key...');
const finalizedTxData = await contract.callTx.rotateAdmin(newAdminPublicKey);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
// 주어진 user secret으로 AdminPublicKey를 계산합니다. 새 admin이 되려는 사람이 현재
// admin에게 건넬 32바이트 public key를 얻기 위해 실행합니다. 동일한 `userSecretKey`가
// 사용자별 신원(PIN에 묶임)과 admin 역할(PIN 없음) 양쪽에 쓰이지만, contract 내부의
// 서로 다른 도메인 구분자가 둘을 논리적으로 분리합니다.
export const deriveAdminPublicKey = (userSecretKey: Uint8Array): Uint8Array => {
return pureCircuits.deriveAdminPublicKey(userSecretKey);
};
export const registerProvider = async (
contract: DeployedZKLoanCreditScorerContract,
providerId: bigint,
providerPk: { x: bigint; y: bigint },
): Promise<FinalizedTxData> => {
logger.info(`Registering attestation provider ${providerId}...`);
const finalizedTxData = await contract.callTx.registerProvider(
providerId,
providerPk,
);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
export const removeProvider = async (
contract: DeployedZKLoanCreditScorerContract,
providerId: bigint,
): Promise<FinalizedTxData> => {
logger.info(`Removing attestation provider ${providerId}...`);
const finalizedTxData =
await contract.callTx.removeProvider(providerId);
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
export const displayContractState = async (
providers: ZKLoanCreditScorerProviders,
contract: DeployedZKLoanCreditScorerContract,
): Promise<{
ledgerState: ZKLoanCreditScorer.Ledger | null;
contractAddress: string;
}> => {
const contractAddress =
contract.deployTxData.public.contractAddress;
const ledgerState = await getZKLoanLedgerState(
providers,
contractAddress,
);
if (ledgerState === null) {
logger.info(
`There is no ZKLoan contract deployed at ${contractAddress}.`,
);
} else {
logger.info(`Contract address: ${contractAddress}`);
logger.info(
`Admin public key: ${Buffer.from(ledgerState.contractAdmin).toString('hex')}`,
);
logger.info(`Blacklist size: ${ledgerState.blacklist.size()}`);
}
return { contractAddress, ledgerState };
};
각 circuit 호출 wrapper는 같은 패턴을 따릅니다:
- 동작을 로그로 남깁니다.
contract.callTx.<circuitName>()을 호출합니다.- 트랜잭션 ID와 block height를 로그로 남깁니다.
- finalize된 트랜잭션 데이터를 반환합니다.
증명 생성, 트랜잭션 balancing, 제출은 SDK가 뒤에서 처리합니다.
Wallet and provider infrastructure
아래 코드 블록은 createWalletAndMidnightProvider를 정의합니다. 지갑이 indexer와 동기화될 때까지 기다린 뒤, SDK가 트랜잭션을 balance하고 제출하는 데 쓰는 지갑 및 Midnight provider 결합본을 반환합니다. 더 넓은 범위의 지갑 설정(자금 폴링, tDUST 생성을 위한 tNIGHT UTXO 등록)은 이어지는 섹션에서 다룹니다.
export const createWalletAndMidnightProvider = async (
walletContext: WalletContext,
): Promise<WalletProvider & MidnightProvider> => {
await Rx.firstValueFrom(
walletContext.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);
return {
getCoinPublicKey(): ledger.CoinPublicKey {
return walletContext.shieldedSecretKeys.coinPublicKey;
},
getEncryptionPublicKey(): ledger.EncPublicKey {
return walletContext.shieldedSecretKeys.encryptionPublicKey;
},
async balanceTx(
tx: UnboundTransaction,
ttl?: Date,
): Promise<ledger.FinalizedTransaction> {
const txTtl = ttl ?? new Date(Date.now() + 30 * 60 * 1000);
const recipe =
await walletContext.wallet.balanceUnboundTransaction(
tx,
{
shieldedSecretKeys: walletContext.shieldedSecretKeys,
dustSecretKey: walletContext.dustSecretKey,
},
{ ttl: txTtl },
);
const finalizedTx =
await walletContext.wallet.finalizeRecipe(recipe);
return finalizedTx;
},
async submitTx(
tx: ledger.FinalizedTransaction,
): Promise<ledger.TransactionId> {
return await walletContext.wallet.submitTransaction(tx);
},
};
};
export const waitForSync = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.tap((state) => {
logger.info(`Waiting for wallet sync. Synced: ${state.isSynced}`);
}),
Rx.filter((state) => state.isSynced),
),
);
export const waitForFunds = (wallet: WalletFacade) =>
Rx.firstValueFrom(
wallet.state().pipe(
Rx.throttleTime(10_000),
Rx.tap((state) => {
const unshielded =
state.unshielded?.balances[ledger.nativeToken().raw] ?? 0n;
const shielded =
state.shielded?.balances[ledger.nativeToken().raw] ?? 0n;
logger.info(
`Waiting for NIGHT funds. Synced: ${state.isSynced}, Unshielded: ${unshielded}, Shielded: ${shielded}`,
);
}),
Rx.filter((state) => state.isSynced),
Rx.map(
(s) =>
(s.unshielded?.balances[ledger.nativeToken().raw] ?? 0n) +
(s.shielded?.balances[ledger.nativeToken().raw] ?? 0n),
),
Rx.filter((balance) => balance > 0n),
),
);
export const displayWalletBalances = async (
wallet: WalletFacade,
): Promise<{
unshielded: bigint;
shielded: bigint;
total: bigint;
dust: bigint;
}> => {
const state = await Rx.firstValueFrom(wallet.state());
const unshielded =
state.unshielded?.balances[ledger.nativeToken().raw] ?? 0n;
const shielded =
state.shielded?.balances[ledger.nativeToken().raw] ?? 0n;
const total = unshielded + shielded;
const dust = state.dust?.balance(new Date()) ?? 0n;
logger.info(`Unshielded NIGHT balance: ${unshielded}`);
logger.info(`Shielded NIGHT balance: ${shielded}`);
logger.info(`Total NIGHT balance: ${total}`);
logger.info(`DUST balance (for fees): ${dust}`);
return { unshielded, shielded, total, dust };
};
export const registerNightForDust = async (
walletContext: WalletContext,
): Promise<boolean> => {
const state = await Rx.firstValueFrom(
walletContext.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
);
const unregisteredNightUtxos =
state.unshielded?.availableCoins.filter(
(coin) => coin.meta.registeredForDustGeneration === false,
) ?? [];
if (unregisteredNightUtxos.length === 0) {
logger.info(
'No unshielded Night UTXOs available for dust registration, or all are already registered',
);
const dustBalance = state.dust?.balance(new Date()) ?? 0n;
logger.info(`Current dust balance: ${dustBalance}`);
return dustBalance > 0n;
}
logger.info(
`Found ${unregisteredNightUtxos.length} unshielded Night UTXOs not registered for dust generation`,
);
logger.info('Registering Night UTXOs for dust generation...');
try {
const recipe =
await walletContext.wallet.registerNightUtxosForDustGeneration(
unregisteredNightUtxos,
walletContext.unshieldedKeystore.getPublicKey(),
(payload) => walletContext.unshieldedKeystore.signData(payload),
);
logger.info('Finalizing dust registration transaction...');
const finalizedTx =
await walletContext.wallet.finalizeRecipe(recipe);
logger.info('Submitting dust registration transaction...');
const txId =
await walletContext.wallet.submitTransaction(finalizedTx);
logger.info(`Dust registration submitted with tx id: ${txId}`);
logger.info('Waiting for dust to be generated...');
await Rx.firstValueFrom(
walletContext.wallet.state().pipe(
Rx.throttleTime(5_000),
Rx.tap((s) => {
const dustBalance =
s.dust?.balance(new Date()) ?? 0n;
logger.info(`Dust balance: ${dustBalance}`);
}),
Rx.filter(
(s) => (s.dust?.balance(new Date()) ?? 0n) > 0n,
),
),
);
logger.info('Dust registration complete!');
return true;
} catch (e) {
logger.error(`Failed to register Night UTXOs for dust: ${e}`);
return false;
}
};
지갑 인프라는 세 가지를 다룹니다:
-
동기화:
waitForSync는 지갑 상태를 5초마다 폴링하며 indexer와 동기화될 때까지 기다립니다. -
충전:
waitForFunds는 10초마다 폴링하며 지갑 잔액(shielded + unshielded)이 0이 아닐 때까지 기다립니다. -
Dust 등록:
registerNightForDust는 unshielded tNIGHT UTXO를 tDUST 생성용으로 등록합니다. Midnight에서 NIGHT은 사용자가 직접 쓰는 토큰이고, DUST는 등록된 NIGHT UTXO에서 생성되는 수수료 자원입니다(테스트넷 버전은 tNIGHT, tDUST). Midnight 네트워크에서 트랜잭션 수수료를 내려면 DUST 토큰이 필요합니다. DUST가 없으면 어떤 트랜잭션도 제출할 수 없습니다.
Wallet initialization
아래 코드 블록은 단일 seed에서 세 가지 키 역할(Zswap, NightExternal, Dust)을 파생하고 — 이 seed는 BIP-39 mnemonic에서 만들거나 hex 문자열로 직접 넘길 수 있습니다 — 해당 지갑들을 초기화한 뒤, 동기화와 충전이 끝나길 기다려 바로 쓸 수 있는 wallet context를 반환합니다.
// BIP-39 mnemonic을 검증하고 seed 버퍼로 변환합니다
export const mnemonicToSeed = async (
mnemonic: string,
): Promise<Buffer> => {
const words = mnemonic.trim().split(/\s+/);
if (!bip39.validateMnemonic(words.join(' '), english)) {
throw new Error('Invalid mnemonic phrase');
}
const seed = await bip39.mnemonicToSeed(words.join(' '));
return Buffer.from(seed);
};
// seed에서 지갑 키를 파생하고 세 가지 지갑 타입을 모두 초기화합니다
export const initWalletWithSeed = async (
seed: Buffer,
config: Config,
): Promise<WalletContext> => {
const hdWallet = HDWallet.fromSeed(seed);
if (hdWallet.type !== 'seedOk') {
throw new Error('Failed to initialize HDWallet');
}
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();
// 각 지갑 역할별 secret key 생성
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(
derivationResult.keys[Roles.Zswap],
);
const dustSecretKey = ledger.DustSecretKey.fromSeed(
derivationResult.keys[Roles.Dust],
);
const unshieldedKeystore = createKeystore(
derivationResult.keys[Roles.NightExternal],
config.networkId as any,
);
const relayURL = new URL(config.node.replace(/^http/, 'ws'));
const shieldedConfig = {
networkId: config.networkId,
indexerClientConnection: {
indexerHttpUrl: config.indexer,
indexerWsUrl: config.indexerWS,
},
provingServerUrl: new URL(config.proofServer),
relayURL,
// As of wallet-sdk 1.x, every wallet variant's default configuration
// (shielded / unshielded / dust) requires its own transaction-history
// storage. `InMemoryTransactionHistoryStorage` now takes a schema —
// the barrel re-exports `WalletEntrySchema` for exactly this use.
txHistoryStorage: new InMemoryTransactionHistoryStorage(WalletEntrySchema),
};
const unshieldedConfig = {
networkId: config.networkId,
indexerClientConnection: {
indexerHttpUrl: config.indexer,
indexerWsUrl: config.indexerWS,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(WalletEntrySchema),
};
const dustConfig = {
networkId: config.networkId,
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
indexerClientConnection: {
indexerHttpUrl: config.indexer,
indexerWsUrl: config.indexerWS,
},
provingServerUrl: new URL(config.proofServer),
relayURL,
txHistoryStorage: new InMemoryTransactionHistoryStorage(WalletEntrySchema),
};
// 지갑별 config를 하나의 설정으로 합쳐 facade가 세 지갑을 모두 초기화하는 데 쓰게 한 뒤,
// 통합 팩토리로 만듭니다. v3에서 `WalletFacade`의 생성자는 private이므로
// `WalletFacade.init({ configuration, shielded, unshielded, dust })`를 사용합니다.
const unifiedConfig = { ...shieldedConfig, ...unshieldedConfig, ...dustConfig };
const facade = await WalletFacade.init({
configuration: unifiedConfig,
shielded: () =>
ShieldedWallet(shieldedConfig).startWithSecretKeys(shieldedSecretKeys),
unshielded: () =>
UnshieldedWallet(unshieldedConfig).startWithPublicKey(
UnshieldedPublicKey.fromKeyStore(unshieldedKeystore),
),
dust: () =>
DustWallet(dustConfig).startWithSecretKey(
dustSecretKey,
ledger.LedgerParameters.initialParameters().dust,
),
});
await facade.start(shieldedSecretKeys, dustSecretKey);
return {
wallet: facade,
shieldedSecretKeys,
dustSecretKey,
unshieldedKeystore,
};
};
// 상위 레벨: mnemonic으로 지갑 생성, 동기화, 자금 대기, dust 등록
export const buildWalletAndWaitForFunds = async (
config: Config,
mnemonic: string,
): Promise<WalletContext> => {
logger.info('Building wallet from mnemonic...');
const seed = await mnemonicToSeed(mnemonic);
const walletContext = await initWalletWithSeed(seed, config);
logger.info(
`Your wallet address: ${walletContext.unshieldedKeystore.getBech32Address().asString()}`,
);
logger.info('Waiting for wallet to sync...');
await waitForSync(walletContext.wallet);
const { total } = await displayWalletBalances(walletContext.wallet);
if (total === 0n) {
logger.info('Waiting to receive tokens...');
await waitForFunds(walletContext.wallet);
await displayWalletBalances(walletContext.wallet);
}
await registerNightForDust(walletContext);
return walletContext;
};
// 새 BIP-39 mnemonic을 생성하고 그것으로 지갑을 만듭니다
export const buildFreshWallet = async (
config: Config,
): Promise<WalletContext> => {
const mnemonic = bip39.generateMnemonic(english, 256);
logger.info(`Generated new wallet mnemonic: ${mnemonic}`);
return await buildWalletAndWaitForFunds(config, mnemonic);
};
// Build wallet from a hex seed (for the genesis wallet on the local network)
export const buildWalletFromHexSeed = async (
config: Config,
hexSeed: string,
): Promise<WalletContext> => {
logger.info('Building wallet from hex seed...');
const seed = Buffer.from(hexSeed, 'hex');
const walletContext = await initWalletWithSeed(seed, config);
logger.info(
`Your wallet address: ${walletContext.unshieldedKeystore.getBech32Address().asString()}`,
);
logger.info('Waiting for wallet to sync...');
await waitForSync(walletContext.wallet);
const { total } = await displayWalletBalances(walletContext.wallet);
if (total === 0n) {
logger.info('Waiting to receive tokens...');
await waitForFunds(walletContext.wallet);
await displayWalletBalances(walletContext.wallet);
}
await registerNightForDust(walletContext);
return walletContext;
};
지갑 초기화는 단일 BIP-39 mnemonic에서 세 가지 키 역할을 파생합니다:
-
Zswap: shielded(비공개) 트랜잭션과 ZK proof 생성에 사용
-
NightExternal: Preprod faucet에서 tNIGHT 받기 같은 unshielded(transparent) 작업에 사용
-
DUST: 트랜잭션 수수료를 내는 DUST 토큰 생성에 사용
buildWalletAndWaitForFunds는 Preprod를 대상으로 할 때 CLI가 호출하는 상위 레벨 함수입니다. mnemonic을 seed로 변환하고, 세 지갑 타입을 모두 초기화하고, 동기화를 기다린 뒤, 잔액을 확인하고, NIGHT UTXO를 DUST 생성용으로 등록합니다. buildWalletFromHexSeed는 같은 단계를 따르지만 mnemonic 대신 원시 hex seed에서 시작합니다. 로컬 standalone 네트워크에서는 CLI가 이 함수를 잘 알려진 genesis seed와 함께 사용하며, 그 seed의 자금은 이미 genesis 블록에 존재합니다.
Provider configuration and utilities
configureProviders는 6개 SDK provider(지갑, Midnight, 증명, ZK config, public data, private state)를 하나의 번들로 조립합니다. setLogger와 closeWallet 유틸리티는 모듈 레벨 logger를 관리하고 종료 시 지갑 리소스를 정리합니다.
export const configureProviders = async (
walletContext: WalletContext,
config: Config,
): Promise<ZKLoanCreditScorerProviders> => {
setNetworkId(config.networkId);
const walletAndMidnightProvider =
await createWalletAndMidnightProvider(walletContext);
const storagePassword = process.env.MIDNIGHT_STORAGE_PASSWORD;
if (!storagePassword) {
throw new Error(
'MIDNIGHT_STORAGE_PASSWORD is not set. Set it in zkloan-credit-scorer-cli/.env (see .env.example). ' +
'The level-private-state-provider requires it to encrypt private state on disk.',
);
}
const zkConfigProvider =
new NodeZkConfigProvider<ZKLoanCreditScorerCircuits>(
contractConfig.zkConfigPath,
);
return {
privateStateProvider:
levelPrivateStateProvider<
typeof ZKLoanCreditScorerPrivateStateId
>({
privateStateStoreName: contractConfig.privateStateStoreName,
privateStoragePasswordProvider: () => storagePassword,
accountId: walletContext.unshieldedKeystore.getBech32Address().asString(),
}),
publicDataProvider: indexerPublicDataProvider(
config.indexer,
config.indexerWS,
),
zkConfigProvider,
proofProvider: httpClientProofProvider(
config.proofServer,
zkConfigProvider,
),
walletProvider: walletAndMidnightProvider,
midnightProvider: walletAndMidnightProvider,
};
};
export function setLogger(_logger: Logger) {
logger = _logger;
}
export const closeWallet = async (
walletContext: WalletContext,
): Promise<void> => {
try {
await walletContext.wallet.stop();
} catch (e) {
logger.error(`Error closing wallet: ${e}`);
}
};
configureProviders는 SDK가 요구하는 6개 provider를 모두 조립합니다. private state provider는 민감한 데이터(신용 프로필과 attestation 서명)를 디스크의 암호화된 LevelDB 데이터베이스에 저장합니다. proof provider는 6300 포트의 로컬 Docker proof server에 proving 요청을 보냅니다.
levelPrivateStateProvider(Midnight JS 4.x)는 강력한 MIDNIGHT_STORAGE_PASSWORD 없이는 실행을 거부합니다. 더 이상 기본 fallback이 없습니다. 비밀번호는 다음 조건을 충족해야 합니다:
- 최소 16자 이상이어야 합니다.
- 대문자, 소문자, 숫자, 특수문자 가운데 최소 세 그룹의 문자를 포함해야 합니다.
aaaa처럼 동일한 문자가 4개 이상 연속되면 안 됩니다.abcd나1234처럼 문자 코드가 4개 이상 연속되면 안 됩니다.
비밀번호를 잃으면 디스크에 암호화된 private state에 접근할 수 없습니다 — 복구 수단은 없습니다.
또한 provider는 accountId(지갑의 Bech32 주소)별로 범위가 지정됩니다. 범위 지정 없이 같은 store에 여러 지갑을 돌리면 작업이 실패합니다.
Interactive CLI
API 모듈이 모든 SDK 상호작용을 처리하므로, CLI 모듈은 사용자 대면 레이어를 맡습니다. 입력을 받고, 선택을 올바른 API 함수로 연결하고, 오류가 나도 멈추지 않게 처리합니다. 대화형 터미널 입력에는 노드 내장 readline/promises를 사용합니다.
zkloan-credit-scorer-cli/src/cli.ts를 생성하고 다음 섹션들을 순서대로 추가하세요.
Imports and menu prompts
아래 코드 블록은 CLI의 기반을 잡습니다. API 모듈, 공유 타입 정의, testcontainers 타입(테스트 스위트가 Docker 컨테이너를 상대로 CLI를 구동할 때 사용), 그리고 대화형 터미널 입력을 위한 Node readline을 import합니다. 그런 다음 genesis 지갑 seed와 메뉴 프롬프트 문자열 두 개를 선언합니다:
GENESIS_MINT_WALLET_SEED: 로컬 개발 노드의 genesis 블록에서 발행된 토큰을 소유한 잘 알려진 seed로, standalone 네트워크에서 미리 충전된 지갑을 만들 때만 사용합니다.DEPLOY_OR_JOIN_QUESTION: 지갑 설정 후 표시되는 첫 메뉴로, 새 컨트랙트를 배포할지 기존 컨트랙트에 합류할지 묻습니다.MAIN_LOOP_QUESTION: 컨트랙트를 배포하거나 합류한 뒤 표시되는 메인 메뉴로, 대출 및 admin 작업을 나열합니다.
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 StartedDockerComposeEnvironment,
type DockerComposeEnvironment,
} from "testcontainers";
import {
type ZKLoanCreditScorerProviders,
type DeployedZKLoanCreditScorerContract,
} from "./common-types";
import { type Config, StandaloneConfig } from "./config";
import * as api from "./api";
import type { WalletContext } from "./api";
import { getUserProfile } from "./state.utils";
import "dotenv/config";
let logger: Logger;
/**
* This seed gives access to tokens minted in the genesis block of a local development node - only
* used in standalone networks to build a wallet with initial funds.
*/
const GENESIS_MINT_WALLET_SEED =
"0000000000000000000000000000000000000000000000000000000000000001";
// 지갑 설정 후 표시되는 메뉴 프롬프트
const DEPLOY_OR_JOIN_QUESTION = `
You can do one of the following:
1. Deploy a new ZKLoan Credit Scorer contract
2. Join an existing ZKLoan Credit Scorer contract
3. Exit
Which would you like to do? `;
// contract 배포/합류 후 표시되는 메뉴 프롬프트
const MAIN_LOOP_QUESTION = `
You can do one of the following:
1. Request a loan
2. Change PIN
3. Display contract state
4. Display wallet balances
5. [Admin] Blacklist a user
6. [Admin] Remove user from blacklist
7. [Admin] Rotate admin role to new derived public key
8. [Admin] Register attestation provider
9. [Admin] Remove attestation provider
10. Exit
Which would you like to do? `;
Deploy or join helpers
이 함수들은 사용자에게 새 컨트랙트를 배포할지, 주소로 기존 컨트랙트에 연결할지 묻습니다:
// 주소로 이미 배포된 contract에 연결
const join = async (
providers: ZKLoanCreditScorerProviders,
rli: Interface,
): Promise<DeployedZKLoanCreditScorerContract> => {
const contractAddress = await rli.question(
"What is the contract address (in hex)? ",
);
return await api.joinContract(providers, contractAddress);
};
// deploy/join/exit 메뉴를 표시하고 사용자의 선택을 처리
const deployOrJoin = async (
providers: ZKLoanCreditScorerProviders,
rli: Interface,
): Promise<DeployedZKLoanCreditScorerContract | null> => {
while (true) {
const choice = await rli.question(DEPLOY_OR_JOIN_QUESTION);
switch (choice) {
case "1":
return await api.deploy(providers, getUserProfile());
case "2":
return await join(providers, rli);
case "3":
logger.info("Exiting...");
return null;
default:
logger.error(`Invalid choice: ${choice}`);
}
}
};
Loan and PIN flow functions
이 함수들은 사용자 대면 작업 두 가지를 처리합니다. 대출 요청(attestation 받아 오기 포함)과 PIN 변경(일괄 대출 마이그레이션을 일으킴)입니다:
// 금액과 PIN을 입력받고, attestation을 받아 온 뒤, 대출 요청 제출
const requestLoan = async (
contract: DeployedZKLoanCreditScorerContract,
providers: ZKLoanCreditScorerProviders,
_walletContext: WalletContext,
rli: Interface,
): Promise<void> => {
const amountStr = await rli.question(
"Enter the loan amount (USD, 1-65535 — the contract caps approvals at $10,000 / $7,000 / $3,000 per tier): ",
);
const pinStr = await rli.question("Enter your secret PIN: ");
const amount = BigInt(amountStr);
const pin = BigInt(pinStr);
const attestationApiUrl =
process.env.ATTESTATION_API_URL || "http://localhost:4000";
await api.requestLoan(
contract,
providers,
amount,
pin,
attestationApiUrl,
);
logger.info("Loan request submitted successfully!");
};
// 이전 PIN과 새 PIN을 입력받은 뒤, PIN 변경 트랜잭션 제출
const changePinFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const oldPinStr = await rli.question("Enter your old PIN: ");
const newPinStr = await rli.question("Enter your new PIN: ");
await api.changePin(contract, BigInt(oldPinStr), BigInt(newPinStr));
logger.info("PIN change submitted successfully!");
logger.info(
"Note: If you have many loans, you may need to call this multiple times to complete the migration.",
);
};
Admin flow functions
다섯 가지 admin 작업(blacklist, 해제, provider 등록/제거, rotate)은 모두 witness 파생 keypair 패턴을 사용합니다. 각 circuit의 첫 번째 assertion은 호출자가 ZK proof 안에서 32바이트 secret을 알고 있음을 증명하도록 강제합니다. 이 secret의 admin 파생값이 contractAdmin에 저장되어 있습니다. 그 secret을 가진 사람은 배포한 admin뿐입니다. 나머지 모두에게는 circuit이 제약 조건을 만족하지 못해 트랜잭션이 revert됩니다. admin 권한을 누가 갖는지 바꾸는 유일한 방법은 rotation이며, 새 admin이 자기 user secret을 로컬에서 생성하고 그 결과인 admin public key만 공유하는 방식으로 동작합니다.
blacklist 흐름은 지갑 주소가 아니라 파생된 UserPublicKey(64 hex 문자)를 받습니다. admin은 이 값을 on-chain loans map에서 읽거나(상호작용한 사용자의 키로 나타남) 대상에게서 별도 경로로 얻습니다. admin은 아직 컨트랙트를 건드리지 않은 사용자를 선제적으로 blacklist에 올릴 수 없습니다 — 위조 불가능한 호출자 신원을 위한 의도적인 트레이드오프입니다.
각 admin 함수는 필요한 입력을 받아 해당 API wrapper에 위임합니다. 이 작업들은 배포자(admin)만 실행할 수 있습니다:
const USER_PUBKEY_PROMPT_HINT =
'(64-char hex of the user\'s derived UserPublicKey — e.g. read from the on-chain `loans` map key, or shared by the target)';
const parseUserPublicKeyHex = (input: string): Uint8Array => {
const hex = input.trim().toLowerCase().replace(/^0x/, '');
if (!/^[0-9a-f]{64}$/.test(hex)) {
throw new Error('User public key must be exactly 64 hex chars (32 bytes).');
}
return Uint8Array.from(Buffer.from(hex, 'hex'));
};
const blacklistUserFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const input = await rli.question(
`Enter the user public key to blacklist ${USER_PUBKEY_PROMPT_HINT}: `,
);
await api.blacklistUser(contract, parseUserPublicKeyHex(input));
logger.info('User public key blacklisted successfully!');
};
const removeBlacklistUserFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const input = await rli.question(
`Enter the user public key to remove from blacklist ${USER_PUBKEY_PROMPT_HINT}: `,
);
await api.removeBlacklistUser(contract, parseUserPublicKeyHex(input));
logger.info('User public key removed from blacklist successfully!');
};
// 새 admin이 이미 로컬에서 파생해 둔 public key로 admin 역할을 rotate합니다. 새 admin은
// 자기 32바이트 user secret에 대해 `deriveAdminPublicKey(userSecret)`을 실행하고, 그
// 결과인 public key(64 hex 문자)를 현재 admin에게 건넵니다. private key는 교환되지 않습니다.
const rotateAdminFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const input = await rli.question(
'Enter the new admin derived public key (64 hex chars). ' +
'The new admin generates this with `deriveAdminPublicKey(userSecret)` and shares only the result: ',
);
await api.rotateAdmin(contract, parseUserPublicKeyHex(input));
logger.info('Admin role rotated successfully!');
};
// attestation provider의 public key를 on-chain에 등록
const registerProviderFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const providerIdStr = await rli.question("Enter the provider ID (number): ");
const pkXStr = await rli.question(
"Enter the provider public key X coordinate (bigint): ",
);
const pkYStr = await rli.question(
"Enter the provider public key Y coordinate (bigint): ",
);
await api.registerProvider(contract, BigInt(providerIdStr), {
x: BigInt(pkXStr),
y: BigInt(pkYStr),
});
logger.info("Attestation provider registered successfully!");
};
// ID로 attestation provider 제거
const removeProviderFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const providerIdStr = await rli.question(
"Enter the provider ID to remove (number): ",
);
await api.removeProvider(contract, BigInt(providerIdStr));
logger.info("Attestation provider removed successfully!");
};
Main loop and wallet selection
메인 루프는 10개 옵션 메뉴를 표시하고 각 선택을 해당 flow 함수로 연결합니다. wallet selection은 네트워크에 따라 달라집니다. 로컬 standalone 네트워크에서는 CLI가 메뉴를 완전히 건너뛰고 genesis seed로 미리 충전된 지갑을 만들며, Preprod에서는 메인 루프에 들어가기 전에 사용자가 지갑을 생성하거나 복원하도록 안내합니다:
// 메인 상호작용 루프 — 메뉴 선택을 flow 함수로 연결
const mainLoop = async (
providers: ZKLoanCreditScorerProviders,
walletContext: WalletContext,
rli: Interface,
): Promise<void> => {
const contract = await deployOrJoin(providers, rli);
if (contract === null) return;
while (true) {
const choice = await rli.question(MAIN_LOOP_QUESTION);
try {
switch (choice) {
case "1":
await requestLoan(contract, providers, walletContext, rli);
break;
case "2":
await changePinFlow(contract, rli);
break;
case "3":
await api.displayContractState(providers, contract);
break;
case "4":
await api.displayWalletBalances(walletContext.wallet);
break;
case "5":
await blacklistUserFlow(contract, rli);
break;
case "6":
await removeBlacklistUserFlow(contract, rli);
break;
case "7":
await rotateAdminFlow(contract, rli);
break;
case "8":
await registerProviderFlow(contract, rli);
break;
case "9":
await removeProviderFlow(contract, rli);
break;
case "10":
logger.info("Exiting...");
return;
default:
logger.error(`Invalid choice: ${choice}`);
}
} catch (e) {
if (e instanceof Error) {
logger.error(`Operation failed: ${e.message}`);
} else {
logger.error(`Operation failed: ${e}`);
}
}
}
};
// 지갑 생성/복원 메뉴
const WALLET_LOOP_QUESTION = `
You can do one of the following:
1. Build a fresh wallet
2. Build wallet from a mnemonic
3. Use mnemonic from .env file
4. Exit
Which would you like to do? `;
// Return the initialized wallet context. On the standalone (local) network
// the menu is skipped and the genesis wallet is built from its hex seed;
// otherwise the wallet options menu is presented.
const buildWallet = async (
config: Config,
rli: Interface,
): Promise<WalletContext | null> => {
if (config instanceof StandaloneConfig) {
// For standalone, use genesis wallet with hex seed
return await api.buildWalletFromHexSeed(config, GENESIS_MINT_WALLET_SEED);
}
// Check if mnemonic is available in environment
const envMnemonic = process.env.WALLET_MNEMONIC;
while (true) {
const choice = await rli.question(WALLET_LOOP_QUESTION);
switch (choice) {
case "1":
return await api.buildFreshWallet(config);
case "2": {
const mnemonic = await rli.question(
"Enter your wallet mnemonic (24 words): ",
);
return await api.buildWalletAndWaitForFunds(config, mnemonic);
}
case "3":
if (envMnemonic) {
logger.info("Using mnemonic from .env file...");
return await api.buildWalletAndWaitForFunds(config, envMnemonic);
} else {
logger.error("No WALLET_MNEMONIC found in .env file");
}
break;
case "4":
logger.info("Exiting...");
return null;
default:
logger.error(`Invalid choice: ${choice}`);
}
}
};
// Rewrite a config URL so its port matches the host port that
// testcontainers mapped for the named container
const mapContainerPort = (
env: StartedDockerComposeEnvironment,
url: string,
containerName: string,
) => {
const mappedUrl = new URL(url);
const container = env.getContainer(containerName);
mappedUrl.port = String(container.getFirstMappedPort());
return mappedUrl.toString().replace(/\/+$/, "");
};
// 진입점 — 지갑 구축, provider 설정, 메인 루프 진입
export const run = async (
config: Config,
_logger: Logger,
dockerEnv?: DockerComposeEnvironment,
): Promise<void> => {
logger = _logger;
api.setLogger(_logger);
const rli = createInterface({ input, output, terminal: true });
let env;
let walletContext: WalletContext | null = null;
if (dockerEnv !== undefined) {
env = await dockerEnv.up();
if (config instanceof StandaloneConfig) {
config.indexer = mapContainerPort(env, config.indexer, "zkloan-indexer");
config.indexerWS = mapContainerPort(env, config.indexerWS, "zkloan-indexer");
config.node = mapContainerPort(env, config.node, "zkloan-node");
config.proofServer = mapContainerPort(
env,
config.proofServer,
"zkloan-proof-server",
);
}
}
try {
walletContext = await buildWallet(config, rli);
if (walletContext !== null) {
const providers = await api.configureProviders(walletContext, config);
await mainLoop(providers, walletContext, rli);
}
} catch (e) {
if (e instanceof Error) {
logger.error(`Found error '${e.message}'`);
logger.info("Exiting...");
logger.debug(`${e.stack}`);
} else {
throw e;
}
} finally {
try {
rli.close();
rli.removeAllListeners();
} catch (e) {
logger.error(`Error closing readline interface: ${e}`);
} finally {
try {
if (walletContext !== null) {
await api.closeWallet(walletContext);
}
} catch (e) {
logger.error(`Error closing wallet: ${e}`);
} finally {
try {
if (env !== undefined) {
await env.down();
logger.info("Goodbye");
}
} catch (e) {
logger.error(`Error shutting down docker environment: ${e}`);
}
}
}
}
};
CLI에는 상호작용 레이어가 세 개 있습니다:
- Wallet selection: 로컬 standalone 네트워크에서는 자동입니다 — CLI가 이미 자금을 보유한
GENESIS_MINT_WALLET_SEED로 genesis 지갑을 만듭니다. Preprod에서는 메뉴가 나타나 새 지갑 생성, mnemonic 복원,.env파일의 mnemonic 불러오기 중에서 고르게 합니다. - Deploy or join: 새 스마트 컨트랙트를 배포하거나, 주소로 기존 컨트랙트에 연결합니다.
- Main loop: 대출 요청, admin 작업, 상태 조회를 위한 10개 옵션 메뉴입니다.
각 메뉴 옵션은 입력을 받아 해당 API 함수를 호출하는 flow 함수에 대응합니다. 오류는 잡아서 로그로 남기되 CLI를 멈추지 않으므로, 작업을 다시 시도할 수 있습니다.
run의 선택적 세 번째 매개변수 dockerEnv는 테스트 스위트가 사용합니다. testcontainers DockerComposeEnvironment를 넘기면 run이 이를 직접 띄우고 정리하며, mapContainerPort로 standalone 설정의 URL을 동적으로 매핑된 컨테이너 포트로 다시 씁니다. CLI를 대화형으로 실행할 때는 이 매개변수를 생략하며, CLI는 이미 실행 중인 네트워크에 연결합니다.
Entry points
환경 변수로 분기하는 단일 진입 파일 대신, CLI는 네트워크마다 작은 진입 파일을 하나씩 제공합니다. 각 파일은 자신의 네트워크 설정을 하드코딩하고, logger를 만든 뒤, 둘 다 CLI 러너에 넘깁니다. 별도의 barrel 모듈은 테스트 스위트 같은 소비자를 위해 API와 CLI 함수를 다시 export합니다.
zkloan-credit-scorer-cli/src/index.ts를 생성하세요 — barrel입니다:
export * from './api';
export * from './cli';
zkloan-credit-scorer-cli/src/standalone.ts를 생성하세요 — 로컬 네트워크 진입점입니다:
import { createLogger } from './logger-utils.js';
import { run } from './cli.js';
import { StandaloneConfig } from './config.js';
// Connects to an already-running local Midnight network (managed by midnight-local-network).
// No docker containers are started here — the network must be running at the default ports:
// Node: http://127.0.0.1:9944
// Indexer: http://127.0.0.1:8088
// Proof Server: http://127.0.0.1:6300
const config = new StandaloneConfig();
const logger = await createLogger(config.logDir);
await run(config, logger);
zkloan-credit-scorer-cli/src/preprod-remote.ts를 생성하세요 — Preprod 진입점입니다:
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);
실행하는 진입 파일이 네트워크를 결정합니다 — 설정할 환경 변수는 없습니다. standalone.ts는 로컬 node, indexer, proof server가 기본 포트에서 이미 실행 중이길 기대하며, Docker 컨테이너를 직접 띄우지 않습니다.
Package configuration
zkloan-credit-scorer-cli/package.json을 생성하세요:
{
"name": "zkloan-credit-scorer-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"preprod-remote": "node --experimental-specifier-resolution=node --loader ts-node/esm src/preprod-remote.ts",
"standalone": "node --experimental-specifier-resolution=node --loader ts-node/esm src/standalone.ts",
"test-api": "docker compose -f standalone.yml pull && DEBUG='testcontainers' vitest run",
"test-against-preprod": "RUN_ENV_TESTS=true TEST_ENV=preprod TEST_WALLET_SEED=1dec0dd58fbe4d3206ef960aebff95a77e09dffbd19f3e9439d23fe6de4fcdd1 vitest run",
"build": "rm -rf dist && tsc --project tsconfig.build.json",
"lint": "eslint src",
"typecheck": "tsc -p tsconfig.json --noEmit",
"start-preprod-remote": "npm run build && npm run preprod-remote"
},
"dependencies": {
"zkloan-credit-scorer-contract": "*"
}
}
standalone과 preprod-remote는 두 진입점을 ts-node로 직접 실행합니다 — 네트워크마다 하나씩입니다. test-api는 (testcontainers로 standalone.yml compose 파일을 사용해) Docker 컨테이너를 상대로 자동화된 end-to-end 스위트를 구동하고, test-against-preprod는 같은 스위트를 라이브 Preprod 네트워크를 상대로 실행합니다.
zkloan-credit-scorer-cli/tsconfig.json을 생성하세요:
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@contract/*": ["../../contract/src/*"]
}
}
}
moduleResolution은 반드시 bundler(또는 node16/nodenext)여야 합니다. 그래야 TypeScript가 @midnight-ntwrk/midnight-js-protocol이 배포하는 exports subpath 맵을 읽을 수 있습니다. 레거시 node 리졸버는 /compact-runtime, /ledger, /compact-js를 찾지 못하고 조용히 실패합니다.
zkloan-credit-scorer-cli/tsconfig.build.json을 생성하세요:
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts"],
"compilerOptions": {}
}
CLI는 dotenv로 zkloan-credit-scorer-cli 디렉터리의 .env 파일에서 환경 변수를 불러옵니다. 저장소에 커밋할 템플릿(.env.example)과 로컬 .env를 모두 생성하세요:
cat > zkloan-credit-scorer-cli/.env.example << 'EOF'
# Password used to encrypt the contract's private state on disk via the
# midnight-js-level-private-state-provider. Required.
#
# Rules enforced by the provider (v4+):
# - At least 16 characters
# - Mix of at least 3 of: uppercase, lowercase, digits, special chars
# - No 4+ identical chars in a row
# - No 4+ sequential char codes (e.g. 'abcd', '1234')
#
# Losing this password = losing access to the local encrypted private state.
# The provider has no recovery mechanism.
MIDNIGHT_STORAGE_PASSWORD=""
# Optional BIP39 mnemonic for the wallet. If unset, the CLI uses a hex seed.
# Needed when running `npm run preprod-remote` — the wallet must hold tDUST.
# WALLET_MNEMONIC=""
EOF
cp zkloan-credit-scorer-cli/.env.example zkloan-credit-scorer-cli/.env
zkloan-credit-scorer-cli/.env를 편집해 MIDNIGHT_STORAGE_PASSWORD를 위 규칙에 맞는 값으로 설정하세요. WALLET_MNEMONIC은 주석 처리된 채로 두세요 — 로컬 standalone 흐름은 이를 사용하지 않으며, CLI가 genesis hex seed로 지갑을 자동으로 만듭니다. 이 값은 npm run preprod-remote로 Preprod를 대상으로 할 때만 중요합니다. 그때는 주석을 풀고 충전된 지갑의 24단어 mnemonic을 붙여 넣으면, CLI 지갑 메뉴의 옵션 3으로 같은 지갑을 복원할 수 있습니다.
Run the CLI
마지막 파트에서는 Midnight Local Dev를 사용해 전체 흐름을 테스트하며 모든 것을 하나로 묶습니다. Midnight Local Dev는 Midnight node, indexer, proof server를 로컬에서 실행하는 독립 실행형 Docker 환경입니다. 이 네트워크에서 ZKLoan CLI는 잘 알려진 genesis seed로 지갑을 만드는데 — 이 seed는 genesis 블록에서 발행된 토큰을 이미 소유합니다 — 그 NIGHT을 DUST용으로 자동 등록하므로, 수동 충전 없이도 지갑이 트랜잭션을 제출할 준비를 마칩니다.
터미널 창이 세 개 필요합니다.
Install dependencies
프로젝트 루트에서 다음을 실행하세요:
npm install
Compile and build the smart contract
Part 1에서 아직 하지 않았다면:
cd contract
npm run compact
npm run build
cd ..
Start Midnight local dev
첫 번째 터미널에서 Midnight Local Dev 저장소를 클론하고, 의존성을 설치한 뒤, 네트워크를 시작하세요:
git clone https://github.com/midnightntwrk/midnight-local-dev.git
cd midnight-local-dev
npm install
npm start
이 명령은 Docker 이미지를 받아 와 노드, indexer, proof server를 각각 9944, 8088, 6300 포트에서 시작합니다. 이어서 genesis master 지갑을 초기화하고 대화형 메뉴를 보여 줍니다. 이미 네트워크가 실행 중이면 마법사가 먼저 그것을 재사용할지, 새 이미지로 다시 시작할지 묻습니다 — 어느 쪽이든 선택하세요.
이 터미널은 메인 메뉴 상태로 두세요 — 열려 있는 동안 네트워크가 계속 실행됩니다. 메뉴를 닫거나 Ctrl+C를 누르면 네트워크가 종료됩니다. 이 튜토리얼에서는 마법사의 충전 옵션이 필요 없습니다. ZKLoan CLI가 genesis 지갑에서 스스로 충전합니다.
대화형 마법사 없이 컨테이너만 띄우고 싶다면, 같은 폴더에서 docker compose -f standalone.yml up -d를 대신 사용할 수 있습니다. 어느 쪽이든 ZKLoan CLI는 여전히 genesis 지갑에서 스스로 충전하고 DUST 등록을 처리합니다.
Start the attestation API
두 번째 터미널에서 프로젝트 루트로부터:
cd zkloan-credit-scorer-attestation-api
NETWORK_ID=undeployed npm run dev
다음과 같은 출력이 보일 것입니다:
Generated ephemeral provider key pair
Provider ID: 1
Provider public key:
x: 1234567890...
y: 9876543210...
Register this provider on-chain with: registerProvider(1, {x: 1234...n, y: 9876...n})
Attestation API listening on port 4000
provider public key 좌표를 복사해 두세요 — 다음 단계에서 필요합니다.
Run the CLI
세 번째 터미널에서 프로젝트 루트로부터 CLI를 시작하세요:
cd zkloan-credit-scorer-cli
npm run standalone
standalone 스크립트는 src/standalone.ts를 실행하며, 이는 첫 번째 터미널에서 시작한 로컬 네트워크를 대상으로 합니다 — 설정할 환경 변수는 없습니다.
CLI는 다섯 단계를 차례로 안내합니다:
- 지갑 구축(자동).
- 컨트랙트 배포.
- attestation provider 등록.
- 대출 요청.
- on-chain 상태 조회.
1. Build the wallet (automatic)
로컬 네트워크에서는 지갑 메뉴도, 수동 충전 단계도 없습니다. CLI가 standalone 설정을 감지하고, 로컬 네트워크의 genesis 블록에서 발행된 토큰을 소유한 잘 알려진 genesis seed로 지갑을 만듭니다:
Building wallet from hex seed...
Your wallet address: mn_addr_undeployed1h3ssm5ru…
Waiting for wallet to sync...
지갑이 동기화되면 잔액 확인이 genesis 자금을 찾아 registerNightForDust를 자동으로 호출합니다. Dust registration complete!가 보이면 지갑에 NIGHT(가치)와 DUST(수수료)가 모두 생겨 거래할 준비가 끝난 것입니다.
지갑 메뉴 — 새 지갑, mnemonic, .env mnemonic — 는 npm run preprod-remote로 Preprod를 대상으로 할 때만 나타납니다. 그때 CLI는 지갑의 unshielded 주소(mn_addr_preprod1q…)를 출력하고 자금을 기다립니다. Preprod faucet에서 그 주소로 tNIGHT을 보내고, mnemonic을 zkloan-credit-scorer-cli/.env의 WALLET_MNEMONIC에 저장하면 **옵션 3(Use mnemonic from .env file)**으로 이후 실행에서 같은 충전된 지갑을 복원할 수 있습니다.
2. Deploy the contract
이제 CLI가 deploy/join 메뉴를 보여 줍니다. **옵션 1(Deploy a new ZKLoan Credit Scorer contract)**을 고르세요. proof server가 배포 증명을 생성하는데, 약 1분이 걸립니다.
완료되면 CLI가 Deployed contract at address: …를 로그로 남기고 — 배포할 때마다 달라지는 64자 hex 문자열입니다 — 메인 10개 옵션 메뉴로 넘어갑니다.
3. Register the attestation provider
이 단계는 컨트랙트마다 한 번만 합니다. 이 단계를 거치지 않으면 circuit이 provider 등록 여부를 assert하므로 모든 대출 요청이 revert됩니다.
- **옵션 8(Register attestation provider)**을 고르세요.
- provider ID로
1을 입력하세요. - 두 번째 터미널에서 attestation API가 출력한
x와y좌표를 붙여 넣으세요.
이제 provider의 public key가 컨트랙트의 providers map에 들어갔습니다.
4. Request a loan
- **옵션 1(Request a loan)**을 고르세요.
- 대출 금액을 USD로 입력하세요 — 예를 들어
5000. 승인 한도는 신용 등급별로 $10,000 / $7,000 / $3,000이며, 등급을 넘는 금액은Proposed제안이 됩니다. - 4자리 비밀 PIN을 입력하세요 — 예를 들어
1234. PIN은Uint<16>로 제한되므로 더 긴 값은 circuit에서 overflow가 납니다.
CLI는 API에서 attestation을 받아 로컬 private state에 저장한 뒤, 대출 요청을 제출합니다. 약 1분 뒤 증명이 완료되고 트랜잭션이 finalize됩니다.
5. Inspect the on-chain state
**옵션 3(Display contract state)**을 고르세요. CLI가 컨트랙트 주소, admin public key(배포한 admin의 user secret 해시), blacklist 크기를 로그로 남깁니다. 대출 기록은 on-chain loans map에 당신의 파생된 user public key를 키로 저장되어 있으며 — 단지 이 조회 명령의 출력에 포함되지 않을 뿐입니다.
on-chain에 없는 것을 눈여겨보세요. 당신의 credit score, 소득, 재직 기간, attestation 서명, PIN은 어디에도 없습니다. 트랜잭션은 이 가운데 무엇도 드러내지 않고 자격을 증명했습니다 — 공개된 것은 결과뿐입니다.