Paymasters introduction

Learn about paymasters and use one to pay transaction fees with your own token

This tutorial makes use of smart contracts deployed in the previous two tutorials, Deploy your first contract and Create an ERC20 token. This section introduces one of the custom features of ZKsync: native account abstraction and paymasters.

In this tutorial we will:

Learn about paymasters.

Review the testnet paymaster smart contract code.

Use the testnet paymaster to pay transaction fees with our own ERC20 token.

Prerequisites

  1. Before you start, make sure that you’ve configured the ZKsync Sepolia Testnet in your browser wallet by following the instructions here.
  2. In addition, fund your wallet with ZKsync Sepolia Testnet ETH using one of the available faucets.

What is a Paymaster?

Paymasters in the ZKsync ecosystem represent a groundbreaking approach to handling transaction fees. They are special accounts designed to subsidize transaction costs for other accounts, potentially making certain transactions free for end-users. This feature is particularly useful for dApp developers looking to improve their platform's accessibility and user experience by covering transaction fees on behalf of their users.

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

Paymaster smart contract code

Although application developers are encouraged to create their own paymaster smart contract, ZKsync provides a testnet paymaster for convenience and testing purposes.

The paymaster smart contract code is provided "as-is" without any express or implied warranties.
  • Users are solely responsible for ensuring that their design, implementation, and use of the paymaster smart contract software complies with all applicable laws, including but not limited to money transmission, anti-money laundering (AML), and payment processing regulations.
  • The developers and publishers of this software disclaim any liability for any legal issues that may arise from its use.

The testnet paymaster address is 0x3cb2b87d10ac01736a65688f3e0fb1b070b3eea3

In the validateAndPayForPaymasterTransaction it is:

  1. Checking that the paymasterInput is approvalBased.
  2. Checking that the allowance of a given ERC20 is enough.
  3. Transferring the transaction fee (requiredETH) in ERC20 from the user’s balance to the paymaster.
  4. Transferring the transaction fee in ETH from the paymaster contract to the bootloader.

How to send a transaction through a paymaster?

In order to send a transaction through a paymaster, the transaction must include the following additional parameters:

  • paymasterAddress: the smart contract address of the paymaster
  • type: should be General or ApprovalBased (to pay fees with ERC20 tokens)
  • minimalAllowance: the amount of ERC20 tokens to be approved for spending (for approvalBased type paymasters only).
  • innerInput: any payload we want to send to the paymaster (optional).

We’ll see an example next.

Interacting with the testnet paymaster

We’re going to interact with the ZeekMessages.sol contract that we created in the first tutorial and use the ERC20 token that we deployed in the second tutorial to pay the transaction fees.

Click the following button to open the project in Atlas:

Open script in Atlas

It’ll open the script to send a transaction via the paymaster. Let’s go through the most important parts:

Retrieve the token balance

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

In this part we’re retrieving the ETH and ERC20 token balances of the account. We’ll compare them after the transaction is executed to see the difference.

Estimate transaction fee

// 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 = 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: 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;
  1. Retrieve the testnet paymaster address.
  2. Generate the paymaster parameters to estimate the transaction fees passing the paymaster address, token address, and ApprovalBased as the paymaster flow type.
  3. Retrieve the gasLimit of sending the transaction with the paymaster params.
  4. Calculate the final estimated fee which is equal to gasPrice multiplied by gasLimit.

Send the transaction

// new paymaster params with fee as minimalAllowance
  const paymasterParams = 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: 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);
  1. Create the new paymaster params with the calculated fee as minimalAllowance .
  2. Complete the transaction overrides object with maxFeePerGas, maxPriorityFeePerGas and gasPerPubdata
  3. Send the transaction including the txOverrides

Compare the final balance

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

Finally we retrieve and print the ETH and ERC20 balances to see how they’ve changed.

Run the script

To run the script, first enter the addresses of the ZeekMessages.sol and TestToken.sol contracts that we deployed previously (Deploy your first contract and Erc20 Token) in the following variables at the beginning of the script:

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

Next, make sure the script file is selected in the Atlas editor and click on the “Deploy” button.

ERC20 interact script in Remix

You’ll see the progress in the console.

If everything worked as expected, only the ERC20 balance will decrease, meaning the fee was paid with the ERC20 token instead of ETH.

The Remix provider does not support EIP712 transactions yet, which are required to interact with paymaster contracts. Please use Atlas for this part.

Click the button below to open the project in Remix and see the contract in the Remix code editor.

Open project in Remix

Once the project is imported, open the scripts/paymaster-transaction.ts file, which contains the code to send a transaction via the paymaster. Let’s go through the most important parts:

Retrieve the token balance

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

In this part we’re retrieving the ETH and ERC20 token balances of the account. We’ll compare them after the transaction is executed to see the difference.

Estimate transaction fee

// 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 = utils.getPaymasterParams(testnetPaymasterAddress, {
  type: "ApprovalBased",
  token: TOKEN_CONTRACT_ADDRESS,
  // set minimalAllowance to 1 for estimation
  minimalAllowance: ethers.BigNumber.from(1),
  // empty bytes as testnet paymaster does not use innerInput
  innerInput: new Uint8Array(0),
});

// estimate gasLimit via paymaster
const gasLimit = await messagesContract.estimateGas.sendMessage(NEW_MESSAGE, {
  customData: {
    gasPerPubdata: 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;

In this part of the script we:

  1. Retrieve the testnet paymaster address.
  2. Generate the paymaster parameters to estimate the transaction fees passing the paymaster address, token address, and ApprovalBased as the paymaster flow type.
  3. Retrieve the gasLimit of sending the transaction with the paymaster params.
  4. Calculate the final estimated fee which is equal to gasPrice multiplied by gasLimit.

Send the transaction

// new paymaster params with fee as minimalAllowance
const paymasterParams = 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: 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);
  1. Create the new paymaster params with the calculated fee as minimalAllowance .
  2. Complete the transaction overrides object with maxFeePerGas, maxPriorityFeePerGas and gasPerPubdata
  3. Send the transaction including the txOverrides

Compare the final balance

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

Finally we retrieve and print the ETH and ERC20 balances to see how they’ve changed.

Run the script

To run the script, first enter the addresses of the ZeekMessages.sol and TestToken.sol contracts that we deployed previously (Deploy your first contract and Erc20 Token) in the following variables at the beginning of the script:

// Address of the ZeekMessages contract
const ZEEK_MESSAGES_CONTRACT_ADDRESS = "";
// Address of the ERC20 token contract
const TOKEN_CONTRACT_ADDRESS = ""
// Message to be sent to the contract
const NEW_MESSAGE = "This tx cost me no ETH!";
Open the "Deploy & run transactions" menu in Remix and select "Injected Provider - Metamask" from the environment dropdown to target the network selected in your wallet when running scripts.

Next, make sure the script file is selected in the Remix editor and click on the “▶️” button. You’ll see the progress in the console.

Contract events in ZKsync explorer

If everything worked as expected, only the ERC20 balance will decrease, meaning the fee was paid with the ERC20 token instead of ETH.

Takeaways

  • Paymasters on ZKsync allow any account to pay fees with ERC20 tokens or enable gasless transactions.
  • Paymasters are smart contracts that can have any validations and rules.
  • To send a transaction through a paymaster, we only need to include additional parameters in the transaction.

Next steps


Made with ❤️ by the ZKsync Community