Private AI Credit Scorer on Midnight

Build a ZK-verified AI credit scorer where financial data stays private on-chain.

Introduction

30 Second Summary

Every time you apply for a loan, you hand over bank statements, pay stubs, and tax returns to prove you're creditworthy. What if you could prove the same thing without revealing a single number?

In this project, you will build a Private AI Credit Scorer on the Midnight blockchain. A local AI model scores your financial data, then a smart contract uses zero-knowledge proofs to verify the result and publish only the credit tier on-chain.

What You'll Build

You'll type your financial data into a terminal, watch a local AI score it, and see the verified credit tier appear on-chain without your income or debt ever being revealed.

By the end of this project, you'll have:

  • A Compact smart contract that rejects dishonest credit tier claims using zero-knowledge proofs while keeping your financial data completely private.
  • A local AI scoring pipeline powered by Ollama where sensitive financial data never leaves your machine.
  • A full end-to-end CLI that connects AI inference to on-chain ZK verification in a single flow.
  • Secret Mission: Build a lender's view that reads on-chain tiers and makes lending decisions, proving the full privacy loop end-to-end.

Are there any prerequisites?

This project assumes you have WSL with Ubuntu installed, Docker Desktop running, and Node.js v22+ available. You'll also need TypeScript experience and prior dApp development on any blockchain.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. This is your moment to commit to the goal before any hands-on work begins.

Set Up Your Midnight + AI Dev Environment

Building a private AI credit scorer requires three pieces of infrastructure: the Compact compiler to write zero-knowledge smart contracts, a local Midnight devnet to deploy them, and a local AI model to score financial data.

In this step, you will install all three. Every tool runs locally, so there are no API keys, no cloud accounts, and zero cost.

In this step, get ready to:
  • Install the Compact compiler in WSL Ubuntu.
  • Scaffold a Midnight project and start the local devnet in Docker.
  • Install Ollama and pull the AI model.
Install the Compact compiler in WSL

Compact is Midnight's smart contract language. The compiler translates your Compact code into zero-knowledge circuits that the Midnight network can verify.

  • Open your WSL Ubuntu terminal.
  • Install the Compact compiler by running this command:
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh

What does this command do?

This downloads the latest Compact compiler installer from the official Midnight GitHub repository and runs it. The installer places the compact binary in your home directory and adds it to your shell's PATH.

  • Reload your shell configuration so the new binary is available:
source ~/.bashrc
  • Verify the compiler is installed by running:
compact --version

You should see a version number printed, confirming the compiler is ready.

Command not found?

  • Make sure you ran source ~/.bashrc after the install completed. The compiler is installed but your shell does not know about it until you reload.
  • Confirm you are running this inside your WSL Ubuntu terminal, not a Windows PowerShell or Command Prompt window. The Compact compiler only runs on Linux.

Still stuck? Help me troubleshoot the Compact compiler installation in WSL.

Scaffold the project and start the local devnet

The Midnight team provides a scaffolding tool called create-mn-app that generates a ready-to-go project with contract templates, deployment scripts, and a bundled local devnet.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new Midnight project by running:
npx create-mn-app@latest midnight-credit-scorer --template hello-world

What will happen when you run this?

The npx command may prompt you to install the create-mn-app package. Type y and press Enter to proceed.

The --template hello-world flag skips the interactive prompts and selects the default contract template. This template comes with a docker-compose.yml for the local devnet and TypeScript deployment scripts.

  • Move into the new project directory:
cd midnight-credit-scorer
  • Confirm the project files were created by running:
ls

You should see contracts/, src/, docker-compose.yml, and package.json in the output.

Now start the local devnet. Make sure Docker Desktop is running on your Windows machine before continuing.

  • Start the three devnet containers by running:
docker compose up -d --wait

What does this start?

This command launches three Docker containers from the bundled docker-compose.yml:

  • midnight-node on port 9944 (the blockchain node).
  • midnight-indexer on port 8088 (indexes chain data for queries).
  • midnight-proof-server on port 6300 (generates ZK proofs for your contracts).

The --wait flag blocks until all containers pass their health checks, so when the command returns, the devnet is ready.

  • Verify the containers are running:
docker ps

You should see three containers with status Up in the output, one for each devnet service.

Containers not starting?

  • Confirm Docker Desktop is running on your Windows machine. WSL2 relies on the Docker Desktop backend for container management.
  • If you see a port conflict error, another service may be using port 9944, 8088, or 6300. Stop the conflicting service and try again.

Still stuck? Help me troubleshoot Docker containers not starting in WSL.

Install Ollama and pull the AI model

Ollama is a local AI inference engine that runs large language models entirely on your machine. Your financial data never leaves your computer.

  • Install Ollama in your WSL terminal by running:
curl -fsSL https://ollama.com/install.sh | sh

What does the installer do?

The script detects your WSL2 environment, downloads the Ollama binary, and sets it up as a background service. When it finishes, you will see a message confirming the API is available at 127.0.0.1:11434.

  • Verify Ollama is installed by running:
