Skip to content

Commit 305cde5

Browse files
committed
fix: lint gasless transfer lessons
1 parent ead4e48 commit 305cde5

File tree

6 files changed

+128
-64
lines changed

6 files changed

+128
-64
lines changed

src/content/tutorial/6-gasless-transfers/4-static-relay/_solution/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
21
import { HttpClient, HttpWrapper } from '@opengsn/common'
2+
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
33
import { ethers } from 'ethers'
44

55
// 🔐 Paste your private key from Lesson 1 here!

src/content/tutorial/6-gasless-transfers/4-static-relay/content.md

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,13 @@ Key roles involved:
3232
- **Transfer contract** executes the token move and fee payment.
3333
- **RelayHub** validates and routes meta-transactions across the network.
3434

35+
If you have never met OpenGSN before, keep this component cheat sheet handy:
36+
37+
- **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](https://developers.nimiq.com/).
38+
- **Paymaster:** refunds the relay in tokens such as USDT or USDC. For this tutorial the same transfer contract doubles as paymaster.
39+
- **RelayHub:** the canonical on-chain registry of relays. Its API is documented in the [OpenGSN Docs](https://docs.opengsn.org/).
40+
- **Relay server:** an off-chain service that watches the hub and exposes `/getaddr` plus `/relay` endpoints. Polygon’s networking requirements for relays are outlined in the [Polygon developer documentation](https://docs.polygon.technology/).
41+
3542
---
3643

3744
## Guardrails for This Lesson
@@ -50,7 +57,7 @@ Later lessons will replace each shortcut with production logic.
5057

5158
Create or update your `.env` file with the following values:
5259

53-
```bash
60+
```bash title=".env"
5461
POLYGON_RPC_URL=https://polygon-rpc.com
5562
SPONSOR_PRIVATE_KEY=your_mainnet_key_with_USDT
5663
RECEIVER_ADDRESS=0x...
@@ -64,7 +71,7 @@ RELAY_URL=https://polygon-mainnet-relay.nimiq-network.com
6471

6572
## Step 2: Connect and Define Contract Addresses
6673

67-
```js
74+
```js title="index.js" showLineNumbers mark=6-13
6875
import dotenv from 'dotenv'
6976
import { ethers } from 'ethers'
7077

@@ -82,14 +89,17 @@ console.log('🔑 Sponsor:', wallet.address)
8289
```
8390

8491
The sponsor wallet is the account that will sign messages and reimburse the relay.
92+
The concrete contract addresses are published in `@cashlink/currency`’s constants module and mirrored in the [Nimiq wallet gasless guide](https://developers.nimiq.com/). Always verify them against the latest deployment notes before running on mainnet.
8593

8694
---
8795

8896
## Step 3: Retrieve the USDT Nonce and Approval Amount
8997

90-
USDT uses its own permit-style approval. Fetch the current nonce and compute how much the transfer contract is allowed to spend (transfer amount + relay fee).
98+
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](https://docs.opengsn.org/contracts/erc-2771.html)), so we can safely reuse it when we sign the approval.
99+
100+
Fetch the current nonce and compute how much the transfer contract is allowed to spend (transfer amount + relay fee).
91101

92-
```js
102+
```js title="index.js" showLineNumbers mark=3-9
93103
const USDT_ABI = ['function nonces(address owner) view returns (uint256)']
94104
const usdt = new ethers.Contract(USDT_ADDRESS, USDT_ABI, provider)
95105

@@ -106,9 +116,9 @@ const approvalAmount = amountToSend.add(staticFee)
106116

107117
## Step 4: Sign the USDT Meta-Approval
108118

109-
USDT on Polygon uses `executeMetaTransaction` for gasless approvals. Build the EIP-712 MetaTransaction payload and sign it.
119+
USDT on Polygon uses `executeMetaTransaction` for gasless approvals. Build the EIP712 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](https://docs.opengsn.org/gsn-provider/metatx.html) to see the differences.
110120

111-
```js
121+
```js title="index.js" showLineNumbers mark=9-19
112122
// First, encode the approve function call
113123
const approveFunctionSignature = usdt.interface.encodeFunctionData('approve', [
114124
TRANSFER_CONTRACT_ADDRESS,
@@ -151,7 +161,7 @@ This signature allows the relay to execute the `approve` call on your behalf via
151161

152162
Prepare the calldata the relay will submit on your behalf.
153163

154-
```js
164+
```js title="index.js" showLineNumbers mark=6-14
155165
const TRANSFER_ABI = ['function transferWithApproval(address token, uint256 amount, address to, uint256 fee, uint256 approval, bytes32 r, bytes32 s, uint8 v)']
156166
const transferContract = new ethers.Contract(TRANSFER_CONTRACT_ADDRESS, TRANSFER_ABI, wallet)
157167

@@ -173,9 +183,9 @@ console.log('📦 Calldata encoded')
173183

174184
## Step 6: Build and Sign the Relay Request
175185

176-
The relay expects a second EIP-712 signature covering the meta-transaction wrapper. Gather the contract nonce and sign the payload.
186+
The relay expects a second EIP712 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.
177187

178-
```js
188+
```js title="index.js" showLineNumbers mark=6-21
179189
const transferNonce = await transferContract.getNonce(wallet.address)
180190

181191
const relayRequest = {
@@ -186,22 +196,23 @@ const relayRequest = {
186196
gas: '350000',
187197
nonce: transferNonce.toString(),
188198
data: transferCalldata,
189-
validUntilTime: (Math.floor(Date.now() / 1000) + 7200).toString()
199+
validUntil: (Math.floor(Date.now() / 1000) + 7200).toString()
190200
},
191201
relayData: {
192202
gasPrice: '100000000000', // 100 gwei (static!)
193203
pctRelayFee: '0',
194204
baseRelayFee: '0',
195-
relayWorker: 'will_be_filled_by_relay',
205+
relayWorker: '0x0000000000000000000000000000000000000000', // Will be filled by relay
196206
paymaster: TRANSFER_CONTRACT_ADDRESS,
197-
forwarder: '0x...', // Trusted Forwarder address
207+
forwarder: TRANSFER_CONTRACT_ADDRESS,
208+
paymasterData: '0x',
198209
clientId: '1'
199210
}
200211
}
201212

202213
// Sign it
203-
const relayDomain = { name: 'GSN Relayed Transaction', version: '2', chainId: 137, verifyingContract: '0x...' }
204-
const relayTypes = { /* RelayRequest types */ }
214+
const relayDomain = { name: 'GSN Relayed Transaction', version: '2', chainId: 137, verifyingContract: TRANSFER_CONTRACT_ADDRESS }
215+
const relayTypes = { /* RelayRequest types – see docs.opengsn.org for the full schema */ }
205216
const relaySignature = await wallet._signTypedData(relayDomain, relayTypes, relayRequest)
206217

207218
console.log('✍️ Relay request signed')
@@ -211,23 +222,28 @@ console.log('✍️ Relay request signed')
211222

212223
## Step 7: Submit the Meta-Transaction
213224

214-
Use the OpenGSN HTTP client to send the request to your chosen relay.
225+
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.
215226

216-
```js
227+
```js title="index.js" showLineNumbers mark=1-18
217228
import { HttpClient, HttpWrapper } from '@opengsn/common'
218229

230+
const relayNonce = await provider.getTransactionCount(relayInfo.relayWorkerAddress)
231+
219232
const httpClient = new HttpClient(new HttpWrapper(), console)
220-
const relayResponse = await httpClient.relayTransaction(process.env.RELAY_URL, {
233+
const relayResponse = await httpClient.relayTransaction(RELAY_URL, {
221234
relayRequest,
222235
metadata: {
223236
signature: relaySignature,
224237
approvalData: '0x',
225238
relayHubAddress: RELAY_HUB_ADDRESS,
226-
relayMaxNonce: 999999
239+
relayMaxNonce: relayNonce + 3
227240
}
228241
})
229242

230-
const txHash = typeof relayResponse === 'string' ? relayResponse : relayResponse.signedTx
243+
const txHash = typeof relayResponse === 'string'
244+
? relayResponse
245+
: relayResponse.signedTx || relayResponse.txHash
246+
231247
console.log('\n✅ Gasless transaction sent!')
232248
console.log('🔗 View:', `https://polygonscan.com/tx/${txHash}`)
233249
```
@@ -263,4 +279,4 @@ You have now:
263279
- ✅ Understood the flow between approval, relay request, and paymaster contract.
264280
- ✅ Prepared the foundation for relay discovery and fee optimization.
265281

266-
Continue to **Lesson 4** to discover relays dynamically via RelayHub events.
282+
Next up, **Lesson 5** walks through discovering relays dynamically from the RelayHub and filtering them with health checks informed by the [OpenGSN relay operator guide](https://docs.opengsn.org/relay/). That will let you replace today’s hardcoded URL with resilient discovery logic.

src/content/tutorial/6-gasless-transfers/5-relay-discovery/_solution/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
21
import { HttpClient, HttpWrapper } from '@opengsn/common'
3-
import { ethers} from 'ethers'
2+
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
3+
import { ethers } from 'ethers'
44

55
// 🔐 Paste your private key from Lesson 1 here!
66
const PRIVATE_KEY = '0xPASTE_YOUR_PRIVATE_KEY_HERE_FROM_LESSON_1'

src/content/tutorial/6-gasless-transfers/5-relay-discovery/content.md

Lines changed: 71 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -26,94 +26,133 @@ By the end of this lesson you will:
2626
- Filter out relays that are offline, outdated, or underfunded.
2727
- Produce a resilient fallback chain when the preferred relay fails.
2828

29+
Before you start, skim the reference material so the field names feel familiar:
30+
31+
- [RelayHub events in the OpenGSN docs](https://docs.opengsn.org/contracts/relay-hub.html).
32+
- Nimiq’s [gasless transfer architecture notes](https://developers.nimiq.com/).
33+
- Polygon’s [gasless transaction guidelines](https://docs.polygon.technology/).
34+
2935
---
3036

3137
## Step 1: Pull Recent Relay Registrations
3238

3339
RelayHub emits a `RelayServerRegistered` event whenever a relay announces itself. Scan the recent blocks to collect candidates.
3440

35-
```js
41+
```js title="discover-relays.ts" showLineNumbers mark=6-24
3642
const RELAY_HUB_ABI = ['event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)']
3743
const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider)
3844

3945
const currentBlock = await provider.getBlockNumber()
40-
const LOOKBACK_BLOCKS = 144000 // ~60 hours on Polygon
46+
const LOOKBACK_BLOCKS = 14400 // ~10 hours on Polygon
4147

4248
const events = await relayHub.queryFilter(
4349
relayHub.filters.RelayServerRegistered(),
4450
currentBlock - LOOKBACK_BLOCKS,
4551
currentBlock
4652
)
4753

48-
console.log(`Found ${events.length} relay registrations`)
54+
const seen = new Map()
55+
56+
for (const event of events) {
57+
const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args
58+
if (!seen.has(relayUrl)) {
59+
seen.set(relayUrl, {
60+
url: relayUrl,
61+
relayManager,
62+
baseRelayFee,
63+
pctRelayFee,
64+
})
65+
}
66+
}
67+
68+
const candidates = Array.from(seen.values())
69+
console.log(`Found ${candidates.length} unique relay URLs`)
4970
```
5071

51-
Looking back roughly 60 hours balances freshness with performance. Adjust the window if you need more or fewer candidates.
72+
Looking back roughly 10 hours balances freshness with performance. Adjust the window if you need more or fewer candidates.
5273

5374
---
5475

5576
## Step 2: Ping and Validate Each Relay
5677

5778
For every registration, call the `/getaddr` endpoint and run a series of health checks before trusting it.
5879

59-
```js
60-
import axios from 'axios'
61-
62-
async function validateRelay(relayUrl) {
80+
```js title="validate-relay.ts" showLineNumbers mark=6-29
81+
async function validateRelay(relay, provider) {
6382
try {
64-
const response = await axios.get(`${relayUrl}/getaddr`, { timeout: 10000 })
65-
const { relayWorkerAddress, ready, version, networkId, minGasPrice } = response.data
83+
const controller = new AbortController()
84+
const timeout = setTimeout(() => controller.abort(), 10_000)
85+
86+
const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal })
87+
88+
clearTimeout(timeout)
89+
90+
if (!response.ok)
91+
return null
92+
93+
const relayInfo = await response.json()
6694

67-
// Check version
68-
if (!version.startsWith('2.'))
95+
if (!relayInfo.version?.startsWith('2.'))
96+
return null
97+
if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137')
98+
return null
99+
if (!relayInfo.ready)
69100
return null
70101

71-
// Check network
72-
if (networkId !== 137)
102+
const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress)
103+
if (workerBalance.lt(ethers.utils.parseEther('0.01')))
73104
return null
74105

75-
// Check worker balance
76-
const workerBalance = await provider.getBalance(relayWorkerAddress)
77-
const requiredBalance = ethers.utils.parseEther('0.01') // Minimum threshold
78-
if (workerBalance.lt(requiredBalance))
106+
const pctFee = Number.parseInt(relay.pctRelayFee)
107+
const baseFee = ethers.BigNumber.from(relay.baseRelayFee)
108+
109+
if (pctFee > 70 || baseFee.gt(0))
79110
return null
80111

81-
// Check recent activity (skip for Fastspot relays)
82-
if (!relayUrl.includes('fastspot.io')) {
83-
const recentTx = await provider.getTransactionCount(relayWorkerAddress)
84-
// ... validate transaction recency
112+
return {
113+
...relay,
114+
relayWorkerAddress: relayInfo.relayWorkerAddress,
115+
minGasPrice: relayInfo.minGasPrice,
116+
version: relayInfo.version,
85117
}
86-
87-
return { url: relayUrl, worker: relayWorkerAddress, minGasPrice, ...response.data }
88118
}
89119
catch (error) {
90120
return null // Relay offline or invalid
91121
}
92122
}
93123
```
94124
125+
`AbortController` gives us a portable timeout without extra dependencies, which keeps the sample compatible with both Node.js scripts and browser bundlers.
126+
95127
Checks to keep in mind:
96128
97129
- **Version** must start with 2.x to match the OpenGSN v2 protocol.
98130
- **Network ID** should be 137 for Polygon mainnet.
99131
- **Worker balance** needs enough POL to front your transaction (the example uses 0.01 POL as a floor).
100-
- **Recent activity** ensures the worker is still alive and signing transactions.
132+
- **Readiness flag** confirms the relay advertises itself as accepting requests.
133+
- **Fee caps** ensure you never accept a base fee or a percentage beyond your policy.
101134
102135
---
103136
104137
## Step 3: Select the First Healthy Relay
105138
106139
Iterate through the registrations until you find one that passes validation. You can collect alternates for fallback if desired.
107140
108-
```js
109-
for (const event of events) {
110-
const relay = await validateRelay(event.args.relayUrl)
111-
if (relay) {
112-
console.log('✅ Found valid relay:', relay.url)
113-
// Use this relay for your transaction
114-
break
141+
```js title="find-best-relay.ts" showLineNumbers mark=3-14
142+
async function findBestRelay(provider) {
143+
console.log('\n🔬 Validating relays...')
144+
145+
for (const relay of candidates) {
146+
const validRelay = await validateRelay(relay, provider)
147+
if (validRelay)
148+
return validRelay
115149
}
150+
151+
throw new Error('No valid relays found')
116152
}
153+
154+
const relay = await findBestRelay(provider)
155+
console.log('✅ Using relay:', relay.url)
117156
```
118157
119158
This simple loop already improves reliability dramatically compared to a hardcoded URL.

src/content/tutorial/6-gasless-transfers/6-optimized-fees/_solution/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
21
import { HttpClient, HttpWrapper } from '@opengsn/common'
2+
import { TypedRequestData } from '@opengsn/common/dist/EIP712/TypedRequestData.js'
33
import { ethers } from 'ethers'
44

55
// 🔐 Paste your private key from Lesson 1 here!

0 commit comments

Comments
 (0)