Build an ERC20 custom paymaster

Build a paymaster that accepts any ERC20 token in exchange for transaction fees.

This tutorial will guide you through the process of building a paymaster that accepts a fixed amount of any ERC20 token in exchange for transaction fees. We'll use it to interact with a smart contract and pay the fees with an ERC20 token.

What you'll learn:

  • How to build a paymaster
  • How to send transactions through a paymaster
  • How to pay transaction fees with an ERC20 token
GitHub

Tools:

  • - zksync-cli
  • - zksync-ethers
  • - Hardhat
account abstractionpaymastersmart contractserc20
Last Updated: Feb 05, 2025

This tutorial shows you how to build a custom paymaster that allows users to pay fees with any ERC20 token.

You will:

  • Create a paymaster that assumes a single unit of an ERC20 token is enough to cover any transaction fee.
  • Create the ERC20 token contract and send some tokens to a new wallet.
  • Send a mint transaction from the newly created wallet via the paymaster. Although the transaction normally requires ETH to pay the gas fee, our paymaster executes the transaction in exchange for 1 unit of the ERC20 token.

Prerequisites

Complete Project

The tutorial code is available on GitHub.

This entire tutorial can be run in under a minute using Atlas. Atlas is a smart contract IDE that lets you write, deploy, and interact with contracts from your browser. Open this project in Atlas

Setup the Project

First, initialize a new project by running the command:

npx zksync-cli create erc20-paymaster --template hardhat_solidity

This creates a new ZKsync Era project called erc20-paymaster with a basic Greeter contract.

Next, navigate into the project directory and install the dependencies:

cd erc20-paymaster && npm install
The template project includes multiple example contracts and scripts. Feel free to delete them.

Design

Paymaster Solidity Contract

The contract code defines an ERC20 token and allows it to be used to pay the fees for transactions.

The skeleton contract looks like this:

MyPaymaster.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import '@openzeppelin/contracts/token/ERC20/IERC20.sol';

import { IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC } from '@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol';
import { IPaymasterFlow } from '@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol';
import { TransactionHelper, Transaction } from '@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol';

import '@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol';

contract MyPaymaster is IPaymaster {
  uint256 constant PRICE_FOR_PAYING_FEES = 1;

  address public allowedToken;

  modifier onlyBootloader() {
    require(msg.sender == BOOTLOADER_FORMAL_ADDRESS, 'Only bootloader can call this method');
    // Continue execution if called from the bootloader.
    _;
  }

  constructor(address _erc20) {
    allowedToken = _erc20;
  }

  function validateAndPayForPaymasterTransaction(
    bytes32,
    bytes32,
    Transaction calldata _transaction
  ) external payable onlyBootloader returns (bytes4 magic, bytes memory context) {
    // TO BE IMPLEMENTED
  }

  function postTransaction(
    bytes calldata _context,
    Transaction calldata _transaction,
    bytes32,
    bytes32,
    ExecutionResult _txResult,
    uint256 _maxRefundedGas
  ) external payable override onlyBootloader {}

  receive() external payable {}
}
Only the bootloader is allowed to call the validateAndPayForPaymasterTransaction and postTransaction functions. To implement that, the onlyBootloader modifier is used on these functions.

Parsing the Paymaster Input

The paymaster pays the transaction fees and charges the user one unit of the allowedToken in exchange.

The input that the paymaster receives is encoded in the paymasterInput within the validateAndPayForPaymasterTransaction function.

As described in the paymaster documentation, there are standardized ways to encode user interactions with paymasterInput. To charge the user, we require that they have provided enough allowance of the ERC20 token to the paymaster contract. This allowance is done in the approvalBased flow behind the scenes.

Firstly, we check that the paymasterInput is encoded as in the approvalBased flow, and that the token sent in paymasterInput is the one the paymaster accepts.

MyPaymaster.sol
magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;

require(
    _transaction.paymasterInput.length >= 4,
    "The standard paymaster input must be at least 4 bytes long"
);

bytes4 paymasterInputSelector = bytes4(_transaction.paymasterInput[0:4]);
if (paymasterInputSelector == IPaymasterFlow.approvalBased.selector)  {
    (address token, uint256 minAllowance, bytes memory data) = abi.decode(_transaction.paymasterInput[4:], (address, uint256, bytes));

    // We verify that the user has provided enough allowance
    require(token == allowedToken, "Invalid token");

    //
    // ...
    //
} else {
    revert("Unsupported paymaster flow");
}

Next, we check the user provided enough allowance:

