Introduction
A cross-chain deposit is not one wallet call. It is a small distributed system with two chains, an off-chain mint service, several transaction IDs, and enough waiting time for your process to crash at every awkward moment.
Treating that workflow as approve(); deposit(); done is asking for duplicate deposits.
We will build a testnet TypeScript runner that moves USDC from Arc Testnet into Circle xReserve for Aleo domain 10002. The runner stores every irreversible decision before broadcasting it, resumes from disk, asks the Aleo mint service to process the confirmed Arc deposit, and checks the resulting Aleo transaction.
Once the mint is confirmed, a Leo program creates a private DepositReceipt record. The record keeps the amount and Aleo mint transaction digest private while a public mapping prevents the same Arc deposit from receiving two receipts.
That receipt is useful application evidence. It is not a bridge proof. Our service supplies the Arc hash, Aleo hash, and amount, so the Leo program proves consistent receipt construction, not that Circle or the Aleo mint operator behaved correctly.
For a broader discussion of public market state and private ownership, see Deep Dive: Shield Swap Splits Market Transparency from Trader Privacy. The same design instinct applies here: publish only the coordination state other actors must inspect.
What we're building
The runner uses these stages:
new: validate configuration and encode the Aleo recipient.approval_prepared: sign and save the Arc approval transaction before broadcasting it.approval_confirmed: confirm the approval receipt.deposit_prepared: sign and save the xReserve deposit transaction.deposit_confirmed: require a successful Arc receipt from the xReserve contract.mint_requested: register the Arc transaction with the configured Aleo mint hook.mint_confirmed: fetch and verify the accepted Aleo mint transaction.receipt_finalized: execute the Leo receipt program.
Arc is source domain 26. Aleo is remote domain 10002. Arc Testnet uses USDC at 0x3600000000000000000000000000000000000000 and xReserve at 0x008888878f94C0d87defdf0B07f46B93C1934442.
The Aleo recipient needs special handling. Aleo addresses encode a field element with bech32m, while depositToRemote accepts a bytes32. The correct payload is the 32-byte little-endian field representation returned by Address.from_string(address).toBytesLe(). Hex-decoding the visible aleo1... text is wrong.
We will request a public USDCx mint. Its hookData is exactly 65 bytes: one 0x00 mode byte followed by 64 zero bytes. Public mode makes reconciliation easier for a tutorial. Private mint modes require the hook layout documented by the Aleo bridge manual, so do not improvise those bytes.
Prerequisites
Install Node.js 22.6 or newer, Leo 4.4.0, and an Arc-compatible EVM wallet. The wallet needs Arc Testnet USDC and native gas. Your Aleo account needs testnet credits for the receipt transaction.
Create the runner:
mkdir arc-aleo-usdcx-runner
cd arc-aleo-usdcx-runner
npm init -y
npm install viem dotenv @provablehq/sdk
npm install --save-dev typescript tsx @types/node
mkdir -p src state
Create .env:
ARC_RPC_URL=[https://your-arc-testnet-rpc.example](https://your-arc-testnet-rpc.example)
ARC_CHAIN_ID=5042002
ARC_PRIVATE_KEY=0x...
ALEO_RECIPIENT=aleo1...
DEPOSIT_AMOUNT=5.00
MINT_HOOK_URL=[https://your-testnet-mint-adapter.example](https://your-testnet-mint-adapter.example)
ALEO_API_URL=[https://api.explorer.provable.com/v1](https://api.explorer.provable.com/v1)
RECEIPT_PROGRAM=arc_usdcx_receipt.aleo
Treat MINT_HOOK_URL as an adapter boundary. Aleo's manual mint service and access policy can change independently from your state machine. The adapter used below accepts POST /mint with an Arc transaction hash, then exposes GET /mint/{arcTxHash} returning status, aleoTransactionId, and amount.
Step-by-step
Build the Leo receipt
Create a Leo project:
leo new arc_usdcx_receipt
cd arc_usdcx_receipt
Replace src/main.leo with the complete program:
program arc_usdcx_receipt.aleo {
@noupgrade
constructor() {}
record DepositReceipt {
owner: address,
source_tx: field,
mint_tx: field,
amount: u64,
}
mapping finalized: field => bool;
storage receipt_count: u64;
fn issue_receipt(
public source_tx: field,
private mint_tx: field,
private amount: u64,
) -> (DepositReceipt, Final) {
let recipient: address = std::ctx::signer();
let receipt: DepositReceipt = DepositReceipt {
owner: recipient,
source_tx: source_tx,
mint_tx: mint_tx,
amount: amount,
};
return (receipt, final {
let already_finalized: bool = finalized.get_or_use(source_tx, false);
assert(!already_finalized);
finalized.set(source_tx, true);
let count: u64 = receipt_count.unwrap_or(0u64);
receipt_count = count + 1u64;
});
}
}
The DepositReceipt is an encrypted record owned by the transaction signer. source_tx is public because it is also the idempotency key in finalized. The Aleo mint digest and amount remain record fields.
A storage singleton counts issued receipts. The count says nothing about who owns them or how much USDCx they represent.
Replace program.json with:
{
"program": "arc_usdcx_receipt.aleo",
"version": "0.1.0",
"description": "Private receipts for reconciled Arc-to-Aleo USDCx deposits",
"license": "MIT",
"leo": "4.4.0",
"dependencies": []
}
Build and run the proof half locally:
leo build
leo run issue_receipt 123field 456field 5000000u64
leo run returns the private record but does not apply the final block. Execute against testnet when you want the mapping and storage writes:
leo execute issue_receipt 123field 456field 5000000u64 \
--network testnet \
--broadcast
Deploy after choosing a globally unused program name:
leo build
leo deploy --network testnet --broadcast
I prefer the public idempotency marker here. Hiding it would force the service to discover duplicate private records after every restart, which is slower and harder to operate. The marker reveals that a particular Arc transaction received a receipt. It does not reveal the private record plaintext.
Encode the deposit
Return to the TypeScript project and create src/index.ts:
import "dotenv/config";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { spawn } from "node:child_process";
import {
createPublicClient,
createWalletClient,
defineChain,
encodeFunctionData,
http,
keccak256,
parseUnits,
pad,
toHex,
type Hex,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { Address } from "@provablehq/sdk";
const USDC = "0x3600000000000000000000000000000000000000";
const XRESERVE = "0x008888878f94C0d87defdf0B07f46B93C1934442";
const ALEO_DOMAIN = 10002;
const STATE_FILE = "state/deposit.json";
const FIELD_MODULUS = 8444461749428370424248824938781546531375899335154063827935233455917409239041n;
const erc20Abi = [{
type: "function",
name: "approve",
stateMutability: "nonpayable",
inputs: [{ name: "spender", type: "address" }, { name: "amount", type: "uint256" }],
outputs: [{ name: "ok", type: "bool" }],
}] as const;
const xreserveAbi = [{
type: "function",
name: "depositToRemote",
stateMutability: "nonpayable",
inputs: [
{ name: "value", type: "uint256" },
{ name: "remoteDomain", type: "uint32" },
{ name: "remoteRecipient", type: "bytes32" },
{ name: "localToken", type: "address" },
{ name: "maxFee", type: "uint256" },
{ name: "hookData", type: "bytes" },
],
outputs: [],
}] as const;
type State = {
amount?: string;
recipientBytes?: Hex;
approvalRaw?: Hex;
approvalHash?: Hex;
depositRaw?: Hex;
depositHash?: Hex;
mintRequested?: boolean;
aleoTransactionId?: string;
receiptTransactionId?: string;
};
const required = (name: string): string => {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return value;
};
async function loadState(): Promise<State> {
try {
return JSON.parse(await readFile(STATE_FILE, "utf8"));
} catch {
return {};
}
}
async function saveState(state: State): Promise<void> {
await mkdir("state", { recursive: true });
const temporary = `${STATE_FILE}.tmp`;
await writeFile(temporary, JSON.stringify(state, null, 2));
await rename(temporary, STATE_FILE);
}
function aleoRecipientBytes(address: string): Hex {
const bytes = Address.from_string(address).toBytesLe();
if (bytes.length !== 32) throw new Error(`Expected 32 Aleo bytes, received ${bytes.length}`);
return toHex(pad(bytes, { size: 32 }));
}
function stringToField(value: string): string {
const digest = createHash("sha256").update(value).digest("hex");
return `${BigInt(`0x${digest}`) % FIELD_MODULUS}field`;
}
function run(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { env: process.env });
let output = "";
child.stdout.on("data", chunk => output += chunk);
child.stderr.on("data", chunk => output += chunk);
child.on("close", code => code === 0 ? resolve(output) : reject(new Error(output)));
});
}
async function main(): Promise<void> {
const rpcUrl = required("ARC_RPC_URL");
const privateKey = required("ARC_PRIVATE_KEY") as Hex;
const recipient = required("ALEO_RECIPIENT");
const mintHook = required("MINT_HOOK_URL");
const aleoApi = required("ALEO_API_URL");
const amount = parseUnits(required("DEPOSIT_AMOUNT"), 6);
const arc = defineChain({
id: Number(required("ARC_CHAIN_ID")),
name: "Arc Testnet",
nativeCurrency: { name: "Gas", symbol: "USDC", decimals: 18 },
rpcUrls: { default: { http: [rpcUrl] } },
});
const account = privateKeyToAccount(privateKey);
const publicClient = createPublicClient({ chain: arc, transport: http(rpcUrl) });
const walletClient = createWalletClient({ account, chain: arc, transport: http(rpcUrl) });
const state = await loadState();
state.amount ??= amount.toString();
state.recipientBytes ??= aleoRecipientBytes(recipient);
if (state.amount !== amount.toString()) throw new Error("State amount differs from DEPOSIT_AMOUNT");
if (state.recipientBytes !== aleoRecipientBytes(recipient)) throw new Error("State recipient differs from ALEO_RECIPIENT");
await saveState(state);
async function prepareAndSend(data: Hex, rawKey: "approvalRaw" | "depositRaw", hashKey: "approvalHash" | "depositHash"): Promise<Hex> {
if (!state[rawKey] || !state[hashKey]) {
const nonce = await publicClient.getTransactionCount({ address: account.address, blockTag: "pending" });
const request = await walletClient.prepareTransactionRequest({ account, to: rawKey === "approvalRaw" ? USDC : XRESERVE, data, nonce });
const raw = await account.signTransaction(request);
state[rawKey] = raw;
state[hashKey] = keccak256(raw);
await saveState(state);
}
const hash = state[hashKey] as Hex;
const existing = await publicClient.getTransactionReceipt({ hash }).catch(() => undefined);
if (!existing) await publicClient.sendRawTransaction({ serializedTransaction: state[rawKey] as Hex }).catch(() => undefined);
const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") throw new Error(`${hashKey} reverted`);
return hash;
}
const approvalData = encodeFunctionData({ abi: erc20Abi, functionName: "approve", args: [XRESERVE, amount] });
await prepareAndSend(approvalData, "approvalRaw", "approvalHash");
const hookData = (`0x00${"00".repeat(64)}`) as Hex;
const depositData = encodeFunctionData({
abi: xreserveAbi,
functionName: "depositToRemote",
args: [amount, ALEO_DOMAIN, state.recipientBytes, USDC, 0n, hookData],
});
const depositHash = await prepareAndSend(depositData, "depositRaw", "depositHash");
if (!state.mintRequested) {
const response = await fetch(`${mintHook}/mint`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sourceDomain: 26, transactionHash: depositHash }),
});
if (!response.ok && response.status !== 409) throw new Error(`Mint hook returned ${response.status}`);
state.mintRequested = true;
await saveState(state);
}
while (!state.aleoTransactionId) {
const response = await fetch(`${mintHook}/mint/${depositHash}`);
if (!response.ok) throw new Error(`Mint status returned ${response.status}`);
const result = await response.json() as { status: string; aleoTransactionId?: string; amount?: string };
if (result.status === "failed") throw new Error("Aleo mint failed");
if (result.status === "confirmed" && result.aleoTransactionId) {
if (result.amount !== amount.toString()) throw new Error("Mint amount does not match the Arc deposit");
state.aleoTransactionId = result.aleoTransactionId;
await saveState(state);
break;
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
const aleoResponse = await fetch(`${aleoApi}/testnet/transaction/${state.aleoTransactionId}`);
if (!aleoResponse.ok) throw new Error("Aleo mint transaction is not indexed");
const aleoTransaction = JSON.stringify(await aleoResponse.json());
if (!aleoTransaction.includes("test_usdcx_stablecoin.aleo")) throw new Error("Unexpected Aleo mint program");
if (!state.receiptTransactionId) {
const output = await run("leo", [
"execute", "issue_receipt",
stringToField(depositHash),
stringToField(state.aleoTransactionId),
`${amount}u64`,
"--network", "testnet",
"--broadcast",
]);
const match = output.match(/at1[0-9a-z]+/);
if (!match) throw new Error(`Could not find receipt transaction ID in Leo output: ${output}`);
state.receiptTransactionId = match;
await saveState(state);
}
console.log(JSON.stringify(state, null, 2));
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});
Run it from the directory containing the deployed Leo project's configuration, or set up your Leo CLI so RECEIPT_PROGRAM and its key environment are available:
npx tsx src/index.ts
The raw approval and deposit transactions are saved before broadcast. A restart therefore rebroadcasts identical bytes and gets the same transaction hash. It never signs a fresh deposit merely because an RPC timed out.
Circle's xReserve contract emits DepositedToRemote after depositToRemote succeeds. Production code should decode that event with the full official ABI and compare its amount, Aleo domain, token, and recipient against the saved intent. Receipt status alone proves that the EVM call succeeded; event validation proves that the observed deposit is the one your runner intended.
The bridge SDK in @provablehq/aleo-bridge-sdk is useful for route discovery and transfer planning. Its preview transfer plans do not replace this journal yet because signing and fund-moving execution are still separate concerns. I would use its route registry, then keep the persisted state machine around it.
Testing
Start with the Leo program:
cd arc_usdcx_receipt
leo build
leo run issue_receipt 123field 456field 5000000u64
Test the final block on a local network or testnet. Calling the same source_tx twice must reject the second call:
leo execute issue_receipt 123field 456field 5000000u64 --network testnet --broadcast
leo execute issue_receipt 123field 999field 5000000u64 --network testnet --broadcast
For the runner, kill it after each saved stage and restart with the same .env:
npx tsx src/index.ts
cat state/deposit.json
npx tsx src/index.ts
The Arc transaction hashes must remain unchanged. A second process using the same state file is unsafe, so run one worker per deposit journal or protect the file with an OS lock.
Also test a mint response containing the wrong amount, an accepted Aleo transaction for another program, and an Arc deposit receipt with status: reverted. Those failures should stop the runner before it creates a receipt.
What's next
Replace the file journal with a database row guarded by a unique deposit-intent ID. Store the signed raw transaction bytes in the same transaction that advances the stage. Multiple workers can then claim jobs with row locks without racing each other.
Event decoding belongs in the next revision. Fetch Circle's complete xReserve ABI, decode DepositedToRemote, and persist its block hash plus log index. Wait for the confirmation depth your Arc risk policy requires before calling the mint hook.
Private mint mode is another project. Its hook payload needs the exact bridge-manual encoding and different Aleo reconciliation because the minted value arrives as an encrypted Token record from test_usdcx_stablecoin.aleo. A record scanner with the recipient view key is a better fit there than a public mapping query.
The receipt can also carry an auditor-facing commitment to the full mint response. Keep the response off-chain, commit to its canonical bytes in Leo, and disclose it only when somebody needs to compare the service's evidence with Circle's attestation. Even then, call it what it is: a private operational receipt issued after reconciliation, not a trustless proof that the bridge completed correctly.