DEV Community

Tosh
Tosh

Posted on

Building a Full-Stack Midnight dApp: From Contract to Deployment

Most tutorials stop at "here's how to write a Compact contract." That's the easy part. The hard part is stitching together the complete stack: a privacy-preserving smart contract, TypeScript witness functions, a React frontend that users can actually interact with, and enough backend infrastructure to handle the off-chain pieces that belong there.

This guide walks through the entire lifecycle of a Midnight dApp — from writing the Compact contract (briefly) through TypeScript witnesses, wallet provider setup, a React frontend with deploy/interact/read-state flows, and a minimal backend for off-chain data. By the end you'll have a working pattern you can adapt to any Midnight application.

We'll build a simple Todo List dApp: users can add tasks (public or private), mark them complete, and only the task owner can modify their own items. It's simple enough to fit in one tutorial but realistic enough to demonstrate real patterns.

Project Structure

Start with create-mn-app:

npx create-mn-app midnight-todo
cd midnight-todo
Enter fullscreen mode Exit fullscreen mode

Choose the Full DApp template. After setup, the structure looks like this:

midnight-todo/
├── contracts/
│   └── todo.compact          # Compact smart contract
├── src/
│   ├── api/
│   │   ├── index.ts          # Contract API class (deploy + interact)
│   │   ├── providers.ts      # Wallet + Midnight provider wiring
│   │   └── types.ts          # Shared TypeScript types
│   ├── ui/
│   │   ├── App.tsx
│   │   ├── components/
│   │   └── hooks/
│   └── backend/
│       ├── server.ts         # Express backend (off-chain data)
│       └── db.ts             # SQLite for off-chain metadata
├── docker-compose.yml        # Proof server
└── package.json
Enter fullscreen mode Exit fullscreen mode

The api/ layer is shared between the CLI (for scripting) and the UI (for users). Keep it framework-agnostic — no React imports, no browser APIs.

The Compact Contract (Brief Overview)

Compact is Midnight's ZK-native contract language. Here's the contract for our Todo List:

// contracts/todo.compact

export ledger taskCount: Counter;
export ledger publicTasks: Map<Uint<32>, PublicTask>;

struct PublicTask {
  id: Uint<32>,
  title: Opaque<"string">,
  completed: Boolean,
  ownerCommitment: Bytes<32>,
}

// Add a public task (title visible on-chain, owner is a commitment)
export circuit addTask(
  title: Opaque<"string">,
  ownerSecret: Bytes<32>,
): [] {
  const id = taskCount;
  taskCount = taskCount + 1;
  const commitment = sha256(ownerSecret);
  publicTasks.insert(id, {
    id,
    title: disclose(title),
    completed: false,
    ownerCommitment: commitment,
  });
}

// Complete a task — proves ownership without revealing the secret
export circuit completeTask(
  id: Uint<32>,
  ownerSecret: Bytes<32>,
): [] {
  const task = publicTasks.lookup(id);
  assert task.ownerCommitment == sha256(ownerSecret);
  publicTasks.insert(id, { ...task, completed: true });
}
Enter fullscreen mode Exit fullscreen mode

Key things to note:

  • taskCount is a simple counter stored on-chain
  • ownerCommitment stores a hash of the user's secret — never the secret itself
  • completeTask proves ownership with a ZK proof: the circuit verifies sha256(ownerSecret) == ownerCommitment without putting the secret on-chain
  • disclose(title) makes the title visible in the public ledger

Compile the contract:

npm run compile
Enter fullscreen mode Exit fullscreen mode

This generates TypeScript bindings in dist/contracts/ — type-safe wrappers for every circuit.

TypeScript Witness Implementations

Witnesses are off-chain TypeScript functions that generate private inputs for ZK circuits. When a circuit needs private data (like ownerSecret), the witness provides it.

After compilation, you'll see generated witness stubs. Fill them in:

// src/api/witnesses.ts
import type { Witnesses } from '../generated/todo-contract/index.js';
import { blake2b } from '@midnight-ntwrk/compact-runtime';

// In-memory store for the current session's secrets
// In production, you'd persist this encrypted to localStorage or a secure store
const secretStore = new Map<string, Uint8Array>();

export function storeSecret(contractAddress: string, secret: Uint8Array): void {
  secretStore.set(contractAddress, secret);
}

export function deriveSecret(userSeed: string, contractAddress: string): Uint8Array {
  const encoder = new TextEncoder();
  const input = encoder.encode(`${userSeed}:${contractAddress}`);
  return blake2b(input, 32);
}

