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.

💡 Articles
13 December 2023
Article Image

<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 using populateTransaction 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.