Build an ERC20 dapp with Typink and ink! v6

In this tutorial, we'll guide you through building a complete ERC20 token dapp using Typink with ink! v6 on PolkaVM (pallet-revive). You'll learn how to create, deploy, and interact with an ERC20 token contract, including transfers and real-time event notifications.

What We'll Build:

  • ERC20 token contract using ink! v6

  • Token transfer interface with real-time balance updates

  • Transaction notifications with txToaster

  • Live Transfer event monitoring with toast notifications

Technologies:

  • Typink - React hooks for contract interactions

  • ink! v6 - Latest ink! on PolkaVM (pallet-revive)

  • POP CLI - Contract development tool

  • Next.js 15 - Frontend framework

Prerequisites

  • Node.js v20 or higher

  • pnpm (or npm/yarn/bun)

  • Rust and cargo-contract (for contract compilation)

Step 1: Create New Typink Project

Let's start by creating a new project using the Typink CLI:

pnpm create typink@latest

Follow the interactive prompts:

  1. Project name: erc20-dapp

  2. Contract type: Select Ink! v6 (PolkaVM, pallet-revive)

  3. Networks: Select Passet Hub

The CLI will:

  • Create the project structure

  • Install dependencies

  • Setup TypinkProvider with Passet Hub

  • Initialize git repository

Navigate to your project:

cd erc20-dapp

Step 2: Create ERC20 Contract with POP CLI

We'll use POP CLI to create our ERC20 contract. First, install POP CLI if you haven't:

cargo install --git https://github.com/r0gue-io/pop-cli

Create a new contract using POP CLI:

pop new contract

When prompted:

  • Template type: ERC

  • Select contract: erc20

  • Project name: erc20

This creates an erc20 folder with the ERC20 contract template.

Step 3: Compile the Contract

Navigate to the contract directory and build it:

cd erc20
pop build
# or
cargo contract build --release

After successful compilation, you'll find the artifacts in target/ink/:

  • erc20.contract - Bundle file (metadata + wasm)

  • erc20.json - Contract metadata

  • erc20.wasm - Contract bytecode

Copy these files to your project's contracts folder:

mkdir -p ../src/contracts/artifacts/erc20
cp target/ink/erc20.* ../src/contracts/artifacts/erc20/
cd ..

Step 4: Get Testnet Tokens

Before deploying, you'll need PAS testnet tokens on Passet Hub:

  1. Get PAS on Passet Hub:

Step 5: Deploy Contract via ui.use.ink

Now let's deploy the ERC20 contract:

  1. Connect Your Wallet:

    • Click "Connect Wallet"

    • Select SubWallet, Talisman, or PolkadotJS

  2. Select Network:

    • Choose "Paseo Asset Hub" from the network dropdown

  3. Upload Contract:

    • Click "Add New Contract"

    • Select "Upload New Contract Code"

    • Upload erc20.contract file

  4. Deploy Contract:

    • Constructor: new

    • total_supply: Enter 1000000000000000000000000 (1M tokens with 18 decimals)

    • Click "Deploy"

    • Sign the transaction

  5. Save Contract Address:

    • After successful deployment, copy the contract address

    • Example: 0x1234...5678

Step 6: Register Contract Deployment

Update src/contracts/deployments.ts to register your ERC20 contract:

import { ContractDeployment, passetHub } from 'typink';
import erc20Metadata from './artifacts/erc20/erc20.json';

export enum ContractId {
  ERC20 = 'erc20',
}

export const deployments: ContractDeployment[] = [
  {
    id: ContractId.ERC20,
    metadata: erc20Metadata,
    network: passetHub.id,
    address: '0x1234...5678', // Replace with your contract address
  },
];

Step 7: Generate TypeScript Bindings

The project includes a pre-configured typegen script. Run it to generate type-safe bindings:

pnpm typegen

This generates TypeScript types in src/contracts/types/erc20/ including the Erc20ContractApi interface.

Step 8: Display Token Information

Create a new component src/components/erc20-board.tsx:

'use client';

