Skip to main content
For the complete documentation index, see llms.txt

Deploying and calling a contract from a browser DApp

A browser DApp does the same work as a Node.js script that deploys and calls a smart contract, with one difference: it holds no keys. The user's wallet extension owns the keys, pays the fee, and submits the transaction, and the page reaches it through the DApp Connector API. This guide takes a web page through the whole sequence. You connect to the wallet and build the Midnight.js providers from it. Then you deploy a contract, call a circuit, read the private state back, and join the same contract from a second browser.

The examples use Midnight.js 4.1.1 and DApp Connector API 4.0.1 with a small counter contract and a plain TypeScript page built with Vite. Nothing in the Midnight.js code depends on a UI framework. In React or Next.js, run it in client-side code and configure your bundler for the WebAssembly packages the way this guide configures Vite. For the Node.js version of this workflow, see Deploying and operating a contract.

Prerequisites​

These apply to every procedure in this guide:

  • Node.js 22 or later.
  • The Compact toolchain 0.31.1, the version the support matrix pairs with Midnight.js 4.1.1. Install it with compact update 0.31.1 --no-set-default, which keeps your current default toolchain. Newer toolchains compile for ledger 9, which Midnight.js 4.1.1 does not support.
  • A browser wallet that implements DApp Connector API 4.x, such as Lace, set to the network you target.
  • NIGHT in that wallet, registered for DUST generation, because every deploy and call pays a fee in DUST. Funding a wallet covers the faucet and the registration.
  • On Preprod, a Blockfrost project ID for the Midnight Preprod network, because the page reads public state from the Blockfrost indexer. See Getting a Blockfrost project token.
  • A proof server running on your machine. Lace 2.4.3 sends proving requests to the proof server address in its Midnight settings, http://localhost:6300.

How the page and the wallet share the work​

A contract call from a browser has five stages, and the page and the wallet each own some of them. Knowing which side does what tells you where a failure comes from and which data never leaves the browser.

StageWho does itWhat happens
Run the circuitThe pageMidnight.js runs the circuit and its witnesses in the page. Private state stays in the browser's storage.
ProveThe wallet's proving providerThe page supplies the prover key, verifier key, and ZKIR for the circuit. The wallet's proving provider returns the proof.
Balance and signThe walletThe page hands over the proven transaction with balanceUnsealedTransaction. The wallet adds the DUST fee, asks the user to approve, and signs.
SubmitThe walletThe page hands the finished transaction back with submitTransaction, and the wallet sends it to the network.
Confirm and readThe pageMidnight.js watches the indexer until the transaction is final, then stores the new private state.

A deployment follows the same path without the proving stage: creating a contract needs the circuits' verifier keys, not a proof.

Three rules follow from this split:

  • The page serves the ZK files. The keys/ and zkir/ folders from the compile have to be reachable over HTTP from the page's own origin.
  • The network ID comes first. Midnight.js reads the network ID whenever it builds a transaction, so call setNetworkId before any deploy or call. Network IDs are lowercase: preprod, preview, mainnet, and undeployed.
  • Private state belongs to the browser profile, not to the wallet. It lives in IndexedDB on the device. The same wallet in another browser starts with no private state for your contract.

Browser provider reference maps each stage to the Midnight.js provider that implements it.

Setting up the project​

Create a Vite project, add the Midnight.js packages, compile the contract, and put the ZK files where the page can fetch them. Two settings cause most first-run failures. Vite's development server needs the WebAssembly packages excluded from pre-bundling, and the private state store needs the events package, which browsers do not provide.

