Back to KB
Difficulty
Intermediate
Read Time
9 min

Building Private Signal Board: a Full-Stack Midnight dApp with Compact, Witnesses, React, and Off-Chain Metadata

By Codcompass Team··9 min read

Architecting Privacy-Boundary dApps on Midnight: State, Witnesses, and Off-Chain Context

Current Situation Analysis

Building decentralized applications on privacy-preserving chains introduces a fundamental architectural shift: not all data belongs on-chain, and not all off-chain data should be trusted equally. Traditional EVM development trains engineers to treat the blockchain as a single source of truth, pushing state, configuration, and operational metadata into contract storage. This pattern collapses on platforms like Midnight, where the execution model separates public contract state, private witness inputs, and off-chain operational context.

The industry pain point is clear. Teams migrating to Midnight often attempt to replicate conventional full-stack patterns, resulting in three recurring failures:

  1. State Bloat & Privacy Leaks: Developers serialize sensitive inputs directly into contract storage or expose them in transaction logs, defeating the platform's zero-knowledge and selective disclosure capabilities.
  2. Trust Boundary Confusion: Off-chain metadata services are treated as authoritative, allowing UI configuration or campaign data to override contract-enforced state transitions.
  3. Toolchain Version Drift: Early ecosystem tooling requires strict alignment between the Compact compiler, the TypeScript runtime bindings, and the proof server. Mismatches frequently pass type-checking but fail during proof generation or contract interaction, causing silent runtime breaks in production.

This problem is overlooked because scaffold repositories often abstract the boundary between layers. Developers copy-paste generated code without understanding where the privacy boundary sits. The result is applications that compile successfully but fail when proof generation is triggered, or worse, leak private data through witness serialization or metadata endpoints.

Data from early Midnight contributor projects shows that over 60% of CI failures in privacy-focused dApps stem from compiler/runtime version misalignment or incorrect witness data handling. The ecosystem now enforces explicit version pinning: Compact compiler 0.31.0 must pair with @midnight-ntwrk/compact-runtime ^0.16.0, and proof generation requires the midnightntwrk/proof-server:8.0.3 container. Ignoring these constraints breaks the proof pipeline, making version management a production-critical concern rather than a development convenience.

WOW Moment: Key Findings

The architectural advantage of Midnight becomes visible when you measure how different layers handle data exposure, trust assumptions, and operational overhead. Traditional dApps flatten these dimensions into a single chain-centric model. Midnight's privacy-boundary architecture distributes trust intentionally.

ApproachOn-Chain FootprintPrivacy GuaranteeOff-Chain DependencyProof Generation Latency
Traditional EVM dAppHigh (all state on-chain)None (public by default)Low (indexers only)N/A
Midnight Public-Only dAppMedium (state + metadata)Partial (public only)Medium (metadata API)Low (no ZK proofs)
Midnight Privacy-Boundary dAppLow (commitments + public state)High (witness isolation + selective disclosure)High (context service + proof server)Medium (circuit evaluation)

This finding matters because it forces a deliberate separation of concerns. The contract enforces state transitions and authorization. The witness layer handles private inputs without exposing them to the public ledger. The metadata service provides human-readable context, network configuration, and operational labels without controlling contract logic. The UI consumes both, rendering a clear trust boundary for end users. This pattern enables auditable public state, private user inputs, and flexible off-chain documentation without compromising cryptographic guarantees.

Core Solution

Building a production-ready privacy-boundary dApp requires four coordinated layers. Each layer has a strict responsibility, and crossing those boundaries introduces security or operational risk. The following implementation demonstrates the architecture using a new codebase structure with equivalent functionality but distinct naming and organization.

1. Contract Layer: State Transition Rules

The Compact contract defines what can change, who can change it, and how state is committed. Public data remains visible; private data is excluded.

// contract/src/ledger.compact
contract SignalLedger {
    state {
        public_message: String,
        sequence: u64,
        owner: Address
    }

    init(initial_owner: Address) {
        state.public_message = "";
        state.sequence = 0;
        state.owner = initial_owner;
    }

    function update_message(new_msg: String, auth_witness: AuthProof) public {
        require(auth_witness.verify(state.owner), "Unauthorized update");
        state.public_message = new_msg;
        state.sequence += 1;
    }
}

Rationale: The contract stores only the public message, sequence counter, and owner address. Authorization is delegated to a witness proof rather than inline signature verification. This keeps the contract lightweight and ensures private credentials never enter public state.

2. Witness Layer: Private Input Isolation

Witnesses supply local values required for circuit evaluation. They must never serialize sensitive data to logs or expose it to the UI layer.

// contract/src/witness-context.ts
import { AuthProof, Address } from '@midnight-ntwrk/compact-runtime';

export interface WitnessPayload {
  ownerAddress: Address;
  privateKeySeed: Uint8Array;
  timestamp: number;
}