import { useState } from 'react';
import { formatBalance, useContract, useContractQuery, useTypink } from 'typink';
import { toEvmAddress } from 'dedot/contracts';
import { Erc20ContractApi } from '@/contracts/types/erc20';
import { ContractId } from '@/contracts/deployments';

export function ERC20Board() {
  const { connectedAccount } = useTypink();
  const { contract } = useContract<Erc20ContractApi>(ContractId.ERC20);

  // Fetch total supply
  const { data: totalSupply, isLoading: loadingSupply } = useContractQuery({
    contract,
    fn: 'totalSupply',
  });

  // Fetch user balance with real-time updates
  const { data: balance, isLoading: loadingBalance } = useContractQuery(
    connectedAccount?.address
      ? {
          contract,
          fn: 'balanceOf',
          args: [toEvmAddress(connectedAccount.address) as `0x${string}`],
          watch: true, // Auto-refresh on new blocks
        }
      : undefined,
  );

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-2xl font-bold">ERC20 Token</h2>
      </div>

      {/* Token Info */}
      <div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
        <div className="space-y-2">
          <div>
            <span className="text-sm text-gray-600 dark:text-gray-400">Total Supply:</span>
            <p className="text-lg font-semibold">
              {loadingSupply ? 'Loading...' : formatBalance(totalSupply, { decimals: 18, symbol: 'UNIT' })}
            </p>
          </div>

          {connectedAccount && (
            <div>
              <span className="text-sm text-gray-600 dark:text-gray-400">Your Balance:</span>
              <p className="text-lg font-semibold text-blue-600 dark:text-blue-400">
                {loadingBalance ? 'Loading...' : formatBalance(balance, { decimals: 18, symbol: 'UNIT' })}
              </p>
            </div>
          )}
        </div>
      </div>

      {!connectedAccount && (
        <p className="text-sm text-gray-600">Connect your wallet to view balance and transfer tokens.</p>
      )}
    </div>
  );
}

Update src/app/page.tsx to use the new component:

import { ERC20Board } from '@/components/erc20-board';

export default function Home() {
  return (
    <main className="container mx-auto p-4 max-w-4xl">
      <ERC20Board />
    </main>
  );
}

Start the development server:

pnpm dev

Visit http://localhost:3000 and connect your wallet to see your token balance!

Step 9: Create Transfer Form

Now let's add a transfer form. Update src/components/erc20-board.tsx:

'use client';

import { useState } from 'react';
import { formatBalance, useContract, useContractQuery, useContractTx, useTypink, txToaster } from 'typink';
import { toEvmAddress } from 'dedot/contracts';
import { Erc20ContractApi } from '@/contracts/types/erc20';
import { ContractId } from '@/contracts/deployments';

export function ERC20Board() {
  const { connectedAccount } = useTypink();
  const { contract } = useContract<Erc20ContractApi>(ContractId.ERC20);

  const [recipient, setRecipient] = useState('');
  const [amount, setAmount] = useState('');

  // ... (previous code for totalSupply and balance queries)

  // Transfer transaction
  const transferTx = useContractTx(contract, 'transfer');

  const handleTransfer = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!recipient || !amount || !connectedAccount) return;

    const toaster = txToaster('Transferring tokens...');

    try {
      // Validate recipient address
      if (recipient === toEvmAddress(connectedAccount.address)) {
        throw new Error('Cannot transfer to yourself');
      }

      // Convert amount to wei (18 decimals)
      const amountInWei = BigInt(Math.floor(parseFloat(amount) * 1e18));

      await transferTx.signAndSend({
        args: [recipient, amountInWei],
        callback: (result) => {
          toaster.onTxProgress(result);

          // Clear form on success (balance auto-refreshes with watch: true)
          if (result.status.type === 'BestChainBlockIncluded' && !result.dispatchError) {
            setRecipient('');
            setAmount('');
          }
        },
      });
    } catch (error: any) {
      console.error('Transfer failed:', error);
      toaster.onTxError(error);
    }
  };

  const isValidTransfer = recipient && amount && parseFloat(amount) > 0;

  return (
    <div className="space-y-6">
      {/* ... (previous token info display) */}

      {/* Transfer Form */}
      {connectedAccount && (
        <div className="border rounded-lg p-4">
          <h3 className="text-lg font-semibold mb-4">Transfer Tokens</h3>

          <form onSubmit={handleTransfer} className="space-y-4">
            <div>
              <label className="block text-sm font-medium mb-1">
                Recipient Address
              </label>
              <input
                type="text"
                value={recipient}
                onChange={(e) => setRecipient(e.target.value)}
                placeholder="0x..."
                className="w-full px-3 py-2 border rounded-md"
                required
              />
            </div>

            <div>
              <label className="block text-sm font-medium mb-1">
                Amount (UNIT)
              </label>
              <input
                type="number"
                value={amount}
                onChange={(e) => setAmount(e.target.value)}
                placeholder="0.0"
                step="0.0001"
                min="0"
                className="w-full px-3 py-2 border rounded-md"
                required
              />
            </div>

            <button
              type="submit"
              disabled={!isValidTransfer || transferTx.inBestBlockProgress}
              className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed">
              {transferTx.inBestBlockProgress ? 'Transferring...' : 'Transfer'}
            </button>
          </form>
        </div>
      )}
    </div>
  );
}