ollama -v

You should see a version number printed.

Seeing a certificate error?

  • Some fresh WSL Ubuntu installations have outdated certificate stores. Run the following commands to refresh them, then re-run the install script:
sudo update-ca-certificates -v
sudo dpkg-reconfigure ca-certificates

Still stuck? Help me fix the Ollama install in WSL.

Now download the AI model you will use for credit scoring. The qwen2.5:3b model is 1.9 GB, small enough to download quickly and powerful enough for structured scoring tasks.

  • Pull the model by running:
ollama pull qwen2.5:3b

This download takes a few minutes depending on your connection speed. When it completes, you will see a success message.

Before you move on, what do you think will happen if you ask the model a question right now?

  • Test the model by running:
ollama run qwen2.5:3b "hello"

The model responds with a greeting. This confirms Ollama is running, the model is loaded, and local AI inference is working inside your WSL environment.

How do I exit the model?

If the model drops you into an interactive chat session after responding, type /bye and press Enter to exit back to your terminal.

Model not responding?

  • If you see a connection error, the Ollama service may not be running. Start it manually with ollama serve in a separate terminal, then try the ollama run command again.
  • If the pull timed out, re-run ollama pull qwen2.5:3b. The download resumes where it left off.

Still stuck? Help me get Ollama working in WSL.

Your development environment is fully assembled. The Compact compiler, local devnet, and AI model are all running. Next up, you will write your first smart contract and compile it into zero-knowledge circuits.

Write and Compile the Naive Credit Scorer Contract

Your environment is ready. Now it is time to write the smart contract that sits at the heart of the project.

Before adding any AI or validation logic, you need a working contract that stores a credit tier on the public ledger. This lets you see data flow from TypeScript through the zero-knowledge proof system to the chain. You will start deliberately simple and add protections later.

In this step, get ready to:
  • Create a credit-scorer.compact contract with a naive submitScore circuit.
  • Update the compile script in package.json to target the new contract.
  • Compile the contract and review the generated zero-knowledge circuits.
Create the credit scorer contract

Every Midnight DApp starts with a Compact smart contract. Compact is Midnight's domain-specific language where all circuit parameters are private by default. The only way to make data public is to explicitly call disclose() on it.

  • Create a new file called credit-scorer.compact inside your contracts/ folder by running this command in your WSL terminal:
touch contracts/credit-scorer.compact
  • Confirm the file was created by running:
ls contracts/

You should see credit-scorer.compact alongside the existing hello-world.compact file from the scaffold.

File not showing in the contracts folder?

  • Make sure you ran the touch command from your project's root directory, not from inside another folder.
  • Check the spelling matches exactly: credit-scorer.compact (with a hyphen, not an underscore).

Still stuck? Help me find where my contract file was created.

  • Open contracts/credit-scorer.compact in your editor and paste the following code:
pragma language_version 0.23;

// Declare a public ledger field to store the credit tier
export ledger creditTier: Uint<8>;

// A circuit that accepts a tier and publishes it on-chain
export circuit submitScore(
  tier: Uint<8>
): [] {
  // disclose() makes the private tier value public
  creditTier = disclose(tier);
}

What does this code do?

  • pragma language_version 0.23 tells the compiler which version of the Compact language to use.
  • export ledger creditTier: Uint<8> declares a public field on the blockchain ledger. Anyone can read this value.
  • disclose(tier) is the key Midnight primitive. The tier parameter is private by default. Without disclose(), assigning it to a ledger field would be a compiler error.

Right now, anyone can call submitScore with any tier value and it will be accepted. That gap is intentional. You will fix it in Step 4.

What is disclose()?

In Compact, circuit parameters are always private by default. They become inputs to a ZK circuit and are never revealed on-chain.

The disclose() function is the only way to mark a private value as safe to store publicly. It is Midnight's version of selective disclosure.

  • Save contracts/credit-scorer.compact.
Update the compile script in package.json

The scaffold's package.json has a compile script that targets the original hello-world.compact contract. You need to point it at your new contract file instead.

  • Open package.json in your editor and find the "compile" script inside the "scripts" section.
  • Find this line:
"compile": "compact compile contracts/hello-world.compact contracts/managed/hello-world"
  • Replace it with:
"compile": "compact compile contracts/credit-scorer.compact contracts/managed/credit-scorer"
  • Save package.json.

What changed?

You swapped the source path from hello-world.compact to credit-scorer.compact, and the output directory from managed/hello-world to managed/credit-scorer. Now running npm run compile will compile your credit scorer contract instead of the scaffold's default.

Compile the contract and review the output

The Compact compiler transforms your contract code into zero-knowledge circuits, generates cryptographic proving keys, and creates a TypeScript API you will use to interact with the contract from Node.js.

Before you run the compile, what do you think the compiler will produce? Think about what a ZK system needs to verify a transaction without revealing private data.

  • Compile your contract by running this command:
