Skip to main content

Gasless Transactions on Base using Base Paymaster

Still trying to onboard users to your app? Want to break free from the worries of gas transactions and sponsor them for your users on Base? Look no further!

Base transaction fees are less than a penny, but the concept of gas can be confusing for new users. Abstract this away and improve your UX by using the Base Paymaster. The Base Paymaster allows you to batch multi-step transactions and create custom gasless experiences. Sponsor up to $10k monthly on mainnet (unlimited on testnet). To request an increase in limit, reach out in Discord.

Objectives

  • Configure security measures to ensure safe and reliable transactions
  • Manage and allocate resources for sponsored transactions
  • Subsidize transaction fees for users, enhancing the user experience by making transactions free for them
  • Set up and manage sponsored transactions on various schedules, including weekly, monthly, and daily cadences

Prerequisites

This tutorial assumes you have a Coinbase Cloud Developer Platform account. If not, sign up on the CDP site.

Coinbase CDP account

This is your access point to the Coinbase Cloud Developer Platform, where you can manage projects and utilize tools like the Paymaster.

Familiarity with Smart Accounts and ERC 4337

Understanding Smart Accounts and the ERC 4337 standard is crucial as they are the backbone of executing advanced transaction patterns and account abstractions on the Ethereum network.

Foundry

Foundry is a development environment, testing framework, and smart contract toolkit for Ethereum. It's essential for deploying and testing smart contracts.

Set Up a Base Paymaster & Bundler

In this section, you will configure a Paymaster to sponsor payments on behalf of a specific smart contract for a specified amount. First, navigate to the Coinbase Developer Platform, create or select your project, and click on the Paymaster tool from the left navigation. Then, go to the Configuration tab and save the RPC URL to your clipboard, which will be needed for later steps in your index.js file.

Navigate to the Coinbase Developer Platform:

Create or select your project of choice from the upper left corner of your screen.

cdp-home.png

Click on the Paymaster tool on the left navigation:** Paymaster Tool

cdp-paymaster-tool.png

Click on Configuration at the top of the screen

cdp-paymaster-tool.png

Save the RPC URL to your paymaster to your clipboard. You will need it in your index.js file in a later step.

Allowlist a Sponsorable Contract

Sponsoring transactions are beneficial to easing the onboarding and UX of a decentralized application. As a developer, you want to ensure this is done in the most secure and cost-effective manner. Start by allowlisting a contract of your choice.

Select Base Mainnet

From the configuration page, select Base Mainnet from the dropdown menu. Then, enable your paymaster by clicking on the toggle button to the right of the screen.

cdp-project-selection.png

Allowlist the NFT contract and the mintTo functions:

Click the Add button to add a contract.

Add the following contract: 0x83bd615eb93eE1336acA53e185b03B54fF4A17e8

Put mintTo(address) as the function to allowlist then click Save at the bottom of the page.

cdp-allowlist-contracts.png

Use your own contract

You will be using this simple NFT contract deployed on Base mainnet for our example. Feel free to use a contract of your choice.

Global & Per User Limits:

Scroll down to the “Per User Limit” section

You can set a Paymaster policy that specifies either a specific dollar amount or a number of UserOperations. You can enable a 'limit cycle' that allows this policy to refresh every week. This feature enables applications to sponsor smart wallets on a weekly, daily, or monthly basis, either by amount or by number of UserOperations.

Set the max USD to $0.05 and the max UserOperation to 1 to create a policy with a maximum sponsorship limit of $0.05 and a maximum of 1 UserOperation per user.

Limit Cycles

Limit Cycles enables applications to sponsor smart wallets on a weekly, daily, or monthly basis, either by amount or by number of UserOperations. These limits reset based on the cadence selected in the dropdown menu.

Set the Global limit:

This setting allows you to define the maximum amount of gas or USD that can be sponsored globally across all users. It helps control the total expenditure on gas sponsorship, ensuring that the allocated budget is not exceeded. For example, setting a global limit of $1 means that the Paymaster will sponsor transactions until the total gas cost reaches $1.

Set your global policy to be $.07 by entering the amount in the text field and clicking the Save button.

cdp-allowlist-contracts.png

Test Your Paymaster policy

You will now test the policy that was created.

The Use of Foundry

In this tutorial, You use foundry to create two key pairs that will allow us to create Smart Accounts that you will sponsor transactions on behalf of. You will not need to send any funds (ETH) to these wallets. You may use private keys of wallets you own as an alternative.

Foundry depends on Rust to work. If you do not have rust installed:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

For more information, see the Foundry Book installation guide.

