Integrate permissionless multi-signer paymaster into your Dapp

Permissionless paymaster allows multiple Dapps to sponsor gas for their users using signature verification seamlessly.

Zyfi's new permissionless paymaster allows protocols to integrate paymaster into their ecosytem without the need to deploy paymaster or trusting 3rd party.

What you'll learn:

  • How permissionless multisignature paymaster works
  • How to integrate this paymaster in your Dapp
  • How to sign EIP-712 signature data required for this paymaster
  • How to send transactions through this paymaster
GitHub

Tools:

  • - zksync-cli
  • - zksync-ethers
  • - Hardhat
paymastergas sponsorshiptutorialeip-712
Last Updated: Jul 26, 2024

This tutorial shows you how to integrate this paymaster in your Dapp.

In this tutorial, we will :

  • Create a signer address that will sign paymaster data for gas sponsorship.
  • Deposit gas funds and add the signer address in the permissionless paymaster.
  • Set up a basic frontend to interact with the ZKsync network.
  • Create a function to sign EIP-712 standard paymaster data by the signer.
  • Integrate paymaster data in frontend to sponsor transactions for users.

Prerequisites

Overview

The permissionless multi-signer paymaster enables ZKsync Dapps to sponsor gas for their users through signature verification. Signature-based verification allows Dapps to setup custom logic for gas sponsorship.

Dapps can begin utilizing this paymaster by simply depositing funds and adding a signer address. Thus removing the need to deploy a paymaster at all or relying on any 3rd party.

There are 2 primary actors involved:

Manager: Fully managed by the Dapp, responsible for depositing/withdrawing gas funds, and adding or removing signer addresses.

Signers: Managed by the Dapp or a trusted third party like Zyfi API. A signer’s signature is required to access gas funds by the Dapp’s user.

Multi-signer

This paymaster allows the manager to set multiple signers through which users can have access to the gas funds. Hence, it is a one-to-many relationship. manager-signer-relation-diagram

Integration

Below the diagram provides the flow of the integration:

  1. Dapp decides on custom logic for each user. Let's assume that Dapp decides to sponsor gas for every approve transaction.
  2. Dapp calls the backend server or Zyfi API with relevant data to get the signer's signature.
    • It is recommended that the signer's signing part is done on a secure backend server of the Dapp.
  3. The signer's key signs this paymaster data and returns the signature and signer address to the Dapp's frontend.
  4. Paymaster address and required data with signature are added to the transaction blob in the frontend.
  5. User gets transaction signature request pop-up on their wallet. User only signs the transaction and the transaction is sent on-chain.
  6. The paymaster validates the signature, identifies the manager related to the signer, deducts gas fees from the manager's balance, and pays for the user's transaction

flow

For this tutorial, we will use paymaster deployed on ZKsync sepolia testnet : 0xc1B0E2edC4cCaB51A764D7Dd8121CBf58C4D9E40

1. Create a signer address

The paymaster will verify signature based on this signer address. The private key of this signer address should be stored securely by the Dapp.

  • Here is an easy way to create one:
const {Wallet} = require("zksync-ethers");

const signer = Wallet.createRandom();

console.log("Signer address: " + signer.address);
console.log("Signer private key: " + signer.privateKey);

2. Deposit gas funds and add the signer address

Navigate to ZKsync Sepolia Testnet Explorer: 0xc1B0E2edC4cCaB51A764D7Dd8121CBf58C4D9E40 Using a different address with test funds, call the depositAndAddSigner() function with 0.1 ether and the signer address as the function arguments. deposit and add a signer

The depositor address is considered a "manager". A manager can deposit/withdraw gas funds and add/remove signers at any given time.
  • A signer can only be linked to one manager at a time.
  • A manager can be a signer address too. (not recommended)

3. Setup a basic frontend project

