Building and Deploying Your First Smart Contract on Midnight Network
compact

Building and Deploying Your First Smart Contract on Midnight Network

A complete beginner-friendly guide, from zero to a deployed, interactive smart contract on Midnight Preprod.

Mechack Elie (8pro)
Mechack Elie (8pro)
·March 25, 2026·33 min read·100 views
#compact#smartcontract#midnight#deploy

1. What Is Midnight?

Midnight is a data-protection blockchain built by Input Output (the company behind Cardano). It allows developers to write smart contracts that can work with private data, data that is verified on-chain without being revealed to anyone.
The core innovation is Zero-Knowledge Proofs (ZKPs). Instead of publishing all your data to the blockchain, you publish a cryptographic proof that says I know the secret data that satisfies these rules, without revealing the secret data itself.

2. How Midnight is Different

Feature

Traditional Blockchain (Ethereum)

Midnight

Smart contract language

Solidity

Compact

Data visibility

Fully public

Selectively private

Execution model

All nodes re-execute

Zero-Knowledge Proofs

Gas token

ETH

DUST

Wallet model

Single account

Multi-layer (Shielded + Unshielded + Dust)

Compact is Midnight's purpose-built programming language for smart contracts. It compiles to ZK circuits, meaning each function call generates a cryptographic proof rather than being re-executed by every node.

3. What We Will Build

A Hello World contract with two capabilities:

  • storeMessage : a circuit (Compact function) that stores a string on-chain, proven by zero-knowledge proof

  • Read message: query the public ledger state to read the current stored message

We will also build:

- A deploy script deploy.ts) that sets up a wallet, gets test funds, and deploys the contract
- A CLI cli.ts) to interactively store and read messages after deployment

The final project structure looks like this:


hello-world-compact/
├── contracts/
│ ├── hello-world.compact ← The smart contract
│ └── managed/
│ └── hello-world/ ← Compiler output (generated)
│ ├── compiler/
│ ├── contract/
│ ├── keys/ ← ZK prover/verifier keys
│ └── zkir/
├── src/
│ ├── deploy.ts ← Deployment script
│ └── cli.ts ← Interactive CLI
├── docker-compose.yml ← Proof server
├── package.json
└── tsconfig.json

4. Prerequisites

You need the following tools installed before starting.

4.1 Node.js (v20 or higher)

Download from nodejs.org. Verify:

bash
node --version
# v20.x.x or higher

4.2 Docker Desktop

Download from docker.com/get-started. The proof server (which generates ZK proofs locally) runs as a Docker container.

Verify Docker is running:

bash
docker --version
docker ps

4.3 The Compact Compiler

The Compact compiler transforms your .compact contract file into TypeScript bindings, ZK circuit keys, and runtime artifacts.

Install it via npm:

bash
npm install -g @midnight-ntwrk/compact-compiler


Verify:

bash
compact --version


» Note: The Compact compiler version must match the ledger-v8 SDK version you use. In this guide we use ledger-v8@8.0.3.

5. Project Setup

5.1 Create the project folder

bash
mkdir hello-world-compact
cd hello-world-compact

5.2 Create package.json

json
{
  "name": "hello-world-compact",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "compile": "compact compile contracts/hello-world.compact contracts/managed/hello-world",
    "build": "tsc",
    "deploy": "tsx src/deploy.ts",
    "cli": "tsx src/cli.ts",
    "proof-server:start": "docker compose up -d",
    "proof-server:stop": "docker compose down"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "@types/ws": "^8.18.1",
    "tsx": "^4.21.0",
    "typescript": "^5.9.3"
  },
  "dependencies": {
    "@midnight-ntwrk/compact-runtime": "0.15.0",
    "@midnight-ntwrk/ledger-v8": "8.0.3",
    "@midnight-ntwrk/midnight-js-contracts": "4.0.1",
    "@midnight-ntwrk/midnight-js-http-client-proof-provider": "4.0.1",
    "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.1",
    "@midnight-ntwrk/midnight-js-level-private-state-provider": "4.0.1",
    "@midnight-ntwrk/midnight-js-network-id": "4.0.1",
    "@midnight-ntwrk/midnight-js-node-zk-config-provider": "4.0.1",
    "@midnight-ntwrk/midnight-js-types": "4.0.1",
    "@midnight-ntwrk/wallet-sdk-address-format": "3.1.0",
    "@midnight-ntwrk/wallet-sdk-dust-wallet": "3.0.0",
    "@midnight-ntwrk/wallet-sdk-facade": "3.0.0",
    "@midnight-ntwrk/wallet-sdk-hd": "3.0.1",
    "@midnight-ntwrk/wallet-sdk-shielded": "2.1.0",
    "@midnight-ntwrk/wallet-sdk-unshielded-wallet": "2.1.0",
    "ws": "^8.19.0"
  }
}

» Version pinning matters. All @midnight-ntwrk/* packages must be version-compatible. The proof server Docker image version must match ledger-v8. If you change ledger-v8 to 8.0.4, the proof server image must also be 8.0.4.

5.3 Create tsconfig.json

json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": "src",
    "strict": false,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "declaration": true,
    "allowSyntheticDefaultImports": true,
    "resolveJsonModule": true
  },
  "include": ["src/**/*"],
  "ts-node": {
    "esm": true,
    "experimentalSpecifierResolution": "node"
  }
}

5.4 Create the docker-compose.yml

yaml
services:
  proof-server:
    image: midnightntwrk/proof-server:8.0.3
    command: ['midnight-proof-server', '-v']
    ports:
      - '6300:6300'
    restart: on-failure
    volumes:
      - proof-server-params:/root

