Quickstart Guide to Develop on ZKSync : Explore Basic Paymaster (Chapter 3)

Ridho IzzulhaqRidho Izzulhaq
9 min read

In the previous chapter, we discussed the idea atlas and dApps on ZKsync. In this chapter, we will explore account abstraction and paymasters on ZKsync.

Note : in this chapter we use ATLAS IDE, for further guide about ATLAS IDE, let's check the previous chapter

Account Abstraction

Account abstraction refers to the concept of making Ethereum accounts more flexible by allowing them to function like smart contracts. This means accounts can have custom logic for handling transactions, security, and fee payment mechanisms, providing a higher level of programmability and security customization than the traditional model.

Account abstraction emerged in response to several limitations in how users interact with Ethereum through externally owned accounts (EOAs). In the traditional Ethereum model, only EOAs can initiate transactions or execute smart contracts. This restricts users' flexibility in interacting with Ethereum, such as making it difficult to batch transactions and requiring users to always maintain an ETH balance to cover gas fees.

One of the common applications of account abstraction is the use of paymasters. Paymasters are specialized smart contracts that manage the payment of transaction fees on behalf of users. They play a crucial role in making Ethereum more user-friendly and flexible by allowing users to interact with the blockchain without needing to hold ETH for gas fees. Here’s how they work and why they’re important:

  1. Fee Sponsorship: Paymasters can cover gas fees for users, enabling transactions to be executed without the sender needing ETH. This can be particularly useful for onboarding new users who may not yet have ETH but want to interact with decentralized applications (DApps).

  2. Custom Payment Methods: With paymasters, users can pay for gas fees using tokens other than ETH, such as ERC20 tokens. This broadens the accessibility and usability of Ethereum-based applications, as users can choose their preferred token for transaction fees.

Thanks to ZKSync for Native AA and Paymaster :
Thanks to ZKsync, native paymasters in their ecosystem bring a revolutionary way of managing transaction fees, making blockchain interactions smoother and more accessible.

Every paymaster has the following two functions:

  • validateAndPayForPaymasterTransaction : this function uses the transaction parameters (fields like from, amount , to ) to execute the required validations and pay for the transaction fee.

  • postTransaction: this optional function runs after the transaction is executed

zksync paymaster

Example of paymaster information directly connected to the blockchain explorer :

Let's Try to Develop Our First Paymester

Similarly to previous chapter, we will use the ATLAS IDE. In this stage, we will continue using the ATLAS IDE, specifically the ZKsync paymaster template provided by ZKsync. click for the template!

There are 2 contracts here:
TestToken.sol for creating a new token and ZeekMessages.sol, which is the contract used to test the Paymaster by allows users to send and store messages. It also provides functionalities to retrieve the total number of messages and the last message sent. Here is a detailed explanation of the contract's components and usage

import * as zk from "zksync-ethers";
import { default as hre, ethers } from "hardhat";
import { getDeployer } from "@atlas-labs-inc/hardhat-atlas-zksync-deploy";

// Address of the ZeekMessages contract
const ZEEK_MESSAGES_CONTRACT_ADDRESS = "0x26C6e7b1896c3807D6b8df9Bdd407755F9eB17dd";
// Address of the ERC20 token contract
const TOKEN_CONTRACT_ADDRESS = "0xfe92f30411F2871653Acbb16f613B3b2379e304F";
// Message to be sent to the contract
const NEW_MESSAGE = "This tx cost me no ETH!";

