Transaction forms age badly when their ABI lives in a TypeScript file copied months ago.
A function gains an argument or changes an input type, but the frontend keeps rendering the old shape. The user discovers the mismatch only after selecting a record, approving a fee, or starting proof generation.
A deployed Aleo program is machine-readable. The Provable SDK can parse its instructions and expose functions, ordered inputs, mappings, imports, structs, and records. That gives us enough information to generate a safe generic transaction form from the program users will execute.
Program inspection does have a limit: compiled inputs use registers such as r0 and r1, so inspection cannot recover descriptive parameter names from the original Leo source. A wallet can generate correct generic labels and add optional product metadata for better wording.
This project builds the generic layer without a handwritten ABI or a regular-expression parser for Aleo instructions.
If you also need browser-side execution and proving, see the browser-first private transfer tutorial. Clients that prove repeatedly can use the reusable proving context project to avoid rebuilding the same context for each execution.
What we are building
The project has two parts.
First, we will create a Leo 4 program containing:
- A public struct input.
- A private plaintext input.
- A private record input.
- A public mapping.
- Singleton storage.
- A constructor that initializes storage for edition zero.
Second, we will build a Vite and TypeScript page that:
- Fetches a deployed program by ID.
- Lists its functions and imports.
- Displays mapping key and value types.
- Generates controls from
getFunctionInputs()descriptors. - Expands struct members into separate controls.
- Parses plaintext values with the SDK.
- Accepts records only as complete record plaintexts.
- Produces the ordered
string[]required by an execution API.
The record rule matters. A spendable record contains ownership data and compiler-managed fields such as _nonce and _version. It must also represent a real unspent output. The generated form checks serialization, while the connected wallet remains responsible for ownership and spent-state checks.
Prerequisites
Use Node.js 22 or newer, npm, and Leo 4.4.x. This project imports the SDK's mainnet entry point.
Check the installed tools:
node --version
npm --version
leo --version
Create the workspace:
mkdir abi-driven-aleo-form
cd abi-driven-aleo-form
leo new invoice_form_demo
npm create vite@latest web -- --template vanilla-ts
The Leo program can be built and run locally at once. A network client can fetch it by program ID only after deployment, so the browser test will use credits.aleo. The invoice program remains a local schema fixture unless you deploy it.
Build the Leo fixture
Replace invoice_form_demo/src/main.leo with this program:
struct InvoiceTerms {
recipient: address,
amount: u64,
due_height: u32,
}
program invoice_form_demo.aleo {
storage created_count: u64;
mapping public_totals: address => u64;
record Invoice {
owner: address,
public issuer: address,
amount: u64,
memo_hash: field,
}
fn create_invoice(
public terms: InvoiceTerms,
private memo_hash: field,
) -> (Invoice, Final) {
let issuer: address = std::ctx::caller();
let invoice: Invoice = Invoice {
owner: terms.recipient,
issuer,
amount: terms.amount,
memo_hash,
};
return (invoice, final {
let old_total: u64 = public_totals.get_or_use(terms.recipient, 0u64);
public_totals.set(terms.recipient, old_total + terms.amount);
let count: u64 = created_count.unwrap_or(0u64);
created_count = count + 1u64;
});
}
fn transfer_invoice(
invoice: Invoice,
private new_owner: address,
) -> Invoice {
return Invoice {
owner: new_owner,
issuer: invoice.issuer,
amount: invoice.amount,
memo_hash: invoice.memo_hash,
};
}
@custom
constructor() {
if std::ctx::edition() == 0u16 {
created_count = 0u64;
}
}
}
InvoiceTerms sits at module scope. The program block contains the record, mapping, storage declaration, functions, and constructor.
create_invoice accepts one public struct and one private field. Its proof computation creates a private Invoice record. The finalizer then updates the recipient's public total and the singleton invoice count after the proof is accepted.
The constructor initializes created_count only when the edition is zero. An upgrade therefore does not reset the existing value. The unwrap_or in the finalizer provides a cheap fallback if the storage value is absent.
transfer_invoice consumes an existing record. Its record argument has no visibility modifier because the record type already defines its privacy behavior.
Replace invoice_form_demo/program.json with:
{
"program": "invoice_form_demo.aleo",
"version": "0.1.0",
"description": "Schema fixture for an ABI-driven Aleo transaction form",
"license": "MIT",
"dependencies": null,
"dev_dependencies": null
}
Build and run the fixture:
cd invoice_form_demo
leo build
leo run create_invoice "{ recipient: aleo1q3vx8pet0h7739hx5xlekfxh9kus6qdlxhx9qdkxhh9rnva8q5gsskve3t, amount: 250u64, due_height: 100u32 }" 123field
cd ../web
The quoted first argument is a single struct plaintext. The second argument is private in the program interface, although a local shell can still see the command you entered.
Configure the web project
Replace web/package.json with:
{
"name": "abi-driven-aleo-form",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"@provablehq/sdk": "0.11.6"
},
"devDependencies": {
"typescript": "^5.9.2",
"vite": "^7.1.1"
}
}
Install the dependencies:
npm install
Keep the Vite-generated index.html and tsconfig.json. The TypeScript entry point will create the interface through DOM APIs, so the project does not need a handwritten page template.
Replace web/src/style.css with:
:root {
font-family: system-ui, sans-serif;
color: #1d1d1f;
background: #ffffff;
}
body {
margin: 0;
}
main {
max-width: 860px;
margin: 40px auto;
padding: 0 20px 60px;
}
label,
select,
input,
textarea,
button {
display: block;
width: 100%;
box-sizing: border-box;
}
input,
select,
textarea,
button {
margin: 6px 0 16px;
padding: 10px;
font: inherit;
}
fieldset {
margin: 12px 0 18px;
padding: 14px;
}
pre {
padding: 16px;
background: #f3f3f3;
overflow: auto;
white-space: pre-wrap;
}
.meta {
margin-top: -12px;
margin-bottom: 16px;
color: #555555;
font-size: 0.9rem;
}
.error {
color: #a40000;
}
Inspect the program
Replace web/src/main.ts with the following implementation:
import {
AleoNetworkClient,
Plaintext,
Program,
RecordPlaintext,
} from "@provablehq/sdk/mainnet.js";
import "./style.css";
type Visibility = "constant" | "public" | "private";
type Descriptor = {
name?: string;
type: string;
visibility?: Visibility;
register?: string;
record?: string;
struct_id?: string;
members?: Descriptor[];
};
type MappingDescriptor = {
name: string;
key_name: string;
key_type: string;
value_name: string;
value_type: string;
};
type Control = {
element: HTMLElement;
read: () => string;
};
const root = document.querySelector("#app") as HTMLDivElement | null;
if (!root) throw new Error("Missing Vite app element");
const main = document.createElement("main");
const heading = document.createElement("h1");
const programLabel = document.createElement("label");
const programIdInput = document.createElement("input");
const loadButton = document.createElement("button");
const programInfo = document.createElement("div");
const functionLabel = document.createElement("label");
const functionSelect = document.createElement("select");
const form = document.createElement("form");
const output = document.createElement("pre");
heading.textContent = "ABI-driven Aleo transaction form";
programLabel.textContent = "Program ID";
programLabel.htmlFor = "program-id";
programIdInput.id = "program-id";
programIdInput.value = "credits.aleo";
loadButton.type = "button";
loadButton.textContent = "Load program";
functionLabel.textContent = "Function";
functionLabel.htmlFor = "function-name";
functionSelect.id = "function-name";
output.textContent = "Load a program to begin.";
main.append(
heading,
programLabel,
programIdInput,
loadButton,
programInfo,
functionLabel,
functionSelect,
form,
output,
);
root.replaceChildren(main);
const client = new AleoNetworkClient("https://api.provable.com/v2");
let program: Program | null = null;
let controls: Control[] = [];
function setOutput(value: unknown, error = false): void {
output.className = error ? "error" : "";
output.textContent = typeof value === "string"
? value
: JSON.stringify(value, null, 2);
}
function normalizePlaintext(raw: string, type: string): string {
let value = raw.trim();
const integerType = /^(?:u|i)(?:8|16|32|64|128)$/;
if (integerType.test(type)) {
const suffixed = new RegExp(`^-?\\d+${type}
Build an ABI-Driven Aleo Transaction Form with the Provable SDK | Aleo for Agents
);
if (!suffixed.test(value)) {
if (!/^-?\d+$/.test(value)) throw new Error(`Expected ${type}`);
value = `${value}${type}`;
}
} else if (
["field", "group", "scalar"].includes(type) &&
/^-?\d+$/.test(value)
) {
value = `${value}${type}`;
} else if (type === "bool" && value !== "true" && value !== "false") {
throw new Error("Expected true or false");
}
Plaintext.fromString(value);
return value;
}
function describeMembers(members: Descriptor[] = []): string {
return members
.map((member) => {
const name = member.name ?? "member";
const visibility = member.visibility ? `.${member.visibility}` : "";
return `${name}: ${member.type}${visibility}`;
})
.join("\n");
}
function hydrateDescriptor(value: Descriptor): Descriptor {
if (!program) return value;
try {
if (value.type === "record" && value.record) {
const record = program.getRecordMembers(value.record) as Descriptor;
return { ...value, members: record.members ?? value.members };
}
if (value.type === "struct" && value.struct_id) {
const members = program.getStructMembers(value.struct_id) as Descriptor[];
return { ...value, members };
}
} catch {
return value;
}
return value;
}
function buildControl(raw: Descriptor, label: string): Control {
const descriptor = hydrateDescriptor(raw);
if (descriptor.type === "record") {
const wrapper = document.createElement("fieldset");
const legend = document.createElement("legend");
const schema = document.createElement("pre");
const textarea = document.createElement("textarea");
legend.textContent = `${label}: ${descriptor.record ?? "record"}`;
schema.textContent = describeMembers(descriptor.members);
textarea.rows = 8;
textarea.placeholder = "Paste a decrypted record plaintext";
wrapper.append(legend, schema, textarea);
return {
element: wrapper,
read: () => {
const value = textarea.value.trim();
RecordPlaintext.fromString(value);
return value;
},
};
}
if (descriptor.type === "struct" && descriptor.members) {
const wrapper = document.createElement("fieldset");
const legend = document.createElement("legend");
legend.textContent = `${label}: ${descriptor.struct_id ?? "struct"}`;
const children = descriptor.members.map((member) => {
const name = member.name ?? "member";
const control = buildControl(member, name);
wrapper.append(control.element);
return { name, control };
});
wrapper.prepend(legend);
return {
element: wrapper,
read: () => {
const fields = children.map(
({ name, control }) => `${name}: ${control.read()}`,
);
const value = `{ ${fields.join(", ")} }`;
Plaintext.fromString(value);
return value;
},
};
}
const wrapper = document.createElement("div");
const inputLabel = document.createElement("label");
const input = document.createElement("input");
const meta = document.createElement("div");
inputLabel.textContent = label;
input.autocomplete = "off";
meta.className = "meta";
meta.textContent = descriptor.visibility
? `${descriptor.type} · ${descriptor.visibility}`
: descriptor.type;
wrapper.append(inputLabel, input, meta);
return {
element: wrapper,
read: () => normalizePlaintext(input.value, descriptor.type),
};
}
function renderFunction(): void {
if (!program) return;
form.replaceChildren();
controls = [];
const functionName = functionSelect.value;
const inputs = program.getFunctionInputs(functionName) as Descriptor[];
inputs.forEach((descriptor, index) => {
const register = descriptor.register ? ` (${descriptor.register})` : "";
const label = `Input ${index + 1}${register}`;
const control = buildControl(descriptor, label);
controls.push(control);
form.append(control.element);
});
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = "Validate execution inputs";
form.append(submit);
}
async function loadProgram(): Promise<void> {
const id = programIdInput.value.trim();
if (!/^[a-z][a-z0-9_]*\.aleo$/.test(id)) {
throw new Error("Enter a valid program ID ending in .aleo");
}
loadButton.disabled = true;
setOutput(`Fetching ${id}...`);
try {
const source = await client.getProgram(id);
program = Program.fromString(source);
const functions = program.getFunctions() as string[];
const imports = program.getImports() as string[];
const mappings = program.getMappings() as MappingDescriptor[];
const options = functions.map((name) => {
const option = document.createElement("option");
option.value = name;
option.textContent = name;
return option;
});
functionSelect.replaceChildren(...options);
const details = document.createElement("pre");
details.textContent = JSON.stringify(
{
program: program.id(),
imports,
mappings,
},
null,
2,
);
programInfo.replaceChildren(details);
renderFunction();
setOutput({ program: program.id(), functions });
} finally {
loadButton.disabled = false;
}
}
loadButton.addEventListener("click", () => {
loadProgram().catch((error: unknown) => {
setOutput(error instanceof Error ? error.message : String(error), true);
});
});
functionSelect.addEventListener("change", renderFunction);
form.addEventListener("submit", (event) => {
event.preventDefault();
try {
const inputs = controls.map((control) => control.read());
setOutput({
program: program?.id(),
function: functionSelect.value,
inputs,
});
} catch (error: unknown) {
setOutput(error instanceof Error ? error.message : String(error), true);
}
});
getProgram() downloads the deployed Aleo instructions. Program.fromString() parses them locally, after which the inspection calls are synchronous. If you do not need the source text, getProgramObject(programId) can replace those two operations.
getFunctions() supplies the function selector. getFunctionInputs() returns ordered descriptors for the selected function, including type, visibility, register, and nested type metadata when available. Input order is part of the program interface, so the form preserves it when constructing the execution array.
hydrateDescriptor() requests the complete local definition for a struct or record. Imported types require extra care because the root program object may not contain every imported definition. The fallback keeps any nested members attached to the input descriptor. A production wallet should fetch the import tree, validate it, and cache it by deployment edition or source hash.
Plaintext inputs pass through Plaintext.fromString(). Integer controls accept either 250 or 250u64 and return the suffixed representation. Struct controls serialize their child values into one Aleo object and parse that complete value again.
Records use RecordPlaintext.fromString(). The page does not manufacture a record from editable fields because a visually valid object may not correspond to any output on-chain. A wallet should supply a decrypted record that already exists, then confirm ownership and spent state before execution.
Mappings appear as inspection data rather than editable controls. Their public state can change between rendering and execution, so reading or simulating mapping values requires a separate network request.
The implementation also writes program details with textContent and constructs interface elements through DOM methods. It does not insert fetched program data as markup.
Test the form
Build the frontend before starting the development server:
npm run build
npm run dev -- --host 127.0.0.1
Open the local address printed by Vite. The program field starts with credits.aleo.
Choose a function with plaintext inputs and enter values in the generated controls. Integer inputs can include their suffix, such as 1000000u64. Address inputs must contain complete Aleo addresses.
After validation, the output has this shape:
{
"program": "credits.aleo",
"function": "transfer_public",
"inputs": [
"aleo1...",
"1000000u64"
]
}
That ordered input array can be passed to a wallet or SDK execution layer. This page stops before signing, fee selection, proof generation, and broadcast. Those operations should remain behind an explicit wallet approval.
Next, select a function that consumes a record. The page displays the discovered record schema and asks for one complete decrypted record plaintext. Malformed serialization should fail local parsing. A correctly serialized but spent record can still pass because spent-state validation needs network data.
Run the fixture test again whenever its schema changes:
cd ../invoice_form_demo
leo build
leo run create_invoice "{ recipient: aleo1q3vx8pet0h7739hx5xlekfxh9kus6qdlxhx9qdkxhh9rnva8q5gsskve3t, amount: 999u64, due_height: 500u32 }" 456field
Production extensions
A generic form should support optional metadata for labels, descriptions, units, and warnings while continuing to treat the deployed program as the authority for type, visibility, and input order. Metadata may label a field as Amount in microcredits, but it must not redefine a private u64 as another type.
Record selection should come from the connected wallet rather than a textarea. Filter decrypted records by program and record name, verify ownership locally, query spent state, and pass the selected plaintext directly into the execution request. Private values should never be written to production logs or analytics systems.
Imported programs should be resolved recursively and cached by an immutable deployment identifier. A proving client can then reuse the validated import graph and prepared program context while the deployed code remains unchanged.
For local build-time tooling, leo abi can generate JSON ABI data from a compiled program. A network-facing wallet has a different requirement: it should inspect the deployed program because the local artifact records developer intent, while the deployed instructions define what the user will execute.
Types still cannot explain business meaning. An address.private input might represent a recipient, delegate, refund account, or administrator. ABI-derived forms prevent stale schemas and reject malformed values early, but they do not replace simulation, product-specific descriptions, or a wallet confirmation screen that explains the transaction's effects.