Aleo for Agents is offline at present

skill·2026-08-03·11 min read

Aleo Frontend Integration

Aleo frontend integration

1. Overview

An Aleo frontend needs @provablehq/sdk for proof generation, a wallet adapter for user interaction, and the REST API for reading public state. The one architectural decision that is not negotiable: proof generation runs in a Web Worker. It takes seconds to a minute and will freeze the main thread otherwise.

Version and canonical syntax

Target: @provablehq/sdk 0.11.6, create-leo-app 0.11.6, Leo compiler >= 4.4.0.

Programs you call use Leo 4.4 conventions: fn entry points, final { } blocks for public state changes, Final return types. From the client side this mostly does not matter, with one exception: an entry point returning Final has an on-chain half that can fail after the proof verifies, so a submitted transaction is not a completed state change.

Inputs are always typed Leo literals as strings: "100u64", "1field", "1scalar". Never a bare number.

The public API endpoint is https://api.explorer.provable.com/v1. There is no /v2; it returns 404.

2. Key concepts

  • WASM: the @provablehq/wasm module doing the cryptography in the browser. It must be initialized before use.
  • Web Worker: a background thread. Proof generation belongs here, always.
  • ProgramManager: builds executions and deployments, manages keys.
  • AleoNetworkClient: queries mappings, programs, transactions, and blocks.
  • AleoKeyProvider: caches proving and verifying keys, which are large enough that re-downloading them is user-visible.
  • NetworkRecordProvider: finds unspent records for an account.
  • Wallet adapter: @demox-labs/aleo-wallet-adapter-* connects to Leo Wallet, Puzzle Wallet, and others.
  • DecryptPermission: how much record decryption a wallet grants. UponRequest prompts per decryption, AutoDecrypt does not, NoDecrypt refuses.

3. Scaffolding

bash
npm create leo-app@latest

This generates a Leo program, a React frontend with the wallet adapter wired up, a Web Worker for proof generation, and a Vite config with the WASM headers already set. Starting here saves a day of build configuration.

4. Installation

bash
npm install @provablehq/sdk@0.11.6 @provablehq/wasm@0.11.6
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",
        },
    },
});

The cross-origin headers enable SharedArrayBuffer, which the WASM thread pool needs. Without them you get an opaque failure inside the worker rather than a clear error, and the same headers have to be set by whatever serves the production build, not just the dev server. This catches a lot of people at deploy time.

5. The Web Worker

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

await initThreadPool();

const ENDPOINT = "https://api.explorer.provable.com/v1";

const networkClient = new AleoNetworkClient(ENDPOINT);
const keyProvider = new AleoKeyProvider();
keyProvider.useCache(true);

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(ENDPOINT, keyProvider, recordProvider);
    programManager.setAccount(account);
    programManagers.set(privateKey, programManager);
    return programManager;
}

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, priorityFee, privateKey } = data;
                const programManager = getProgramManager(privateKey);

                // Note the positional arguments here, unlike buildExecutionTransaction
                const tx = await programManager.buildDeploymentTransaction(
                    program,
                    priorityFee,
                    false,
                );

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

The base fee is estimated automatically in SDK 0.11.x. priorityFee is the only fee you set; a fee option is ignored.

Keying the manager cache by private key means holding keys in worker memory. That is acceptable only for a burner or development key. For anything holding real value, use the wallet adapter flow in section 6 and keep keys out of your code entirely.

Driving the worker from React

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

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(new Error("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(new Error(event.data.data));
                    }
                };

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

    return { execute, loading, error };
}

Assigning onmessage per call means a second call in flight overwrites the first one's handler. That is fine while the UI blocks on one operation at a time and a bug the moment it does not; use a request ID map if you allow concurrent operations.

6. Wallet adapter

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>
    );
};
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 } = useWallet();

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

        try {
            const tx = Transaction.createTransaction(
                publicKey,
                WalletAdapterNetwork.Testnet,
                "token.aleo",
                "transfer_public",
                ["aleo1receiver...", "100u64"],
                1_000_000,   // fee in microcredits
            );

            const txId = await requestTransaction(tx);
            console.log("Submitted:", txId);
        } catch (error) {
            if (error.name === "WalletNotConnectedError") {
                console.error("Connect a wallet first");
            } else {
                console.error("Transaction failed:", error);
            }
        }
    };

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

The wallet does the proving and signing, so your app never touches a private key. This is the right default for anything user-facing.

Requesting records:

tsx
const { requestRecords } = useWallet();

const records = await requestRecords("token.aleo");

What comes back depends on the DecryptPermission the user granted. Under UponRequest each decryption prompts, so do not call this in a render path or a polling loop.

7. Reading public state

No wallet needed, and no transaction.

typescript
const BASE = "https://api.explorer.provable.com/v1";
const NETWORK = "testnet";

