DEV Community

Syv
Syv

Posted on • Edited on

[Tutorial] Building a full-stack Midnight DApp: zero-knowledge attestation protocol

📁 Full source code and installation steps: midnight-apps/fullstack-dapp

Target audience: Developers

Within the next few sections, you go through smart contract compilation and focus on the DApp lifecycle.

Prerequisites

  • Node.js installed (v20+)
  • A Midnight Wallet (e.g., 1AM or Lace)
  • Some Preprod faucet NIGHT tokens
  • A package.json with the needed packages
    • @midnight-ntwrk/compact-runtime
    • @midnight-ntwrk/dapp-connector-api
    • @midnight-ntwrk/ledger-v8
    • @midnight-ntwrk/midnight-js-contracts
    • @midnight-ntwrk/midnight-js-dapp-connector-proof-provider
    • @midnight-ntwrk/midnight-js-fetch-zk-config-provider
    • @midnight-ntwrk/midnight-js-http-client-proof-provider
    • @midnight-ntwrk/midnight-js-indexer-public-data-provider
    • @midnight-ntwrk/midnight-js-level-private-state-provider
    • @midnight-ntwrk/midnight-js-network-id
    • @midnight-ntwrk/midnight-js-node-zk-config-provider
    • @midnight-ntwrk/midnight-js-types
    • @midnight-ntwrk/wallet-sdk-address-format
    • @midnight-ntwrk/wallet-sdk-dust-wallet
    • @midnight-ntwrk/wallet-sdk-facade
    • @midnight-ntwrk/wallet-sdk-hd
    • @midnight-ntwrk/wallet-sdk-shielded
    • @midnight-ntwrk/wallet-sdk-unshielded-wallet
    • @scure/bip39, cors, express, postgres, react, react-dom, react-router-dom, rxjs, semver, vite-plugin-node-polyfills, vite-plugin-top-level-await, vite-plugin-wasm, ws, zustand

1. Building the smart contract

For this attestation you need two core witnesses. localSecretKey() will be used to fetch the user's secret key, and findAgePath(commit: Bytes<32>) fetches the required cryptographic Merkle path from the local private state and passes it to the circuit(s) as needed.

witness localSecretKey(): Bytes<32>;
witness findAgePath(commit: Bytes<32>): MerkleTreePath<10, Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode

You also need some essential ledgers:

  • authority stores the public key of the admin (only the authority can issue attestations)
  export sealed ledger authority: Bytes<32>;
Enter fullscreen mode Exit fullscreen mode
  • ageCommitments uses HistoricMerkleTree. Think of it as a secure cryptographic folder. Use it instead of a list for privacy. Later, the user can mathematically prove their commitment is inside this tree without the blockchain knowing which leaf belongs to them.
  export ledger ageCommitments: HistoricMerkleTree<10, Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode
  • usedNullifiers: whenever a user proves their age, a circuit generates a unique nullifier hash from their secret key, so if they try to prove a second time, the circuit sees their nullifier is already present.
  export ledger usedNullifiers: Set<Bytes<32>>;
Enter fullscreen mode Exit fullscreen mode
  • totalAgeProofs is a simple ledger. It is incremented later in the proveAge() circuit
  export ledger totalAgeProofs: Counter;
Enter fullscreen mode Exit fullscreen mode

You also need a simple constructor to initialize the smart contract. Constructor arguments are witness data — in this case, authoritySk.

constructor(authoritySk: Bytes<32>) {
    // authoritySk is a constructor argument (witness data) — disclose required
    authority = disclose(publicKey(authoritySk));
}
Enter fullscreen mode Exit fullscreen mode

The first circuit is attestAge(). It fetches the secret key via the localSecretKey() witness and then checks whether the entity attempting to run attestAge() is an authority.

export circuit attestAge(userCommit: Bytes<32>): [] {
    const sk = localSecretKey();
    assert(authority == disclose(publicKey(sk)), "Not the authority");
    ageCommitments.insert(disclose(userCommit));
}
Enter fullscreen mode Exit fullscreen mode

But as you can see, attestAge() requires a userCommit. The user can forward a commitment to the authority, so userCommit is an authority input to grant the user an attestation that they can use to prove their age.

