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
The signed request
The user signs EIP-712 typed data called aForwardRequest:
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.{ 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.