Feb 10, 2025

Building an Open-Source Paymaster for Gasless Transactions

Building an Open-Source Paymaster for Gasless Transactions

In our two previous posts, we demonstrated how to use a paymaster service provider like Pimlico to (1) swap between the stablecoins USDC and SBC using Uniswap V3 and (2) mass pay SBC to hundreds of users at once—all without needing to pay for gas.

What if you wanted to build your custom paymaster and modify the service to fit your needs—giving you 100% control of your infrastructure? This blog post shows a simple implementation of how you can start doing just that in your local development environment.

If you'd like to follow along, you can find our code here.

Know the tech

Let's begin by examining docker-compose.yml. It contains four services: anvil, alto, mock-paymaster, and mock-app.

Anvil

Anvil is a local testnet Ethereum blockchain node that comes with Foundry. We've locked it to a specific version that's compatible with our other components. The node runs on the standard localhost port 8545 at localhost. You can monitor your local chain using Foundry's companion tools like cast and forge.

Alto

Alto is Pimlico's bundler implementation. According to the ERC-4337 proposal, a bundler is a node that collects multiple user operations and submits them to a singleton Entry Point contract. We'll use Pimlico's main image from their repo without modifications.

Paymaster

There are two main classes of Paymasters: Verifying Paymasters and Token Paymasters. A Verifying Paymaster uses an offchain, external service to decide whether to pay for user operations. A Token Paymaster covers gas fees in exchange for ERC20 tokens (instead of native ETH). For this demo, we'll use a simple Verifying Paymaster.

A working Verifying Paymaster needs a deployed paymaster contract that implements two essential functions: validatePaymasterUserOp and postOp. We've implemented the simplest "approve all" version in Paymaster.sol. When the mock-verifying-paymaster docker container runs, it compiles this contract using hardhat. This compiled bytecode is used to deploy the contract to Anvil using a Deterministic Deployer, which lets you keep the same deployment address given the same contract source code and a salt.

Our newly deployed Paymaster smart contract needs to have a deposit at the singleton Entry Point contract. We fund it with 50 ETH from one of the pre-generated accounts from Anvil.

A Verifying Paymaster also needs a compatible "paymaster service." If your paymaster contract uses a signature scheme, this service implements the logic and conditions for when you'll pay for gas. It provides a signature that the Entry Point contract uses to call your paymaster smart contract's validatePaymasterUserOp function. The service must handle two JSON-RPC endpoints: pm_getPaymasterStubData, which returns stub values for paymaster-related fields in an unsigned user operation for gas estimation, and pm_getPaymasterData, which returns values for paymaster-related fields in a signed user operation.

App

Our mock application will demonstrate how to use our local account abstraction setup to allow our custom paymaster to handle the gas fees. For our user operation, we’ve chosen to mint a SampleNFT, which we have placed in the same directory as the Paymaster contract and which is conveniently compiled during the Verification Paymaster setup step. We’ve also copied over its ABI for convenience. Let’s walk through index.ts to see how to set up this user operation.

We have the environment variables given to us by Docker, which allows us to connect to the other three components locally. Notice we can hardcode the NFT contract address as it has been derived deterministically.

// Environment variables defined in docker-compose.yml
const ANVIL_RPC = process.env.ANVIL_RPC!;
const BUNDLER_RPC = process.env.ALTO_RPC!;
const PAYMASTER_RPC = process.env.PAYMASTER_RPC!;

// The address of the NFT contract we deployed with mock-paymaster
const NFT_CONTRACT_ADDRESS = "0x3755982e69143c1c05C8cC13EF220ec96e382b4e";

We then define the local Anvil chain using Anvil’s default chain ID (31337) and create a public client in viem. This public client is our way to read states in a smart contract.

// Define our local anvil chain
const localAnvil = defineChain({
  id: 31337,
  name: "local",
  nativeCurrency: {
    decimals: 18,
    name: "Ether",
    symbol: "ETH",
  },
  rpcUrls: {
    default: { http: [ANVIL_RPC] },
  },
});

// Create a public client for reading the local anvil chain.
const localPublicClient = createPublicClient({
  chain: localAnvil,
  transport: http(localAnvil.rpcUrls.default.http[0]),
}) as PublicClient;

After making one of the pre-generated private keys as the owner, we create a Smart Account using the reference “SimpleAccount” implementation with the convenience toSimpleSmartAccount function provided by Pimlico, as well as pass in the local public client we created.

// Create a simple smart account using the owner and the entry point v0.7 address.
const simpleAccount = await toSimpleSmartAccount({
  client: localPublicClient,
  owner: owner,
  entryPoint: {
    address: entryPoint07Address,
    version: "0.7",
  },
}); 

