DEV Community

Tosh
Tosh

Posted on

Building a Shielded Token dApp on Midnight: From Contract to React UI

Building a Shielded Token dApp on Midnight: From Contract to React UI

Midnight flips the default that most blockchain developers are used to. On Ethereum, your token balances and transfer history are public by default — you add privacy as an afterthought, usually by interacting with a mixer or bridge. On Midnight, everything that matters to you is private by default. The ZK proof system enforces what gets revealed, not what gets hidden.

This tutorial builds a complete shielded token dApp end-to-end: a Compact contract that manages mint authority and tracks total supply, TypeScript witnesses that bridge private state into the proving system, and a React frontend where users can mint tokens, transfer to another address, and burn them. We'll hit all the tricky parts — the Merkle tree freshness constraint, change management in ShieldedSendResult, and how to wire the dApp Connector API to your provider setup.

This is a long one. Get a coffee.


What We're Building

By the end, you'll have:

  • A Compact contract with circuits for mint, transfer, and burn
  • TypeScript witnesses implementing private state management
  • A provider stack that handles wallet connection, ZK proof generation, and public data queries
  • A React frontend with mint/transfer/burn/balance views

The contract won't be production-ready — you'd want more robust authorization and input validation before deploying with real assets. But the architecture and patterns are exactly what you'd build on.


Prerequisites

Install the Compact compiler:

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/midnightntwrk/compact/releases/latest/download/compact-installer.sh | sh
compact --version
Enter fullscreen mode Exit fullscreen mode

Run the proof server locally via Docker:

docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 midnight-proof-server -v
Enter fullscreen mode Exit fullscreen mode

Install the Midnight Lace wallet extension in Chrome. You'll need it to sign transactions from the browser.


Project Structure

shielded-token-dapp/
├── contract/
│   ├── src/
│   │   ├── shielded-token.compact     # Compact contract
│   │   ├── witnesses.ts               # Private state and witness functions
│   │   └── index.ts                   # Compiled contract export
├── api/
│   └── src/
│       ├── index.ts                   # ShieldedTokenAPI class
│       └── providers.ts               # Provider assembly
├── ui/
│   └── src/
│       ├── App.tsx
│       ├── components/
│       │   ├── MintPanel.tsx
│       │   ├── TransferPanel.tsx
│       │   ├── BurnPanel.tsx
│       │   └── BalanceDisplay.tsx
│       └── contexts/
│           └── WalletProvider.tsx
└── package.json
Enter fullscreen mode Exit fullscreen mode

The Compact Contract

The contract manages two things publicly: total token supply and the mint authority commitment. Everything about individual holdings — who owns what, how much — stays off-chain in the private state.

pragma language_version >= 0.22;

import CompactStandardLibrary;

// Track total supply publicly — useful for analytics without
// revealing individual balances
export ledger totalSupply: Field;

// Stores a commitment to the authority secret rather than the
// secret itself, so anyone can verify an operation is authorized
// without learning the secret key
export ledger mintAuthorityCommitment: Bytes<32>;

// Sequence counter prevents replay attacks on mint operations
export ledger mintSequence: Counter;

// Witness: the operator provides their secret key from private storage
witness authoritySecretKey(): Bytes<32>;

// Witness: fresh coin nonce for each operation, provided by the wallet
witness coinNonce(): Bytes<32>;

// --------------------------------------------------------------------------
// Constructor: set up initial authority commitment
// --------------------------------------------------------------------------

constructor(initialAuthorityCommitment: Bytes<32>) {
  mintAuthorityCommitment = initialAuthorityCommitment;
  totalSupply = 0 as Field;
}

// --------------------------------------------------------------------------
// Helper: derive a domain-separated public key from a secret
// --------------------------------------------------------------------------

export circuit authorityPublicKey(sk: Bytes<32>, seq: Bytes<32>): Bytes<32> {
  return persistentHash<Vector<3, Bytes<32>>>(
    [pad(32, "stkn:authority:"), seq, sk]
  );
}

// --------------------------------------------------------------------------
// Mint and immediately send to recipient
// This is the atomic pattern — the Merkle tree freshness constraint means
// you can't mint in one tx and spend in the next unless the first
// transaction is finalized. mint_and_send handles both in one circuit.
// --------------------------------------------------------------------------