export const witnesses: Witnesses = {
  // Called by completeTask circuit to provide the owner secret
  ownerSecret: ({ contractAddress }: { contractAddress: string }) => {
    const secret = secretStore.get(contractAddress);
    if (!secret) {
      throw new Error(
        'Owner secret not found. Did you add the task in this session?'
      );
    }
    return secret;
  },
};
Enter fullscreen mode Exit fullscreen mode

This is the private state problem in miniature. The secret never leaves the browser — the witness is called locally during proof generation. What goes to the chain is only the ZK proof that you know the secret.

For persistence across sessions, you can encrypt the secret with the user's wallet public key before storing it in localStorage. That's more advanced than we'll cover here, but it's the right production pattern.

Wallet Provider Setup

This is the bridge layer — it connects the wallet's signing and submission capabilities to the Midnight contract API.

// src/api/providers.ts
import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
import type { WalletProvider, MidnightProvider } from '@midnight-ntwrk/midnight.js';

export async function createProviders(
  api: ConnectedAPI,
): Promise<WalletProvider & MidnightProvider> {
  const config = await api.getConfiguration();

  return {
    // Keys for transaction construction
    getCoinPublicKey() {
      return api.getUnshieldedAddress();
    },
    getEncryptionPublicKey() {
      return api.getShieldedAddresses().then((a) => a.encryptionPublicKey);
    },

    // Balance the transaction: add fees, select unspent outputs
    async balanceTx(tx, ttl?) {
      return api.balanceUnsealedTransaction(tx, ttl);
    },

    // Submit the proven transaction
    submitTx(tx) {
      return api.submitTransaction(tx);
    },

    // Network and service config from the wallet
    indexerUri: config.indexerUri,
    indexerWsUri: config.indexerWsUri,
    proverServerUri: config.proverServerUri,
    substrateNodeUri: config.substrateNodeUri,
    networkId: config.networkId,
  };
}
Enter fullscreen mode Exit fullscreen mode

This provider object is what Midnight.js uses internally. You create it once when the wallet connects and pass it to your contract API class.

The Contract API Class

This is the heart of the application — the class that wraps all contract interactions.

// src/api/index.ts
import {
  Contract,
  deployContract,
  findDeployedContract,
} from '@midnight-ntwrk/midnight.js';
import type { WalletProvider, MidnightProvider } from '@midnight-ntwrk/midnight.js';
import { TodoContract, type TodoProviders, type DeployedTodo } from '../generated/todo-contract/index.js';
import { witnesses } from './witnesses.js';
import type { ContractAddress } from '@midnight-ntwrk/ledger';

export type Providers = WalletProvider & MidnightProvider;

export interface TodoTask {
  id: number;
  title: string;
  completed: boolean;
  ownerCommitment: string;
}

export class TodoAPI {
  private readonly contract: TodoContract;
  private deployed: DeployedTodo | null = null;

  constructor(private readonly providers: Providers) {
    this.contract = new TodoContract(witnesses);
  }

  // Deploy a new contract instance
  async deploy(): Promise<ContractAddress> {
    this.deployed = await deployContract(this.providers, {
      contract: this.contract,
      privateStateKey: 'todo-private-state',
      initialPrivateState: {},
    });
    return this.deployed.deployTxData.public.contractAddress;
  }

  // Connect to an existing deployed contract
  async connect(contractAddress: ContractAddress): Promise<void> {
    this.deployed = await findDeployedContract(this.providers, {
      contractAddress,
      contract: this.contract,
      privateStateKey: 'todo-private-state',
    });
  }

  // Read current task count from the ledger
  async getTaskCount(): Promise<number> {
    if (!this.deployed) throw new Error('Contract not connected');
    const state = await this.providers.publicDataProvider
      .queryContractState(this.deployed.deployTxData.public.contractAddress)
      .then((s) => (s ? TodoContract.ledger(s.data) : null));
    return state ? Number(state.taskCount) : 0;
  }

  // Read a specific task by ID
  async getTask(id: number): Promise<TodoTask | null> {
    if (!this.deployed) throw new Error('Contract not connected');
    const state = await this.providers.publicDataProvider
      .queryContractState(this.deployed.deployTxData.public.contractAddress)
      .then((s) => (s ? TodoContract.ledger(s.data) : null));
    if (!state) return null;
    const task = state.publicTasks.get(BigInt(id));
    if (!task) return null;
    return {
      id: Number(task.id),
      title: task.title as string,
      completed: task.completed,
      ownerCommitment: Buffer.from(task.ownerCommitment).toString('hex'),
    };
  }