Create a private helper circuit commitment() to compute a deterministic hash with the user's secret key and a domain separator.

circuit commitment(sk: Bytes<32>, domain: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>(
        [pad(32, "mydapp:commit:v1"), domain, sk]
    );
}
Enter fullscreen mode Exit fullscreen mode

You can then use it in a getCommitment() circuit. Because it is an export, the frontend can execute this off-chain to generate the commitment.

export circuit getCommitment(sk: Bytes<32>, domain: Bytes<32>): Bytes<32> {
    return commitment(sk, domain);
}
Enter fullscreen mode Exit fullscreen mode

The proveAge() circuit fetches localSecretKey() via witness and defines domain as age for this circuit. Compute a commitment using both values, then call the findAgePath(commit) witness to check whether an active attestation by an authority exists in the Merkle Tree. If there is, return whether the user has a valid attestation.

You then generate a nullifier. To understand why you need it, look at the privacy guarantees of the smart contract. If a user proves they are over 18 once, the blockchain only sees TRUE; it does not know who proved it, so without a nullifier, a malicious user can spam the protocol with hundreds of generated on-chain proofs.

circuit nullifier(sk: Bytes<32>, domain: Bytes<32>): Bytes<32> {
    return persistentHash<Vector<3, Bytes<32>>>(
        [pad(32, "mydapp:nullify:v1"), domain, sk]
    );
}
Enter fullscreen mode Exit fullscreen mode

The full proveAge() demonstrates how the nullifier is implemented to address the issue.

export circuit proveAge(): Boolean {
    const sk = localSecretKey();
    const domain = pad(32, "age");
    const commit = commitment(sk, domain);
    const path = findAgePath(commit);

    assert(
        ageCommitments.checkRoot(disclose(merkleTreePathRoot<10, Bytes<32>>(path))),
        "Age not attested"
    );

    const nul = nullifier(sk, domain);
    assert(!usedNullifiers.member(disclose(nul)), "Age proof already used");
    usedNullifiers.insert(disclose(nul));
    totalAgeProofs.increment(1);

    return disclose(true);
}
Enter fullscreen mode Exit fullscreen mode

Note: The example uses domain because the smart contract is set to handle multiple types of attestations (age, residency, certifications). Refer to the GitHub repo for more information.

You now need to compile this smart contract, but first install compact dev tools

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

Then run compact compile contracts/Contract.compact src/contracts. In this case, you can assume src/contracts is a directory your frontend and API will use to load the compiled smart contract (ZKIR, keys, etc.).


2. Wallet, identity & providers

You begin by setting up a wallet connection. For this you need DApp connector API v4 installed