Procedure​

  1. Create a TypeScript project with Vite and install its dependencies:

    npm create vite@latest counter-dapp -- --template vanilla-ts
    cd counter-dapp
    npm install
  2. Install the DApp Connector types, the Midnight.js packages, and events, pinned to the versions this guide uses:

    npm install @midnight-ntwrk/dapp-connector-api@4.0.1 @midnight-ntwrk/midnight-js-contracts@4.1.1 \
    @midnight-ntwrk/midnight-js-dapp-connector-proof-provider@4.1.1 @midnight-ntwrk/midnight-js-fetch-zk-config-provider@4.1.1 \
    @midnight-ntwrk/midnight-js-indexer-public-data-provider@4.1.1 @midnight-ntwrk/midnight-js-level-private-state-provider@4.1.1 \
    @midnight-ntwrk/midnight-js-network-id@4.1.1 @midnight-ntwrk/midnight-js-protocol@4.1.1 \
    @midnight-ntwrk/midnight-js-types@4.1.1 @midnight-ntwrk/midnight-js-utils@4.1.1 events
  3. Create vite.config.ts in the project root. Without the exclude list, the development server fails with Cannot access '__wbindgen_start' before initialization:

    vite.config.ts
    import { defineConfig } from 'vite';

    export default defineConfig({
    optimizeDeps: {
    // Vite's dev server pre-bundles dependencies, which breaks the start-up
    // order of these WebAssembly packages. Excluding them keeps it intact.
    exclude: [
    '@midnight-ntwrk/ledger-v8',
    '@midnight-ntwrk/onchain-runtime-v3',
    ],
    },
    });
  4. Add the contract. Each call to increment raises a public counter, and a witness supplies a number that only this browser knows: how many times this user has called it.

    contract/counter.compact
    pragma language_version 0.23;

    import CompactStandardLibrary;

    export ledger round: Counter;

    witness nextLocalCount(): Uint<32>;

    export circuit increment(): [] {
    const local = nextLocalCount();
    assert(local > 0, "local count must be positive");
    round.increment(1);
    }
  5. Compile the contract with toolchain 0.31.1, then copy the ZK files into public/counter/ so that Vite serves them at /counter/keys/ and /counter/zkir/. Repeat the copy whenever you recompile:

    compact compile +0.31.1 contract/counter.compact contract/managed/counter
    mkdir -p public/counter
    cp -R contract/managed/counter/keys contract/managed/counter/zkir public/counter/
  6. Replace index.html with a page that has one button for each step of this guide:

    index.html
    <!doctype html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <title>Counter DApp</title>
    </head>
    <body>
    <h1>Counter DApp</h1>
    <p>
    <label>Private state password <input id="password" type="password" autocomplete="current-password" /></label>
    <button id="connect">Connect wallet</button>
    </p>
    <p>
    <button id="deploy" disabled>Deploy</button>
    <label>Contract address <input id="address" size="70" /></label>
    <button id="join" disabled>Join</button>
    </p>
    <p>
    <button id="increment" disabled>Increment</button>
    <button id="read">Read public state</button>
    </p>
    <pre id="log"></pre>
    <script type="module" src="/src/main.ts"></script>
    </body>
    </html>
  7. Delete the template's files in src/, then create src/main.ts. The later procedures add to this file, always above the last line:

    src/main.ts
    const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T;
    const log = (line: string) => {
    $('log').textContent += `${line}\n`;
    };

    log('Ready');

Verification​

The project builds and the page loads. Build it first:

npm run build

The build succeeds and ends with the time it took, which varies by machine:

✓ built in 19ms

Then start the development server with npm run dev and open the URL it prints. The page shows Ready. The network ID defaults to preprod; to target another network, put it in a .env.local file, for example VITE_NETWORK_ID=preview, before you start the server.

Connecting to the wallet and setting the network​

Find the wallet, connect to it with the network ID you target, and give Midnight.js the same ID. Do not look a wallet up by a fixed key: the specification has each wallet add its entry to window.midnight under a freshly generated UUID, and Lace generates a new one on every page load. Read the entries instead, and identify a wallet by its rdns and apiVersion. Lace reports the rdns value io.lace.wallet.

