Vai al contenuto principale

Letting your gasless users hold tokens and call contracts

gasless
erc-20
account abstraction
Intermedio
Ori Pomerantz
1 aprile 2026
18 minuti di lettura

Introduction

A previous article discussed using gasless access to your own application using EIP-712 signatures, but it is limited to your own smart contracts. Using account abstraction, we can create smart contract wallets that accept two types of transactions and relay them to a requested destination:

  • Transactions sent by a specific EOA (which require that EOA to have ETH)
  • Transactions sent from anywhere, but signed by the same EOA.

This way, we can provide a gasless way for an account to hold assets (tokens, etc.) and perform all the functions an EOA with gas can.

Why can't we just relay the request?

In ERC-20 and related standards, the account owner is msg.sender (opens in a new tab), the address that called the token contract, which is not necessarily the originator of the transaction, tx.origin (opens in a new tab). This is required for security reasons (opens in a new tab). This means that if we relay token transfer requests, they'll attempt to transfer tokens from the relayer's address rather than an address controlled by the user.

There is a solution that lets you use the EOA address via EIP-7702 (opens in a new tab), but it requires signing a potentially dangerous delegation, so you can only use it to delegate to a smart contract of which the wallet provider approves. For this tutorial I prefer the much simpler method of creating a smart contract as a proxy to the user.

Seeing it in action

  1. Ensure you have both Node (opens in a new tab) and Foundry (opens in a new tab).

  2. Clone the application and install the necessary software.

    1git clone https://github.com/qbzzt/260315-gasless-tokens.git
    2cd 260315-gasless-tokens
    3forge build
    4cd server
    5npm install
  3. Edit .env to set SEPOLIA_PRIVATE_KEY to a wallet that has ETH on Sepolia. If you need Sepolia ETH, use a faucet to get it. Ideally, this private key should be different from the one you have in your browser wallet.

  4. Start the server.

    1npm run dev
  5. Browse to the application at URL http://localhost:5173 (opens in a new tab).

  6. Click Connect with Injected to connect to a wallet. Approve in the wallet, and approve the change to Sepolia if necessary.

  7. Scroll down and click Deploy UserProxy (slow process).

  8. You can see when the user proxy is deployed because there is an address next to UserProxy access. If you waited 24 seconds (2 blocks) and it still hasn't happened, there might be a problem with detecting changes.

    If that is the case, go to the Sepolia Explorer (opens in a new tab) and enter the deployment transaction hash you see in the server output at npm run dev. Click the created contract to view its address, then copy it. Paste the address in the Or enter existing proxy address field, then click Set proxy address.

  9. Click Request more tokens for proxy to submit a call to the ERC-20 contract's faucet (opens in a new tab) function to get tokens. Confirm the signature in the wallet. Of course, the tokens reach the proxy's address, not the user's.

  10. Scroll down and click the link under Last transaction:. This will open the browser to show you the faucet transaction.

  11. In the amount to transfer, enter a number between one and one thousand. Click Transfer to transfer the tokens to your own address. Before you click Confirm for the request, see that the data being signed is opaque. Users would have a hard time understanding what they are signing. Remember that we will discuss it below.

  12. After the transaction is confirmed, wait to see the change in both your balance and proxy balance. Note that this will also take some time, because Sepolia has a block time of 12 seconds.

How it works

For a gasless experience, we need a user interface for the user, a server to route messages from the user interface to the chain, and a smart contract to receive and verify them.

The wallet smart contract

This is the smart contract (opens in a new tab). Its purpose is to do whatever the real owner requests, regardless of the channel used to request it, and ignore everything else. To do this, its functions receive a target address to call and the data to use to call it.

