For the complete documentation index, see llms.txt
Proving transactions locally
Every Midnight transaction that calls a contract, spends shielded tokens, or pays a fee in DUST carries zero-knowledge (ZK) proofs. Something has to build those proofs, and whatever builds them sees your private inputs. That is why the Midnight Foundation recommends proving on the user's own machine. This guide is for DApp developers. It shows what a proof server receives and why remote proving isn't recommended. It then walks you through connecting a DApp to a local proof server and to the wallet's proving provider. It also lists the proof server's endpoints and settings.
This guide describes proof server 8.1.3, Compact toolchain 0.31.1, Midnight.js 4.1.1, DApp Connector API 4.0.1, and Lace 2.4.2. All but Lace are the versions in the compatibility matrix at the time of writing.
To start a proof server from Docker Desktop as a wallet user, see Run the proof server.
Prerequisites
These apply to every procedure in this guide:
- Docker Desktop installed and running
- Node.js v22 or later
- A contract compiled with Compact toolchain 0.31.1, including its proving keys. See install the Midnight toolchain. Toolchain 0.31.1 pairs with Compact runtime 0.16.0 and the ledger 8 networks. Toolchains 0.34.0 and 0.35.0 compile for ledger 9, which the networks in the compatibility matrix don't run.
What a proof server receives
The proof server is an HTTP service that turns a proof request into a ZK proof. The request holds everything the proof is about, including the values the proof keeps hidden from the network. Whoever operates the proof server can read those values, so choosing a proof server is a decision about who you trust with them.
Contract calls. When your DApp calls a circuit, Midnight.js runs the circuit on the user's machine. It collects the inputs, the witness values, and the public transcript into a proof preimage. The proof provider sends that preimage to the proof server, together with the circuit's prover key, verifier key, and ZKIR. The witness values travel in the clear inside the preimage.
Wallet proofs. The wallet proves its own parts of a transaction, such as shielded inputs and the DUST spend that pays the fee. For a shielded spend, the preimage includes the coin secret key for the coin. For a DUST spend, it includes the DUST secret key. A proof server that handles these requests can read those keys.
Logs. With verbose logging on, the proof server writes every request body to its log as hex. Keep verbose logging for debugging on your own machine.
Network exposure. The proof server has no authentication and no TLS. It listens on every interface inside its container and accepts cross-origin requests from any web page. Publish its port on 127.0.0.1 only, as the commands in this guide do.
The proof server contacts one outside host, https://srs.midnight.network/. It downloads public proving parameters and the keys for the built-in shielded and DUST circuits from there. It fetches a standard set at startup, and it fetches the parameters for any other circuit size the first time it proves one. These files are public and the same for everyone. Your proof requests aren't part of that traffic.
Proving options
Use this table to pick between the two supported setups. The local proof server is the default on every network, including Preprod and Mainnet. Midnight's public networks give you a node and an indexer. They don't give you a proof server. See Networks and environments.
| Option | Who builds the proof | Who can read the private inputs | How a DApp uses it |
|---|---|---|---|
| Local proof server | A container on the user's machine | Only that machine | httpClientProofProvider('http://localhost:6300', zkConfigProvider) |
| Wallet proving provider | Whatever the wallet is set up to use | The wallet, and any proof server it forwards to | dappConnectorProofProvider(api, zkConfigProvider, costModel) |
A proof server anywhere else is a remote proof server. The Midnight Foundation doesn't recommend one outside a trusted execution environment (TEE), for the reasons in Why remote proving is not recommended.
The wallet proving provider is an interface, so what happens behind it depends on the wallet:
- Lace. In Lace 2.4.2,
getProvingProvider()returns a provider that sends requests to the proof server address in the wallet's settings. That address ishttp://localhost:6300on every network, and the local proof server is the only choice the settings offer. A Lace user needs a local proof server running with either option. - Other wallets. A wallet can prove in its own process instead, in a browser with the wallet SDK's WebAssembly prover in a Web Worker, or natively on a mobile device. Check the wallet's own documentation to see where it proves.
Proof server 8.1.3 produces one proof version, V2, which you can confirm at /proof-versions.
How a transaction gets its proofs
A contract call needs proofs from two places. Your DApp is responsible for the proof of the circuit it called. The wallet is responsible for the proofs that balance the transaction and pay its fee. Knowing which is which tells you what each proving option covers.
The DApp's proof. Midnight.js runs the circuit locally and passes the unproven transaction to the proofProvider in your providers object. That provider checks and proves the circuit through whichever option you chose. See Deploying and operating for the full pipeline.
The wallet's proofs. Your DApp then hands the transaction to the wallet for balancing. The wallet adds its inputs and the DUST spend, and proves them with its own prover. Your DApp doesn't choose where this happens.
Delegating to the wallet moves only the first of these. Your DApp still runs the circuit and computes the witness values. It gives the finished preimage to the wallet's provider instead of sending it to a URL you name. The wallet's provider asks your DApp for the prover key, verifier key, and ZKIR of each circuit as it needs them.
Connecting a DApp to a local proof server
Start a proof server on your machine and point Midnight.js at it. This is the setup for Node.js scripts, tests, and command-line tools. It's also the fallback for a browser DApp when the wallet has no proving provider.
Procedure
-
Start the proof server. Use the version that the compatibility matrix lists for your network.
docker run -d --name proof-server -p 127.0.0.1:6300:6300 midnightntwrk/proof-server:8.1.3Don't add arguments after the image name. The image doesn't pass them to the proof server. To change a setting, pass an environment variable from Proof server settings with
-e. -
Check that it answers. The first start takes longer because it downloads the public parameters.
curl http://localhost:6300/version8.1.3 -
Install the proof provider and a ZK config provider.
npm install @midnight-ntwrk/midnight-js-http-client-proof-provider@4.1.1 \@midnight-ntwrk/midnight-js-node-zk-config-provider@4.1.1 -
Create the proof provider. The ZK config provider reads the prover key, verifier key, and ZKIR that the compiler wrote for each circuit.
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';const zkConfigProvider = new NodeZkConfigProvider<'bump'>('./src/managed/vault');const proofProvider = httpClientProofProvider('http://localhost:6300', zkConfigProvider);In a browser, use
FetchZkConfigProviderfrom@midnight-ntwrk/midnight-js-fetch-zk-config-providerto load the same files over HTTP. -
Pass
proofProviderandzkConfigProviderin the providers object you give todeployContractorfindDeployedContract.
Verification
The proof server reports the version you pinned, and its worker pool is idle and ready for requests.
import { describe, expect, it } from 'vitest';
const PROOF_SERVER = process.env.PROOF_SERVER ?? 'http://localhost:6300';
describe('local proof server', () => {
it('runs the pinned version', async () => {
const version = await (await fetch(`${PROOF_SERVER}/version`)).text();
expect(version).toBe('8.1.3');
});
it('is ready with an idle worker pool', async () => {
const response = await fetch(`${PROOF_SERVER}/ready`);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ status: 'ok', jobsProcessing: 0, jobsPending: 0 });
});
});
✓ proof-server.test.ts > local proof server > runs the pinned version
✓ proof-server.test.ts > local proof server > is ready with an idle worker pool
Test Files 1 passed (1)
Tests 2 passed (2)
Delegating proving to the wallet
In a browser DApp, ask the connected wallet for its proving provider, so the user's wallet decides where proofs are made. Not every wallet version has getProvingProvider(), and a wallet that has it can still reject the call. Check for the method and fall back to a local proof server instead of assuming either one.
Prerequisites
- A connected wallet API from the DApp Connector. See Connect a React app to a wallet.
- The compiled contract's
keysandzkirfolders served by your web app, soFetchZkConfigProvidercan load them.
Procedure
-
Install the wallet proof provider next to the HTTP one.
npm install @midnight-ntwrk/midnight-js-dapp-connector-proof-provider@4.1.1 \@midnight-ntwrk/midnight-js-http-client-proof-provider@4.1.1 \@midnight-ntwrk/midnight-js-fetch-zk-config-provider@4.1.1 -
Build the proof provider from the wallet when it offers one, and from the local proof server when it doesn't.
import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';import { CostModel } from '@midnight-ntwrk/ledger-v8';import { dappConnectorProofProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';import type { ProofProvider } from '@midnight-ntwrk/midnight-js-types';export async function proofProviderFor(api: ConnectedAPI): Promise<ProofProvider> {const zkConfigProvider = new FetchZkConfigProvider<'bump'>(window.location.origin, fetch.bind(window));if (typeof api.getProvingProvider === 'function') {try {return await dappConnectorProofProvider(api, zkConfigProvider, CostModel.initialCostModel());} catch (error) {console.warn('The wallet did not return a proving provider, using the local proof server instead.', error);}}return httpClientProofProvider('http://localhost:6300', zkConfigProvider);} -
Use the result as
proofProviderin your providers object. -
Don't read the proof server address from
getConfiguration(). The DApp Connector API deprecates theproverServerUrifield in favor ofgetProvingProvider(), and a wallet can leave it out.
Verification
The test below runs a circuit with a secret witness and proves it through dappConnectorProvingProvider. A stub stands in for the wallet. Like Lace, the stub forwards to a proof server that it chooses, and it loads the circuit's key material from the DApp. The DApp side never names a proof server.
pragma language_version >= 0.22;
import CompactStandardLibrary;
export ledger owner: Bytes<32>;
export ledger round: Counter;
witness secretKey(): Bytes<32>;
constructor() {
owner = disclose(persistentHash<Vector<2, Bytes<32>>>([pad(32, "vault:pk"), secretKey()]));
}
export circuit bump(): [] {
const pk = persistentHash<Vector<2, Bytes<32>>>([pad(32, "vault:pk"), secretKey()]);
assert(disclose(pk) == owner, "not the owner");
round.increment(1);
}
Compile it with compact compile +0.31.1 src/vault.compact src/managed/vault, then run the test with a local proof server up. The +0.31.1 picks the toolchain that matches Compact runtime 0.16.0 and ledger 8. If it isn't installed, compact update 0.31.1 --no-set-default adds it without changing your default.
import { describe, expect, it } from 'vitest';
import * as runtime from '@midnight-ntwrk/compact-runtime';
import type { KeyMaterialProvider, WalletConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import { dappConnectorProvingProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';
import { httpClientProvingProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import {
createProverKey,
createVerifierKey,
createZKIR,
ZKConfigProvider,
} from '@midnight-ntwrk/midnight-js-types';
import { Contract } from './src/managed/vault/contract/index.js';
const PROOF_SERVER = process.env.PROOF_SERVER ?? 'http://localhost:6300';
const zkConfigProvider = new NodeZkConfigProvider<'bump'>('./src/managed/vault');
// Run the circuit locally. The secret key is a witness, so it ends up in the proof preimage.
const secretKey = new Uint8Array(32).fill(7);
const contract = new Contract({
secretKey: ({ privateState }: { privateState: unknown }): [unknown, Uint8Array] => [privateState, secretKey],
});
const coinPublicKey = '0'.repeat(64);
const deployed = contract.initialState(runtime.createConstructorContext({}, coinPublicKey));
const { proofData } = contract.impureCircuits.bump(
runtime.createCircuitContext(
runtime.dummyContractAddress(),
coinPublicKey,
deployed.currentContractState,
deployed.currentPrivateState,
),
);
const preimage = runtime.proofDataIntoSerializedPreimage(
proofData.input,
proofData.output,
proofData.publicTranscript,
proofData.privateTranscriptOutputs,
'bump',
);
describe('wallet proving', () => {
it('proves through the wallet without naming a proof server', async () => {
// Stands in for a wallet. It does what Lace 2.4.2 does: forward to the
// proof server in the wallet's own settings, using the DApp's key material.
const requested: string[] = [];
const wallet: Pick<WalletConnectedAPI, 'getProvingProvider'> = {
getProvingProvider: async (keyMaterialProvider: KeyMaterialProvider) => {
class WalletZkConfig extends ZKConfigProvider<string> {
getZKIR = async (id: string) => {
requested.push(id);
return createZKIR(await keyMaterialProvider.getZKIR(id));
};
getProverKey = async (id: string) => createProverKey(await keyMaterialProvider.getProverKey(id));
getVerifierKey = async (id: string) => createVerifierKey(await keyMaterialProvider.getVerifierKey(id));
}
return httpClientProvingProvider(PROOF_SERVER, new WalletZkConfig());
},
};
const provingProvider = await dappConnectorProvingProvider(wallet, zkConfigProvider);
const proof = await provingProvider.prove(preimage, 'bump');
expect(proof.length).toBeGreaterThan(0);
expect(requested).toContain('bump');
}, 120_000);
});
✓ wallet-proving.test.ts > wallet proving > proves through the wallet without naming a proof server
Test Files 1 passed (1)
Tests 1 passed (1)
Why remote proving is not recommended
A remote proof server is one that doesn't run on the user's machine, whether you operate it or someone else does. The Midnight Foundation doesn't recommend proving on one, in any current form, outside a trusted execution environment (TEE).
The reason is what the request contains. A contract call sends every witness value in the clear, and the wallet's shielded and DUST spends send the coin secret key and the DUST secret key. HTTPS protects a request on its way to the server, but the server decrypts it and works on the plain values. Whoever operates the server, or compromises it, can read spending keys, keep witness data to deanonymize users later, or change a preimage before proving it. A proof server has no way to show a client that it runs unmodified code, so a client can't tell an honest server from one that logs everything.
MPS-0004 sets out these problems in full. It also sets the goals for a proof server that runs inside a TEE and proves, through hardware attestation, that it runs a published build. For now, prove locally or through the wallet, and keep any proof server's port off the public network.
Workers and queueing
The proof server proves a fixed number of requests at a time and queues the rest. Knowing how the queue behaves helps you read /ready and choose settings for a server that more than one process uses.
Workers. The number of workers is the number of proofs built at the same time. The default is two. Each /check and /prove request is one job. A job that arrives while every worker is busy waits as pending.
Queue limit. The job capacity is the number of pending jobs the server allows. The default in 8.1.3 is 0, which means no limit. With a limit set, a request that arrives while the queue is full gets HTTP 429 with the message Job Queue full. While the queue is full, /ready answers HTTP 503 with "status":"busy".
Client behavior. httpClientProofProvider waits up to five minutes for each request by default. It retries a request up to three times after HTTP 500 or 503. It doesn't retry after 429, so your code sees that error and decides when to try again.
Workers and speed. More workers let more proofs run side by side. They don't make a single proof faster, and the proofs share the same processor cores and memory. Proving time and memory grow with the size of the circuit. In one test, proving a circuit with a k value of 17 took about 1.5 GiB of memory in the container. To size a server, time your own circuits: the proof server logs how long each request took.
POST /prove HTTP/1.1; took 0.405531s
The startup line starting service: "actix-web-service-0.0.0.0:6300", workers: 10 counts HTTP workers, which follow the number of processor cores. It isn't the number of proving workers.
Proof server settings
These are the settings in proof server 8.1.3. With the Docker image, set them as environment variables, for example -e MIDNIGHT_PROOF_SERVER_NUM_WORKERS=4.
| Environment variable | Flag | Default | What it sets |
|---|---|---|---|
MIDNIGHT_PROOF_SERVER_PORT | --port | 6300 | The port the server listens on. The Docker image starts the proof server with --port $PORT, so this variable has no effect there. Leave the port alone and change the host side of -p instead. |
MIDNIGHT_PROOF_SERVER_VERBOSE | --verbose | off | Debug logging, which includes every request body. |
MIDNIGHT_PROOF_SERVER_NUM_WORKERS | --num-workers | 2 | How many proofs are built at the same time. |
MIDNIGHT_PROOF_SERVER_JOB_CAPACITY | --job-capacity | 0 | How many jobs can wait. 0 means no limit. |
MIDNIGHT_PROOF_SERVER_JOB_TIMEOUT | --job-timeout | 600 | Seconds before the server discards a job. |
MIDNIGHT_PROOF_SERVER_NO_FETCH_PARAMS | --no-fetch-params | off | Skips the downloads at startup. The server fetches each file the first time it needs it instead. |
MIDNIGHT_PARAM_SOURCE | none | https://srs.midnight.network/ | Where the server downloads public parameters and built-in keys from. |
The proof server keeps what it downloads in /.cache/midnight inside the container. Mount a volume there, for example -v midnight-zk-params:/.cache/midnight, and later containers start from the cache without downloading anything. MIDNIGHT_PROOF_SERVER_NO_FETCH_PARAMS=true skips the download at startup, but it doesn't make the proof server work offline. Anything missing from the cache downloads the first time a proof needs it.
Proof server endpoints
These are the HTTP endpoints in proof server 8.1.3. Midnight.js and the wallet SDK both call /check and /prove. The other endpoints are for health checks and tooling.
| Endpoint | Method | What it does |
|---|---|---|
/health | GET | Returns {"status":"ok","timestamp":"..."} when the server is up. / returns the same response. |
/version | GET | Returns the server version as plain text, for example 8.1.3. |
/ready | GET | Returns status, jobsProcessing, jobsPending, and jobCapacity. Answers 503 with "status":"busy" when the queue is full. |
/proof-versions | GET | Returns the proof versions the server produces, ["V2"] in 8.1.3. |
/check | POST | Runs a proof preimage against its circuit without building a proof. |
/prove | POST | Builds the proof for one preimage. |
/prove-tx | POST | Proves a whole transaction. Deprecated. Midnight.js doesn't use it. |
/k | POST | Returns the size parameter k for a circuit's ZKIR. |
/fetch-params/{k} | GET | Downloads the public parameters for one k. Not available when the startup downloads are off. |
The proving endpoints take and return binary bodies. These are the errors a DApp most often sees:
| What the DApp sees | What it means |
|---|---|
request to http://localhost:6300/check failed | Nothing is listening at the proof server address. The client retries the connection three times, so the error arrives after about seven seconds. The reason after failed is empty when the address is localhost, and connect ECONNREFUSED 127.0.0.1:6300 when it is 127.0.0.1. Start the proof server, or fix the address. |
Failed Proof Server response: ... code="400" | The server rejected the request. One cause is keys that don't match the circuit, so recompile the contract and reload its keys. The proof server log has the reason. |
Failed Proof Server response: ... code="429" | The queue is full. Wait and send the request again. |
Additional resources
- Run the proof server: start the proof server from Docker Desktop.
- Networks and environments: endpoints and network IDs for every network, and running a local network.
- Funding a wallet and Managing DUST: get tNIGHT and generate the tDUST that pays fees.
- Deploying and operating: where the proof provider sits among the other Midnight.js providers.
- Proving and private data: the proving trust boundary next to the other security decisions.
- MPS-0004: Trustworthy Delegated Proof Generation for Privacy-Preserving Transactions: the problems with remote proving in full, and the goals for a TEE-based proof server.
- Midnight.js SDK: the proof provider packages.
- DApp Connector API reference:
getProvingProvider()and the rest of the wallet API. - Compatibility matrix: the proof server version tested with each network.