Procedure​

  1. Create src/wallet.ts:

    src/wallet.ts
    import type { ConnectedAPI, InitialAPI } from '@midnight-ntwrk/dapp-connector-api';
    import { setNetworkId } from '@midnight-ntwrk/midnight-js-network-id';

    // Lowercase, exactly as the wallet and Midnight.js expect it.
    export const NETWORK_ID: string = import.meta.env.VITE_NETWORK_ID ?? 'preprod';

    // Every installed wallet adds an entry to window.midnight. The key is not
    // stable, so identify a wallet by its rdns and apiVersion instead.
    export const findWallets = (): InitialAPI[] =>
    Object.values(window.midnight ?? {}).filter(
    (wallet) => typeof wallet?.connect === 'function' && wallet.apiVersion.startsWith('4.'),
    );

    export async function connectWallet(wallet: InitialAPI): Promise<ConnectedAPI> {
    const api = await wallet.connect(NETWORK_ID);

    const status = await api.getConnectionStatus();
    if (status.status !== 'connected' || status.networkId !== NETWORK_ID) {
    throw new Error(`The wallet is not connected to ${NETWORK_ID}.`);
    }

    // Midnight.js reads this value whenever it builds a transaction.
    setNetworkId(NETWORK_ID);
    return api;
    }
  2. Add these imports at the top of src/main.ts:

    src/main.ts
    import type { APIError } from '@midnight-ntwrk/dapp-connector-api';

    import { connectWallet, findWallets } from './wallet';
  3. Add a helper that runs a button's action and logs any failure, and the handler for the Connect wallet button, above the log('Ready') line. Wallet errors are not instances of a class your page can import. The helper checks the type field instead of using instanceof, as the DApp Connector API recommends:

    src/main.ts
    const run = (id: string, action: () => Promise<void>) =>
    $(id).addEventListener('click', () => {
    action().catch((error: unknown) => {
    // Wallet errors carry type DAppConnectorAPIError and a reason. Some browser errors have a name and no message.
    const { type, reason, message, name } = error as Partial<APIError>;
    log(`${id} failed: ${type === 'DAppConnectorAPIError' ? reason : message || name}`);
    });
    });

    run('connect', async () => {
    const [wallet] = findWallets();
    if (!wallet) throw new Error('No Midnight wallet found.');
    const api = await connectWallet(wallet);
    const { unshieldedAddress } = await api.getUnshieldedAddress();
    log(`Connected to ${wallet.name} as ${unshieldedAddress}`);
    });

    This page takes the first wallet it finds. When more than one wallet is installed, the DApp Connector API specification requires you to let the user choose, and asks you to warn them when two wallets report the same or a similar rdns. Create a React wallet connector shows a picker.

Verification​

Reload the page and select Connect wallet. The wallet asks you to approve the connection. After you approve, the page logs Connected to, the wallet's name, and your unshielded address. Except on Mainnet, the address starts with mn_addr_ and the network name, such as mn_addr_preprod1. Mainnet addresses start with mn_addr1.

If the wallet is set to a different network than NETWORK_ID, the connection fails instead. See Browser DApp troubleshooting.

Building the providers from the wallet​

Build the six providers that every Midnight.js deploy and call takes. In a browser, the wallet fills three of them: proving, balancing, and submitting. The page's own origin serves the ZK files, IndexedDB holds private state, and the indexer comes from .env.local, or from the wallet when you leave it out.

Prerequisites​

