HomeBlockchainBlockchain DIYHow to Build a Gasless Token Transfer App Using Account Abstraction

How to Build a Gasless Token Transfer App Using Account Abstraction


Learn how gasless transactions work by building a simple smart-account wallet, relayer flow, and ERC-20 token transfer demo — then understand how the same idea scales to ERC-4337, paymasters, bundlers, and production-ready Web3 apps.

Gas fees are one of the biggest reasons normal users struggle with blockchain apps.

A new user may understand the purpose of a token, a game item, a DeFi position, or a loyalty reward, but the moment the app asks them to buy ETH just to move something, the experience begins to feel broken. In Web2, users do not need to understand server costs before sending an email, booking a cab, or subscribing to a product. In Web3, users are often forced to understand gas, native tokens, transaction signing, nonce management, and wallet funding before they can perform even a basic action.

Account abstraction changes that experience.

Instead of treating a wallet as a simple private-key-controlled account, account abstraction allows a wallet to behave like programmable infrastructure. A smart account can define custom rules for who may spend, what may be spent, how transactions are approved, whether gas is sponsored, whether limits exist, and whether recovery can happen without a seed phrase.

In this tutorial, we will build a beginner-friendly gasless token transfer system. The goal is not to ship a production wallet in one sitting. The goal is to understand the mechanics behind gasless transfers by creating a working educational demo.

By the end, you will understand:

  • why gasless transactions matter;
  • how smart accounts differ from normal wallets;
  • how a relayer can sponsor gas;
  • how a user can authorize a transfer without paying ETH;
  • how the smart account verifies the user’s signature;
  • how the relayer submits the transaction on-chain;
  • how this educational model maps to ERC-4337, bundlers, paymasters, and UserOperations;
  • what security risks students must understand before building production account-abstraction systems.

This article is part of Blockgeni’s Blockchain DIY series. If you are new to smart contracts, you may also want to read Blockgeni’s earlier guides on Ethereum, Solidity and Web3.js, how blockchain accounts and keys work, and why private-key security remains one of crypto’s biggest weaknesses.


1. What We Are Building

We will build a simple gasless token transfer app with three components:

  1. A test ERC-20 token
    This is the token users will transfer.
  2. A smart account contract
    This contract holds tokens for a user and can transfer them only when it receives a valid signature from that user.
  3. A relayer script
    The relayer pays gas on behalf of the user. The user signs a message off-chain. The relayer submits the transaction on-chain.

In a normal transfer, the user pays gas directly.

In our gasless flow:

  1. The user signs a transfer request off-chain.
  2. The signed request says: “Move 10 tokens from my smart account to this recipient.”
  3. The relayer receives the request.
  4. The relayer pays gas and submits the transaction.
  5. The smart account checks that the signature is valid.
  6. If the signature is valid, the smart account transfers the tokens.
  7. The user receives the benefit of an on-chain transfer without holding ETH.

This is not yet a full ERC-4337 implementation. It is an educational account-abstraction pattern that helps students understand what ERC-4337 later formalizes at a wider infrastructure level.

Ethereum’s ERC-4337 architecture introduces a standardized flow involving UserOperations, bundlers, an EntryPoint contract, smart accounts, and paymasters. In production, gas sponsorship is usually handled through paymasters rather than a simple custom relayer. But the learning principle is the same: separate the user’s intent from the account that pays gas.


2. Why Gasless Transactions Matter

Gasless transactions are not just a convenience feature. They change who can use blockchain apps.

A normal blockchain transaction assumes that every user:

  • already has a wallet;
  • already understands seed phrases;
  • already owns the network’s native token;
  • knows how to bridge or buy gas;
  • understands transaction approval;
  • is willing to pay fees before receiving value.

That is too much friction for mainstream adoption.

Gasless design allows a developer, protocol, game, marketplace, exchange, enterprise, or fintech app to pay the gas on behalf of users. This creates a more familiar onboarding experience.

Examples include:

  • a gaming app letting players move in-game assets without buying ETH;
  • a fintech app sponsoring the first stablecoin transfer;
  • a DAO letting members vote without maintaining gas balances;
  • a loyalty program issuing rewards as tokens without forcing users into crypto complexity;
  • a DeFi app letting users pay gas in USDC instead of ETH;
  • a social app letting users sign actions while the app handles settlement.

Blockgeni has covered how crypto is moving into an infrastructure era. Gasless account abstraction is part of that shift. It turns blockchain from a developer-first system into something closer to user-facing financial and application infrastructure.


3. Normal Wallets vs Smart Accounts

Ethereum historically has two major account types:

Externally Owned Account