We're going to use the React.js web framework to build this app. In addition, we're going to use the zksync-ethers SDK, ethers@v5, and Next.js. In order to focus on paymaster, we have bootstrapped a template using zksync-cli create. We'll just need to implement the methods to sign and integrate paymaster into the frontend. Let's start!

  1. Clone the template project and cd into the folder.
    git clone https://github.com/ondefy/basic-frontend-template.git
    cd ./basic-frontend-template
    
  2. Make sure you are on the main branch(use git branch to check) and spin up the project.
    yarn
    yarn dev
    

Navigate to http://localhost:3000/ in a browser to see the application running.

frotend

This is a simple template that allows users to approve a certain amount of DAI tokens to a spender address on ZKsync Sepolia Testnet.

Our goal for this tutorial is to sponsor this approval transaction using the paymaster.


4. Create a function to sign EIP-712 typed paymaster data

This paymaster verifies the signature signed by the signer address on the below data. On successful validation, it allows sponsorship for the user using manager's deposited gas funds.

 bytes32 messageHash = keccak256(
 abi.encode(
 SIGNATURE_TYPEHASH,
 _from,
 _to,
 _expirationTime,
 _maxNonce,
 _maxFeePerGas,
 _gasLimit
 )
 );
For the sake of this tutorial, we will be creating the signing function in the repo itself at "src/backend/getSignatureApiCall.ts" path.

It is highly recommended, that signing should be done in a closed backend server for the safety of signer's private key.
  • Create a .env file in the root folder and add signer's private key as NEXT_PUBLIC_SIGNER_PRIVATE_KEY. Run source .env once done.
.env
NEXT_PUBLIC_SIGNER_PRIVATE_KEY=0xabcabc...
  • We will create a file backend/getSignatureApiCall.ts with getSignature() function that signs required data with the help of signer as below:
src/backend/getSignatureApiCall.tsx
import {ethers, BigNumber} from "ethers";
import {Contract, Wallet, Provider} from "zksync-ethers";
import dotenv from "dotenv";
dotenv.config();

export async function getSignature(
  from: string, to: string, expirationTime: BigNumber, maxNonce: BigNumber, maxFeePerGas: BigNumber, gasLimit: BigNumber
){
  const rpcUrl = process.env.ZKSYNC_RPC_URL ?? 'https://sepolia.era.zksync.dev';
  const provider = new Provider(rpcUrl);
  const signer = new Wallet(process.env.NEXT_PUBLIC_SIGNER_PRIVATE_KEY || "", provider);

// Paymaster Sepolia Testnet address
const paymasterAddress = "0xc1B0E2edC4cCaB51A764D7Dd8121CBf58C4D9E40";
const paymasterAbi = [
  "function eip712Domain() public view returns (bytes1 fields,string memory name,string memory version,uint256 chainId,address verifyingContract,bytes32 salt,uint256[] memory extensions)",
];

const paymasterContract = new Contract(
  paymasterAddress,
  paymasterAbi,
  provider
);
// EIP-712 domain from the paymaster
  const eip712Domain = await paymasterContract.eip712Domain();
  const domain = {
    name: eip712Domain[1],
    version: eip712Domain[2],
    chainId: eip712Domain[3],
    verifyingContract: eip712Domain[4],
 }
  const types = {
    PermissionLessPaymaster: [
 { name: "from", type: "address"},
 { name: "to", type: "address"},
 { name: "expirationTime", type: "uint256"},
 { name: "maxNonce", type: "uint256"},
 { name: "maxFeePerGas", type: "uint256"},
 { name: "gasLimit", type: "uint256"}
 ]
 };
// -------------------- IMPORTANT --------------------
  const values = {
    from,  // User address
    to, // Your dapp contract address which the user will interact
    expirationTime, // Expiration time post which the signature expires
    maxNonce, // Max nonce of user after which signature becomes invalid
    maxFeePerGas, // Current max gas price
    gasLimit // Max gas limit you want to allow to your user. Ensure to add 60K gas for paymaster overhead.
 }
// Note: MaxNonce allows the signature to be replayed.
// For eg: If the currentNonce of user is 5, maxNonce is set to 10. The signature will allowed to be replayed for nonce 6,7,8,9,10 on the same `to` address by the same user.
// This is to provide flexibility to Dapps to ensure signature works if users have multiple transactions running. 
// Important: Signers are recommended to set maxNonce as current nonce of the user or as close as possible to ensure the safety of gas funds.
// Important: Signers should set expirationTime close enough to ensure safety of funds.

  return [paymasterAddress,(await signer._signTypedData(domain, types, values)), signer.address];
}