async function main() {
  // connects to the wallet connected in the network tab -->
  const { provider, signer } = await getDeployer(hre);

  const zkProvider = new zk.Provider("https://sepolia.era.zksync.dev");
  const signerAddress = await signer.getAddress();

  console.log("Sending a transaction via the testnet paymaster");

  // initialise messages and token contracts with address, abi and signer
  const messagesContract = new zk.Contract(
    ZEEK_MESSAGES_CONTRACT_ADDRESS,
    (await ethers.getContractFactory("ZeekMessages")).interface,
    signer
  );

  const tokenContract = new zk.Contract(
    TOKEN_CONTRACT_ADDRESS,
    (await ethers.getContractFactory("TestToken")).interface,
    signer
  );

  // retrieve and print the current balance of the wallet
  let ethBalance = await provider.getBalance(signerAddress);
  let tokenBalance = await tokenContract.balanceOf(signerAddress);
  console.log(
    `Account ${signerAddress} has ${ethers.formatEther(ethBalance)} ETH`,
  );
  console.log(
    `Account ${signerAddress} has ${ethers.formatUnits(
      tokenBalance,
      18,
    )} tokens`,
  );

  // retrieve the testnet paymaster address
  const testnetPaymasterAddress = await zkProvider.getTestnetPaymasterAddress();

  console.log(`Testnet paymaster address is ${testnetPaymasterAddress}`);

  const gasPrice = await zkProvider.getGasPrice();

  // define paymaster parameters for gas estimation
  const paramsForFeeEstimation = zk.utils.getPaymasterParams(
    testnetPaymasterAddress,
    {
      type: "ApprovalBased",
      token: TOKEN_CONTRACT_ADDRESS,
      // set minimalAllowance to 1 for estimation
      minimalAllowance: ethers.toBigInt(1),
      // empty bytes as testnet paymaster does not use innerInput
      innerInput: new Uint8Array(0),
    },
  );

  // estimate gasLimit via paymaster
  const gasLimit = await messagesContract.sendMessage.estimateGas(NEW_MESSAGE, {
    customData: {
      gasPerPubdata: zk.utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
      paymasterParams: paramsForFeeEstimation,
    },
  });

  // fee calculated in ETH will be the same in
  // ERC20 token using the testnet paymaster
  const fee = gasPrice * gasLimit;

  // new paymaster params with fee as minimalAllowance
  const paymasterParams = zk.utils.getPaymasterParams(testnetPaymasterAddress, {
    type: "ApprovalBased",
    token: TOKEN_CONTRACT_ADDRESS,
    // provide estimated fee as allowance
    minimalAllowance: fee,
    // empty bytes as testnet paymaster does not use innerInput
    innerInput: new Uint8Array(0),
  });

  // full overrides object including maxFeePerGas and maxPriorityFeePerGas
  const txOverrides = {
    maxFeePerGas: gasPrice,
    maxPriorityFeePerGas: "1",
    gasLimit,
    customData: {
      gasPerPubdata: zk.utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
      paymasterParams,
    },
  };

  console.log(`Sign the transaction in your wallet`);

  // send transaction with additional paymaster params as overrides
  const txHandle = await messagesContract.sendMessage(NEW_MESSAGE, txOverrides);
  console.log(
    `Transaction ${txHandle.hash} with fee ${ethers.formatUnits(
      fee,
      18,
    )} ERC20 tokens, sent via paymaster ${testnetPaymasterAddress}`,
  );
  await txHandle.wait();
  console.log(`Transaction processed`);

  ethBalance = await provider.getBalance(signerAddress);
  tokenBalance = await tokenContract.balanceOf(signerAddress);
  console.log(
    `Account ${signerAddress} now has ${ethers.formatEther(ethBalance)} ETH`,
  );
  console.log(
    `Account ${signerAddress} now has ${ethers.formatUnits(
      tokenBalance,
      18,
    )} tokens`,
  );

  console.log(`Done!`);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ZeekMessages {
    string[] private messages;

    // Event to acknowledge a new message
    event MessageReceived(string);

    constructor() {
        // Zeek initializes the contract with a welcome message
        emit MessageReceived("Zeek welcomes you to zkSync!");
    }

    function sendMessage(string memory _message) public {
        messages.push(_message);

        // Acknowledge the message receipt with Zeek's reply
        emit MessageReceived("ZK is the endgame - Message received!");
    }

    // Function to count the total messages sent to Zeek
    function getTotalMessages() public view returns (uint256) {
        return messages.length;
    }

    // Function to return the last message sent to Zeek
    function getLastMessage() public view returns (string memory) {
        require(messages.length > 0, "No messages sent to Zeek yet!");
        return messages[messages.length - 1];
    }
}

In short, you just need to deploy both of them!

Run the Typescript Paymaster

Configuration of the addresses and input of your first message at paymaster-transaction.ts

// Address of the ZeekMessages contract
const ZEEK_MESSAGES_CONTRACT_ADDRESS = "0x26C6e7b1896c3807D6b8df9Bdd407755F9eB17dd";
// Address of the ERC20 token contract
const TOKEN_CONTRACT_ADDRESS = "0xfe92f30411F2871653Acbb16f613B3b2379e304F";
// Message to be sent to the contract
const NEW_MESSAGE = "This tx cost me no ETH!";

And run your fist paymaster program

We can view the transaction hash programmatically on ZKsync's explorer and observe how the paymaster natively functions by charging transaction fees in the ERC-20 token we create and performing ETH refunds.

Build Paymaster for Voting Smart Contract (Chapter 2 Code)

In Chapter 2, we discussed a voting DAPP and deployed a smart contract related to it on ZKSync. Now, let's implement a paymaster to vote for candidate 1 on that contract.

Vote.sol :

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

contract Voting {
    struct Candidate {
        uint id;
        string name;
        uint voteCount;
    }

    mapping(uint => Candidate) public candidates;
    mapping(address => bool) public voters;
    uint public candidatesCount;


    constructor(string[] memory _candidateNames) {
        for (uint i = 0; i < _candidateNames.length; i++) {
            addCandidate(_candidateNames[i]);
        }
    }

    function addCandidate(string memory _name) private {
        candidatesCount++;
        candidates[candidatesCount] = Candidate(candidatesCount, _name, 0);
    }

    function vote(uint _candidateId) public {
        require(!voters[msg.sender], "You have already voted.");
        require(_candidateId > 0 && _candidateId <= candidatesCount, "Invalid candidate ID.");

        voters[msg.sender] = true;
        candidates[_candidateId].voteCount++;
    }

    function getVotes(uint _candidateId) public view returns (uint) {
        require(_candidateId > 0 && _candidateId <= candidatesCount, "Invalid candidate ID.");
        return candidates[_candidateId].voteCount;
    }
}

Note : For more smart contract explain, visit chapter 2

Build paymaster-vote.ts

import * as zk from "zksync-ethers";
import { default as hre, ethers } from "hardhat";
import { getDeployer } from "@atlas-labs-inc/hardhat-atlas-zksync-deploy";

// Address of the Voting contract
const VOTING_CONTRACT_ADDRESS = "";
// Address of the ERC20 token contract
const TOKEN_CONTRACT_ADDRESS = "";
// Candidate ID to vote for
const CANDIDATE_ID = 2;

async function main() {
  // Connects to the wallet connected in the network tab
  const { provider, signer } = await getDeployer(hre);

  const zkProvider = new zk.Provider("https://sepolia.era.zksync.dev");
  const signerAddress = await signer.getAddress();

  console.log("Sending a transaction via the testnet paymaster");

  // Initialize the Voting and token contracts with address, ABI, and signer
  const votingContract = new zk.Contract(
    VOTING_CONTRACT_ADDRESS,
    (await ethers.getContractFactory("Vote")).interface,
    signer
  );

  const tokenContract = new zk.Contract(
    TOKEN_CONTRACT_ADDRESS,
    (await ethers.getContractFactory("TestToken")).interface,
    signer
  );

  // Debug: Log contract addresses and signer address
  console.log(Voting Contract Address: ${VOTING_CONTRACT_ADDRESS});
  console.log(Token Contract Address: ${TOKEN_CONTRACT_ADDRESS});
  console.log(Signer Address: ${signerAddress});

  // Retrieve and print the current balance of the wallet
  try {
    let ethBalance = await provider.getBalance(signerAddress);
    let tokenBalance = await tokenContract.balanceOf(signerAddress);
    console.log(Account ${signerAddress} has ${ethers.formatEther(ethBalance)} ETH);
    console.log(Account ${signerAddress} has ${ethers.formatUnits(tokenBalance, 18)} tokens);
  } catch (error) {
    console.error("Error retrieving balances:", error);
  }

  // Retrieve the testnet paymaster address
  const testnetPaymasterAddress = await zkProvider.getTestnetPaymasterAddress();
  console.log(Testnet paymaster address is ${testnetPaymasterAddress});

  const gasPrice = await zkProvider.getGasPrice();

  // Define paymaster parameters for gas estimation
  const paramsForFeeEstimation = zk.utils.getPaymasterParams(testnetPaymasterAddress, {
    type: "ApprovalBased",
    token: TOKEN_CONTRACT_ADDRESS,
    minimalAllowance: ethers.toBigInt(1),
    innerInput: new Uint8Array(0),
  });

  // Estimate gasLimit via paymaster
  const gasLimit = await votingContract.vote.estimateGas(CANDIDATE_ID, {
    customData: {
      gasPerPubdata: zk.utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
      paymasterParams: paramsForFeeEstimation,
    },
  });

  // Fee calculated in ETH will be the same in ERC20 token using the testnet paymaster
  const fee = gasPrice * gasLimit;

  // New paymaster params with fee as minimalAllowance
  const paymasterParams = zk.utils.getPaymasterParams(testnetPaymasterAddress, {
    type: "ApprovalBased",
    token: TOKEN_CONTRACT_ADDRESS,
    minimalAllowance: fee,
    innerInput: new Uint8Array(0),
  });

  // Full overrides object including maxFeePerGas and maxPriorityFeePerGas
  const txOverrides = {
    maxFeePerGas: gasPrice,
    maxPriorityFeePerGas: "1",
    gasLimit,
    customData: {
      gasPerPubdata: zk.utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
      paymasterParams,
    },
  };

  console.log(Sign the transaction in your wallet);

  // Send transaction with additional paymaster params as overrides
  const txHandle = await votingContract.vote(CANDIDATE_ID, txOverrides);
  console.log(Transaction ${txHandle.hash} with fee ${ethers.formatUnits(fee, 18)} ERC20 tokens, sent via paymaster ${testnetPaymasterAddress});
  await txHandle.wait();
  console.log(Transaction processed);

  // Retrieve and print the updated balance of the wallet
  ethBalance = await provider.getBalance(signerAddress);
  tokenBalance = await tokenContract.balanceOf(signerAddress);
  console.log(Account ${signerAddress} now has ${ethers.formatEther(ethBalance)} ETH);
  console.log(Account ${signerAddress} now has ${ethers.formatUnits(tokenBalance, 18)} tokens);

  console.log(Done!);
}

main()
  .then(() => process.exit(0))
  .catch((error) => {
    console.error(error);
    process.exit(1);
  });

Please configure by including the deployed voting contract and token address, then enter the candidate ID you wish to vote for

const VOTING_CONTRACT_ADDRESS = "";
// Address of the ERC20 token contract
const TOKEN_CONTRACT_ADDRESS = "";
// Candidate ID to vote for
const CANDIDATE_ID = 2;

Code that execute the transaction :

  const txHandle = await votingContract.vote(CANDIDATE_ID, txOverrides);
  console.log(Transaction ${txHandle.hash} with fee ${ethers.formatUnits(fee, 18)} ERC20 tokens, sent via paymaster ${testnetPaymasterAddress});
  await txHandle.wait();
  console.log(Transaction processed);

ZyFi: Instant Paymaster with just one API shortcut

ZyFi is a Paymaster-as-a-Service platform built for flexibility and seamless integration, aimed at enhancing the adoption of paymasters within the zkSync ecosystem. It has already been successfully implemented with various blockchain DApps, including PancakeSwap. I recommend checking out the tutorial on ZyFi integration in docs and magic link tutorial to delve deeper into understanding ZyFi capabilities and integration possibilities.

Closing

Thank you for your support and interest in this series of articles, which is part of a grant program available at Wave Hacks on Akindo.

Structure :

WAVETITLELINK
1stQuickstart Guide to Develop on zkSync : Fundamental Concept and Development with ZKSync Remix IDE 'Instant Way' (Chapter 1)https://ridhoizzulhaq.hashnode.dev/quickstart-guide-to-develop-on-zksync-fundamental-concept-and-development-with-zksync-remix-ide-instant-way-chapter-1
3rdQuickstart Guide to Develop on zkSync : Build a Complete Voting DApp on the ZKSync (Chapter 2)https://ridhoizzulhaq.hashnode.dev/quickstart-guide-to-develop-on-zksync-build-a-complete-voting-dapp-on-the-zksync-chapter-2
4thQuickstart Guide to Develop on ZKSync : Explore Basic Paymaster (Chapter 3)https://ridhoizzulhaq.hashnode.dev/quickstart-guide-to-develop-on-zksync-explore-basic-paymaster-chapter-3
5thQuickstart Guide to Develop on ZKSync : Build NFT ERC-1155 Ticket Minter with Paymaster feature and DApp for Ticket Checker (Chapter 4)https://ridhoizzulhaq.hashnode.dev/quickstart-guide-to-develop-on-zksync-build-nft-erc-1155-ticket-minter-with-paymaster-feature-and-dapp-for-ticket-checker-chapter-4
0
Subscribe to my newsletter

Read articles from Ridho Izzulhaq directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ridho Izzulhaq
Ridho Izzulhaq