volumes:
  proof-server-params:

Key points:

  • The image tag 8.0.3 must matcledger-v8: 8.0.3 in package.json

  • restart: on-failure ensures the container retries if S3 downloads fail during first startup

  • The named volume proof-server-params caches the ~30MB ZK parameter files so you don't re-download them on every restart

5.5 Create the contracts folder and install dependencies

bash
mkdir -p contracts/managed src
npm install

6. Writing the Smart Contract in Compact

Create contracts/hello-world.compact:

compact

pragma language_version >= 0.20;
import CompactStandardLibrary;

// Public ledger state - stores the message visible on-chain
export ledger message: Opaque<"string">;

// Circuit to store a new message
export circuit storeMessage(newMessage: Opaque<"string">): [] {
  message = disclose(newMessage);
}

Let's break this down line by line:

pragma language_version >= 0.20;
Declares the minimum Compact language version required. This prevents your contract from being compiled with an incompatible older compiler.

import CompactStandardLibrary;
Imports built-in types and utilities — similar to `import std` in other languages.

export ledger message: Opaque<"string">;
Declares a piece of public ledger state, a field stored on-chain, readable by anyone. Opaque<"string"> means it holds a string value that is passed through without ZK constraints. By marking it ledger, Midnight tracks it in the global contract state.

export circuit storeMessage(newMessage: Opaque<"string">): []
Declares a circuit, Compact's name for a smart contract function. Circuits compile to ZK proofs. The `[]` return type means no return value (like void).

message = disclose(newMessage);
disclose() takes a private input and "discloses" it to the public ledger. This is how private data transitions to public state in Compact. Without disclose(), newMessage would remain private to the caller.

7. Compiling the Contract

Run:

bash
npm run compile

This is equivalent to:

bash
compact compile contracts/hello-world.compact contracts/managed/hello-world

The compiler generates a contracts/managed/hello-world/ folder containing:


contracts/managed/hello-world/
├── compiler/
│ └── contract-info.json ← ABI and metadata
├── contract/
│ ├── index.js ← JavaScript bindings
│ └── index.d.ts ← TypeScript types
├── keys/
│ ├── storeMessage.prover ← ZK prover key
│ └── storeMessage.verifier ← ZK verifier key
└── zkir/
├── storeMessage.bzkir ← Binary ZK intermediate representation
└── storeMessage.zkir ← Human-readable ZK IR

You never need to touch these files, they are inputs to the proof server and the SDK.

» Commit these files to version control. They are deterministic output from your source contract, and other developers need them to interact with the same contract.

8. Setting Up the Proof Server

The proof server is a local service that generates Zero-Knowledge proofs. Every contract call that involves a circuit storeMessage in our case) requires a proof to be generated before the transaction is submitted.

8.1 Start the proof server

bash
npm run proof-server:start
# or: docker compose up -d

8.2 First-time startup: ZK parameter download

The first time you start the proof server, it downloads ~30MB of ZK cryptographic parameters from AWS S3. This can take 5–15 minutes depending on your connection speed.

Monitor the progress:

bash
docker compose logs -f

You will see output like:


Fetching 'bls_midnight_2p10' - 131072 / 131072 bytes downloaded
Fetching public parameters for k=10 - finished.
Fetching 'bls_midnight_2p11' - 262144 / 262144 bytes downloaded
...
Fetching 'bls_midnight_2p14' - 3146116 / 3146116 bytes downloaded
Fetching public parameters for k=14 - finished.
starting 8 workers
starting service: "actix-web-service-0.0.0.0:6300", workers: 8, listening on: 0.0.0.0:6300

Do not proceed to deployment until you see the last line above. The proof server is ready when all 8 workers are started and it is listening on port 6300.

After first startup, the parameters are cached in the proof-server-params Docker volume. Subsequent starts take only a few seconds.

8.3 Verify the proof server is ready

bash
curl http://127.0.0.1:6300/health

A 200 response means it is ready. A connection refused error means it is still starting or downloading params.

9. Understanding the Midnight Wallet

Before writing code, it helps to understand Midnight's unusual wallet model.

Unlike Ethereum (one key pair = one account), a Midnight wallet has three sub-wallets:

Sub-wallet

Purpose

Token

Unshielded

Public account, visible on-chain

tNight (test Night)

Shielded

Private transactions using ZK proofs

tNight (shielded)

Dust

Gas fees for ZK circuits calls

DUST

DUST is Midnight's gas token. You do not buy it, it is automatically generated from your tNight balance by registering your UTXOs for DUST generation. The protocol periodically mints DUST for you.

The typical flow for a new wallet is:

  1. Create wallet from seed → derive all three sub-wallet keys from one HD seed

  2. Sync with the network → wallet downloads relevant blockchain state

  3. Get tNight from faucet → fund the unshielded wallet

  4. Register for DUST → register your tNight UTXOs for DUST generation

  5. Wait for DUST → the protocol mints DUST automatically

  6. Deploy / call contract → DUST is consumed as gas

10. Writing the Deploy Script

Create src/deploy.ts. We'll build it section by section so every part is clear.

10.1 Imports

```typescript
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { WebSocket } from 'ws';
import * as Rx from 'rxjs';
import { Buffer } from 'buffer';

// Midnight SDK imports
import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { setNetworkId, getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { toHex } from '@midnight-ntwrk/midnight-js-utils';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
  createKeystore,
  InMemoryTransactionHistoryStorage,
  PublicKey,
  UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { CompiledContract } from '@midnight-ntwrk/compact-js';
```

