For the complete documentation index, see llms.txt
CLI and end-to-end testing
The smart contract is compiled. The attestation API can sign credit data. The proof server can generate zero-knowledge (ZK) proofs. Now you need a way to talk to all three.
In Part 1, you built the Compact smart contract and witness layer. In Part 2, you built the attestation API and set up the proof server. This final part builds the CLI that ties everything together, then runs the full flow end-to-end on Midnight's Preprod network.
You will build two things:
-
CLI: An interactive command-line tool that handles wallet creation, smart contract deployment, attestation provider registration, loan requests, and on-chain state inspection.
-
End-to-end test: A walkthrough that exercises the complete system: fund a wallet with tNIGHT, deploy the smart contract, register a provider, request a loan with private credit data, and verify that only the outcome appears on-chain.
Prerequisites
- Complete Part 1 and Part 2
- The compiled smart contract package should be in
contract/dist/ - The attestation API should be ready to start
- The Docker proof server should be running on port
6300
Build the CLI
The CLI is a TypeScript application that orchestrates wallet operations, smart contract interactions, and attestation requests. It is structured as six files, each with a distinct responsibility.
Configuration
The CLI needs to know where to find the Midnight Preprod indexer, the blockchain node, and the local proof server. It also needs a path to the compiled circuit artifacts from Part 1.
This configuration file centralizes all of those endpoints and paths in one place.
Create 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";
}
This is configured for Preprod and Local Dev. The indexer and node point to Midnight's remote infrastructure. The proof server points to the local Docker container you started in Part 2.
Key details:
-
zkConfigPathpoints to the compiled circuit artifacts (proving keys, verifying keys, and ZKIR files) generated by the Compact compiler in Part 1. -
privateStateStoreNameis the LevelDB store name where the user's private state (credit data and attestation signature) persists locally between CLI sessions. -
The indexer provides two connections: HTTP for queries and WebSocket for real-time subscription to ledger state changes.
-
LocalDevConfigconnects to Midnight Local Dev, a standalone Docker-based development environment that runs the Midnight node, indexer, and proof server locally. It uses theundeployednetwork ID, and all services run onlocalhost.
Type definitions
The Midnight JS SDK is heavily typed; every smart contract interaction requires specific type parameters for circuits, private state, and provider bundles. Rather than repeating these types across files, this module defines them once and exports them for use throughout the CLI.
Create 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>;
-
ZKLoanCreditScorerCircuitsis a union type of all the circuit names the CLI can call. This type is used by the proof provider and ZK config provider to load the correct proving keys for each transaction. -
ZKLoanCreditScorerProvidersbundles all six provider types required by the Midnight JS SDK: wallet, midnight (transaction submission), proof, ZK config, public data (indexer), and private state (LevelDB). -
DeployedZKLoanCreditScorerContractis a union because you can either deploy a new smart contract (returningDeployedContract) or join an existing one (returningFoundContract). Both expose the samecallTxinterface.
Mock user profiles
When the CLI requests a loan, it needs a credit profile (credit score, monthly income, and employment tenure) to send to the attestation API for signing.
In production, this data would come from a real credit bureau or banking provider. For this tutorial, the CLI uses a set of mock profiles spanning all four eligibility tiers, from Tier 1 approval to outright rejection, so you can test each outcome without real financial data.
Create 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';
// Generate a fresh 32-byte user secret. This single value drives all identity
// in the contract: per-user PIN-bound identity (`deriveUserPublicKey(secret, pin)`)
// and the admin role (`deriveAdminPublicKey(secret)`). It is the only
// authoritative caller identity — `ownPublicKey()` is prover-supplied and
// unused.
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,
};
}
The userSecretKey field is the 32-byte preimage of both the caller's per-user pubkey (with PIN) and the admin pubkey (without PIN). Whoever deploys becomes admin by virtue of holding this secret; rotating the admin role requires the next admin to generate their own secret and share only its derived admin public key.
These profiles map to the eligibility tiers defined in the smart contract from 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 (1 point short of Tier 3) |
The getUserProfile function returns a ZKLoanCreditScorerPrivateState with the attestation fields initialized to zero. These fields get populated later when the CLI fetches a real attestation from the API before submitting a loan request.
Logger utility
Midnight transactions can take over a minute to finalize while the proof server generates ZK proofs. Without logging, you have no visibility into what the CLI is doing during those waits.
This utility creates a logger that writes to both the console (with color formatting) and a timestamped file, so you can monitor progress in real time and debug issues after the fact.
Create 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 },
]),
);
};
Set DEBUG_LEVEL=debug in your environment for verbose output during development.
Core API implementation
This is the core module that connects the CLI to the Midnight SDK. It handles four responsibilities: creating and funding wallets from BIP-39 mnemonics, deploying or joining smart contracts on Preprod, fetching Schnorr attestations from the API you built in Part 2, and wrapping each smart contract circuit call (loan requests, PIN changes, admin operations) in a function the interactive CLI can invoke. Because of its size, it is presented in logical sections with explanations between each block.
Create zkloan-credit-scorer-cli/src/api.ts and add the following sections in order.
Imports and global setup
This block imports the Midnight SDK modules, wallet libraries, and project-specific types. It also patches the global WebSocket constructor for Node.js compatibility with the SDK's GraphQL subscriptions:
import 'dotenv/config';
import {
type ContractAddress,
transientHash,
CompactTypeBytes,
} from '@midnight-ntwrk/compact-runtime';
import {
ZKLoanCreditScorer,
type ZKLoanCreditScorerPrivateState,
witnesses,
} from 'zkloan-credit-scorer-contract';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { CompiledContract } from '@midnight-ntwrk/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';
import { HDWallet, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import {
createKeystore,
InMemoryTransactionHistoryStorage,
PublicKey as UnshieldedPublicKey,
type UnshieldedKeystore,
UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
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;
The WebSocket assignment is required because the Midnight SDK's GraphQL subscriptions (used by the indexer) expect a global WebSocket constructor. Node.js does not provide one by default.
Wallet context and ledger state
The WalletContext interface and getZKLoanLedgerState function define how the CLI interacts with wallet state and reads on-chain contract data:
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 bundles the four wallet components:
wallet— the facade that coordinates shielded, unshielded, and dust wallets.shieldedSecretKeys— used for ZK transactions.dustSecretKey— used for paying transaction fees.unshieldedKeystore— used for transparent operations such as DUST registration.
getZKLoanLedgerState queries the indexer for the current on-chain state of the smart contract. The ZKLoanCreditScorer.ledger() function deserializes the raw contract state into the typed Ledger object with fields like contractAdmin, loans, providers, and blacklist.
Compiled smart contract and deploy/join
This section combines the compiled contract artifacts with the witness implementations, then provides functions to deploy a new contract or join an existing one:
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;
};
The code above defines three key exports:
-
zkLoanCompiledContractcombines three things: the generated TypeScript smart contract interface, the witness implementations from Part 1, and the compiled circuit assets (proving keys and ZKIR files). -
deploycreates a new instance of the smart contract on Preprod. This triggers a deployment transaction that includes a ZK proof — the proof server generates this, which takes about a minute. -
joinContractconnects to an existing deployed smart contract by address. This is how a second user (or the same user in a new session) interacts with a smart contract deployed by someone else.
Attestation and loan request logic
This section handles the core loan flow: computing the user's public key hash, fetching a Schnorr attestation from the API, storing it in private state, and submitting the loan request transaction:
const bytes32Type = new CompactTypeBytes(32);
const { pureCircuits } = ZKLoanCreditScorer;
// Derive the per-user public key off-chain from the local user secret and a
// PIN, using the same pure circuit the contract uses on-chain. The user
// secret comes from private state — it is the only authoritative identity
// for the caller, since `ownPublicKey()` is prover-claimed and not used.
export const deriveUserPublicKey = (userSecretKey: Uint8Array, pin: bigint): Uint8Array => {
return pureCircuits.deriveUserPublicKey({ bytes: userSecretKey }, pin).bytes;
};
// Compute the userPubKeyHash for an attestation message. Matches what the
// contract computes inside `requestLoan` via
// `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. Get current private state (must contain `userSecretKey`)
const currentState = await providers.privateStateProvider.get(
'zkLoanCreditScorerPrivateState',
);
if (!currentState) {
throw new Error('No private state found');
}
// 2. Compute user pub key hash from the user secret (same as the circuit)
const userPubKeyHash = computeUserPubKeyHash(
currentState.userSecretKey,
secretPin,
);
logger.info(`Computed userPubKeyHash for attestation`);
// 3. Fetch attestation signature from API
logger.info(`Fetching attestation from ${attestationApiUrl}...`);
const signature = await fetchAttestation(
attestationApiUrl,
Number(currentState.creditScore),
Number(currentState.monthlyIncome),
Number(currentState.monthsAsCustomer),
userPubKeyHash,
);
// 4. Get provider info
const providerRes = await fetch(`${attestationApiUrl}/provider-info`);
const providerInfo = (await providerRes.json()) as {
providerId: number;
};
// 5. Update private state with attestation data
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. Call the 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;
};
This is the core loan flow from the CLI's perspective. requestLoan does four things in sequence:
-
Reads
userSecretKeyfrom local private state and derives the user's public key hash withcomputeUserPubKeyHash(userSecretKey, pin). The function no longer takeszwapKeyBytes— identity comes from the witness secret, not the wallet's coin public key. The wallet still pays for the transaction via the standard Zswap balancing flow, but does not identify the user. -
Sends the credit data to the attestation API, which returns a Schnorr signature.
-
Stores the signature and provider ID in the local private state.
-
Calls
requestLoanon the smart contract. The proof server reads private state (including the attestation and the user secret) and generates the ZK proof.
The computeUserPubKeyHash function uses the same deriveUserPublicKey pure circuit the smart contract uses on-chain. Calling the generated pureCircuits.deriveUserPublicKey from TypeScript guarantees the off-chain hash and the in-circuit hash agree, so the attestation message and the in-proof identity match.
Circuit call wrappers and state display
Each wrapper function below maps to a single smart contract circuit. They all follow the same pattern: log the action, call contract.callTx.<circuitName>(), and return the finalized transaction data. The displayContractState function queries the indexer for the current on-chain ledger state.
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;
};
// Blacklist a user by their derived `UserPublicKey` (the value the contract
// checks inside `assert(!blacklist.member(deriveUserPublicKey(...)))`). The
// admin must obtain this 32-byte value out of band — typically by reading
// the on-chain `loans` map keys for users who have already interacted, or
// by asking the target to share their derived pubkey directly. Note that
// admin cannot blacklist by wallet address, since `ownPublicKey()` is not
// trusted by the contract.
export const blacklistUser = async (
contract: DeployedZKLoanCreditScorerContract,
userPublicKey: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Blacklisting user public key...');
const finalizedTxData = await contract.callTx.blacklistUser({
bytes: 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({
bytes: userPublicKey,
});
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
// Hand the admin role over by writing the new admin's derived public key to
// the ledger. The new admin generates their secret locally and computes
// `deriveAdminPublicKey(userSecret)` off-chain; only the resulting 32-byte
// public key crosses the wire. No private key is ever transmitted.
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({
bytes: newAdminPublicKey,
});
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
// Compute the AdminPublicKey for a given user secret. Run by a prospective
// new admin to obtain the 32-byte public key they hand to the current admin.
// Same `userSecretKey` is used for both per-user identity (PIN-bound) and
// the admin role (no PIN) — different domain separators inside the contract
// keep them logically independent.
export const deriveAdminPublicKey = (userSecretKey: Uint8Array): Uint8Array => {
return pureCircuits.deriveAdminPublicKey({ bytes: userSecretKey }).bytes;
};
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.bytes).toString('hex')}`,
);
logger.info(`Blocklist size: ${ledgerState.blacklist.size()}`);
}
return { contractAddress, ledgerState };
};
Each circuit call wrapper follows the same pattern:
- Log the action.
- Call
contract.callTx.<circuitName>(). - Log the transaction ID and block height.
- Return the finalized transaction data.
The SDK handles proof generation, transaction balancing, and submission behind the scenes.
Wallet and provider infrastructure
The code block below defines createWalletAndMidnightProvider. It waits for the wallet to sync with the indexer, then returns a combined wallet and Midnight provider that the SDK uses to balance and submit transactions. The wider wallet setup (polling for funds and registering tNIGHT UTXOs for tDUST generation) is covered in the sections that follow.
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;
}
};
The wallet infrastructure handles three concerns:
-
Syncing:
waitForSyncpolls the wallet state every 5 seconds until it is synchronized with the indexer. -
Funding:
waitForFundspolls every 10 seconds until the wallet has a non-zero balance (shielded + unshielded). -
Dust registration:
registerNightForDustregisters unshielded tNIGHT UTXOs for tDUST generation. On Midnight, NIGHT is the user-facing token and DUST is the fee resource generated from registered NIGHT UTXOs (testnet variants are tNIGHT and tDUST). DUST tokens are required to pay transaction fees on Midnight Network. Without DUST, no transactions can be submitted.
Wallet initialization
The code block below derives three key roles (Zswap, NightExternal, Dust) from a single BIP-39 mnemonic, initializes the corresponding wallets, and waits for synchronization and funding before returning a ready-to-use wallet context.
// Validate a BIP-39 mnemonic and convert it to a seed buffer
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);
};
// Derive wallet keys from a seed and initialize all three wallet types
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();
// Create secret keys for each wallet role
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,
};
const unshieldedConfig = {
networkId: config.networkId,
indexerClientConnection: {
indexerHttpUrl: config.indexer,
indexerWsUrl: config.indexerWS,
},
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
};
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,
};
// Combine the per-wallet configs into a single configuration the facade
// can use to initialize all three wallets, then build them through the
// unified factory. `WalletFacade`'s constructor is private in v3 — use
// `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,
};
};
// High-level: Create wallet from mnemonic, sync, wait for funds, register 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;
};
// Generate a new BIP-39 mnemonic and build a wallet from it
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);
};
The wallet initialization derives three key roles from a single BIP-39 mnemonic:
-
Zswap: Used for shielded (private) transactions and ZK proof generation
-
NightExternal: Used for unshielded (transparent) operations like receiving tNIGHT from the faucet
-
DUST: Used for generating DUST tokens that pay transaction fees
buildWalletAndWaitForFunds is the high-level function the CLI calls. It converts the mnemonic to a seed, initializes all three wallet types, waits for synchronization, checks the balance, and registers NIGHT UTXOs for DUST generation.
Provider configuration and utilities
configureProviders assembles the six SDK providers (wallet, Midnight, proof, ZK config, public data, and private state) into a single bundle. The setLogger and closeWallet utilities manage the module-level logger and clean up wallet resources on exit.
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 assembles all six providers the SDK requires. The private state provider stores sensitive data (credit profiles and attestation signatures) in an encrypted LevelDB database on disk. The proof provider sends proving requests to the local Docker proof server on port 6300.
The levelPrivateStateProvider (Midnight JS 4.x) refuses to run without a strong MIDNIGHT_STORAGE_PASSWORD. There is no longer a default fallback. The password must:
- Be at least 16 characters long.
- Contain characters from at least three of these groups: uppercase, lowercase, digits, special characters.
- Avoid four or more identical characters in a row, such as
aaaa. - Avoid four or more sequential character codes, such as
abcdor1234.
Losing the password means losing access to the encrypted private state on disk — there is no recovery.
The provider is also scoped per accountId (the wallet's Bech32 address). If you run more than one wallet against the same store without scoping, the operations fail.
Interactive CLI
With the API module handling all the SDK interactions, the CLI module is responsible for the user-facing layer: prompting for input, routing choices to the correct API function, and handling errors without crashing. It uses Node's built-in readline/promises for interactive terminal input.
Create zkloan-credit-scorer-cli/src/cli.ts and add the following sections in order.
Imports and menu prompts
The code block below sets up the CLI's foundation. It imports the API module, the shared type definitions, and Node's readline for interactive terminal input, then declares two menu prompt strings:
DEPLOY_OR_JOIN_QUESTION: the first menu, shown after wallet setup, asking whether to deploy a new contract or join an existing one.MAIN_LOOP_QUESTION: the main menu, shown after a contract is deployed or joined, listing the loan and admin actions.
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;
// Menu prompt shown after wallet setup
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? `;
// Menu prompt shown after contract deploy/join
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
These functions prompt the user to deploy a new contract or connect to an existing one by address:
// Connect to an existing deployed contract by address
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);
};
// Present the deploy/join/exit menu and handle the user's choice
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
These functions handle the two user-facing operations: requesting a loan (which involves fetching an attestation) and changing a PIN (which triggers batched loan migration):
// Prompt for amount and PIN, fetch attestation, and submit loan request
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!");
};
// Prompt for old and new PIN, then submit the PIN change transaction
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
All five admin operations — blacklist, unblock, register/remove provider, and rotate — use the witness-derived keypair pattern. Each circuit's first assertion forces the caller, inside the ZK proof, to demonstrate knowledge of the 32-byte secret whose admin-derivation is stored in contractAdmin. Only the deploying admin holds that secret; for everyone else the circuit fails to satisfy its constraints and the transaction reverts. Rotation is the only way to change who holds admin authority, and it works by the new admin generating their own user secret locally and sharing only the resulting admin public key.
The blacklist flows take a derived UserPublicKey (64 hex chars), not a wallet address. Admin obtains this value either by reading the on-chain loans map (where it appears as the key for any user who has interacted) or out-of-band from the target. Admin cannot preemptively blacklist a user who has not yet touched the contract — that's a deliberate trade-off for unforgeable caller identity.
Each admin function prompts for the required input and delegates to the corresponding API wrapper. Only the deployer (admin) can execute these operations:
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!');
};
// Rotate the admin role to a public key the new admin already derived
// locally. The new admin runs `deriveAdminPublicKey(userSecret)` against
// their own 32-byte user secret and hands the resulting public key (64 hex
// chars) to the current admin. No private key is exchanged.
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!');
};
// Register an attestation provider's 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!");
};
// Remove an attestation provider by ID
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
The main loop presents the ten-option menu and routes each choice to the corresponding flow function. The wallet selection prompts the user to create or restore a wallet before entering the main loop:
// Main interaction loop — routes menu choices to flow functions
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}`);
}
}
}
};
// Wallet creation/restore menu
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? `;
// Present wallet options and return the initialized 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}`);
}
}
};
// Entry point — build wallet, configure providers, enter main loop
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}`);
}
}
}
};
The CLI has three layers of interaction:
- Wallet selection: Generate a fresh wallet, restore from a mnemonic, or load from the
.envfile. - Deploy or join: Deploy a new smart contract or connect to an existing one by address.
- Main loop: The ten-option menu for loan requests, admin operations, and state inspection.
Each menu option maps to a flow function that prompts for input and calls the corresponding API function. Errors are caught and logged without crashing the CLI, so you can retry operations.
Entry point
The entry point initializes the Preprod configuration, creates the logger, and hands both to the CLI runner.
Create 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
Create 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": "*"
}
}
Create zkloan-credit-scorer-cli/tsconfig.json:
{
"include": ["src/**/*.ts"],
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"lib": ["ESNext"],
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"noImplicitAny": true,
"strict": true,
"isolatedModules": true,
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@contract/*": ["../../contract/src/*"]
}
}
}
Create zkloan-credit-scorer-cli/tsconfig.build.json:
{
"extends": "./tsconfig.json",
"exclude": ["src/test/**/*.ts"],
"compilerOptions": {}
}
The CLI uses dotenv to load environment variables from a .env file in the zkloan-credit-scorer-cli directory. Create both a checked-in template (.env.example) and a local .env:
cat > zkloan-credit-scorer-cli/.env.example << 'EOF'
# Required. Strong password used to encrypt the on-disk private state.
# Rules (enforced by the level-private-state-provider in Midnight JS 4.x):
# - at least 16 characters
# - characters from at least three of: uppercase, lowercase, digits, symbols
# - no 4-or-more identical characters in a row (e.g. aaaa)
# - no 4-or-more sequential character codes (e.g. abcd, 1234)
# Losing this password means losing access to the encrypted state — there is no recovery.
MIDNIGHT_STORAGE_PASSWORD=
# Optional. Paste a 24-word BIP-39 mnemonic to restore the same wallet
# across CLI sessions via menu option 3.
WALLET_MNEMONIC=
# Optional. Defaults to http://localhost:4000 if unset.
ATTESTATION_API_URL=http://localhost:4000
EOF
cp zkloan-credit-scorer-cli/.env.example zkloan-credit-scorer-cli/.env
Edit zkloan-credit-scorer-cli/.env and set MIDNIGHT_STORAGE_PASSWORD to a value that satisfies the rules above. Leave WALLET_MNEMONIC empty for now — after you generate or fund a wallet in the testing step, paste your mnemonic here so you can restore the same wallet in future sessions using option 3 in the CLI menu.
Run the CLI
The final part brings everything together by testing the complete flow using Midnight Local Dev, a standalone Docker environment that runs the Midnight node, indexer, and proof server locally. Its npm start wizard can transfer NIGHT to any address you give it; the ZKLoan CLI then registers that NIGHT for DUST automatically, so the wallet is ready to submit transactions.
You need three terminal windows.
Install dependencies
From the project root, run:
npm install
Compile and build the smart contract
If you have not already done so in Part 1:
cd contract
npm run compact
npm run build
cd ..
Start Midnight local dev
In your first terminal, clone the Midnight Local Dev repo, install its dependencies, and start the network:
git clone https://github.com/midnightntwrk/midnight-local-dev.git
cd midnight-local-dev
npm install
npm start
This pulls the Docker images and starts the node, indexer, and proof server on ports 9944, 8088, and 6300. It then initializes a genesis master wallet and shows an interactive menu. If a network is already running, then the wizard first prompts you to reuse it or restart with fresh images — choose either.
Leave this terminal at the main menu. You'll come back to it once the ZKLoan CLI prints a wallet address that needs funding. Closing the menu or pressing Ctrl+C shuts the network down.
If you only want the bare containers without the funding wizard, then you can use docker compose -f standalone.yml up -d from the same folder instead. The trade-off is that you would have to handle wallet funding and DUST registration yourself.
Start the attestation API
In your second terminal, from the project root:
cd zkloan-credit-scorer-attestation-api
NETWORK_ID=undeployed npm run dev
You should see output like:
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
Copy the provider public key coordinates — you need them in the next step.
Run the CLI
In your third terminal, start the CLI from the project root:
cd zkloan-credit-scorer-cli
npm run local
The local script already exports NETWORK=localdev — you do not need to prefix it.
The CLI walks you through five phases:
- Fund the wallet.
- Deploy the contract.
- Register the attestation provider.
- Request a loan.
- Inspect the on-chain state.
1. Fund the wallet
When the wallet menu appears, pick option 1 (Build a fresh wallet). The CLI generates a 24-word mnemonic, derives the wallet, and prints:
Your wallet address: mn_shield-addr_undeployed1q…
Waiting to receive tokens...
Copy the mn_shield-addr_undeployed1q… address. Switch to your first terminal (the local-dev menu), pick [2] Fund accounts by public key (NIGHT transfer only), and paste the address. The wizard transfers 50,000 NIGHT to it.
Switch back to the CLI terminal. Within ~10–20 seconds the "Waiting to receive tokens..." poller will see the funds and call registerNightForDust automatically. Once you see Dust registration complete!, the wallet has both NIGHT (value) and DUST (fees) and is ready to transact.
The generated mnemonic only lives in this terminal session. To restore the same funded wallet next time, copy the mnemonic into WALLET_MNEMONIC in zkloan-credit-scorer-cli/.env and pick option 3 (Use mnemonic from .env file) on the next start.
2. Deploy the contract
The CLI now shows the deploy/join menu. Pick option 1 (Deploy a new ZKLoan Credit Scorer contract). The proof server generates the deployment proof — this takes about a minute.
When it finishes, the CLI logs Deployed contract at address: 02… and drops you into the main 10-option menu.
3. Register the attestation provider
This step is one-time per contract. Without it, every loan request will revert because the circuit asserts the provider is registered.
- Pick option 8 (Register attestation provider).
- Enter provider ID:
1. - Paste the
xandycoordinates printed by the attestation API in your second terminal.
The provider's public key is now in the contract's providers map.
4. Request a loan
- Pick option 1 (Request a loan).
- Enter a loan amount in USD — for example,
5000. Approvals are capped at $10,000 / $7,000 / $3,000 per credit tier; anything above your tier becomes aProposedoffer. - Enter a 4-digit secret PIN — for example,
1234. The PIN is constrained toUint<16>, so longer values would overflow the circuit.
The CLI fetches an attestation from the API, stores it in the local private state, and submits the loan request. After about a minute the proof completes and the transaction is finalized.
5. Inspect the on-chain state
Pick option 3 (Display contract state). The CLI logs the contract address, the admin public key (the hash of the deploying admin's user secret), blacklist size, and the loan record (status + authorized amount).
Notice what is not on-chain: your credit score, income, employment tenure, attestation signature, or PIN. The transaction proved your eligibility without revealing any of those — only the outcome was disclosed.