Vai al contenuto principale

Sponsoring gas fees: How to cover transaction costs for your users

gasless
solidity
eip-712
meta-transactions
Intermedio
Ori Pomerantz
27 febbraio 2026
11 minuti letti

Introduction

If we want Ethereum to serve a billion more peopleopens in a new tab, we need to remove friction and make it as easy to use as possible. One source of this friction is the need for ETH to pay gas fees.

If you have a dapp that makes money from users, it might make sense to let users submit transactions through your server and pay the transaction fees yourself. Because users still sign an EIP-712 authorization messageopens in a new tab in their wallets, they retain Ethereum's guarantees of integrity. Availability depends on the server that relays transactions, so it is more limited. However, you can set things up so users can also access the smart contract directly (if they get ETH), and let others set up their own servers if they want to sponsor transactions.

The technique in this tutorial only works when you control the smart contract. There are other techniques, including account abstractionopens in a new tab that let you sponsor transactions to other smart contracts, which I hope to cover in a future tutorial.

Note: This is not production-level code. It is vulnerable to significant attacks and lacks major features. Learn more in the vulnerabilities section of this guide.

Prerequisites

To understand this tutorial you need to already be familiar with:

The sample application

The sample application here is a variant on Hardhat's Greeter contract. You can see it on GitHubopens in a new tab. The smart contract is already deployed on the Sepoliaopens in a new tab, at address 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfAopens in a new tab.

To see it in action, follow these steps.

  1. Clone the repository and install the necessary software.

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

  3. Start the server.

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

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

  6. Write a new greeting and click Update greeting via sponsor.

  7. Sign the message.

  8. Wait about 12 seconds (the block time on Sepolia). While waiting you can look at the URL in the server's console to see the transaction.

  9. See that the greeting changed, and that the last updated by address value is now the address of your browser wallet.

To understand how this works, we need to look at how the message gets created in the user interface, how it is relayed by the server, and how the smart contract processes it.

The user interface

The user interface is based on WAGMIopens in a new tab; you can read about it in this tutorial.

Here is how we sign the message:

1const signGreeting = useCallback(

The React hook useCallbackopens in a new tab lets us improve performance by reusing the same function when the component is redrawn.

1 async (greeting) => {
2 if (!account) throw new Error("Wallet not connected")

If there is no account, raise an error. This should never happen because the UI button that starts the process that calls signGreeting is disabled in that case. However, future programmers may remove that safeguard, so it's a good idea to check this condition here as well.

1 const domain = {
2 name: "Greeter",
3 version: "1",
4 chainId,
5 verifyingContract: contractAddr,
6 }

Parameters for the domain separatoropens in a new tab. This value is constant, so in a better-optimized implementation, we might calculate it once rather than recalculate it each time the function is called.

  • name is a user-readable name, such as the name of the dapp for which we are producing signatures.
  • version is the version. Different versions are not compatible.
  • chainId is the chain we are using, as provided by WAGMIopens in a new tab.
  • verifyingContract is the contract address that will verify this signature. We do not want the same signature to apply to multiple contracts, in case there are several Greeter contracts and we want them to have different greetings.
1
2 const types = {
3 GreetingRequest: [
4 { name: "greeting", type: "string" },
5 ],
6 }

The data type we sign. Here, we have a single parameter, greeting, but real-life systems typically have more.

1 const message = { greeting }

The actual message we want to sign and send. greeting is both the field name and the name of the variable that fills it.

1 const signature = await signTypedDataAsync({
2 domain,
3 types,
4 primaryType: "GreetingRequest",
5 message,
6 })

Actually get the signature. This function is asynchronous because users take a long time (from a computer's perspective) to sign data.

1 const r = `0x${signature.slice(2, 66)}`
2 const s = `0x${signature.slice(66, 130)}`
3 const v = parseInt(signature.slice(130, 132), 16)
4
5 return {
6 req: { greeting },
7 v,
8 r,
9 s,
10 }
11 },
Mostra tutto

The function returns a single hexadecimal value. Here we divide it into fields.

1 [account, chainId, contractAddr, signTypedDataAsync],
2)

If any of these variables change, create a new instance of the function. The account and chainId parameters can be changed by the user in the wallet. contractAddr is a function of the chain Id. signTypedDataAsync should not change, but we import it from a hookopens in a new tab, so we can't be sure, and it's best to add it here.

Now that the new greeting is signed, we need to send it to the server.

1 const sponsoredGreeting = async () => {
2 try {

This function takes a signature and sends it to the server.

1 const signedMessage = await signGreeting(newGreeting)
2 const response = await fetch("/server/sponsor", {

Send to the path /server/sponsor in the server we came from.

1 method: "POST",
2 headers: { "Content-Type": "application/json" },
3 body: JSON.stringify(signedMessage),
4 })

Use POST to send the information JSON-encoded.

1 const data = await response.json()
2 console.log("Server response:", data)
3 } catch (err) {
4 console.error("Error:", err)
5 }
6 }

Output the response. On a production system we'd also show the response to the user.

The server

I like using Viteopens in a new tab as my front-end. It automatically serves the React libraries and updates the browser when the front-end code changes. However, Vite does not include backend tooling.

The solution is in index.jsopens in a new tab.

1 app.post("/server/sponsor", async (req, res) => {
2 ...
3 })
4
5 // Let Vite handle everything else
6 const vite = await createViteServer({
7 server: { middlewareMode: true }
8 })
9
10 app.use(vite.middlewares)
Mostra tutto

First we register a handler for the requests we handle ourselves (POST to /server/sponsor). Then we create and use a Vite server to handle all other URLs.

1 app.post("/server/sponsor", async (req, res) => {
2 try {
3 const signed = req.body
4
5 const txHash = await sepoliaClient.writeContract({
6 address: greeterAddr,
7 abi: greeterABI,
8 functionName: 'sponsoredSetGreeting',
9 args: [signed.req, signed.v, signed.r, signed.s],
10 })
11 } ...
12 })
Mostra tutto

This is just a standard viemopens in a new tab blockchain call.

The smart contract

Finally, Greeter.solopens in a new tab needs to verify the signature.

1 constructor(string memory _greeting) {
2 greeting = _greeting;
3
4 DOMAIN_SEPARATOR = keccak256(
5 abi.encode(
6 keccak256(
7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"
8 ),
9 keccak256(bytes("Greeter")),
10 keccak256(bytes("1")),
11 block.chainid,
12 address(this)
13 )
14 );
15 }
Mostra tutto

The constructor creates the domain separatoropens in a new tab, similar to the user interface code above. Blockchain execution is much more expensive, so we only calculate it once.

1 struct GreetingRequest {
2 string greeting;
3 }

This is the structure that gets signed. Here we have just one field.

1 bytes32 private constant GREETING_TYPEHASH =
2 keccak256("GreetingRequest(string greeting)");

This is the structure identifieropens in a new tab. It is calculated each time in the user interface.

1 function sponsoredSetGreeting(
2 GreetingRequest calldata req,
3 uint8 v,
4 bytes32 r,
5 bytes32 s
6 ) external {

This function receives a signed request and updates the greeting.

1 // Compute EIP-712 digest
2 bytes32 digest = keccak256(
3 abi.encodePacked(
4 "\x19\x01",
5 DOMAIN_SEPARATOR,
6 keccak256(
7 abi.encode(
8 GREETING_TYPEHASH,
9 keccak256(bytes(req.greeting))
10 )
11 )
12 )
13 );
Mostra tutto

Create the digest in accordance with EIP 712opens in a new tab.

1 // Recover signer
2 address signer = ecrecover(digest, v, r, s);
3 require(signer != address(0), "Invalid signature");

Use ecrecoveropens in a new tab to get the signer address. Note that a bad signature can still result in a valid address, just a random one.

1 // Apply greeting as if signer called it
2 greeting = req.greeting;
3 emit SetGreeting(signer, req.greeting);
4 }

Update the greeting.

Vulnerabilities

This is not production-level code. It is vulnerable to significant attacks and lacks major features. Here are some, along with how to solve them.

To see some of these attacks, click the buttons under the Attacks heading and see what happens. For the Invalid signature button, check the server console to see the transaction response.

Denial of service on the server

The easiest attack is a denial-of-serviceopens in a new tab attack on the server. The server receives requests from anywhere on the Internet and based on those requests sends transactions. There is absolutely nothing preventing an attacker from issuing a bunch of signatures, valid or invalid. Each will cause a transaction. Eventually the server will run out of ETH to pay for gas.

One solution to this problem is to limit the rate to one transaction per block. If the purpose is to show greetings to externally owned accounts, it does not matter what the greeting is in the middle of the block anyway.

Another solution is to keep track of addresses and only allow signatures from valid customers.

Wrong greeting signatures

When you click Signature for wrong greeting, you submit a valid signature for a specific address (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) and greeting (Hello). But it submits it with a different greeting. This confuses ecrecover, which changes the greeting but has the wrong address.

To solve this problem, add the address to the signed structureopens in a new tab. This way, the ecrecover random address won't match the address in the signature, and the smart contract will reject the message.

Replay attacks

When you click Replay attack, you submit the same "I'm 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e, and I'd like the greeting to be Hello" signature, but with the correct greeting. As a result, the smart contract believes that the address (which isn't yours) changed the greeting back to Hello. The information to do this is publicly available in the transaction informationopens in a new tab.

If this is a problem, one solution is to add a nonceopens in a new tab. Have a mappingopens in a new tab between addresses and numbers, and add a nonce field to the signature. If the nonce field matches the mapping for the address, accept the signature and increment the mapping for next time. If it doesn't, reject the transaction.

Another solution is to add a timestamp to the signed data and accept the signature as valid only for a few seconds after that timestamp. This is simpler and cheaper, but we risk replay attacks within the time window, and the failure of legitimate transactions if the time window is exceeded.

Other missing features

There are additional features we would add in a production setting.

Access from other servers

Currently, we allow any address to submit a sponsorSetGreeting. This may be exactly what we want, in the interest of decentralization. Or maybe we want to ensure that sponsored transactions go through our server, in which case we'd check msg.sender in the smart contract.

Either way, this should be a conscious design decision, not just the result of not thinking about the issue.

Error handling

A user submits a greeting. Maybe it gets updated at the next block. Maybe it doesn't. Errors are invisible. On a production system, the user should be able to distinguish between these cases:

  • The new greeting has not been submitted yet
  • The new greeting has been submitted, and it's in process
  • The new greeting has been rejected

Conclusion

At this point, you should be able to create a gasless experience for your dapp users, at the cost of some centralization.

However, this only works with smart contracts that support ERC-712. To transfer an ERC-20 token, for example, it is necessary to have the transaction signed by the owner rather than just a message. The solution is account abstraction (ERC-4337)opens in a new tab. I hope to write a future tutorial about it.

See here for more of my workopens in a new tab.

Ultimo aggiornamento pagina: 27 febbraio 2026

Questo tutorial è stato utile?