AI agent documentation index: llms.txt. Raw markdown for any page is available by appending .md to the URL. Full content snapshot: llms-full.txt.
llms.txt — complete Cartesi documentation index. Append .md to any page URL for raw Markdown (e.g. /cartesi-rollups/2.0/development/building-an-application.md).
Skip to main content

Integrating ERC20 token wallet functionality

This tutorial will guide you through creating a basic ERC20 token wallet for a Cartesi backend application using TypeScript.

Setting up the project

First, create a new TypeScript project using the Cartesi CLI.

cartesi create erc-20-token-wallet --template typescript

Run the following to generate the types for your project:

yarn && yarn run codegen

Now, navigate to the project directory and install viem:

yarn add viem

Deposit payloads from portals are packed ABI-encoded fields; see asset handling for the ERC-20 layout (token, sender, amount). Withdrawals use vouchers executed by the on-chain Application contract—see withdrawing tokens.

Building the ERC20 wallet

Create a file named balance.ts in src/wallet directory and add the following code:

import { Address } from "viem";

export class Balance {
private account: string;
private erc20Balances: Map<Address, bigint>;

constructor(account: string, erc20Balances: Map<Address, bigint>) {
this.account = account;
this.erc20Balances = erc20Balances;
}

listErc20(): Map<Address, bigint> {
return this.erc20Balances;
}

getErc20Balance(erc20: Address): bigint | undefined {
return this.erc20Balances.get(erc20);
}

increaseErc20Balance(erc20: Address, amount: bigint): void {
if (amount < 0n) {
throw new Error(
`Failed to increase balance of ${erc20} for ${this.account}`
);
}
try {
if (this.erc20Balances.get(erc20) === undefined) {
this.erc20Balances.set(erc20, 0n);
}
this.erc20Balances.set(
erc20,
(this.erc20Balances.get(erc20) || 0n) + amount
);
console.log("ERC20 balance is", this.erc20Balances);
} catch (e) {
throw new Error(
`Failed to increase balance of ${erc20} for ${this.account}: ${e}`
);
}
}

decreaseErc20Balance(erc20: Address, amount: bigint): void {
if (amount < 0n) {
throw new Error(
`Failed to decrease balance of ${erc20} for ${this.account}: invalid amount specified`
);
}
if (this.erc20Balances.get(erc20) === undefined) {
this.erc20Balances.set(erc20, 0n);
throw new Error(
`Failed to decrease balance of ${erc20} for ${this.account}: not found with ERC20 balance`
);
}
let erc20Balance = this.erc20Balances.get(erc20) || 0n;
if (erc20Balance < amount) {
throw new Error(
`Failed to decrease balance of ${erc20} for ${this.account}: insufficient ERC20 balance`
);
}
this.erc20Balances.set(erc20, erc20Balance - amount);
}
}

The Balance class represents an individual account's balance. It includes methods to list ERC20 tokens and get, increase, and decrease the ERC20 balance.

Now, create a file named wallet.ts in the src/wallet directory and add the following code:

import {
Address,
getAddress,
encodeFunctionData,
sliceHex,
zeroHash,
type Hex,
} from "viem";
import { Balance } from "./balance";
import { erc20Abi } from "viem";
import { Voucher } from "..";

export class Wallet {
static accounts: Map<Address, Balance>;

constructor() {
Wallet.accounts = new Map<Address, Balance>();
}

private getBalance = (account: Address): Balance => {
let balance = Wallet.accounts.get(account);
if (!balance) {
balance = new Balance(account, new Map());
Wallet.accounts.set(account, balance);
}
return balance;
};

getAccountBalance = (
account: Address,
erc20: Address
): bigint | undefined => {
const balance = this.getBalance(account);
const erc20Balance = balance.getErc20Balance(erc20);
console.info(
`Balance for ${account} and token ${erc20} retrieved as ${erc20Balance}`
);
return erc20Balance;
};

processErc20Deposit = (payload: string): string => {
try {
let [erc20, account, amount] = this.parseErc20Deposit(payload);
console.log(`${amount} ${erc20} tokens deposited to account ${account}`);
return this.depositErc20(account, erc20, amount);
} catch (e) {
return `Error depositing ERC20 tokens: ${e}`;
}
};

private parseErc20Deposit = (payload: Hex): [Address, Address, bigint] => {
try {
const erc20 = getAddress(sliceHex(payload, 0, 20));
const account = getAddress(sliceHex(payload, 20, 40));
const amount = BigInt(sliceHex(payload, 40, 72));
return [erc20, account, amount];
} catch (e) {
throw new Error(`Error parsing ERC20 deposit: ${e}`);
}
};

private depositErc20 = (
account: Address,
erc20: Address,
amount: bigint
): string => {
let balance = this.getBalance(account);
balance.increaseErc20Balance(erc20, amount);
let noticePayload = {
type: "erc20deposit",
content: {
address: account,
erc20: erc20,
amount: amount.toString(),
},
};
return JSON.stringify(noticePayload);
};

withdrawErc20 = (
application: Address,
account: Address,
erc20: Address,
amount: bigint
): Voucher => {
try {
let balance = this.getBalance(account);
balance.decreaseErc20Balance(erc20, amount);
const call = encodeFunctionData({
abi: erc20Abi,
functionName: "transferFrom",
args: [application, account, amount],
});

console.log(`Voucher creation success`, {
destination: erc20,
payload: call,
});

return {
destination: erc20,
payload: call,
value: zeroHash,
};
} catch (e) {
throw Error(`Error withdrawing ERC20 tokens: ${e}`);
}
};

transferErc20 = (
from: Address,
to: Address,
erc20: Address,
amount: bigint
): string => {
try {
let balanceFrom = this.getBalance(from);
let balanceTo = this.getBalance(to);
balanceFrom.decreaseErc20Balance(erc20, amount);
balanceTo.increaseErc20Balance(erc20, amount);
let noticePayload = {
type: "erc20transfer",
content: {
from: from,
to: to,
erc20: erc20,
amount: amount.toString(),
},
};
console.info(
`${amount} ${erc20} tokens transferred from ${from} to ${to}`
);
return JSON.stringify(noticePayload);
} catch (e) {
throw Error(`Error transferring ERC20 tokens: ${e}`);
}
};
}

