Use the Call Permit Precompile to Send Gasless Transactions¶
by Erin Shaben
Introduction¶
To interact with dApps on Moonbeam, users typically need to hold GLMR, Moonbeam's native token, in order to pay for transaction fees. This requirement creates an obstacle for dApps in terms of user experience, as a user needs to ensure they keep a balance of the native token to interact with the dApp.
One solution to this problem is gasless transactions, also known as meta transactions. Gasless transactions are a type of transaction that does not require the user to pay for the gas required to execute the transaction. The gas for these transactions can be covered by a third-party service or it can be deducted from the user's balance of a different token, depending on the implementation. For example, a user could simply sign a message that represents the transaction to be submitted to the network, and then a third-party could submit the transaction and pay the transaction fees for the user.
A regular transaction may have the following flow:
Whereas a gasless transaction may look something like this:
Gasless transactions can be especially beneficial for users that make small transactions frequently, as is the case with gaming dApps like Damned Pirates Society (DPS). In DPS, users go on voyages in search of treasure and with the goal of growing their fleet. There are two in-game currencies that are used in DPS: Treasure Maps (TMAP) and Doubloons (DBL). TMAP are used to buy voyages, and DBL are used to maintain flagships and buy support ships and can be earned while on voyages. Currently, if a user wants to start a voyage, they'll need TMAP to buy the voyage and GLMR to pay for transaction fees. Wouldn't it be ideal to lower the barrier to entry by implementing gasless transactions so users wouldn't need to worry about keeping a GLMR balance on top of their TMAP and DBL balances? From a dApp's perspective, it would keep users on their platform, as their users wouldn't need to leave the dApp to fund their GLMR balance; they could keep on gaming.
Gasless transactions can be implemented using Moonbeam's Call Permit Precompile, which is a Solidity interface that allows a user to sign a permit, an EIP-712 signed message, that can then be dispatched by your dApp. The Call Permit Precompile can be used to execute any EVM call. The best part is that you don't need to modify your existing contracts!
In this tutorial, we'll walk through the process of implementing gasless transactions in a dApp. More specifically, we'll take a closer look at how we can implement gasless transactions to buy a voyage in DPS, as an example. We'll go over building an EIP-712 signed message, signing it, and dispatching it with the Call Permit Precompile.
What are EIP-712 Signed Messages?¶
An EIP-712 signed message is a message that is structured, hashed, and signed in a standardized way. The benefit of the EIP-712 standardization is that message data can be displayed in a much more human-readable way for users signing these messages, so they can better understand what exactly they're signing. Before this standardization existed, users had to sign off on unreadable and difficult-to-decode hexadecimal strings, which made it easy for users to misplace their trust and sign off on messages with malicious data.
The EIP-712 standard specifies how the message data should be structured by requiring developers to define a JSON structure of the message data that users will sign off on and specifying a domain separator. The main goal of the domain separator is to prevent replay attacks. We'll cover both of these requirements in the following sections.
Checking Prerequisites¶
For this tutorial, you'll need the following:
- An account with funds. You can get DEV tokens for testing on Moonbase Alpha once every 24 hours from the Moonbase Alpha Faucet
-
A project with Ethers installed:
¶npm i ethers
To test out the examples in this guide on Moonbeam or Moonriver, you will need to have your own endpoint and API key, which you can get from one of the supported Endpoint Providers
Configure your Project¶
To get started, make sure you have a project with Ethers installed, as specified in the prerequisites. To configure Ethers for Moonbeam, you'll need to:
- Import
ethers
- Define the network configurations
- Create an
ethers
provider
// 1. Import ethers
import { ethers } from 'ethers';
// 2. Define network configurations
const providerRPC = {
moonbeam: {
name: 'moonbeam',
rpc: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1284, // 0x504 in hex,
},
};
// 3. Create ethers provider
const provider = new ethers.JsonRpcProvider(providerRPC.moonbeam.rpc, {
chainId: providerRPC.moonbeam.chainId,
name: providerRPC.moonbeam.name,
});
// 1. Import ethers
import { ethers } from 'ethers';
// 2. Define network configurations
const providerRPC = {
moonriver: {
name: 'moonriver',
rpc: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1285, // 0x505 in hex,
},
};
// 3. Create ethers provider
const provider = new ethers.JsonRpcProvider(providerRPC.moonriver.rpc, {
chainId: providerRPC.moonriver.chainId,
name: providerRPC.moonriver.name,
});
// 1. Import ethers
import { ethers } from 'ethers';
// 2. Define network configurations
const providerRPC = {
moonbase: {
name: 'moonbase-alpha',
rpc: 'https://rpc.api.moonbase.moonbeam.network',
chainId: 1287, // 0x507 in hex,
},
};
// 3. Create ethers provider
const provider = new ethers.JsonRpcProvider(providerRPC.moonbase.rpc, {
chainId: providerRPC.moonbase.chainId,
name: providerRPC.moonbase.name,
});
// 1. Import ethers
import { ethers } from 'ethers';
// 2. Define network configurations
const providerRPC = {
dev: {
name: 'moonbeam-development',
rpc: 'http://127.0.0.1:9944',
chainId: 1281, // 0x501 in hex,
},
};
// 3. Create ethers provider
const provider = new ethers.JsonRpcProvider(providerRPC.dev.rpc, {
chainId: providerRPC.dev.chainId,
name: providerRPC.dev.name,
});
As previously mentioned, there are several ways to set up gasless transactions. For the purposes of this tutorial, we'll assume that there is a third-party account that pays the fees. As such, you'll need to have a signer for the user of the dApp, which is connected to your user's wallet, and a signer for the third-party account paying for the transaction fees. This tutorial assumes that you already have these signers in place, but if needed, you can set up the following generic signers for testing purposes:
const userSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
const thirdPartyGasSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
Remember
Never store your private keys in a JavaScript or TypeScript file.
Now that we've set up the initial configurations, let's dive into building the EIP-712 signed message.
Build an EIP-712 Typed Message¶
There are three components that we'll need to build an EIP-712 typed message: the domain separator, the typed data structure for the data that users will sign, and the actual message data.
The domain separator and the typed data structure will be based on the Call Permit Precompile. The steps to build both of these components will always be the same, regardless of the data that is being signed. The actual message data will change depending on your individual use case.
Define the Domain Separator¶
We'll first start off with the domain separator, which will define the Call Permit Precompile as the signing domain. Permits will get dispatched by calling the dispatch
function of the Call Permit Precompile, which is why the Call Permit Precompile is always going to be the signing domain. As previously mentioned, the goal of the domain separator is to avoid replay attacks.
The domain separator is defined in the EIP-712 standard and is calculated as:
keccak256(PERMIT_DOMAIN, name, version, chain_id, address)
The parameters of the hash can be broken down as follows:
- PERMIT_DOMAIN - is the
keccak256
ofEIP712Domain(string name,string version,uint256 chainId,address verifyingContract)
- name - is the name of the signing domain and must be
'Call Permit Precompile'
exactly - version - is the version of the signing domain. For this case version is set to
1
- chainId - is the chain ID of the network
- verifyingContract - is the address of the contract that will verify the signature. In this case, the Call Permit Precompile address
We're using Ethers in this example, which requires the domain separator to be in the format specified by the TypedDataDomain
interface, but if desired, you could generate the domain separator as a bytes32 representation using the DOMAIN_SEPARATOR()
function of the Call Permit Precompile.
The domain separator for each Moonbeam network is as follows:
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1284,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1285,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1287,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1281,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
Define the Typed Data Structure¶
Next, we'll need to define the typed data structure. The typed data structure defines the acceptable types of data that our users will be signing. We'll go into detail on the actual data in the following section.
If you take a look at the dispatch
function of the Call Permit Precompile, you'll see that the data that we need to send, along with the associated types, is as follows:
function dispatch(
address from,
address to,
uint256 value,
bytes memory data,
uint64 gaslimit,
uint256 deadline,
uint8 v,
bytes32 r,
bytes32 s
) external returns (bytes memory output);
We'll need to add each of the above parameters to our typed data structure, with a couple of modifications. We don't need to include the signature-related parameters, but we do need to include a parameter for the nonce
of the from
account, which will be a uint256. The signature-related parameters aren't needed at this point because we're building the message data for the users to sign. We'll circle back to the signature-related parameters after we've finished building the message and requested the signature.
So, if we grab the rest of the parameters, we can start to build our data structure. Some implementations of EIP-712 require a type for EIP712Domain
to be specified, but this is not the case when using Ethers as it computes it for you! For our implementation, the only type we'll need is the CallPermit
type. The CallPermit
type will be an array of objects that correspond to each of the parameters and define the name
and type
for each one:
const types = {
CallPermit: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
{ name: 'gaslimit', type: 'uint64' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
Define the Message Data¶
Since we are going to implement gasless transactions for buying a voyage, we're going to be interacting with the Cartographer V1 contract, which is located at this address: 0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138
on Moonbeam.
So, let's start by going over the arguments required to build the message data:
from
- your user's address, which you can easily get from your user's Ethers signer usingsigner.address
to
- the contract address that you want to interact with. For this example, we'll use the address of DPS's Cartographer V1 contractvalue
- the value to be transferred from thefrom
account. This will be0
as TMAP are used to buy voyages, not GLMRdata
- the calldata to be executed, which we'll calculate in the following stepsgaslimit
- the gas limit the call requiresnonce
- the nonce of thefrom
account. This isn't your standard nonce, but the nonce for permits dispatched through the Call Permit Precompile specifically. To get this nonce, you can call the Call Permit Precompile'snonces
function and pass in the address of thefrom
accountdeadline
- the deadline in UNIX seconds after which the permit will expire and no longer be valid
The message will resemble the following:
const message = {
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contract
value: 0,
data: 'TODO: Calculate the data that will buy a voyage',
gaslimit: 'TODO: Estimate the gas',
nonce: 'TODO: Use the Call Permit Precompile to get the nonce of the from account',
deadline: '1714762357000', // Randomly created deadline in the future
};
Now, let's dig a little bit deeper and tackle the TODO
items.
Get the Encoded Call Data for Buying a Voyage¶
We'll start off by calculating the data
value. We can programmatically calculate the data
value with Ethers by creating an interface of the Cartographer V1 contract and using the interface.encodeFunctionData
function.
If you take a look at the DPSCartographer.sol
contract's code, you'll see the buyVoyages
function. The buyVoyages
function accepts three parameters:
- uint16
_voyageType
- specifies the type of voyage to buy, i.e., easy, medium, hard, etc. This value corresponds to the index of the voyage in theVOYAGE_TYPE
enum. For this example, we'll do an easy voyage, so we'll pass in0
as the value - uint256
_amount
- corresponds to the number of voyages to buy. We'll buy one voyage - DPSVoyageIV2
_voyage
- represents the address of theDPSVoyageV2.sol
contract, which is:0x72A33394f0652e2Bf15d7901f3Cd46863d968424
on Moonbeam
To create an interface using Ethers, we'll need to get the ABI of the Cartographer V1 contract. You can retrieve it in full from Moonscan, or for simplicity, you can use the following snippet, which is the part of the ABI we need for this example:
const cartographerAbi = [
{
inputs: [
{ internalType: 'uint16', name: '_voyageType', type: 'uint16' },
{ internalType: 'uint256', name: '_amount', type: 'uint256' },
{
internalType: 'contract DPSVoyageIV2',
name: '_voyage',
type: 'address',
},
],
name: 'buyVoyages',
outputs: [],
stateMutability: 'nonpayable',
type: 'function',
},
];
Then we can create the interface using the ABI and get the encoded data using the values we specified for each of the parameters of the buyVoyages
function:
const cartographerInterface = new ethers.Interface(cartographerAbi);
const data = cartographerInterface.encodeFunctionData('buyVoyages', [
0n, // Voyage type: Easy
1n, // Number of voyages to buy
'0x72A33394f0652e2Bf15d7901f3Cd46863d968424', // Voyage V2 contract
]);
This will provide us with the following value for data
:
'0xdb76d5b30000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000072a33394f0652e2bf15d7901f3cd46863d968424'
Estimate the Gas Required to Buy a Voyage¶
Now that we have the encoded call data for buying a voyage, we can use it to estimate the gas required for the transaction. We'll use the estimateGas
method and pass in the user's address, the address of the Cartographer V1 contract, and the encoded call data:
const gasEstimate = await provider.estimateGas({
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contraact
data,
})
Note
For this example, you'll need to have a balance of at least 1 TMAP to be able to estimate the gas. Otherwise, you'll get a 'VM Exception while processing transaction: revert'
error.
We'll add a little bit of a buffer to the gasEstimate
value and set it as the gaslimit
:
const message = {
...
gaslimit: gasEstimate + 50000n,
...
}
We'll get the nonce in the next section, and then put all of the arguments together, and the message data will be complete.
Get the Signer's Nonce Using the Call Permit Precompile¶
Lastly, we'll need to get the nonce
of the from
account. As previously mentioned, we can use the nonces
function of the Call Permit Precompile to get this value. To do so, you'll need to create a contract instance for the Call Permit Precompile:
- Create a new file in your project that contains the ABI of the Call Permit Precompile. You can find the ABI on GitHub
- Import the ABI into your Ethers file
- Create an instance of the Call Permit Precompile using the precompile's address and the ABI of the precompile. You can use either a provider or a signer. Since we are dispatching the permit later on in this tutorial, we'll use the signer associated with the third-party account for transaction fees, but if you only needed to access the
nonces
function, you could use a provider instead - Call the
nonces
function and pass in thesigner.account
of the user, which is the same as thefrom
account
...
import abi from './callPermitABI.js'
...
const callPermit = new ethers.Contract(
'0x000000000000000000000000000000000000080a',
abi,
thirdPartyGasSigner,
);
const nonce = await callPermit.nonces(userSigner.address);
View the script so far
import { ethers } from 'ethers';
import abi from './callPermitABI.js'
import cartographerAbi from './cartographerAbi.js'
const providerRPC = {
moonbeam: {
name: 'moonbeam',
rpc: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1284, // 0x504 in hex,
},
};
const provider = new ethers.JsonRpcProvider(providerRPC.moonbeam.rpc, {
chainId: providerRPC.moonbeam.chainId,
name: providerRPC.moonbeam.name,
});
// Insert your own signer logic or use the following for testing purposes
const userSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
const thirdPartyGasSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1284,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
const types = {
CallPermit: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
{ name: 'gaslimit', type: 'uint64' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const cartographerInterface = new ethers.Interface(cartographerAbi);
const data = cartographerInterface.encodeFunctionData('buyVoyages', [
0n, // Voyage type: Easy
1n, // Number of voyages to buy
'0x72A33394f0652e2Bf15d7901f3Cd46863d968424', // Voyage V2 contract
]);
const gasEstimate = await provider.estimateGas({
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contraact
data,
})
const callPermit = new ethers.Contract(
'0x000000000000000000000000000000000000080a',
abi,
thirdPartyGasSigner,
);
const nonce = await callPermit.nonces(userSigner.address);
const message = {
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contract
value: 0,
data,
gaslimit: gasEstimate + 50000n,
nonce,
deadline: '1714762357000', // Randomly created deadline in the future
};
Remember
Never store your private keys in a JavaScript or TypeScript file.
So far, we've created the domain separator, defined the data structure of our EIP-712 message, and assembled the data for the message. Next, we'll need to request the signature for our EIP-712 typed message!
Get Signature for EIP-712 Typed Messages¶
For this next step, we're going to use our Ethers signer and the signer.signTypedData
function to prompt our users to sign the EIP-712 typed message we've assembled. This signature will allow the third-party account for transaction fees to call the dispatch
function of the Call Permit Precompile. The third-party account will pay the transaction fees for us, and a voyage will be bought on our behalf!
The signTypedData
function will calculate a signature for our data using the following calculation:
sign(keccak256("\x19\x01" ‖ domainSeparator ‖ hashStruct(message)))
The components of the hash can be broken down as follows:
- \x19 - makes the encoding deterministic
- \x01 - the version byte, which makes the hash compliant with EIP-191
- domainSeparator - the 32-byte domain seperator, which was previously covered and can be easily retrieved using the
DOMAIN_SEPARATOR
function of the Call Permit Precompile - hashStruct(message) - the 32-byte data to sign, which is based on the typed data structure and the actual data. For more information, please refer to the EIP-712 specification
Now that we have an understanding of what the signTypedData
function does, we can go ahead and pass in the data we've assembled in the previous sections:
const signature = await signer.signTypedData(
domain, // The domain separator
types, // The typed data structure
message, // The message data
);
console.log(`Signature hash: ${signature}`);
A hash of the signature will print to the terminal. We'll use the user's signature to dispatch the permit from the third-party account using the Call Permit Precompile's dispatch
function in the next section.
Dispatch a Signed EIP-712 Message¶
Before an EIP-712 signed message can be dispatched, we'll need to get the signature-related parameters, v
, r
, and s
, from the signed message. The signTypedData
function returned a hex string that contains each of these values, but to easily get these values individually, we're going to use Ethers' Signature.from
function. This will create a new instance of Ether's Signature class, which will allow us to easily grab the v
, r
, and s
values that we need in order to use the dispatch
function.
const formattedSignature = ethers.Signature.from(signature);
Now that we can individually access the v
, r
, and s
arguments needed to dispatch the permit, we can call the dispatch
function of the Call Permit Precompile. The arguments passed to the dispatch
function must be the exact same arguments that were passed in for the value
parameter of the signTypedData
function. You'll send the following function using an account associated with your dApp as the signer (not the signer associated with the user), and it will dispatch the permit that the user signed:
const dispatch = await callPermit.dispatch(
message.from,
message.to,
message.value,
message.data,
message.gaslimit,
message.deadline,
formattedSignature.v,
formattedSignature.r,
formattedSignature.s,
);
await dispatch.wait();
console.log(`Transaction hash: ${dispatch.hash}`);
View the complete script
import { ethers } from 'ethers';
import abi from './callPermitABI.js';
import cartographerAbi from './cartographerAbi.js';
const providerRPC = {
moonbeam: {
name: 'moonbeam',
rpc: 'INSERT_RPC_API_ENDPOINT', // Insert your RPC URL here
chainId: 1284, // 0x504 in hex,
},
};
const provider = new ethers.JsonRpcProvider(providerRPC.moonbeam.rpc, {
chainId: providerRPC.moonbeam.chainId,
name: providerRPC.moonbeam.name,
});
// Insert your own signer logic or use the following for testing purposes.
// For demo purposes only. Never store your private keys in a JavaScript file
const userSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
const thirdPartyGasSigner = new ethers.Wallet('INSERT_PRIVATE_KEY', provider);
const domain = {
name: 'Call Permit Precompile',
version: '1',
chainId: 1284,
verifyingContract: '0x000000000000000000000000000000000000080a',
};
const types = {
CallPermit: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'data', type: 'bytes' },
{ name: 'gaslimit', type: 'uint64' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
};
const cartographerInterface = new ethers.Interface(cartographerAbi);
const data = cartographerInterface.encodeFunctionData('buyVoyages', [
0n, // Voyage type: Easy
1n, // Number of voyages to buy
'0x72A33394f0652e2Bf15d7901f3Cd46863d968424', // Voyage V2 contract
]);
const gasEstimate = await provider.estimateGas({
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contract
data,
});
const callPermit = new ethers.Contract(
'0x000000000000000000000000000000000000080a', // Call Permit contract
abi,
thirdPartyGasSigner
);
const nonce = await callPermit.nonces(userSigner.address);
const message = {
from: userSigner.address,
to: '0xD1A9bA3e61Ac676f58B29EA0a09Cf5D7f4f35138', // Cartographer V1 contract
value: 0,
data,
gaslimit: gasEstimate + 50000n,
nonce,
deadline: '1714762357000', // Randomly created deadline in the future
};
const signature = await userSigner.signTypedData(domain, types, message);
console.log(`Signature hash: ${signature}`);
const formattedSignature = ethers.Signature.from(signature);
// This gets dispatched using the dApps signer
const dispatch = await callPermit.dispatch(
message.from,
message.to,
message.value,
message.data,
message.gaslimit,
message.deadline,
formattedSignature.v,
formattedSignature.r,
formattedSignature.s
);
await dispatch.wait();
console.log(`Transaction hash: ${dispatch.hash}`);
Remember
Never store your private keys in a JavaScript or TypeScript file.
Once the transaction goes through, the gas fees will be deducted from the GLMR balance of the third-party account, 1 TMAP will be deducted from the user's balance, and a voyage will be purchased on behalf of the user. As you can see, the user doesn't need to worry about having a GLMR balance!
You can view the transaction for the example that we covered in this guide on Moonscan. You'll notice the following:
- The
from
account is the third-party account:0xd0ccb8d33530456f1d37e91a6ef5503b5dcd2ebc
- The contract interacted with is the Call Permit Precompile:
0x000000000000000000000000000000000000080a
- A TMAP has been deducted from the user's account:
0xa165c7970886d4064b6cec9ab1db9d03202bda37
- A voyage with ID 622646 has been sent to the user's account
And that's it! Congrats! You've learned how to implement gasless transactions using the Call Permit Precompile on Moonbeam. You can now adapt the logic in this tutorial for your own dApp!
| Created: April 5, 2023