  // Add a new task
  async addTask(title: string, ownerSecret: Uint8Array): Promise<void> {
    if (!this.deployed) throw new Error('Contract not connected');
    await this.deployed.callTx.addTask(title, ownerSecret);
  }

  // Complete a task (proves ownership)
  async completeTask(id: number): Promise<void> {
    if (!this.deployed) throw new Error('Contract not connected');
    await this.deployed.callTx.completeTask(BigInt(id));
  }

  get contractAddress(): ContractAddress | null {
    return this.deployed?.deployTxData.public.contractAddress ?? null;
  }
}
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

  • deployContract and findDeployedContract come from Midnight.js — they handle the proof generation and submission lifecycle
  • privateStateKey is a string that namespaces private state in local storage. Use a consistent key per contract type.
  • callTx.addTask() returns a FinalizedTxData — you can await it and get the transaction hash for display

React Frontend: Deploy, Interact, Read State

Now the UI. Keep components thin — they call hooks, hooks call the API.

App Setup

// src/ui/App.tsx
import { useState } from 'react';
import { WalletProvider } from './context/WalletContext';
import { ContractProvider } from './context/ContractContext';
import { ConnectSection } from './components/ConnectSection';
import { ContractSection } from './components/ContractSection';
import { TaskList } from './components/TaskList';

