Integrate permissionless multi-signer paymaster into your Dapp
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
Tools:
- - zksync-cli
- - zksync-ethers
- - Hardhat
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
- A Node.js installation running at minimum Node.js version 18.
- Use the
yarn
ornpm
package manager. We recommend usingyarn
. To installyarn
, follow the Yarn installation guide. - You are a bit familiar with paymaster integration on ZKsync Era. If not, please refer to the first section of the paymaster introduction.
- Get some ZKSync Sepolia Testnet ETH using one of these faucets.
- You know how to get your private key from your MetaMask wallet.
- Some background knowledge on the concepts helpful for the tutorial:
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.
Integration
Below the diagram provides the flow of the integration:
- Dapp decides on custom logic for each user. Let's assume that Dapp decides to sponsor gas for every approve transaction.
- 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.
- The signer's key signs this paymaster data and returns the signature and signer address to the Dapp's frontend.
- Paymaster address and required data with signature are added to the transaction blob in the frontend.
- User gets transaction signature request pop-up on their wallet. User only signs the transaction and the transaction is sent on-chain.
- 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
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.
- 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!
- Clone the template project and
cd
into the folder.git clone https://github.com/ondefy/basic-frontend-template.git cd ./basic-frontend-template
- Make sure you are on the
main
branch(usegit branch
to check) and spin up the project.yarn yarn dev
Navigate to http://localhost:3000/
in a browser to see the application running.
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
)
);
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 asNEXT_PUBLIC_SIGNER_PRIVATE_KEY
. Runsource .env
once done.
NEXT_PUBLIC_SIGNER_PRIVATE_KEY=0xabcabc...
- We will create a file
backend/getSignatureApiCall.ts
withgetSignature()
function that signs required data with the help of signer as below:
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.
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.
'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 createdpreparePaymasterParam
function before the contract call for approve transaction.
// ------------- 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 thecustomData
of the user transaction as below. - Ensure to set
maxFeePerGas
andgasLimit
as signed by the signer.
// 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.
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
_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();
- Check here
- 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. - 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
. _gasLimit
should be set in the near range of the estimated gas required + paymaster gas overhead.
Other functionalities
- Manager can
replaceSigner
,addSigner
,batchAddSigners
,removeSigner
,batchRemoveSigners
depositAndAddSigner
,deposit
,withdraw
,withdrawFull
&withdrawAndRemoveSigners
depositOnBehalf
andrescueTokens
are public functions.- A signer can call selfRevokeSigner to revoke their signing privileges.
- To check the latest balance of the manager including refunded ETH, call
getLatestManagerBalance
Common Errors
- Paymaster validation errors:
- All paymaster-related errors can be found here.(https://github.com/ondefy/permissionless-multisigner-paymaster/blob/main/contracts/libraries/Errors.sol)
- One of the common signature validation errors is that
gasLimit
andgasPrice
are not set exactly in the transaction as signed by the signer.
- 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).
- 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.