Integrating Midnight Proofs into an Existing Node.js Backend
The first time I wired Midnight proofs into an Express API, I designed it the way I'd design any async operation: send request, await response, return result. The endpoint timed out after 30 seconds. The proof server was still running. It finished 12 seconds later.
That experience is what this tutorial is actually about. Proof generation on Midnight takes 5 to 30 seconds under normal conditions — sometimes longer. If you model your REST API around that assumption from the start, you'll save yourself a significant rewrite.
What's Different About Node.js
Browser-based Midnight apps delegate wallet operations to a browser extension that manages keys and signs transactions. There is no window.midnight on a server. You own the key material, you manage the wallet lifecycle, and you provision every provider yourself.
This has implications beyond just "more setup." Your Node.js process becomes a long-lived service that holds wallet state in memory. You need to think about startup time (wallet sync takes a moment), graceful shutdown, and what happens when the proof server goes offline at 3am.
Dependencies
npm install @midnight-ntwrk/wallet \
@midnight-ntwrk/midnight-js-http-client-proof-provider \
@midnight-ntwrk/midnight-js-types \
express
Your tsconfig.json needs "target": "ES2022" and "moduleResolution": "bundler" or "node16". The wallet SDK uses top-level await internally.
Wallet Provider Setup
WalletBuilder.build() is the entry point for server-side wallets. Unlike the browser extension flow, you pass a BIP39 seed phrase directly:
import { WalletBuilder } from '@midnight-ntwrk/wallet';
import { NetworkId } from '@midnight-ntwrk/zswap';
const INDEXER_HTTP = process.env.INDEXER_HTTP_URI!;
const INDEXER_WS = process.env.INDEXER_WS_URI!;
const PROVER_SERVER = process.env.PROVER_SERVER_URI!; // e.g. http://localhost:6300
const SUBSTRATE_NODE = process.env.SUBSTRATE_NODE_URI!;
const SEED_PHRASE = process.env.WALLET_SEED_PHRASE!; // 24-word mnemonic, hex encoded
export async function buildWallet() {
return WalletBuilder.build(
INDEXER_HTTP,
INDEXER_WS,
PROVER_SERVER,
SUBSTRATE_NODE,
SEED_PHRASE,
NetworkId.TestNet,
'info', // log level
true, // discard tx history — saves memory for stateless API servers
);
}
The wallet returned is both a Wallet and a Resource. Call wallet.start() once before handling requests, and wallet.close() on shutdown. Skip either one and you'll see connection leaks or incomplete sync.
Never reconstruct the wallet per request. Wallet initialization involves syncing local state against the indexer — it takes seconds. Build it once at startup and share the instance.
// app.ts
import express from 'express';
import { buildWallet } from './wallet';
let wallet: Awaited<ReturnType<typeof buildWallet>>;
async function main() {
wallet = await buildWallet();
wallet.start();
const app = express();
app.use(express.json());
// routes go here
const server = app.listen(3000, () => {
console.log('API listening on :3000');
});
process.on('SIGTERM', async () => {
server.close();
await wallet.close();
process.exit(0);
});
}
main().catch(console.error);
Proof Generation via httpClientProofProvider
The proof provider is a thin HTTP client that talks to a Midnight proof server. The server exposes two endpoints: /check validates your circuit inputs and /prove generates the actual zero-knowledge proof.
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import type { ZKConfigProvider } from '@midnight-ntwrk/midnight-js-types';
function buildProofProvider(zkConfigProvider: ZKConfigProvider<string>) {
return httpClientProofProvider(
process.env.PROVER_SERVER_URI!,
zkConfigProvider,
{ timeout: 60_000 }, // 60 seconds — enough for most circuits
);
}
The default timeout is 300 seconds (5 minutes). That's conservative for batch jobs, but it will silently hang your API endpoint if a user is waiting. I use 60 seconds for interactive endpoints and 270 seconds for background jobs.
The provider automatically retries on 500 and 503 with exponential backoff: 1 second, 2 seconds, 4 seconds. After three attempts it throws. This retry happens transparently inside proveTx() — you don't need to implement it yourself, but you do need to account for it in your timeout budget. A worst-case three-attempt sequence burns 7 seconds before the first real error surfaces.
The Full Provider Setup
Your contract's CallableContractInstance needs a complete MidnightProviders object. Here's how the pieces fit together:
import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';
interface AppProviders {
wallet: Wallet & Resource;
providers: MidnightProviders;
}
function buildProviders(wallet: Wallet & Resource, zkConfigProvider: ZKConfigProvider<string>): AppProviders['providers'] {
return {
privateStateProvider: wallet, // wallet implements PrivateStateProvider
publicDataProvider: wallet, // wallet implements PublicDataProvider
walletProvider: wallet, // wallet implements WalletProvider
midnightProvider: wallet, // wallet implements MidnightProvider
zkConfigProvider,
proofProvider: httpClientProofProvider(
process.env.PROVER_SERVER_URI!,
zkConfigProvider,
{ timeout: 60_000 },
),
};
}
The wallet implements all four non-proof provider interfaces. The proofProvider is the only one you wire separately.
Transaction Submission
Once you have providers, calling a contract circuit follows this pattern:
import { Contract } from './compiled/contract.cjs'; // your compiled Compact contract
async function callContractCircuit(
providers: MidnightProviders,
contractAddress: string,
...args: Parameters<typeof Contract.prototype.yourCircuit>
) {
const contract = new Contract(providers, contractAddress);
// proveTx runs /check then /prove against the proof server
// This is where the 5-30 second wait happens
const unprovenTx = await contract.yourCircuit(...args);
const provenTx = await providers.proofProvider.proveTx(unprovenTx);
// balanceTx handles fee computation and coin selection
const finalTx = await providers.walletProvider.balanceTx(provenTx);
// submitTx sends to the node
const txId = await providers.midnightProvider.submitTx(finalTx);
return txId;
}
In practice your compiled contract wraps these steps, but understanding the pipeline matters when something breaks — the error will tell you which stage failed.
Exposing Contract Operations as REST Endpoints
The naive implementation blocks your event loop for 30 seconds:
// DON'T do this in production
app.post('/contract/call', async (req, res) => {
const txId = await callContractCircuit(providers, address, req.body.value);
res.json({ txId });
});
This works in development. Under any real load, queued requests pile up and your process looks frozen. The proof server becomes a serial bottleneck.
Job Queue Pattern
Fire proof generation into a job queue and return a job ID immediately:
import { randomUUID } from 'crypto';
// In-memory for illustration; use Redis + BullMQ in production
const jobs = new Map<string, { status: 'pending' | 'done' | 'failed'; result?: string; error?: string }>();
app.post('/contract/call', async (req, res) => {
const jobId = randomUUID();
jobs.set(jobId, { status: 'pending' });
// Don't await — let it run in background
callContractCircuit(providers, contractAddress, req.body.value)
.then(txId => jobs.set(jobId, { status: 'done', result: txId }))
.catch(err => jobs.set(jobId, { status: 'failed', error: err.message }));
res.status(202).json({ jobId });
});
app.get('/contract/job/:id', (req, res) => {
const job = jobs.get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
res.json(job);
});
The client polls /contract/job/:id until status is done or failed. This is the same pattern you'd use for any slow async operation — the 30-second proof window just makes it non-optional.
Error Handling
Three failure modes are worth handling explicitly.
Proof Timeouts
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
async function proveWithTimeout(providers: MidnightProviders, unprovenTx: UnprovenTransaction) {
try {
return await providers.proofProvider.proveTx(unprovenTx);
} catch (err: any) {
// Timeout: "Failed Proof Server response: ... code="408""
// or the fetch itself aborts
if (err.message?.includes('Failed Proof Server response') ||
err.name === 'AbortError') {
throw new Error('Proof generation timed out. The proof server may be overloaded — retry in 30 seconds.');
}
throw err;
}
}
The actual error message looks like: Failed Proof Server response: url="http://localhost:6300/prove", code="500", status="Internal Server Error". Parse for that pattern.
Proof Server Unreachable
async function proveWithHealthCheck(
proofServerUri: string,
providers: MidnightProviders,
unprovenTx: UnprovenTransaction,
) {
// Cheap health check before committing to a long prove call
try {
await fetch(`${proofServerUri}/check`, { method: 'HEAD', signal: AbortSignal.timeout(2000) });
} catch {
throw new Error('Proof server is unreachable. Check PROVER_SERVER_URI and ensure the service is running.');
}
return providers.proofProvider.proveTx(unprovenTx);
}
This costs 2 seconds but saves 60 seconds of waiting for a doomed prove request. I only add it when the proof server lives on a different host — localhost connectivity is reliable enough.
Invalid Circuit Inputs
The /check endpoint runs before /prove and validates your inputs. If it rejects them, you get an error before any expensive proof work happens:
// The error surfaces from proveTx() — it calls /check internally before /prove
try {
const provenTx = await providers.proofProvider.proveTx(unprovenTx);
} catch (err: any) {
if (err.message?.includes('code="400"')) {
// Invalid circuit inputs — this is a bug in your transaction construction, not a transient error
// Don't retry; surface immediately
return res.status(400).json({ error: 'Invalid transaction inputs', detail: err.message });
}
throw err;
}
The 400 is not retryable. The 500 is (and the provider already retries it three times for you). The timeout is retryable after a delay.
Putting It Together
// Full Express handler with job queue and error handling
app.post('/api/mint', async (req, res) => {
const { amount } = req.body;
if (typeof amount !== 'number' || amount <= 0) {
return res.status(400).json({ error: 'amount must be a positive number' });
}
const jobId = randomUUID();
jobs.set(jobId, { status: 'pending' });
(async () => {
try {
const unprovenTx = await contract.mint(BigInt(amount));
const provenTx = await proveWithTimeout(providers, unprovenTx);
const finalTx = await providers.walletProvider.balanceTx(provenTx);
const txId = await providers.midnightProvider.submitTx(finalTx);
jobs.set(jobId, { status: 'done', result: txId });
} catch (err: any) {
jobs.set(jobId, { status: 'failed', error: err.message });
}
})();
res.status(202).json({ jobId, pollUrl: `/api/job/${jobId}` });
});
What to Monitor
Once this is in production, two metrics matter most:
-
Proof duration: log
Date.now()before and afterproveTx(). P99 above 20 seconds means your proof server is undersized or your circuits are complex. P99 above 45 seconds means you'll start hitting timeout errors. - Proof server error rate: track 500/503 responses separately from timeouts. A burst of 503s usually means the server is restarting; timeouts usually mean CPU saturation.
The proof server is the rate limiter for your entire application. Size it accordingly.
Questions or issues with this tutorial? Comment on contributor-hub issue #311.
Top comments (0)