Procedure​

  1. Describe the contract to TypeScript in src/contract.ts: its private state, its witness, and the compiled contract object that Midnight.js deploys and calls. The witness runs in the page and returns the next private state together with the value the circuit asked for:

    src/contract.ts
    import type { FoundContract } from '@midnight-ntwrk/midnight-js-contracts';
    import { CompiledContract } from '@midnight-ntwrk/midnight-js-protocol/compact-js';
    import type { MidnightProviders } from '@midnight-ntwrk/midnight-js-types';

    import * as Counter from '../contract/managed/counter/contract/index.js';

    export type CounterPrivateState = { readonly localCount: number };
    export type CounterCircuits = 'increment';
    export const PRIVATE_STATE_ID = 'counterPrivateState';
    export type CounterProviders = MidnightProviders<CounterCircuits, typeof PRIVATE_STATE_ID, CounterPrivateState>;
    // findDeployedContract returns this type. deployContract returns a DeployedContract,
    // which is this type plus private data that only the deployer has.
    export type CounterContract = FoundContract<Counter.Contract<CounterPrivateState>>;

    const witnesses: Counter.Witnesses<CounterPrivateState> = {
    nextLocalCount: ({ privateState }) => {
    const localCount = privateState.localCount + 1;
    return [{ localCount }, BigInt(localCount)];
    },
    };

    export const compiledCounter = CompiledContract.make('counter', Counter.Contract<CounterPrivateState>).pipe(
    CompiledContract.withWitnesses(witnesses),
    // The contract type requires an asset path. The browser fetches the ZK files from
    // the base URL you give FetchZkConfigProvider, not from this value.
    CompiledContract.withCompiledFileAssets('/counter'),
    );

    export const ledger = Counter.ledger;
  2. Create src/providers.ts. The wallet exchanges transactions as hex strings and Midnight.js works with ledger transaction objects, so walletBridge converts in both directions:

    src/providers.ts
    import type { ConnectedAPI } from '@midnight-ntwrk/dapp-connector-api';
    import { dappConnectorProofProvider } from '@midnight-ntwrk/midnight-js-dapp-connector-proof-provider';
    import { FetchZkConfigProvider } from '@midnight-ntwrk/midnight-js-fetch-zk-config-provider';
    import { indexerPublicDataProvider } from '@midnight-ntwrk/midnight-js-indexer-public-data-provider';
    import { levelPrivateStateProvider } from '@midnight-ntwrk/midnight-js-level-private-state-provider';
    import { CostModel, Transaction } from '@midnight-ntwrk/midnight-js-protocol/ledger';
    import type { MidnightProvider, WalletProvider } from '@midnight-ntwrk/midnight-js-types';
    import { fromHex, toHex, validatePassword } from '@midnight-ntwrk/midnight-js-utils';

    import type { CounterCircuits, CounterPrivateState, CounterProviders } from './contract';
    import { PRIVATE_STATE_ID } from './contract';

    // The wallet speaks hex strings. Midnight.js speaks ledger transaction objects.
    async function walletBridge(api: ConnectedAPI): Promise<WalletProvider & MidnightProvider> {
    const { shieldedCoinPublicKey, shieldedEncryptionPublicKey } = await api.getShieldedAddresses();
    return {
    getCoinPublicKey: () => shieldedCoinPublicKey,
    getEncryptionPublicKey: () => shieldedEncryptionPublicKey,
    balanceTx: async (tx) => {
    const balanced = await api.balanceUnsealedTransaction(toHex(tx.serialize()));
    return Transaction.deserialize('signature', 'proof', 'binding', fromHex(balanced.tx));
    },
    submitTx: async (tx) => {
    await api.submitTransaction(toHex(tx.serialize()));
    return tx.identifiers()[0];
    },
    };
    }

    export async function buildProviders(api: ConnectedAPI, password: string): Promise<CounterProviders> {
    // The store checks the password on its first write, which happens after a
    // deploy transaction has already landed. Check it now instead.
    validatePassword(password);

    // Read from the wallet's indexer unless .env.local names another one.
    const config = await api.getConfiguration();
    const indexerUri: string = import.meta.env.VITE_INDEXER_URI ?? config.indexerUri;
    const indexerWsUri: string = import.meta.env.VITE_INDEXER_WS_URI ?? config.indexerWsUri;
    const { unshieldedAddress } = await api.getUnshieldedAddress();

    // Fetches keys/ and zkir/ from public/counter/.
    const zkConfigProvider = new FetchZkConfigProvider<CounterCircuits>(
    `${window.location.origin}/counter`,
    fetch.bind(window),
    );
    const wallet = await walletBridge(api);

    return {
    privateStateProvider: levelPrivateStateProvider<typeof PRIVATE_STATE_ID, CounterPrivateState>({
    privateStoragePasswordProvider: () => password,
    accountId: unshieldedAddress,
    }),
    publicDataProvider: indexerPublicDataProvider(indexerUri, indexerWsUri),
    zkConfigProvider,
    proofProvider: await dappConnectorProofProvider(api, zkConfigProvider, CostModel.initialCostModel()),
    walletProvider: wallet,
    midnightProvider: wallet,
    };
    }
    Private state lives in this browser profile

    levelPrivateStateProvider encrypts private state and the contract's signing key with the password and keeps them in IndexedDB. Clearing the site's data deletes them, and a wrong password cannot decrypt them. The password needs at least 16 characters and at least three of the four character classes: uppercase, lowercase, digits, and symbols. It cannot repeat a character more than three times in a row or contain a run of four sequential characters, such as 1234 or abcd. This page asks the user for it; do not ship a password in your source.

  3. Name the indexer in a .env.local file in the project root. buildProviders reads these two values before it falls back to the indexer that getConfiguration() reports. Lace 2.4.3's built-in defaults put the indexer on blockfrost.lw.iog.io for Preprod, Preview, and Mainnet, and that proxy now answers 410 Gone for Midnight, so naming the indexer here keeps the page working whatever the wallet reports. On Preprod, use the Blockfrost endpoints with your project ID:

    .env.local
    VITE_INDEXER_URI=https://midnight-preprod.blockfrost.io/api/v0?project_id=<your-preprod-project-id>
    VITE_INDEXER_WS_URI=wss://midnight-preprod.blockfrost.io/api/v0/ws?project_id=<your-preprod-project-id>

    On Preview, use the Midnight-hosted indexer, which needs no token. The Environment reference lists the addresses for every network. Restart the development server whenever you change .env.local.

    The project ID is public

    Vite builds VITE_ values into the page, so anyone who loads the page can read your project ID. Use a project that you created for testing.

  4. Add these imports at the top of src/main.ts:

    src/main.ts
    import type { CounterContract, CounterProviders } from './contract';
    import { buildProviders } from './providers';
  5. Add an object that holds the providers and the contract between button presses, above the run('connect', ...) handler:

    src/main.ts
    const session: { providers?: CounterProviders; counter?: CounterContract } = {};
  6. Replace the run('connect', ...) handler so that it builds the providers and enables the next buttons:

    src/main.ts
    run('connect', async () => {
    const [wallet] = findWallets();
    if (!wallet) throw new Error('No Midnight wallet found.');
    const api = await connectWallet(wallet);
    session.providers = await buildProviders(api, $<HTMLInputElement>('password').value);
    log(`Connected to ${wallet.name}`);
    $<HTMLButtonElement>('deploy').disabled = false;
    $<HTMLButtonElement>('join').disabled = false;
    });