The ws package provides WebSocket support needed for live GraphQL subscriptions that the wallet uses to sync with the network.

10.2 Global setup

```typescript
// The Midnight SDK needs WebSocket available globally
// @ts-expect-error Required for wallet sync
globalThis.WebSocket = WebSocket;

// Target the preprod test network
setNetworkId('preprod');

// Network endpoints
const CONFIG = {
  indexer: 'https://indexer.preprod.midnight.network/api/v3/graphql',
  indexerWS: 'wss://indexer.preprod.midnight.network/api/v3/graphql/ws',
  node: 'https://rpc.preprod.midnight.network',
  proofServer: 'http://127.0.0.1:6300',
};
```

10.3 Load the compiled contract

```typescript
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const zkConfigPath = path.resolve(__dirname, '..', 'contracts', 'managed', 'hello-world');

// Dynamically import the JS bindings generated by the Compact compiler
const contractPath = path.join(zkConfigPath, 'contract', 'index.js');
const HelloWorld = await import(pathToFileURL(contractPath).href);

// Wrap it with SDK metadata: contract name, empty witnesses, ZK key assets
const compiledContract = CompiledContract.make('hello-world', HelloWorld.Contract).pipe(
  CompiledContract.withVacantWitnesses,
  CompiledContract.withCompiledFileAssets(zkConfigPath),
);
```

`CompiledContract` is a builder that attaches:

  • withVacantWitnesses : no private witness inputs for this simple contract

  • withCompiledFileAssets : tells the SDK where to find the `.prover` and `.verifier` key files

10.4 Key derivation

```typescript
function deriveKeys(seed: string) {
  const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
  if (hdWallet.type !== 'seedOk') throw new Error('Invalid seed');

  const result = hdWallet.hdWallet
    .selectAccount(0)
    .selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
    .deriveKeysAt(0);

  if (result.type !== 'keysDerived') throw new Error('Key derivation failed');
  hdWallet.hdWallet.clear(); // Clear sensitive key material from memory
  return result.keys;
}

From a single 64-character hex seed, we derive three independent key pairs:

  • Roles.Zswap → shielded wallet keys

  • Roles.NightExternal → unshielded wallet keys

  • Roles.Dust → dust wallet keys

This is similar to BIP-39/BIP-44 HD derivation — one seed, many wallets.

10.5 Create the wallet

```typescript
async function createWallet(seed: string) {
  const keys = deriveKeys(seed);
  const networkId = getNetworkId();

  const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
  const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
  const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], networkId);

  const walletConfig = {
    networkId,
    indexerClientConnection: { indexerHttpUrl: CONFIG.indexer, indexerWsUrl: CONFIG.indexerWS },
    relayURL: new URL(CONFIG.node.replace(/^http/, 'ws')),
    provingServerUrl: new URL(CONFIG.proofServer),
    txHistoryStorage: new InMemoryTransactionHistoryStorage(),
    costParameters: { additionalFeeOverhead: 300_000_000_000_000n, feeBlocksMargin: 5 },
  };

  const wallet = await WalletFacade.init({
    configuration: walletConfig,
    shielded: (config) => ShieldedWallet(config).startWithSecretKeys(shieldedSecretKeys),
    unshielded: (config) => UnshieldedWallet(config).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
    dust: (config) => DustWallet(config).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
  });

  return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore, seed };
}

WalletFacade.init() is the public factory method that creates all three sub-wallets in one shot. It is initialized but not yet syncing — you need to call wallet.start() to begin background sync.

10.6 Create providers

Providers are the abstraction layer between your code and the blockchain network. Each provider handles a specific concern:

```typescript
async function createProviders(walletCtx) {
  // Wait until the wallet is fully synced
  const state = await Rx.firstValueFrom(
    walletCtx.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
  );

  const walletProvider = {
    // Returns the coin public key for shielded transaction inputs
    getCoinPublicKey: () => state.shielded.coinPublicKey.toHexString(),
    // Returns the encryption public key for shielded outputs
    getEncryptionPublicKey: () => state.shielded.encryptionPublicKey.toHexString(),

    // Balances, signs, and finalizes an unbound transaction
    async balanceTx(tx, ttl) {
      const recipe = await walletCtx.wallet.balanceUnboundTransaction(
        tx,
        { shieldedSecretKeys: walletCtx.shieldedSecretKeys, dustSecretKey: walletCtx.dustSecretKey },
        { ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
      );
      const signedRecipe = await walletCtx.wallet.signRecipe(
        recipe,
        (payload) => walletCtx.unshieldedKeystore.signData(payload),
      );
      return walletCtx.wallet.finalizeRecipe(signedRecipe);
    },
    submitTx: (tx) => walletCtx.wallet.submitTransaction(tx),
  };

  const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath);

  return {
    // Stores private state (e.g., shielded notes) in a local LevelDB database
    privateStateProvider: levelPrivateStateProvider({
      privateStoragePasswordProvider: () => `Aa1!${walletCtx.seed}`,
      accountId: walletCtx.unshieldedKeystore.getBech32Address().toString(),
    }),
    // Queries on-chain contract state via the indexer
    publicDataProvider: indexerPublicDataProvider(CONFIG.indexer, CONFIG.indexerWS),
    // Loads ZK keys from disk
    zkConfigProvider,
    // Talks to the local proof server
    proofProvider: httpClientProofProvider(CONFIG.proofServer, zkConfigProvider),
    walletProvider,
    midnightProvider: walletProvider,
  };
}

Important note on privateStoragePasswordProvider: The password must contain at least 3 of 4 character classes (uppercase, lowercase, digits, special characters). A raw hex seed only has lowercase + digits (2 classes), so we prefix with Aa1! to add uppercase and a special character. The prefix is deterministic — the same seed always produces the same password.

10.7 The main function

```typescript
async function main() {
  // Step 1: Create or restore a wallet
  const choice = await rl.question('[1] Create new wallet\n[2] Restore from seed\n> ');
  const seed = choice === '2'
    ? await rl.question('Enter your 64-character seed: ')
    : toHex(Buffer.from(generateRandomSeed()));

  if (choice !== '2') {
    console.log(`⚠️  SAVE THIS SEED:\n  ${seed}`);
  }

  const walletCtx = await createWallet(seed);

  // CRITICAL: call start() before waiting for sync
  await walletCtx.wallet.start(walletCtx.shieldedSecretKeys, walletCtx.dustSecretKey);

  // Step 2: Wait for wallet to sync with the network
  const state = await Rx.firstValueFrom(
    walletCtx.wallet.state().pipe(Rx.throttleTime(5000), Rx.filter((s) => s.isSynced))
  );

  // Step 3: Fund wallet if balance is 0
  const balance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;
  if (balance === 0n) {
    console.log('Visit: https://faucet.preprod.midnight.network/');
    // Wait for incoming funds...
  }

  // Step 4: Register for DUST if needed
  if (state.dust.balance(new Date()) === 0n) {
    // Register tNight UTXOs for DUST generation
    // Wait for DUST to arrive...
  }

  // Step 5: Deploy the contract
  const providers = await createProviders(walletCtx);
  const deployed = await deployContract(providers, { compiledContract });

  // Step 6: Save deployment info
  fs.writeFileSync('deployment.json', JSON.stringify({
    contractAddress: deployed.deployTxData.public.contractAddress,
    seed,
    network: 'preprod',
    deployedAt: new Date().toISOString(),
  }, null, 2));
}

