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/wasmmodule 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.
UponRequestprompts per decryption,AutoDecryptdoes not,NoDecryptrefuses.
3. Scaffolding
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
npm install @provablehq/sdk@0.11.6 @provablehq/wasm@0.11.6
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",
},
},
});
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
// 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
// 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
// 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>
);
};
// 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:
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.
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:
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:
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
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 type | Input format | Example |
|---|---|---|
u8 to u128 | typed literal string | "255u8", "1000000u64" |
i8 to i128 | typed literal string | "-42i32" |
field | typed literal string | "123456789field" |
scalar | typed literal string | "1scalar" |
group | typed literal string | "0group" |
bool | "true" or "false" | |
address | full address string | "aleo1..." |
| Record | plaintext record string | record.toString() |
| Struct | Leo 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
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
| Error | Cause | Fix |
|---|---|---|
WalletNotConnectedError | no wallet connected | Show the connect button first |
| UI freezes during execution | proving on the main thread | Move it to a Web Worker |
SharedArrayBuffer not available | missing COOP and COEP headers | Set them on the dev server and in production |
WASM not initialized | initThreadPool() not awaited | Await it before any SDK call |
| 404 from the API | using /v2 | The endpoint is /v1 |
| Input format error | missing type suffix | "100u64", not "100" |
| Fee option ignored | passing fee | Use priorityFee; the base fee is automatic |
| Deployment builder rejects its argument | options object passed positionally | buildDeploymentTransaction(source, priorityFee, privateFee) |
Record not found | no unspent records for this program | Check the wallet has records; mint first if needed |
| Stale value shown after a transaction | reading before finalization landed | Poll 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
- Model the user flow: connect wallet, collect typed inputs, submit, observe confirmation.
- Initialize WASM and the worker once, before the first proof request.
- Validate and serialize inputs with the suffix rules in section 9.
- Execute in the worker and keep the UI responsive with explicit loading and error states.
- Broadcast through the wallet adapter and keep the transaction ID.
- Read the mapping back to confirm the state change, rather than trusting the transaction ID alone.
- On failure, match the error to the table in section 11 before retrying.