An externally owned account, or EOA, is controlled by a private key. This is the type of account most users interact with through wallets like MetaMask.

An EOA can:

  • hold ETH;
  • hold tokens;
  • sign transactions;
  • call smart contracts.

But it has limited built-in logic. If the private key is compromised, the attacker can usually drain the account. If the user loses the seed phrase, recovery is difficult or impossible. If the account has no ETH, it cannot pay gas.

Smart Contract Account

A smart contract account is a contract that behaves like a wallet.

It can define rules such as:

  • only allow transfers below a daily limit;
  • require multiple signatures;
  • allow a guardian to help recover access;
  • allow session keys for games or apps;
  • whitelist approved contracts;
  • reject suspicious transactions;
  • let a third party sponsor gas;
  • allow fees to be paid in another token.

This is the core idea behind account abstraction: the account itself becomes programmable.


4. How ERC-4337 Fits In

ERC-4337 is the most widely discussed account-abstraction standard on Ethereum. It enables account abstraction without requiring Ethereum protocol-layer changes. Instead of users submitting normal transactions directly, users submit a data object called a UserOperation.

A simplified ERC-4337 flow looks like this:

  1. User creates a UserOperation.
  2. User signs the UserOperation.
  3. A bundler collects UserOperations.
  4. The bundler submits them to the EntryPoint contract.
  5. The EntryPoint asks the smart account to validate the operation.
  6. If a paymaster sponsors gas, the EntryPoint verifies the paymaster’s approval.
  7. The operation executes.
  8. The bundler gets paid.

Key ERC-4337 actors:

Component Role
Smart Account The programmable wallet controlled by custom validation logic
UserOperation The user’s signed intent
Bundler The service that submits UserOperations on-chain
EntryPoint The shared contract that validates and executes operations
Paymaster The sponsor that pays gas or allows gas to be paid in another token

In our educational project, the relayer plays a simplified version of the bundler/paymaster role. The smart account verifies the user’s signature and executes the token transfer.


5. What You Need Before Starting

You should have basic familiarity with:

  • JavaScript;
  • Solidity;
  • terminal commands;
  • MetaMask or any Ethereum wallet;
  • ERC-20 token transfers;
  • basic smart-contract deployment.

Install these tools:

  • Node.js 20 or later;
  • npm;
  • Git;
  • VS Code;
  • MetaMask;
  • a testnet RPC provider such as Alchemy, Infura, QuickNode, or a local Hardhat network.

For this tutorial, we will use:

  • Hardhat;
  • Solidity;
  • ethers.js;
  • OpenZeppelin contracts;
  • a local Hardhat network first;
  • optional Sepolia deployment later.

We will keep the first version local because students should learn the mechanics before worrying about real testnet RPC limits, API keys, faucets, and deployment friction.


6. Project Architecture

Create a project folder:

mkdir gasless-aa-token-transfer
cd gasless-aa-token-transfer
npm init -y

Install dependencies:

npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
npm install @openzeppelin/contracts ethers

Initialize Hardhat:

npx hardhat init

Choose a JavaScript project.

Your structure should look like this:

gasless-aa-token-transfer/
  contracts/
    DemoToken.sol
    GaslessSmartAccount.sol
  scripts/
    deploy.js
    gaslessTransfer.js
  test/
    gaslessTransfer.test.js
  hardhat.config.js
  package.json

7. Create the Demo ERC-20 Token

Create contracts/DemoToken.sol:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract DemoToken is ERC20 {
    constructor() ERC20("Blockgeni Demo Token", "BDT") {
        _mint(msg.sender, 1_000_000 * 10 ** decimals());
    }
}

This token is simple. It mints one million BDT tokens to the deployer. We will later transfer some of these tokens into the smart account so the smart account can send them gaslessly.


8. Create the Gasless Smart Account

Now create contracts/GaslessSmartAccount.sol.