Voucher creation

The withdrawErc20 method encodes transferFrom(application, recipient, amount) and returns a voucher. Tokens deposited through the portal sit in your on-chain Application contract; the application address in the voucher must match metadata.app_contract from the advance. Set value to zeroHash when no Ether is sent with the call. See withdrawing tokens.

Using the ERC20 wallet

Now, let's create a simple application at the entry point src/index.ts to test the wallet’s functionality.

The ERC20Portal contract moves ERC-20 tokens from the base layer into your application. Deposits arrive as advances whose metadata.msg_sender is the portal address.

ERC20Portal address

Run cartesi address-book and copy the ERC20Portal address for your network into index.ts. Do not hardcode portal addresses—they differ by CLI version and chain.

import createClient from "openapi-fetch";
import type { components, paths } from "./schema";
import { Wallet } from "./wallet/wallet";
import { stringToHex, getAddress, Address, hexToString } from "viem";

type AdvanceRequestData = components["schemas"]["Advance"];
type InspectRequestData = components["schemas"]["Inspect"];
type RequestHandlerResult = components["schemas"]["Finish"]["status"];
type RollupRequest = components["schemas"]["RollupRequest"];
type InspectRequestHandler = (data: InspectRequestData) => Promise<void>;
type AdvanceRequestHandler = (
data: AdvanceRequestData
) => Promise<RequestHandlerResult>;

export type Notice = components["schemas"]["Notice"];
export type Payload = components["schemas"]["Payload"];
export type Report = components["schemas"]["Report"];
export type Voucher = components["schemas"]["Voucher"];

const wallet = new Wallet();
// Replace with the ERC20Portal address from `cartesi address-book`
const ERC20Portal = `0xYOUR_ERC20_PORTAL_ADDRESS`;

const rollupServer = process.env.ROLLUP_HTTP_SERVER_URL;
console.log(`HTTP rollup_server url is ${rollupServer}`);

const handleAdvance: AdvanceRequestHandler = async (data) => {
console.log("Received advance request data " + JSON.stringify(data));

const application = data["metadata"]["app_contract"];
const sender = data["metadata"]["msg_sender"];
const payload = data.payload;

if (sender.toLowerCase() === ERC20Portal.toLowerCase()) {
// Handle deposit
const deposit = wallet.processErc20Deposit(payload);
await createNotice({ payload: stringToHex(deposit) });
} else {
// Handle transfer or withdrawal
try {
const { operation, erc20, from, to, amount } = JSON.parse(
hexToString(payload)
);

if (operation === "transfer") {
const transfer = wallet.transferErc20(
getAddress(from as Address),
getAddress(to as Address),
getAddress(erc20 as Address),
BigInt(amount)
);

await createNotice({ payload: stringToHex(transfer) });
} else if (operation === "withdraw") {
const voucher = wallet.withdrawErc20(
getAddress(application as Address),
getAddress(from as Address),
getAddress(erc20 as Address),
BigInt(amount)
);

await createVoucher(voucher);
} else {
console.log("Unknown operation");
}
} catch (error) {
console.error("Error processing payload:", error);
}
}

return "accept";
};

const handleInspect: InspectRequestHandler = async (data) => {
console.log("Received inspect request data " + JSON.stringify(data));

try {
const payloadString = hexToString(data.payload);

const [address, erc20] = payloadString.split("/");

const balance = wallet.getAccountBalance(
address as Address,
erc20 as Address
);

if (balance === undefined) {
throw new Error("ERC20 balance is undefined");
}

const balmsg = `Balance of token ${erc20} for user address ${address} is ${balance}`;

await createReport({ payload: stringToHex(balmsg) });
} catch (error) {
const error_message = `Error processing inspect payload: ${error}`;

await createReport({ payload: stringToHex(error_message) });
}
};

