Skip to main content

Gas Sponsorship for Smart Account

I have implemented the Coinbase Gas Sponsorship feature for Smart Accounts, which tested successfully with Coinbase Smart Wallets (https://keys.coinbase.com). However, for standard EOA wallets (like Metamask or Coinbase Wallet), users will still have to pay their own gas to claim the box.

A centered diagram

Register Paymaster

Go to Coinbase paymaster service

https://portal.cdp.coinbase.com/products/paymaster/

Register your project to get

  • Paymaster & Bundler endpoint: looks like https://api.developer.coinbase.com/rpc/v1/base/${YOUR_API_KEY}

  • Enable Paymaster: register your contract and function that will be sponsored, in my case:

Name
PixelGift

Contract Address
0x83514843b0A11398e98e99873908c1d6f1C1CaeA

Functions
claimBox(uint16,uint256,bytes)

Claim Box with normal EOA Account

export function useClaimBoxEOA() {
const { writeContractAsync, data, error } = useWriteContract()

const claimBoxEOA = useCallback(async (pos: number, deadline: number, sig: `0x${string}`) => {
await writeContractAsync({
address: GiftContractAddress,
abi: claimBoxAbi,
functionName: 'claimBox',
args: [pos, BigInt(deadline), sig],
})
}, [writeContractAsync])

// After call, get status and receipts (with logs)
const { data: receipt } = useWaitForTransactionReceipt({
hash: data,
query: { enabled: !!data }
})

// Parse the logs to get result (how many tokens claimed)
const result = useMemo(() => getBoxClaimedEventLog(receipt), [receipt])

return { claimBoxEOA, result, error }
}

Claim Box with Smart Account

  • First, get the capabilities of the connected account on the current chain.
  • If paymaster supported, we set the Paymaster Url got above, then call contract using useWriteContracts (plural)

Check Capabilities

import { useMemo } from 'react'
import { useAccount, useCapabilities, useChainId } from 'wagmi'

interface AccountCapabilities {
paymasterService?: {
url: string
}
}

export function useAccountCapabilities(): AccountCapabilities {
const { address: account } = useAccount()
const chainId = useChainId()

// Check for paymaster capabilities with `useCapabilities`
const { data: availableCapabilities } = useCapabilities({
account,
});

const capabilities = useMemo(() => {
if (!availableCapabilities) return {};
const capabilitiesForChain = availableCapabilities[chainId];
if (
capabilitiesForChain['paymasterService'] &&
capabilitiesForChain['paymasterService'].supported
) {
return {
paymasterService: {
url: `https://api.developer.coinbase.com/rpc/v1/base/${YOUR_API_KEY}`, //For production use proxy
},
};
}
return {};
}, [availableCapabilities, chainId]);

return capabilities
}

Claimbox with Paymaster

import { useCallback, useEffect, useMemo } from 'react'
import { useCallsStatus } from 'wagmi'
import { useWriteContracts } from 'wagmi/experimental'

import { GiftContractAddress } from '../constants'
import { AccountCapabilities, claimBoxAbi } from './types'
import { getBoxClaimedEventLog } from './utils'

export function useClaimBoxPayMaster(capabilities: AccountCapabilities) {
const { writeContractsAsync, data, error } = useWriteContracts();

const claimBoxPayMaster = useCallback(async (pos: number, deadline: number, sig: `0x${string}`) => {
await writeContractsAsync({
contracts: [
{
address: GiftContractAddress,
abi: claimBoxAbi,
functionName: 'claimBox',
args: [pos, BigInt(deadline), sig],
}
],
capabilities // pass the capabilities with PaymasterUrl here
})
}, [capabilities])

// After call, get status and receipts (with logs)
const { data: statusData } = useCallsStatus({
id: data?.id ? data.id : '',
query: { enabled: !!data?.id}
})

useEffect(() => {
console.log('claimBoxPayMaster receipts', statusData)
}, [statusData?.receipts])

const result = useMemo(() => getBoxClaimedEventLog(statusData?.receipts?.[0] as any), [statusData?.receipts])

return { claimBoxPayMaster, result, error }
}

After calling contract successful, we get the bundle id, use it to call useCallsStatus get the receipt from the call. The receipt then can be used to get logs emitted by the transaction to see result how many tokens are claimed.

Support both EOA and Smart Account

I tried the Paymaster way with normal EOA account and it did not work because normal EOA account doesn't have code attached with it like a Smart Account, to be called by other contract. To support both we have to check the account and process the contract call differently.

Create a wrapper for claimBox function:

  • Check the capabilities of the account to decide which way to go.
  • Call claimBoxPayMaster or claimBoxEOA accordingly.

The hook function will look like

export function useClaimBox() {
// claimBox for EOA (Normal Account)
const { claimBoxEOA, result: rs1, error: err1 } = useClaimBoxEOA()

// claimBox for SA (Smart Account)
const capabilities = useAccountCapabilities()
const { claimBoxPayMaster, result: rs2, error: err2 } = useClaimBoxPayMaster(capabilities)

const claimBoxUniversal = useCallback(async (pos: number, deadline: number, signature: `0x${string}`) => {
if (capabilities?.paymasterService) {
await claimBoxPayMaster(pos, deadline, signature)
} else {
await claimBoxEOA(pos, deadline, signature)
}
}, [capabilities, claimBoxEOA, claimBoxPayMaster])

// Handle error (err1 || err2)

// Handle result (rs1 || rs2)

return claimBoxUniversal
}

Get Result & Parse log

After calling the claimBox function successfully, we get the result log in 2 different ways for EOA and Smart Account.

Get result

For EOA

  // data is the 'hash' after calling
const { ..., data } = useWriteContract()

// After call, get status and receipts (with logs)
const { data: receipt } = useWaitForTransactionReceipt({
hash: data,
query: { enabled: !!data }
})

// Parse the logs to get result (how many tokens claimed)
const result = useMemo(() => getBoxClaimedEventLog(receipt), [receipt])

For Smart Account

  // data is `{id: '...'}` after calling
const { ..., data } = useWriteContracts();

// After call, get status and receipts (with logs)
const { data: statusData } = useCallsStatus({
id: data?.id ? data.id : '',
query: { enabled: !!data?.id}
})

const result = useMemo(() => getBoxClaimedEventLog(statusData?.receipts?.[0] as any), [statusData?.receipts])

The EOA account method work quite stable with the receipt and logs, but for Smart Account, sometimes:

  • I received 1 recepit with logs, and status is complete which is good and expected.
  • I received 0 receipt, and status is pending, this case we can not see how many tokens claimed.

Parse the log

If things go as expected, we will get the same logs in both cases and can parse it to get information about how many tokens claimed.

import { Log, parseEventLogs } from 'viem'

// event ABI for parsing logs
export const boxClaimedEventAbi = [
{
type: "event",
name: "BoxClaimed",
inputs: [
{ name: "user", type: "address", indexed: false, internalType: "address" },
{ name: "position", type: "uint16", indexed: false, internalType: "uint16" },
{ name: "token", type: "uint16", indexed: false, internalType: "uint16" }
],
anonymous: false
},
] as const

// Note: the type of log here is not quite clear for me
export function getBoxClaimedEventLog(receipt?: { logs: Log<bigint, number, false>[]}) {
if (!receipt || !receipt.logs) return null

const parsedLogs = parseEventLogs({
abi: boxClaimedEventAbi,
eventName: 'BoxClaimed',
logs: receipt.logs,
})

if (parsedLogs.length === 0) {
console.log('No BoxClaimed event found in logs')
return null
}

return parsedLogs[0].args
}