compact compile contracts/credit-scorer.compact contracts/managed/credit-scorer

You should see a progress indicator and a message showing one circuit being compiled (submitScore). This may take 10-30 seconds depending on your machine.

  • Verify the compilation output by listing the generated directories:
ls contracts/managed/credit-scorer/

You should see four directories: compiler, contract, keys, and zkir.

What did the compiler generate?

Each directory serves a different role in the ZK system:

  • contract/ contains the compiled JavaScript implementation and TypeScript type definitions. This is the API your Node.js code will import.
  • keys/ holds the cryptographic proving and verifying keys. The prover key lets you generate a ZK proof. The verifier key lets the chain confirm the proof is valid.
  • zkir/ stores the Zero-Knowledge Intermediate Representation. This is the bridge between your Compact code and the ZK backend.

Compilation failed?

  • Check that your credit-scorer.compact file has the exact code from the step above. A missing semicolon or typo in disclose will cause a compiler error.
  • Make sure you are running the command from your project's root directory, not from inside the contracts/ folder.
  • Verify the Compact compiler is available by running compact --version. If the command is not found, run source ~/.bashrc first.

Still stuck? Help me debug my Compact compilation error.

✔️ Awesome, I've got everything!

Great. Double check you have saved both contracts/credit-scorer.compact and package.json.

ⓧ I'd like to double check the full code

Here is the complete contract code. Make sure yours matches exactly:

pragma language_version 0.23;

export ledger creditTier: Uint<8>;

export circuit submitScore(
  tier: Uint<8>
): [] {
  creditTier = disclose(tier);
}

And the updated compile script in package.json:

"compile": "compact compile contracts/credit-scorer.compact contracts/managed/credit-scorer"

Your contract is compiled and the ZK machinery is ready. In the next step, you will deploy this contract to the local devnet and discover why a contract with no validation is a dangerous thing.

Deploy and Discover the Gap

Your credit scorer contract compiles, but it lives only as files on disk. To test whether it actually works, you need to deploy it to the local Midnight devnet and call its submitScore circuit from TypeScript.

Deploying reveals something critical. The contract accepts any tier you throw at it. Tier 1 with zero income? No problem. This step exposes the gap so the next step can fix it with zero-knowledge validation.

In this step, get ready to:
  • Modify the scaffold's deployment script to deploy the credit-scorer contract.
  • Modify the scaffold's interaction script to call the submitScore circuit and read the on-chain tier.
  • Deploy, submit a score, and discover that any tier is blindly accepted.
Update the deployment script for the credit scorer

The scaffold generated src/deploy.ts to deploy the hello-world contract. You need to point it at your credit-scorer contract instead.

The deployment script imports a compiled contract and a zkConfigPath from a shared utilities file. Both currently reference the hello-world contract.

  • Open src/deploy.ts in your editor.
  • Find the line that references the hello-world contract path. It looks similar to this:
// References to hello-world contract in utils or deploy
privateStateId: 'helloWorldState',
  • Change the privateStateId value from 'helloWorldState' to 'creditScorerState' so it reads:
privateStateId: 'creditScorerState',
  • Update the zkConfigPath to point at your credit-scorer managed output. Find the path that references managed/hello-world and change it to managed/credit-scorer.
  • If the deploy script imports a contract module dynamically, update that path to point at contracts/managed/credit-scorer/contract/index.js instead of the hello-world equivalent.
  • Save src/deploy.ts.

What did you just change?

The scaffold's deploy script was wired to the hello-world contract. By changing the privateStateId and the zkConfigPath, you told the SDK to load the zero-knowledge circuits, proving keys, and TypeScript bindings from your credit-scorer compilation output.

The privateStateId is a label the SDK uses to partition private state on disk. Each contract needs its own ID so their private states do not collide.

Update the interaction script to call submitScore

The scaffold also generated src/interact.ts (or src/cli.ts) to interact with the deployed contract. It currently calls storeMessage on the hello-world contract. You need to replace that with a call to submitScore and a ledger read for creditTier.

  • Open src/interact.ts in your editor.
  • Find the section where the script calls the contract's circuit. It looks similar to:
// Hello-world interaction pattern
const tx = await contract.callTx.storeMessage(message);
  • Replace the storeMessage call with a submitScore call that passes a tier value as a bigint. Update that section so it reads:
// Submit a credit tier to the contract
const tier = 1n; // Tier 1: Excellent
console.log(`Submitting tier ${tier}...`);
const tx = await contract.callTx.submitScore(tier);

Why bigint?

Compact's Uint<8> type maps to TypeScript's bigint type. The 1n syntax is how you write a bigint literal in TypeScript. The SDK performs runtime bounds checking to make sure the value fits within the Uint<8> range (0 to 255).

  • Next, find the section where the script reads the contract's ledger state. The hello-world version reads the message field. Replace it with a read of creditTier:
