Discovering Relays Dynamically
Hardcoding a relay URL works for demos, but production code needs to discover healthy relays automatically. In this lesson, you will query the OpenGSN RelayHub contract, vet the results, and pick a relay that is ready to carry your transaction. This approach mirrors what the Nimiq wallet uses in production.
Objectives
By the end of this lesson you will:
- Query on-chain events with ethers.js.
- Check each relay’s advertised metadata and on-chain balances.
- Filter out relays that are offline, outdated, or underfunded.
- Produce a resilient fallback chain when the preferred relay fails.
Before you start, skim the reference material so the field names feel familiar:
- RelayHub events in the OpenGSN docs.
- Nimiq’s gasless transfer architecture notes.
- Polygon’s gasless transaction guidelines.
Step 1: Pull Recent Relay Registrations
RelayHub emits a RelayServerRegistered event whenever a relay announces itself. Scan the recent blocks to collect candidates.
1const RELAY_HUB_ABI = ['event RelayServerRegistered(address indexed relayManager, uint256 baseRelayFee, uint256 pctRelayFee, string relayUrl)']2const relayHub = new ethers.Contract(RELAY_HUB_ADDRESS, RELAY_HUB_ABI, provider)3
4const currentBlock = await provider.getBlockNumber()5const LOOKBACK_BLOCKS = 14400 // ~10 hours on Polygon6
7const events = await relayHub.queryFilter(8 relayHub.filters.RelayServerRegistered(),9 currentBlock - LOOKBACK_BLOCKS,10 currentBlock11)12
13const seen = new Map()14
15for (const event of events) {16 const { relayManager, baseRelayFee, pctRelayFee, relayUrl } = event.args17 if (!seen.has(relayUrl)) {18 seen.set(relayUrl, {19 url: relayUrl,20 relayManager,21 baseRelayFee,22 pctRelayFee,23 })24 }25}26
27const candidates = Array.from(seen.values())28console.log(`Found ${candidates.length} unique relay URLs`)Looking back roughly 10 hours balances freshness with performance. Adjust the window if you need more or fewer candidates.
Step 2: Ping and Validate Each Relay
For every registration, call the /getaddr endpoint and run a series of health checks before trusting it.
1async function validateRelay(relay, provider) {2 try {3 const controller = new AbortController()4 const timeout = setTimeout(() => controller.abort(), 10_000)5
6 const response = await fetch(`${relay.url}/getaddr`, { signal: controller.signal })7
8 clearTimeout(timeout)9
10 if (!response.ok)11 return null12
13 const relayInfo = await response.json()14
15 if (!relayInfo.version?.startsWith('2.'))16 return null17 if (relayInfo.networkId !== '137' && relayInfo.chainId !== '137')18 return null19 if (!relayInfo.ready)20 return null21
22 const workerBalance = await provider.getBalance(relayInfo.relayWorkerAddress)23 if (workerBalance.lt(ethers.utils.parseEther('0.01')))24 return null25
26 const pctFee = Number.parseInt(relay.pctRelayFee)27 const baseFee = ethers.BigNumber.from(relay.baseRelayFee)28
29 if (pctFee > 70 || baseFee.gt(0))30 return null31
32 return {33 ...relay,34 relayWorkerAddress: relayInfo.relayWorkerAddress,35 minGasPrice: relayInfo.minGasPrice,36 version: relayInfo.version,37 }38 }39 catch (error) {40 return null // Relay offline or invalid41 }42}AbortController gives us a portable timeout without extra dependencies, which keeps the sample compatible with both Node.js scripts and browser bundlers.
Checks to keep in mind:
- Version must start with 2.x to match the OpenGSN v2 protocol.
- Network / chain ID should be 137 for Polygon mainnet.
- Worker balance needs enough POL to front your transaction (the example uses 0.01 POL as a floor).
- Readiness flag confirms the relay advertises itself as accepting requests.
- Fee caps ensure you never accept a base fee or a percentage beyond your policy.
Step 3: Select the First Healthy Relay
Iterate through the registrations until you find one that passes validation. You can collect alternates for fallback if desired.
1async function findBestRelay(provider) {2 console.log('\n🔬 Validating relays...')3
4 for (const relay of candidates) {5 const validRelay = await validateRelay(relay, provider)6 if (validRelay)7 return validRelay8 }9
10 throw new Error('No valid relays found')11}12
13const relay = await findBestRelay(provider)14console.log('✅ Using relay:', relay.url)This simple loop already improves reliability dramatically compared to a hardcoded URL.
Why This Beats a Static Relay
- ✅ Automatically skips relays that are offline or misconfigured.
- ✅ Picks up newly registered relays without code changes.
- ✅ Gives you hooks to rank relays by price, latency, or trust level.
- ❌ Still relies on static fee estimates (we will tackle that in the next lesson).
Wrap-Up
You now have a discovery pipeline that:
- ✅ Queries RelayHub for fresh relay registrations.
- ✅ Validates each relay’s network, version, balance, and responsiveness.
- ✅ Falls back gracefully when a relay fails health checks.
- ✅ Removes the last hardcoded relay URL from your workflow.
Next up: Optimized Fee Calculation, where you replace static fees with a dynamic, production-ready calculation.
- npm install
- npm run discover