skill·2026-03-22·11 min read

Aleo Frontend Integration

Aleo Frontend Integration

1. Overview

Building an Aleo frontend requires integrating the @provablehq/sdk for proof generation, wallet adapters for user interaction, and REST APIs for reading on-chain state. The most critical architectural decision is that proof generation MUST happen in a Web Worker to avoid freezing the browser's main thread.

Version & Canonical Syntax

  • Target: Leo compiler >= 3.5.0 with current @provablehq/sdk and wallet adapter packages
  • Canonical syntax: Program calls should target Leo transition / async transition entry points (with paired async function finalize_* logic when public state is updated)
  • Input serialization rule: Always send typed Leo literals (100u64, 1field, 1scalar) rather than unsuffixed numbers
  • When docs conflict: Prefer current SDK API docs and this skill's worker-first architecture

2. Key Concepts & Glossary

  • WASM: WebAssembly module (@provablehq/wasm) that performs cryptographic operations in the browser. Must be initialized before use.
  • Web Worker: A background thread in the browser. ZK proof generation takes 5-60 seconds and MUST run in a worker to keep the UI responsive.
  • ProgramManager: The main SDK orchestration class. Manages program execution, deployment, and key caching.
  • AleoNetworkClient: SDK class for querying the Aleo network (mappings, transactions, programs).
  • AleoKeyProvider: SDK class for managing proving/verifying keys. Use useCache(true) to avoid re-downloading.
  • NetworkRecordProvider: SDK class for discovering unspent records on the network.
  • Wallet Adapter: Library (@demox-labs/aleo-wallet-adapter-react) for connecting to Leo Wallet, Puzzle Wallet, etc.
  • DecryptPermission: Controls when a wallet can decrypt records:
    • UponRequest — User must approve each decryption (most secure)
    • AutoDecrypt — Wallet auto-decrypts (convenient but less secure)
    • NoDecrypt — No decryption allowed

3. Quick Start: create-leo-app

The fastest way to scaffold a complete Aleo frontend:

bash
npm create leo-app@latest
# Follow prompts to choose:
# - React + TypeScript
# - Aleo wallet integration
# - Leo program template

This generates a complete project with:

  • Leo program in src/
  • React frontend with wallet adapter pre-configured
  • Web Worker for off-main-thread proof generation
  • Vite configuration with WASM support

4. SDK Installation

bash
npm install @provablehq/sdk @provablehq/wasm

For wallet integration:

bash
npm install @demox-labs/aleo-wallet-adapter-base \
            @demox-labs/aleo-wallet-adapter-react \
            @demox-labs/aleo-wallet-adapter-reactui \
            @demox-labs/aleo-wallet-adapter-leo

Vite Configuration

typescript
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
    plugins: [react()],
    optimizeDeps: {
        exclude: ["@provablehq/sdk", "@provablehq/wasm"],
    },
    server: {
        headers: {
            "Cross-Origin-Opener-Policy": "same-origin",
            "Cross-Origin-Embedder-Policy": "require-corp",
        },
    },
});

CRITICAL: The Cross-Origin-* headers are required for SharedArrayBuffer, which WASM thread pools need.

5. Web Worker Pattern (MANDATORY)

Proof generation takes 5-60 seconds and blocks the thread. You MUST use a Web Worker.

worker.js

javascript
// src/workers/worker.js
import {
    Account,
    ProgramManager,
    AleoKeyProvider,
    AleoNetworkClient,
    NetworkRecordProvider,
    initThreadPool,
} from "@provablehq/sdk";

// Initialize WASM thread pool
await initThreadPool();

const networkClient = new AleoNetworkClient("https://api.explorer.provable.com/v2");
const keyProvider = new AleoKeyProvider();
keyProvider.useCache(true); // Cache keys to avoid re-download

const programManagers = new Map();