// Read the credit tier from the public ledger
const state = await providers.publicDataProvider.queryContractState(
  contractAddress
);
if (state) {
  const ledgerState = CreditScorer.ledger(state.data);
  console.log(`On-chain credit tier: ${ledgerState.creditTier}`);
}
  • Update the contract import at the top of the file. Replace the HelloWorld import with CreditScorer from your compiled contract output, and update the compiledContract reference to match.
  • Update the privateStateId in the findDeployedContract call to 'creditScorerState' to match what you set in deploy.ts.
  • Save src/interact.ts.

What does this code do?

The contract.callTx.submitScore(tier) call sends a transaction to the Midnight devnet. The SDK generates a zero-knowledge proof from your circuit, submits it to the proof server, and the node verifies the proof before writing the result to the ledger.

The queryContractState call reads the public ledger state through the indexer on port 8088. The CreditScorer.ledger() helper decodes the raw state bytes into a typed object so you can access creditTier directly.

Not sure which lines to change?

The scaffold's files have a consistent structure. Look for imports from ./utils.js and any reference to helloWorld or hello-world. Every occurrence needs to point at your credit-scorer contract instead.

If you are stuck, search the file for hello and replace each match with the credit-scorer equivalent.

Help me update my scaffold files to use the credit-scorer contract instead of hello-world.

Deploy, submit a score, and discover the gap

Both scripts are updated. Time to deploy the contract to your local devnet and see it in action.

  • Deploy the contract by running this command in your WSL terminal:
npm run setup

What does npm run setup do?

This single command does three things. It boots the local devnet Docker containers (if not already running), compiles the contract, and deploys it. The local devnet pre-mints NIGHT tokens to a genesis seed, so deployment works immediately with no faucet or wallet extension needed.

Deployment may take 30 to 60 seconds. The proof server generates the zero-knowledge proof for the deployment transaction, the node verifies it, and the indexer indexes the new contract state.

Deployment failing?

Make sure Docker Desktop is running and the devnet containers are up. You can check with docker ps in your WSL terminal. You should see the midnight-node, midnight-indexer, and midnight-proof-server containers.

If you see a compilation error, double-check that your package.json compile script points to contracts/credit-scorer.compact and that the zkConfigPath in deploy.ts matches contracts/managed/credit-scorer.

Help me debug my Midnight contract deployment error.

Once deployment succeeds, you should see a contract address printed in your terminal. The contract is now live on your local devnet.

Now submit a credit tier. Before you run this, think about what you expect to happen. The contract has a submitScore circuit that takes a single tier parameter. Do you think there is anything stopping you from claiming Tier 1 (Excellent)?

  • Run the interaction script to submit a tier:
npm run cli

The script submits tier 1n to the contract and reads back the creditTier from the on-chain ledger. You should see the tier stored successfully.

What should I see?

The output shows the tier being submitted, the transaction completing, and the on-chain creditTier reading back as 1. The ZK proof was generated, verified, and the tier was written to the public ledger via disclose().

Now for the critical test. Think about what this contract is actually checking. You submitted Tier 1 (Excellent). But your contract has no income data, no debt data, and no validation rules. What happens if you change the tier value to 1n and imagine you have terrible finances? Nothing stops you.

  • Open src/interact.ts again.
  • The tier is already set to 1n. Run npm run cli again.
npm run cli

It works. Tier 1 stored again. No questions asked. Someone with an income of $10,000 and $50,000 in debt could claim Tier 1 (Excellent) and the contract would happily accept it. The zero-knowledge proof only proves that the circuit ran correctly. It does not prove the tier is honest.

This is the gap

The contract has no validation. It blindly stores whatever tier you give it. Anyone can claim any credit tier without any connection to real financial data. The ZK proof guarantees that disclose(tier) ran, but it says nothing about whether the tier is truthful.

This is exactly the problem the next step solves. By adding private financial parameters (income, debt, assets) and assert() rules, the ZK proof will enforce that the tier matches the data. A fraudulent claim will fail before it ever reaches the ledger.

You have proven that the contract works end-to-end. Data flows from TypeScript through the ZK system to the chain. But the contract is a rubber stamp. Next up, you will add private financial data and assertion rules so the ZK proof actually means something.

Add Zero-Knowledge Validation

In the last step, you deployed the credit scorer and proved that anyone can fake their tier. A user with $10,000 income claimed Tier 1 (Excellent) and the contract stored it on-chain without question.

The fix is to feed the raw financial data into the zero-knowledge circuit as private parameters and add assertion rules. The contract will reject any tier that the numbers do not support. The data itself never appears on-chain because Compact circuit parameters are private by default.

In this step, get ready to:
  • Add private financial parameters to the credit scorer circuit.
  • Add assertion rules that validate each tier claim against the financial data.
  • Recompile, redeploy, and test with both fraudulent and honest tier claims.
Add private financial parameters to the circuit