1// SPDX-License-Identifier: MIT
2pragma solidity ^0.8.21;
3
4contract UserProxy {
5 address immutable OWNER;
6 uint public nonce = 0;

The owner's identity and a nonce (opens in a new tab) to prevent messages from being repeated. Because the nonce is a public variable, the Solidity compiler also creates a view function, nonce() (opens in a new tab), that allows offchain code to read its value.

1 bytes32 private constant SIGNED_ACCESS_TYPEHASH =
2 keccak256("SignedAccess(address target,bytes data,uint256 nonce)");
3
4 bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
5 keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");
6
7 bytes32 immutable DOMAIN_SEPARATOR;

The information required to verify EIP-712 signatures (opens in a new tab).

1 constructor(address owner_) {
2 OWNER = owner_;

A UserProxy is tied to a single owner address. This is necessary because it can own assets (ERC-20 tokens, NFTs, etc.). We don't want to intermingle assets belonging to different owners.

1 DOMAIN_SEPARATOR = keccak256(
2 abi.encode(
3 keccak256(
4 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
5 ),
6 keccak256(bytes("UserProxy")),
7 keccak256(bytes("1")),
8 block.chainid,
9 address(this)
10 )
11 );
12 }

The domain separator (opens in a new tab). It cannot be calculated at compile time, because it depends on the chain ID and the contract address. This makes it impossible for a UserProxy to be fooled by a message prepared for another.

1 event CallResult(address target, bytes returnData);

Log the results of a call.

1 function directAccess(address target, bytes calldata data)
2 external returns (bytes memory) {

This function can be called directly by the owner. If no relays are available, the owner can still access the assets directly on the blockchain (if the user has ETH).

1 require(msg.sender == OWNER, "Only owner can call");
2 (bool success, bytes memory returnData) = target.call(data);
3 require(success, "Call failed");
4
5 emit CallResult(target, returnData);
6
7 return returnData;
8 }

If we are called directly by the owner, call the target with the provided calldata.

1 function signedAccess(
2 address target,
3 bytes calldata data,
4 uint8 v,
5 bytes32 r,
6 bytes32 s)

This is UserProxy's main function. It gets target and data, as well as a signature.

1 external returns (bytes memory) {
2 // Compute EIP-712 digest
3 bytes32 digest = keccak256(
4 abi.encodePacked(
5 "\x19\x01",
6 DOMAIN_SEPARATOR,
7 keccak256(
8 abi.encode(
9 SIGNED_ACCESS_TYPEHASH,
10 target,
11 keccak256(data),
12 nonce
13 )
14 )
15 )
16 );

The digest also includes the nonce, but we do not need to receive it from the transaction; we already know the right value. A signature with the wrong nonce will be rejected.

1
2 // Recover signer
3 address signer = ecrecover(digest, v, r, s);
4 require(signer == OWNER, "Signature invalid or not by owner");

If the signature is invalid, ecrecover will usually return a different address, and it will not be accepted.

1 (bool success, bytes memory returnData) = target.call(data);
2 require(success, "Call failed");

Call the contract the user told us to call, and revert if not successful.

1 emit CallResult(target, returnData);
2
3 nonce++; // Increment nonce to prevent replay
4
5 return returnData;
6}

If successful, emit a log event and increment the nonce.

1 function directAccessPayable(address target, uint value, bytes calldata data)
2 external payable returns (bytes memory) {
3 .
4 .
5 .
6 }
7
8 function signedAccessPayable(
9 .
10 .
11 .
12 }
13}

These are nearly identical variants that let you also transfer ETH out of the contract.

The relayer

The relayer is a server component. It is written in JavaScript; you can see the source code here (opens in a new tab).

1import express from "express";
2import { createServer as createViteServer } from "vite";
3import { createWalletClient, createPublicClient, http } from 'viem'
4import { privateKeyToAccount } from 'viem/accounts'
5import { sepolia } from 'viem/chains'
6import dotenv from 'dotenv'

The libraries we need. This is an Express (opens in a new tab) server, which uses Vite (opens in a new tab) to serve the user interface code. We use Viem (opens in a new tab) to communicate with the blockchain, and dotenv (opens in a new tab) to read the private key for the address that sends the transaction.

1import { createRequire } from 'module'
2const require = createRequire(import.meta.url)
3const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

This is a simple way to read the compiled UserProxy. We need the ABI to be able to call UserProxy, and the compiled code to be able to deploy it for a user.

1dotenv.config()
2const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
3console.log("Using account:", sepoliaAccount.address)

Read the .env file, extract the address, and print it to the console.

1const sepoliaClient = createWalletClient({
2 account: sepoliaAccount,
3 chain: sepolia,
4 transport: http("https://rpc.sentio.xyz/sepolia"),
5})
6
7const publicClient = createPublicClient({
8 chain: sepolia,
9 transport: http(),
10})

The Viem clients that talk to the blockchain.