function getProgramManager(privateKey) {
    if (programManagers.has(privateKey)) {
        return programManagers.get(privateKey);
    }

    const account = new Account({ privateKey });
    const recordProvider = new NetworkRecordProvider(account, networkClient);
    const programManager = new ProgramManager(
        "https://api.explorer.provable.com/v2",
        keyProvider,
        recordProvider,
    );
    programManager.setAccount(account);
    programManagers.set(privateKey, programManager);
    return programManager;
}

// Handle messages from main thread
self.addEventListener("message", async (event) => {
    const { type, data } = event.data;

    try {
        switch (type) {
            case "execute": {
                const { programName, functionName, inputs, priorityFee, privateKey } = data;
                const programManager = getProgramManager(privateKey);

                const tx = await programManager.buildExecutionTransaction({
                    programName,
                    functionName,
                    inputs,
                    priorityFee,
                    privateFee: false,
                });

                self.postMessage({ type: "result", data: tx });
                break;
            }
            case "deploy": {
                const { program, deploymentFee, privateKey } = data;
                const programManager = getProgramManager(privateKey);

                const tx = await programManager.buildDeploymentTransaction(
                    program,
                    deploymentFee,
                    false
                );

                self.postMessage({ type: "result", data: tx });
                break;
            }
        }
    } catch (error) {
        self.postMessage({ type: "error", data: error.message });
    }
});

Using the Worker from React

typescript
// src/hooks/useAleoWorker.ts
import { useState, useCallback, useRef, useEffect } from "react";

type ExecuteWorkerRequest = {
    programName: string;
    functionName: string;
    inputs: string[];
    priorityFee: number;
    privateKey: string;
};

export function useAleoWorker() {
    const workerRef = useRef<Worker | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        workerRef.current = new Worker(
            new URL("../workers/worker.js", import.meta.url),
            { type: "module" }
        );
        return () => workerRef.current?.terminate();
    }, []);

    const execute = useCallback(
        (
            programName: string,
            functionName: string,
            inputs: string[],
            priorityFee: number,
            privateKey: string
        ) => {
            return new Promise((resolve, reject) => {
                if (!workerRef.current) return reject("Worker not ready");

                setLoading(true);
                setError(null);

                workerRef.current.onmessage = (event) => {
                    setLoading(false);
                    if (event.data.type === "result") {
                        resolve(event.data.data);
                    } else {
                        setError(event.data.data);
                        reject(event.data.data);
                    }
                };

                workerRef.current.postMessage({
                    type: "execute",
                    data: {
                        programName,
                        functionName,
                        inputs,
                        priorityFee,
                        privateKey,
                    } satisfies ExecuteWorkerRequest,
                });
            });
        },
        []
    );

    return { execute, loading, error };
}

If you keep proving keys client-side in the worker flow, send a private key string and construct an SDK Account in the worker before calling programManager.setAccount(...). If you are using a wallet adapter flow, prefer requestTransaction(...) and avoid handling private keys in app code.

The NetworkRecordProvider should be created with a real SDK Account; avoid initializing it with null for transaction-building flows.

Use separate fee fields in worker payloads: priorityFee for execution calls and deploymentFee for deployment calls.

6. Wallet Adapter Integration

Provider Setup

tsx
// src/App.tsx
import { FC, useMemo } from "react";
import { WalletProvider } from "@demox-labs/aleo-wallet-adapter-react";
import { WalletModalProvider } from "@demox-labs/aleo-wallet-adapter-reactui";
import { LeoWalletAdapter } from "@demox-labs/aleo-wallet-adapter-leo";
import { DecryptPermission, WalletAdapterNetwork } from "@demox-labs/aleo-wallet-adapter-base";

import "@demox-labs/aleo-wallet-adapter-reactui/styles.css";

const App: FC = () => {
    const wallets = useMemo(() => [new LeoWalletAdapter({ appName: "My Aleo App" })], []);

    return (
        <WalletProvider
            wallets={wallets}
            decryptPermission={DecryptPermission.UponRequest}
            network={WalletAdapterNetwork.Testnet}
            autoConnect
        >
            <WalletModalProvider>
                <MyDApp />
            </WalletModalProvider>
        </WalletProvider>
    );
};