Verification​

Build the project again with npm run build. Now that the page imports Midnight.js, Vite prints warnings that the assert module has been externalized for browser compatibility and that the WebSocket import from isomorphic-ws is undefined. The second one is harmless: with no WebSocket implementation passed in, the indexer client uses the browser's own. The build still succeeds.

Reload the page. It now loads the Midnight.js WebAssembly modules, midnight_ledger_wasm_bg.wasm and midnight_onchain_runtime_wasm_bg.wasm, and still shows Ready. Enter a password that follows the rules in the warning above, and select Connect wallet. The page logs Connected to and the wallet's name, and the Deploy and Join buttons become active.

With a password that is too short, the providers are not built and nothing is sent to the network:

connect failed: Password is shorter than 16 characters

Deploying the contract​

Deploy the counter from the page and keep its address. deployContract builds the deployment transaction in the page and passes it to the wallet to balance and submit. It then waits for the indexer to report the transaction as final, and stores the initial private state and the contract's signing key in the browser.

Keep the page open until the deploy finishes

The page saves the contract's signing key and initial private state only after the transaction is final. If the user reloads or closes the page after approving, the contract can still land, but this browser never saves either one. Joining the contract later stores a new signing key, which cannot run maintenance on it. A circuit call works the same way: the page saves its new private state only when the call returns.

