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/sdkand wallet adapter packages - Canonical syntax: Program calls should target Leo
transition/async transitionentry points (with pairedasync 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:
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
npm install @provablehq/sdk @provablehq/wasm
For wallet integration:
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
// 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
// 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
// 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
// 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
// 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
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
// 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
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
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
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 Type | TypeScript Input Format | Example |
|---|---|---|
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" | — |
| Record | record.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)
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
| Error | Cause | Fix |
|---|---|---|
WalletNotConnectedError | No wallet connected | Show connect button first |
| UI freezes during execution | Proof generation on main thread | Move to Web Worker |
SharedArrayBuffer not available | Missing COOP/COEP headers | Add cross-origin headers to Vite config |
WASM not initialized | initThreadPool() not called | Call await initThreadPool() before any SDK use |
Record not found | No unspent records for this program | Check wallet has records; may need to mint first |
| Input format error | Missing type suffix in inputs | Always include suffix: "100u64" not "100" |
Insufficient balance | Not enough credits for fee | Ensure wallet has credits; check fee amount |
| Stale mapping value | Cached/old data displayed | Implement polling or manual refresh |
12. Security Notes
- Never expose private keys in frontend code. Use wallet adapters for signing.
- Use
DecryptPermission.UponRequestfor production apps.AutoDecryptis 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/wasminitialization 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
- Model the user flow: connect wallet, collect typed inputs, submit transaction, observe confirmation
- Initialize SDK once: set up WASM and worker lifecycle before first proof request
- Validate and serialize inputs with strict Leo suffix rules before transaction creation
- Execute in worker thread and keep UI responsive with loading and error states
- Broadcast via wallet adapter and capture transaction ID for status polling
- Read back public state from mappings to confirm the UI reflects canonical on-chain values
- On failure: map to the error matrix and apply the documented remediation path before retrying