5. Estimate gas and create paymaster data

5.1. Add the preparePaymasterParam() function in the src/components/WriteContract.tsx file
  • This function will set expiration time, max nonce, gas fees, and gas limit and call the getSignature() function.
Notice how we add 65K gas to the gas limit.

This is to facilitate paymaster overhead to ensure the transaction is passed successfully.
All extra ETH spent will be refunded back to the manager's balance, hence, setting the gas limit high would not result in loss of funds.
  • At the end, it returns paymasterParams data that we will add to the transaction.
src/components/WriteContract.tsx
'use client'

import { useState } from 'react';
import { Contract, Provider, utils } from "zksync-ethers";
import { useAsync } from '../hooks/useAsync';
import { daiContractConfig } from './contracts'
import { useEthereum } from './Context';
import { getSignature } from "../backend/getSignatureApiCall";
import { BigNumber, ethers } from "ethers";
const abiCoder = new ethers.utils.AbiCoder();

const preparePaymasterParam = async (account:any, estimateGas: BigNumber) =>{
    const rpcUrl = process.env.ZKSYNC_RPC_URL ?? "https://sepolia.era.zksync.dev";
    const provider = new Provider(rpcUrl);
    // Below part can be managed in getSignature() as well.
    // ------------------------------------------------------------------------------------
    // Note: Do not set maxNonce too high than current to avoid an unwanted signature replay.
    // Consider maxNonce is as replayLimit. Setting maxNonce to currentNonce means 0 replays.
    // Get the maxNonce allowed to user. Here we ensure it's currentNonce.
    const maxNonce = BigNumber.from(
      await provider.getTransactionCount(account.address || "") 
 );
    // You can also check for min Nonce from the NonceHolder System contract to fully ensure as ZKsync support arbitrary nonce.
    const nonceHolderAddress = "0x0000000000000000000000000000000000008003";
    const nonceHolderAbi = [
      "function getMinNonce(address _address) external view returns (uint256)",
 ];
    const nonceHolderContract = new Contract(
      nonceHolderAddress,
      nonceHolderAbi,
      provider
 );
    const maxNonce2 = await nonceHolderContract.callStatic.getMinNonce(
      account.address || ""
 );
    console.log(maxNonce2.toString());
    // -----------------
    // Get the expiration time. Here signature will be valid up to 120 sec.
    const currentTimestamp = BigNumber.from(
 (await provider.getBlock("latest")).timestamp
 );
    const expirationTime = currentTimestamp.add(120);
    // Get the current gas price.
    const maxFeePerGas = await provider.getGasPrice();
    // Add paymaster overhead gas to be on the safe side.
    const gasLimit = estimateGas.add(65000);
    // ------------------------------------------------------------------------------------
    const [paymasterAddress, signature, signerAddress] = await getSignature(
      account.address.toString(),
      daiContractConfig.address.toString(),
      expirationTime,
      maxNonce,
      maxFeePerGas,
      gasLimit
 );
    console.log("Signer: " + signerAddress);
    // We encode the extra data to be sent to paymaster
    // Notice how it's not required to provide from, to, maxFeePerGas, and gasLimit as per the signature above.
    // That's because paymaster will get it from the transaction struct directly to ensure it's the correct user.
    const innerInput = ethers.utils.arrayify(
      abiCoder.encode(
 ["uint256", "uint256", "address", "bytes"],
 [
          expirationTime, // As used in the above signature
          maxNonce, // As used in the above signature
          signerAddress, // The signer address
          signature,
 ]
 ) // Signature created in the above snippet. get from API server
 );
    // getPaymasterParams function is available in zksync-ethers
    const paymasterParams = utils.getPaymasterParams(
      paymasterAddress, // Paymaster address
 {
        type: "General",
        innerInput: innerInput,
 }
 );
    // Returns paymaster params, gas fee, gas limit
    return [paymasterParams, maxFeePerGas, gasLimit];
 };
