Skip to main content
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.

OptionWho builds the proofWho can read the private inputsHow a DApp uses it
Local proof serverA container on the user's machineOnly that machinehttpClientProofProvider('http://localhost:6300', zkConfigProvider)
Wallet proving providerWhatever the wallet is set up to useThe wallet, and any proof server it forwards todappConnectorProofProvider(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 is http://localhost:6300 on 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​

  1. 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.3

    Don'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.

  2. Check that it answers. The first start takes longer because it downloads the public parameters.

    curl http://localhost:6300/version
    8.1.3
  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
  4. 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 FetchZkConfigProvider from @midnight-ntwrk/midnight-js-fetch-zk-config-provider to load the same files over HTTP.

  5. Pass proofProvider and zkConfigProvider in the providers object you give to deployContract or findDeployedContract.

Verification​

The proof server reports the version you pinned, and its worker pool is idle and ready for requests.

proof-server.test.ts
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 keys and zkir folders served by your web app, so FetchZkConfigProvider can load them.

Procedure​

  1. 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
  2. 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);
    }
  3. Use the result as proofProvider in your providers object.

  4. Don't read the proof server address from getConfiguration(). The DApp Connector API deprecates the proverServerUri field in favor of getProvingProvider(), 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.

src/vault.compact
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.

wallet-proving.test.ts
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)

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 variableFlagDefaultWhat it sets
MIDNIGHT_PROOF_SERVER_PORT--port6300The 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--verboseoffDebug logging, which includes every request body.
MIDNIGHT_PROOF_SERVER_NUM_WORKERS--num-workers2How many proofs are built at the same time.
MIDNIGHT_PROOF_SERVER_JOB_CAPACITY--job-capacity0How many jobs can wait. 0 means no limit.
MIDNIGHT_PROOF_SERVER_JOB_TIMEOUT--job-timeout600Seconds before the server discards a job.
MIDNIGHT_PROOF_SERVER_NO_FETCH_PARAMS--no-fetch-paramsoffSkips the downloads at startup. The server fetches each file the first time it needs it instead.
MIDNIGHT_PARAM_SOURCEnonehttps://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.

EndpointMethodWhat it does
/healthGETReturns {"status":"ok","timestamp":"..."} when the server is up. / returns the same response.
/versionGETReturns the server version as plain text, for example 8.1.3.
/readyGETReturns status, jobsProcessing, jobsPending, and jobCapacity. Answers 503 with "status":"busy" when the queue is full.
/proof-versionsGETReturns the proof versions the server produces, ["V2"] in 8.1.3.
/checkPOSTRuns a proof preimage against its circuit without building a proof.
/provePOSTBuilds the proof for one preimage.
/prove-txPOSTProves a whole transaction. Deprecated. Midnight.js doesn't use it.
/kPOSTReturns the size parameter k for a circuit's ZKIR.
/fetch-params/{k}GETDownloads 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 seesWhat it means
request to http://localhost:6300/check failedNothing 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​