MyPaymaster.sol
      // We verify that the user has provided enough allowance
      address userAddress = address(uint160(_transaction.from));

      address thisAddress = address(this);

      uint256 providedAllowance = IERC20(token).allowance(userAddress, thisAddress);
      require(providedAllowance >= PRICE_FOR_PAYING_FEES, 'Min allowance too low');

Finally, we check the price of transaction fees, transfer the ERC20 tokens to the paymaster, and transfer the correspondent gas fee from the paymaster to the bootloader to cover the transaction fees.

MyPaymaster.sol
      // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit,
      // neither paymaster nor account are allowed to access this context variable.
      uint256 requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas;

      try IERC20(token).transferFrom(userAddress, thisAddress, amount) {} catch (bytes memory revertReason) {
        // If the revert reason is empty or represented by just a function selector,
        // we replace the error with a more user-friendly message
        if (revertReason.length <= 4) {
          revert("Failed to transferFrom from users' account");
        } else {
          assembly {
            revert(add(0x20, revertReason), mload(revertReason))
          }
        }
      }

      // The bootloader never returns any data, so it can safely be ignored here.
      (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{ value: requiredETH }('');
      require(success, 'Failed to transfer tx fee to the bootloader. Paymaster balance might not be enough.');
The validation steps ensure that the paymaster won't throttle if the first storage read which has a different value from the execution on the API is a storage slot that belongs to the user.
This is why it is important to verify transaction prerequisites before performing any logic and why we first check that the user provided enough allowance before calling transferFrom.

Paymaster Contract Full Code

Create the contracts/MyPaymaster.sol file with

touch contracts/MyPaymaster.sol

and copy/paste the following:

MyPaymaster.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import '@openzeppelin/contracts/token/ERC20/IERC20.sol';

import { IPaymaster, ExecutionResult, PAYMASTER_VALIDATION_SUCCESS_MAGIC } from '@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymaster.sol';
import { IPaymasterFlow } from '@matterlabs/zksync-contracts/l2/system-contracts/interfaces/IPaymasterFlow.sol';
import { TransactionHelper, Transaction } from '@matterlabs/zksync-contracts/l2/system-contracts/libraries/TransactionHelper.sol';

import '@matterlabs/zksync-contracts/l2/system-contracts/Constants.sol';

contract MyPaymaster is IPaymaster {
  uint256 constant PRICE_FOR_PAYING_FEES = 1;

  address public allowedToken;

  modifier onlyBootloader() {
    require(msg.sender == BOOTLOADER_FORMAL_ADDRESS, 'Only bootloader can call this method');
    // Continue execution if called from the bootloader.
    _;
  }

  constructor(address _erc20) {
    allowedToken = _erc20;
  }

  function validateAndPayForPaymasterTransaction(
    bytes32,
    bytes32,
    Transaction calldata _transaction
  ) external payable onlyBootloader returns (bytes4 magic, bytes memory context) {
    // By default we consider the transaction as accepted.
    magic = PAYMASTER_VALIDATION_SUCCESS_MAGIC;
    require(_transaction.paymasterInput.length >= 4, 'The standard paymaster input must be at least 4 bytes long');

    bytes4 paymasterInputSelector = bytes4(_transaction.paymasterInput[0:4]);
    if (paymasterInputSelector == IPaymasterFlow.approvalBased.selector) {
      // While the transaction data consists of address, uint256 and bytes data,
      // the data is not needed for this paymaster
      (address token, uint256 amount, bytes memory data) = abi.decode(
        _transaction.paymasterInput[4:],
        (address, uint256, bytes)
      );

      // Verify if token is the correct one
      require(token == allowedToken, 'Invalid token');

      // We verify that the user has provided enough allowance
      address userAddress = address(uint160(_transaction.from));

      address thisAddress = address(this);

      uint256 providedAllowance = IERC20(token).allowance(userAddress, thisAddress);
      require(providedAllowance >= PRICE_FOR_PAYING_FEES, 'Min allowance too low');

      // Note, that while the minimal amount of ETH needed is tx.gasPrice * tx.gasLimit,
      // neither paymaster nor account are allowed to access this context variable.
      uint256 requiredETH = _transaction.gasLimit * _transaction.maxFeePerGas;

      try IERC20(token).transferFrom(userAddress, thisAddress, amount) {} catch (bytes memory revertReason) {
        // If the revert reason is empty or represented by just a function selector,
        // we replace the error with a more user-friendly message
        if (revertReason.length <= 4) {
          revert("Failed to transferFrom from users' account");
        } else {
          assembly {
            revert(add(0x20, revertReason), mload(revertReason))
          }
        }
      }

      // The bootloader never returns any data, so it can safely be ignored here.
      (bool success, ) = payable(BOOTLOADER_FORMAL_ADDRESS).call{ value: requiredETH }('');
      require(success, 'Failed to transfer tx fee to the bootloader. Paymaster balance might not be enough.');
    } else {
      revert('Unsupported paymaster flow');
    }
  }

  function postTransaction(
    bytes calldata _context,
    Transaction calldata _transaction,
    bytes32,
    bytes32,
    ExecutionResult _txResult,
    uint256 _maxRefundedGas
  ) external payable override onlyBootloader {}

  receive() external payable {}
}

Create ERC20 Contract

For the sake of simplicity we will use a modified OpenZeppelin ERC20 implementation.

touch contracts/MyERC20.sol

Create the contracts/MyERC20.sol file and copy/paste the following:

contracts/MyERC20.sol
// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.0;

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

contract MyERC20 is ERC20 {
  uint8 private _decimals;

  constructor(string memory name_, string memory symbol_, uint8 decimals_) ERC20(name_, symbol_) {
    _decimals = decimals_;
  }

  function mint(address _to, uint256 _amount) public returns (bool) {
    _mint(_to, _amount);
    return true;
  }

  function decimals() public view override returns (uint8) {
    return _decimals;
  }
}

Compile and Deploy the Contracts

The script below deploys the ERC20 contract and the paymaster contract. It also mints some MyERC20 tokens into the account we use to deploy the contracts to use them with the paymaster at a later step. In addition, the script sends 0.06ETH to the paymaster contract so it can pay the transaction fees we send later on.

  1. In the scripts folder, create the file deploy-paymaster.ts and copy/paste the following.
    touch scripts/deploy-paymaster.ts
    

    Make sure the private key of the account used to deploy the contracts is configured in the .env file of the project.
    scripts/deploy-paymaster.ts
    import { ethers, network } from 'hardhat';
    
    async function main() {
      // Deploy ERC20 and Paymaster contracts
      const ERC20_CONTRACT_NAME = 'MyERC20';
      const ERC20_ARGS = ['MyToken', 'MyToken', 18];
      const erc20Contract = await deployContract(ERC20_CONTRACT_NAME, ERC20_ARGS);
    
      const PAYMASTER_CONTRACT_NAME = 'MyPaymaster';
      const PAYMASTER_ARGS = [erc20Contract.address];
      const paymasterContract = await deployContract(PAYMASTER_CONTRACT_NAME, PAYMASTER_ARGS);
    
      // Supplying paymaster with ETH
      console.log('Funding paymaster with ETH...');
      const [signer] = await ethers.getSigners();
      await (
        await signer.sendTransaction({
          to: paymasterContract.address,
          value: ethers.parseEther('0.06'),
        })
      ).wait();
    
      // Check the balance of the paymaster
      const paymasterBalance = await ethers.provider.getBalance(paymasterContract.address);
      console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`);
    
      // Supplying the ERC20 tokens to the wallet:
      // We will give the wallet 3 units of the token:
      await (await erc20Contract.contract.mint(signer.address, 3)).wait();
    
      console.log('Minted 3 tokens for the wallet');
      console.log(`Done!`);
    }
    
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    async function deployContract(contractName: string, args: any[]) {
      console.log(`Deploying ${contractName} contract to ${network.name}`);
      const contract = await ethers.deployContract(contractName, args, {});
      await contract.waitForDeployment();
      const address = await contract.getAddress();
      console.log(`${contractName} deployed to ${address}`);
      return { contract, address };
    }
    
    main()
      .then(() => process.exit(0))
      .catch((error) => {
        console.error(error);
        process.exit(1);
      });
    
  2. Compile and the contracts from the project root:
    npm run compile
    
  3. Execute the deployment script:
    npx hardhat run ./scripts/deploy-paymaster.ts
    

    The output should be roughly the following:
    Deploying MyERC20 contract to ZKsyncEraSepolia
    MyERC20 deployed to 0x26b368C3Ed16313eBd6660b72d8e4439a697Cb0B
    Deploying MyPaymaster contract to dockerizedNode
    MyPaymaster deployed to 0x094499Df5ee555fFc33aF07862e43c90E6FEe501
    Funding paymaster with ETH...
    Paymaster ETH balance is now 60000000000000000
    Minted 3 tokens for the wallet
    Done!
    

Using the Paymaster

  1. Create the use-paymaster.ts script in the scripts folder, replacing the parameter placeholders with the details from the previous deploy step.
    touch scripts/use-paymaster.ts
    
    Make sure you use the private key of the wallet created by the previous script as that wallet contains the ERC20 tokens.
    scripts/use-paymaster.ts
    import { ethers } from 'hardhat';
    import { utils } from 'zksync-ethers';
    
    // Put the address of the deployed paymaster here
    const PAYMASTER_ADDRESS = process.env.PAYMASTER_ADDRESS ?? '<PAYMASTER_ADDRESS>';
    
    // Put the address of the ERC20 token here:
    const TOKEN_ADDRESS = process.env.TOKEN_ADDRESS ?? '<TOKEN_ADDRESS>';
    
    async function main() {
      const [signer] = await ethers.getSigners();
      console.log(`ERC20 token balance of the wallet before mint: ${await signer.getBalance(TOKEN_ADDRESS)}`);
      let paymasterBalance = await ethers.provider.getBalance(PAYMASTER_ADDRESS);
      console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`);
    
      const erc20 = await ethers.getContractAt('MyERC20', TOKEN_ADDRESS, signer);
      const gasPrice = await ethers.provider.getGasPrice();
    
      // Encoding the "ApprovalBased" paymaster flow's input
      const paymasterParams = utils.getPaymasterParams(PAYMASTER_ADDRESS, {
        type: 'ApprovalBased',
        token: TOKEN_ADDRESS,
        // set minimalAllowance as we defined in the paymaster contract
        minimalAllowance: BigInt('1'),
        // empty bytes as testnet paymaster does not use innerInput
        innerInput: new Uint8Array(),
      });
    
      // Estimate gas fee for mint transaction
      const gasLimit = await erc20.mint.estimateGas(signer.address, 5, {
        customData: {
          gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
          paymasterParams: paymasterParams,
        },
      });
    
      const fee = gasPrice * gasLimit;
      console.log('Transaction fee estimation is :>> ', fee.toString());
    
      console.log(`Minting 5 tokens for the wallet via paymaster...`);
      await (
        await erc20.mint(signer.address, 5, {
          // paymaster info
          customData: {
            paymasterParams: paymasterParams,
            gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
          },
        })
      ).wait();
    
      console.log(`Paymaster ERC20 token balance is now ${await erc20.balanceOf(PAYMASTER_ADDRESS)}`);
      paymasterBalance = await ethers.provider.getBalance(PAYMASTER_ADDRESS);
    
      console.log(`Paymaster ETH balance is now ${paymasterBalance.toString()}`);
      console.log(`ERC20 token balance of the wallet after mint: ${await signer.getBalance(TOKEN_ADDRESS)}`);
    }
    
    main()
      .then(() => process.exit(0))
      .catch((error) => {
        console.error(error);
        process.exit(1);
      });
    
  2. Run the script:
    npx hardhat run ./scripts/use-paymaster.ts
    

    The output should look something like this:
    ERC20 token balance of the wallet before mint: 3
    Paymaster ETH balance is 60000000000000000
    Transaction fee estimation is :>>  568750000000000
    Minting 5 tokens for the wallet via paymaster...
    Paymaster ERC20 token balance is now 1
    Paymaster ETH balance is now 59650952750000000
    ERC20 token balance of the wallet after mint: 7
    

The wallet had 3 tokens after running the deployment script and, after sending the transaction to mint 5 more tokens, the balance is 7 as 1 token was used to pay the transaction fee to the paymaster. The paymaster paid the fees for the mint transaction with ETH.

Common Errors

  • If the use-paymaster.ts script fails with the error:
    Failed to submit transaction: Failed to validate the transaction.
    Reason: Validation revert: Paymaster validation error: Failed to transfer tx fee to the bootloader. Paymaster balance might not be enough.
    

    please try sending additional ETH to the paymaster so it has enough funds to pay for the transaction. You can use ZKsync native bridge or ecosystem partners (make sure Sepolia testnet is supported by selected bridge).
  • If the use-paymaster.ts script fails when minting new ERC20 tokens with the error Error: transaction failed, and the transactions appear with status "Failed" in the ZKsync explorer, please reach out to us on our Discord. As a workaround, try including a specific gasLimit value in the transaction.

Learn More

  • To learn more about L1->L2 interaction on ZKsync, check out the documentation.
  • To learn more about the zksync-ethers SDK, check out its documentation.
  • To learn more about the ZKsync hardhat plugins, check out their documentation.

Made with ❤️ by the ZKsync Community