export circuit mintAndSend(
  amount: Field,
  recipientCoinPublicKey: Bytes<32>
): [] {
  // Derive the authority public key from the private witness
  const seqBytes = mintSequence as Field as Bytes<32>;
  const derivedAuthority = authorityPublicKey(authoritySecretKey(), seqBytes);

  // Verify the caller is the mint authority
  assert(
    disclose(derivedAuthority) == mintAuthorityCommitment,
    "Not mint authority"
  );

  // Update public supply
  totalSupply = totalSupply + amount;
  mintSequence.increment(1);

  // Mint and immediately deliver — the recipient gets a committed coin
  // that's already in the Merkle tree when this tx finalizes
  const coin = mintShieldedToken(amount, recipientCoinPublicKey);
  evolveNonce(coin, coinNonce());
}

// --------------------------------------------------------------------------
// Transfer: send a portion of a coin to another address
// ShieldedSendResult contains the sent coin and change coin
// --------------------------------------------------------------------------

export circuit transfer(
  senderCoinNullifier: Bytes<32>,
  amount: Field,
  recipientCoinPublicKey: Bytes<32>
): [] {
  const result: ShieldedSendResult = sendShielded(
    senderCoinNullifier,
    amount,
    recipientCoinPublicKey
  );

  // evolveNonce updates the change coin's nonce so it's fresh
  // and not linkable to the original coin
  evolveNonce(result.changeCoin, coinNonce());
}

// --------------------------------------------------------------------------
// Burn: permanently destroy tokens
// --------------------------------------------------------------------------

export circuit burn(
  coinNullifier: Bytes<32>,
  amount: Field
): [] {
  const burnAddr = shieldedBurnAddress();
  sendImmediateShielded(coinNullifier, amount, burnAddr);

  // Reduce public supply to reflect the burn
  totalSupply = totalSupply - amount;
}
Enter fullscreen mode Exit fullscreen mode

A few things worth calling out:

The Merkle tree constraint. When mintShieldedToken runs, the resulting coin gets committed to the Merkle tree — but that commitment is only final once the transaction hits the chain. If you wrote a separate mint circuit and a spend circuit, a user couldn't spend the newly minted coin until the mint transaction confirmed. The mintAndSend pattern bypasses this by combining both in one atomic operation. The recipient gets a coin that's already committed when the transaction finalizes.

Authority commitment vs. key storage. The contract stores a commitment (a hash of the authority key) rather than the key itself. This way the authority can rotate their key by updating the commitment, and anyone can verify operations are authorized without ever seeing the actual secret. The authorityPublicKey helper uses domain separation ("stkn:authority:") to prevent cross-contract commitment reuse.

ShieldedSendResult. The transfer circuit calls sendShielded which returns a struct with two coins: the sent coin (going to the recipient) and the change coin (any leftover value returning to the sender). This mirrors how UTXO systems work — you consume an input entirely and create multiple outputs. The evolveNonce call on the change coin updates its nonce so it's not trivially linkable to the original.


TypeScript Witnesses

Witnesses are the bridge between private off-chain state and the ZK circuit. The contract declares what it needs (witness authoritySecretKey(): Bytes<32>), and the TypeScript implementation supplies it at proof-generation time.

// contract/src/witnesses.ts
import { WitnessContext } from '@midnight-ntwrk/compact-runtime';
import { Ledger } from './managed/shielded-token/contract/index.js';

export type ShieldedTokenPrivateState = {
  readonly secretKey: Uint8Array;
  readonly pendingNonce: Uint8Array;
};

export const createPrivateState = (
  secretKey: Uint8Array,
  pendingNonce?: Uint8Array
): ShieldedTokenPrivateState => ({
  secretKey,
  pendingNonce: pendingNonce ?? crypto.getRandomValues(new Uint8Array(32)),
});