import type { InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
Enter fullscreen mode Exit fullscreen mode

You can discover installed wallets using InitialAPI[]. Each object is injected by the browser-installed wallet extensions. In this case, there are 3 wallets installed (1AM, Lace, GSD).

interface WalletSelectModalProps {
  isOpen: boolean;
  onClose: () => void;
  wallets: InitialAPI[];
  onSelect: (wallet: InitialAPI) => void;
  connecting: boolean;
}
Enter fullscreen mode Exit fullscreen mode

View the full WalletSelectModal.tsx on GitHub.

            {wallets.map((wallet) => {
              const iconUrl = getWalletIcon(wallet.rdns);
                // rest of the code
            })}
Enter fullscreen mode Exit fullscreen mode

Wallet Selection UI

Create a Zustand hook in useWallet.ts to manage the wallet lifecycle. It is a Zustand store that manages the entire wallet lifecycle, and it scans for installed wallets.

// 1. Find injected wallets
export function getCompatibleWallets(): InitialAPI[] {
  if (!window.midnight) return [];
  return Object.values(window.midnight).filter(
    (wallet): wallet is InitialAPI =>
      !!wallet &&
      typeof wallet === 'object' &&
      'apiVersion' in wallet &&
      semver.satisfies(wallet.apiVersion, COMPATIBLE_CONNECTOR_API_VERSION)
  );
}
Enter fullscreen mode Exit fullscreen mode

Then create a wallet connection by calling:

      const connectedApi = await wallet.connect(networkId);
      const status = await connectedApi.getConnectionStatus();
Enter fullscreen mode Exit fullscreen mode

Note: You reuse useWallet.ts across all the frontend pages (Deploy, Attest, Prove)

Derive your identity deterministically from two inputs: userPassword and shieldedAddresses.shieldedCoinPublicKey. Hash them with domain-specific salts (User/Authority) to generate attest_sk (prove identity) for users and authoritySk (deploy/attest identity) for authorities.

This derivation is used everywhere, including Deploy to create the authority key, Attest to sign attestations, and Prove to generate ZK proofs. Same wallet + same password always produces the same key, so you do not lose your identity even if you clear browser storage. However, you would lose it if you forget your password.

This goes through a lock screen / session model, as shown below

Lock Screen UI

const masterKey = await deriveKeyFromPassword(userPassword, shieldedAddresses.shieldedCoinPublicKey);
Enter fullscreen mode Exit fullscreen mode

The most crucial step of this project is making sure the witnesses are correctly set up. You need a witnesses.ts file for this.

Point index.js to the path where you compiled the smart contract previously

import type { WitnessContext } from '@midnight-ntwrk/compact-runtime';
import type { Ledger } from '../contracts/managed/attest/contract/index.js';
Enter fullscreen mode Exit fullscreen mode

You need to define AttestPrivateState. This defines the shape of the smart contract's private state. You only need the secretKey, and createAttestPrivateState is a helper that constructs an object.

export type AttestPrivateState = {
  readonly secretKey: Uint8Array;
};

export const createAttestPrivateState = (
  secretKey: Uint8Array,
): AttestPrivateState => ({
  secretKey,
});
Enter fullscreen mode Exit fullscreen mode

Then you have two witnesses set up. localSecretKey() will be used to fetch the user's secret key, and findAgePath(commit: Bytes<32>) fetches the required cryptographic Merkle path from the local private state and passes it to the circuit(s) as needed.

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

  findAgePath: (
    { privateState, ledger }: WitnessContext<Ledger, AttestPrivateState>,
    commit: Uint8Array,
  ) => {
    const path = ledger.ageCommitments.findPathForLeaf(commit);
    if (!path) throw new Error('Age commitment not found in tree');
    return [privateState, path];
  },
};
Enter fullscreen mode Exit fullscreen mode

You can now proceed to set up the providers, as shown below:

  • privateStateProvider: uses levelPrivateStateProvider for persistent localStorage (IndexedDB)
  • publicDataProvider: reads on-chain state from the indexer
  • zkConfigProvider: loads FetchZkConfigProvider — compiled verifiers, keys...
  • proofProvider: generates zero-knowledge proofs on your proof server
  • walletProvider: handles balanceTx via connectedApi.balanceUnsealedTransaction
  • midnightProvider: submits transactions via connectedApi.submitTransaction
      const providers = {
        privateStateProvider: privateState,
        publicDataProvider: indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS),
        zkConfigProvider: zkConfig,
        proofProvider,
        walletProvider,
        midnightProvider,
      };
Enter fullscreen mode Exit fullscreen mode

3. Deploy the smart contract

You can now proceed to create Deploy.tsx

Begin by setting the network — in this case, it's preprod

import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';

setNetworkId('preprod');
Enter fullscreen mode Exit fullscreen mode

Run a proof server locally.

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

Then build the smart contract using CompiledContract API from @midnight-ntwrk/compact-js

      const cc = CompiledContract.make('attest', contractModule.Contract);
      const ccWithWitnesses = CompiledContract.withWitnesses(cc, witnesses as any);
      const compiledContract = CompiledContract.withCompiledFileAssets(ccWithWitnesses, ZK_ARTIFACTS_PATH);
Enter fullscreen mode Exit fullscreen mode

Then the next step is to deploy, passing authoritySk as an argument. This makes the admin deploying the smart contract an authority with the ability to create attestations.

      const deployed = await deployContract(providers as any, {
        compiledContract,
        privateStateId: 'attestState',
        initialPrivateState,
        args: [authoritySk],
      } as any);