Prerequisites​

Procedure​

  1. Create src/deploy.ts:

    src/deploy.ts
    import { deployContract } from '@midnight-ntwrk/midnight-js-contracts';

    import { compiledCounter, type CounterProviders, PRIVATE_STATE_ID } from './contract';

    export const deployCounter = (providers: CounterProviders) =>
    deployContract(providers, {
    compiledContract: compiledCounter,
    privateStateId: PRIVATE_STATE_ID,
    initialPrivateState: { localCount: 0 },
    });
  2. Add this import at the top of src/main.ts:

    src/main.ts
    import { deployCounter } from './deploy';
  3. Add the handler for the Deploy button above the log('Ready') line:

    src/main.ts
    run('deploy', async () => {
    if (!session.providers) return;
    log('Deploying...');
    session.counter = await deployCounter(session.providers);
    const { contractAddress } = session.counter.deployTxData.public;
    $<HTMLInputElement>('address').value = contractAddress;
    log(`Deployed at ${contractAddress}`);
    $<HTMLButtonElement>('increment').disabled = false;
    });

Verification​

Reload the page, connect, and select Deploy. Approve the transaction in the wallet. When the transaction is final, the page logs the contract address and fills the Contract address field:

Deploying...
Deployed at d49ebc2cde6fd3fe97eb93d7ef75dec933fa930ed5d92c13999907c2a7496377

Copy the address. You need it to join the contract after a reload or from another browser.

If you decline the transaction in the wallet, the page deploys nothing and stores nothing. With Lace, the page logs:

Deploying...
deploy failed: User rejected transaction

Calling a circuit and reading the result​

Call increment, then read both halves of the result: the private state the witness produced and the public counter on the ledger. Every circuit is a function on the contract's callTx object. The value it resolves to carries the new private state in private.nextPrivateState, and Midnight.js writes that state to the store only after the transaction succeeds on-chain.

Prerequisites​

Procedure​

  1. Create src/call.ts. Reading public state needs only the indexer: queryContractState returns the contract's current state, and the generated ledger function decodes its data field:

    src/call.ts
    import { type CounterContract, type CounterProviders, ledger } from './contract';

    export async function increment(counter: CounterContract) {
    const tx = await counter.callTx.increment();
    return {
    txId: tx.public.txId,
    blockHeight: tx.public.blockHeight,
    localCount: tx.private.nextPrivateState.localCount,
    };
    }

    export async function readRound(providers: Pick<CounterProviders, 'publicDataProvider'>, contractAddress: string) {
    const state = await providers.publicDataProvider.queryContractState(contractAddress);
    return state === null ? null : ledger(state.data).round;
    }
  2. Add these imports at the top of src/main.ts:

    src/main.ts
    import { increment, readRound } from './call';
    import { PRIVATE_STATE_ID } from './contract';
  3. Add the handlers for the Increment and Read public state buttons above the log('Ready') line. The first one reads the private state back from the store to show that it matches the call's result:

    src/main.ts
    run('increment', async () => {
    if (!session.providers || !session.counter) return;
    log('Calling increment...');
    const result = await increment(session.counter);
    const stored = await session.providers.privateStateProvider.get(PRIVATE_STATE_ID);
    log(`Transaction ${result.txId} in block ${result.blockHeight}`);
    log(`localCount from the call: ${result.localCount}; stored: ${stored?.localCount}`);
    });

    run('read', async () => {
    if (!session.providers) throw new Error('Connect first.');
    const contractAddress = $<HTMLInputElement>('address').value.trim();
    log(`round is ${await readRound(session.providers, contractAddress)}`);
    });