Compact circuit parameters are private by default. Adding income, debt, and assets as Uint<64> parameters means they enter the ZK proof but never appear on the public ledger.

  • In contracts/credit-scorer.compact, find this circuit signature:
export circuit submitScore(
  tier: Uint<8>
): [] {
  • Replace the entire circuit signature with this updated version, adding income, debt, and assets as private parameters:
export circuit submitScore(
  income: Uint<64>,
  debt: Uint<64>,
  assets: Uint<64>,
  tier: Uint<8>
): [] {

Why are circuit parameters private by default?

On most blockchains, all calldata is public. On Midnight, the opposite is true. Every circuit parameter is a private input to the zero-knowledge proof by default.

The only way data reaches the public ledger is through an explicit disclose() call. This is Midnight's core privacy guarantee: you choose what to reveal, and everything else stays hidden inside the proof.

Add assertion rules for each tier level

With the financial data inside the circuit, you can now add rules that check whether the claimed tier is honest. Compact's assert() function halts proof generation if a condition is false.

  • In the same file, find the line creditTier = disclose(tier); and add the following code above it:
  // Reject tiers outside the valid 1-4 range
  assert(tier >= 1, "Tier must be at least 1");
  assert(tier <= 4, "Tier must be at most 4");

  // Tier 1 (Excellent): high income, low debt ratio
  if (tier == 1) {
    assert(income >= 100000, "Income too low for Tier 1");
    assert(debt * 3 < income, "Debt ratio too high for Tier 1");
  }
  // Tier 2 (Good): moderate income, moderate debt ratio
  if (tier == 2) {
    assert(income >= 50000, "Income too low for Tier 2");
    assert(debt * 2 < income, "Debt ratio too high for Tier 2");
  }
  // Tier 3 (Fair): minimum income threshold
  if (tier == 3) {
    assert(income >= 25000, "Income too low for Tier 3");
  }

What does this code do?

  • The first two assert() calls enforce a valid tier range (1 through 4). Any value outside that range halts proof generation.
  • Each if block checks tier-specific rules only when that tier is claimed. Tier 1 requires income of at least 100,000 and debt below one-third of income. Tier 2 requires at least 50,000 income and debt below half.
  • Tier 4 has no assertions, so it always passes. The contract enforces a ceiling, not a floor. You can always claim a lower tier than you qualify for, but you cannot lie upward.
  • The expression debt * 3 < income is safe from overflow. Compact auto-widens the result of Uint<64> * 3 to a larger type, so no integer overflow is possible.
  • Save contracts/credit-scorer.compact.

✔️ Awesome, I've got everything!

Great. Double check you have saved contracts/credit-scorer.compact before moving on to recompilation.

ⓧ I'd like to double check the full code

Here is the complete contracts/credit-scorer.compact file at this point:

pragma language_version 0.23;

export ledger creditTier: Uint<8>;

export circuit submitScore(
  income: Uint<64>,
  debt: Uint<64>,
  assets: Uint<64>,
  tier: Uint<8>
): [] {
  assert(tier >= 1, "Tier must be at least 1");
  assert(tier <= 4, "Tier must be at most 4");

  if (tier == 1) {
    assert(income >= 100000, "Income too low for Tier 1");
    assert(debt * 3 < income, "Debt ratio too high for Tier 1");
  }
  if (tier == 2) {
    assert(income >= 50000, "Income too low for Tier 2");
    assert(debt * 2 < income, "Debt ratio too high for Tier 2");
  }
  if (tier == 3) {
    assert(income >= 25000, "Income too low for Tier 3");
  }

  creditTier = disclose(tier);
}
Recompile, redeploy, and test the validated contract

The contract source has changed, so the ZK circuits need to be regenerated. After recompilation you will redeploy and test with two scenarios: a fraudulent tier claim that should fail, and an honest one that should succeed.

  • Recompile the updated contract by running this command in your WSL terminal:
npm run compile

You should see the contract/, keys/, and zkir/ directories regenerated inside contracts/managed/credit-scorer/. The new circuits now include your assertion logic and the three additional private parameters.

Compilation failed?

  • Check that every if block uses == (double equals) for comparison, not = (single equals, which is assignment).
  • Verify the circuit signature ends with ): [] { and every parameter line except the last ends with a comma.
  • Use the full-file double-check tab above to compare your code line by line.

Help me debug a Compact compilation error for my credit-scorer contract with assert rules

Now update src/interact.ts so it passes the financial data to the recompiled contract. The circuit signature changed from one parameter to four, so the generated TypeScript API now expects four bigint arguments in the same order: income, debt, assets, tier.

  • In src/interact.ts, find your existing submitScore call. It currently passes only the tier:
// Current call - only the tier argument
submitScore(1n)
  • Update the arguments to pass fraudulent financial data. Use an income of 10000n (well below the Tier 1 minimum of 100,000) while still claiming Tier 1n:
// Fraudulent: income far too low for Tier 1
submitScore(10000n, 50000n, 0n, 1n)
  • Save src/interact.ts.
  • Redeploy the updated contract by running:
npm run deploy

Deployment may take a moment

The proof server needs to process the new ZK circuits. This can take 30 to 60 seconds. Wait for the contract address to appear in the output before continuing.

Before you run the interaction, think about what should happen. The contract now has an assertion requiring income of at least 100,000 for Tier 1. You are submitting an income of 10,000. Do you think the contract will accept or reject this claim?

  • Run the interaction script to test the fraudulent claim:
npx tsx src/interact.ts

You will see an assertion error. The ZK prover cannot generate a valid proof because the assertion income >= 100000 fails when income is 10,000. The transaction is rejected before it ever reaches the ledger. This is the gap from Step 3 closed.

Not seeing an assertion error?

  • Make sure you ran npm run compile after editing the contract. If you skipped recompilation, the old contract without assertions is still deployed.
  • Confirm you ran npm run deploy after recompilation. A fresh deploy creates a new contract instance with the updated validation logic.
  • Check that your submitScore call passes four arguments in the correct order: income, debt, assets, tier.

Help me troubleshoot why my Midnight contract is not rejecting a fraudulent credit tier

The contract rejected the lie. Now test the honest path to confirm legitimate claims still work.

  • In src/interact.ts, find the fraudulent submitScore call you just added:
// Fraudulent: income far too low for Tier 1
submitScore(10000n, 50000n, 0n, 1n)
  • Replace the arguments with honest financial data. Use income 120000n, debt 15000n, and assets 50000n:
// Honest: income and debt ratio qualify for Tier 1
submitScore(120000n, 15000n, 50000n, 1n)
  • Save src/interact.ts.

This time, income is 120,000 (above the 100,000 minimum) and debt times three is 45,000 (below 120,000). Both assertions should pass. What do you expect to see on-chain after running this?

  • Run the interaction script again:
npx tsx src/interact.ts

This time the proof generation succeeds. The contract verifies that the financial data supports a Tier 1 claim, generates a valid ZK proof, and publishes the credit tier on-chain. The income, debt, and assets values never appear on the ledger.

What stays private and what gets published?

  • Private (never on-chain): income, debt, and assets. These are circuit parameters, which are private by default in Compact.
  • Public (on-chain): only the tier value, because you explicitly called disclose(tier) before writing it to the ledger.
  • Anyone reading the ledger sees "Tier 1" but has no way to learn the income, debt, or assets that justified it. The ZK proof guarantees the tier is honest without revealing why.

Your contract now rejects lies and keeps secrets. In the next step, you will connect a local AI model to score the financial data automatically, so the learner no longer has to pick the tier by hand.

Wire In the AI Scorer and Build the CLI

Your contract now enforces honest credit tiers with zero-knowledge proofs. A fraudulent claim gets rejected before it ever touches the ledger.

But right now, you are picking tiers by hand. That is tedious and defeats the purpose of having a scoring system. In this step, you will connect Ollama to analyze financial data and recommend a tier automatically. Then you will wire that recommendation straight into the contract so the full pipeline runs in one CLI command.

In this step, get ready to:
  • Create an AI scoring module that sends financial data to Ollama and parses a structured tier recommendation.
  • Update the CLI to prompt for income, debt, and assets, then feed them through the AI scorer and into the contract.
  • Run the full end-to-end pipeline and see a ZK-verified credit tier published on-chain.
Create the AI scorer module

The AI scorer is a standalone TypeScript module that talks to Ollama's local API. It sends a structured prompt with the learner's financial data and parses back a tier number and reasoning.

  • Create a new file called src/ai-scorer.ts and add the following constants, type definitions, and function signature with the AI prompt:
// Ollama API endpoint running locally in WSL
const OLLAMA_URL = 'http://localhost:11434/api/generate';
const MODEL = 'qwen2.5:3b';

// Shape of the financial data the AI will score
export interface FinancialData {
  income: number;
  debt: number;
  assets: number;
}

// Shape of the AI's response after parsing
export interface CreditScore {
  tier: number;
  reasoning: string;
}

export async function scoreCreditWithAI(data: FinancialData): Promise<CreditScore> {
  // Build a structured prompt that mirrors the contract's tier rules
  const prompt = `You are a credit scoring assistant. Given the following financial data, assign a credit tier from 1 to 4.

Tier 1 (Excellent): Annual income >= $100,000, total debt less than 1/3 of income
Tier 2 (Good): Annual income >= $50,000, total debt less than 1/2 of income
Tier 3 (Fair): Annual income >= $25,000
Tier 4 (Poor): Does not meet Tier 3 requirements

Financial Data:
- Annual Income: $${data.income.toLocaleString()}
- Total Debt: $${data.debt.toLocaleString()}
- Total Assets: $${data.assets.toLocaleString()}

Respond in this exact format on two lines:
TIER: <number>
REASONING: <one sentence explanation>`;

What does this code do?

OLLAMA_URL points to the local Ollama API running on port 11434. All inference happens on your machine with zero data leakage.

The prompt tells the AI the exact same tier rules that the Compact contract enforces. This alignment is critical. If the AI recommends a tier the contract's assert() rules reject, the ZK proof will fail.

  • Still in src/ai-scorer.ts, add the following code directly below the prompt to send the request to Ollama and parse the response:
  // Send the prompt to Ollama's generate endpoint
  const response = await fetch(OLLAMA_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model: MODEL,
      prompt,
      stream: false,
    }),
  });

  if (!response.ok) {
    throw new Error(`Ollama request failed: ${response.statusText}`);
  }

  const result = (await response.json()) as { response: string };
  const text = result.response;

  // Extract the tier number and reasoning from the AI's text response
  const tierMatch = text.match(/TIER:\s*(\d)/);
  const reasoningMatch = text.match(/REASONING:\s*(.+)/);

  const tier = tierMatch ? parseInt(tierMatch[1], 10) : 4;
  const reasoning = reasoningMatch ? reasoningMatch[1].trim() : 'No reasoning provided';

  // Clamp the tier to valid range 1-4 in case the AI hallucinates
  const clampedTier = Math.max(1, Math.min(4, tier));

  return { tier: clampedTier, reasoning };
}

