Skip to main content

Since January 2026, we are unfortunately unable to actively maintain or develop ink! further.

We have written down the details in a Polkadot Forum post.

Frontend Title Picture

Building a Frontend for an ink! ERC20 Smart Contract with Inkathon

In this tutorial, you'll learn how to build a frontend interface that interacts with an ERC20 smart contract written in ink! on a Substrate-based chain. You'll start from the Inkathon boilerplate, remove the default Flipper contract, and integrate a new ERC20 contract.

ink!athon is a starter kit for full-stack dApp development with ink! smart contracts and a React-based frontend in one place. Under the hood, it leverages the power of the PAPI ink-sdk, ReactiveDOT, and other developer tools to simplify contract interaction.

Prerequisites​

Before you begin, make sure you have:

  • Understand ink! and Rust at a basic level.
  • Set up your development environment with the Pop CLI: Guide
  • Installed Node.js.
  • Bun package manage

Getting Started​

1. Setup the Inkathon Boilerplate​

# https://docs.inkathon.xyz/#create-your-project
npx create-inkathon-app@latest
cd <project>

# Start the project
bun run dev

2. Add the ERC20 Smart Contract​

The project is divided into two main folders:

  • contracts: where the smart contracts and deployment scripts live.
  • frontend: the React-based UI to interact with the contracts.

To scaffold the ERC20 contract, run:

cd contracts/src
pop new contract erc20 -c erc -t erc20

Then, in contracts/Cargo.toml, update the members list:

members = ["src/flipper", "src/erc20"]

Now build and generate contract metadata:

# Executed from the /contracts directory
bun run build

# Executed from the /contracts directory
bun run codegen

3. Deploy the ERC20 Contract on Passet Hub​

note

If you're using an already deployed contract, you can skip this section.

We’ll deploy the contract to Passet Hub

# Executed from the /contracts directory
# If `CHAIN` is not set, it will default to `dev`
CHAIN=passethub bun run deploy

By default, the //Alice account is used. If you want to use another account, put your signers ACCOUNT_URI in .env.<chain> (e.g. .env.passethub).

info

Use the Passet Hub Faucet to fund your account with test tokens.

Successful deployment will look like:

$ bun run scripts/deploy.ts

✔ Initialized chain 'passethub' with account '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'

⠇ Deploying contract…WS halt (3)
✔ 📜 Deployed contract 'erc20' at address '5EnYcjJg88Ccg5Fco4L5zFR3r9QtFKknqbW9uubqbNXEgkbr' (0x7861ab0f2b73aceb7fbf661585caeff7dbad7140)

✔ Exported deployment info to file 'deployments/erc20/passethub.ts'
info

To deploy on another chain:

bunx papi add -w <websocket-url> <chain-name>
CHAIN=<chain-name> bun run deploy

4. Interact with the ERC20 Contract in the Frontend​

In frontend/src/lib/inkathon/deployments.ts, replace the Flipper contract with ERC20:

import {
evmAddress as evmAddressPassethub,
ss58Address as ss58AddressPassethub,
} from "@inkathon/contracts/deployments/erc20/passethub"

import { contracts } from "@polkadot-api/descriptors"


export const erc20 = {
contract: contracts.erc20,
evmAddresses: {
passethub: evmAddressPassethub,
},
ss58Addresses: {
passethub: ss58AddressPassethub,
},
}

Update frontend/src/components/web3/contract-card.tsx to replace Flipper logic with ERC20 interaction logic.

4.1. Querying the Contract​

To read values like total_supply or an account’s balance, use storage queries or message calls:

4.1.1. Get Total Supply (via storage)​

const [erc20TotalSupply, setErc20TotalSupply] = useState<FixedSizeArray<4, bigint>>()
// Create SDK & contract instance
const sdk = createReviveSdk(api as ReviveSdkTypedApi, erc20.contract)
const contract = sdk.getContract(erc20.evmAddresses[chain])