console.log(`Smart Account Address: ${simpleAccount.address}`);

Behind the scenes, a smart contract wallet (SimpleAccount) gets created via a SimpleAccountFactory, resulting in deploying its initialization code to a deterministic “new” address. We log this out to the console so we can keep track of this later. Given that we pass in the same owner and use the same smart account implementation, the smart account address will be identical.

Next, we create a paymaster client using createPaymasterClient from viem and passing in our Verifying Paymaster’s URL as transport. Finally, we create a “Smart Account Client” which has the combined ability of (1) a smart contract wallet, (2) knowledge of the chain we’re using, (3) a connection to the bundler, and (4) a connection to the paymaster client.

// Create a client for our custom paymaster service.
const pmClient = createPaymasterClient({
  transport: http(PAYMASTER_RPC),
});

// Create the smart account client with owner's wallet and paymasterClient
const smartAccountClient = createSmartAccountClient({
  account: simpleAccount,
  chain: localAnvil,
  bundlerTransport: http(BUNDLER_RPC),
  paymaster: pmClient,
  userOperation: {
    estimateFeesPerGas: async () => {
      return {
        maxFeePerGas: 10n,
        maxPriorityFeePerGas: 10n,
      };
    },
  },
});

At this point, all there’s left to do is to construct the user operation in a way that’s compatible with the smartAccountClient.

In our example, we want to mint the SampleNFT that we deployed in the Verifying Paymaster setup step. Checking SampleNFT.sol, we see it asks for a recipient address.


So we encode our data and use our smartAccountClient to send the transaction (user operation) to its bundler. The bundler will know what to do and interact with your paymaster service in order to send the user operation to the Entry Point in a ERC4337-compliant format.

// Encode the calldata for mint function
const callData = encodeFunctionData({
  abi: nftAbi,
  functionName: "mintNFT",
  args: [smartAccountClient.account.address],
});

// Now we send our user operation (mint an NFT to the smart account).
const txHash = await smartAccountClient
  .sendTransaction({
    account: smartAccountClient.account,
    to: NFT_CONTRACT_ADDRESS,
    data: callData,
    value: BigInt(0),
  })
  .catch((error) => {
    console.error("\\x1b[31m", `❌ ${error.message}`);
    process.exit(1);
  });

We can write some code to verify that the user operation was successful by checking the owner of the first NFT minted.

// We can check the owner of the NFT by calling the `ownerOf` function.
const ownerOfCallData = encodeFunctionData({
  abi: nftAbi,
  functionName: "ownerOf",
  args: [BigInt(1)], // tokenId starts at 1
});

const ownerOfNft = await localPublicClient.call({
  to: NFT_CONTRACT_ADDRESS,
  data: ownerOfCallData,
});

const ownerOfNftAddress = decodeFunctionResult({
  abi: nftAbi,
  functionName: "ownerOf",
  data: ownerOfNft.data as Hex,
});

console.log("                                       ");
console.log("                                       ");
console.log("                                       ");
console.log("***************************************");
console.log(`Owner of NFT Address : ${ownerOfNftAddress}`);
console.log(`Smart Account Address: ${smartAccountClient.account.address}`);
console.log("***************************************");
console.log("                                       ");
console.log("                                       ");
console.log("                                       ");

Finally, we can run the demo using docker compose up and see the results for ourselves!

Here are the 10 accounts and their private keys from your local anvil node.

Scrolling down a bit, we can see the SBC Paymaster being deployed and funded.

We can also see SampleNFT being deployed.

After the alto bundler finishes building, we see our Smart Account Address.

After smartAccountClient’s call to sendTransaction, we can see that alto receives a transaction hash after getting confirmation that the user operation has been included.

The transactionHash gets passed back to our app. Finally, we verify the the owner of the first NFT minted is in fact our Smart Account address.

And that’s a wrap. We took a quick journey through how to set up a local account abstraction development environment, where you can freely swap out to use your own custom paymaster contract and its related paymaster service. We also saw an example user operation of minting an NFT and verified that it worked.

Next steps

In an upcoming post, we'll demonstrate a testnet implementation of SBC's custom paymaster and bundler. Stay tuned.

If you have thoughts, or improvements, or would like to contribute to the project, please let us know via Telegram.

Join the SBC

ecosystem

Partner with us to build a global, free, and accessible payment network.

Join the SBC

ecosystem

Partner with us to build a global, free, and accessible payment network.

Join the SBC

ecosystem

Partner with us to build a global, free, and accessible payment network.

Join the SBC

ecosystem

Partner with us to build a global, free, and accessible payment network.