Using the Wallet

tsx
// src/components/TransferButton.tsx
import { useWallet } from "@demox-labs/aleo-wallet-adapter-react";
import { Transaction, WalletAdapterNetwork } from "@demox-labs/aleo-wallet-adapter-base";

function TransferButton() {
    const { publicKey, requestTransaction, requestRecords } = useWallet();

    const handleTransfer = async () => {
        if (!publicKey) return;

        try {
            // Build transaction payload
            const tx = Transaction.createTransaction(
                publicKey,
                WalletAdapterNetwork.Testnet,
                "token.aleo",           // program ID
                "transfer_public",       // function name
                [
                    "aleo1receiver...",   // receiver address
                    "100u64",            // amount (string format!)
                ],
                1_000_000,               // fee in microcredits
            );

            // Send to wallet for user approval + signing
            const txId = await requestTransaction(tx);
            console.log("Transaction submitted:", txId);

        } catch (error) {
            if (error.name === "WalletNotConnectedError") {
                console.error("Please connect your wallet first");
            } else {
                console.error("Transaction failed:", error);
            }
        }
    };

    return <button onClick={handleTransfer}>Transfer</button>;
}

Requesting Records

tsx
const handleGetRecords = async () => {
    if (!requestRecords) return;

    try {
        const records = await requestRecords("token.aleo");
        // records is an array of wallet record objects/ciphertexts
        console.log("Found records:", records);
    } catch (error) {
        console.error("Failed to get records:", error);
    }
};

7. Reading Public State (No Wallet Needed)

Direct REST API

typescript
// Read a mapping value
async function getMappingValue(
    programId: string,
    mappingName: string,
    key: string
): Promise<string> {
    const url = `https://api.explorer.provable.com/v2/testnet/program/${programId}/mapping/${mappingName}/${key}`;

    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);

    const value = await response.text();
    return value; // Returns the value as a string, e.g., "100u64"
}

// Example usage
const balance = await getMappingValue(
    "token.aleo",
    "account",
    "aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9"
);

Using AleoNetworkClient

typescript
import { AleoNetworkClient } from "@provablehq/sdk";

const client = new AleoNetworkClient("https://api.explorer.provable.com/v2");

// Get mapping value
const balance = await client.getProgramMappingValue(
    "token.aleo",
    "account",
    "aleo1qnr4dkkvkgfqph0vzc3y6z2eu975wnpz2925ntjccd5cfqxtyu8s7pyjh9"
);

// Get latest block height
const height = await client.getLatestHeight();

// Get program source
const source = await client.getProgram("token.aleo");

// Get transaction by ID
const tx = await client.getTransaction("at1...");

React Hook for Mapping Reads

tsx
import { useState, useEffect } from "react";

function useAleoMapping(
    programId: string,
    mappingName: string,
    key: string | null
) {
    const [value, setValue] = useState<string | null>(null);
    const [loading, setLoading] = useState(false);
    const [error, setError] = useState<string | null>(null);

    useEffect(() => {
        if (!key) return;

        const fetchValue = async () => {
            setLoading(true);
            setError(null);
            try {
                const url = `https://api.explorer.provable.com/v2/testnet/program/${programId}/mapping/${mappingName}/${key}`;
                const res = await fetch(url);
                if (!res.ok) throw new Error(`HTTP ${res.status}`);
                const data = await res.text();
                setValue(data);
            } catch (err: any) {
                setError(err.message);
            } finally {
                setLoading(false);
            }
        };

        fetchValue();
        // Poll every 15 seconds
        const interval = setInterval(fetchValue, 15000);
        return () => clearInterval(interval);
    }, [programId, mappingName, key]);

    return { value, loading, error };
}