What does this code do?

The stream: false option tells Ollama to return the full response in one JSON object instead of streaming tokens. The response field contains the AI's complete text output.

Two regex patterns extract the TIER: number and REASONING: text from the response. If parsing fails (the AI doesn't follow the format), the code defaults to Tier 4 as a safe fallback. The clampedTier line prevents out-of-range values from reaching the contract.

  • Save src/ai-scorer.ts.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm both chunks are present in src/ai-scorer.ts, in this order:

  • Chunk 1: OLLAMA_URL and MODEL constants, FinancialData and CreditScore interfaces, the scoreCreditWithAI function signature, and the prompt template literal.
  • Chunk 2: The fetch call to Ollama, the error check, JSON parsing, regex extraction of TIER and REASONING, the tier clamp, and the closing } of the function.
  • Confirm the file exists by running:
ls src/ai-scorer.ts

You should see src/ai-scorer.ts listed in the output.

Don't see the file?

  • Make sure you created the file inside the src/ directory, not in the project root.
  • Check that you saved the file after pasting both chunks.

Help me find my ai-scorer.ts file

Wire the AI scorer into the CLI

The AI scorer module is ready. Now you need to update src/interact.ts so it prompts for financial data, calls the scorer, and feeds the result into the contract.

  • Open src/interact.ts in your editor.
  • At the top of the file, add these two import lines alongside the existing imports:
import { createInterface } from 'node:readline/promises';
import { scoreCreditWithAI } from './ai-scorer';
  • Find the section in your main function where you previously hardcoded the financial data and tier values.
  • Replace that hardcoded section with the following CLI prompt and AI scoring flow:
    // Prompt the user for financial data
    const rl = createInterface({ input: process.stdin, output: process.stdout });

    const incomeStr = await rl.question('Enter annual income: $');
    const debtStr = await rl.question('Enter total debt: $');
    const assetsStr = await rl.question('Enter total assets: $');
    rl.close();

    const income = parseInt(incomeStr, 10);
    const debt = parseInt(debtStr, 10);
    const assets = parseInt(assetsStr, 10);

    console.log('\nScoring your financial data with AI...\n');

    // Call the local AI model to recommend a credit tier
    const score = await scoreCreditWithAI({ income, debt, assets });
    console.log(`AI Recommendation: Tier ${score.tier}`);
    console.log(`Reasoning: ${score.reasoning}\n`);

    console.log('Submitting to Midnight contract for ZK verification...\n');

What does this code do?

The createInterface function from Node's readline/promises module creates a CLI prompt that waits for user input. Each rl.question() call pauses until the learner types a number and presses Enter.

After collecting the three values, the code calls scoreCreditWithAI() which sends them to the local Ollama model. The AI's tier recommendation and reasoning are printed so you can see the AI's thinking before it hits the contract.

  • Next, find the existing submitScore circuit call and update it to use the prompted values and AI-recommended tier. The call should pass BigInt(income), BigInt(debt), BigInt(assets), and BigInt(score.tier) as the four arguments.
  • After the submitScore call completes, read the creditTier value from the contract's public ledger and print it. This confirms the tier made it through the ZK proof and is now on-chain.
  • Save src/interact.ts.

Why BigInt for the financial data?

The Compact contract declares income, debt, and assets as Uint<64> types. Compact's Uint types map to TypeScript bigint values. Passing a regular JavaScript number would cause a type error at the SDK level.

Not sure where to make changes?

Look for the lines you added in the previous step where you called submitScore with hardcoded bigint values. The CLI prompt code replaces the hardcoded values, and the submitScore call stays in the same place but now uses the dynamic variables.