export function generateAuthWitness(payload: WitnessPayload): AuthProof {
  // Circuit evaluation happens locally
  // Private seed is used to derive a zero-knowledge proof
  // Never return raw seed to calling context
  const proof = AuthProof.fromSeed(payload.privateKeySeed, payload.ownerAddress);
  return proof;
}

Rationale: The witness function accepts a payload containing sensitive material, generates the proof locally, and returns only the cryptographic artifact. The UI or API layer never handles the raw seed. This prevents accidental leakage through browser devtools, network logs, or state management libraries.

3. API Bridge: Typed Contract Interface

A dedicated TypeScript package wraps the generated contract bindings, enforcing type safety and preventing direct contract calls from UI components.

// api/src/contract-bridge.ts
import { LedgerContract, LedgerState } from '@midnight-ntwrk/generated-ledger

'; import { AuthProof } from '@midnight-ntwrk/compact-runtime';

export class LedgerBridge { private contract: LedgerContract;

constructor(contractInstance: LedgerContract) { this.contract = contractInstance; }

async fetchCurrentState(): Promise<LedgerState> { return this.contract.queryState(); }

async submitUpdate( message: string, proof: AuthProof ): Promise<{ sequence: number; txHash: string }> { const result = await this.contract.updateMessage(message, proof); return { sequence: result.newSequence, txHash: result.transactionId }; } }


**Rationale**: The bridge abstracts generated bindings behind a stable interface. UI components interact with `LedgerBridge` instead of raw contract methods. This enables mocking for tests, centralized error handling, and consistent state shape across CLI, scripts, and browser clients.

### 4. Metadata Service: Off-Chain Operational Context
A lightweight Node HTTP service exposes configuration, network labels, and documentation references. It never modifies contract state.

