Skip to content

Commit 16c41dd

Browse files
committed
feat: implement multi-RPC failover for source chains
Sequential failover across multiple RPC endpoints with exponential backoff. Replaces single SOURCE_RPC_URL with comma-delimited SOURCE_RPC_URLS. Retries indefinitely on network failures with graceful shutdown support.
1 parent 2cb09fe commit 16c41dd

18 files changed

+1398
-207
lines changed

paymaster-relayer/.env.example

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,22 @@ ROFL_ADAPTER_ADDRESS=0xYourROFLAdapterOnSapphire
1414
# ===========================================
1515
# BASE SEPOLIA (Chain ID: 84532) -> relayer-base
1616
# ===========================================
17-
BASE_SOURCE_RPC_URL=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY
17+
BASE_SOURCE_RPC_URLS=https://base-sepolia.g.alchemy.com/v2/YOUR_KEY
18+
# Add failover RPCs with commas: https://url1,https://url2,https://url3
1819
BASE_VAULT_ADDRESS=0xYourPaymasterVaultOnBase
1920

2021
# ===========================================
2122
# ETHEREUM SEPOLIA (Chain ID: 11155111) -> relayer-eth
2223
# ===========================================
23-
ETH_SOURCE_RPC_URL=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY
24+
ETH_SOURCE_RPC_URLS=https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY
25+
# Add failover RPCs with commas: https://url1,https://url2,https://url3
2426
ETH_VAULT_ADDRESS=0xYourPaymasterVaultOnEthereum
2527

2628
# ===========================================
2729
# ARBITRUM SEPOLIA (Chain ID: 421614) -> relayer-arb
2830
# ===========================================
29-
ARB_SOURCE_RPC_URL=https://arb-sepolia.g.alchemy.com/v2/YOUR_KEY
31+
ARB_SOURCE_RPC_URLS=https://arb-sepolia.g.alchemy.com/v2/YOUR_KEY
32+
# Add failover RPCs with commas: https://url1,https://url2,https://url3
3033
ARB_VAULT_ADDRESS=0xYourPaymasterVaultOnArbitrum
3134

3235
# ===========================================

paymaster-relayer/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ ROFL deployment in one go.
4242

4343
| Variable | Required | Description |
4444
|----------|----------|-------------|
45-
| `SOURCE_RPC_URL` | Yes | Source chain RPC endpoint (HTTP) |
45+
| `SOURCE_RPC_URLS` | Yes | Source chain RPC endpoints (comma-separated for failover) |
4646
| `PAYMASTER_VAULT_ADDRESS` | Yes | PaymasterVault contract address on source chain |
4747
| `TARGET_RPC_URL` | Yes | Sapphire RPC endpoint |
4848
| `PAYMASTER_PROXY_ADDRESS` | Yes | CrossChainPaymaster proxy address on Sapphire |

paymaster-relayer/compose.local.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ x-relayer-common: &relayer-common
55
build:
66
context: .
77
dockerfile: Dockerfile
8-
image: ghcr.io/oasisprotocol/rofl-paymaster-relayer:multichain-test
8+
image: ghcr.io/oasisprotocol/rofl-paymaster-relayer:multirpc-test
99
platform: linux/amd64
1010
entrypoint: /bin/sh -c 'uv run python -m paymaster_relayer --local'
1111
restart: on-failure
@@ -31,7 +31,7 @@ services:
3131
environment:
3232
<<: *common-env
3333
CHAIN_NAME: base
34-
SOURCE_RPC_URL: ${BASE_SOURCE_RPC_URL}
34+
SOURCE_RPC_URLS: ${BASE_SOURCE_RPC_URLS}
3535
PAYMASTER_VAULT_ADDRESS: ${BASE_VAULT_ADDRESS}
3636
POLLING_INTERVAL: ${BASE_POLLING_INTERVAL:-6}
3737

@@ -43,7 +43,7 @@ services:
4343
environment:
4444
<<: *common-env
4545
CHAIN_NAME: ethereum
46-
SOURCE_RPC_URL: ${ETH_SOURCE_RPC_URL}
46+
SOURCE_RPC_URLS: ${ETH_SOURCE_RPC_URLS}
4747
PAYMASTER_VAULT_ADDRESS: ${ETH_VAULT_ADDRESS}
4848
POLLING_INTERVAL: ${ETH_POLLING_INTERVAL:-12}
4949

@@ -55,6 +55,6 @@ services:
5555
environment:
5656
<<: *common-env
5757
CHAIN_NAME: arbitrum
58-
SOURCE_RPC_URL: ${ARB_SOURCE_RPC_URL}
58+
SOURCE_RPC_URLS: ${ARB_SOURCE_RPC_URLS}
5959
PAYMASTER_VAULT_ADDRESS: ${ARB_VAULT_ADDRESS}
6060
POLLING_INTERVAL: ${ARB_POLLING_INTERVAL:-3}

