Gasless with Static Relay
You just measured the cost of a standard USDT transfer. Now we will send the same payment through OpenGSN, where a relay covers the POL gas and you reimburse it in USDT. This first iteration keeps everything intentionally simple so you can see each moving part clearly before layering on optimizations in later lessons.
OpenGSN at a Glance
The gasless flow has three stages:
- You sign a meta-transaction off-chain. No POL is spent yet.
- A relay server broadcasts that request on-chain and pays the POL gas.
- Your transfer contract reimburses the relay in USDT (amount plus fee).
Key roles involved:
- Sponsor (you) signs messages and funds relay fees in USDT.
- Relay servers front the POL gas and expect reimbursement.
- Transfer contract executes the token move and fee payment.
- RelayHub validates and routes meta-transactions across the network.
If you have never met OpenGSN before, keep this component cheat sheet handy:
- Forwarder: verifies the meta-transaction signature and keeps per-sender nonces so relays cannot replay old requests. The Nimiq transfer contract bundles a forwarder implementation; see the reference in the Nimiq Developer Center.
- Paymaster: refunds the relay in tokens such as USDT or USDC. For this tutorial the same transfer contract doubles as paymaster.
- RelayHub: the canonical on-chain registry of relays. Its API is documented in the OpenGSN Docs.
- Relay server: an off-chain service that watches the hub and exposes
/getaddrplus/relayendpoints. Polygon’s networking requirements for relays are outlined in the Polygon developer documentation.
Guardrails for This Lesson
To keep the walkthrough approachable we will:
- Hardcode a known relay URL instead of discovering one dynamically.
- Use a static relay fee (0.1 USDT) and a fixed gas price.
- Work entirely on Polygon mainnet because OpenGSN is not deployed on Amoy.
Later lessons will replace each shortcut with production logic.
Step 1: Configure Environment Variables
Create or update your .env file with the following values:
POLYGON_RPC_URL=https://polygon-rpc.comSPONSOR_PRIVATE_KEY=your_mainnet_key_with_USDTRECEIVER_ADDRESS=0x...TRANSFER_AMOUNT_USDT=1.0RELAY_URL=https://polygon-relay.fastspot.io⚠️ Mainnet required: you need a mainnet wallet that holds at least 1-2 USDT and a small amount of POL. Acquire funds via your preferred exchange or bridge service.
Step 2: Connect and Define Contract Addresses
1import dotenv from 'dotenv'2import { ethers } from 'ethers'3
4dotenv.config()5
6const provider = new ethers.providers.JsonRpcProvider(process.env.POLYGON_RPC_URL)7const wallet = new ethers.Wallet(process.env.SPONSOR_PRIVATE_KEY, provider)8
9// Contract addresses (Polygon mainnet)10const USDT_ADDRESS = '0xc2132D05D31c914a87C6611C10748AEb04B58e8F'11const TRANSFER_CONTRACT_ADDRESS = '0x...' // Nimiq's transfer contract12const RELAY_HUB_ADDRESS = '0x...' // OpenGSN RelayHub13
14console.log('🔑 Sponsor:', wallet.address)The sponsor wallet is the account that will sign messages and reimburse the relay. The concrete contract addresses are documented in the Nimiq Developer Center. Always verify them against the latest deployment notes before running on mainnet.
Step 3: Retrieve the USDT Nonce and Approval Amount
USDT on Polygon does not implement the standard ERC‑2612 permit. Instead it exposes executeMetaTransaction, which expects you to sign the encoded approve call. The nonces counter you query below is USDT’s own meta-transaction nonce (documented in Tether’s contract implementation), so we can safely reuse it when we sign the approval.
Fetch the current nonce and compute how much the transfer contract is allowed to spend (transfer amount + relay fee).
1const USDT_ABI = ['function nonces(address owner) view returns (uint256)']2const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider)3
4const nonce = await usdt.nonces(wallet.address)5console.log('📝 USDT Nonce:', nonce.toString())6
7// Calculate amounts8const amountToSend = ethers.utils.parseUnits(process.env.TRANSFER_AMOUNT_USDT, 6)9const staticFee = ethers.utils.parseUnits('0.1', 6) // 0.1 USDT fee (static!)10const approvalAmount = amountToSend.add(staticFee)Step 4: Sign the USDT Meta-Approval
USDT on Polygon uses executeMetaTransaction for gasless approvals. Build the EIP‑712 MetaTransaction payload and sign it. Notice the domain uses the salt field instead of chainId; that is specific to the USDT contract. Compare this to the generic permit flow covered in OpenGSN’s meta-transaction docs to see the differences.
1// First, encode the approve function call2const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [3 TRANSFER_CONTRACT_ADDRESS,4 approvalAmount5])6
7// Build the MetaTransaction EIP-712 domain8const domain = {9 name: 'USDT0',10 version: '1',11 verifyingContract: USDT_ADDRESS,12 salt: ethers.utils.hexZeroPad(ethers.utils.hexlify(137), 32) // chainId as salt13}14
15const types = {16 MetaTransaction: [17 { name: 'nonce', type: 'uint256' },18 { name: 'from', type: 'address' },19 { name: 'functionSignature', type: 'bytes' }20 ]21}22
23const message = {24 nonce: nonce.toNumber(),25 from: wallet.address,26 functionSignature: approveFunctionSignature27}28
29const signature = await wallet._signTypedData(domain, types, message)30const { r, s, v } = ethers.utils.splitSignature(signature)31
32console.log('✍️ USDT approval signed')This signature allows the relay to execute the approve call on your behalf via executeMetaTransaction.
Step 5: Encode the Transfer Call
Prepare the calldata the relay will submit on your behalf.
1const TRANSFER_ABI = ['function transferWithApproval(address token, uint256 amount, address to, uint256 fee, uint256 approval, bytes32 r, bytes32 s, uint8 v)']2const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, wallet)3
4const transferCalldata = transferContract.interface.encodeFunctionData('transferWithApproval', [5 USDT_ADDRESS,6 amountToSend,7 process.env.RECEIVER_ADDRESS,8 staticFee,9 approvalAmount,10 r,11 s,12 v13])14
15console.log('📦 Calldata encoded')Step 6: Build and Sign the Relay Request
The relay expects a second EIP‑712 signature covering the meta-transaction wrapper. This time the domain is the forwarder (embedded inside the transfer contract). Gather the contract nonce and sign the payload.
1const transferNonce = await transferContract.getNonce(wallet.address)2
3const relayRequest = {4 request: {5 from: wallet.address,6 to: TRANSFER_CONTRACT_ADDRESS,7 value: '0',8 gas: '350000',9 nonce: transferNonce.toString(),10 data: transferCalldata,11 validUntil: (Math.floor(Date.now() / 1000) + 7200).toString()12 },13 relayData: {14 gasPrice: '100000000000', // 100 gwei (static!)15 pctRelayFee: '0',16 baseRelayFee: '0',17 relayWorker: '0x0000000000000000000000000000000000000000', // Will be filled by relay18 paymaster: TRANSFER_CONTRACT_ADDRESS,19 forwarder: TRANSFER_CONTRACT_ADDRESS,20 paymasterData: '0x',21 clientId: '1'22 }23}24
25// Sign it26const relayDomain = { name: 'GSN Relayed Transaction', version: '2', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS }27const relayTypes = { /* RelayRequest types – see docs.opengsn.org for the full schema */ }28const relaySignature = await wallet._signTypedData(relayDomain, relayTypes, relayRequest)29
30console.log('✍️ Relay request signed')Step 7: Submit the Meta-Transaction
Use the OpenGSN HTTP client to send the request to your chosen relay. The worker nonce check prevents you from handing the relay a relayMaxNonce that is already stale — if the worker broadcasts several transactions in quick succession, your request will still slide in. Likewise, validUntil in the previous step protects the relay from signing requests that could be replayed months later.
1import { HttpClient, HttpWrapper } from '@opengsn/common'2
3const relayNonce = await provider.getTransactionCount(relayInfo.relayWorkerAddress)4
5const httpClient = new HttpClient(new HttpWrapper(), console)6const relayResponse = await httpClient.relayTransaction(RELAY_URL, {7 relayRequest,8 metadata: {9 signature: relaySignature,10 approvalData: '0x',11 relayHubAddress: RELAY_HUB_ADDRESS,12 relayMaxNonce: relayNonce + 313 }14})15
16const txHash = typeof relayResponse === 'string'17 ? relayResponse18 : relayResponse.signedTx || relayResponse.txHash19
20console.log('\n✅ Gasless transaction sent!')21console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`)Recap: What Just Happened
- You signed a USDT meta-approval without spending gas.
- You signed a meta-transaction request for the relay.
- The relay paid POL to submit the transaction on-chain.
- The receiver received USDT minus the 0.1 USDT relay fee.
- Your wallet retained its POL balance.
Limitations to Keep in Mind
- ❌ Hardcoded relay URL (no fallback if it goes offline).
- ❌ Static fee and gas price (no adaptation to network conditions).
- ❌ No validation of relay health beyond a single request.
The next lessons address each of these gaps.
Wrap-Up
You have now:
- ✅ Sent USDT without paying POL yourself.
- ✅ Practiced constructing and signing OpenGSN meta-transactions.
- ✅ Understood the flow between approval, relay request, and paymaster contract.
- ✅ Prepared the foundation for relay discovery and fee optimization.
Next up, Discovering Relays Dynamically walks through discovering relays from RelayHub and filtering them with health checks informed by the OpenGSN relay operator guide. That will let you replace today’s hardcoded URL with resilient discovery logic.
- npm install
- npm run gasless