const createNotice = async (payload: Notice) => {
console.log("creating notice with payload", payload);

await fetch(`${rollupServer}/notice`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
};

const createVoucher = async (payload: Voucher) => {
await fetch(`${rollupServer}/voucher`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
};

const createReport = async (payload: Report) => {
await fetch(`${rollupServer}/report`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
};

const main = async () => {
const { POST } = createClient<paths>({ baseUrl: rollupServer });
let status: RequestHandlerResult = "accept";
while (true) {
const { data, response } = await POST("/finish", {
body: { status },
parseAs: "text",
});

if (response.status === 200 && data) {
const request = JSON.parse(data) as RollupRequest;
switch (request.request_type) {
case "advance_state":
status = await handleAdvance(request.data as AdvanceRequestData);
break;
case "inspect_state":
await handleInspect(request.data as InspectRequestData);
break;
}
} else if (response.status === 202) {
// no rollup request available
console.log(await response.text());
}
}
};

main().catch((e) => {
console.log(e);
process.exit(1);
});

Here is a breakdown of the wallet functionality:

  • We handle deposits when the sender is the ERC20Portal.

  • We parse the payload for other senders to determine the operation (transfer or withdraw).

  • For transfers, we call wallet.transferErc20 and create a notice with the parsed parameters.

  • For withdrawals, we call wallet.withdrawErc20 with the on-chain application address (metadata.app_contract) as the transferFrom sender, then emit a voucher to the token contract.

  • We created helper functions to createNotice for deposits and transfers, createReport for balance checks, and createVoucher for withdrawals.

Build and run the application

With Docker running, build your backend application by running:

cartesi build

To run your application, enter the command:

cartesi run

Deposits

token approvals

An approval step is needed for the ERC20 token standard. This ensures you grant explicit permission for ERC20Portal to transfer tokens on your behalf.

Without this approval, the ERC20Portal cannot deposit your tokens to the Cartesi backend.

You will encounter this error if you don't approve the ERC20Portal address before deposits:

ContractFunctionExecutionError: The contract function "depositERC20Tokens" reverted with the following reason: ERC20: insufficient allowance

To deposit ERC20 tokens interactively:

cartesi deposit erc20

Non-interactive alternative. Run from your project root (with cartesi run up). Resolve addresses from cartesi address-book:

TOKEN=$(cartesi address-book 2>&1 | grep -i TestToken | awk '{print $NF}')
ERC20_PORTAL=$(cartesi address-book 2>&1 | grep -i ERC20Portal | awk '{print $NF}')

cast send "$TOKEN" \
"approve(address,uint256)" \
"$ERC20_PORTAL" \
1000000000000000000 \
--rpc-url http://127.0.0.1:6751/anvil \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80

cartesi deposit erc20 100000000000000000 \
--token "$TOKEN" \
--project-name erc-20-token-wallet \
--rpc-url http://127.0.0.1:6751/anvil

Balance checks (used in Inspect requests)

Inspect payloads use the form userAddress/erc20TokenAddress as a UTF-8 string (hex-encoded in the JSON body). Example for user 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 and token 0x5FbDB2315678afecb367f032d93F642f64180aa3:

curl -X POST http://127.0.0.1:6751/inspect/erc-20-token-wallet \
-H "Content-Type: application/json" \
-d '{"payload": "0x3078663339466436653531616164383846364634636536614238383237323739636666466239323236362f307835466244423233313536373861666563623336376630333264393346363432663634313830616133"}'

Replace the inspect path if your project name is not erc-20-token-wallet (see the URL printed by cartesi run). Build the payload with stringToHex from viem if you use different addresses.

Transfers and Withdrawals

To process transfers and withdrawals interactively, run the command below, select String encoding, then enter one of the sample payloads:

cartesi send

Non-interactive alternative (from your project root):

cartesi send --encoding string \
'{"operation":"withdraw","erc20":"0xTokenAddress","from":"0xFromAddress","amount":"1000000000000000000"}' \
--project-name erc-20-token-wallet \
--rpc-url http://127.0.0.1:6751/anvil
  1. For transfers:

    {"operation":"transfer","erc20":"0xTokenAddress","from":"0xFromAddress","to":"0xToAddress","amount":"1000000000000000000"}
  2. For withdrawals:

    {"operation":"withdraw","erc20":"0xTokenAddress","from":"0xFromAddress","amount":"1000000000000000000"}

Using the explorer

CartesiScan is a web application that offers a comprehensive overview of your application. It provides expandable data regarding notices, vouchers, and reports.

Start the node with the explorer service enabled:

cartesi run --services explorer

The local explorer is then available at http://localhost:6751/explorer (same port as the application node—see running an application). The explorer is not started by plain cartesi run; you must pass --services explorer.

You can execute your vouchers via the explorer, which completes the withdrawal process at the end of an epoch.

Repo Link

You can access the complete project implementation here!

We use cookies to ensure that we give you the best experience on our website. By using the website, you agree to the use of cookies.