Enter fullscreen mode Exit fullscreen mode

You then retrieve the deployed address using

const address = deployed.deployTxData.public.contractAddress;
Enter fullscreen mode Exit fullscreen mode

Deploy Success UI


4. Generate a commitment

A commitment is a tunnel between your private identity and the public ledger. It is a deterministic hash computed from the secret key (derived from userPassword + shieldedAddresses.shieldedCoinPublicKey) and a domain separator such as age (it could be anything — residence, etc.). Because the hash is one-way, anybody can see the commitment on-chain without learning your secret key. This is the core of the privacy model: the authority knows that you are attested but never learns who you are. However, be sure to force a strong password because an attacker can attempt to compute a similar commitment in many ways.

Generate the commitment off-chain in Home.tsx. The getCommitment circuit takes two inputs: your secretKey (passed as witness from your private state) and a domain such as age, residency, or certification. The domain acts as a namespace, so a commitment for age is completely different from a commitment for residency even when both use the same secret key.

      const commitment = contractModule.pureCircuits.getCommitment(
        secretKey,
        domainToBytes(domain)
      );
Enter fullscreen mode Exit fullscreen mode

The output is a 32-byte hash. Send this commitment to the authority through any channel of communication. The authority never sees your secret key; they only receive the commitment. Once the authority creates an attestation, they insert the commitment into the ageCommitments Merkle tree on-chain. You can then use it to generate a zero-knowledge proof (ZKP) validating that your secret key produced a commitment that exists in the tree.

When the commitment is deterministic, the same wallet and password always generate the same hash. The current design does not rely on localStorage. Derive the secret key from the public key and a user password instead — this prevents losing your identity.

Commitment Builder UI


5. Attest a credential

The authority creates an attestation by selecting type and pasting the user commitment.

This page goes through a couple of steps:

Set up the providers

The provider bundle is the bridge between your frontend and the Midnight network. Each provider handles a specific responsibility:

  • privateStateProvider manages your local encrypted state (secret keys, Merkle paths) via IndexedDB
  • publicDataProvider reads on-chain data from the indexer without submitting transactions
  • zkConfigProvider loads the compiled ZK circuit artifacts (proving keys, verifier keys)
  • proofProvider forwards proof-generation requests to your local proof server on port 6300
  • walletProvider handles transaction balancing: it serializes the unsigned transaction, sends it to your wallet extension for fee coverage and signing, then returns the balanced transaction
  • midnightProvider submits the final signed transaction to the network and returns the transaction identifier
      const providers = {
        privateStateProvider,
        publicDataProvider: indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS),
        zkConfigProvider: zkConfig,
        proofProvider: httpClientProofProvider(PROOF_SERVER, zkConfig),
        walletProvider: {
          getCoinPublicKey(): string {
            return shieldedAddresses.shieldedCoinPublicKey;
          },
          getEncryptionPublicKey(): string {
            return shieldedAddresses.shieldedEncryptionPublicKey;
          },
          async balanceTx(tx: unknown, _ttl?: Date): Promise<unknown> {
            const serializedTx = toHex((tx as { serialize: () => Uint8Array }).serialize());
            const received = await connectedApi.balanceUnsealedTransaction(serializedTx);
            return Transaction.deserialize(
              'signature', 'proof', 'binding', fromHex(received.tx)
            );
          },
        },
        midnightProvider: {
          async submitTx(tx: unknown): Promise<string> {
            const txData = tx as { serialize: () => Uint8Array; identifiers: () => string[] };
            await connectedApi.submitTransaction(toHex(txData.serialize()));
            return txData.identifiers()?.[0] ?? '';
          },
        },
      };
Enter fullscreen mode Exit fullscreen mode

Build the smart contract interface

Reconstruct the deployed smart contract's runtime interface. This is a three-step process:

  1. CompiledContract.make() creates a base contract descriptor from the generated Compact module
  2. CompiledContract.withWitnesses() binds your TypeScript witness implementations so the runtime knows how to resolve localSecretKey() and findAgePath() when the circuit calls them
  3. CompiledContract.withCompiledFileAssets() loads the ZK artifacts from disk — the proving keys, verifier keys, and circuit definitions that the proof server needs
      const cc = CompiledContract.make('attest', contractModule.Contract);
      const ccWithWitnesses = CompiledContract.withWitnesses(cc, witnesses as any);
      const finalContract = CompiledContract.withCompiledFileAssets(
        ccWithWitnesses,
        ZK_ARTIFACTS_PATH
      );