This contract will:

  • store the owner address;
  • receive ERC-20 tokens;
  • verify a signed transfer request;
  • prevent replay attacks with nonces;
  • transfer tokens if the signature is valid.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract GaslessSmartAccount {
    using ECDSA for bytes32;

    address public owner;
    uint256 public nonce;

    event TokenTransferred(
        address indexed token,
        address indexed to,
        uint256 amount,
        uint256 nonce
    );

    constructor(address _owner) {
        require(_owner != address(0), "Invalid owner");
        owner = _owner;
    }

    function getMessageHash(
        address token,
        address to,
        uint256 amount,
        uint256 _nonce,
        uint256 chainId,
        address smartAccount
    ) public pure returns (bytes32) {
        return keccak256(
            abi.encodePacked(
                token,
                to,
                amount,
                _nonce,
                chainId,
                smartAccount
            )
        );
    }

    function executeTokenTransfer(
        address token,
        address to,
        uint256 amount,
        bytes calldata signature
    ) external {
        require(to != address(0), "Invalid recipient");
        require(amount > 0, "Invalid amount");

        bytes32 messageHash = getMessageHash(
            token,
            to,
            amount,
            nonce,
            block.chainid,
            address(this)
        );

        bytes32 ethSignedMessageHash = messageHash.toEthSignedMessageHash();
        address signer = ethSignedMessageHash.recover(signature);

        require(signer == owner, "Invalid signature");

        uint256 usedNonce = nonce;
        nonce++;

        bool success = IERC20(token).transfer(to, amount);
        require(success, "Token transfer failed");

        emit TokenTransferred(token, to, amount, usedNonce);
    }
}

What this contract does

The smart account does not allow anyone to move tokens freely.

Even though the relayer submits the transaction, the smart account checks whether the original owner signed the transfer request. If the signature is valid, the transfer happens. If not, the transaction fails.

This is the key account-abstraction idea: the sender of the blockchain transaction and the authority behind the user’s intent are no longer the same thing.

The relayer pays gas.
The user authorizes the action.
The smart account enforces the rule.


9. Understanding the Security Logic

Before deploying, understand why each field matters.

Why include token address?

The signature must approve a transfer for a specific token. Without this, the same signature might be reused for another token.

Why include recipient?

The user must approve exactly who receives the tokens.

Why include amount?

The relayer should not be able to change the amount.

Why include nonce?

The nonce prevents replay attacks. Without a nonce, the same signed message could be submitted again and again.

Why include chain ID?

The chain ID prevents a signature from one network being replayed on another network.

Why include smart account address?

The smart account address prevents the signature from being used against another contract.

This is one of the most important lessons in gasless app design: signing is not enough. You must sign the right data.


10. Deploy the Contracts

Create scripts/deploy.js:

const hre = require("hardhat");

