How it works
ERC-2771 splits a call into two parts:- The user signs a
ForwardRequestas EIP-712 typed data: “call this function on this contract for me”. - A relayer sends that request to a forwarder contract in a transaction it pays for.
ERC2771Context, whose _msgSender() returns that address when the caller is the trusted forwarder. Each nonce works once, so a signed request can’t be replayed.
What you’ll build
- A forwarder and a small
Notescontract that records who set each note. - A viem script that creates a new wallet with no tBOT, signs a request, and relays it from your funded wallet.
Prerequisites
- A Foundry project from Set up Foundry, with the
uzo-devkeystore and some tBOT. - The
bot-defiproject and.envfile from Wrap BOT, for the script.
Steps
1
Install OpenZeppelin
In your Foundry project:Add
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ to remappings in foundry.toml if it isn’t there.2
Write the forwarder
Use OpenZeppelin’s forwarder unchanged. The name becomes part of the EIP-712 domain that wallets show when signing.
src/Forwarder.sol
3
Make your contract trust it
Extend
ERC2771Context, pass the forwarder’s address to its constructor, and use _msgSender() wherever you’d use msg.sender.src/Notes.sol
4
Deploy both
setNote in place of sign, stored the note under the signing user’s address, not the relayer’s.5
Sign and relay a request
This script plays both roles. A brand new wallet signs, and the wallet in your
.env relays. It uses a forwarder and guest book deployed on testnet by the gasless app template. To use your own contracts, change the two addresses and the function.meta-tx.ts
6
Understand each part
- The domain comes from the forwarder’s
eip712Domain(), so the name, version, chain ID and address always match what the forwarder checks. - The nonce comes from
nonces(user). It’s part of the signed message but not ofForwardRequestData, because the forwarder looks it up itself. That’s why the script removes it before callingverifyandexecute. gasis the gas the forwarder passes to your contract. If it’s too low, the inner call fails.deadlineis a Unix time in seconds. After it, the request is rejected.verifychecks the signature, nonce, deadline and that the target trusts this forwarder, without spending gas.
7
Run it
Verify it worked
On testnet on 2026-10-01:Output
getEntries, as in Events without getLogs.
Sign in a browser wallet
In a web app, the user signs with their own wallet instead of a generated key. With wagmi, usesignTypedData with the same domain, types and message. The wallet shows the request fields and asks for a signature, not a transaction. Then post the request to your relayer. The gasless app template does this end to end.
Troubleshooting
ERC2771ForwarderInvalidSigner
ERC2771ForwarderInvalidSigner
The signature doesn’t match the request. Check you signed with the forwarder’s own domain, used the current nonce, and sent exactly the fields you signed. A request that already ran fails this way too, because its nonce is used.
ERC2771ForwarderExpiredRequest
ERC2771ForwarderExpiredRequest
The deadline passed. Sign again.
ERC2771UntrustfulTarget
ERC2771UntrustfulTarget
Your contract doesn’t trust this forwarder. Deploy it with the forwarder’s address in the constructor.
FailedCall
FailedCall
The forwarder’s call to your contract reverted. Call your function directly with the same arguments to see why, or raise
gas.The contract records the forwarder as the sender
The contract records the forwarder as the sender
The function uses
msg.sender. Change it to _msgSender().Next steps
Run a relayer
Accept signed requests over HTTP, safely.
Gasless app template
Contracts, relayer and web app.