1const start = async () => {
2 const app = express()

Run an Express server.

1 app.use(express.json())

Tell Express to read the request body, and if it's JSON to parse it.

1 app.post("/server/deploy", async (req, res) => {

This is the code that handles requests to deploy the proxy. Note that we are vulnerable to denial-of-service (opens in a new tab) attacks here because an attacker can spam us with requests to deploy the proxy until our ETH is exhausted. On a production system, we'd probably require that the request to deploy the proxy be signed and that the signer be an existing customer.

1 try {
2 const ownerAddress = req.body.ownerAddress

Get the owner's address from the request.

1 const txHash = await sepoliaClient.deployContract({
2 abi: UserProxy.abi,
3 bytecode: UserProxy.bytecode.object,
4 args: [ownerAddress],
5 account: sepoliaAccount,
6 })
7
8 console.log("Deployment transaction hash:", txHash)
9
10 const receipt = await publicClient.waitForTransactionReceipt({
11 hash: txHash,
12 })

Deploy the contract (opens in a new tab) and wait until it is deployed (opens in a new tab).

1 res.json({ contractAddress: receipt.contractAddress })

If everything is fine, return the proxy address to the user interface.

1 } catch (err) {
2 console.error(err)
3 res.status(500).json({ error: err.message })
4 }
5 })

If there's a problem, report it.

1 app.post("/server/message", async (req, res) => {

This is the code that processes user messages for the UserProxy contract. This is another point vulnerable to a denial-of-service attack.

1 try {
2 const { proxy, target, data, v, r, s } = req.body
3
4 const txHash = await sepoliaClient.writeContract({
5 address: proxy,
6 abi: UserProxy.abi,
7 functionName: 'signedAccess',
8 args: [target, data, v, r, s],
9 account: sepoliaAccount,
10 })

Get the request data and use it to call signedAccess on the proxy.

1 console.log("Message transaction hash:", txHash)
2
3 res.json({ txHash })

Report back the transaction hash. This lets the UI display a URL for the user to check the transaction.

1 } catch (err) {
2 console.error(err)
3 res.status(500).json({ error: err.message })
4 }
5 })

Again, if there is a problem, report it.

1 // Let Vite handle everything else
2 const vite = await createViteServer({
3 server: { middlewareMode: true }
4 })
5
6 app.use(vite.middlewares)
7
8 app.listen(5173, () => {
9 console.log("Dev server running on http://localhost:5173");
10 })
11}
12
13start()

For everything else, use Vite, which handles serving the user interface for us.

User interface

This is the user interface code (opens in a new tab). Most of the code is nearly identical to that documented in this article, with the exception of Token.jsx (opens in a new tab).

Parts of Token.jsx (opens in a new tab) are similar to Greeter.jsx (opens in a new tab) in this article. Here are the new parts.

1import {
2 encodeFunctionData
3 } from 'viem'

This function (opens in a new tab) creates the calldata for an EVM function call. This is necessary so the user can sign the calldata.

1import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

The UserProxy, explained above.

1import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

This contract (opens in a new tab) is mostly a normal ERC-20 contract, with the addition of one important function, faucet(). This function grants tokens to anyone who asks for them for testing purposes.

1const erc20Addrs = {
2 // Sepolia
3 11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
4}

The address for FaucetToken.

1const Address = ({ address }) => {
2 if (!address) return null
3 return (
4 <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
5 )
6}

This component outputs an address with a link to the contract on a block explorer.

