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 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 tDUST, 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/v3/graphql";
indexerWS = "wss://indexer.preprod.midnight.network/api/v3/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/v3/graphql";
indexerWS = "ws://127.0.0.1:8088/api/v3/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. 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'
| 'transferAdmin'
| '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 zero-knowledge 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, zero-knowledge 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,
},
];
export function getUserProfile(index?: number): 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,
};
}
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 zero-knowledge 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
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-v7';
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
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: the facade (which coordinates shielded, unshielded, and dust wallets), the shielded secret key (for zero-knowledge transactions), the dust secret key (for paying fees), and the unshielded keystore (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 admin, loans, providers, and blacklist.
Compiled smart contract and deploy/join
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;
};
-
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 zero-knowledge 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
const bytes32Type = new CompactTypeBytes(32);
const { pureCircuits } = ZKLoanCreditScorer;
export const computeUserPubKeyHash = (
zwapKeyBytes: Uint8Array,
pin: bigint,
): bigint => {
const pubKey = pureCircuits.publicKey(zwapKeyBytes, 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,
zwapKeyBytes: Uint8Array,
attestationApiUrl: string,
): Promise<FinalizedTxData> => {
const userPubKeyHash = computeUserPubKeyHash(zwapKeyBytes, secretPin);
logger.info(`Computed userPubKeyHash for attestation`);
const currentState =
await providers.privateStateProvider.get(
'zkLoanCreditScorerPrivateState',
);
if (!currentState) {
throw new Error('No private state found');
}
logger.info(`Fetching attestation from ${attestationApiUrl}...`);
const signature = await fetchAttestation(
attestationApiUrl,
Number(currentState.creditScore),
Number(currentState.monthlyIncome),
Number(currentState.monthsAsCustomer),
userPubKeyHash,
);
const providerRes = await fetch(`${attestationApiUrl}/provider-info`);
const providerInfo = (await providerRes.json()) as {
providerId: number;
};
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})`,
);
logger.info(
`Requesting loan for amount: ${amountRequested} 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:
-
Computes the user's public key hash from the wallet key and PIN using the
publicKeypure circuit -
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 the private state (including the attestation) and generates the zero-knowledge proof
The computeUserPubKeyHash function uses the same publicKey pure circuit that the smart contract uses. This ensures the hash included in the attestation matches what the circuit computes during verification.
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;
};
export const blacklistUser = async (
contract: DeployedZKLoanCreditScorerContract,
account: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Adding user to blocklist...');
const finalizedTxData = await contract.callTx.blacklistUser({
bytes: account,
});
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
export const removeBlacklistUser = async (
contract: DeployedZKLoanCreditScorerContract,
account: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Removing user from blocklist...');
const finalizedTxData = await contract.callTx.removeBlacklistUser({
bytes: account,
});
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
export const transferAdmin = async (
contract: DeployedZKLoanCreditScorerContract,
newAdmin: Uint8Array,
): Promise<FinalizedTxData> => {
logger.info('Transferring admin role...');
const finalizedTxData = await contract.callTx.transferAdmin({
bytes: newAdmin,
});
logger.info(
`Transaction ${finalizedTxData.public.txId} added in block ${finalizedTxData.public.blockHeight}`,
);
return finalizedTxData.public;
};
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: ${Buffer.from(ledgerState.admin.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, and return the finalized transaction data. The SDK handles proof generation, transaction balancing, and submission behind the scenes.
Wallet and provider infrastructure
The wallet infrastructure handles syncing with the indexer, polling for funds, and registering tNIGHT UTXOs for tDUST generation. It also creates the combined wallet and midnight provider that the SDK uses for transaction balancing and submission.
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 }> => {
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;
logger.info(`Unshielded balance: ${unshielded} tDUST`);
logger.info(`Shielded balance: ${shielded} tDUST`);
logger.info(`Total balance: ${total} tDUST`);
return { unshielded, shielded, total };
};
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?.walletBalance(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?.walletBalance(new Date()) ?? 0n;
logger.info(`Dust balance: ${dustBalance}`);
}),
Rx.filter(
(s) => (s.dust?.walletBalance(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. tDUST tokens are required to pay transaction fees on Midnight Network. Without tDUST, no transactions can be submitted.
Wallet initialization
This section 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.
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);
};
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();
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,
};
const shieldedWallet =
ShieldedWallet(shieldedConfig).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet =
UnshieldedWallet(unshieldedConfig).startWithPublicKey(
UnshieldedPublicKey.fromKeyStore(unshieldedKeystore),
);
const dustWallet = DustWallet(dustConfig).startWithSecretKey(
dustSecretKey,
ledger.LedgerParameters.initialParameters().dust,
);
const facade: WalletFacade = new WalletFacade(
shieldedWallet,
unshieldedWallet,
dustWallet,
);
await facade.start(shieldedSecretKeys, dustSecretKey);
return {
wallet: facade,
shieldedSecretKeys,
dustSecretKey,
unshieldedKeystore,
};
};
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;
};
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 zero-knowledge proof generation
-
NightExternal: Used for unshielded (transparent) operations like receiving tDUST 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, zero-knowledge 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 ??
'zkloan-credit-scorer-default-password';
const zkConfigProvider =
new NodeZkConfigProvider<ZKLoanCreditScorerCircuits>(
contractConfig.zkConfigPath,
);
return {
privateStateProvider:
levelPrivateStateProvider<
typeof ZKLoanCreditScorerPrivateStateId
>({
privateStateStoreName: contractConfig.privateStateStoreName,
privateStoragePasswordProvider: () => storagePassword,
}),
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.
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
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? `;
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 blocklist
6. [Admin] Remove user from blocklist
7. [Admin] Transfer admin role
8. [Admin] Register attestation provider
9. [Admin] Remove attestation provider
10. Exit
Which would you like to do? `;
Deploy or join helpers
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);
};
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
const requestLoan = async (
contract: DeployedZKLoanCreditScorerContract,
providers: ZKLoanCreditScorerProviders,
walletContext: WalletContext,
rli: Interface,
): Promise<void> => {
const amountStr = await rli.question("Enter the loan amount requested: ");
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";
const coinPubKeyHex = walletContext.shieldedSecretKeys
.coinPublicKey as unknown as string;
const zwapKeyBytes = Buffer.from(coinPubKeyHex, "hex");
await api.requestLoan(
contract,
providers,
amount,
pin,
zwapKeyBytes,
attestationApiUrl,
);
logger.info("Loan request submitted successfully!");
};
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
const blacklistUserFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const accountHex = await rli.question(
"Enter the Zswap public key to add to the blocklist (hex): ",
);
await api.blacklistUser(contract, Buffer.from(accountHex, "hex"));
logger.info("User added to blocklist successfully!");
};
const removeBlacklistUserFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const accountHex = await rli.question(
"Enter the Zswap public key to remove from the blocklist (hex): ",
);
await api.removeBlacklistUser(contract, Buffer.from(accountHex, "hex"));
logger.info("User removed from blocklist successfully!");
};
const transferAdminFlow = async (
contract: DeployedZKLoanCreditScorerContract,
rli: Interface,
): Promise<void> => {
const newAdminHex = await rli.question(
"Enter the new admin Zswap public key (hex): ",
);
await api.transferAdmin(contract, Buffer.from(newAdminHex, "hex"));
logger.info("Admin role transferred successfully!");
};
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!");
};
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
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 transferAdminFlow(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? `;
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}`);
}
}
};
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 this file to avoid re-entering your mnemonic on every restart:
cat > zkloan-credit-scorer-cli/.env << 'EOF'
WALLET_MNEMONIC=
ATTESTATION_API_URL=http://localhost:4000
EOF
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. It handles wallet funding and dust registration automatically, enabling focused DApp testing.
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 and start the local development environment:
git clone https://github.com/midnightntwrk/midnight-local-dev.git
cd midnight-local-dev
docker compose up -d
Wait for all services to be healthy:
docker compose ps
You should see the node, indexer, and proof server all running. The proof server listens on port 6300, the indexer on 8088, and the node on 9944.
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, from the project root:
cd zkloan-credit-scorer-cli
NETWORK=localdev npm run local
The CLI prompts you to create or restore a wallet. For local dev, select option 1 (Build a fresh wallet). The local environment automatically funds new wallets.
Once the wallet is synced and funded, deploy the smart contract by selecting option 1 (Deploy). This takes about a minute while the proof server generates the deployment proof.
After deployment, register the attestation provider:
- Select option 8 (Register attestation provider)
- Enter provider ID:
1 - Enter the X and Y coordinates from the attestation API output
Now request a loan:
- Select option 1 (Request a loan)
- Enter a loan amount (for example,
5000) - Enter a secret PIN (for example,
1234)
The CLI fetches an attestation, submits the loan request, and waits for the proof server to generate the zero-knowledge proof. After about a minute, you should see the transaction confirmed.
Inspect the result by selecting option 3 (Display contract state). You will see the loan record on-chain with the status and authorized amount, but no credit score, income, or employment data. That information stayed private throughout the entire process.