8. Transaction Status Polling

typescript
async function waitForTransaction(
    client: AleoNetworkClient,
    txId: string,
    maxAttempts: number = 60,
    intervalMs: number = 3000
): Promise<any> {
    for (let i = 0; i < maxAttempts; i++) {
        try {
            const tx = await client.getTransaction(txId);
            if (tx) return tx;
        } catch {
            // Transaction not yet found — keep polling
        }
        await new Promise((resolve) => setTimeout(resolve, intervalMs));
    }
    throw new Error(`Transaction ${txId} not confirmed after ${maxAttempts} attempts`);
}

9. Input Serialization Rules

When calling Leo functions from TypeScript, inputs must be formatted as strings with type suffixes:

Leo TypeTypeScript Input FormatExample
u8"42u8""255u8"
u16"1000u16""65535u16"
u32"100000u32""4294967295u32"
u64"1000000u64""18446744073709551615u64"
u128"340u128"
i8-i128"-42i32"
field"1field""123456789field"
bool"true" or "false"
address"aleo1..."Full address string
scalar"1scalar"
group"0group"
Recordrecord.toString()Plaintext record string
Struct"{ field1: value1, field2: value2 }"As Leo literal string

CRITICAL: Always include the type suffix. "100" is INVALID; "100u64" is correct.

10. Record Decryption (Client-Side)

typescript
import { ViewKey, RecordCiphertext } from "@provablehq/sdk";

// Decrypt a record ciphertext using a view key
const viewKey = ViewKey.from_string("AViewKey1...");
const ciphertext = RecordCiphertext.fromString("record1...");
const plaintext = viewKey.decrypt(ciphertext.toString());

11. Common Frontend Errors and Fixes

ErrorCauseFix
WalletNotConnectedErrorNo wallet connectedShow connect button first
UI freezes during executionProof generation on main threadMove to Web Worker
SharedArrayBuffer not availableMissing COOP/COEP headersAdd cross-origin headers to Vite config
WASM not initializedinitThreadPool() not calledCall await initThreadPool() before any SDK use
Record not foundNo unspent records for this programCheck wallet has records; may need to mint first
Input format errorMissing type suffix in inputsAlways include suffix: "100u64" not "100"
Insufficient balanceNot enough credits for feeEnsure wallet has credits; check fee amount
Stale mapping valueCached/old data displayedImplement polling or manual refresh

12. Security Notes

  • Never expose private keys in frontend code. Use wallet adapters for signing.
  • Use DecryptPermission.UponRequest for production apps. AutoDecrypt is convenient for development only.
  • Validate all user inputs before passing to SDK functions.
  • Do not log record contents or private keys to the console in production.
  • Use HTTPS for all network requests to prevent MITM attacks.

13. Performance Notes

  • Web Workers are mandatory — proof generation will freeze the UI otherwise
  • Cache proving keys using keyProvider.useCache(true) to avoid re-downloading (keys are large)
  • Poll mapping values at reasonable intervals (15-30s), not continuously
  • Use @provablehq/wasm initialization once at app startup, not per-operation
  • Show progress feedback during proof generation (spinner, progress bar)
  • Write Leo programs using aleo_smart_contracts
  • Server-side integration using aleo_backend
  • Deploy programs using aleo_deployment
  • Complete recipes in aleo_cookbook

15. Agent SOP: Frontend Integration Workflow

  1. Model the user flow: connect wallet, collect typed inputs, submit transaction, observe confirmation
  2. Initialize SDK once: set up WASM and worker lifecycle before first proof request
  3. Validate and serialize inputs with strict Leo suffix rules before transaction creation
  4. Execute in worker thread and keep UI responsive with loading and error states
  5. Broadcast via wallet adapter and capture transaction ID for status polling
  6. Read back public state from mappings to confirm the UI reflects canonical on-chain values
  7. On failure: map to the error matrix and apply the documented remediation path before retrying

Sources