Enter fullscreen mode Exit fullscreen mode

Connect to the deployed smart contract

findDeployedContract() serves multiple purposes. It fetches the on-chain smart contract state, then extracts the embedded verifier keys and compares them head-to-head with the compiled artifacts generated after running compact contracts/Contract.compact {compile_path}. If there is a mismatch between the verifier keys, it throws an error instead of proceeding. This acts like protection against accidentally interacting with the wrong smart contract.

findDeployedContract() also initializes your local private state. Pass authoritySk to createAttestPrivateState() so the witness localSecretKey() resolves correctly when the circuit runs. If the private state ID collides with another role — for example, the prover state — the authentication step would fail with an error, so keeping attestState separate is crucial.

      await findDeployedContract(providers as never, {
        contractAddress,
        compiledContract: finalContract as never,
        privateStateId,
        initialPrivateState: createAttestPrivateState(authoritySk),
      });
Enter fullscreen mode Exit fullscreen mode

Create the transaction interface

createCircuitCallTxInterface() builds a proxy over the deployed smart contract. Instead of manually building transactions, you can call methods such as txInterface.attestAge(commitBytes) directly, and the installed library handles constructing the transaction.

It looks up the circuit definition, wires the witnesses, prepares the private state, and returns a transaction builder that you can execute directly.

      const txInterface = createCircuitCallTxInterface(
        providers as never,
        finalContract as never,
        contractAddress,
        privateStateId
      );
Enter fullscreen mode Exit fullscreen mode

Attestation execution

Calling attestAge() triggers the full Midnight transaction process:

  1. Witness resolution: localSecretKey() fetches authoritySk from the private state
  2. Authority check: the circuit verifies whether the user is an authority by checking if publicKey(sk) == authority on-chain
  3. A zero-knowledge proof is then generated by the proof server, proving that the authority check passes.
  4. Transaction balancing: walletProvider sends the unsigned transaction to your wallet extension/provider, which calculates fees and signs it.
  5. Submission: midnightProvider broadcasts the signed transaction to the Midnight network
  6. Confirmation: the network includes the transaction in a block and inserts the commitment into the ageCommitments Merkle Tree. The user can then use that commitment to prove their age.
result = await (txInterface as any).attestAge(commitBytes);
Enter fullscreen mode Exit fullscreen mode

Attestation UI

Now the user has a valid attestation under their unique commitment, which was computed using the secret key passed through witness.


6. Prove your eligibility

The user verifies and generates a proof in Prove.tsx

handleProve() goes through a similar flow to handleAttest(), except that it calls the proveAge() circuit and uses attestSk instead of authoritySk for authentication.

privateStateId is also different. Attest passes attestState, while Prove passes attestProverState — otherwise the transaction fails with Unsupported state unable to authenticate data

initialPrivateState differs too. Attest passes createAttestPrivateState(authoritySk), while Prove passes { secretKey: attestSk } — the prover identity key, not the authority key.

      const txInterface = createCircuitCallTxInterface(
        providers as never,
        finalContract as never,
        contractAddress,
        privateStateId
      );

      let result;
      console.log('[DEBUG] Searching for commitment in tree...');
// proofType refers to user input domain separator used for commitment generation
      switch (proofType) {
        case 'residency':
          result = await (txInterface as any).proveResidency();
          break;
        case 'certification':
          result = await (txInterface as any).proveCertification();
          break;
        default:
          result = await (txInterface as any).proveAge();
      }
Enter fullscreen mode Exit fullscreen mode

Important: Because the commitment is generated with a domain separator eg: age, residency. You can decide to have your UI compute a commitment based on the type of proof selected. However if you gave the attestation authority a commitment generated using residency domain separator and you try to prove age then proof generation will fail if no attestation for age exists.

Prove UI