export const witnesses = {
  authoritySecretKey: ({
    privateState,
  }: WitnessContext<Ledger, ShieldedTokenPrivateState>): [
    ShieldedTokenPrivateState,
    Uint8Array,
  ] => [privateState, privateState.secretKey],

  coinNonce: ({
    privateState,
  }: WitnessContext<Ledger, ShieldedTokenPrivateState>): [
    ShieldedTokenPrivateState,
    Uint8Array,
  ] => {
    // Generate a fresh nonce and store it in the private state
    // so subsequent operations get a consistent value within
    // a single transaction
    const nonce = crypto.getRandomValues(new Uint8Array(32));
    return [{ ...privateState, pendingNonce: nonce }, nonce];
  },
};
Enter fullscreen mode Exit fullscreen mode

The structure here follows the same pattern as the bulletin board example from the official Midnight repository. Each witness function receives a WitnessContext containing the current ledger state, the private state, and the contract address. It returns a tuple of the (possibly updated) private state and the witness value the circuit needs.


Provider Assembly

This is where Midnight development gets verbose compared to other chains. Midnight.js uses a modular provider architecture with seven pluggable components. In a browser context, most of these connect through the Lace wallet extension.

// api/src/providers.ts
import { NetworkId, setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
import { httpClientProofProvider } from '@midnight-ntwrk/midnight-js-http-client-proof-provider';
import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
import { ConnectedAPI, InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
import {
  Binding,
  FinalizedTransaction,
  Proof,
  SignatureEnabled,
  Transaction,
} from '@midnight-ntwrk/ledger-v8';
import { fromHex, toHex } from '@midnight-ntwrk/compact-runtime';
import type { UnboundTransaction } from '@midnight-ntwrk/midnight-js-types';
import { interval, filter, take, timeout, throwError, concatMap, map, firstValueFrom } from 'rxjs';
import semver from 'semver';
import { inMemoryPrivateStateProvider } from './private-state-provider';
import type { ShieldedTokenProviders } from './index';

const COMPATIBLE_CONNECTOR_API_VERSION = '4.x';

export async function buildProviders(networkId: NetworkId): Promise<ShieldedTokenProviders> {
  setNetworkId(networkId);

  const connectedAPI = await connectWallet(networkId);
  const config = await connectedAPI.getConfiguration();
  const shieldedAddresses = await connectedAPI.getShieldedAddresses();

  const zkConfigProvider = new FetchZkConfigProvider(
    window.location.origin,
    fetch.bind(window)
  );

  return {
    privateStateProvider: inMemoryPrivateStateProvider(),
    zkConfigProvider,
    proofProvider: httpClientProofProvider(config.proverServerUri!, zkConfigProvider),
    publicDataProvider: indexerPublicDataProvider(config.indexerUri, config.indexerWsUri),
    walletProvider: {
      getCoinPublicKey: () => shieldedAddresses.shieldedCoinPublicKey,
      getEncryptionPublicKey: () => shieldedAddresses.shieldedEncryptionPublicKey,
      balanceTx: async (tx: UnboundTransaction): Promise<FinalizedTransaction> => {
        const serialized = toHex(tx.serialize());
        const balanced = await connectedAPI.balanceUnsealedTransaction(serialized);
        return Transaction.deserialize<SignatureEnabled, Proof, Binding>(
          'signature', 'proof', 'binding', fromHex(balanced.tx)
        );
      },
    },
    midnightProvider: {
      submitTx: async (tx: FinalizedTransaction): Promise<string> => {
        await connectedAPI.submitTransaction(toHex(tx.serialize()));
        return tx.identifiers()[0];
      },
    },
  };
}

function getFirstCompatibleWallet(): InitialAPI | undefined {
  if (!window.midnight) return undefined;
  return Object.values(window.midnight).find(
    (w): w is InitialAPI =>
      !!w &&
      typeof w === 'object' &&
      'apiVersion' in w &&
      semver.satisfies((w as InitialAPI).apiVersion, COMPATIBLE_CONNECTOR_API_VERSION)
  );
}

async function connectWallet(networkId: string): Promise<ConnectedAPI> {
  return firstValueFrom(
    interval(100).pipe(
      map(() => getFirstCompatibleWallet()),
      filter((w): w is InitialAPI => !!w),
      take(1),
      timeout({
        first: 5_000,
        with: () => throwError(() => new Error('Midnight Lace wallet not found. Is the extension installed?')),
      }),
      concatMap(async (initialAPI) => {
        const connectedAPI = await initialAPI.connect(networkId);
        await connectedAPI.getConnectionStatus();
        return connectedAPI;
      })
    )
  );
}
Enter fullscreen mode Exit fullscreen mode

The wallet provider is where most of the integration work lives. The balanceTx function serializes the unbalanced transaction (the output of local circuit execution), sends it to the Lace wallet for signing and balancing, then deserializes the finalized result. The wallet handles coin selection for fees transparently — your contract code doesn't need to think about fee inputs.


The API Layer

The API class wraps contract interactions and state observation into something the React layer can use without knowing about circuits or providers.

// api/src/index.ts
import { deployContract, findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';
import { combineLatest, map, from, type Observable } from 'rxjs';
import { ContractAddress, toHex } from '@midnight-ntwrk/compact-runtime';
import * as Contract from '../../contract/src/managed/shielded-token/contract/index.js';
import { CompiledShieldedTokenContract } from '../../contract/src/index.js';
import {
  createPrivateState,
  ShieldedTokenPrivateState,
} from '../../contract/src/witnesses.js';
import { buildProviders } from './providers.js';
import type { NetworkId } from '@midnight-ntwrk/midnight-js-network-id';

export type TokenState = {
  totalSupply: bigint;
  mintAuthorityCommitment: string;
};

const PRIVATE_STATE_KEY = 'shielded-token';

export class ShieldedTokenAPI {
  readonly contractAddress: ContractAddress;
  readonly state$: Observable<TokenState>;

  private constructor(
    private deployedContract: any,
    providers: any
  ) {
    this.contractAddress = deployedContract.deployTxData.public.contractAddress;
    this.state$ = combineLatest([
      providers.publicDataProvider.contractStateObservable(this.contractAddress, { type: 'latest' }),
      from(providers.privateStateProvider.get(PRIVATE_STATE_KEY) as Promise<ShieldedTokenPrivateState>),
    ]).pipe(
      map(([contractState]) => {
        const ledger = Contract.ledger(contractState.data);
        return {
          totalSupply: ledger.totalSupply,
          mintAuthorityCommitment: toHex(ledger.mintAuthorityCommitment),
        };
      })
    );
  }

  async mintAndSend(amount: bigint, recipientCoinPublicKey: string): Promise<void> {
    await this.deployedContract.callTx.mintAndSend(amount, recipientCoinPublicKey);
  }

  async transfer(
    coinNullifier: string,
    amount: bigint,
    recipientCoinPublicKey: string
  ): Promise<void> {
    await this.deployedContract.callTx.transfer(coinNullifier, amount, recipientCoinPublicKey);
  }

  async burn(coinNullifier: string, amount: bigint): Promise<void> {
    await this.deployedContract.callTx.burn(coinNullifier, amount);
  }

  static async deploy(
    networkId: NetworkId,
    authoritySecretKey: Uint8Array
  ): Promise<ShieldedTokenAPI> {
    const providers = await buildProviders(networkId);
    const deployed = await deployContract(providers, {
      compiledContract: CompiledShieldedTokenContract,
      privateStateId: PRIVATE_STATE_KEY,
      initialPrivateState: createPrivateState(authoritySecretKey),
    });
    return new ShieldedTokenAPI(deployed, providers);
  }

  static async join(
    networkId: NetworkId,
    contractAddress: ContractAddress,
    userSecretKey: Uint8Array
  ): Promise<ShieldedTokenAPI> {
    const providers = await buildProviders(networkId);
    const deployed = await findDeployedContract(providers, {
      contractAddress,
      compiledContract: CompiledShieldedTokenContract,
      privateStateId: PRIVATE_STATE_KEY,
      initialPrivateState: createPrivateState(userSecretKey),
    });
    return new ShieldedTokenAPI(deployed, providers);
  }
}
Enter fullscreen mode Exit fullscreen mode

React Frontend

The UI has four panels: a deploy/join screen, a mint panel for the authority, a transfer panel for any holder, and a burn panel. Here's how they fit together.

Wallet Context

// ui/src/contexts/WalletProvider.tsx
import React, { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { ShieldedTokenAPI } from '../../api/src/index.js';
import type { NetworkId } from '@midnight-ntwrk/midnight-js-network-id';

type WalletState =
  | { status: 'disconnected' }
  | { status: 'connecting' }
  | { status: 'connected'; api: ShieldedTokenAPI }
  | { status: 'error'; message: string };

type WalletContextValue = {
  state: WalletState;
  deploy: (networkId: NetworkId, authorityKey: Uint8Array) => Promise<void>;
  join: (networkId: NetworkId, address: string, userKey: Uint8Array) => Promise<void>;
};

const WalletContext = createContext<WalletContextValue | null>(null);

export const WalletProvider = ({ children }: { children: ReactNode }) => {
  const [state, setState] = useState<WalletState>({ status: 'disconnected' });

  const deploy = useCallback(async (networkId: NetworkId, authorityKey: Uint8Array) => {
    setState({ status: 'connecting' });
    try {
      const api = await ShieldedTokenAPI.deploy(networkId, authorityKey);
      setState({ status: 'connected', api });
    } catch (e) {
      setState({ status: 'error', message: String(e) });
    }
  }, []);

  const join = useCallback(async (networkId: NetworkId, address: string, userKey: Uint8Array) => {
    setState({ status: 'connecting' });
    try {
      const api = await ShieldedTokenAPI.join(networkId, address, userKey);
      setState({ status: 'connected', api });
    } catch (e) {
      setState({ status: 'error', message: String(e) });
    }
  }, []);

  return (
    <WalletContext.Provider value={{ state, deploy, join }}>
      {children}
    </WalletContext.Provider>
  );
};

export const useWallet = () => {
  const ctx = useContext(WalletContext);
  if (!ctx) throw new Error('useWallet must be inside WalletProvider');
  return ctx;
};
Enter fullscreen mode Exit fullscreen mode

Mint Panel

The mint panel is only useful to the authority. In a real deployment you'd show/hide it based on whether the connected wallet's key matches the authority commitment.

// ui/src/components/MintPanel.tsx
import React, { useState } from 'react';
import { useWallet } from '../contexts/WalletProvider.js';

export const MintPanel: React.FC = () => {
  const { state } = useWallet();
  const [amount, setAmount] = useState('');
  const [recipient, setRecipient] = useState('');
  const [status, setStatus] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  if (state.status !== 'connected') return null;

  const handleMint = async () => {
    if (!amount || !recipient) return;
    setPending(true);
    setStatus(null);
    try {
      await state.api.mintAndSend(BigInt(amount), recipient);
      setStatus(`Minted ${amount} tokens to ${recipient.slice(0, 12)}…`);
      setAmount('');
      setRecipient('');
    } catch (e) {
      setStatus(`Error: ${String(e)}`);
    } finally {
      setPending(false);
    }
  };

  return (
    <div className="panel">
      <h2>Mint Tokens</h2>
      <input
        type="number"
        placeholder="Amount"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
      <input
        type="text"
        placeholder="Recipient coin public key"
        value={recipient}
        onChange={(e) => setRecipient(e.target.value)}
      />
      <button onClick={handleMint} disabled={pending}>
        {pending ? 'Minting…' : 'Mint and Send'}
      </button>
      {status && <p>{status}</p>}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Transfer Panel

// ui/src/components/TransferPanel.tsx
import React, { useState } from 'react';
import { useWallet } from '../contexts/WalletProvider.js';

export const TransferPanel: React.FC = () => {
  const { state } = useWallet();
  const [nullifier, setNullifier] = useState('');
  const [amount, setAmount] = useState('');
  const [recipient, setRecipient] = useState('');
  const [status, setStatus] = useState<string | null>(null);
  const [pending, setPending] = useState(false);

  if (state.status !== 'connected') return null;

  const handleTransfer = async () => {
    if (!nullifier || !amount || !recipient) return;
    setPending(true);
    setStatus(null);
    try {
      await state.api.transfer(nullifier, BigInt(amount), recipient);
      setStatus('Transfer submitted.');
      setNullifier('');
      setAmount('');
      setRecipient('');
    } catch (e) {
      setStatus(`Error: ${String(e)}`);
    } finally {
      setPending(false);
    }
  };

  return (
    <div className="panel">
      <h2>Transfer Tokens</h2>
      <input
        placeholder="Coin nullifier (from your wallet)"
        value={nullifier}
        onChange={(e) => setNullifier(e.target.value)}
      />
      <input
        type="number"
        placeholder="Amount to send"
        value={amount}
        onChange={(e) => setAmount(e.target.value)}
      />
      <input
        placeholder="Recipient coin public key"
        value={recipient}
        onChange={(e) => setRecipient(e.target.value)}
      />
      <button onClick={handleTransfer} disabled={pending}>
        {pending ? 'Sending…' : 'Transfer'}
      </button>
      {status && <p>{status}</p>}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Balance Display

Balance in a shielded system doesn't come from querying a public ledger — it comes from your wallet's knowledge of coins you own. The getShieldedBalances() call on the connected wallet API returns what the wallet has scanned for you.

// ui/src/components/BalanceDisplay.tsx
import React, { useEffect, useState } from 'react';

type BalanceDisplayProps = {
  connectedAPI: any;
};

export const BalanceDisplay: React.FC<BalanceDisplayProps> = ({ connectedAPI }) => {
  const [balances, setBalances] = useState<Record<string, bigint>>({});

  useEffect(() => {
    let active = true;
    const poll = async () => {
      try {
        const result = await connectedAPI.getShieldedBalances();
        if (active) setBalances(result);
      } catch {
        // wallet not ready yet
      }
    };

    poll();
    const interval = setInterval(poll, 10_000);
    return () => {
      active = false;
      clearInterval(interval);
    };
  }, [connectedAPI]);

  return (
    <div className="balance-display">
      <h3>Shielded Balances</h3>
      {Object.entries(balances).length === 0 ? (
        <p>No shielded tokens found.</p>
      ) : (
        <ul>
          {Object.entries(balances).map(([tokenType, amount]) => (
            <li key={tokenType}>
              {tokenType.slice(0, 16)}… : {amount.toString()}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Running Locally

The minimal stack for local testing:

# Start the proof server
docker run -p 6300:6300 midnightntwrk/proof-server:8.0.3 midnight-proof-server -v

# In a separate terminal, start the local Midnight node
# (uses the midnight-node-docker image)
docker run -p 9944:9944 midnightntwrk/midnight-node:latest

# Start the indexer
docker run -p 8088:8088 midnightntwrk/midnight-indexer:latest

# Compile the contract
compact compile contract/src/shielded-token.compact contract/src/managed/shielded-token.compact

# Run the UI dev server
cd ui && yarn dev
Enter fullscreen mode Exit fullscreen mode

When Lace connects in the browser, point it to your local node at ws://localhost:9944. You should see wallet connection succeed and the deploy/join UI appear.


What to Watch For

Transaction timing. ZK proof generation takes a few seconds locally, more on constrained hardware. The UI should reflect a pending state throughout — don't let users double-submit by clicking multiple times.

Change coin management. After a transfer, the ShieldedSendResult.changeCoin needs to be tracked. The wallet handles this automatically if you're using Lace — it scans for coins belonging to your key. If you're building headless tooling, you need to persist the change coin yourself.

The freshness constraint. If you write a circuit that mints a coin and returns it without immediately delivering it to a recipient, that coin can't be spent until the mint transaction has fully confirmed and the Merkle tree has updated. Use mintAndSend whenever possible to avoid building async dependency chains in your UX.

Wallet compatibility. The dApp connector API version check (4.x) matters. Older wallet versions don't support the balanceUnsealedTransaction method. Check for compatibility before proceeding, and show a clear error if the user's wallet is outdated.


Going Further

A few natural extensions from here:

  • Add a token allowlist — only approved addresses can receive minted tokens
  • Build a multi-signature authority using threshold key derivation in the witnesses
  • Replace in-memory private state with LevelDB storage for persistence across page refreshes
  • Add a transaction history view using the indexer's GraphQL subscription API

The Midnight developer forum is active and the core team responds quickly to questions about the ZK layer and SDK specifics. Worth checking before you spend hours debugging a proof-generation issue that's already been triaged.

Top comments (0)