paymaster-relayer/compose.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ services:
3131
environment:
3232
<<: *common-env
3333
CHAIN_NAME: base
34-
SOURCE_RPC_URL: ${BASE_SOURCE_RPC_URL}
34+
SOURCE_RPC_URLS: ${BASE_SOURCE_RPC_URLS}
3535
PAYMASTER_VAULT_ADDRESS: ${BASE_VAULT_ADDRESS}
3636
POLLING_INTERVAL: ${BASE_POLLING_INTERVAL:-6}
3737

@@ -43,7 +43,7 @@ services:
4343
environment:
4444
<<: *common-env
4545
CHAIN_NAME: ethereum
46-
SOURCE_RPC_URL: ${ETH_SOURCE_RPC_URL}
46+
SOURCE_RPC_URLS: ${ETH_SOURCE_RPC_URLS}
4747
PAYMASTER_VAULT_ADDRESS: ${ETH_VAULT_ADDRESS}
4848
POLLING_INTERVAL: ${ETH_POLLING_INTERVAL:-12}
4949

@@ -55,6 +55,6 @@ services:
5555
environment:
5656
<<: *common-env
5757
CHAIN_NAME: arbitrum
58-
SOURCE_RPC_URL: ${ARB_SOURCE_RPC_URL}
58+
SOURCE_RPC_URLS: ${ARB_SOURCE_RPC_URLS}
5959
PAYMASTER_VAULT_ADDRESS: ${ARB_VAULT_ADDRESS}
6060
POLLING_INTERVAL: ${ARB_POLLING_INTERVAL:-3}

paymaster-relayer/paymaster_relayer/__main__.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ async def main():
1919
)
2020
args = parser.parse_args()
2121

22-
logger.info(f"=== Paymaster Relayer Starting {'(LOCAL MODE)' if args.local else ''} ===")
22+
logger.info(
23+
f"=== Paymaster Relayer Starting {'(LOCAL MODE)' if args.local else ''} ==="
24+
)
2325

2426
relayer = None
2527

@@ -29,11 +31,19 @@ async def main():
2931
except ValueError as e:
3032
logger.error(f"Configuration error: {e}")
3133
logger.error("Required environment variables:")
32-
logger.error(" - SOURCE_RPC_URL: Source chain RPC endpoint (e.g., Ethereum)")
34+
logger.error(
35+
" - SOURCE_RPC_URLS: Source chain RPC endpoints (comma-separated)"
36+
)
3337
logger.error(" - TARGET_RPC_URL: Target chain RPC endpoint (e.g., Sapphire)")
34-
logger.error(" - PAYMASTER_VAULT_ADDRESS: PaymasterVault contract address (source)")
35-
logger.error(" - PAYMASTER_PROXY_ADDRESS: CrossChainPaymaster contract address (target)")
36-
logger.error(" - ROFL_ADAPTER_ADDRESS: ROFLAdapter contract address (target, HashStored)")
38+
logger.error(
39+
" - PAYMASTER_VAULT_ADDRESS: PaymasterVault contract address (source)"
40+
)
41+
logger.error(
42+
" - PAYMASTER_PROXY_ADDRESS: CrossChainPaymaster contract address (target)"
43+
)
44+
logger.error(
45+
" - ROFL_ADAPTER_ADDRESS: ROFLAdapter contract address (target, HashStored)"
46+
)
3747
if args.local:
3848
logger.error(" - PRIVATE_KEY: Private key for signing transactions")
3949
sys.exit(1)

paymaster-relayer/paymaster_relayer/config.py