Now look at the nullifier in action. In this example, the age has already been verified, as you can see in the explorer: proveAge

https://explorer.1am.xyz/tx/a6b14a14c15d486bc547a449342cc196036be74e4c04699f2a6a1be1ebd03ccb

Execution successful

Explorer Success

The wallet returns Proof already used — each credential can only be proven once. The console shows: Prove error: Error: Unexpected error executing scoped transaction '<unnamed>': Error: failed assert: Age proof already used

This means the smart contract is working exactly as intended — the nullifier is recognized as already used.

Proof Already Used Error

7. State read flow

When you unlock the Home page, it checks whether you are an authority or not. It does this by querying the smart contract state from the indexer. The raw state data is fed into contractModule.ledger(), which deserializes it into typed ledger fields, including authority: Bytes<32>.

        // Compute publicKey(authoritySk) using the same hash as the contract
        const enc = new TextEncoder();
        const pad = new Uint8Array(32);
        pad.set(enc.encode('mydapp:pk:v1'));
        const descriptor = new CompactTypeVector(2, new CompactTypeBytes(32));
        const authorityPublicKey = persistentHash(descriptor, [pad, authoritySk]);

        const provider = indexerPublicDataProvider(INDEXER_HTTP, INDEXER_WS);
        const state = await provider.queryContractState(contractAddress);
        if (!state) return;

        const ledger = contractModule.ledger(state.data);
        const onChainAuthority = ledger.authority;
Enter fullscreen mode Exit fullscreen mode

The frontend derives your authority secret key from the same master key that unlocked your identity. It then hashes that key through the smart contract's publicKey() circuit to produce your authority public key. If the on-chain authority matches your computed public key byte-for-byte, a green badge appears saying "You are the authority". If there is a mismatch, a grey badge shows "Not the authority".

        const match = onChainAuthority.length === authorityPublicKey.length &&
          onChainAuthority.every((b: number, i: number) => b === authorityPublicKey[i]);
Enter fullscreen mode Exit fullscreen mode

Authority Badge UI

Note: Even if you use the same wallet but there is a password mismatch, it does not show "You are the authority".

8. Off-chain API to store data

Directly requesting the smart contract state from the indexer loads the network unnecessarily and creates a slow user experience. A solution to this is to run a lightweight Express server connected to PostgreSQL. It polls the indexer every 15 seconds or so and caches the data inside PostgreSQL, improving the user experience.

Import a factory function that queries data via GraphQL on the indexer:

import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
Enter fullscreen mode Exit fullscreen mode

Use setNetworkId('preprod') to set the network to Preprod:

import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';
Enter fullscreen mode Exit fullscreen mode

Import compact-runtime. contractRuntime.ContractState.deserialize(serialized) reconstructs a contract's state from its serialized bytes so you can read ledger data such as totalAgeProofs.

import * as contractRuntime from '@midnight-ntwrk/compact-runtime';
Enter fullscreen mode Exit fullscreen mode

Use the V4 Midnight indexer GraphQL endpoints:

const INDEXER_HTTP = 'https://indexer.preprod.midnight.network/api/v4/graphql';
const INDEXER_WS = 'wss://indexer.preprod.midnight.network/api/v4/graphql/ws';
Enter fullscreen mode Exit fullscreen mode

Database schema

When you start the server, it begins tracking the hardcoded smart contract

const TRACKED_CONTRACT = '331460e632fad9146d23b2176433413e8405976afef8a6f0999dda10433f599d';
Enter fullscreen mode Exit fullscreen mode

A simple database schema stores the data. The contracts table tracks which smart contracts are being monitored.

await sql`
  CREATE TABLE contracts (
    address TEXT PRIMARY KEY,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    updated_at TIMESTAMPTZ DEFAULT NOW(),
    status TEXT DEFAULT 'synced'
  )
`;

await sql`
  CREATE TABLE contract_states (
    id SERIAL PRIMARY KEY,
    contract_address TEXT REFERENCES contracts(address) ON DELETE CASCADE,
    total_age_proofs BIGINT NOT NULL DEFAULT 0,
    total_residency_proofs BIGINT NOT NULL DEFAULT 0,
    total_cert_proofs BIGINT NOT NULL DEFAULT 0,
    recorded_at TIMESTAMPTZ DEFAULT NOW()
  )
`;
Enter fullscreen mode Exit fullscreen mode

