Sponsoring user’s gas fees – Implementing gas-less transactions
Learn how to implement gas-less transactions to facilitate the user onboarding process for your dapp.
<br></br>You’re a web2 company transitioning to web3, you want all your existing customers to onboard your new web3 dApp free of cost without burdening them with complex processes and gas fees. You decided to sponsor their gas-fees, making their transactions gas-less. But the challenge is how do we make onboarding user-friendly, keeping a good balance between user experience and security. In this article we’re going to build an end-to-end solution to this challenge. You can find the full source code in this repository and deployed frontend at this link gasless-transactions.humayun.io.<br></br>
<br></br>Our solution to gas-less transactions is to forward user transactions through a company wallet. This company wallet pays the gas-fee for user transactions. Our dApp contract must be configured to support these forwarded transactions.<br></br>
<br></br>We need three components for this implementation. A forwarder endpoint for which we’re gonna use NextJs route. A smart contract, we’re going to write it in Solidity and frontend which we’ll build in NextJs.<br></br>
Designing Frontend
Whenever a user wants to perform any action on smart contract. We follow this process:
- Construct the transaction data, in our case we’re calling
mintNFT
method of contract. - Get the
calldata
of transaction usingpopulateTransaction
instead of sending it. - Ask the user to sign this
calldata
so we can verify the authenticity on forwarder side. - Send the transaction to forward API endpoint.
/// app/page.tsx
// ...
const contract = new ethers.Contract(Collectible.address, Collectible.abi);
export default function Home() {
// ...
const handleMint = useCallback(async () => {
const tx = await contract["mintNFT"].populateTransaction();
const calldata = tx.data;
let signature;
try {
signature = await signMessage({ message: calldata });
} catch (er) {
alert(er);
return;
}
const body: IForwardRequest = {
calldata: calldata,
sender: address!,
sign: signature,
};
try {
setSubmitting(true);
await axios.post("/forward", body);
alert("NFT Minted!");
} catch (er) {
console.warn(er);
alert("Something went wrong. Check console logs");
} finally {
setSubmitting(false);
}
}, [address]);
return ...
}
Building Trusted Forwarder
Forwarder is just an API endpoint. The responsibility of forwarder is to forward the transaction from user to the contract, paying the gas fee of transaction. In NextJs it’s going to be a route.
/// app/forward/route.ts
// ...
const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
const forwarderWallet = new ethers.Wallet(
process.env.FORWARDER_WALLET_PRIVATE_KEY as string,
provider
);
export async function POST(request: NextRequest) {
// We're going to receive calldata, sender address and a signature
// of calldata signed by sender.
const { calldata, sender, sign }: IForwardRequest = await request.json();
// Todo: verify signature
// Todo: pass the sender in transaction and send
return NextResponse.json({ ok: true });
}
Verifying user and their intent
To make sure that the sender
in POST request has the private keys and they allowed this transaction by signing the calldata, we verify the signature.
/// app/forward/route.ts
// ...
export async function POST(request: NextRequest) {
// We're going to receive calldata, sender address and a signature
// of calldata signed by sender.
const { calldata, sender, sign }: IForwardRequest = await request.json();
// Recover signer address
const signerAddress = ethers.verifyMessage(calldata, sign);
// Verify signature
if (signerAddress !== sender) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Todo: pass the sender in transaction and send
return NextResponse.json({ ok: true });
}
Passing the original sender to contract
As the forwarder is going to submit the transactions to contract, msg.sender
would point to forwarder address instead of original sender. As a result, if a user mints the transaction, the contract will mint to the relayer address instead of user address. We could modify functions in our contract to take original sender as argument but then we won’t be supporting standard interfaces and creating additional complexities.
The most straight forward way is to include the original sender address at the end of the calldata
. We can modify our contract to read message sender from the calldata if the transaction is submitted by our forwarder.
/// app/forward/route.ts
// ...
export async function POST(request: NextRequest) {
// We're going to receive calldata, sender address and a signature
// of calldata signed by sender.
const { calldata, sender, sign }: IForwardRequest = await request.json();
// Recover signer address
const signerAddress = ethers.verifyMessage(calldata, sign);
// Verify signature
if (signerAddress !== sender) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
// Append sender address (without 0x prefix) to calldata
const forwardCalldata = calldata.concat(sender.slice(2));
// Send the transaction
const tx = await forwarderWallet.sendTransaction({
to: Collectible.address,
data: forwardCalldata,
});
return NextResponse.json({ txHash: tx.hash });
}
Crafting smart contract
Our smart contract is going to be standard ERC721 contract but with an additional feature to read message sender from calldata
if the transaction is submitted by our forwarder.
Basic NFT contract
This is our basic ERC721 contract with mintNFT
public function which we want our users to invoke without paying any fee.
/// contracts/Collectible.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
contract Collectible is ERC721 {
uint256 internal nextTokenId = 1;
constructor() ERC721("Collectible", "$CLB") {}
function mintNFT() public {
address sender = _msgSender();
_safeMint(sender, nextTokenId);
nextTokenId += 1;
}
function _msgSender() internal view override returns (address signer) {
signer = msg.sender;
}
}
We’re getting message sender from _msgSender
internal method which right now is only returning msg.sender
but we’ll modify this method to read from calldata
if the the transaction is from our trusted forwarder.
Trusted forwarder
Before we read message sender from calldata
we must check if the transaction is submitted by forwarder we trust. Because only this forwarder appends the message sender to the call data. We modify contract to add a state variable for trusted forwarder address and a utility function to check if the transaction sender is our forwarder.
// ...
contract Collectible is ERC721 {
uint256 internal nextTokenId = 1;
address immutable _trustedForwarder;
constructor(address trustedForwarder) ERC721("Collectible", "$CLB") {
_trustedForwarder = trustedForwarder;
}
// ...
function isTrustedForwarder(address forwarder) public view returns (bool) {
return forwarder == _trustedForwarder;
}
}
Reading original sender from calldata
We can now modify _msgSender
to read the original sender address from calldata.
// ...
function _msgSender() internal view override returns (address signer) {
signer = msg.sender;
if (msg.data.length >= 20 && isTrustedForwarder(signer)) {
assembly {
// Read last 20 bytes of calldata
// and right shift these bytes because
// calldataload always reads 32 bytes (evm things)
signer := shr(96, calldataload(sub(calldatasize(), 20)))
}
}
}
// ...
And voila! our gas-less transactions flow is complete, and our contract is also ERC-2771 standard compliant. Our users can now mint their NFTs with 0 balance in their wallet.
Conclusion
We’ve implemented end-to-end gas-less transactions solution for our users so they can interact with our dApp without any financial barriers. Rather than burdening users with the task of paying fees, we take care of their transaction fees using our forwarder. This solution can be extremely beneficial, especially when your web2 user base is migrating to your new web3 app or during an exclusive minting event for your loyal customers. There are a lot of improvements which can be done specially the EIP-712 typed signature so the users know what they’re signing instead of just raw hex string.
We can extend this flow to allow silent signing of transactions using delegated device wallets, so users won’t have to sign each transaction individually, this is useful specially in video games where a player has to perform bunch of actions.
This post is written by Humayun Javed. Blockchain Developer at Antematter.io.