export function App() {
  return (
    <WalletProvider>
      <ContractProvider>
        <div className="app">
          <ConnectSection />
          <ContractSection />
          <TaskList />
        </div>
      </ContractProvider>
    </WalletProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Contract Context

// src/ui/context/ContractContext.tsx
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { TodoAPI } from '../../api/index.js';
import { createProviders } from '../../api/providers.js';
import { useWalletContext } from './WalletContext.js';
import { deriveSecret, storeSecret } from '../../api/witnesses.js';

interface ContractContextValue {
  api: TodoAPI | null;
  contractAddress: string | null;
  deploy: () => Promise<void>;
  connect: (address: string) => Promise<void>;
  deploying: boolean;
  error: string | null;
}

const ContractContext = createContext<ContractContextValue | null>(null);

export function ContractProvider({ children }: { children: ReactNode }) {
  const { walletApi } = useWalletContext(); // ConnectedAPI from wallet hook
  const [api, setApi] = useState<TodoAPI | null>(null);
  const [contractAddress, setContractAddress] = useState<string | null>(null);
  const [deploying, setDeploying] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const buildApi = useCallback(async () => {
    if (!walletApi) throw new Error('Wallet not connected');
    const providers = await createProviders(walletApi);
    return new TodoAPI(providers);
  }, [walletApi]);

  const deploy = useCallback(async () => {
    setDeploying(true);
    setError(null);
    try {
      const todoApi = await buildApi();
      const address = await todoApi.deploy();
      setApi(todoApi);
      setContractAddress(address);
      // Persist address for reconnect
      localStorage.setItem('todo-contract-address', address);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Deploy failed');
    } finally {
      setDeploying(false);
    }
  }, [buildApi]);

  const connect = useCallback(async (address: string) => {
    setError(null);
    try {
      const todoApi = await buildApi();
      await todoApi.connect(address);
      setApi(todoApi);
      setContractAddress(address);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Connect failed');
    }
  }, [buildApi]);

  return (
    <ContractContext.Provider value={{ api, contractAddress, deploy, connect, deploying, error }}>
      {children}
    </ContractContext.Provider>
  );
}

export function useContractContext() {
  const ctx = useContext(ContractContext);
  if (!ctx) throw new Error('Must be inside ContractProvider');
  return ctx;
}
Enter fullscreen mode Exit fullscreen mode

Deploy / Connect Component

// src/ui/components/ContractSection.tsx
import { useState } from 'react';
import { useContractContext } from '../context/ContractContext';

export function ContractSection() {
  const { contractAddress, deploy, connect, deploying, error } = useContractContext();
  const [inputAddress, setInputAddress] = useState('');

  // Auto-reconnect on load
  if (!contractAddress) {
    const saved = localStorage.getItem('todo-contract-address');
    if (saved && !inputAddress) setInputAddress(saved);
  }

  if (contractAddress) {
    return (
      <div>
        <p>Contract: <code>{contractAddress.slice(0, 12)}</code></p>
      </div>
    );
  }

  return (
    <div>
      {error && <p className="error">{error}</p>}
      <button onClick={deploy} disabled={deploying}>
        {deploying ? 'Deploying…' : 'Deploy New Contract'}
      </button>
      <p>— or connect to existing —</p>
      <input
        value={inputAddress}
        onChange={(e) => setInputAddress(e.target.value)}
        placeholder="Contract address"
      />
      <button onClick={() => connect(inputAddress)} disabled={!inputAddress}>
        Connect
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Task List with Real-Time State

// src/ui/components/TaskList.tsx
import { useState, useEffect, useCallback } from 'react';
import { useContractContext } from '../context/ContractContext';
import { deriveSecret, storeSecret } from '../../api/witnesses.js';
import type { TodoTask } from '../../api/index.js';

export function TaskList() {
  const { api, contractAddress } = useContractContext();
  const [tasks, setTasks] = useState<TodoTask[]>([]);
  const [newTitle, setNewTitle] = useState('');
  const [submitting, setSubmitting] = useState(false);

  // Poll contract state every 8 seconds
  const fetchTasks = useCallback(async () => {
    if (!api) return;
    const count = await api.getTaskCount();
    const fetched: TodoTask[] = [];
    for (let i = 0; i < count; i++) {
      const task = await api.getTask(i);
      if (task) fetched.push(task);
    }
    setTasks(fetched);
  }, [api]);

  useEffect(() => {
    fetchTasks();
    const id = setInterval(fetchTasks, 8_000);
    return () => clearInterval(id);
  }, [fetchTasks]);

  const addTask = async () => {
    if (!api || !newTitle.trim() || !contractAddress) return;
    setSubmitting(true);
    try {
      // Derive a deterministic secret for this user+contract
      // In production: derive from wallet key or user-provided passphrase
      const secret = deriveSecret('user-session-seed', contractAddress);
      storeSecret(contractAddress, secret);

      await api.addTask(newTitle.trim(), secret);
      setNewTitle('');
      // Refresh after a short delay to let the tx finalize
      setTimeout(fetchTasks, 3_000);
    } catch (err) {
      console.error('Add task failed:', err);
    } finally {
      setSubmitting(false);
    }
  };

  const completeTask = async (id: number) => {
    if (!api) return;
    try {
      await api.completeTask(id);
      setTimeout(fetchTasks, 3_000);
    } catch (err) {
      console.error('Complete task failed:', err);
    }
  };

  if (!api) return <p>Connect your wallet and deploy or connect to a contract.</p>;

  return (
    <div>
      <div>
        <input
          value={newTitle}
          onChange={(e) => setNewTitle(e.target.value)}
          placeholder="New task title"
          onKeyDown={(e) => e.key === 'Enter' && addTask()}
        />
        <button onClick={addTask} disabled={submitting || !newTitle.trim()}>
          {submitting ? 'Adding…' : 'Add Task'}
        </button>
      </div>

      <ul>
        {tasks.map((task) => (
          <li key={task.id} style={{ opacity: task.completed ? 0.5 : 1 }}>
            <span>{task.title}</span>
            {!task.completed && (
              <button onClick={() => completeTask(task.id)}>Complete</button>
            )}
            {task.completed && <span></span>}
          </li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Simple Backend for Off-Chain Data

Not everything belongs on-chain. For our Todo app, task metadata (creation timestamps, descriptions longer than fits in Compact, user display names) lives off-chain in a simple Express + SQLite backend.

// src/backend/db.ts
import Database from 'better-sqlite3';

const db = new Database('todo-offchain.db');

db.exec(`
  CREATE TABLE IF NOT EXISTS task_metadata (
    id INTEGER PRIMARY KEY,
    contract_address TEXT NOT NULL,
    task_id INTEGER NOT NULL,
    created_at INTEGER NOT NULL,
    description TEXT,
    UNIQUE(contract_address, task_id)
  )
`);

export function saveTaskMetadata(
  contractAddress: string,
  taskId: number,
  description: string,
): void {
  db.prepare(`
    INSERT OR REPLACE INTO task_metadata (id, contract_address, task_id, created_at, description)
    VALUES (NULL, ?, ?, ?, ?)
  `).run(contractAddress, taskId, Date.now(), description);
}

export function getTaskMetadata(
  contractAddress: string,
  taskId: number,
): { createdAt: number; description: string } | null {
  return db.prepare(`
    SELECT created_at as createdAt, description
    FROM task_metadata
    WHERE contract_address = ? AND task_id = ?
  `).get(contractAddress, taskId) as any ?? null;
}
Enter fullscreen mode Exit fullscreen mode
// src/backend/server.ts
import express from 'express';
import cors from 'cors';
import { saveTaskMetadata, getTaskMetadata } from './db.js';

const app = express();
app.use(cors());
app.use(express.json());

// Save metadata when a task is created
app.post('/api/tasks/:contractAddress/:taskId/metadata', (req, res) => {
  const { contractAddress, taskId } = req.params;
  const { description } = req.body;
  saveTaskMetadata(contractAddress, parseInt(taskId, 10), description ?? '');
  res.json({ ok: true });
});

// Fetch metadata for display
app.get('/api/tasks/:contractAddress/:taskId/metadata', (req, res) => {
  const { contractAddress, taskId } = req.params;
  const meta = getTaskMetadata(contractAddress, parseInt(taskId, 10));
  res.json(meta ?? { createdAt: null, description: null });
});

app.listen(3001, () => console.log('Backend running on :3001'));
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

npm install express cors better-sqlite3
npm install -D @types/express @types/cors @types/better-sqlite3
Enter fullscreen mode Exit fullscreen mode

The frontend calls this backend alongside the on-chain queries. On-chain data is the source of truth for ownership and task status; the backend provides the enrichment layer.

Wiring the Backend into the Frontend

Add a simple fetch utility:

// src/ui/api/backend.ts
const BACKEND = 'http://localhost:3001';

export async function saveMetadata(
  contractAddress: string,
  taskId: number,
  description: string,
): Promise<void> {
  await fetch(`${BACKEND}/api/tasks/${contractAddress}/${taskId}/metadata`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ description }),
  });
}

export async function fetchMetadata(
  contractAddress: string,
  taskId: number,
): Promise<{ createdAt: number | null; description: string | null }> {
  const res = await fetch(`${BACKEND}/api/tasks/${contractAddress}/${taskId}/metadata`);
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Call saveMetadata after api.addTask() succeeds. Call fetchMetadata alongside api.getTask() and merge the results for display. The pattern is clean: on-chain data is authoritative for ownership and completion; backend data fills in the rest.

Deployment

Proof Server

The proof server runs in Docker. Keep the docker-compose.yml from the scaffold:

services:
  proof-server:
    image: midnightntwrk/proof-server:latest
    ports:
      - "6300:6300"
Enter fullscreen mode Exit fullscreen mode
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Frontend

Deploy to Vercel:

npm run build
npx vercel --prod
Enter fullscreen mode Exit fullscreen mode

Set environment variables in Vercel:

  • VITE_BACKEND_URL — your backend URL
  • VITE_NETWORK_IDpreprod or preview

Backend

Deploy to Railway or Fly.io. Both handle SQLite fine for small-scale apps (Fly with a volume for persistence, Railway with a mounted disk). For production scale, swap SQLite for Postgres.

# Railway
railway init
railway up
Enter fullscreen mode Exit fullscreen mode

Smart Contract

The Compact contract is already on-chain once you deploy from the frontend. The contract address is the permanent identifier — persist it in your backend or frontend config so users can reconnect without redeploying.

Patterns to Carry Forward

Separate on-chain and off-chain concerns clearly. The contract stores minimal data — ownership commitments, state flags, identifiers. The backend stores everything else.

Witnesses are private by design. They run locally, generate ZK proofs, and produce no network traffic. Design your Compact circuits so witness inputs are secrets; public inputs go through disclose().

State reads are cheap; writes are not. Querying the indexer for contract state is a fast HTTP call. Writing (any callTx.*) generates a ZK proof, which takes seconds. Design your UX accordingly — show optimistic state, poll for confirmation.

One provider instance per session. The Providers object holds open connections to the indexer and Substrate node. Create it once after wallet connect and reuse it for the lifetime of the session.

Handle proof server unavailability explicitly. If balanceUnsealedTransaction() fails because the prover server is down, the error is opaque. Catch it and give users a clear message: "Proof server is not responding. Make sure Docker is running and the proof server container is healthy."

What's Next

You now have a complete pattern: Compact contract → TypeScript witnesses → wallet provider → contract API class → React frontend → off-chain backend. Every Midnight dApp you build follows this same shape. The complexity lives in the contract logic and the witness functions — the rest is wiring.

From here, explore:

  • Private state providers: using privateStateProvider in Midnight.js to persist witness secrets encrypted to the wallet
  • ZK proof caching: the prover server caches proofs by circuit and input — use this to speed up repeated operations
  • Multi-user contracts: using commitments as identity, building access control without revealing user identity
  • WebSocket indexer subscriptions: instead of polling, subscribe to contract state updates over WebSocket for real-time UX

Building on Midnight? I also maintain a 200-prompt developer pack for ChatGPT — code review, debugging, documentation, architecture decisions. $19 instant download.

Top comments (0)