Polling lifecycle

When you start the server, it calls startPolling(TRACKED_CONTRACT). TRACKED_CONTRACT is a hardcoded 331460e632fad9146d23b2176433413e8405976afef8a6f0999dda10433f599d smart contract value. It then begins fetching the current state and registers a setInterval loop repeating every 15 seconds. If the server shuts down, stopPolling() clears the interval and closes the database connection.

function startPolling(address: string) {
  const poll = async () => {
    try {
      const state = await provider.queryContractState(address);
      if (state) await insertState(address, state);
    } catch (e) {
      console.error(`[Poll] ${address.slice(0, 12)}:`, e);
    }
  };

  poll();
  const interval = setInterval(poll, 15_000);
  pollingIntervals.set(address, interval);
}
Enter fullscreen mode Exit fullscreen mode

Parsing and inserting state

The generated smart contract code cannot read raw states returned by the indexer GraphQL V4 endpoint. They must first be serialized back into bytes, then deserialized through ContractState.deserialize(). This is when @midnight-ntwrk/compact-runtime comes in. Finally, the state is passed to ledger() to extract fields such as totalAgeProofs (number of age proofs committed), and then the insertState() function inserts the values into the PostgreSQL database contract_states table.

async function parseContractState(address: string, state: any) {
  const serialized = state.serialize();
  const freshState = contractRuntime.ContractState.deserialize(serialized);
  const ls = ledger(freshState.data);

  return {
    totalAgeProofs: Number(ls.totalAgeProofs) || 0,
    totalResidencyProofs: Number(ls.totalResidencyProofs) || 0,
    totalCertProofs: Number(ls.totalCertProofs) || 0,
  };
}
Enter fullscreen mode Exit fullscreen mode

Serving cached data

The frontend sends a GET /contract request to retrieve the latest snapshot stored in the database. The endpoint joins the contracts and contract_states tables, returning the most recent row ordered by recorded_at.

app.get('/contract', async (req, res) => {
  const c = await sql`SELECT * FROM contracts WHERE address = ${TRACKED_CONTRACT}`;
  if (!c.length) return res.status(404).json({ error: 'Not tracked' });

  const latest = await sql`
    SELECT total_age_proofs, total_residency_proofs, total_cert_proofs, recorded_at
    FROM contract_states
    WHERE contract_address = ${TRACKED_CONTRACT}
    ORDER BY recorded_at DESC
    LIMIT 1
  `;

  res.json({
    address: TRACKED_CONTRACT,
    totalAgeProofs: Number(latest[0]?.total_age_proofs ?? 0),
    totalResidencyProofs: Number(latest[0]?.total_residency_proofs ?? 0),
    totalCertProofs: Number(latest[0]?.total_cert_proofs ?? 0),
  });
});
Enter fullscreen mode Exit fullscreen mode

In the frontend, the Home page sends a request to the /contract endpoint, then renders the cached data.

API Polling Logs

Now the data is being successfully cached: age=2 residency=1 cert=0

Conclusion

You have now built a full-stack DApp on the Midnight network: a complete zero-knowledge attestation system. It consists of a Compact contract enforcing privacy-preserving zero-knowledge proofs, a UI that derives identities deterministically from nothing but a wallet's shieldedAddresses.shieldedCoinPublicKey and a user password, and an Express API that caches smart contract state. Your identity is not stored. If you lose your password, you lose your identity. Remember these critical design decisions.

Next steps

Now that you've finished this tutorial, here are a few things you can do next:

  • Check the full repository source code
  • Add a new credential type e.g., "employment"
  • Read the Midnight Compact language docs

Troubleshooting

  • "Wallet not detected" → Make sure 1AM or Lace browser extensions are installed
  • Transactions failing → Make sure you have tDUST and that the wallet is fully synced
  • Not the authority → Password or wallet mismatch
  • Age proof already used → You already proved this credential; use a different one.

Top comments (0)