async function getMappingValue(programId: string, mappingName: string, key: string) {
    const url = `${BASE}/${NETWORK}/program/${programId}/mapping/${mappingName}/${key}`;
    const response = await fetch(url);
    if (!response.ok) throw new Error(`HTTP ${response.status}`);
    return response.text();     // "100u64"
}

Or through the SDK:

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

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

const balance = await client.getProgramMappingValue("token.aleo", "account", "aleo1...");
const height = await client.getLatestHeight();
const source = await client.getProgram("token.aleo");
const tx = await client.getTransaction("at1...");

A React hook with polling:

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;
        let cancelled = false;

        const fetchValue = async () => {
            setLoading(true);
            setError(null);
            try {
                const url =
                    `https://api.explorer.provable.com/v1/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();
                if (!cancelled) setValue(data);
            } catch (err: any) {
                if (!cancelled) setError(err.message);
            } finally {
                if (!cancelled) setLoading(false);
            }
        };

        fetchValue();
        const interval = setInterval(fetchValue, 15000);
        return () => {
            cancelled = true;
            clearInterval(interval);
        };
    }, [programId, mappingName, key]);

    return { value, loading, error };
}

The cancelled flag matters: without it, an in-flight request that resolves after the key changes writes stale data into state.

8. Transaction status

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

Finding the transaction proves it was accepted, not that its final block succeeded. When the UI reports a balance or a state change, read the mapping back rather than trusting the transaction ID.

9. Input serialization

Leo typeInput formatExample
u8 to u128typed literal string"255u8", "1000000u64"
i8 to i128typed literal string"-42i32"
fieldtyped literal string"123456789field"
scalartyped literal string"1scalar"
grouptyped literal string"0group"
bool"true" or "false"
addressfull address string"aleo1..."
Recordplaintext record stringrecord.toString()
StructLeo literal string"{ field1: 1u64, field2: 2u64 }"

A bare "100" is rejected. The suffix is part of the value, not decoration.

10. Record decryption in the browser

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

const viewKey = ViewKey.from_string("AViewKey1...");
const ciphertext = RecordCiphertext.fromString("record1...");
const plaintext = viewKey.decrypt(ciphertext.toString());

Handling a view key in browser code means anything with script access on that page can read every record the address ever received, and a view key cannot be revoked. Prefer the wallet's decrypt permission flow, and treat a view key in the frontend as a deliberate exception rather than a default.

11. Common frontend errors

ErrorCauseFix
WalletNotConnectedErrorno wallet connectedShow the connect button first
UI freezes during executionproving on the main threadMove it to a Web Worker
SharedArrayBuffer not availablemissing COOP and COEP headersSet them on the dev server and in production
WASM not initializedinitThreadPool() not awaitedAwait it before any SDK call
404 from the APIusing /v2The endpoint is /v1
Input format errormissing type suffix"100u64", not "100"
Fee option ignoredpassing feeUse priorityFee; the base fee is automatic
Deployment builder rejects its argumentoptions object passed positionallybuildDeploymentTransaction(source, priorityFee, privateFee)
Record not foundno unspent records for this programCheck the wallet has records; mint first if needed
Stale value shown after a transactionreading before finalization landedPoll the mapping, not just the transaction

12. Security notes

Never put a private key in frontend code. Use a wallet adapter for signing.

Use DecryptPermission.UponRequest in production. AutoDecrypt is a development convenience that silently grants standing access to record contents.

Validate user input before it reaches the SDK, including the type suffix. An unvalidated string reaches the proving path and fails there, which is a slow and confusing way to learn about a typo.

Do not log record contents or keys to the console in production builds.

Use HTTPS everywhere.

13. Performance notes

Web Workers are not optional; proving blocks whatever thread it runs on.

Cache proving keys with keyProvider.useCache(true). They are large, and re-downloading them on every page load is the single most visible performance mistake in an Aleo frontend.

Call initThreadPool() once at startup, not per operation.

Poll mapping values every 15 to 30 seconds. Continuous polling gets you rate-limited and tells the user nothing they could not wait a few seconds for.

Show progress during proof generation. A 30-second wait with no feedback reads as a broken app.

Write Leo programs with aleo_smart_contracts. Server-side integration with aleo_backend, which also covers prepared program contexts for repeated calls. Deployment with aleo_deployment. Working code in aleo_cookbook.

15. Agent frontend workflow

  1. Model the user flow: connect wallet, collect typed inputs, submit, observe confirmation.
  2. Initialize WASM and the worker once, before the first proof request.
  3. Validate and serialize inputs with the suffix rules in section 9.
  4. Execute in the worker and keep the UI responsive with explicit loading and error states.
  5. Broadcast through the wallet adapter and keep the transaction ID.
  6. Read the mapping back to confirm the state change, rather than trusting the transaction ID alone.
  7. On failure, match the error to the table in section 11 before retrying.

Sources