5.2 Estimate gas and call the above function
  • In the WriteContract component, we will estimate the gas and call the above created preparePaymasterParam function before the contract call for approve transaction.
src/components/WriteContract.tsx
   // ------------- Add above approve function call
   // Estimate gas
    const estimateGas = await contract.estimateGas.approve(spender,amount); 
    const [paymasterParams, maxFeePerGas, gasLimit] = await preparePaymasterParam(account, estimateGas);
   // --------------
    const tx = await contract.approve(spender, amount);

6. Add paymaster data to the transaction

  • We will update the approve function call by adding paymasterParams in the customData of the user transaction as below.
  • Ensure to set maxFeePerGas and gasLimit as signed by the signer.
src/components/WriteContract.tsx

   // Estimate gas
    const estimateGas = await contract.estimateGas.approve(spender,amount); 
    const [paymasterParams, maxFeePerGas, gasLimit] = await preparePaymasterParam(account, estimateGas);

    const tx = await contract.approve(spender, amount,{
      maxFeePerGas,
      gasLimit,
      customData: { // Add custom data with paymaster params
        paymasterParams,
        gasPerPubdata: utils.DEFAULT_GAS_PER_PUBDATA_LIMIT,
 }
 });
  • Funds are deducted as per: gasPrice*gasLimit upon successful verification. Dapp should ensure enough funds are deposited to avoid failures.
  • If a signer's private key is leaked, the respective manager will need to replace/remove the signer immediately.
  • The user shall get a signature request pop-up for transaction. Here the user can verify that gas is not being paid from their end and only a signature is required. transaction
Paymaster is successfully integrated.

The final integration code should look like permissionless-paymaster-integration branch of the template repo.

Refunds

ZKsync refunds ETH to the paymaster for the unused gas. All refunded ETH are added back to the respective manager's balance in the next paymaster transaction.

Notes

  1. _maxNonce introduces flexibility to Dapps by allowing signature replay in a secure constrained way. The signer should ensure _maxNonce is not too big from the current nonce of the user and _expirationTime is not too far from the current timestamp. If _maxNonce is set to current nonce of the user, then the signature cannot be replayed at all.
    • Check here
      
      // Validate that the transaction generated by the API is not expired
      if (block.timestamp > expirationTime)
      revert Errors.PM_SignatureExpired();
      // Validate that the nonce is not higher than the maximum allowed
      if (_transaction.nonce > maxNonce) revert Errors.PM_InvalidNonce();
      
  2. ZKsync might allow arbitrary nonce ordering in the future. To ensure surety over the nonce of a user, you can add one more check by calling getMinNonce on the NonceHolder system contract of ZKsync. For more details, check docs here & here.
  3. This paymaster has a gas overhead of 51K-59K gas, which is quite nominal compared to other paymaster gas overheads. Signer should ensure to add this overhead(60K~65K) in the _gasLimit.
  4. _gasLimit should be set in the near range of the estimated gas required + paymaster gas overhead.

Other functionalities

  1. Manager can replaceSigner, addSigner, batchAddSigners, removeSigner, batchRemoveSignersdepositAndAddSigner, deposit, withdraw, withdrawFull & withdrawAndRemoveSigners
  2. depositOnBehalf and rescueTokens are public functions.
  3. A signer can call selfRevokeSigner to revoke their signing privileges.
  4. To check the latest balance of the manager including refunded ETH, call getLatestManagerBalance

Common Errors

  1. Paymaster validation errors:
  2. Panic due to not enough balance of the manager:
    • Please try depositing additional ETH to the manager's balance in 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).
  3. Please reach out to us on our Discord in case of other errors.

Learn More

  • Audited by Cantina.
  • To learn more about this permissionless paymaster, check out 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