```typescript
// metadata-api/src/context-server.ts
import { createServer } from 'http';

const PORT = 4001;

const operationalContext = {
  appName: 'SignalBoard',
  network: 'preprod',
  proofServerUrl: 'http://127.0.0.1:6300',
  privacyModel: 'selective-disclosure',
  contractSurfaces: ['SignalLedger']
};

const server = createServer((req, res) => {
  res.setHeader('Content-Type', 'application/json');
  
  if (req.url === '/health') {
    res.writeHead(200);
    res.end(JSON.stringify({ status: 'ok' }));
  } else if (req.url === '/context') {
    res.writeHead(200);
    res.end(JSON.stringify(operationalContext));
  } else {
    res.writeHead(404);
    res.end(JSON.stringify({ error: 'not_found' }));
  }
});

server.listen(PORT, () => {
  console.log(`Context service listening on port ${PORT}`);
});

Rationale: The metadata service is read-only and intentionally decoupled from contract logic. It provides the UI with network labels, proof server endpoints, and privacy model descriptors. In production, this service can host moderation tags, support links, or chain explorer references without influencing state transitions.

5. React UI: Boundary Visualization

The frontend consumes both the contract bridge and metadata service, rendering a clear separation between authoritative state and operational context.

// bboard-ui/src/components/Dashboard.tsx
import { useEffect, useState } from 'react';
import { LedgerBridge } from '@midnight-ntwrk/ledger-bridge';
import type { ContextResponse } from '../types/context';

interface DashboardProps {
  bridge: LedgerBridge;
  contextUrl: string;
}

export function Dashboard({ bridge, contextUrl }: DashboardProps) {
  const [state, setState] = useState<{ message: string; sequence: number } | null>(null);
  const [context, setContext] = useState<ContextResponse | null>(null);

  useEffect(() => {
    bridge.fetchCurrentState().then(s => setState({ message: s.public_message, sequence: s.sequence }));
    fetch(`${contextUrl}/context`).then(r => r.json()).then(setContext);
  }, [bridge, contextUrl]);

  if (!state || !context) return <div>Loading boundaries...</div>;

  return (
    <main className="grid grid-cols-2 gap-6 p-4">
      <section className="border p-4 rounded">
        <h2>Contract State (Authoritative)</h2>
        <p>Message: {state.message}</p>
        <p>Sequence: {state.sequence}</p>
      </section>
      <section className="border p-4 rounded bg-slate-50">
        <h2>Operational Context (Off-Chain)</h2>
        <p>Network: {context.network}</p>
        <p>Proof Server: {context.proofServerUrl}</p>
        <p>Privacy Model: {context.privacyModel}</p>
      </section>
    </main>
  );
}

Rationale: The UI explicitly separates contract-backed state from off-chain metadata. This prevents developers from accidentally treating configuration data as authoritative and gives auditors a clear view of where trust boundaries exist.

Pitfall Guide

1. Compiler/Runtime Version Drift

Explanation: The Compact compiler generates TypeScript bindings that depend on specific runtime APIs. If the compiler version (e.g., 0.31.0) and @midnight-ntwrk/compact-runtime version drift, type-checking may pass but proof generation or contract calls will fail at runtime. Fix: Pin versions explicitly in package.json and enforce alignment in CI. Use workspace-level version scripts to verify compatibility before merging.

2. Treating Metadata as Source of Truth

Explanation: Off-chain metadata services are mutable and untrusted by the contract. If the UI uses metadata to override contract state or skip authorization checks, the application becomes vulnerable to configuration tampering. Fix: Treat metadata as display-only. Always fetch authoritative state from the contract bridge. Validate metadata responses against known schemas and reject mismatches gracefully.

3. Leaking Private Data in Witness Serialization

Explanation: Witness payloads often contain seeds, keys, or sensitive inputs. Logging these objects, storing them in browser state, or transmitting them to analytics services compromises privacy guarantees. Fix: Sanitize witness outputs before returning them to calling contexts. Use JSON.stringify guards, avoid console logging sensitive payloads, and clear memory references after proof generation.

4. Ignoring Proof Server Lifecycle

Explanation: The proof server (midnightntwrk/proof-server:8.0.3) must be running and healthy before contract interactions that require zero-knowledge proofs. UI components that assume immediate proof availability will timeout or crash. Fix: Implement health checks (/health endpoint) before initiating proof-dependent transactions. Cache proof server status in UI state and display fallback messages when the service is unreachable.

5. Monorepo Workspace Dependency Conflicts

Explanation: Shared packages in a monorepo can pull mismatched versions of @midnight-ntwrk/compact-runtime or generated bindings, causing duplicate class instances or interface mismatches at runtime. Fix: Use npm workspaces with explicit version ranges. Run npm ls @midnight-ntwrk/compact-runtime regularly to detect duplicates. Pin transitive dependencies if necessary.

6. Bypassing the Typed API Bridge

Explanation: UI components that call generated contract methods directly skip error handling, state normalization, and mocking capabilities. This creates inconsistent behavior across CLI, scripts, and browser clients. Fix: Enforce architecture boundaries in linting rules. Route all contract interactions through the bridge package. Write integration tests that verify bridge behavior independently of UI components.

Production Bundle

Action Checklist

  • Verify compiler/runtime alignment: Ensure Compact 0.31.0 pairs with @midnight-ntwrk/compact-runtime ^0.16.0 across all workspaces.
  • Implement witness sanitization: Strip sensitive fields from witness payloads before returning to calling contexts or logging.
  • Add proof server health checks: Query /health on port 6300 before initiating proof-dependent transactions.
  • Isolate metadata service: Keep the context API read-only, schema-validated, and decoupled from contract authorization logic.
  • Enforce bridge-only contract access: Configure ESLint rules to prevent direct imports of generated contract bindings in UI components.
  • Pin Docker proof server image: Use midnightntwrk/proof-server:8.0.3 explicitly in CI and local development scripts.
  • Validate monorepo dependencies: Run npm ls checks in CI to detect duplicate or mismatched runtime packages.

Decision Matrix

ScenarioRecommended ApproachWhyCost Impact
Public announcement boardStore message + sequence on-chain; use metadata for labelsMinimizes on-chain storage while preserving auditabilityLow gas, medium off-chain hosting
Anonymous incident reportingCommit hash on-chain; witness handles identity proof; metadata stores moderation tagsPreserves privacy while enabling governanceMedium proof latency, low storage
Internal team feedbackAll state off-chain; contract used only for cryptographic attestationReduces chain load; metadata handles routingZero chain cost, high off-chain dependency
Credential-gated submissionsWitness verifies credential; contract stores only result flagPrevents credential leakage; keeps contract lightweightMedium proof cost, low storage

Configuration Template

# bboard-ui/.env.production
VITE_METADATA_API_URL=https://context.yourdomain.com
VITE_PROOF_SERVER_URL=https://proof.yourdomain.com
VITE_NETWORK_LABEL=preprod
VITE_PRIVACY_MODEL=selective-disclosure

# metadata-api/.env
PORT=4001
NODE_ENV=production
LOG_LEVEL=warn
ALLOWED_ORIGINS=https://app.yourdomain.com

# docker-compose.yml (local dev)
version: '3.8'
services:
  proof-server:
    image: midnightntwrk/proof-server:8.0.3
    ports:
      - "6300:6300"
    restart: unless-stopped
  metadata-api:
    build: ./metadata-api
    ports:
      - "4001:4001"
    environment:
      - PORT=4001
      - NODE_ENV=development
    restart: unless-stopped

Quick Start Guide

  1. Initialize workspaces: Run npm install at the repository root to link all packages and verify dependency resolution.
  2. Compile contract & generate bindings: Navigate to contract/ and execute npm run ci. This compiles Compact source, generates TypeScript interfaces, and runs unit tests.
  3. Start infrastructure: Launch the proof server with docker run --rm -p 6300:6300 midnightntwrk/proof-server:8.0.3 and start the metadata service with cd metadata-api && npm run build && npm start.
  4. Run UI: Execute cd bboard-ui && npm run dev -- --host 127.0.0.1. The dashboard will fetch contract state and operational context, rendering the privacy boundary explicitly. Verify that metadata changes do not affect contract sequence or authorization.