Verification​

Reload the page, connect, deploy a new contract, and select Increment. Approve the transaction in the wallet. The private state from the call and the stored private state agree:

Calling increment...
Transaction 00235d246422a09bf4127d5c673fc106d156ddaa503ad501f3a3c2904e353ce816 in block 757
localCount from the call: 1; stored: 1

Then select Read public state. The public counter shows the same call:

round is 1

Joining a deployed contract​

Reconnect to a contract that already exists, after a page reload or from another browser, without losing private state. The trap is initialPrivateState: whenever you pass it, findDeployedContract writes it over whatever the browser has stored for that contract. Pass it only when nothing is stored yet.

Prerequisites​

  • The address of a contract deployed from the same compiled output. findDeployedContract compares your local verifier keys with the deployed ones and refuses a contract that differs.

Procedure​

  1. Create src/join.ts:

    src/join.ts
    import { findDeployedContract } from '@midnight-ntwrk/midnight-js-contracts';

    import { compiledCounter, type CounterContract, type CounterProviders, PRIVATE_STATE_ID } from './contract';

    export async function joinCounter(providers: CounterProviders, contractAddress: string): Promise<CounterContract> {
    // Private state is stored per contract address, so select the address before looking.
    providers.privateStateProvider.setContractAddress(contractAddress);
    const stored = await providers.privateStateProvider.get(PRIVATE_STATE_ID);

    return findDeployedContract(providers, {
    contractAddress,
    compiledContract: compiledCounter,
    privateStateId: PRIVATE_STATE_ID,
    // Passing initialPrivateState overwrites whatever is stored, so pass it on a first join only.
    ...(stored === null ? { initialPrivateState: { localCount: 0 } } : {}),
    });
    }
  2. Add this import at the top of src/main.ts:

    src/main.ts
    import { joinCounter } from './join';
  3. Add the handler for the Join button above the log('Ready') line:

    src/main.ts
    run('join', async () => {
    if (!session.providers) return;
    const contractAddress = $<HTMLInputElement>('address').value.trim();
    session.counter = await joinCounter(session.providers, contractAddress);
    const stored = await session.providers.privateStateProvider.get(PRIVATE_STATE_ID);
    log(`Joined ${contractAddress}; stored localCount is ${stored?.localCount}`);
    $<HTMLButtonElement>('increment').disabled = false;
    });

Verification​

Deploy a contract and increment it once, then reload the page. Connect with the same password, paste the contract address, and select Join. The private state from before the reload is still there, and the next call continues from it:

Joined d49ebc2cde6fd3fe97eb93d7ef75dec933fa930ed5d92c13999907c2a7496377; stored localCount is 1
Calling increment...
Transaction 00f492a9397992b1759c2a7417eda9229cea7b4b9c79801350e10803eb44080136 in block 762
localCount from the call: 2; stored: 2

Now open the page in a different browser profile with the same wallet, connect, paste the address, and select Join, then Read public state. That profile has no private state for the contract, so it starts from zero while the public counter keeps its value:

Joined d49ebc2cde6fd3fe97eb93d7ef75dec933fa930ed5d92c13999907c2a7496377; stored localCount is 0
round is 2

Browser provider reference​

Which implementation fills each Midnight.js provider slot in a browser, and which wallet method it relies on. The packages live under the @midnight-ntwrk/ scope. For the Node.js implementations, see Provider implementations.

