Introduction
There is a quiet problem at the heart of modern AI: the data powering personalised outputs often belongs to someone who never consented to share it. Artists, writers, musicians their work gets swept into training pipelines, their style replicated, and they receive nothing in return.
But what if it didn't have to work that way?
This tutorial shows you how to use Midnight a privacy-first blockchain built by IOG to run AI inference on private data and produce a cryptographic proof that the output is authentic. The user's private context never leaves their machine. The blockchain only ever sees a commitment and a proof.
By the end you will know how to:
- Encrypt a private data vault locally
- Generate a cryptographic Merkle commitment to that data
- Run AI model inference using the private data as context
- Register an artist profile on a Midnight Compact smart contract
- Have consumers pay in DUST and receive a verified, authentic AI output
Why Midnight?
Midnight is purpose-built for exactly this kind of problem. A few key properties that matter here:
Compact is Midnight's TypeScript-inspired smart contract language. It compiles directly to ZK circuits you write business logic, the compiler handles the cryptography.
Witnesses are Compact's mechanism for private inputs. A witness is a function that runs off-chain with access to private data. It constructs a ZK proof using data that never reaches the public network.
DUST is Midnight's transaction token. DUST transactions are shielded metadata like sender, receiver, and amount are all hidden. This means even the payment for an AI query can be made privately.
NIGHT is Midnight's staking and governance token, used by node operators and for ecosystem participation.
The result: the contract sets the rules, your app runs the inference locally, and Midnight verifies the outcome — without ever seeing the sensitive inputs.
1. Architecture Overview
Key differences from a generic EVM approach:
2. Prerequisites and Setup
Install the Midnight Toolchain
# Install the Compact compiler (check docs.midnight.network for latest version)
# Download from: https://github.com/midnightntwrk/compact/releases
# Add the compact binary to your PATH
compact --version
# compact 0.22.x
Install Node.js Dependencies
node --version # requires v22+
mkdir private-ai-midnight && cd private-ai-midnight
yarn init -y
yarn add @midnight-ntwrk/midnight-js-contracts \
@midnight-ntwrk/midnight-js-network-id \
@midnight-ntwrk/midnight-js-types \
@midnight-ntwrk/wallet \
typescript ts-node
Project Structure
private-ai-midnight/
├── contracts/
│ └── art-style-marketplace.compact # Compact smart contract
├── src/
│ ├── vault.ts # Encrypted private data vault
│ ├── commitment.ts # Merkle commitment
│ ├── inference.ts # Local AI inference
│ ├── deploy.ts # Deploy the Compact contract
│ └── consumer.ts # Consumer: pay and verify
├── package.json
└── tsconfig.json
Start Local Devnet
# Clone the Midnight starter environment
git clone https://github.com/midnightntwrk/example-hello-world.git devnet
cd devnet && yarn install
# Start the local proof server and devnet (Docker required)
yarn env:up
3. Step 1 Build the Private Data Vault
The vault stores all private data locally using AES-256-GCM encryption. Nothing leaves this vault unencrypted.
// src/vault.ts
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
const ALGORITHM = 'aes-256-gcm';
export interface VaultRecord {
artistId: string;
styleEmbeddings: number[]; // private — artist's style vectors
description: string;
}
export class PrivateVault {
private key: Buffer;
constructor(key?: Buffer) {
this.key = key ?? randomBytes(32); // 256-bit key
}
static fromKey(keyHex: string): PrivateVault {
return new PrivateVault(Buffer.from(keyHex, 'hex'));
}
getKeyHex(): string {
return this.key.toString('hex');
}
encrypt(record: VaultRecord): Buffer {
const nonce = randomBytes(12); // 96-bit nonce for GCM
const cipher = createCipheriv(ALGORITHM, this.key, nonce);
const plaintext = JSON.stringify(record);
const ciphertext = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
]);
const authTag = cipher.getAuthTag(); // GCM authentication tag
// Layout: [nonce (12)] [authTag (16)] [ciphertext]
return Buffer.concat([nonce, authTag, ciphertext]);
}
decrypt(blob: Buffer): VaultRecord {
const nonce = blob.subarray(0, 12);
const authTag = blob.subarray(12, 28);
const ciphertext = blob.subarray(28);
const decipher = createDecipheriv(ALGORITHM, this.key, nonce);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString('utf8');
return JSON.parse(plaintext) as VaultRecord;
}
}
// Usage (artist one-time setup)
const vault = new PrivateVault();
const record: VaultRecord = {
artistId: 'alice_impressionist_v1',
styleEmbeddings: [0.12, 0.87, 0.43, /* ... 512-dim vector */],
description: 'Watercolour impressionist portfolio — 300 pieces',
};
const blob = vault.encrypt(record);
// Save: blob → vault.bin | vault.getKeyHex() → vault.key
// NEVER share vault.key
4. Step 2 Generate a Merkle Commitment
The Merkle root is the only thing that goes on-chain. It anchors the artist's private data to a public, verifiable identity without revealing any content.
// src/commitment.ts
import { createHash } from 'crypto';
function sha256(data: Buffer): Buffer {
return createHash('sha256').update(data).digest();
}
export function buildMerkleRoot(leaves: Buffer[]): string {
if (leaves.length === 0) throw new Error('Need at least one leaf');
let layer = leaves.map(l => sha256(l));
while (layer.length > 1) {
if (layer.length % 2 === 1) layer.push(layer[layer.length - 1]);
const next: Buffer[] = [];
for (let i = 0; i < layer.length; i += 2) {
next.push(sha256(Buffer.concat([layer[i], layer[i + 1]])));
}
layer = next;
}
return layer[0].toString('hex');
}
export function commitToVault(records: object[]): string {
/**
* Commits to record METADATA only — not raw embeddings.
* The root is published on-chain via the Compact contract.
*
* Example records: [{ embeddingId: 'style_001', shape: [512] }]
*/
const leaves = records.map(r =>
Buffer.from(JSON.stringify(r, Object.keys(r).sort()), 'utf8')
);
return buildMerkleRoot(leaves);
}
// Usage
const merkleRoot = commitToVault([
{ embeddingId: 'style_001', artistId: 'alice', shape: [512] },
{ embeddingId: 'style_002', artistId: 'alice', shape: [512] },
]);
console.log('Public commitment (goes on-chain):', merkleRoot);
5. Step 3 Run Local AI Inference
The model runs entirely on the artist's machine. The private style embedding is loaded from the vault and used for inference. Only the output is shared never the style embedding.
// src/inference.ts
import { PrivateVault } from './vault';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
/**
* StyleAdapter — fuses a public prompt embedding with
* a private style embedding to produce a conditioned output.
*
* In production: replace with a real ONNX model or LoRA adapter.
* The key invariant is: style_emb comes from vault, never from network.
*/
function styleAdapter(
promptEmb: number[],
styleEmb: number[],
): number[] {
// Simplified: weighted combination (replace with real model)
return promptEmb.map((p, i) => 0.6 * p + 0.4 * (styleEmb[i] ?? 0));
}
export interface InferenceResult {
outputEmbedding: number[];
outputHash: string; // hash of output — used as public input to ZK proof
commitmentRoot: string; // artist's Merkle root — anchors to on-chain identity
}
export function runPrivateInference(
promptEmbedding: number[],
vaultBlob: Buffer,
vaultKeyHex: string,
commitmentRoot: string,
): InferenceResult {
// 1. Load private style from vault (stays in memory only)
const vault = PrivateVault.fromKey(vaultKeyHex);
const record = vault.decrypt(vaultBlob);
const styleEmb = record.styleEmbeddings;
// 2. Run inference locally (private data NEVER leaves this function scope)
const outputEmbedding = styleAdapter(promptEmbedding, styleEmb);
// 3. Hash the output (public input for ZK verification)
const outputHash = createHash('sha256')
.update(JSON.stringify(outputEmbedding))
.digest('hex');
// style_emb is garbage-collected after this return — never persisted
return { outputEmbedding, outputHash, commitmentRoot };
}
6. Step 4 Write the Compact Smart Contract
This is the heart of the Midnight integration. The Compact contract stores the artist's commitment on-chain and verifies that inference outputs are authentic using a witness Midnight's native mechanism for private input proofs.
// contracts/art-style-marketplace.compact
pragma language_version >= 0.22;
import CompactStandardLibrary;
// ── On-chain state (public, persistent on Midnight ledger)
// Registered artist profiles
export ledger artistCommitments: Map<Bytes<32>, ArtistProfile>;
// Used proof hashes (prevents replay attacks)
export ledger usedProofs: Set<Bytes<32>>;
// Total queries processed
export ledger totalQueries: Counter;
// Data types
struct ArtistProfile {
commitment: Bytes<32>, // Merkle root of private data vault
pricePerQuery: Uint<64>, // price in tDUST (smallest unit)
active: Boolean,
totalEarned: Uint<64>,
totalQueries: Counter,
}
// Witness (runs off-chain with private data)
/**
* The witness is the key privacy primitive.
* It runs locally on the artist's machine, accesses the private vault,
* and produces a proof that inference ran correctly — without any
* private data reaching the blockchain.
*
* Inputs (private, never on-chain):
* - styleEmbedding: the artist's private style vector
* - promptEmbedding: the consumer's prompt vector
*
* Outputs (public, verified on-chain):
* - outputHash: SHA-256 of the inference output
* - commitmentRoot: artist's Merkle root (must match ledger)
*/
witness inferenceWitness(
styleEmbedding: Bytes<64>, // private — from encrypted vault
promptEmbedding: Bytes<64>, // public prompt, encoded as bytes
): { outputHash: Bytes<32>, commitmentRoot: Bytes<32> };
// Entry points (circuits)
/**
* Artist registers their commitment on-chain.
* Only the Merkle root is stored — no raw data.
*/
export circuit registerArtist(
commitment: Bytes<32>,
pricePerQuery: Uint<64>,
): [] {
const artistKey = disclose(commitment);
assert(!artistCommitments.member(artistKey), "Commitment already registered");
assert(pricePerQuery > 0, "Price must be positive");
artistCommitments.insert(artistKey, ArtistProfile {
commitment: artistKey,
pricePerQuery: disclose(pricePerQuery),
active: true,
totalEarned: 0,
totalQueries: Counter.new(0),
});
}
/**
* Consumer submits a prompt and triggers proof verification.
* The witness runs off-chain; the contract verifies the proof on-chain.
* Payment in DUST is handled by the Midnight runtime.
*/
export circuit purchaseAndVerify(
artistCommitment: Bytes<32>,
promptEmbedding: Bytes<64>,
): Bytes<32> {
// 1. Look up the registered artist
assert(
artistCommitments.member(artistCommitment),
"Artist not registered"
);
const profile = artistCommitments.lookup(artistCommitment);
assert(profile.active, "Artist profile is inactive");
// 2. Run the witness off-chain (private inference + proof generation)
// The witness accesses the vault locally; only outputHash and
// commitmentRoot are returned to the on-chain circuit.
const { outputHash, commitmentRoot } = inferenceWitness(
/* styleEmbedding — provided by artist's local DApp, never on-chain */,
promptEmbedding,
);
// 3. Verify the commitment matches the registered artist
assert(
commitmentRoot == artistCommitment,
"Proof commitment does not match registered artist"
);
// 4. Prevent proof replay
assert(!usedProofs.member(outputHash), "Output hash already used");
usedProofs.insert(outputHash);
// 5. Update counters
totalQueries.increment(1);
const updatedProfile = ArtistProfile {
commitment: profile.commitment,
pricePerQuery: profile.pricePerQuery,
active: profile.active,
totalEarned: profile.totalEarned + profile.pricePerQuery,
totalQueries: profile.totalQueries,
};
artistCommitments.insert(artistCommitment, updatedProfile);
// 6. Return the verified output hash to the consumer
// Consumer uses this to confirm the output they received is authentic
return outputHash;
}
/**
* Artist can deactivate their profile.
*/
export circuit deactivateArtist(commitment: Bytes<32>): [] {
const key = disclose(commitment);
assert(artistCommitments.member(key), "Not registered");
const profile = artistCommitments.lookup(key);
artistCommitments.insert(key, ArtistProfile {
commitment: profile.commitment,
pricePerQuery: profile.pricePerQuery,
active: false,
totalEarned: profile.totalEarned,
totalQueries: profile.totalQueries,
});
}
/**
* Anyone can look up an artist profile by their commitment.
*/
export circuit getArtistProfile(commitment: Bytes<32>): ArtistProfile {
assert(artistCommitments.member(commitment), "Artist not found");
return artistCommitments.lookup(commitment);
}
Compile the Contract
compact compile \
contracts/art-style-marketplace.compact \
contracts/managed/art-style-marketplace.compact
This generates:
- ZK circuits for every
circuitentry point - Proving and verification keys in
contracts/managed/keys/ - TypeScript API in
contracts/managed/contract/
7. Step 5 Connect with Midnight.js
// src/deploy.ts
import {
ContractAddress,
deployContract,
findContractDeployment,
} from '@midnight-ntwrk/midnight-js-contracts';
import { NetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { commitToVault } from './commitment';
// The compiled contract artifacts (generated by `compact compile`)
import { ArtStyleMarketplaceContract } from '../contracts/managed/art-style-marketplace.compact/contract';
async function deployMarketplace() {
// 1. Connect wallet (pre-funded devnet wallet for local testing)
const wallet = await WalletBuilder.buildFromSeed(
'http://localhost:9000', // proof server
'http://localhost:8080', // indexer
NetworkId.TestNet,
process.env.WALLET_SEED!,
);
await wallet.sync();
console.log('Wallet synced. DUST balance:', await wallet.balanceDust());
// 2. Deploy the Compact contract
const contract = new ArtStyleMarketplaceContract();
const deployment = await deployContract(wallet, contract);
console.log('Contract deployed at:', deployment.contractAddress);
return deployment.contractAddress;
}
async function registerArtist(
contractAddress: ContractAddress,
vaultRecords: object[],
pricePerQuery: bigint,
) {
const wallet = await WalletBuilder.buildFromSeed(/* ... */);
await wallet.sync();
// Compute Merkle commitment from vault metadata (not raw data)
const merkleRoot = commitToVault(vaultRecords);
const commitmentBytes = Buffer.from(merkleRoot, 'hex');
// Call the Compact contract entry point
const contract = new ArtStyleMarketplaceContract();
const deployment = await findContractDeployment(wallet, contract, contractAddress);
await deployment.callTx.registerArtist(
commitmentBytes,
pricePerQuery, // in tDUST
);
console.log('Artist registered with commitment:', merkleRoot);
console.log('Price per query:', pricePerQuery, 'tDUST');
}
export { deployMarketplace, registerArtist };
8. Step 6 Consumer Flow: Pay and Verify
// src/consumer.ts
import { findContractDeployment } from '@midnight-ntwrk/midnight-js-contracts';
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { ArtStyleMarketplaceContract } from '../contracts/managed/art-style-marketplace.compact/contract';
import { runPrivateInference } from './inference';
import { createHash } from 'crypto';
/**
* Consumer flow:
* 1. Look up artist profile on-chain
* 2. Send prompt to artist's service (off-chain)
* 3. Receive output embedding + outputHash from artist
* 4. Call purchaseAndVerify — Midnight verifies the ZK proof
* 5. Confirm the on-chain outputHash matches what artist sent
*/
async function purchaseInference(
contractAddress: string,
artistCommitment: string,
promptEmbedding: number[],
) {
// 1. Connect consumer wallet
const wallet = await WalletBuilder.buildFromSeed(
'http://localhost:9000',
'http://localhost:8080',
/* NetworkId.TestNet, */
process.env.CONSUMER_WALLET_SEED!,
);
await wallet.sync();
const contract = new ArtStyleMarketplaceContract();
const deployment = await findContractDeployment(
wallet, contract, contractAddress
);
// 2. Encode prompt as bytes for the contract
const promptBytes = Buffer.from(JSON.stringify(promptEmbedding));
// 3. Call purchaseAndVerify
// - Midnight routes the DUST payment privately
// - The witness runs on the artist's machine (off-chain)
// - The on-chain circuit verifies commitment + proof
// - Returns the verified outputHash
const verifiedOutputHash = await deployment.callTx.purchaseAndVerify(
Buffer.from(artistCommitment, 'hex'),
promptBytes,
);
console.log(' Proof verified on-chain!');
console.log('Verified output hash:', Buffer.from(verifiedOutputHash).toString('hex'));
return verifiedOutputHash;
}
/**
* After receiving the output embedding from the artist off-chain,
* the consumer can confirm it matches the on-chain verified hash.
*/
function confirmOutputAuthenticity(
outputEmbedding: number[],
verifiedOutputHash: Uint8Array,
): boolean {
const localHash = createHash('sha256')
.update(JSON.stringify(outputEmbedding))
.digest();
const matches = Buffer.from(verifiedOutputHash).equals(localHash);
if (matches) {
console.log(' Output is authentic — hash matches on-chain verification');
} else {
console.log('Output hash mismatch — do not trust this output');
}
return matches;
}
export { purchaseInference, confirmOutputAuthenticity };
9. The Artist Use Case End-to-End
Here is the complete journey for an artist monetising their style on Midnight.
Artist: One-Time Setup
// scripts/artist-setup.ts
import { PrivateVault } from '../src/vault';
import { deployMarketplace, registerArtist } from '../src/deploy';
import { writeFileSync } from 'fs';
async function setup() {
// 1. Create and encrypt the private vault
const vault = new PrivateVault();
const record = {
artistId: 'alice_impressionist',
styleEmbeddings: [/* 512-dim vector from CLIP or fine-tuned model */],
description: 'Watercolour impressionist portfolio',
};
const blob = vault.encrypt(record);
writeFileSync('vault.bin', blob);
writeFileSync('vault.key', vault.getKeyHex(), 'utf8');
console.log('Vault encrypted and saved.');
// 2. Deploy the marketplace contract (once per platform)
const contractAddress = await deployMarketplace();
writeFileSync('contract-address.txt', contractAddress, 'utf8');
// 3. Register artist on-chain with Merkle commitment
await registerArtist(
contractAddress,
[{ embeddingId: 'style_001', shape: [512] }],
BigInt(1_000_000), // 1 tDUST per query
);
console.log('Artist registered on Midnight.');
console.log('Contract:', contractAddress);
}
setup();
Consumer: Per-Query Flow
// scripts/consumer-query.ts
import { purchaseInference, confirmOutputAuthenticity } from '../src/consumer';
import { readFileSync } from 'fs';
async function query() {
const contractAddress = readFileSync('contract-address.txt', 'utf8');
const artistCommitment = process.env.ARTIST_COMMITMENT!;
// Consumer's prompt (e.g., from a text embedding model)
const promptEmbedding = Array.from({ length: 512 }, () => Math.random() - 0.5);
// 1. Call purchaseAndVerify on Midnight
// DUST payment routed privately
// Witness runs on artist's machine (private inference)
// Contract verifies proof on-chain
const verifiedHash = await purchaseInference(
contractAddress,
artistCommitment,
promptEmbedding,
);
// 2. Artist sends output embedding off-chain (e.g., via API)
const outputEmbedding = [/* received from artist's service */];
// 3. Confirm authenticity
const authentic = confirmOutputAuthenticity(outputEmbedding, verifiedHash);
console.log('Authentic:', authentic);
}
query();
What Each Party Knows
| Party | Knows |
|---|---|
| Artist | Everything private vault, style embeddings, model |
| Consumer | Prompt, output embedding, verified output hash |
| Midnight ledger | Merkle commitment, verified output hash, query count |
| Anyone | That artist registered with a given commitment. Nothing else. |
| Model provider | Nothing inference is fully local |
10. Key Takeaways
After working through this tutorial you now understand how to:
Create private context on Midnight Encrypt a vault with AES-256 locally, commit to it with a Merkle root, and register that root on a Midnight Compact contract. The raw data never leaves the artist's device.
Use Compact witnesses for private inference The witness construct in Compact is Midnight's native mechanism for off-chain private computation. The witness runs locally, accesses private inputs, and returns only the cryptographically safe outputs to the on-chain circuit.
Accept shielded payments with DUST Midnight routes DUST transactions with full privacy sender, receiver, and amount are all hidden. Consumers pay for inference without revealing their identity.
Verify outputs without seeing private data The on-chain outputHash gives consumers a cryptographic guarantee that the output was authentically generated from the registered artist's private data.
What to do next:
- Read the full Midnight documentation
- Complete the Midnight Developer Academy
- Join the Midnight Discord — active developer support
- Watch Midnight's YouTube channel for weekly developer sessions (Wednesdays 15:00 UTC)
- Explore the bulletin board example for more Compact patterns
Midnight makes privacy a standard engineering resource not a cryptography research problem. Private AI inference is buildable today, with tools developers already know.

Top comments (0)