1const Token = () => {
2 ...

This is the main component that does most of the work.

1 const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

The token balance of the user address.

1 const [ proxyAddr, setProxyAddr ] = useState(null)

The address of a proxy owned by the user.

1 const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

The proxy's token balance.

1 const [ newProxyAddr, setNewProxyAddr ] = useState("")

This field is used when the user manually sets the proxy address. Having the ability to set the proxy address manually lets the user use an existing proxy instead of deploying a new one each time (and losing all tokens owned by the old proxy).

1 const [ txHash, setTxHash ] = useState(null)

The hash of the last transaction, used to show a link to the explorer so the user can check that transaction.

1 const [ transferToken, setTransferToken ] = useState("")
2 const [ transferAmount, setTransferAmount ] = useState("")
3 const [ transferTo, setTransferTo ] = useState("")

These fields are all used to send token transfer commands to an ERC-20 contract. This may be FaucetToken, but it does not have to be. The transfer function is part of the ERC-20 standard.

1 const balance = useReadContract({
2 ...
3 })
4
5
6 const proxyBalance = useReadContract({
7 ...
8 })

Read the two token balances we are interested in, how much the user owns, and how much the proxy owns.

1 const nonce = useReadContract({
2 address: proxyAddr,
3 abi: UserProxy.abi,
4 functionName: 'nonce',
5 args: [],
6 })

To prevent replay attacks (for example, a seller replaying a transaction that gives them money), we use a nonce (opens in a new tab). We need to know the current value to add it to the data we sign.

1 useEffect(() => {
2 if (balance?.status === "success")
3 setBalanceAmount(balance.data / 10n**18n)
4 else
5 setBalanceAmount("Loading...")
6 }, [balance])
7
8 useEffect(() => {
9 if (proxyBalance?.status === "success")
10 setProxyBalanceAmount(proxyBalance.data / 10n**18n)
11 else
12 setProxyBalanceAmount("Loading...")
13 }, [proxyBalance])

Use useEffect (opens in a new tab) to update the balance displayed to the user when the information read from the blockchain changes.

1 useEffect(() => {
2 setTransferToken(faucetAddr)
3 }, [faucetAddr])
4
5 useEffect(() => {
6 setTransferTo(account.address)
7 }, [account.address])

The default is to transfer FaucetToken tokens to the user's own account. Here we set these values when we receive them from Viem.

1 const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
2 const transferTokenChange = (evt) => setTransferToken(evt.target.value)
3 const transferToChange = (evt) => setTransferTo(evt.target.value)
4 const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

Event handlers for when the text fields change.

1 const deployUserProxy = async () => {
2 try {
3 const response = await fetch("/server/deploy", {
4 method: "POST",
5 headers: { "Content-Type": "application/json" },
6 body: JSON.stringify({ ownerAddress: account.address })
7 })
8
9 const data = await response.json()
10 setProxyAddr(data.contractAddress)
11 } catch (err) {
12 console.error("Error:", err)
13 }
14 }

Ask the server to deploy a proxy for this user.

1 const signMessage = async(proxyAddr, target, calldata) => {

Sign a message before sending it to the server to send to UserProxy onchain. This is explained here. We need to sign a message with both the target address (the address of the token we're calling and) and the calldata to send.

1 const domain = {
2 .
3 .
4 .
5 return {v, r, s}
6 }
7
8 const messageUserProxy = async (proxy, target, data, v, r, s) => {

Send a signed message to UserProxy, which will verify the signature and then send it to the target.

1 try {
2 const response = await fetch("/server/message", {
3 method: "POST",
4 headers: { "Content-Type": "application/json" },
5 body: JSON.stringify({
6 proxy, target, // both addresses
7 data, // calldata to send target
8 v, r, s // signature
9 })
10 })
11 const serverResponse = await response.json()
12 setTxHash(serverResponse.txHash)
13 } catch (err) {
14 console.error("Error:", err)
15 }
16 }

Send a request to the server, and when you receive the response, get the transaction hash.

1 const faucetSimulation = useSimulateContract({
2 address: faucetAddr,
3 abi: Erc20.abi,
4 functionName: 'faucet',
5 account: account.address
6 })

Simulate calling the faucet function. We only enable the faucet button if this is successful.

1 const proxyFaucet = async () => {
2 const calldata = encodeFunctionData({
3 abi: Erc20.abi,
4 functionName: 'faucet',
5 args: [],
6 })
7
8 const {v, r, s} = await signMessage(proxyAddr, calldata)
9 messageUserProxy(proxyAddr, faucetAddr, calldata, v, r, s)
10 }
11
12 const proxyTransfer = async () => {
13 const calldata = encodeFunctionData({
14 abi: Erc20.abi,
15 functionName: 'transfer',
16 args: [transferTo, BigInt(transferAmount) * 10n**18n],
17 })
18
19 const {v, r, s} = await signMessage(proxyAddr, transferToken, calldata)
20 messageUserProxy(proxyAddr, transferToken, calldata, v, r, s)
21 }

To call a function through the server and UserProxy, we follow three steps:

  1. Create the calldata to sign and send using encodeFunctionData (opens in a new tab).

  2. Sign the message (target address, calldata, and nonce).

  3. Send the message to the server.

1 return (
2 <>
3 <div align="left">
4 <h2>Token</h2>
5 <h4>Token contract address <Address address={faucetAddr} /></h4>
6 <hr />
7 <h4>Direct access (as <Address address={account?.address} />)</h4>
8 Your balance: {balanceAmount}
9 <br />
10 <button disabled={!faucetSimulation.data}
11 onClick={() => writeContract(
12 faucetSimulation.data.request
13 )}
14 >
15 Request more tokens
16 </button>
17 <hr />

This portion of the component lets you use FaucetToken directly from the browser. Its main purpose is to facilitate debugging.

1 <h4>UserProxy access <Address address={proxyAddr} /></h4>
2 <button onClick={deployUserProxy}>
3 Deploy UserProxy (slow process)
4 </button>

Let the user deploy a new UserProxy.

1 <br /><br />
2 <input type="text" placeholder="Or enter existing proxy address" value={newProxyAddr} onChange={proxyAddressChange} />
3 <br /><br />
4 <button
5 onClick={() => setProxyAddr(newProxyAddr)}
6 disabled={newProxyAddr.match(/^0x[a-fA-F0-9]{40}$/) === null}
7 >
8 Set proxy address
9 </button>

Only let users click Set proxy address when they enter a legitimate address. Note that this does not ensure that the address in question is indeed a UserProxy contract. It is possible to add such a check, but it will be much slower (worse user experience) and not improve security (attackers can always use their own code for the user interface).

1 <br /><br />
2 { proxyAddr && (

Show the rest only if there is a legitimate proxy address.

1 <>
2 Proxy balance: {proxyBalanceAmount}
3 <br />
4 Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

The user does not need to know the nonce; this is just for debugging purposes.

1 <br />
2 <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
3 onClick={proxyFaucet}
4 >
5 Request more tokens for proxy
6 </button>

We can't simulate a call to faucet() through the proxy. However, we can at least ensure that we have a proxy and that the proxy reported a nonce to us.

1 <hr />
2 <h4>Transfer tokens from proxy</h4>
3 <ul>
4 <li> Token to transfer: <input type="text" placeholder="Token to transfer" value={transferToken} onChange={transferTokenChange} /> </li>
5 <li> Recipient address: <input type="text" placeholder="Recipient address" value={transferTo} onChange={transferToChange} /> </li>
6 <li> Amount to transfer: <input type="number" placeholder="Amount to transfer" value={transferAmount} onChange={transferAmountChange} /> </li>
7 </ul>
8 <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
9 onClick={proxyTransfer}
10 >
11 Transfer
12 </button>
13 </>
14 )}

Let the user issue ERC-20 transfer transactions.

1 <hr />
2 { txHash && (
3 <>
4 <h4>Last transaction:</h4>
5 <a href={`https://eth-sepolia.blockscout.com/tx/${txHash}`} target="_blank">
6 {txHash}
7 </a>
8 </>
9 )}

If there is a last transaction hash, show a link so the user can view it in a block explorer.

1 </div>
2 </>
3 )
4}
5
6export {Token}

This is just React boilerplate.

Vulnerabilities

Our server is vulnerable to denial-of-service attacks. This attack is explained in the previous article of the series.

Additionally, we are encouraging bad user behavior. This is what we ask the user to sign:

Screen capture with opaque calldata

We know this is a legitimate ERC-20 transfer for the token, amount, and destination address the user wants to transfer. But most users don't know how to interpret calldata, and have no idea what they are signing. That is bad design, for two reasons:

  • Some users will not use us because they don't trust the data we tell them to sign.
  • Other users will trust us and learn that they should just sign calldata without understanding what it is. This means that if Adam Attacker manages to redirect them to his website, he can have them sign a transaction that grants him all the USDC (or DAI, or any other ERC-20) the user owns.

The solution is to have separate functions in UserProxy for commonly used functions, such as transfer. Then users can sign something they understand.

Screen capture with transfer details

Note: While users can use any wallet they want, it is highly recommended that applications using EIP-712 encourage them to use a wallet that shows the entire signature data (opens in a new tab). Some wallets truncate the address, which is insecure. An attacker can create an address that has the same beginning and ending characters, but differs in the middle.

Screen capture with truncated addresses

Conclusion

In addition to the vulnerabilities above, the solution in this tutorial has several drawbacks that Ethereum can help us address.

  • Censorship resistance. Currently, users can use your server, a competing server set up by someone else, or connect to Ethereum directly, which incurs gas costs. Using ERC-4337 (opens in a new tab) lets users offer their transaction to a large pool of servers, reducing the likelihood that their transactions will be censored.
  • EOA owned assets. As noted above, EIP-7702 (opens in a new tab) can be used to manage assets already owned by an EOA address. This has its difficulties, but sometimes it is necessary.

I hope to publish tutorials about adding these features in the near future.

See here for more of my work (opens in a new tab).

Questo tutorial è stato utile?