// Query storage directly
const storageResult = await contract.getStorage().getRoot()
const total_supply = storageResult.success ? storageResult.value.total_supply : undefined
setErc20TotalSupply(total_supply)

4.1.2. Get Balance (via balance_of message):​

const { signer, signerAddress } = useSignerAndAddress()
const [accountErc20MyBalance, setAccountErc20MyBalance] = useState<FixedSizeArray<4, bigint>>()
// Create SDK & contract instance
const sdk = createReviveSdk(api as ReviveSdkTypedApi, erc20.contract)
const contract = sdk.getContract(erc20.evmAddresses[chain])

// NOTE: Unfortunately, as `origin` is mandatory, every passed accounts needs to be mapped in an extra transaction first
// before it can be used for querying.
if (!api || !chain || !signer) return
const isMapped = await sdk.addressIsMapped(signerAddress)
if (!isMapped) {
toast.error("Account not mapped. Please map your account first.")
return
}
// Query my balance
const resultQueryMyBalance = await contract.query("balance_of", { origin: signerAddress , data: {
owner: ss58ToEthereum(signerAddress)
}});
const mybalance = resultQueryMyBalance.success ? resultQueryMyBalance.value.response : undefined
setAccountErc20MyBalance(mybalance)

4.2. Sending Transactions (Transfer)​

To transfer ERC20 tokens:

// Check if account is mapped
const isMapped = await sdk.addressIsMapped(signerAddress)
if (!isMapped) {
toast.error("Account not mapped. Please map your account first.")
return
}

// Send transfer transaction
const tx = contract
.send("transfer", {
origin: signerAddress,
data: {
to: Binary.fromHex(inputAddress),
value: [1n, 0n, 0n, 0n] // Transfer 1 token
}
})
.signAndSubmit(signer)
.then((tx) => {
queryContract() // Refresh data after transfer
if (!tx.ok) throw new Error("Failed to send transaction", { cause: tx.dispatchError })
})

toast.promise(tx, {
loading: "Transferring token...",
success: "Token transferred successfully",
error: "Failed to transfer token",
})

You’ve successfully replaced the default Flipper contract in Inkathon with an ERC20 ink! contract, deployed it to a testnet, and built a frontend interface using Inkathon, PAPI ink-sdk, and ReactiveDOT.

If you'd like to explore or customize the full React component that interacts with the ERC20 contract, here's the complete implementation of frontend/src/components/web3/contract-card.tsx:

import { createReviveSdk, ss58ToEthereum, type ReviveSdkTypedApi } from "@polkadot-api/sdk-ink"
import { useChainId, useTypedApi } from "@reactive-dot/react"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { useSignerAndAddress } from "@/hooks/use-signer-and-address"
import { erc20 } from "@/lib/inkathon/deployments"
import { CardSkeleton } from "../layout/skeletons"
import { Button } from "../ui/button-extended"
import { Card, CardHeader, CardTitle } from "../ui/card"
import { Table, TableBody, TableCell, TableRow } from "../ui/table"
import { Binary, FixedSizeArray } from "polkadot-api"