Open a terminal and install Foundry by running:

curl -L https://foundry.paradigm.xyz | bash

Install the Foundry toolchain installer:

foundryup

Create a directory named sponsored_transactions:

mkdir sponsored_transactions

Change into the new directory, initialize a node project, install two dependencies viem and permissionless and create a file titled index.js:

cd sponsored_transactions
npm init es6
npm install permissionless
npm install viem
touch index.js

In the index.js file, import the dependencies from viem and permissionless. These dependencies will allow us to connect to Base, create our smart accounts, initialize a paymaster, and send our encoded data to the network.

import { http, createPublicClient, encodeFunctionData } from 'viem';
import { base } from 'viem/chains';
import { createSmartAccountClient } from 'permissionless';
import { privateKeyToSimpleSmartAccount } from 'permissionless/accounts';
import { createPimlicoPaymasterClient } from 'permissionless/clients/pimlico';

Set Constants for Your Paymaster & Bundler endpoint

Find your Paymaster & Bundler endpoint

The Paymaster & Bundler endpoint is the URL for your Coinbase Developer Platform (CDP) Paymaster.

This was saved in the previous section and follows this format: https://api.developer.coinbase.com/rpc/v1/base/<SPECIAL-KEY>

Navigate to the Paymaster Tool and select the Configuration tab at the top of the screen to obtain your RPC URL.

Secure your endpoints

You will create a constant for our Paymaster & Bundler endpoint obtained from cdp.portal.coinbase.com. The most secure way to do this is by using a proxy. For the purposes of this demo, hardcode it into our index.js file. For product, we highly recommend using a proxy service.

You will also need the address of the entrypoint contract for Base. A full list of entrypoint contracts and their addresses can be found here.

Add the following to lines of code after your import statements:

const rpcUrl = 'https://api.developer.coinbase.com/rpc/v1/base/<SPECIAL-KEY>' //Paymaster & Bundler endpoint
const baseEntryPoint = '0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789';
const baseFactoryAddress = '0x15Ba39375ee2Ab563E8873C8390be6f2E2F50232';

Now, create a public client. Public clients enable our application to interact with the Ethereum blockchain. They use JSON-RPC API methods to perform actions such as retrieving block numbers, transactions, and reading from smart contracts. Learn more about this on viem.

Initialize a public client named publicClient and set the chain to base and the transport to our rpcURL variable:

const publicClient = createPublicClient({
chain: base,
transport: http(rpcUrl),
});

Create Two Smart Accounts

Now, you are going to create two SimpleAccounts for this demonstration. Collectively, these two accounts will help us test the security policies you set at both the global level and the per-account user operation level.

To do so, you'll first need two private keys that will be used to create the Smart Accounts using Foundry. Open a terminal and run the following command twice:

cast wallet new

You will see something like this:

> cast wallet new
Successfully created new keypair.
Address: 0xD440D74620542...D6F005cfD9
Private key: 0x01c9720c1dfa3c9...634793138897


> cast wallet new
Successfully created new keypair.
Address: 0x5f8e5bC8620542...D6F005cfD9
Private key: 0xbcd6fbc1dfa3c9...634793138897

Use the newly generated private keys as the parameter to the privateKeyToSimpleSmartAccount function and set the factory address to

Secure your private keys

Be sure to store your private keys somewhere safe. Committing these to a public code repository will give anyone access to your smart accounts.

Saving them to a .env file is safest.

const simpleAccount = await privateKeyToSimpleSmartAccount(publicClient, {
privateKey: '<Your first key>',
factoryAddress: baseFactoryAddress, //Base v0.6
entryPoint: baseEntryPoint,
});

const simpleAccount2 = await privateKeyToSimpleSmartAccount(publicClient, {
privateKey: '<Your second key>',
factoryAddress: baseFactoryAddress, //Base v0.6
entryPoint: baseEntryPoint,
});

Let’s break down what’s happening here: Private key : the private key to the wallet that your created either using Foundry or a wallet that you own. Either is fine.

Factory address is the address to the smart account factory deployed on base. Account factories are smart contracts that facilitate the creation of new wallet contracts. You make view a more comprehensive list of factory addresses on Alchemy.

Entrypoint is the entrypoint contract for Base.

Find the correct entrypoint address

Make an JSON RPC request to a node using the `` method to get the correct entrpoint contract. Here's an example for Base using a Coinbase Base Node:

curl --request POST \
--url https://api.developer.coinbase.com/rpc/v1/base/<Your-Key> \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"id": 1, "jsonrpc": "2.0", "method": "eth_supportedEntryPoints", "params": []}'