Help me update interact.ts to use the AI scorer

Run the full end-to-end pipeline

Everything is wired up. The contract is deployed, the AI scorer module is ready, and the CLI prompts for input. Time to run the complete pipeline.

  • First, redeploy the contract to make sure everything is fresh by running:
npm run setup

Before you run the interaction script, think about this: if you enter an income of 120000, debt of 15000, and assets of 50000, what tier do you think the AI will recommend? Will the contract accept it?

  • Run the interaction script by running:
npm run cli

What prompts will I see?

The script will first connect to the wallet and sync with the network. This may take a moment. Then you will see three prompts asking for your annual income, total debt, and total assets.

After you enter all three values, the script sends them to the local Ollama model, waits for a response, then submits the AI's recommended tier along with your private financial data to the contract for ZK verification.

  • When prompted, enter 120000 for income, 15000 for debt, and 50000 for assets.

You should see the AI recommend Tier 1 with a reasoning line explaining why the income and debt ratio qualify. The contract then verifies this via a ZK proof and publishes the tier on-chain. The output shows the verified creditTier value read back from the ledger.

What just happened?

Your financial data (income, debt, assets) never left your machine. The AI scored it locally via Ollama. The Compact contract verified the tier was honest using ZK proofs. Only the tier number was published on-chain via disclose(). Anyone reading the blockchain sees "Tier 1" but has zero visibility into the income, debt, or assets that earned it.

Seeing an assertion error?

  • The AI may occasionally recommend a tier that the contract's assert() rules reject. This happens when the AI misjudges the tier boundaries. Run the script again. The clampedTier in ai-scorer.ts prevents out-of-range values, but the contract still enforces the actual financial thresholds.
  • If Ollama is not responding, make sure it is running in WSL. Run ollama list to check that the qwen2.5:3b model is available.
  • If the contract deployment fails, check that Docker containers are running with docker compose ps. You should see the midnight-node, midnight-indexer, and midnight-proof-server containers.

Help me debug my end-to-end pipeline

Secret mission

Build the Lender's View

The privacy story is incomplete until a third party can make a real decision based solely on the ZK-verified on-chain tier. In this mission, you will build a lender's verification mode that reads your on-chain credit tier and produces a lending decision, proving that the lender never needed your raw financial data.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Local devnet Docker containers (midnight-node on port 9944, midnight-indexer on port 8088, midnight-proof-server on port 6300).
  • Ollama service and the qwen2.5:3b model.
  • Project directory created with create-mn-app (contains contracts/, src/, docker-compose.yml, and compiled output in contracts/managed/).

Keep everything running

No action needed. Choose this if you are still actively building on your hackathon project or plan to continue iterating.

  • Your Docker containers will keep serving the local devnet.
  • Ollama will remain available for AI scoring requests.
  • The Compact compiler stays installed in WSL for future contract work.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your project files stay on disk so you can restart anytime.

  • Stop the local devnet containers by running this command from your project directory:
docker compose down
  • Stop the Ollama service by running:
sudo systemctl stop ollama
  • To restart later, run docker compose up -d from your project directory and sudo systemctl start ollama to bring everything back.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes containers, volumes, project files, and the Ollama model.

  • Remove the devnet containers and their volumes by running this command from your project directory:
docker compose down -v
  • Delete the project directory. Navigate to the parent folder and remove it:
cd ~/Desktop
rm -rf my-app
  • Remove the qwen2.5:3b model from Ollama by running:
ollama rm qwen2.5:3b
sudo systemctl stop ollama
sudo systemctl disable ollama
sudo rm /etc/systemd/system/ollama.service
sudo rm $(which ollama)
sudo rm -rf /usr/local/lib/ollama
sudo userdel ollama
sudo groupdel ollama
sudo rm -rf /usr/share/ollama

What about the Compact compiler?

The Compact compiler installed in WSL does not have a documented uninstall command. It lives in your $HOME/.local/bin directory and uses minimal disk space. You can leave it installed for future Midnight projects.

To remove the downloaded toolchain versions and cache, run compact clean --cache in your WSL terminal.

Nice Work!

Nice Work!

Nice work! You just built a private AI credit scorer on Midnight where financial data stays completely private while credit decisions are verified on-chain with zero-knowledge proofs.

You've learned how to:

  • Wrote a Compact smart contract with private-by-default circuit parameters and selective disclosure using disclose(), compiled it into zero-knowledge circuits, and deployed it to a local Midnight devnet.
  • Discovered that an unvalidated contract accepts any credit tier claim, then fixed the gap with ZK assertion rules that reject fraudulent tiers while keeping all financial data completely private.
  • Built a local AI scoring pipeline with Ollama that analyzes financial data and recommends a credit tier, then connected it to on-chain contract verification in a single CLI flow with zero API costs and zero data leakage.
  • Secret Mission: Completed an optional Secret Mission to push your skills further.

Ready to quiz yourself?