10.8 The complete `src/deploy.ts`

```typescript
/**
 * Deploy Hello World contract to Midnight Preprod network
 */
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { WebSocket } from 'ws';
import * as Rx from 'rxjs';
import { Buffer } from 'buffer';

import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { setNetworkId, getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { toHex } from '@midnight-ntwrk/midnight-js-utils';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { unshieldedToken } from '@midnight-ntwrk/ledger-v8';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { HDWallet, Roles, generateRandomSeed } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
  createKeystore,
  InMemoryTransactionHistoryStorage,
  PublicKey,
  UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { CompiledContract } from '@midnight-ntwrk/compact-js';

// @ts-expect-error Required for wallet sync
globalThis.WebSocket = WebSocket;

setNetworkId('preprod');

const CONFIG = {
  indexer: 'https://indexer.preprod.midnight.network/api/v3/graphql',
  indexerWS: 'wss://indexer.preprod.midnight.network/api/v3/graphql/ws',
  node: 'https://rpc.preprod.midnight.network',
  proofServer: 'http://127.0.0.1:6300',
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const zkConfigPath = path.resolve(__dirname, '..', 'contracts', 'managed', 'hello-world');

const contractPath = path.join(zkConfigPath, 'contract', 'index.js');
const HelloWorld = await import(pathToFileURL(contractPath).href);

const compiledContract = CompiledContract.make('hello-world', HelloWorld.Contract).pipe(
  CompiledContract.withVacantWitnesses,
  CompiledContract.withCompiledFileAssets(zkConfigPath),
);

function deriveKeys(seed: string) {
  const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
  if (hdWallet.type !== 'seedOk') throw new Error('Invalid seed');
  const result = hdWallet.hdWallet
    .selectAccount(0)
    .selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
    .deriveKeysAt(0);
  if (result.type !== 'keysDerived') throw new Error('Key derivation failed');
  hdWallet.hdWallet.clear();
  return result.keys;
}

async function createWallet(seed: string) {
  const keys = deriveKeys(seed);
  const networkId = getNetworkId();
  const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
  const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
  const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], networkId);

  const walletConfig = {
    networkId,
    indexerClientConnection: { indexerHttpUrl: CONFIG.indexer, indexerWsUrl: CONFIG.indexerWS },
    relayURL: new URL(CONFIG.node.replace(/^http/, 'ws')),
    provingServerUrl: new URL(CONFIG.proofServer),
    txHistoryStorage: new InMemoryTransactionHistoryStorage(),
    costParameters: { additionalFeeOverhead: 300_000_000_000_000n, feeBlocksMargin: 5 },
  };

  const wallet = await WalletFacade.init({
    configuration: walletConfig,
    shielded: (config) => ShieldedWallet(config).startWithSecretKeys(shieldedSecretKeys),
    unshielded: (config) => UnshieldedWallet(config).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
    dust: (config) => DustWallet(config).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
  });

  return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore, seed };
}

async function createProviders(
  walletCtx: ReturnType<typeof createWallet> extends Promise<infer T> ? T : never,
) {
  const state = await Rx.firstValueFrom(
    walletCtx.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
  );

  const walletProvider = {
    getCoinPublicKey: () => state.shielded.coinPublicKey.toHexString(),
    getEncryptionPublicKey: () => state.shielded.encryptionPublicKey.toHexString(),
    async balanceTx(tx: any, ttl?: Date) {
      const recipe = await walletCtx.wallet.balanceUnboundTransaction(
        tx,
        { shieldedSecretKeys: walletCtx.shieldedSecretKeys, dustSecretKey: walletCtx.dustSecretKey },
        { ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
      );
      const signedRecipe = await walletCtx.wallet.signRecipe(
        recipe,
        (payload) => walletCtx.unshieldedKeystore.signData(payload),
      );
      return walletCtx.wallet.finalizeRecipe(signedRecipe);
    },
    submitTx: (tx: any) => walletCtx.wallet.submitTransaction(tx) as any,
  };

  const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath);

  return {
    privateStateProvider: levelPrivateStateProvider({
      privateStoragePasswordProvider: () => `Aa1!${walletCtx.seed}`,
      accountId: walletCtx.unshieldedKeystore.getBech32Address().toString(),
    }),
    publicDataProvider: indexerPublicDataProvider(CONFIG.indexer, CONFIG.indexerWS),
    zkConfigProvider,
    proofProvider: httpClientProofProvider(CONFIG.proofServer, zkConfigProvider),
    walletProvider,
    midnightProvider: walletProvider,
  };
}

async function main() {
  console.log('\n╔══════════════════════════════════════════════════════════════╗');
  console.log('║           Deploy Hello World to Midnight Preprod             ║');
  console.log('╚══════════════════════════════════════════════════════════════╝\n');

  if (!fs.existsSync(path.join(zkConfigPath, 'contract', 'index.js'))) {
    console.error('Contract not compiled! Run: npm run compile');
    process.exit(1);
  }

  const rl = createInterface({ input: stdin, output: stdout });

  try {
    console.log('─── Step 1: Wallet Setup ───────────────────────────────────────\n');
    const choice = await rl.question('  [1] Create new wallet\n  [2] Restore from seed\n  > ');

    const seed =
      choice.trim() === '2'
        ? await rl.question('\n  Enter your 64-character seed: ')
        : toHex(Buffer.from(generateRandomSeed()));

    if (choice.trim() !== '2') {
      console.log(`\n  ⚠️  SAVE THIS SEED (you'll need it later):\n  ${seed}\n`);
    }

    console.log('  Creating wallet...');
    const walletCtx = await createWallet(seed);

    console.log('  Starting wallet sync...');
    await walletCtx.wallet.start(walletCtx.shieldedSecretKeys, walletCtx.dustSecretKey);

    console.log('  Syncing with network (this may take a few minutes)...');
    const state = await Rx.firstValueFrom(
      walletCtx.wallet.state().pipe(Rx.throttleTime(5000), Rx.filter((s) => s.isSynced)),
    );
    const address = walletCtx.unshieldedKeystore.getBech32Address();
    const balance = state.unshielded.balances[unshieldedToken().raw] ?? 0n;

    console.log(`\n  Wallet Address: ${address}`);
    console.log(`  Balance: ${balance.toLocaleString()} tNight\n`);

    if (balance === 0n) {
      console.log('─── Step 2: Fund Your Wallet ───────────────────────────────────\n');
      console.log('  Visit: https://faucet.preprod.midnight.network/');
      console.log(`  Address: ${address}\n`);
      console.log('  Waiting for funds...');

      await Rx.firstValueFrom(
        walletCtx.wallet.state().pipe(
          Rx.throttleTime(10000),
          Rx.filter((s) => s.isSynced),
          Rx.map((s) => s.unshielded.balances[unshieldedToken().raw] ?? 0n),
          Rx.filter((b) => b > 0n),
        ),
      );
      console.log('  Funds received!\n');
    }

    console.log('─── Step 3: DUST Token Setup ───────────────────────────────────\n');
    const dustState = await Rx.firstValueFrom(
      walletCtx.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
    );

    if (dustState.dust.balance(new Date()) === 0n) {
      const nightUtxos = dustState.unshielded.availableCoins.filter(
        (c: any) => !c.meta?.registeredForDustGeneration,
      );
      if (nightUtxos.length > 0) {
        console.log('  Registering for DUST generation...');
        const recipe = await walletCtx.wallet.registerNightUtxosForDustGeneration(
          nightUtxos,
          walletCtx.unshieldedKeystore.getPublicKey(),
          (payload) => walletCtx.unshieldedKeystore.signData(payload),
        );
        await walletCtx.wallet.submitTransaction(await walletCtx.wallet.finalizeRecipe(recipe));
      }

      console.log('  Waiting for DUST tokens...');
      await Rx.firstValueFrom(
        walletCtx.wallet.state().pipe(
          Rx.throttleTime(5000),
          Rx.filter((s) => s.isSynced),
          Rx.filter((s) => s.dust.balance(new Date()) > 0n),
        ),
      );
    }
    console.log('  DUST tokens ready!\n');

    console.log('─── Step 4: Deploy Contract ────────────────────────────────────\n');
    console.log('  Setting up providers...');
    const providers = await createProviders(walletCtx);

    console.log('  Deploying contract (this may take 30-60 seconds)...\n');
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const deployed = await (deployContract as any)(providers, { compiledContract });

    const contractAddress = deployed.deployTxData.public.contractAddress;
    console.log('  ✅ Contract deployed successfully!\n');
    console.log(`  Contract Address: ${contractAddress}\n`);

    fs.writeFileSync('deployment.json', JSON.stringify({
      contractAddress,
      seed,
      network: 'preprod',
      deployedAt: new Date().toISOString(),
    }, null, 2));
    console.log('  Saved to deployment.json\n');

    await walletCtx.wallet.stop();
    console.log('─── Deployment Complete! ───────────────────────────────────────\n');
    console.log('  Next: Run `npm run cli` to interact with your contract.\n');
  } finally {
    rl.close();
  }
}

