For the complete documentation index, see llms.txt
Part 3: Browser DApp
In this section, you build a React frontend that connects to the Lace wallet and reads leaderboard data from the indexer. The frontend also submits scores on-chain and lets you prove ownership of your entries.
Set up the UI package
Create the UI workspace directory structure and its package.json.
mkdir -p leaderboard-ui/src/contexts leaderboard-ui/src/hooks
touch leaderboard-ui/package.json
The leaderboard-contract workspace dependency gives the UI access to the compiled contract bindings. The dev script copies circuit keys into public/ so the browser can fetch them at runtime.
{
"name": "leaderboard-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "mkdir -p ./public/keys ./public/zkir && cp -r ../contract/managed/leaderboard/keys/* ./public/keys/ && cp -r ../contract/managed/leaderboard/zkir/* ./public/zkir/ && vite",
"build": "tsc && vite build --mode preprod && cp -r ../contract/managed/leaderboard/keys ./dist/keys && cp -r ../contract/managed/leaderboard/zkir ./dist/zkir",
"preview": "vite preview"
},
"dependencies": {
"@midnight-ntwrk/dapp-connector-api": "^4.0.1",
"@midnight-ntwrk/compact-runtime": "^0.16.0",
"@midnight-ntwrk/midnight-js-contracts": "^4.0.4",
"@midnight-ntwrk/midnight-js-fetch-zk-config-provider": "^4.0.4",
"@midnight-ntwrk/midnight-js-http-client-proof-provider": "^4.0.4",
"@midnight-ntwrk/midnight-js-indexer-public-data-provider": "^4.0.4",
"@midnight-ntwrk/midnight-js-network-id": "^4.0.4",
"@midnight-ntwrk/midnight-js-types": "^4.0.4",
"@midnight-ntwrk/midnight-js-utils": "^4.0.4",
"@midnight-ntwrk/wallet-sdk-address-format": "^3.0.0",
"@midnight-ntwrk/ledger-v8": "^8.0.3",
"buffer": "^6.0.3",
"fp-ts": "^2.16.11",
"leaderboard-contract": "*",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rxjs": "^7.8.2",
"semver": "^7.7.4",
"pino": "^10.3.1"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/semver": "^7.7.1",
"@vitejs/plugin-react": "^5.1.4",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-wasm": "^3.5.0"
}
}
Vite configuration
The Midnight SDK uses WebAssembly modules for cryptographic operations. Vite needs specific plugins to handle WASM imports in the browser.
touch leaderboard-ui/vite.config.ts
The Vite configuration enables WebAssembly support, sets up the WASM module resolver for the Midnight runtime, and configures the development server on port 3000.
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import wasm from 'vite-plugin-wasm';
import topLevelAwait from 'vite-plugin-top-level-await';
export default defineConfig({
cacheDir: './.vite',
build: {
target: 'esnext',
minify: false,
rollupOptions: {
output: {
manualChunks: {
wasm: ['@midnight-ntwrk/onchain-runtime-v3'],
},
},
},
commonjsOptions: {
transformMixedEsModules: true,
extensions: ['.js', '.cjs'],
ignoreDynamicRequires: true,
},
},
plugins: [
react(),
wasm(),
topLevelAwait({
promiseExportName: '__tla',
promiseImportName: (i) => `__tla_${i}`,
}),
{
name: 'wasm-module-resolver',
resolveId(source, importer) {
if (
source === '@midnight-ntwrk/onchain-runtime-v3' &&
importer &&
importer.includes('@midnight-ntwrk/compact-runtime')
) {
return { id: source, external: false, moduleSideEffects: true };
}
return null;
},
},
],
optimizeDeps: {
esbuildOptions: {
target: 'esnext',
supported: { 'top-level-await': true },
platform: 'browser',
format: 'esm',
loader: { '.wasm': 'binary' },
},
include: ['@midnight-ntwrk/compact-runtime'],
exclude: [
'@midnight-ntwrk/onchain-runtime-v3',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm_bg.wasm',
'@midnight-ntwrk/onchain-runtime-v3/midnight_onchain_runtime_wasm.js',
],
},
resolve: {
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.wasm'],
mainFields: ['browser', 'module', 'main'],
},
server: { port: 3000, open: true },
});
TypeScript config
The TypeScript configuration needs "types": ["vite/client"] so that import.meta.env references compile correctly. Midnight SDK subpath imports require the "bundler" module resolution.
touch leaderboard-ui/tsconfig.json
This configuration targets ES2022 with React JSX transform enabled.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}
Environment variables
Create the local development environment file. Leave VITE_DEFAULT_CONTRACT empty until you deploy a contract in a later step.
touch leaderboard-ui/.env
Vite loads these variables at build time. Any variable prefixed with VITE_ is available in the browser via import.meta.env.
VITE_NETWORK_ID=preprod
VITE_INDEXER_URL=https://indexer.preprod.midnight.network/api/v4/graphql
VITE_INDEXER_WS_URL=wss://indexer.preprod.midnight.network/api/v4/graphql/ws
VITE_DEFAULT_CONTRACT=
HTML entry point
Create the HTML shell that Vite uses as the application entry point. The browser loads the src/main.tsx script as an ES module.
touch leaderboard-ui/index.html
Vite transforms this file during development, injecting hot module reload support and resolving the TypeScript entry point.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Midnight Leaderboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Application entry point
Several Midnight SDK packages use Node.js Buffer, which does not exist in the browser. The entry point must polyfill it before any other imports.
touch leaderboard-ui/src/main.tsx
touch leaderboard-ui/src/App.css
The entry point polyfills Buffer into the global scope, then mounts the React application.
import { Buffer } from 'buffer';
(globalThis as any).Buffer = Buffer;
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './App.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
In-memory private state provider
The midnight-js-contracts library requires a PrivateStateProvider. In the browser, an in-memory implementation is sufficient.
touch leaderboard-ui/src/in-memory-private-state-provider.ts
The provider stores private state and signing keys in memory, scoped by contract address. It implements the full PrivateStateProvider interface including import and export operations.
import type { ContractAddress, SigningKey } from '@midnight-ntwrk/compact-runtime';
import type {
ExportPrivateStatesOptions,
ExportSigningKeysOptions,
ImportPrivateStatesOptions,
ImportPrivateStatesResult,
ImportSigningKeysOptions,
ImportSigningKeysResult,
PrivateStateExport,
PrivateStateId,
PrivateStateProvider,
SigningKeyExport,
} from '@midnight-ntwrk/midnight-js-types';
export const inMemoryPrivateStateProvider = <PSI extends PrivateStateId, PS = unknown>(): PrivateStateProvider<PSI, PS> => {
const privateStates = new Map<ContractAddress, Map<PSI, PS>>();
const signingKeys = new Map<ContractAddress, SigningKey>();
let contractAddress: ContractAddress | null = null;
const requireContractAddress = (): ContractAddress => {
if (contractAddress === null) throw new Error('Contract address not set');
return contractAddress;
};
const getScopedStates = (address: ContractAddress): Map<PSI, PS> => {
let scopedStates = privateStates.get(address);
if (!scopedStates) {
scopedStates = new Map<PSI, PS>();
privateStates.set(address, scopedStates);
}
return scopedStates;
};
const encode = <T>(value: T): string => JSON.stringify(value);
const decode = <T>(value: string): T => JSON.parse(value) as T;
return {
setContractAddress(address: ContractAddress): void { contractAddress = address; },
set(key: PSI, state: PS): Promise<void> {
getScopedStates(requireContractAddress()).set(key, state);
return Promise.resolve();
},
get(key: PSI): Promise<PS | null> {
return Promise.resolve(getScopedStates(requireContractAddress()).get(key) ?? null);
},
remove(key: PSI): Promise<void> {
getScopedStates(requireContractAddress()).delete(key);
return Promise.resolve();
},
clear(): Promise<void> {
privateStates.delete(requireContractAddress());
return Promise.resolve();
},
setSigningKey(addr: ContractAddress, key: SigningKey): Promise<void> {
signingKeys.set(addr, key);
return Promise.resolve();
},
getSigningKey(addr: ContractAddress): Promise<SigningKey | null> {
return Promise.resolve(signingKeys.get(addr) ?? null);
},
removeSigningKey(addr: ContractAddress): Promise<void> {
signingKeys.delete(addr);
return Promise.resolve();
},
clearSigningKeys(): Promise<void> {
signingKeys.clear();
return Promise.resolve();
},
exportPrivateStates(_options?: ExportPrivateStatesOptions): Promise<PrivateStateExport> {
const address = requireContractAddress();
const states = Object.fromEntries(
Array.from(getScopedStates(address).entries()).map(([k, v]) => [k, encode(v)]),
);
return Promise.resolve({
format: 'midnight-private-state-export',
encryptedPayload: encode({ contractAddress: address, states }),
salt: 'in-memory',
});
},
importPrivateStates(exportData: PrivateStateExport, options?: ImportPrivateStatesOptions): Promise<ImportPrivateStatesResult> {
const address = requireContractAddress();
const strategy = options?.conflictStrategy ?? 'error';
const payload = decode<{ states?: Record<string, string> }>(exportData.encryptedPayload);
const scopedStates = getScopedStates(address);
let imported = 0, skipped = 0, overwritten = 0;
for (const [rawId, serialized] of Object.entries(payload.states ?? {})) {
const id = rawId as PSI;
if (scopedStates.has(id)) {
if (strategy === 'skip') { skipped++; continue; }
if (strategy === 'error') return Promise.reject(new Error(`Conflict: ${id}`));
overwritten++;
} else { imported++; }
scopedStates.set(id, decode<PS>(serialized));
}
return Promise.resolve({ imported, skipped, overwritten });
},
exportSigningKeys(_options?: ExportSigningKeysOptions): Promise<SigningKeyExport> {
return Promise.resolve({
format: 'midnight-signing-key-export',
encryptedPayload: encode({ keys: Object.fromEntries(signingKeys.entries()) }),
salt: 'in-memory',
});
},
importSigningKeys(exportData: SigningKeyExport, options?: ImportSigningKeysOptions): Promise<ImportSigningKeysResult> {
const strategy = options?.conflictStrategy ?? 'error';
const payload = decode<{ keys?: Record<string, SigningKey> }>(exportData.encryptedPayload);
let imported = 0, skipped = 0, overwritten = 0;
for (const [addr, key] of Object.entries(payload.keys ?? {})) {
if (signingKeys.has(addr)) {
if (strategy === 'skip') { skipped++; continue; }
if (strategy === 'error') return Promise.reject(new Error(`Conflict: ${addr}`));
overwritten++;
} else { imported++; }
signingKeys.set(addr, key);
}
return Promise.resolve({ imported, skipped, overwritten });
},
};
};