Lines changed: 29 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,45 +7,37 @@
77
"""
88

99
import os
10-
import re
1110
from dataclasses import dataclass, field
1211

12+
from .utils.multi_rpc_provider import sanitize_url
1313

14-
def parse_indexed_rpc_urls(prefix: str) -> list[str]:
14+
15+
def parse_rpc_urls() -> list[str]:
1516
"""
16-
Parse indexed environment variables like PREFIX_1, PREFIX_2, etc.
17+
Parse SOURCE_RPC_URLS env var into a list of RPC endpoint URLs.
1718
18-
Args:
19-
prefix: The base name without the index (e.g., "SOURCE_RPC_URL")
19+
Splits on commas, strips whitespace, and filters empty entries.
2020
2121
Returns:
22-
List of URLs in ascending index order
22+
List of non-empty, trimmed URLs
2323
2424
Raises:
25-
ValueError: If no indexed environment variables are found
25+
ValueError: If SOURCE_RPC_URLS is missing or contains no valid URLs
2626
"""
27-
pattern = re.compile(rf"^{re.escape(prefix)}_(\d+)$")
28-
indexed_urls: list[tuple[int, str]] = []
29-
30-
for key, value in os.environ.items():
31-
match = pattern.match(key)
32-
if match:
33-
index = int(match.group(1))
34-
indexed_urls.append((index, value))
27+
raw = os.environ.get("SOURCE_RPC_URLS", "")
28+
urls = [url.strip() for url in raw.split(",") if url.strip()]
3529

36-
if not indexed_urls:
37-
raise ValueError(f"No {prefix}_N environment variables found")
30+
if not urls:
31+
raise ValueError("SOURCE_RPC_URLS environment variable is missing or empty")
3832

39-
# Sort by index and return just the URLs
40-
indexed_urls.sort(key=lambda x: x[0])
41-
return [url for _, url in indexed_urls]
33+
return urls
4234

4335

4436
@dataclass(frozen=True, slots=True)
4537
class SourceChainConfig:
4638
"""Configuration for the source chain (e.g., Base/Sepolia)."""
4739

48-
rpc_url: str
40+
rpc_urls: list[str]
4941
paymaster_vault_address: str
5042

5143

@@ -68,7 +60,9 @@ class MonitoringConfig:
6860
retry_count: int = 3
6961
lookback_blocks: int = 9
7062
process_batch_size: int = 10 # max events to process in one batch
71-
max_block_range: int = 10 # max blocks per get_logs request (Alchemy free tier limit)
63+
max_block_range: int = (
64+
10 # max blocks per get_logs request (Alchemy free tier limit)
65+
)
7266

7367
def __post_init__(self) -> None:
7468
"""Validate monitoring configuration."""
@@ -100,7 +94,6 @@ def __post_init__(self) -> None:
10094
f"Lookback blocks too high (max 1000), got {self.lookback_blocks}"
10195
)
10296

103-
10497
# Validate batch size
10598
if self.process_batch_size <= 0:
10699
raise ValueError(
@@ -121,6 +114,7 @@ def __post_init__(self) -> None:
121114
f"Max block range too large (max 10000), got {self.max_block_range}"
122115
)
123116

117+
124118
@dataclass(frozen=True, slots=True)
125119
class RelayerConfig:
126120
"""Main configuration class for the ROFL Relayer."""
@@ -141,13 +135,14 @@ def from_env(cls, local_mode: bool = False) -> "RelayerConfig":
141135
Raises:
142136
ValueError: If required environment variables are missing
143137
"""
144-
# Source chain configuration
145-
source_rpc_url = os.environ.get("SOURCE_RPC_URL")
146-
if not source_rpc_url:
138+
# Source chain configuration - parse comma-delimited RPC URLs
139+
try:
140+
source_rpc_urls = parse_rpc_urls()
141+
except ValueError:
147142
raise ValueError(
148-
"SOURCE_RPC_URL environment variable is required. "
149-
"Example: https://ethereum-sepolia.publicnode.com"
150-
)
143+
"SOURCE_RPC_URLS environment variable is required (comma-separated). "
144+
"Example: SOURCE_RPC_URLS=https://rpc1.example.com,https://rpc2.example.com"
145+
) from None
151146

152147
paymaster_vault_address = os.environ.get("PAYMASTER_VAULT_ADDRESS")
153148
if not paymaster_vault_address:
@@ -204,7 +199,7 @@ def from_env(cls, local_mode: bool = False) -> "RelayerConfig":
204199

205200
# Create configuration objects
206201
source_chain = SourceChainConfig(
207-
rpc_url=source_rpc_url,
202+
rpc_urls=source_rpc_urls,
208203
paymaster_vault_address=paymaster_vault_address,
209204
)
210205

@@ -228,11 +223,13 @@ def log_config(self) -> None:
228223
print(f"Mode: {'LOCAL' if self.local_mode else 'ROFL'}")
229224

230225
print("\n[Source Chain]")
231-
print(f" RPC URL: {self.source_chain.rpc_url}")
226+
print(f" RPC URLs ({len(self.source_chain.rpc_urls)} configured):")
227+
for i, url in enumerate(self.source_chain.rpc_urls, 1):
228+
print(f" [{i}] {sanitize_url(url)}")
232229
print(f" PaymasterVault: {self.source_chain.paymaster_vault_address}")
233230

234231
print("\n[Target Chain]")
235-
print(f" RPC URL: {self.target_chain.rpc_url}")
232+
print(f" RPC URL: {sanitize_url(self.target_chain.rpc_url)}")
236233
print(f" CrossChainPaymaster: {self.target_chain.paymaster_address}")
237234
print(f" ROFLAdapter: {self.target_chain.rofl_adapter_address}")
238235
print(

paymaster-relayer/paymaster_relayer/event_processor.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,9 @@ async def process_hash_stored(self, event: EventData) -> tuple[int, str] | None:
182182
self.stored_hashes.popitem(last=False)
183183
self.stored_hashes[block_id] = block_hash
184184

185-
logger.info(f"Hash stored - Chain {domain} Block {block_id}: {block_hash[:10]}...")
185+
logger.info(
186+
f"Hash stored - Chain {domain} Block {block_id}: {block_hash[:10]}..."
187+
)
186188

187189
matching_payments: list[PaymentEvent] = self.pending_payments.get(
188190
block_id, []
@@ -264,7 +266,9 @@ async def process_matched_payment(self, payment_event: PaymentEvent) -> bool:
264266
# This prevents race between HashStored handler and retry task
265267
if not self._remove_from_pending(payment_event):
266268
# Already removed by another task - skip to avoid duplicate submission
267-
logger.debug(f"Payment {payment_event.tx_hash[:10]}... already being processed")
269+
logger.debug(
270+
f"Payment {payment_event.tx_hash[:10]}... already being processed"
271+
)
268272
return False
269273

270274
paymaster_address = self.config.target_chain.paymaster_address

0 commit comments

Comments
 (0)