export function ContractCard() {
// State
const [queryIsLoading, setQueryIsLoading] = useState(true)
const [erc20TotalSupply, setErc20TotalSupply] = useState<FixedSizeArray<4, bigint>>()
const [accountErc20MyBalance, setAccountErc20MyBalance] = useState<FixedSizeArray<4, bigint>>()
const [accountErc20InputBalance, setAccountErc20InputBalance] = useState<FixedSizeArray<4, bigint>>()
const [inputAddress, setInputAddress] = useState<string>("0x41dccbd49b26c50d34355ed86ff0fa9e489d1e01") // BOB by default

// Hooks
const api = useTypedApi()
const chain = useChainId()
const { signer, signerAddress } = useSignerAndAddress()

/**
* Query contract data (total supply, my balance, and input address balance)
*/
const queryContract = useCallback(async () => {
setQueryIsLoading(true)
try {
if (!api || !chain) return

// Create SDK & contract instance
const sdk = createReviveSdk(api as ReviveSdkTypedApi, erc20.contract)
const contract = sdk.getContract(erc20.evmAddresses[chain])

// Query total supply from storage
const storageResult = await contract.getStorage().getRoot()
const total_supply = storageResult.success ? storageResult.value.total_supply : undefined
setErc20TotalSupply(total_supply)

// Check if account is mapped before querying balances
if (!api || !chain || !signer) return
const isMapped = await sdk.addressIsMapped(signerAddress)
if (!isMapped) {
toast.error("Account not mapped. Please map your account first.")
return
}

// Query my balance
const resultQueryMyBalance = await contract.query("balance_of", {
origin: signerAddress,
data: {
owner: ss58ToEthereum(signerAddress)
}
})
const mybalance = resultQueryMyBalance.success ? resultQueryMyBalance.value.response : undefined
setAccountErc20MyBalance(mybalance)

// Query input address balance
const resultQueryInputAddressBalance = await contract.query("balance_of", {
origin: signerAddress,
data: {
owner: Binary.fromHex(inputAddress)
}
})
const balance = resultQueryInputAddressBalance.success ? resultQueryInputAddressBalance.value.response : undefined
setAccountErc20InputBalance(balance)
} catch (error) {
console.error(error)
} finally {
setQueryIsLoading(false)
}
}, [api, chain, inputAddress, signer, signerAddress])

useEffect(() => {
queryContract()
}, [queryContract])

/**
* Transfer 1 ERC20 token to the input address
*/
const transfer = useCallback(async () => {
if (!api || !chain || !signer || !inputAddress) return

const sdk = createReviveSdk(api as ReviveSdkTypedApi, erc20.contract)
const contract = sdk.getContract(erc20.evmAddresses[chain])

// Check if account is mapped
const isMapped = await sdk.addressIsMapped(signerAddress)
if (!isMapped) {
toast.error("Account not mapped. Please map your account first.")
return
}

// Send transfer transaction
const tx = contract
.send("transfer", {
origin: signerAddress,
data: {
to: Binary.fromHex(inputAddress),
value: [1n, 0n, 0n, 0n] // Transfer 1 token
}
})
.signAndSubmit(signer)
.then((tx) => {
queryContract() // Refresh data after transfer
if (!tx.ok) throw new Error("Failed to send transaction", { cause: tx.dispatchError })
})

toast.promise(tx, {
loading: "Transferring token...",
success: "Token transferred successfully",
error: "Failed to transfer token",
})
}, [signer, api, chain, inputAddress, queryContract])

if (queryIsLoading) return <CardSkeleton />

return (
<Card className="inkathon-card">
<CardHeader>
<CardTitle>ERC20 Contract</CardTitle>
</CardHeader>

<Table className="inkathon-card-table">
<TableBody>
<TableRow>
<TableCell>Total Supply</TableCell>
<TableCell>{erc20TotalSupply}</TableCell>
</TableRow>
<TableRow>
<TableCell>My Balance</TableCell>
<TableCell>{accountErc20MyBalance}</TableCell>
</TableRow>
{inputAddress && (
<TableRow>
<TableCell>Balance of {inputAddress}</TableCell>
<TableCell>{accountErc20InputBalance}</TableCell>
</TableRow>
)}
<TableRow>
<TableCell>Contract Address</TableCell>
<TableCell>{erc20.evmAddresses[chain]}</TableCell>
</TableRow>
</TableBody>
</Table>

<div className="p-4 border-t border-gray-200">
<div className="flex gap-3 items-center">
<input
type="text"
placeholder="Enter address to transfer 1 ERC20 token"
value={inputAddress}
onChange={(e) => setInputAddress(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
setInputAddress(inputAddress)
}
}}
className="flex-1 px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-base"
/>
<Button
onClick={transfer}
size="sm"
variant="default"
>
Transfer
</Button>
</div>
</div>
</Card>
)
}

Resources​