Initialize Paymaster and Create Accounts

Initialize the paymaster and smart account client for both smart accounts:

const cloudPaymaster = createPimlicoPaymasterClient({
chain: base,
transport: http(rpcUrl),
entryPoint: baseEntryPoint,
});

const smartAccountClient = createSmartAccountClient({
account: simpleAccount,
chain: base,
bundlerTransport: http(rpcUrl),
middleware: {
sponsorUserOperation: cloudPaymaster.sponsorUserOperation,
},
});

const smartAccountClient2 = createSmartAccountClient({
account: simpleAccount2,
chain: base,
bundlerTransport: http(rpcUrl),
middleware: {
sponsorUserOperation: cloudPaymaster.sponsorUserOperation,
},
});

To sponsor transactions you will need the ABI and the address of the contracts you want to support.

Sponsor any contract

Feel free to use your own contract to interact with the Paymaster. For learning purposes, you will interact with an ERC-721 contract deployed on the Base mainnet. You will call the mintTo function, which takes an address as its only parameter.

You will be interacting with the NFT + ABI from a simple NFT contract deployed at: 0x83bd615eb93eE1336acA53e185b03B54fF4A17e8

Copy and paste the NFT's ABI into index.js

const abi = [
{
type: 'constructor',
inputs: [
{ name: '_name', type: 'string', internalType: 'string' },
{ name: '_symbol', type: 'string', internalType: 'string' },
],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'approve',
inputs: [
{ name: 'spender', type: 'address', internalType: 'address' },
{ name: 'id', type: 'uint256', internalType: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'balanceOf',
inputs: [{ name: 'owner', type: 'address', internalType: 'address' }],
outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'currentTokenId',
inputs: [],
outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'getApproved',
inputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
outputs: [{ name: '', type: 'address', internalType: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'isApprovedForAll',
inputs: [
{ name: '', type: 'address', internalType: 'address' },
{ name: '', type: 'address', internalType: 'address' },
],
outputs: [{ name: '', type: 'bool', internalType: 'bool' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'mintTo',
inputs: [{ name: 'recipient', type: 'address', internalType: 'address' }],
outputs: [{ name: '', type: 'uint256', internalType: 'uint256' }],
stateMutability: 'payable',
},
{
type: 'function',
name: 'name',
inputs: [],
outputs: [{ name: '', type: 'string', internalType: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'ownerOf',
inputs: [{ name: 'id', type: 'uint256', internalType: 'uint256' }],
outputs: [{ name: 'owner', type: 'address', internalType: 'address' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'safeTransferFrom',
inputs: [
{ name: 'from', type: 'address', internalType: 'address' },
{ name: 'to', type: 'address', internalType: 'address' },
{ name: 'id', type: 'uint256', internalType: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'safeTransferFrom',
inputs: [
{ name: 'from', type: 'address', internalType: 'address' },
{ name: 'to', type: 'address', internalType: 'address' },
{ name: 'id', type: 'uint256', internalType: 'uint256' },
{ name: 'data', type: 'bytes', internalType: 'bytes' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'setApprovalForAll',
inputs: [
{ name: 'operator', type: 'address', internalType: 'address' },
{ name: 'approved', type: 'bool', internalType: 'bool' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'function',
name: 'supportsInterface',
inputs: [{ name: 'interfaceId', type: 'bytes4', internalType: 'bytes4' }],
outputs: [{ name: '', type: 'bool', internalType: 'bool' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'symbol',
inputs: [],
outputs: [{ name: '', type: 'string', internalType: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'tokenURI',
inputs: [{ name: 'id', type: 'uint256', internalType: 'uint256' }],
outputs: [{ name: '', type: 'string', internalType: 'string' }],
stateMutability: 'view',
},
{
type: 'function',
name: 'transferFrom',
inputs: [
{ name: 'from', type: 'address', internalType: 'address' },
{ name: 'to', type: 'address', internalType: 'address' },
{ name: 'id', type: 'uint256', internalType: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
type: 'event',
name: 'Approval',
inputs: [
{
name: 'owner',
type: 'address',
indexed: true,
internalType: 'address',
},
{
name: 'spender',
type: 'address',
indexed: true,
internalType: 'address',
},
{ name: 'id', type: 'uint256', indexed: true, internalType: 'uint256' },
],
anonymous: false,
},
{
type: 'event',
name: 'ApprovalForAll',
inputs: [
{
name: 'owner',
type: 'address',
indexed: true,
internalType: 'address',
},
{
name: 'operator',
type: 'address',
indexed: true,
internalType: 'address',
},
{ name: 'approved', type: 'bool', indexed: false, internalType: 'bool' },
],
anonymous: false,
},
{
type: 'event',
name: 'Transfer',
inputs: [
{ name: 'from', type: 'address', indexed: true, internalType: 'address' },
{ name: 'to', type: 'address', indexed: true, internalType: 'address' },
{ name: 'id', type: 'uint256', indexed: true, internalType: 'uint256' },
],
anonymous: false,
},
];

Encode the Function Call

Encode the mintTo function call with the parameter being the address of the first smart wallet

const callData = encodeFunctionData({
abi: abi,
functionName: 'mintTo',
args: [smartAccountClient.account.address],
});

Create a function called sendTransactionFromAccount1 in order to send the transaction request and store the response in a variable named txHash:

async function sendTransactionFromAccount1() {
try {
const txHash = await smartAccountClient.sendTransaction({
account: smartAccountClient.account,
to: '0x83bd615eb93eE1336acA53e185b03B54fF4A17e8',
data: callData,
value: 0n,
});

console.log('✅ Transaction successfully sponsored for account 1!');
console.log(`🔍 View on Etherscan: https://basescan.org/tx/${txHash}`);
} catch (error) {
console.log('Transaction failed from account 1: ', error);
}
}

Call the function at the bottom of the index.js file:

async function sendTransactionFromAccount1() {
// previous lines of code ...
}

sendTransactionFromAccount1();

Run the following line of code from your terminal, ensuring you are in the root folder:

node index.js

The first attempt should be successful with the following output:

✅ Transaction successfully sponsored!
🔍 View on Etherscan: https://basescan.org/tx/0x1439431b0bf333f8106bc1815a532fdec18d62c3ee1c07a10d07711c6e5a2e56

Run the script again but this time you should get an error that contains a code and message similar to this:

{
"code": -32001,
"message": "request denied - rejected due to maximum per address transaction count reached"
}

Awesome! Errors, in this case, are good. This means our Paymaster policy works and prevents unwanted sponsorship.

Navigate back to your CDP portal and update the number of UserOperations to 2.

Give it about 10-15 minutes for the changes to take affect then run node index.js again to send a new transaction.

It should return a successful prompt:

✅ Transaction successfully sponsored for account 1!
🔍 View on Etherscan: https://basescan.org/tx/0x1439431b0bf333f8106bc1815a532fdec18d62c3ee1c07a10d07711c6e5a2e56

Looking back at our Paymaster configuration, you'll see that you've used about $0.03 of our $0.05 global limit, and for account 1, you've hit our UserOperation limit.

Now, let's send another transaction, this time using the second account.

Before you do that, let's update the per-wallet UserOperation limit to 10. Since gas is cheap on Base, you may need to do a few transactions before you hit our global limit of $0.07.

Increase the global limit

You may get a warning to increase the global limit and that is okay for now...

[Image-of-policy]

Navigate back to the UI and update the policy to 10 and hit the Save button.

[image of updated policy]

Testing Global Limits

Back in your code editor, open the index.js file and create a function called sendTransactionFromAccount2 and run place it at the bottom on the index.js file:

// previous lines of code ...

async function sendTransactionFromAccount2() {
try {
const txHash2 = await smartAccountClient2.sendTransaction({
account: smartAccountClient2.account,
to: '0x83bd615eb93eE1336acA53e185b03B54fF4A17e8',
data: callData,
value: 0n,
});

console.log('✅ Transaction successfully sponsored for account 2!');
console.log(`🔍 View on Etherscan: https://basescan.org/tx/${txHash2}`);
} catch (error) {
console.log('Transaction failed from account 2: ', error);
}
}

// sendTransactionFromAccount1();
sendTransactionFromAccount2();

Run the sendTransactionFromAccount2() function a few times to help reach your global limit.

You should eventually encounter an error similar to this:

{
"code": -32001,
"message": "request denied - rejected due to max global usd spend limit reached"
}

Congrats! Your global limit has been hit.

Navigate back to the cloud portal and increase the global policy to your choosing. Run node index.js again and you should be able to successfully sponsor a transaction.

Conclusion

In this tutorial, you learned to set up and configure the Base Paymaster on the Coinbase Developer Platform, allowing you to sponsor gasless transactions for your users. This enhances the user experience by abstracting away gas fees and making transactions more accessible and cost-effective.


We use cookies and similar technologies on our websites to enhance and tailor your experience, analyze our traffic, and for security and marketing. You can choose not to allow some type of cookies by clicking . For more information see our Cookie Policy.