main().catch(console.error);

11. Deploying the Contract

Make sure the proof server is ready (see Section 8), then run:

bash
npm run deploy

What happens step by step

Step 1: Wallet Setup

The script asks if you want a new wallet or to restore from seed. For your first deployment, choose 1.


[1] Create new wallet
[2] Restore from seed
> 1

⚠️ SAVE THIS SEED (you'll need it later):
a3f8b2c...d74e1a9b

Creating wallet...
Starting wallet sync...
Syncing with network (this may take a few minutes)...

» Save your seed phrase. It is the only way to access the same wallet again. The deploy script saves it in deployment.json too, but you should keep a copy.

Wallet sync connects to the Midnight preprod indexer and downloads the relevant history. This takes 1–5 minutes on the first run.

Step 2: Fund Your Wallet

If your balance is 0, the script pauses and asks you to use the faucet:


Visit: https://faucet.preprod.midnight.network/
Address: mn1abc...xyz
Waiting for funds...

Go to the faucet URL in your browser, paste your address, and request test tokens. Once the transaction is confirmed, the script continues automatically.

Step 3: DUST Token Setup

DUST is the gas token. The script registers your tNight UTXOs for DUST generation and waits for the first DUST to arrive:

Registering for DUST generation...
Waiting for DUST tokens...
DUST tokens ready!

This may take 1–2 minutes as it requires an on-chain transaction to be confirmed.

Step 4: Deploy


Setting up providers...
Deploying contract (this may take 30-60 seconds)...

✅ Contract deployed successfully!

Contract Address: 0300abc123...

Saved to deployment.json

The deployment:

  1. Builds the deploy transaction

  2. Calls the local proof server to generate a ZK proof

  3. Signs and submits the transaction to the network

  4. Waits for confirmation

Proof generation on a typical laptop takes 20–90 seconds. On first call after proof server startup it may take longer. Do not interrupt the process.

After success, a deployment.json file is created:

json
{
"contractAddress": "0300abc123...",
"seed": "a3f8b2c...d74e1a9b",
"network": "preprod",
"deployedAt": "2026-03-24T10:00:00.000Z"
}

12. Writing the CLI

Now we write the interactive CLI src/cli.ts) that lets us call storeMessage and read the current message.

