Loading...
Loading...
Build a single Midnight dApp that deploys and runs across all networks (undeployed/local, preview, preprod, mainnet). Covers local Docker stack setup via midnight-local-dev, environment config, provider routing, wallet abstraction, proof server selection, ZK asset resolution, contract registry, and DUST flow differences per network. Use when a user wants to target multiple networks from one codebase, set up a local dev environment, or understand what changes between undeployed/preprod/preview/mainnet.
npx skill4agent add kali-decoder/midnight-skills multinetworkgithub.com/midnightntwrk/midnight-local-devgithub.com/midnightntwrk/example-counter| Network | | Use for | Proof server | DUST flow |
|---|---|---|---|---|
| | Local dev, CI | Local Docker | Manual registration (genesis wallet seeds funded) |
| | Active development on testnet | 1AM ProofStation ( | Sponsored — user pays 0 fees |
| | Pre-release / integration testing | Local Docker | Manual registration (faucet → NIGHT → DUST) |
| | Production | 1AM ProofStation ( | Sponsored — user pays 0 fees |
previewmainnetundeployedpreprodundeployedundeployedmidnight-local-devgithub.com/midnightntwrk/midnight-local-dev
git clone https://github.com/midnightntwrk/midnight-local-dev
cd midnight-local-dev
npm install
npm start
npm start0x00...01networkId: undeployed
indexer: http://localhost:8088/api/v3/graphql
indexerWS: ws://localhost:8088/api/v3/graphql/ws
node: http://localhost:9944
proofServer: http://localhost:6300/api/v3/graphql# From midnight-local-dev repo root:
docker compose -f standalone.yml up -d
# Check all three services are healthy
docker compose -f standalone.yml ps
# View logs
docker compose -f standalone.yml logs -f
# Tear down
docker compose -f standalone.yml down| Service | Image | Version |
|---|---|---|
| Node | | |
| Indexer | | |
| Proof Server | | |
undeployedlocalhost:9944localhost:8088localhost:6300# Option 1: Fund from a JSON file (gets NIGHT + DUST registered)
# accounts.json: { "accounts": [{ "name": "Alice", "mnemonic": "..." }] }
# (max 10 accounts, 50,000 NIGHT each)
# Option 2: Fund by Bech32 address (NIGHT only — DUST not registered)
# Use when you have a Lace address or DApp-generated address0x0000000000000000000000000000000000000000000000000000000000000001// src/config/networks.ts
import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
export type NetworkId = 'undeployed' | 'preview' | 'preprod' | 'mainnet';
// 'sponsored' = 1AM ProofStation pays fees (preview, mainnet)
// 'manual' = you run a proof server, user registers NIGHT→DUST (undeployed, preprod)
export type DustMode = 'sponsored' | 'manual';
export interface NetworkConfig {
networkId: NetworkId;
indexerHttp: string;
indexerWs: string;
rpc: string;
proofServerUrl: string;
dustMode: DustMode;
faucetUrl?: string;
}
export const NETWORKS: Record<NetworkId, NetworkConfig> = {
undeployed: {
networkId: 'undeployed',
indexerHttp: 'http://localhost:8088/api/v3/graphql', // NOTE: v3, not v4
indexerWs: 'ws://localhost:8088/api/v3/graphql/ws',
rpc: 'ws://localhost:9944',
proofServerUrl: 'http://127.0.0.1:6300',
dustMode: 'manual',
},
preview: {
networkId: 'preview',
indexerHttp: 'https://indexer.preview.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.preview.midnight.network/api/v4/graphql/ws',
rpc: 'wss://rpc.preview.midnight.network',
proofServerUrl: 'https://api-preview.1am.xyz', // 1AM ProofStation
dustMode: 'sponsored',
},
preprod: {
networkId: 'preprod',
indexerHttp: 'https://indexer.preprod.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws',
rpc: 'https://rpc.preprod.midnight.network',
proofServerUrl: 'http://127.0.0.1:6300', // local proof server (run via Docker)
dustMode: 'manual',
faucetUrl: 'https://faucet.preprod.midnight.network',
},
mainnet: {
networkId: 'mainnet',
indexerHttp: 'https://indexer.mainnet.midnight.network/api/v4/graphql',
indexerWs: 'wss://indexer.mainnet.midnight.network/api/v4/graphql/ws',
rpc: 'https://rpc.mainnet.midnight.network',
proofServerUrl: 'https://api.1am.xyz', // 1AM ProofStation
dustMode: 'sponsored',
},
};
export function getActiveNetwork(): NetworkConfig {
const id = (
process.env.NETWORK ||
process.env.VITE_NETWORK ||
'undeployed'
) as NetworkId;
const config = NETWORKS[id];
if (!config) throw new Error(`Unknown network: "${id}". Valid: ${Object.keys(NETWORKS).join(', ')}`);
return config;
}
// Call this once at startup before any SDK operations
export function applyNetworkId(config: NetworkConfig) {
setNetworkId(config.networkId);
}// src/config/providers.ts
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import type { NetworkConfig } from './networks';
// ── ZK Config Provider ──────────────────────────────────────────────────────
// Node.js: load ZK assets from filesystem
export function makeNodeZkConfigProvider(zkConfigPath: string) {
const { NodeZkConfigProvider } = require('@midnight-ntwrk/midnight-js-node-zk-config-provider');
const path = require('path');
return new NodeZkConfigProvider(path.resolve(zkConfigPath));
}
// Browser: load ZK assets via fetch from CDN or public/
export function makeFetchZkConfigProvider(zkAssetBasePath: string) {
const { FetchZkConfigProvider } = require('@midnight-ntwrk/midnight-js-fetch-zk-config-provider');
return new FetchZkConfigProvider(
new URL(zkAssetBasePath, window.location.origin).toString(),
window.fetch.bind(window),
);
}
// ── Proof Provider ───────────────────────────────────────────────────────────
// Node.js / self-hosted: points at local Docker proof server or ProofStation URL
// For ProofStation (preview/mainnet headless), pass the 1AM endpoint + API key
export function makeHttpProofProvider(proofServerUrl: string, zkConfigProvider: any) {
const { httpClientProofProvider } = require('@midnight-ntwrk/midnight-js-http-client-proof-provider');
return httpClientProofProvider(new URL(proofServerUrl), zkConfigProvider);
}
// Browser + 1AM wallet: proving handled by the wallet extension (no proof server needed)
export function make1AMProofProvider(provingProvider: any) {
const { createProofProvider } = require('@midnight-ntwrk/midnight-js-types');
return createProofProvider(provingProvider);
}
// ── Public Data Provider ─────────────────────────────────────────────────────
export function makePublicDataProvider(network: NetworkConfig) {
return indexerPublicDataProvider(network.indexerHttp, network.indexerWs);
}
// ── Private State Provider ───────────────────────────────────────────────────
// Node.js: persistent LevelDB store (survives restarts)
export function makeLevelPrivateStateProvider(storeName: string, walletProvider: any) {
const { levelPrivateStateProvider } = require('@midnight-ntwrk/midnight-js-level-private-state-provider');
return levelPrivateStateProvider({ privateStateStoreName: storeName, walletProvider });
}
// Browser: in-memory (lost on page refresh — acceptable for browser DApps)
export function makeInMemoryPrivateStateProvider() {
let scope = '';
const stateStore = new Map<string, unknown>();
const signingKeyStore = new Map<string, unknown>();
const key = (id: string) => `${scope}:${id}`;
return {
setContractAddress(address: string) { scope = address; },
async set(id: string, state: unknown) { stateStore.set(key(id), state); },
async get(id: string) { return stateStore.get(key(id)) ?? null; },
async remove(id: string) { stateStore.delete(key(id)); },
async clear() { stateStore.clear(); },
async setSigningKey(addr: string, k: unknown) { signingKeyStore.set(addr, k); },
async getSigningKey(addr: string) { return signingKeyStore.get(addr) ?? null; },
async removeSigningKey(addr: string) { signingKeyStore.delete(addr); },
async clearSigningKeys() { signingKeyStore.clear(); },
async exportPrivateStates(): Promise<never> { throw new Error('Not implemented'); },
async importPrivateStates(): Promise<never> { throw new Error('Not implemented'); },
async exportSigningKeys(): Promise<never> { throw new Error('Not implemented'); },
async importSigningKeys(): Promise<never> { throw new Error('Not implemented'); },
};
}signTransactionIntents// src/config/wallet-provider.ts
import * as Rx from 'rxjs';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import type { WalletProvider, MidnightProvider } from '@midnight-ntwrk/midnight-js-types';
/**
* Workaround for wallet SDK bug: signRecipe hardcodes 'pre-proof' marker,
* but proven (UnboundTransaction) intents contain 'proof' data → "Failed to clone intent".
* Call with proofMarker='proof' for baseTransaction, 'pre-proof' for balancingTransaction.
*/
function signTransactionIntents(
tx: { intents?: Map<number, any> },
signFn: (payload: Uint8Array) => ledger.Signature,
proofMarker: 'proof' | 'pre-proof',
): void {
if (!tx.intents || tx.intents.size === 0) return;
for (const segment of tx.intents.keys()) {
const intent = tx.intents.get(segment);
if (!intent) continue;
const cloned = ledger.Intent.deserialize<
ledger.SignatureEnabled, ledger.Proofish, ledger.PreBinding
>('signature', proofMarker, 'pre-binding', intent.serialize());
const signature = signFn(cloned.signatureData(segment));
if (cloned.fallibleUnshieldedOffer) {
const sigs = cloned.fallibleUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) =>
cloned.fallibleUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.fallibleUnshieldedOffer = cloned.fallibleUnshieldedOffer.addSignatures(sigs);
}
if (cloned.guaranteedUnshieldedOffer) {
const sigs = cloned.guaranteedUnshieldedOffer.inputs.map(
(_: ledger.UtxoSpend, i: number) =>
cloned.guaranteedUnshieldedOffer!.signatures.at(i) ?? signature,
);
cloned.guaranteedUnshieldedOffer = cloned.guaranteedUnshieldedOffer.addSignatures(sigs);
}
tx.intents.set(segment, cloned);
}
}
export async function makeWalletAndMidnightProvider(
walletFacade: any,
shieldedSecretKeys: ledger.ZswapSecretKeys,
dustSecretKey: ledger.DustSecretKey,
unshieldedKeystore: any,
): Promise<WalletProvider & MidnightProvider> {
// state() is an Observable — must subscribe, not access as property
const state = await Rx.firstValueFrom(
walletFacade.state().pipe(Rx.filter((s: any) => s.isSynced)),
);
const signFn = (payload: Uint8Array) => unshieldedKeystore.signData(payload);
return {
getCoinPublicKey() {
return state.shielded.coinPublicKey.toHexString();
},
getEncryptionPublicKey() {
return state.shielded.encryptionPublicKey.toHexString();
},
async balanceTx(tx: any, ttl?: Date) {
const recipe = await walletFacade.balanceUnboundTransaction(
tx,
{ shieldedSecretKeys, dustSecretKey },
{ ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
);
// Apply signTransactionIntents workaround on both tx parts
signTransactionIntents(recipe.baseTransaction, signFn, 'proof');
if (recipe.balancingTransaction) {
signTransactionIntents(recipe.balancingTransaction, signFn, 'pre-proof');
}
return walletFacade.finalizeRecipe(recipe);
},
async submitTx(tx: any) {
return walletFacade.submitTransaction(tx) as any;
},
};
}undeployed → manual: register NIGHT UTXOs → wait for DUST to accrue
preview → sponsored: 1AM ProofStation pays all fees → user needs nothing
preprod → manual: faucet NIGHT → register UTXOs → wait for DUST
mainnet → sponsored: 1AM ProofStation pays all fees → user needs nothing// src/config/dust.ts
import * as Rx from 'rxjs';
import type { NetworkConfig } from './networks';
export async function ensureDust(
walletFacade: any,
unshieldedKeystore: any,
network: NetworkConfig,
): Promise<void> {
// sponsored networks: 1AM pays fees — nothing to do
if (network.dustMode === 'sponsored') return;
const state = await Rx.firstValueFrom(
walletFacade.state().pipe(Rx.filter((s: any) => s.isSynced)),
);
// already have DUST
if (state.dust.availableCoins.length > 0) return;
// find NIGHT UTXOs not yet registered for DUST generation
const unregistered = state.unshielded.availableCoins.filter(
(coin: any) => coin.meta?.registeredForDustGeneration !== true,
);
if (unregistered.length === 0) {
// all UTXOs registered — just wait for DUST to accrue
console.log('Waiting for DUST to generate (all UTXOs already registered)...');
} else {
// submit registration tx
console.log(`Registering ${unregistered.length} NIGHT UTXO(s) for DUST generation...`);
const recipe = await walletFacade.registerNightUtxosForDustGeneration(
unregistered,
unshieldedKeystore.getPublicKey(),
(payload: Uint8Array) => unshieldedKeystore.signData(payload),
);
const finalized = await walletFacade.finalizeRecipe(recipe);
await walletFacade.submitTransaction(finalized);
}
// wait for DUST balance > 0 (can take a few minutes on preprod)
await Rx.firstValueFrom(
walletFacade.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s: any) => s.isSynced),
Rx.filter((s: any) => s.dust.walletBalance(new Date()) > 0n),
),
);
console.log('DUST available.');
}// src/config/wallet.ts
import * as Rx from 'rxjs';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { HDWallet, generateRandomSeed, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
UnshieldedWallet, createKeystore, PublicKey,
InMemoryTransactionHistoryStorage,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { WebSocket } from 'ws';
import { Buffer } from 'buffer';
import { applyNetworkId, type NetworkConfig } from './networks';
import { ensureDust } from './dust';
import { makeWalletAndMidnightProvider } from './wallet-provider';
// Required: set WebSocket global before any wallet SDK imports
globalThis.WebSocket = WebSocket as unknown as typeof globalThis.WebSocket;
export async function buildHeadlessWallet(network: NetworkConfig, seedHex?: string) {
applyNetworkId(network);
const seed = seedHex
? Buffer.from(seedHex, 'hex')
: Buffer.from(generateRandomSeed());
const hdWallet = HDWallet.fromSeed(seed);
if (hdWallet.type !== 'seedOk') throw new Error('Invalid wallet seed');
const derivationResult = hdWallet.hdWallet
.selectAccount(0)
.selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
.deriveKeysAt(0);
if (derivationResult.type !== 'keysDerived') throw new Error('Key derivation failed');
hdWallet.hdWallet.clear(); // wipe secret material from memory
const keys = derivationResult.keys;
const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], network.networkId);
// relayURL must be WebSocket — convert http→ws, https→wss
const relayURL = new URL(network.rpc.replace(/^https/, 'wss').replace(/^http(?!s)/, 'ws'));
const shieldedWallet = ShieldedWallet({
networkId: network.networkId,
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
provingServerUrl: new URL(network.proofServerUrl),
relayURL,
}).startWithSecretKeys(shieldedSecretKeys);
const unshieldedWallet = UnshieldedWallet({
networkId: network.networkId,
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
txHistoryStorage: new InMemoryTransactionHistoryStorage(),
}).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore));
const dustWallet = DustWallet({
networkId: network.networkId,
costParameters: {
additionalFeeOverhead: 300_000_000_000_000n,
feeBlocksMargin: 5,
},
indexerClientConnection: { indexerHttpUrl: network.indexerHttp, indexerWsUrl: network.indexerWs },
provingServerUrl: new URL(network.proofServerUrl),
relayURL,
}).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust);
const walletFacade = new WalletFacade(shieldedWallet, unshieldedWallet, dustWallet);
await walletFacade.start(shieldedSecretKeys, dustSecretKey);
// Wait for sync
await Rx.firstValueFrom(
walletFacade.state().pipe(
Rx.throttleTime(5_000),
Rx.filter((s: any) => s.isSynced),
),
);
// Handle DUST based on network's dustMode
await ensureDust(walletFacade, unshieldedKeystore, network);
const walletProvider = await makeWalletAndMidnightProvider(
walletFacade, shieldedSecretKeys, dustSecretKey, unshieldedKeystore,
);
return {
walletFacade,
shieldedSecretKeys,
dustSecretKey,
unshieldedKeystore,
walletProvider,
seedHex: Buffer.from(seed).toString('hex'),
unshieldedAddress: unshieldedKeystore.getBech32Address(),
};
}// src/config/providers-full.ts
import type { NetworkConfig } from './networks';
import {
makeNodeZkConfigProvider, makeFetchZkConfigProvider,
makeHttpProofProvider, make1AMProofProvider,
makePublicDataProvider,
makeLevelPrivateStateProvider, makeInMemoryPrivateStateProvider,
} from './providers';
export async function assembleProviders(
network: NetworkConfig,
zkConfigPath: string, // filesystem path (Node.js) or URL path (browser)
walletContext: {
walletProvider: any;
midnightProvider?: any;
},
privateStateStoreName: string,
proofProviderOverride?: any, // pass 1AM provingProvider for browser
) {
const isBrowser = typeof window !== 'undefined';
const zkConfigProvider = isBrowser
? makeFetchZkConfigProvider(zkConfigPath)
: makeNodeZkConfigProvider(zkConfigPath);
const proofProvider = proofProviderOverride
? make1AMProofProvider(proofProviderOverride) // browser + 1AM
: makeHttpProofProvider(network.proofServerUrl, zkConfigProvider); // Node.js
const privateStateProvider = isBrowser
? makeInMemoryPrivateStateProvider()
: makeLevelPrivateStateProvider(privateStateStoreName, walletContext.walletProvider);
return {
zkConfigProvider,
publicDataProvider: makePublicDataProvider(network),
proofProvider,
privateStateProvider,
walletProvider: walletContext.walletProvider,
midnightProvider: walletContext.midnightProvider ?? walletContext.walletProvider,
};
}// src/config/registry.ts
// { contractName: { networkId: contractAddress } }
export type ContractRegistry = Record<string, Partial<Record<string, string>>>;
export const registry: ContractRegistry = {
counter: {
undeployed: '', // fill after local deploy
preview: '', // fill after preview deploy
preprod: '09dbe05f...',
mainnet: '',
},
};
export function resolveAddress(
contractName: string,
networkId: string,
): string {
const addr = registry[contractName]?.[networkId];
if (!addr) throw new Error(
`No address for contract "${contractName}" on network "${networkId}". Deploy first.`,
);
return addr;
}
export function saveAddress(contractName: string, networkId: string, address: string): void {
if (!registry[contractName]) registry[contractName] = {};
registry[contractName][networkId] = address;
// In a real app, persist this to a JSON file or env variable
}{
"scripts": {
"dev": "VITE_NETWORK=undeployed vite",
"dev:preview": "VITE_NETWORK=preview vite",
"dev:preprod": "VITE_NETWORK=preprod vite",
"deploy:local": "NETWORK=undeployed npx tsx scripts/deploy.ts",
"deploy:preview": "NETWORK=preview npx tsx scripts/deploy.ts",
"deploy:preprod": "NETWORK=preprod npx tsx scripts/deploy.ts",
"deploy:mainnet": "NETWORK=mainnet npx tsx scripts/deploy.ts",
"local:start": "docker compose -f standalone.yml up -d",
"local:stop": "docker compose -f standalone.yml down",
"local:logs": "docker compose -f standalone.yml logs -f",
"local:proof": "docker run -d -p 6300:6300 midnightntwrk/proof-server:latest midnight-proof-server -v",
"preprod:proof": "docker compose -f proof-server.yml up"
}
}// vite.config.ts
import { defineConfig } from 'vite';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({
plugins: [wasm(), topLevelAwait()],
build: {
target: 'esnext',
},
server: { // proxy lives inside 'server', not at the top level
allowedHosts: true,
proxy: process.env.VITE_NETWORK === 'undeployed' ? {
'/api': {
target: 'http://localhost:8088',
changeOrigin: true,
},
} : undefined,
},
});undeployed/api/v3/graphql/api/v4/midnight-local-devdocker compose -f standalone.yml upwalletFacade.statewalletFacade.state()Rx.firstValueFrom(walletFacade.state().pipe(...)).staterelayURLhttps://wss://http://ws://globalThis.WebSocket = WebSocket@midnight-ntwrk/wallet-sdk-*signTransactionIntentsbalanceTxundeployedpreprodapi-preprod.1am.xyzX-API-Key: pk_live_xxxhttpClientProofProvidersetNetworkIdapplyNetworkId(network)WALLET_SEED