The form will look like below:

Step 10: Watch Transfer Events

Let's add real-time Transfer event monitoring with toast notifications. Update src/components/erc20-board.tsx:

'use client';

import { useCallback, useState } from 'react';
import { toast } from 'sonner';
import { formatBalance, useContract, useContractQuery, useContractTx, useTypink, useWatchContractEvent, txToaster } from 'typink';
import { toEvmAddress } from 'dedot/contracts';
import { Erc20ContractApi } from '@/contracts/types/erc20';
import { ContractId } from '@/contracts/deployments';
import { shortenAddress } from '@/lib/utils';

export function ERC20Board() {
  const { connectedAccount } = useTypink();
  const { contract } = useContract<Erc20ContractApi>(ContractId.ERC20);

  // ... (all previous code)

  // Watch Transfer events
  useWatchContractEvent(
    contract,
    'Transfer',
    useCallback((events) => {
      events.forEach((event) => {
        const { from, to, value } = event.data;

        // Check if current user is involved (case-insensitive EVM address comparison)
        const userEvmAddress = connectedAccount?.address ? toEvmAddress(connectedAccount.address).toLowerCase() : '';
        const isReceiver = to && userEvmAddress === to.toString().toLowerCase();
        const isSender = from && userEvmAddress === from.toString().toLowerCase();

        if (isReceiver || isSender) {
          const direction = isReceiver ? 'received' : 'sent';
          const otherParty = isReceiver ? from : to;

          toast.success('Transfer Event', {
            description: `You ${direction} ${formatBalance(value, { decimals: 18, symbol: 'UNIT' })} ${
              isReceiver ? 'from' : 'to'
            } ${shortenAddress(otherParty?.toString())}`,
            duration: 5000,
          });
        }
      });
    }, [connectedAccount?.address])
  );

  return (
    <div className="space-y-6">
      {/* ... (all previous JSX) */}

      {/* Recent Events */}
      <div className="text-xs text-gray-500 mt-4">
        <p>💡 Transfer events are monitored in real-time</p>
        <p>You'll see notifications when tokens are transferred to/from your account</p>
      </div>
    </div>
  );
}

The notification appears when the contract emits a Transfer event.

Final Result

Congratulations! You've built a complete ERC20 token dapp with:

  • Real-time Balance Display - Auto-updates on new blocks

  • Token Transfer Form - Send tokens to any address

  • Transaction Notifications - Real-time progress with txToaster

  • Event Monitoring - Live Transfer event notifications

  • Type-Safe Interactions - Full TypeScript support

Features Demonstrated

Contract Queries:

  • useContractQuery with watch: true for real-time updates

  • Auto-refresh balance after transactions

Contract Transactions:

  • useContractTx for sending transfers

  • txToaster for transaction progress tracking

  • Error handling and validation

Event Watching:

  • useWatchContractEvent for Transfer events

  • Filtered notifications for user-relevant events

  • Real-time balance refresh on events

Resources


Happy Building! 🎉

Last updated

Was this helpful?