The full file:

```typescript
/**
 * Interactive CLI to interact with deployed Hello World contract
 */
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { WebSocket } from 'ws';
import * as Rx from 'rxjs';
import { Buffer } from 'buffer';

import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
import { NodeZkConfigProvider } from '@midnight-ntwrk/midnight-js-node-zk-config-provider';
import { setNetworkId, getNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import * as ledger from '@midnight-ntwrk/ledger-v8';
import { WalletFacade } from '@midnight-ntwrk/wallet-sdk-facade';
import { DustWallet } from '@midnight-ntwrk/wallet-sdk-dust-wallet';
import { HDWallet, Roles } from '@midnight-ntwrk/wallet-sdk-hd';
import { ShieldedWallet } from '@midnight-ntwrk/wallet-sdk-shielded';
import {
  createKeystore,
  InMemoryTransactionHistoryStorage,
  PublicKey,
  UnshieldedWallet,
} from '@midnight-ntwrk/wallet-sdk-unshielded-wallet';
import { CompiledContract } from '@midnight-ntwrk/compact-js';

// @ts-expect-error Required for wallet sync
globalThis.WebSocket = WebSocket;

setNetworkId('preprod');

const CONFIG = {
  indexer: 'https://indexer.preprod.midnight.network/api/v3/graphql',
  indexerWS: 'wss://indexer.preprod.midnight.network/api/v3/graphql/ws',
  node: 'https://rpc.preprod.midnight.network',
  proofServer: 'http://127.0.0.1:6300',
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const zkConfigPath = path.resolve(__dirname, '..', 'contracts', 'managed', 'hello-world');

const contractPath = path.join(zkConfigPath, 'contract', 'index.js');
const HelloWorld = await import(pathToFileURL(contractPath).href);

const compiledContract = CompiledContract.make('hello-world', HelloWorld.Contract).pipe(
  CompiledContract.withVacantWitnesses,
  CompiledContract.withCompiledFileAssets(zkConfigPath),
);

// ─── Wallet (same as deploy.ts) ───────────────────────────────────────────────

function deriveKeys(seed: string) {
  const hdWallet = HDWallet.fromSeed(Buffer.from(seed, 'hex'));
  if (hdWallet.type !== 'seedOk') throw new Error('Invalid seed');
  const result = hdWallet.hdWallet
    .selectAccount(0)
    .selectRoles([Roles.Zswap, Roles.NightExternal, Roles.Dust])
    .deriveKeysAt(0);
  if (result.type !== 'keysDerived') throw new Error('Key derivation failed');
  hdWallet.hdWallet.clear();
  return result.keys;
}

async function createWallet(seed: string) {
  const keys = deriveKeys(seed);
  const networkId = getNetworkId();
  const shieldedSecretKeys = ledger.ZswapSecretKeys.fromSeed(keys[Roles.Zswap]);
  const dustSecretKey = ledger.DustSecretKey.fromSeed(keys[Roles.Dust]);
  const unshieldedKeystore = createKeystore(keys[Roles.NightExternal], networkId);

  const walletConfig = {
    networkId,
    indexerClientConnection: { indexerHttpUrl: CONFIG.indexer, indexerWsUrl: CONFIG.indexerWS },
    relayURL: new URL(CONFIG.node.replace(/^http/, 'ws')),
    provingServerUrl: new URL(CONFIG.proofServer),
    txHistoryStorage: new InMemoryTransactionHistoryStorage(),
    costParameters: { additionalFeeOverhead: 300_000_000_000_000n, feeBlocksMargin: 5 },
  };

  const wallet = await WalletFacade.init({
    configuration: walletConfig,
    shielded: (config) => ShieldedWallet(config).startWithSecretKeys(shieldedSecretKeys),
    unshielded: (config) => UnshieldedWallet(config).startWithPublicKey(PublicKey.fromKeyStore(unshieldedKeystore)),
    dust: (config) => DustWallet(config).startWithSecretKey(dustSecretKey, ledger.LedgerParameters.initialParameters().dust),
  });
  await wallet.start(shieldedSecretKeys, dustSecretKey);

  return { wallet, shieldedSecretKeys, dustSecretKey, unshieldedKeystore, seed };
}

async function createProviders(
  walletCtx: ReturnType<typeof createWallet> extends Promise<infer T> ? T : never,
) {
  const state = await Rx.firstValueFrom(
    walletCtx.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
  );

  const walletProvider = {
    getCoinPublicKey: () => state.shielded.coinPublicKey.toHexString(),
    getEncryptionPublicKey: () => state.shielded.encryptionPublicKey.toHexString(),
    async balanceTx(tx: any, ttl?: Date) {
      const recipe = await walletCtx.wallet.balanceUnboundTransaction(
        tx,
        { shieldedSecretKeys: walletCtx.shieldedSecretKeys, dustSecretKey: walletCtx.dustSecretKey },
        { ttl: ttl ?? new Date(Date.now() + 30 * 60 * 1000) },
      );
      const signedRecipe = await walletCtx.wallet.signRecipe(
        recipe,
        (payload) => walletCtx.unshieldedKeystore.signData(payload),
      );
      return walletCtx.wallet.finalizeRecipe(signedRecipe);
    },
    submitTx: (tx: any) => walletCtx.wallet.submitTransaction(tx) as any,
  };

  const zkConfigProvider = new NodeZkConfigProvider(zkConfigPath);

  return {
    privateStateProvider: levelPrivateStateProvider({
      privateStoragePasswordProvider: () => `Aa1!${walletCtx.seed}`,
      accountId: walletCtx.unshieldedKeystore.getBech32Address().toString(),
    }),
    publicDataProvider: indexerPublicDataProvider(CONFIG.indexer, CONFIG.indexerWS),
    zkConfigProvider,
    proofProvider: httpClientProofProvider(CONFIG.proofServer, zkConfigProvider),
    walletProvider,
    midnightProvider: walletProvider,
  };
}

// ─── Main CLI ─────────────────────────────────────────────────────────────────

async function main() {
  console.log('\n╔══════════════════════════════════════════════════════════════╗');
  console.log('║              Hello World Contract CLI                        ║');
  console.log('╚══════════════════════════════════════════════════════════════╝\n');

  if (!fs.existsSync('deployment.json')) {
    console.error('No deployment.json found! Run `npm run deploy` first.\n');
    process.exit(1);
  }

  const deployment = JSON.parse(fs.readFileSync('deployment.json', 'utf-8'));
  console.log(`  Contract: ${deployment.contractAddress}\n`);

  const rl = createInterface({ input: stdin, output: stdout });

  try {
    const seed = await rl.question('  Enter your wallet seed: ');

    console.log('\n  Connecting to Midnight Preprod...');
    const walletCtx = await createWallet(seed.trim());

    console.log('  Syncing wallet...');
    await Rx.firstValueFrom(
      walletCtx.wallet.state().pipe(Rx.throttleTime(5000), Rx.filter((s) => s.isSynced)),
    );

    console.log('  Setting up providers...');
    const providers = await createProviders(walletCtx);

    console.log('  Joining contract...');
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const contract = await (findDeployedContract as any)(providers, {
      contractAddress: deployment.contractAddress,
      compiledContract,
      privateStateId: 'helloWorldState',
      initialPrivateState: {},
    });

    console.log('  Connected!\n');

    let running = true;
    while (running) {
      const walletState = await Rx.firstValueFrom(
        walletCtx.wallet.state().pipe(Rx.filter((s) => s.isSynced)),
      );
      const dust = walletState.dust.balance(new Date());

      console.log('─────────────────────────────────────────────────────────────────');
      console.log(`  DUST balance: ${dust.toLocaleString()}`);
      console.log('─────────────────────────────────────────────────────────────────');

      const choice = await rl.question(
        '  [1] Store a message\n  [2] Read current message\n  [3] Exit\n  > ',
      );

      switch (choice.trim()) {
        case '1': {
          const message = await rl.question('\n  Enter message: ');
          console.log('  Storing message (this may take 20-30 seconds)...\n');
          const tx = await contract.callTx.storeMessage(message);
          console.log(`  ✅ Message stored!`);
          console.log(`  Transaction: ${tx.public.txId}`);
          console.log(`  Block: ${tx.public.blockHeight}\n`);
          break;
        }
        case '2': {
          const state = await providers.publicDataProvider.queryContractState(
            deployment.contractAddress,
          );
          if (state) {
            const ledgerState = HelloWorld.ledger(state.data);
            console.log(`  Current message: "${ledgerState.message || '(empty)'}"\n`);
          } else {
            console.log('  No message found.\n');
          }
          break;
        }
        case '3':
          running = false;
          break;
      }
    }

    await walletCtx.wallet.stop();
    console.log('\n  Goodbye!\n');
  } finally {
    rl.close();
  }
}

main().catch(console.error);

Key difference from deploy.ts: findDeployedContract

Instead of deployContract, the CLI uses findDeployedContract. This:

  • Connects to an **existing** contract at a known address

  • Loads its current state from the indexer

  • Returns a contract handle with callable methods (e.g., `contract.callTx.storeMessage(...)`)

13. Interacting with the Contract

Make sure the proof server is still running docker ps), then:

bash
npm run cli

You will be prompted for your wallet seed (the one shown or saved during deployment):


╔══════════════════════════════════════════════════════════════╗
║ Hello World Contract CLI ║
╚══════════════════════════════════════════════════════════════╝

Contract: 0300abc123...

Enter your wallet seed: a3f8b2c...

Connecting to Midnight Preprod...
Syncing wallet...
Setting up providers...
Joining contract...
Connected!

─────────────────────────────────────────────────────────────────
DUST balance: 1,000,000
─────────────────────────────────────────────────────────────────
[1] Store a message
[2] Read current message
[3] Exit
>

Storing a message:


> 1

Enter message: Hello, Midnight!
Storing message (this may take 20-30 seconds)...

✅ Message stored!
Transaction: 0xabc123...
Block: 4521

Reading the message:


> 2

Current message: "Hello, Midnight!"

Every call to storeMessage generates a fresh ZK proof and submits a transaction to the Midnight preprod network. The message is stored publicly on-chain, anyone can read it, but the proof guarantees it was set by someone who holds the correct private key.

14. Understanding What Just Happened

Let's trace exactly what happened when you stored a message:

  1. Circuit execution: The Compact runtime executed storeMessage("Hello, Midnight!") locally

  2. Proof generation: The local proof server generated a ZK proof that says: "I correctly executed the storeMessage circuit with a valid input"

  3. Transaction assembly: The SDK assembled a Midnight transaction containing:
    - The proof
    - The disclosed (public) value of newMessage
    - DUST fee inputs

  4. Transaction signing: Your unshielded keystore signed the transaction

  5. Submission: The signed transaction was submitted to a Midnight relay node

  6. Verification: Network validators verified the ZK proof (fast, O(1))

  7. State update: The ledger's message field was updated to "Hello, Midnight!"

No validator ever re-executed your circuit. They only verified the proof. This is the fundamental shift in Midnight's computation model.

15. Troubleshooting

"Cannot find module '@midnight-ntwrk/compact-js'"

The contract has not been compiled yet. Run:

bash
npm run compile

Wait for the line:


starting service: "actix-web-service-0.0.0.0:6300"

"Password must contain at least 3 of: uppercase letters, lowercase letters, digits, special characters"

You are passing the raw seed as a password to levelPrivateStateProvider. The seed is hex [0-9a-f]) which only satisfies 2 character classes. Fix by prefixing:

typescript
privateStoragePasswordProvider: () => `Aa1!${walletCtx.seed`,

"Transport error" during proof generation (even when proof server is running)

The most common cause is a version mismatch between ledger-v8 and the proof server Docker image. They must have the same version number. Check:

bash
# Check installed ledger version
cat node_modules/@midnight-ntwrk/ledger-v8/package.json | grep '"version"'

# Check docker-compose.yml
grep "image:" docker-compose.yml

Both should show 8.0.3 (or whatever matching version you use). If they differ, update docker-compose.yml:

yaml
image: midnightntwrk/proof-server:8.0.3 # Must match ledger-v8 version

Then restart with a fresh volume:

bash
docker compose down -v
docker compose up -d

Wallet sync hangs forever

You must call wallet.start() before waiting for wallet.state(). Without it, the background sync never begins and the Observable never emits:


// WRONG - will hang forever
const state = await Rx.firstValueFrom(wallet.state().pipe(...));

// CORRECT
await wallet.start(shieldedSecretKeys, dustSecretKey);
const state = await Rx.firstValueFrom(wallet.state().pipe(...));

"No DUST tokens" / deployment fails with insufficient fee

DUST is not immediately available after registration. It is minted by the protocol on a schedule. After calling registerNightUtxosForDustGeneration, wait for a few minutes and retry. The deploy script handles this automatically with the Waiting for DUST tokens... step.

Proof generation takes too long on Apple Silicon (M1/M2/M3)

Proof generation runs inside a Docker x86 container via Rosetta 2 emulation on Apple Silicon. This is slower than native. Expect 2–5 minutes per proof. You can pull the ARM64-native image for faster performance:

yaml
image: midnightntwrk/proof-server:8.0.3-arm64

Summary

You have built a complete end-to-end Midnight smart contract project:

File

Purpose

contracts/hello-world.compact

The smart contract written in Compact

contracts/managed/hello-world/

Compiler output (ZK keys, JS bindings)

docker-compose.yml

Local ZK proof server

src/deploy.ts

Wallet setup + contract deployment script

src/cli.ts

Interactive CLI to call contract methods

deployment.json

Saved contract address + wallet seed

The key concepts to take away:

  • Compact circuits compile to ZK proofs, validators verify proofs, not re-execute your code

  • DUST is Midnight's gas, auto-generated from tNight, you don't buy it

  • The proof server runs locally, generates proofs before each transaction

  • Proof server version must match ledger-v8 version, this is the most common source of errors

  • wallet.start() must be called before any sync-dependent operations

Happy building on Midnight!

Community

Discussion

1

Join the conversation

Connect your wallet to share your thoughts and engage with the community

BY

Written by

Mechack Elie (8pro)

Mechack Elie (8pro)

Web3 builder and open-source contributor, creating Eightblock, a wallet-based blogging platform for Cardano and blockchain education.

addr1qyej7l3mctvtqjvtxsr