async function main() {
  const [deployer, user] = await hre.ethers.getSigners();

  console.log("Deploying with:", deployer.address);
  console.log("Smart account owner:", user.address);

  const DemoToken = await hre.ethers.getContractFactory("DemoToken");
  const token = await DemoToken.deploy();
  await token.waitForDeployment();

  const tokenAddress = await token.getAddress();
  console.log("DemoToken deployed to:", tokenAddress);

  const GaslessSmartAccount = await hre.ethers.getContractFactory(
    "GaslessSmartAccount"
  );
  const smartAccount = await GaslessSmartAccount.deploy(user.address);
  await smartAccount.waitForDeployment();

  const smartAccountAddress = await smartAccount.getAddress();
  console.log("Smart account deployed to:", smartAccountAddress);

  const fundingAmount = hre.ethers.parseUnits("1000", 18);
  await token.transfer(smartAccountAddress, fundingAmount);

  console.log("Funded smart account with 1000 BDT");
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Run a local Hardhat node:

npx hardhat node

In another terminal, deploy:

npx hardhat run scripts/deploy.js --network localhost

You should see:

Deploying with: 0x...
Smart account owner: 0x...
DemoToken deployed to: 0x...
Smart account deployed to: 0x...
Funded smart account with 1000 BDT

At this point:

  • the deployer has created the token;
  • the user owns the smart account;
  • the smart account holds 1000 BDT;
  • the relayer has not done anything yet.

11. Build the Gasless Transfer Script

Create scripts/gaslessTransfer.js.

This script will simulate the gasless flow:

  1. user signs transfer request;
  2. relayer submits transaction;
  3. smart account verifies signature;
  4. tokens move to recipient;
  5. relayer pays gas.

Replace the contract addresses after deployment, or modify the deployment script to save them automatically.

const hre = require("hardhat");

async function main() {
  const [deployer, user, recipient, relayer] = await hre.ethers.getSigners();

  const tokenAddress = "PASTE_DEMO_TOKEN_ADDRESS";
  const smartAccountAddress = "PASTE_SMART_ACCOUNT_ADDRESS";

  const token = await hre.ethers.getContractAt("DemoToken", tokenAddress);
  const smartAccount = await hre.ethers.getContractAt(
    "GaslessSmartAccount",
    smartAccountAddress
  );

  const amount = hre.ethers.parseUnits("25", 18);
  const nonce = await smartAccount.nonce();
  const network = await hre.ethers.provider.getNetwork();
  const chainId = network.chainId;

  const messageHash = await smartAccount.getMessageHash(
    tokenAddress,
    recipient.address,
    amount,
    nonce,
    chainId,
    smartAccountAddress
  );

  const signature = await user.signMessage(hre.ethers.getBytes(messageHash));

  console.log("User signed transfer request.");
  console.log("Relayer will now submit transaction and pay gas.");

  const beforeRecipientBalance = await token.balanceOf(recipient.address);
  const beforeRelayerEth = await hre.ethers.provider.getBalance(relayer.address);

  const tx = await smartAccount
    .connect(relayer)
    .executeTokenTransfer(
      tokenAddress,
      recipient.address,
      amount,
      signature
    );

  const receipt = await tx.wait();

  const afterRecipientBalance = await token.balanceOf(recipient.address);
  const afterRelayerEth = await hre.ethers.provider.getBalance(relayer.address);

  console.log("Transaction hash:", receipt.hash);
  console.log(
    "Recipient token balance before:",
    hre.ethers.formatUnits(beforeRecipientBalance, 18)
  );
  console.log(
    "Recipient token balance after:",
    hre.ethers.formatUnits(afterRecipientBalance, 18)
  );
  console.log("Relayer paid gas.");
  console.log("Relayer ETH before:", hre.ethers.formatEther(beforeRelayerEth));
  console.log("Relayer ETH after:", hre.ethers.formatEther(afterRelayerEth));
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Run:

npx hardhat run scripts/gaslessTransfer.js --network localhost

Expected output:

User signed transfer request.
Relayer will now submit transaction and pay gas.
Transaction hash: 0x...
Recipient token balance before: 0.0
Recipient token balance after: 25.0
Relayer paid gas.
Relayer ETH before: ...
Relayer ETH after: ...

The user did not pay gas.
The recipient received tokens.
The relayer paid gas.
The smart account enforced the user’s signature.

That is the essence of a gasless token transfer.


12. Add a Test for the Full Flow

Create test/gaslessTransfer.test.js:

const { expect } = require("chai");
const hre = require("hardhat");

describe("GaslessSmartAccount", function () {
  it("allows a relayer to execute a user-approved token transfer", async function () {
    const [deployer, user, recipient, relayer] = await hre.ethers.getSigners();

    const DemoToken = await hre.ethers.getContractFactory("DemoToken");
    const token = await DemoToken.deploy();
    await token.waitForDeployment();

    const GaslessSmartAccount = await hre.ethers.getContractFactory(
      "GaslessSmartAccount"
    );
    const smartAccount = await GaslessSmartAccount.deploy(user.address);
    await smartAccount.waitForDeployment();

    const tokenAddress = await token.getAddress();
    const smartAccountAddress = await smartAccount.getAddress();

    await token.transfer(
      smartAccountAddress,
      hre.ethers.parseUnits("1000", 18)
    );

    const amount = hre.ethers.parseUnits("100", 18);
    const nonce = await smartAccount.nonce();
    const network = await hre.ethers.provider.getNetwork();

    const messageHash = await smartAccount.getMessageHash(
      tokenAddress,
      recipient.address,
      amount,
      nonce,
      network.chainId,
      smartAccountAddress
    );

    const signature = await user.signMessage(hre.ethers.getBytes(messageHash));

    await smartAccount
      .connect(relayer)
      .executeTokenTransfer(
        tokenAddress,
        recipient.address,
        amount,
        signature
      );

    expect(await token.balanceOf(recipient.address)).to.equal(amount);
    expect(await smartAccount.nonce()).to.equal(1);
  });

  it("rejects replayed signatures", async function () {
    const [deployer, user, recipient, relayer] = await hre.ethers.getSigners();

    const DemoToken = await hre.ethers.getContractFactory("DemoToken");
    const token = await DemoToken.deploy();
    await token.waitForDeployment();

    const GaslessSmartAccount = await hre.ethers.getContractFactory(
      "GaslessSmartAccount"
    );
    const smartAccount = await GaslessSmartAccount.deploy(user.address);
    await smartAccount.waitForDeployment();

    const tokenAddress = await token.getAddress();
    const smartAccountAddress = await smartAccount.getAddress();

    await token.transfer(
      smartAccountAddress,
      hre.ethers.parseUnits("1000", 18)
    );

    const amount = hre.ethers.parseUnits("50", 18);
    const nonce = await smartAccount.nonce();
    const network = await hre.ethers.provider.getNetwork();

    const messageHash = await smartAccount.getMessageHash(
      tokenAddress,
      recipient.address,
      amount,
      nonce,
      network.chainId,
      smartAccountAddress
    );

    const signature = await user.signMessage(hre.ethers.getBytes(messageHash));

    await smartAccount
      .connect(relayer)
      .executeTokenTransfer(
        tokenAddress,
        recipient.address,
        amount,
        signature
      );

    await expect(
      smartAccount
        .connect(relayer)
        .executeTokenTransfer(
          tokenAddress,
          recipient.address,
          amount,
          signature
        )
    ).to.be.revertedWith("Invalid signature");
  });

  it("rejects signatures from the wrong user", async function () {
    const [deployer, user, attacker, recipient, relayer] =
      await hre.ethers.getSigners();

    const DemoToken = await hre.ethers.getContractFactory("DemoToken");
    const token = await DemoToken.deploy();
    await token.waitForDeployment();

    const GaslessSmartAccount = await hre.ethers.getContractFactory(
      "GaslessSmartAccount"
    );
    const smartAccount = await GaslessSmartAccount.deploy(user.address);
    await smartAccount.waitForDeployment();

    const tokenAddress = await token.getAddress();
    const smartAccountAddress = await smartAccount.getAddress();

    await token.transfer(
      smartAccountAddress,
      hre.ethers.parseUnits("1000", 18)
    );

    const amount = hre.ethers.parseUnits("10", 18);
    const nonce = await smartAccount.nonce();
    const network = await hre.ethers.provider.getNetwork();

    const messageHash = await smartAccount.getMessageHash(
      tokenAddress,
      recipient.address,
      amount,
      nonce,
      network.chainId,
      smartAccountAddress
    );

    const badSignature = await attacker.signMessage(
      hre.ethers.getBytes(messageHash)
    );

    await expect(
      smartAccount
        .connect(relayer)
        .executeTokenTransfer(
          tokenAddress,
          recipient.address,
          amount,
          badSignature
        )
    ).to.be.revertedWith("Invalid signature");
  });
});

Run the test:

npx hardhat test

If everything is correct, all tests should pass.


13. What Students Should Notice

This demo teaches five important blockchain engineering ideas.

1. The user signs intent, not a transaction

The user does not broadcast a blockchain transaction. The user signs a message that says what they want to happen.

This distinction is critical. In account abstraction, the user’s intent can be packaged, validated, routed, and executed by infrastructure.

2. The relayer pays gas

The relayer submits the transaction. That means the relayer must hold ETH. The user does not need ETH.

In production, this role is often handled by bundlers and paymasters in ERC-4337.

3. The smart account enforces the rules

The smart account is not passive. It validates the signature, nonce, amount, recipient, token, chain, and account address.

This is why smart accounts are powerful: they can enforce custom security policies.

4. Replay protection is essential

The nonce prevents the same signed request from being reused.

Without nonce protection, a relayer or attacker could submit the same signature multiple times.

5. Gasless does not mean free

Someone always pays gas. Gasless means the end user does not pay directly. The app, protocol, sponsor, paymaster, merchant, DAO, or enterprise absorbs the fee.

This is similar to how Web2 apps hide infrastructure costs from users. Users do not pay AWS fees for every click; the application absorbs infrastructure cost as part of its business model.


14. How This Maps to ERC-4337

Our demo uses:

  • user signature;
  • smart account validation;
  • relayer submission;
  • sponsored gas.

ERC-4337 formalizes these ideas.

Educational demo ERC-4337 equivalent
Signed message UserOperation
Relayer Bundler
Smart account contract ERC-4337 smart account
Gas sponsor Paymaster
Contract validation validateUserOp
Direct contract call EntryPoint execution

A production ERC-4337 transfer would not usually call executeTokenTransfer directly through a custom relayer. Instead, the user would create a UserOperation. A bundler would submit it to the EntryPoint. A paymaster would agree to sponsor the gas. The smart account would validate the user’s intent and execute the token transfer.

This standardized architecture matters because it reduces reliance on a single custom relayer. It creates a shared infrastructure layer for smart accounts, bundlers, and paymasters across applications.


15. Why Paymasters Are Important

A paymaster is a contract or service that agrees to pay gas for a user operation.

A paymaster can apply conditions such as:

  • sponsor only first-time users;
  • sponsor only transfers below a limit;
  • sponsor only whitelisted contracts;
  • sponsor only KYC-approved wallets;
  • sponsor only transactions paid in USDC;
  • sponsor only users with a valid subscription;
  • sponsor only actions inside a game or app;
  • reject suspicious activity.

This makes gas sponsorship programmable.

For example, a learning platform might sponsor a student’s first five testnet actions. A stablecoin checkout app might sponsor gas if the user pays in USDC. A DeFi app might sponsor gas only for certain low-risk actions. A gaming app might sponsor gas during onboarding, then charge fees later through in-game economics.

This is why gasless apps are not just UX improvements. They are business-model infrastructure.


16. Production Architecture

A real production gasless app usually needs more than the simple contract above.

A production architecture may include:

  1. Frontend
    • user connects wallet;
    • app prepares transfer intent;
    • user signs typed data;
    • app sends signed intent to backend.
  2. Backend / Policy Engine
    • checks user eligibility;
    • checks abuse limits;
    • verifies CAPTCHA or identity rules;
    • estimates gas cost;
    • decides whether to sponsor.
  3. Smart Account
    • validates owner signature;
    • validates nonce;
    • executes transfer;
    • enforces limits.
  4. Bundler
    • receives UserOperations;
    • submits them to EntryPoint.
  5. Paymaster
    • sponsors gas;
    • enforces sponsorship policy;
    • manages deposit balance.
  6. Monitoring
    • tracks failed operations;
    • detects replay attempts;
    • detects abnormal transfer patterns;
    • alerts when paymaster balance is low.
  7. Security Controls
    • rate limits;
    • spending caps;
    • approved token lists;
    • approved recipient lists;
    • emergency pause;
    • audit logs;
    • separate hot and cold sponsorship funds.

This is where account abstraction becomes a serious engineering discipline rather than a simple UX feature.


17. Add EIP-712 Typed Data

Our simple version signs a raw message hash. That works for learning, but production apps should usually use EIP-712 typed structured data.

Why?

Because typed data helps users and wallets display what is being signed in a more readable way.

Instead of signing an opaque hash, the user can see fields such as:

  • token;
  • recipient;
  • amount;
  • nonce;
  • chain ID;
  • deadline.

A typed-data transfer request might look like this:

const domain = {
  name: "BlockgeniGaslessAccount",
  version: "1",
  chainId,
  verifyingContract: smartAccountAddress,
};

const types = {
  TokenTransfer: [
    { name: "token", type: "address" },
    { name: "to", type: "address" },
    { name: "amount", type: "uint256" },
    { name: "nonce", type: "uint256" },
    { name: "deadline", type: "uint256" },
  ],
};

const value = {
  token: tokenAddress,
  to: recipient.address,
  amount,
  nonce,
  deadline,
};

const signature = await user.signTypedData(domain, types, value);

If you continue this project, replacing the raw hash with EIP-712 is one of the best upgrades you can make.


18. Add a Deadline

A signed request should not remain valid forever.

Add a deadline field:

require(block.timestamp <= deadline, "Signature expired");

Then include the deadline in the signed message.

This prevents old signatures from being executed unexpectedly months later.

A good production transfer request should usually include:

  • token;
  • recipient;
  • amount;
  • nonce;
  • chain ID;
  • smart account address;
  • deadline;
  • optional maximum fee;
  • optional sponsor ID;
  • optional action type.

19. Add Spending Limits

A smart account can do something normal wallets cannot do easily: enforce spending rules.

For example:

uint256 public dailyLimit = 500 * 10 ** 18;
mapping(uint256 => uint256) public spentPerDay;

function currentDay() public view returns (uint256) {
    return block.timestamp / 1 days;
}

Before transfer:

uint256 day = currentDay();
require(spentPerDay[day] + amount <= dailyLimit, "Daily limit exceeded");
spentPerDay[day] += amount;

Now the smart account can reject excessive transfers even if the user signs them.

This is especially useful for:

  • consumer wallets;
  • payroll apps;
  • gaming wallets;
  • treasury wallets;
  • institutional DeFi apps;
  • student experiments in smart-wallet security.

20. Add a Whitelist

A simple whitelist can restrict where tokens may be sent:

mapping(address => bool) public approvedRecipients;

function setApprovedRecipient(address recipient, bool approved) external {
    require(msg.sender == owner, "Only owner");
    approvedRecipients[recipient] = approved;
}

Before transfer:

require(approvedRecipients[to], "Recipient not approved");

This is useful in controlled environments, but it creates trade-offs. A whitelist improves safety but reduces flexibility. A production wallet may use guardian approvals, session keys, risk scoring, or spending limits instead of a strict whitelist.


21. Add a Frontend Flow

A simple frontend would follow this logic:

  1. Connect wallet.
  2. Show smart-account token balance.
  3. User enters recipient and amount.
  4. App fetches current nonce from smart account.
  5. App creates typed-data payload.
  6. User signs payload.
  7. App sends signed payload to relayer API.
  8. Relayer checks policy.
  9. Relayer submits transaction.
  10. App shows transaction status.

Example frontend request to a relayer:

await fetch("/api/relay-transfer", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    smartAccount: smartAccountAddress,
    token: tokenAddress,
    to: recipientAddress,
    amount: amount.toString(),
    nonce: nonce.toString(),
    deadline,
    signature,
  }),
});

