zkSync Era: Develop a custom paymaster contract
TLDR
- Shows how to deploy a custom paymaster on zkSync Era, sponsoring user transactions in ERC-20 tokens.
- Uses Hardhat, TypeScript, and zkSync’s account abstraction to bypass ETH-only gas fees.
- Demonstrates token minting to a zero-ETH wallet, with the paymaster contract covering transaction costs.
- Provides a complete workflow for contract setup, funding, and gasless (sponsored) interaction on zkSync Era.
Introduction
This tutorial provides a step-by-step guide on creating and deploying a custom paymaster smart contract on the zkSync Era Testnet using Hardhat and TypeScript. By leveraging zkSync Era’s account abstraction feature, you’ll be able to facilitate transactions where gas fees are paid using a custom ERC-20 token, rather than ETH.
The tutorial will guide you through deploying two smart contracts: a paymaster contract and an ERC-20 token contract. The paymaster contract, once deployed and funded with zkSync Era Testnet ETH, will act as a sponsor for users’ gas fees.
In this setup, users can execute transactions without directly paying for gas. Instead, they will transfer a token to the paymaster contract, which handles the gas fee payment. This approach can, for example, offer a monthly quota of sponsored transactions for your DApp users. This enhances the user experience and makes DApps more accessible.
What is zkSync Era?
zkSync is a trustless zkEVM protocol based on ZK rollups that offers scalable and low-cost transactions on Ethereum by conducting computation and storing most data off-chain. As a subset of zkSync, zkSync Era is designed to emulate Ethereum’s functionality but at a lower cost. zkSync Era supports existing Ethereum wallets without separate registration and permits smart contract usage, like its Ethereum counterpart. Transactions in zkSync Era undergo a lifecycle that includes user initiation, operator processing, block commitment, and final block verification, all of which ensure the same level of security as Ethereum’s mainchain.
The current version of zkSync Era caters to the needs of most applications on Ethereum, with features such as native support of ECDSA signatures, Solidity 0.8.x support, compatibility with Ethereum’s Web3 API, support for Ethereum cryptographic primitives, and L1 to L2 smart contract messaging. It also offers high security and usability compared to other L2 scaling solutions, with unique properties like safely withdrawing assets even when the user or ZK rollup validators are offline.
Learn more about zkEVM and rollups in zkEVM and rollups explained.
What is account abstraction?
zkSync Era natively supports account abstraction, a feature designed to address inefficiencies in Ethereum’s account system, which currently divides into externally owned accounts (EOAs) and smart contract accounts.
These two types have limitations that can lead to friction in certain applications like smart-contract wallets or privacy protocols. However, account abstraction allows accounts on zkSync Era, an EVM-compatible chain, to initiate transactions akin to EOAs and implement arbitrary logic akin to smart contracts.
zkSync Era introduces the concept of smart accounts and paymasters, designed to enhance flexibility, user experience, and security by offering fully programmable accounts and transaction sponsorship in ERC-20 tokens, respectively. Despite some similarities with Ethereum’s EIP 4337, key differences shape zkSync’s unique account management approach.
Prerequisites
- Chainstack account to deploy a zkSync Era Testnet node and get funds from the faucet
- node.js at least V16 on your machine
- yarn installed on your machine
Overview
To get from zero to a paymaster-sponsored transaction, do the following:
With Chainstack, create a .
With Chainstack, join the zkSync Era Sepolia Testnet.
With Chainstack, access your zkSync Era Sepolia Testnet node credentials.
Setup the project and install the required dependencies.
Create the smart contracts.
Get zkSync Era Sepolia Testnet funds from the faucet.
Set up the Hardhat scripts to deploy and interact.
Deploy the smart contracts.
Use the paymaster to mint tokens to a new address without using gas.
Step-by-step
Create a public chain project
See Create a project.
Join the zkSync Era Sepolia Testnet
Get your zkSync Era Sepolia Testnet node endpoint
See View node access and credentials.
Setup the project and install the required dependencies
Create a new directory where you want to develop your project:
Initialize a new yarn project:
To create the project structure, we’ll need a contracts
and a deploy
directories:
Install the required dependencies:
Create a .env
file and add your Chainstack zkSync Era endpoint and the private key for the account you want to use to deploy the smart contracts and get some gas tokens initially minted to.
Create a new hardhat.config.ts
file and paste the following:
Check the zksolc
compiler GitHub repository to find the latest version available.
Create the smart contracts
Now that the project is set up let’s create the smart contracts starting from the ERC-20 token that users will use to pay ‘pay’ for transactions.
ERC-20 smart contract
In the contracts
directory, create a new file named CSgas.sol
and paste the following Solidity code:
This is a simple implementation of an ERC-20 smart contract using the OpenZeppelin library, which provides secure and community-audited implementations for parts of the Ethereum protocol.
The smart contract is written in version 0.8.0 of Solidity, specified at the top of the file. The caret symbol ^
means it’s compatible with any new minor releases under version 0.8.
The contract is importing something important—the ERC-20 token standard from the OpenZeppelin library. The contract itself is named CSgas
.
When CSgas
is deployed, it calls a constructor function. This function sets up the token’s name, symbol, and the number of decimal places the token will have. The deploying code will pass through these details.
The _decimals
variable is used to determine how the token’s value is displayed in wallets and user interfaces. Most tokens go with 18 decimal places, matching the precision of Ethereum’s own currency, ether.
The mint function is public, meaning anyone can call it. When called, it mints new tokens. You need to input who should receive the tokens and how many to create. It uses the _mint
function from the ERC20 contract to create the token and update the balance.
Paymaster smart contract
Now let’s go over the interesting part, the Paymaster
smart contract. Create a new file in the contract
directory named Paymaster.sol
and paste the following:
Let’s go over it.
This contract is named Paymaster
and it’s designed to manage transaction fees on behalf of users and uses Solidity version 0.8.0.
The contract starts by importing dependencies. It uses the OpenZeppelin library for the IERC20
interface, which is a standard interface for interacting with ERC-20 tokens. It’s also importing several interfaces and libraries from the @matterlabs/zksync-contracts
package, which is used for handling zkSync transactions.
The Paymaster
contract itself implements the IPaymaster
interface. This interface defines the methods that a paymaster contract should have.
The contract has a constant PRICE_FOR_PAYING_FEES
set to 1 ether. This is the amount of tokens the user needs to allow the paymaster to spend on their behalf to pay for transaction fees.
The allowedToken
variable stores the address of the ERC-20 token that the paymaster will accept for transaction fees. This is passed through the deploying script after the ERC-20 token is deployed.
The onlyBootloader
modifier is a security feature. It ensures that only the bootloader can call certain functions.
In the constructor, the contract sets the allowedToken
to the address of the ERC-20 token that’s passed in when the contract is deployed.
The validateAndPayForPaymasterTransaction
function is where the magic happens. It’s called by the bootloader to validate and pay for a transaction. This function checks that the transaction is using the correct token and that the user has allowed the paymaster to spend enough tokens to cover the transaction fees. If everything checks out, the paymaster pays the transaction fees.
The postTransaction
function is called after a transaction is executed. It doesn’t do anything right now because refunds are not supported yet.
Finally, thereceive
function is a special function called when someone sends ether to the contract without providing any data. This function is empty, so the contract accepts ether without doing anything else.
Get zkSync Era Sepolia Testnet funds from the faucet
Before moving on, let’s make sure we have testnet funds to deploy smart contracts and interact with them on the zkSync Testnet. You can request funds from the Chainstack zkSync faucet.
Make sure to select zkSync Era Testnet on the bottom right of the screen.
Set up the Hardhat scripts to deploy and interact
At this stage, we can make the scripts to interact with zkSync Era. We’ll make two scripts:
- A deployment script that will deploy and fund the smart contracts.
- An interaction script that will use the paymaster to allow a user with no ETH mint tokens to a new wallet.
Script to deploy the smart contracts
Create a script to deploy those smart contracts in the deploy
directory create a new file named deploy.ts
and paste the following code:
Note that the directory must be named deploy
, otherwise the zkSync version of Hardhat will not find the files.
This script has a lot going on, so let’s break it down:
-
Imports and anvironment variables. The script begins by importing necessary modules and types. It then defines several constants and retrieves environment variables.
Here is how we set up the script and keep it somewhat easier to maintain and edit:
-
Environment variable checks. The script checks if the necessary environment variables are set. If they are not, it throws an error. This is to accommodate the fact that TypeScript does not allow for strings to have
undefined
values. -
Function definitions. The script defines several helper functions to keep the code clean and maintainable:
-
createWallet()
— creates a new random wallet and logs the wallet’s address and private key. We create a new wallet to demonstrate how an account can initiate transactions sponsored by the paymaster without having any ETH. -
deployContract()
— deploys a contract to the zkSync Era network. It takes aDeployer
instance, the name of the contract, and an array of arguments for the contract’s constructor. It loads the contract’s artifact, deploys the contract, waits for a certain number of confirmations, and then logs the contract’s address. -
verifyContract()
— verifies a contract on the zkSync Era network. It takes aHardhatRuntimeEnvironment
instance, the contract’s address, the path to the contract’s source code, and an array of arguments passed to the contract’s constructor. It runs theverify:verify
task provided by the Hardhat environment. -
fundPaymaster()
— funds the paymaster contract with a certain amount of ETH. It takes aDeployer
instance and the paymaster contract’s address. It sends a transaction to the paymaster contract and waits for a certain number of confirmations. -
mintTokens()
— mints a certain number of tokens and sends them to a specified address. It takes a contract instance and an address. It calls themint
function on the contract and waits for the transaction to be confirmed.
-
-
Main function. The script exports a default async function that performs the main deployment process:
-
It initializes a
Provider
instance, aWallet
instance, and aDeployer
instance. -
It creates a new wallet.
-
It deploys the
CSgas
contract, the token sent to the paymaster to indicate the account is allowed to get the transaction sponsored, and thePaymaster
contract. -
It verifies both contracts.
-
It funds the paymaster contract with ETH and mints tokens to the new wallet.
-
It retrieves and logs the balance of the paymaster contract and the new wallet’s token balance.
-
-
Error handling. If any errors occur during deployment, the script catches them and logs the error message.
Script to interact
In the same deploy
directory, create a new file named gasless-tx.ts
and paste the following code:
Let’s break down the code and the logic:
-
Imports and environment variables. The script begins by importing necessary modules and types. It then defines several constants and retrieves environment variables. Let’s go over what’s happening with the constants as is our setup point.
-
Function definitions. The script defines several helper functions:
-
getToken()
— retrieves the instance of the Chainstack gas token. It takes aHardhatRuntimeEnvironment
instance and aWallet
instance reads the token’s artifact and returns a new contract instance with the token’s address and ABI. This is used to interact with the token contract. -
createWallet()
— creates a new random wallet and logs the wallet’s address and private key. We create another random wallet to use as a second user, where basically the first user we created in the deployment script mints new tokens for this second new user. -
displayBalances()
— retrieves and logs the balances of the user and the paymaster contract in both ETH and Chainstack gas tokens. It takes aProvider
instance, a contract instance, aWallet
instance, and the paymaster contract’s address. This is just used to give insights into the process and logic. -
mintTokens()
— mints a certain number of tokens and sends them to a specified user. It takes a contract instance, aWallet
instance, and the paymaster parameters. It calls themint
function on the contract and waits for the transaction to be confirmed.
-
-
Main function. The script exports a default async function that performs the main process:
-
It initializes a
Provider
instance and aWallet
instance, and retrieves the Chainstack gas token instance. -
It displays the initial balances of the user and the paymaster contract.
-
It creates a new wallet.
-
It retrieves the current gas price and sets the paymaster parameters for an ApprovalBased flow.
-
It estimates the gas fee for minting new tokens and logs the estimated fee.
-
It mints tokens to the new wallet.
-
It displays the user’s final balances, the paymaster contract, and the new wallet’s token balance.
-
-
Error handling. If errors occur during the process, the script catches them and logs the error message.
Deploy the smart contracts
At this point, everything is set up to deploy the contracts. Make sure to edit the configuration constants as you need:
Then in the terminal from the root of your project, compile the smart contracts:
This will compile the smart contracts in the contracts
directory. Then run the deploy.ts
script:
This process will generate a new wallet and deploy the contracts while transferring 0.001 ETH to the paymaster contract. It will also mint 10 CSgas
tokens to the new wallet. Please note that this ETH is drawn from the account associated with the private key you specified in the .env
file.
The console will log the following result:
You can now verify the contracts on the zkSync explorer. You will see the deployment transactions and the verified contracts. You can check the contracts we deployed:
Now the smart contracts are deployed. The paymaster should have 0.001 ETH to sponsor gas fees, and the new account should have 10 CSG
tokens.
Use the paymaster to mint tokens to a new address without using gas
Now we can use the second script to interact with the paymaster contract and see the magic happening. First, make sure to update the configuration constants in the gasless-tx.ts
file with the smart contract addresses, token name, and generated walled private key. In my specific case, it will look like this; note that you will have different parameters:
You can find those parameters in the deployment log; note that the token name depends on which name you gave:
Run the script with the following command:
The console will output the following logs:
Now, let’s dive into the sequence of events and the underlying logic:
Initially, the script assesses the balances of all accounts. This is done to illustrate that the first user (created during the deployment phase) has a zero balance, rendering it incapable of sending a transaction due to the lack of gas. Despite this, the account holds 10 Chainstack gas tokens. On the other hand, the paymaster account holds a balance of 0.01 ETH but lacks any Chainstack gas tokens.
Next, the script generates a new wallet, which will serve as the recipient of the freshly minted tokens. The first account initiates the minting process, which, interestingly, does not pay for the gas in ETH. Instead, it transfers 1 CSG token to the paymaster contract. The paymaster contract, recognizing the token as an acceptable form of payment, sponsors the gas for the minting transaction. It’s important to note that if the script is configured to send less than 1 CSG token to the paymaster, or a different token, the transaction will fail and be reverted.
This is a really powerful concept as it allows your DApp to onboard users more easily. In this specific example, we don’t do anything particularly meaningful, but you can build on top of this.
Conclusion
This tutorial guided you through setting up Hardhat to work with zkSync Era and develop a paymaster contract to sponsor users’ gas fees.