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로 실행합니다. 환경 변수 하나만 바꾸면 같은 CLI로 Midnight의 Preprod 네트워크를 대상으로 삼을 수도 있습니다.
다음 두 가지를 만듭니다:
-
CLI: 지갑 생성, 스마트 컨트랙트 배포, attestation provider 등록, 대출 요청, on-chain 상태 조회를 처리하는 대화형 명령줄 도구.
-
End-to-end 테스트: 전체 시스템을 직접 돌려 보는 과정입니다. 지갑에 tNIGHT를 충전하고, 스마트 컨트랙트를 배포하고, 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 애플리케이션입니다. 각각 역할이 분명한 7개 파일로 구성됩니다.
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 LocalDevConfig implements Config {
logDir = path.resolve(
currentDir,
"..",
"logs",
"localdev",
`${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";
}
이 파일은 두 네트워크를 모두 다룹니다. PreprodConfig는 indexer와 노드를 Midnight의 원격 인프라로 향하게 하고, LocalDevConfig는 로컬 스택으로 향하게 합니다. 두 경우 모두 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입니다.
-
LocalDevConfig는 Midnight Local Dev에 연결합니다. 이는 Midnight node, indexer, proof server를 로컬에서 실행하는 독립 실행형 Docker 기반 개발 환경입니다.undeployed네트워크 ID를 사용하며 모든 서비스가localhost에서 동작합니다.
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으로 지갑을 만들고 충전하기, 설정된 네트워크에서 스마트 컨트랙트를 배포하거나 합류하기, 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(`Blocklist 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 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
아래 코드 블록은 단일 BIP-39 mnemonic에서 세 가지 키 역할(Zswap, NightExternal, Dust)을 파생하고, 해당 지갑들을 초기화한 뒤, 동기화와 충전이 끝나길 기다려 바로 쓸 수 있는 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);
};
지갑 초기화는 단일 BIP-39 mnemonic에서 세 가지 키 역할을 파생합니다:
-
Zswap: shielded(비공개) 트랜잭션과 ZK proof 생성에 사용
-
NightExternal: Preprod faucet에서 tNIGHT 받기 같은 unshielded(transparent) 작업에 사용
-
DUST: 트랜잭션 수수료를 내는 DUST 토큰 생성에 사용
buildWalletAndWaitForFunds는 CLI가 호출하는 상위 레벨 함수입니다. mnemonic을 seed로 변환하고, 세 지갑 타입을 모두 초기화하고, 동기화를 기다린 뒤, 잔액을 확인하고, NIGHT UTXO를 DUST 생성용으로 등록합니다.
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 모듈, 공유 타입 정의, 대화형 터미널 입력을 위한 Node readline을 import한 뒤, 메뉴 프롬프트 문자열 두 개를 선언합니다:
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 ZKLoanCreditScorerProviders,
type DeployedZKLoanCreditScorerContract,
} from "./common-types";
import { type Config } from "./config";
import * as api from "./api";
import type { WalletContext } from "./api";
import { getUserProfile } from "./state.utils";
import "dotenv/config";
let logger: Logger;
// 지갑 설정 후 표시되는 메뉴 프롬프트
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] Add user to blacklist
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, then you might 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은 메인 루프에 들어가기 전에 사용자가 지갑을 생성하거나 복원하도록 안내합니다:
// 메인 상호작용 루프 — 메뉴 선택을 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? `;
// 지갑 옵션을 표시하고 초기화된 wallet context 반환
const buildWallet = async (
config: Config,
rli: Interface,
): Promise<WalletContext | null> => {
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}`);
}
}
};
// 진입점 — 지갑 구축, provider 설정, 메인 루프 진입
export const run = async (config: Config, _logger: Logger): Promise<void> => {
logger = _logger;
api.setLogger(_logger);
const rli = createInterface({ input, output, terminal: true });
let walletContext: WalletContext | null = null;
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}`);
}
}
}
};
CLI에는 상호작용 레이어가 세 개 있습니다:
- Wallet selection: 새 지갑을 생성하거나, mnemonic으로 복원하거나,
.env파일에서 불러옵니다. - Deploy or join: 새 스마트 컨트랙트를 배포하거나, 주소로 기존 컨트랙트에 연결합니다.
- Main loop: 대출 요청, admin 작업, 상태 조회를 위한 10개 옵션 메뉴입니다.
각 메뉴 옵션은 입력을 받아 해당 API 함수를 호출하는 flow 함수에 대응합니다. 오류는 잡아서 로그로 남기되 CLI를 멈추지 않으므로, 작업을 다시 시도할 수 있습니다.
Entry point
진입점은 NETWORK 환경 변수에서 네트워크 설정을 고르고(기본값은 Preprod), logger를 만든 뒤, 둘 다 CLI 러너에 넘깁니다.
zkloan-credit-scorer-cli/src/index.ts를 생성하세요:
import { createLogger } from './logger-utils.js';
import { run } from './cli.js';
import { PreprodConfig, LocalDevConfig } from './config.js';
const network = process.env.NETWORK ?? 'preprod';
const config = network === 'localdev' ? new LocalDevConfig() : new PreprodConfig();
const logger = await createLogger(config.logDir);
logger.info(`Starting CLI with network: ${config.networkId}`);
await run(config, logger);
Package configuration
zkloan-credit-scorer-cli/package.json을 생성하세요:
{
"name": "zkloan-credit-scorer-cli",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"preprod": "node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts",
"local": "NETWORK=localdev node --experimental-specifier-resolution=node --loader ts-node/esm src/index.ts",
"build": "rm -rf dist && tsc --project tsconfig.build.json"
},
"dependencies": {
"zkloan-credit-scorer-contract": "*"
}
}
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'
# 필수. 디스크에 저장되는 private state를 암호화하는 데 쓰는 강력한 비밀번호.
# 규칙(Midnight JS 4.x의 level-private-state-provider가 강제):
# - 최소 16자
# - 대문자, 소문자, 숫자, 기호 중 최소 세 그룹의 문자 포함
# - 동일 문자 4개 이상 연속 금지 (예: aaaa)
# - 문자 코드 4개 이상 연속 금지 (예: abcd, 1234)
# 이 비밀번호를 잃으면 암호화된 state에 접근할 수 없습니다 — 복구 수단은 없습니다.
MIDNIGHT_STORAGE_PASSWORD=
# 선택. 메뉴 옵션 3으로 CLI 세션 간 같은 지갑을 복원하려면
# 24단어 BIP-39 mnemonic을 붙여 넣으세요.
WALLET_MNEMONIC=
# 선택. 설정하지 않으면 http://localhost:4000으로 기본 설정됩니다.
ATTESTATION_API_URL=http://localhost:4000
EOF
cp zkloan-credit-scorer-cli/.env.example zkloan-credit-scorer-cli/.env
zkloan-credit-scorer-cli/.env를 편집해 MIDNIGHT_STORAGE_PASSWORD를 위 규칙에 맞는 값으로 설정하세요. WALLET_MNEMONIC은 지금은 비워 두세요 — 테스트 단계에서 지갑을 생성하거나 충전한 뒤 mnemonic을 여기에 붙여 넣으면, 다음 세션에서 CLI 메뉴의 옵션 3으로 같은 지갑을 복원할 수 있습니다.
Run the CLI
마지막 파트에서는 Midnight Local Dev를 사용해 전체 흐름을 테스트하며 모든 것을 하나로 묶습니다. Midnight Local Dev는 Midnight node, indexer, proof server를 로컬에서 실행하는 독립 실행형 Docker 환경입니다. npm start 마법사는 원하는 주소로 NIGHT을 전송할 수 있고, 그러면 ZKLoan CLI가 그 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 지갑을 초기화하고 대화형 메뉴를 보여 줍니다. 이미 네트워크가 실행 중이면 마법사가 먼저 그것을 재사용할지, 새 이미지로 다시 시작할지 묻습니다 — 어느 쪽이든 선택하세요.
이 터미널은 메인 메뉴 상태로 두세요. ZKLoan CLI가 충전이 필요한 지갑 주소를 출력하면 다시 이 터미널로 돌아옵니다. 메뉴를 닫거나 Ctrl+C를 누르면 네트워크가 종료됩니다.
충전 마법사 없이 컨테이너만 띄우고 싶다면, 같은 폴더에서 docker compose -f standalone.yml up -d를 대신 사용할 수 있습니다. 대신 지갑 충전과 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 local
local 스크립트는 이미 NETWORK=localdev를 export하므로, 앞에 따로 붙일 필요가 없습니다.
CLI는 다섯 단계를 차례로 안내합니다:
- 지갑 충전.
- 컨트랙트 배포.
- attestation provider 등록.
- 대출 요청.
- on-chain 상태 조회.
1. Fund the wallet
지갑 메뉴가 나타나면 **옵션 1(Build a fresh wallet)**을 고르세요. CLI가 24단어 mnemonic을 생성하고, 지갑을 파생한 뒤, 다음을 출력합니다:
Your wallet address: mn_shield-addr_undeployed1q…
Waiting to receive tokens...
mn_shield-addr_undeployed1q… 주소를 복사하세요. 첫 번째 터미널(local-dev 메뉴)로 전환해 **[2] Fund accounts by public key (NIGHT transfer only)**를 고른 뒤 주소를 붙여 넣으세요. 마법사가 그 주소로 50,000 NIGHT을 전송합니다.
다시 CLI 터미널로 돌아오세요. 약 10~20초 안에 "Waiting to receive tokens..." 폴러가 자금을 감지하고 registerNightForDust를 자동으로 호출합니다. Dust registration complete!가 보이면 지갑에 NIGHT(가치)와 DUST(수수료)가 모두 생겨 거래할 준비가 끝난 것입니다.
생성된 mnemonic은 이 터미널 세션에만 존재합니다. 다음에 같은 충전된 지갑을 복원하려면 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: 02…를 로그로 남기고 메인 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에 없는 것을 눈여겨보세요. 당신의 credit score, 소득, 재직 기간, attestation 서명, PIN은 어디에도 없습니다. 트랜잭션은 이 가운데 무엇도 드러내지 않고 자격을 증명했습니다 — 공개된 것은 결과뿐입니다.