Skip to main content
Follow the gasless app template file by file: the signed request, the contracts that trust it, and the checks the relayer makes before it pays.
Ready. This template is published in uzolabs/templates and has been deployed and verified on testnet. It’s a learning template and hasn’t been audited. Use test funds only.

Project layout

Project layout
Deploy and verify work as in the token template, except that two contracts are deployed: the forwarder first, then the guest book with the forwarder’s address. See Token walkthrough.

The signed request

The user signs EIP-712 typed data called a ForwardRequest: The EIP-712 domain is (“UzoForwarder”, “1”, chain ID, forwarder address). The app and npm run sign read it from the forwarder’s eip712Domain(), so it always matches the deployed contract.

The contracts

UzoForwarder is OpenZeppelin’s ERC2771Forwarder with a name. execute(request) checks the signature, nonce and deadline, then calls the target with the signer’s address appended to the calldata. GuestBook extends ERC2771Context and trusts that forwarder. Its _msgSender() returns the appended address when the caller is the forwarder, and msg.sender otherwise. Anyone can also call sign directly and pay their own gas. They’re still recorded as the author.

What the relayer checks

POST /relay takes { "request": { from, to, value, gas, deadline, data, signature } }, with numbers as decimal strings. In order, the relayer:
1

Checks the shape

Refuses bodies over 8 KB and anything that isn’t a well-formed request.
2

Checks the target

Refuses any contract except the deployed guest book and any function except sign(string), with a 403.
3

Checks the limits

Refuses a value other than 0, gas above RELAYER_MAX_GAS (300,000 by default), a past deadline, and an empty or too-long message.
4

Applies rate limits

Counts the request against the signer and the caller’s IP address, 10 each per hour by default. Over the limit it answers 429 with Retry-After. Rejected requests count too, so nobody can make the relayer call the node for free.
5

Asks the forwarder

Calls the forwarder’s verify(request) to check the signature, nonce, deadline and that the guest book trusts this forwarder.
6

Simulates, then sends

Simulates execute(request) and returns a plain reason if it would revert. Otherwise it sends it, one transaction at a time so nonces never clash, and waits up to 60 seconds for the receipt.
The response is { txHash, txUrl, status, paidBy, relayer }. GET /health returns the relayer’s address, balance, chain and paymaster setting. The relayer logs a warning when its balance falls below RELAYER_LOW_BALANCE, 1 tBOT by default.

Relayer settings

Rate limits live in memory, so they reset when the relayer restarts. The relayer reads contract addresses from deployments/<chainId>.json, or from GUESTBOOK_ADDRESS and FORWARDER_ADDRESS if you set them.

The optional paymaster

relayer/src/paymaster.ts implements BOT Chain’s EOA paymaster flow: ask the paymaster pm_isSponsorable, and if it agrees, send the transaction with a gas price of 0. It’s only used when PAYMASTER_URL is set. If the paymaster refuses or fails, the relayer pays the gas itself, and the web app shows who paid. See EOA paymaster.

The web app

frontend/src/forward.ts builds the request (domain, nonce, gas estimate, deadline) and posts it to the relayer. App.tsx only ever asks the wallet for eth_signTypedData_v4. The sign card moves through “Preparing”, “Sign in your wallet”, “Relaying”, then “Signed. Gas paid by relayer” with a BOTScan link. The guest book list reads entryCount(), then the newest 20 entries with getEntries, every 10 seconds. It doesn’t use the Signed event, because BOT Chain’s public RPCs don’t serve eth_getLogs. A relayer card shows the relayer’s address and balance, and warns when it’s offline, low on tBOT or on another chain.

Next steps

Customize

Relay your own contract and host the relayer.

Run a relayer

Running a relayer in production.
Last modified on October 3, 2026