Example relayer response:

{
  "status": "submitted",
  "transactionHash": "0x..."
}

For a classroom assignment, students can build a basic React page that displays:

  • connected user wallet;
  • smart account address;
  • token balance;
  • transfer amount;
  • recipient field;
  • signed message;
  • transaction hash;
  • final recipient balance.

22. Add a Relayer API

A basic Express relayer might look like this:

npm install express dotenv

Create relayer/server.js:

require("dotenv").config();
const express = require("express");
const { ethers } = require("ethers");

const app = express();
app.use(express.json());

const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const relayerWallet = new ethers.Wallet(process.env.RELAYER_PRIVATE_KEY, provider);

const smartAccountAbi = [
  "function executeTokenTransfer(address token,address to,uint256 amount,bytes signature) external",
];

app.post("/relay-transfer", async (req, res) => {
  try {
    const { smartAccount, token, to, amount, signature } = req.body;

    if (!ethers.isAddress(smartAccount) || !ethers.isAddress(token) || !ethers.isAddress(to)) {
      return res.status(400).json({ error: "Invalid address" });
    }

    if (!amount || BigInt(amount) <= 0n) {
      return res.status(400).json({ error: "Invalid amount" });
    }

    const account = new ethers.Contract(
      smartAccount,
      smartAccountAbi,
      relayerWallet
    );

    const tx = await account.executeTokenTransfer(
      token,
      to,
      amount,
      signature
    );

    res.json({
      status: "submitted",
      transactionHash: tx.hash,
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({
      error: "Relay failed",
      details: error.message,
    });
  }
});

app.listen(3000, () => {
  console.log("Relayer running on port 3000");
});

Create .env:

RPC_URL=http://127.0.0.1:8545
RELAYER_PRIVATE_KEY=PASTE_LOCAL_RELAYER_PRIVATE_KEY

Never use a real mainnet private key in a classroom project. Use a local key or a dedicated testnet key only.


23. Abuse Controls for the Relayer

A relayer can lose money if it sponsors gas without controls.

Add rules such as:

  • maximum transfer amount per request;
  • maximum sponsored requests per wallet per hour;
  • maximum daily gas budget;
  • blocklist suspicious recipients;
  • allow only selected token contracts;
  • require valid nonce before submission;
  • reject repeated failed requests;
  • require CAPTCHA for public demos;
  • log IP, wallet, action type, and gas used;
  • maintain separate relayer wallets for testing and production.

A gasless app is an economic system. If you sponsor gas, attackers may try to drain your gas budget by submitting spam requests.

This is one reason production ERC-4337 paymaster services require careful policy design.


24. Deploying to Sepolia

Once the local version works, deploy to Sepolia.

Update hardhat.config.js:

require("@nomicfoundation/hardhat-toolbox");
require("dotenv").config();

module.exports = {
  solidity: "0.8.24",
  networks: {
    sepolia: {
      url: process.env.SEPOLIA_RPC_URL,
      accounts: [process.env.DEPLOYER_PRIVATE_KEY],
    },
  },
};

Update .env:

SEPOLIA_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
DEPLOYER_PRIVATE_KEY=YOUR_TESTNET_DEPLOYER_PRIVATE_KEY
RELAYER_PRIVATE_KEY=YOUR_TESTNET_RELAYER_PRIVATE_KEY

Deploy:

npx hardhat run scripts/deploy.js --network sepolia

Important testnet safety rules:

  • use a new testnet-only wallet;
  • never paste your main wallet seed phrase into code;
  • never commit .env files to GitHub;
  • add .env to .gitignore;
  • use small testnet amounts;
  • verify contract addresses before interacting;
  • check transaction details in the explorer.

25. Common Errors and Fixes

Error: Invalid signature

Possible causes:

  • wrong signer;
  • wrong nonce;
  • wrong smart account address;
  • wrong chain ID;
  • amount changed after signing;
  • recipient changed after signing;
  • contract computed a different hash from the frontend.

Fix:

  • print every field before signing;
  • print every field before submitting;
  • ensure the frontend and contract encode fields in the same order.

Error: Token transfer failed

Possible causes:

  • smart account has no tokens;
  • wrong token address;
  • token is not a standard ERC-20;
  • amount exceeds balance.

Fix:

console.log(await token.balanceOf(smartAccountAddress));

Error: Relayer has insufficient funds

The relayer pays gas.

Fix:

  • fund the relayer address with testnet ETH;
  • check relayer balance before submitting.

Error: Replay test does not fail

Possible causes:

  • nonce is not incrementing;
  • nonce is not included in signed message;
  • test is generating a new signature accidentally.

Fix:

  • ensure nonce++ happens after successful validation;
  • ensure nonce is part of the signed hash.

26. Security Warnings

Do not deploy this demo to mainnet as-is.

It is designed for education. A production wallet requires deeper review.

Major risks include:

Signature phishing

Users may sign something they do not understand. EIP-712 helps, but it does not eliminate phishing.

Relayer abuse

Attackers may spam relayer endpoints to drain gas funds.

Replay attacks

Nonces, chain IDs, deadlines, and verifying contract addresses must be handled correctly.

Upgradeability risk

If smart accounts are upgradeable, the upgrade mechanism must be secure.

Centralized relayer risk

If one relayer controls access, it can censor users or fail. ERC-4337 reduces this risk by creating a broader bundler ecosystem.

Paymaster policy risk

A weak paymaster can be abused. A too-strict paymaster can break UX.

EIP-7702 delegation risk

EIP-7702 can bring account-abstraction features to existing EOAs, but delegation approvals must be handled carefully. Users should only delegate to trusted contracts from trusted wallet providers.

Security is the difference between a clever demo and a responsible product.


27. Classroom Exercises

Students can extend this project in several ways.

Exercise 1: Add EIP-712 typed data

Replace raw hash signing with structured typed-data signatures.

Learning goal: understand safer signing UX.

Exercise 2: Add deadlines

Prevent old signatures from being valid forever.

Learning goal: reduce replay and stale-signature risk.

Exercise 3: Add daily spending limits

Allow the smart account to enforce user-level financial controls.

Learning goal: understand programmable wallet policy.

Exercise 4: Add approved recipients

Restrict transfers to a whitelist.

Learning goal: understand security/usability trade-offs.

Exercise 5: Add a frontend

Build a React interface that lets users create and sign transfer requests.

Learning goal: connect smart contracts to user experience.

Exercise 6: Add relayer rate limiting

Prevent users from submitting unlimited sponsored transactions.

Learning goal: understand gas sponsorship economics.

Exercise 7: Replace the relayer with ERC-4337 infrastructure

Use an ERC-4337 bundler and paymaster provider to submit UserOperations.

Learning goal: connect the educational demo to production account abstraction.


28. Production Upgrade Path

Once students understand the demo, the production path is:

  1. Replace custom smart account with audited ERC-4337-compatible smart account.
  2. Replace direct relayer calls with UserOperations.
  3. Use a bundler service or run your own bundler.
  4. Add a paymaster for gas sponsorship.
  5. Use EIP-712 typed data wherever possible.
  6. Add session keys for limited app-specific permissions.
  7. Add spending limits and recovery logic.
  8. Add monitoring for failed operations and gas spend.
  9. Audit contracts before mainnet deployment.
  10. Separate testnet, staging, and production wallets.

This is where account abstraction becomes a practical product framework rather than a tutorial concept.


29. Why This Matters for Web3 Adoption

Gasless token transfers solve a basic but important problem: users should not need to understand gas before they can understand the product.

For years, blockchain apps forced users to manage infrastructure-level complexity. That made sense for early adopters, but it does not work for mainstream users, students, gaming communities, fintech customers, or enterprise workflows.

Account abstraction lets developers design wallets around user intent.

A user wants to transfer a token.
A gamer wants to move an asset.
A shopper wants to pay with a stablecoin.
A DAO member wants to vote.
A student wants to test a contract.
A business wants to automate settlement.

None of those users should have to think first about native gas tokens.

This is why account abstraction is one of the most important UX upgrades in Ethereum’s roadmap. It does not make blockchain invisible, but it makes blockchain less hostile to normal users.


30. Final Takeaway

A gasless transaction is not magic. It is a carefully designed flow where:

  • the user signs an intent;
  • a smart account validates the intent;
  • a relayer or bundler submits the transaction;
  • a sponsor or paymaster pays gas;
  • the contract executes only if the rules are satisfied.

The educational project in this tutorial gives students a hands-on way to understand that flow.

The simple version uses a custom relayer.
The production version uses ERC-4337.
The future version may combine ERC-4337, EIP-7702, smart accounts, passkeys, paymasters, session keys, and app-specific policy engines.

The direction is clear: wallets are becoming programmable, gas is becoming abstracted, and blockchain apps are moving closer to Web2-style usability without giving up the core benefits of verifiable on-chain execution.

For students, this is a valuable topic to learn now because it sits at the intersection of smart contracts, wallet security, user experience, infrastructure economics, and blockchain adoption.

Gasless token transfers are not just a feature. They are a window into the next generation of Web3 product design.

Most Popular