Paymasters (Sponsored Transactions)
One of the biggest UX enhancements unlocked by Smart Wallet is the ability for app developers to sponsor their users' transactions. If your app supports Smart Wallet, you can start sponsoring your users' transactions by using standardized paymaster service communication enabled by new wallet RPC methods.
Paymasters can also accept ERC2-20 tokens for gas payment! Setting up a compatible ERC20 accepting paymaster for your token is outlined in this section. Smart wallet will have some universal ERC20 tokens enabled and available for gas usage as well, such as USDC.
The code below is also in our Wagmi Smart Wallet template.
Using Wagmi + Permissionless in a Next.js app
Choose a paymaster service provider
As a prerequisite, you'll need to obtain a paymaster service URL from a paymaster service provider. To be compatible with Smart Wallet, the paymaster provider you choose must be ERC-7677-compliant.
We recommend the Coinbase Developer Platform paymaster, currently offering up to $15k in gas credits as part of the Base Gasless Campaign. You can find a full list of ERC-7677-compliant paymaster services here.
Once you choose a paymaster service provider and obtain a paymaster service URL, you can proceed to integration.
(Recommended) Setup your paymaster proxy
Creating an API to proxy calls to your paymaster service is important for two reasons.
- Allows you to protect any API secret.
- Allows you to add extra validation on what requests you want to sponsor.
Validate UserOperation
Before we write our proxy, let's write a willSponsor
function to add some extra validation.
The policies on many paymaster services are quite simple and limited. As your API will be exposed on the web,
you want to make sure in cannot abused: called to sponsor transaction you do not want to fund. The checks below
are a bit tedious, but highly recommended to be safe. See "Trust and Validation" here
for more on this.
The code below is built specifically for Smart Wallet. It would need to be updated to support other smart accounts.
import { ENTRYPOINT_ADDRESS_V06, UserOperation } from "permissionless";
import {
Address,
BlockTag,
Hex,
decodeAbiParameters,
decodeFunctionData,
} from "viem";
import { baseSepolia } from "viem/chains";
import {client} from "./config"
import {
coinbaseSmartWalletABI,
coinbaseSmartWalletFactoryAddress,
coinbaseSmartWalletProxyBytecode,
coinbaseSmartWalletV1Implementation,
erc1967ProxyImplementationSlot,
magicSpendAddress
} from "./constants"
import { myNFTABI, myNFTAddress } from "./myNFT";
export async function willSponsor({
chainId,
entrypoint,
userOp,
}: { chainId: number; entrypoint: string; userOp: UserOperation<"v0.6"> }) {
// check chain id
if (chainId !== baseSepolia.id) return false;
// check entrypoint
// not strictly needed given below check on implementation address, but leaving as example
if (entrypoint.toLowerCase() !== ENTRYPOINT_ADDRESS_V06.toLowerCase())
return false;
try {
// check the userOp.sender is a proxy with the expected bytecode
const code = await client.getBytecode({ address: userOp.sender });
if (!code) {
// no code at address, check that the initCode is deploying a Coinbase Smart Wallet
// factory address is first 20 bytes of initCode after '0x'
const factoryAddress = userOp.initCode.slice(0, 42);
if (factoryAddress.toLowerCase() !== coinbaseSmartWalletFactoryAddress.toLowerCase())
return false;
} else {
// code at address, check that it is a proxy to the expected implementation
if (code != coinbaseSmartWalletProxyBytecode) return false;
// check that userOp.sender proxies to expected implementation
const implementation = await client.request<{
Parameters: [Address, Hex, BlockTag];
ReturnType: Hex;
}>({
method: "eth_getStorageAt",
params: [userOp.sender, erc1967ProxyImplementationSlot, "latest"],
});
const implementationAddress = decodeAbiParameters(
[{ type: "address" }],
implementation
)[0];
if (implementationAddress != coinbaseSmartWalletV1Implementation)
return false;
}
// check that userOp.callData is making a call we want to sponsor
const calldata = decodeFunctionData({
abi: coinbaseSmartWalletABI,
data: userOp.callData,
});
// keys.coinbase.com always uses executeBatch
if (calldata.functionName !== "executeBatch") return false;
if (!calldata.args || calldata.args.length == 0) return false;
const calls = calldata.args[0] as {
target: Address;
value: bigint;
data: Hex;
}[];
// modify if want to allow batch calls to your contract
if (calls.length > 2) return false;
let callToCheckIndex = 0;
if (calls.length > 1) {
// if there is more than one call, check if the first is a magic spend call
if (calls[0].target.toLowerCase() !== magicSpendAddress.toLowerCase())
return false;
callToCheckIndex = 1;
}
if (
calls[callToCheckIndex].target.toLowerCase() !==
myNFTAddress.toLowerCase()
)
return false;
const innerCalldata = decodeFunctionData({
abi: myNFTABI,
data: calls[callToCheckIndex].data,
});
if (innerCalldata.functionName !== "safeMint") return false;
return true;
} catch (e) {
console.error(`willSponsor check failed: ${e}`);
return false;
}
}
Create Proxy
The proxy you create will need to handle the pm_getPaymasterStubData
and pm_getPaymasterData
JSON-RPC requests specified by ERC-7677.
import { paymasterClient } from "./config";
import { willSponsor } from "./utils";
export async function POST(r: Request) {
const req = await r.json();
const method = req.method;
const [userOp, entrypoint, chainId] = req.params;
const sponsorable = await willSponsor({ chainId, entrypoint, userOp });
if (!sponsorable) {
return Response.json({ error: "Not a sponsorable operation" });
}
if (method === "pm_getPaymasterStubData") {
const result = await paymasterClient.getPaymasterStubData({
userOperation: userOp,
});
return Response.json({ result });
} else if (method === "pm_getPaymasterData") {
const result = await paymasterClient.getPaymasterData({
userOperation: userOp,
});
return Response.json({ result });
}
return Response.json({ error: "Method not found" });
}
Send EIP-5792 requests with a paymaster service capability
Once you have your paymaster service set up, you can now pass its URL along to Wagmi's useWriteContracts
hook.
If you set up a proxy in your app's backend as recommended in step (2) above, you'll want to pass in the proxy URL you created.
import { useAccount } from "wagmi";
import { useCapabilities, useWriteContracts } from "wagmi/experimental";
import { useMemo, useState } from "react";
import { CallStatus } from "./CallStatus";
import { myNFTABI, myNFTAddress } from "./myNFT";
export function App() {
const account = useAccount();
const [id, setId] = useState<string | undefined>(undefined);
const { writeContracts } = useWriteContracts({
mutation: { onSuccess: (id) => setId(id) },
});
const { data: availableCapabilities } = useCapabilities({
account: account.address,
});
const capabilities = useMemo(() => {
if (!availableCapabilities || !account.chainId) return {};
const capabilitiesForChain = availableCapabilities[account.chainId];
if (
capabilitiesForChain["paymasterService"] &&
capabilitiesForChain["paymasterService"].supported
) {
return {
paymasterService: {
url: `${document.location.origin}/api/paymaster`,
},
};
}
return {};
}, [availableCapabilities, account.chainId]);
return (
<div>
<h2>Transact With Paymaster</h2>
<p>{JSON.stringify(capabilities)}</p>
<div>
<button
onClick={() => {
writeContracts({
contracts: [
{
address: myNFTAddress,
abi: myNFTABI,
functionName: "safeMint",
args: [account.address],
},
],
capabilities,
});
}}
>
Mint
</button>
{id && <CallStatus id={id} />}
</div>
</div>
);
}
That's it! Smart Wallet will handle the rest. If your paymaster service is able to sponsor the transaction, in the UI Smart Wallet will indicate to your user that the transaction is sponsored.
ERC20 Paymasters
If you are using Coinbase Developer Platform's Paymaster you can sign up for ERC20 by clicking the ERC20 Paymaster tab and going through the steps to add your token. This paymaster is already set up to be fully compatible with smart wallet. The CDP Paymaster will allow sponsorship and fallback to accepting ERC20 tokens for gas payment if no sponsorship is provided.
Otherwise if using a different paymaster provider, it must conform to the specification outlined in ERC20 Compatible Paymasters to correctly work with Smart Wallet.
App setup
Once you have a paymaster that is ERC20 compatible, you are only responsible for including the approvals to the the paymaster for your token. It is recommended to periodically top up the allowance once they hit some threshold similar to.
const usdcDecimals = 6
const minTokenThreshold = 1 * 10 ** usdcDecimals // 1 USDC
const paymasterAddress = "0x2FAEB0760D4230Ef2aC21496Bb4F0b47D634FD4c"
// Checks for allowance
const allowance = await client.readContract({
abi: parseAbi(["function allowance(address owner, address spender) returns (uint256)"]),
address: tokenAddress,
functionName: "allowance",
args: [account.address, paymasterAddress],
})
if (allowance < minTokenThreshold) {
// include approval for $20 usdc in calls so that the paymaster will be able to move the token to accept payment
}
That is it! Smart Wallet will handle the rest as long as it is compatible as outlined below.
ERC20 Compatible Paymasters
Coinbase Developer Platform is compatible out of the box and we will be working with other teams to include support soon!
The paymaster must handle the pm_getPaymasterStubData
and pm_getPaymasterData
JSON-RPC requests specified by ERC-7677 in addition to pm_getAcceptedPaymentTokens
. We step through each request and response below.
pm_getPaymasterStubData and pm_getPaymasterData
- The paymaster must use the specified ERC20 for payment if specified in the 7677 context field under
erc20
. - Upon rejection / failure the paymaster should return a
data
field in the JSONRPC response which could be used to approve the paymaster and includes:
acceptedTokens
array which is a struct including the token addresspaymasterAddress
field which is the paymaster address which will perform the token transfers.
- Upon success the paymaster must return a
tokenPayment
field in the result. This includes:
tokenAddress
address of the token used for paymentmaxFee
the maximum fee to show in the UIdecimals
decimals to use in the UIname
name of the token
Smart wallet will simulate the transaction to ensure success and accurate information.
Request
This is a standard V0.6 Entrypoint request example with the additional context for the specified token to be used.
{
"jsonrpc": "2.0",
"id": 1,
"method": "pm_getPaymasterData",
"params": [
{
"sender": "0xe62B4aD6A7c079F47D77a9b939D5DC67A0dcdC2B",
"nonce": "0x4e",
"initCode": "0x",
"callData": "0xb61d27f60000000000000000000000007746371e8df1d7099a84c20ed72e3335fb016b23000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000600000000000000000000000000000000000000000000000000000000000000000",
"callGasLimit": "0x113e10",
"verificationGasLimit": "0x113e10",
"preVerificationGas": "0x113e10",
"maxFeePerGas": "0x113e10",
"maxPriorityFeePerGas": "0x113e10",
"paymasterAndData": "0x",
"signature": "0x5ee079a5dec73fe39c1ce323955fb1158fc1b9a6b2ddbec104cd5cfec740fa5531584f098b0ca95331b6e316bd76091e3ab75a7bc17c12488664d27caf19197e1c"
},
"0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789",
"0x2105",
{
"erc20": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
}
]
}
Response
Successful response:
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"paymasterAndData": "0x2faeb0760d4230ef2ac21496bb4f0b47d634fd4c0000670fdc98000000000000494b3b6e1d074fbca920212019837860000100833589fcd6edb6e08f4c7c32d4f71b54bda029137746371e8df1d7099a84c20ed72e3335fb016b23000000000000000000000000000000000000000000000000000000009b75458400000000697841102cd520d4e0171a58dadc3e6086111a49a90826cb0ad25579f25f1652081f68c17d8652387a33bf8880dc44ecf95be4213e786566d755baa6299f477b0bb21c",
"tokenPayment": {
"name": "USDC",
"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"maxFee": "0xa7c8",
"decimals": 6
}
}
}
Rejection response:
{
"id": 1,
"jsonrpc": "2.0",
"error": {
"code": -32002,
"message": "request denied - no sponsorship and address can not pay with accepted token",
"data": {
"acceptedTokens": [
{
"name": "USDC",
"address": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
}
]
}
}
}
pm_getAcceptedPaymentTokens
pm_getAcceptedPaymentTokens
returns an array of tokens the paymaster will accept for payment.
The request contains the entrypoint and the chain id with optional context.
Request
{
"jsonrpc": "2.0", "id": 1,
"method": "pm_getAcceptedPaymentTokens",
"params": [ "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789", "0x2105", {}]
}
Response
{
"id": 1,
"jsonrpc": "2.0",
"result": {
"acceptedTokens": [
{
"name": "USDC",
"address": "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"
}
]
}
}