SlotBrowser implementationPackageWallet methods usedNotes
privateStateProviderlevelPrivateStateProvidermidnight-js-level-private-state-providergetUnshieldedAddress, for accountIdEncrypted IndexedDB store, scoped to the account and the contract address. Needs the events package in the bundle
publicDataProviderindexerPublicDataProvidermidnight-js-indexer-public-data-providergetConfiguration, for indexerUri and indexerWsUriReads need no wallet approval. VITE_INDEXER_URI and VITE_INDEXER_WS_URI replace the wallet's indexer
zkConfigProviderFetchZkConfigProvidermidnight-js-fetch-zk-config-providerNoneFetches keys/<circuit>.prover, keys/<circuit>.verifier, and zkir/<circuit>.bzkir under the base URL
proofProviderdappConnectorProofProvidermidnight-js-dapp-connector-proof-providergetProvingProviderLace 2.4.3 forwards proving to the proof server address in its settings
walletProviderYour bridge over the connected APIdapp-connector-api for the typesgetShieldedAddresses, balanceUnsealedTransactionThe wallet asks the user to approve each transaction it balances
midnightProviderYour bridge over the connected APIdapp-connector-api for the typessubmitTransactionTakes the finished transaction as a hex string and returns nothing. The bridge returns the transaction ID from tx.identifiers()

Browser DApp troubleshooting​

Failures you are likely to meet on this guide's path, and their fixes. The Lace messages come from the Lace 2.4.3 source; the others come from a real run.

ErrorCauseFix
language version 0.27.0 mismatch or language version 0.26.0 mismatch from compact compileThe default Compact toolchain is 0.35.0 or 0.34.0, which compile for ledger 9Install toolchain 0.31.1 with compact update 0.31.1 --no-set-default, and compile with compact compile +0.31.1
ReferenceError: Cannot access '__wbindgen_start' before initialization, and a blank page on the development serverVite pre-bundled the WebAssembly packagesAdd the optimizeDeps.exclude list from Setting up the project
TypeError: Class extends value undefined is not a constructor or null, with a console warning that events has been externalizedThe private state store imports the Node.js events module, which browsers lackRun npm install events
Network ID has not been configured. Call setNetworkId() before any wallet or contract operation.A deploy or call ran before setNetworkIdCall setNetworkId when you connect, with the same ID you pass to connect
Invalid network ID, Unsupported network ID, or Network ID mismatch from Lace on connectThe ID passed to connect is misspelled, or Lace is set to another networkPass the lowercase ID, such as preprod, and switch Lace to the same network
Access to wallet api denied from Lace on connectThe user declined the connectionLet the user try again
User rejected transaction from LaceThe user declined the transactionNothing was submitted. Let the user try again
Password is shorter than 16 characters, Password contains sequential patterns, or another PasswordValidationErrorThe private state password fails the store's rulesUse 16 or more characters with three of the four character classes, no more than three identical characters in a row, and no sequences such as 1234 or abcd. The error message names the rule that failed
OperationError when joiningThe password does not match the one that encrypted the stored private stateAsk for the password the user set when they first used the contract in this browser
No private state found at private state ID '<id>'findDeployedContract ran without initialPrivateState in a browser that has nothing stored for the contractPass initialPrivateState when the store returns null, as in Joining a deployed contract
Failed to fetch on Read public state or Increment, or after the wallet approved a deploy, with a console error for the indexer addressThe page cannot reach the indexer it reads from, such as Lace 2.4.3's built-in blockfrost.lw.iog.io address. After a deploy, the wallet had already submitted the transaction. A call or a read fails before the wallet sees a transaction, so the wallet submits nothingSet VITE_INDEXER_URI and VITE_INDEXER_WS_URI to an indexer that answers, as in Building the providers from the wallet
Response not successful: Received status code 403Blockfrost refused the project ID in .env.local: it is missing, mistyped, or from a project for another networkPut the ID of a Midnight Preprod project in both lines, then restart the development server
The join keeps waitingNo contract exists at that address on the network the wallet reportsCheck the address and the network

Additional resources​