Skip to content

Commit d1d6315

Browse files
Copilot0xrinegade
andcommitted
Fix Solana devnet blockhash issues with RPC failover and robust retry logic
Co-authored-by: 0xrinegade <[email protected]>
1 parent b199fe4 commit d1d6315

File tree

2 files changed

+147
-41
lines changed

2 files changed

+147
-41
lines changed

python/solana_ai_registries/client.py

Lines changed: 138 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from solana.rpc.async_api import AsyncClient
1414
from solana.rpc.commitment import Commitment
1515
from solana.rpc.types import TxOpts
16+
from solders.hash import Hash
1617
from solders.instruction import AccountMeta, Instruction
1718
from solders.keypair import Keypair
1819
from solders.pubkey import Pubkey as PublicKey
@@ -22,6 +23,7 @@
2223
from .constants import (
2324
AGENT_REGISTRY_PROGRAM_ID,
2425
DEFAULT_DEVNET_RPC,
26+
FALLBACK_DEVNET_RPCS,
2527
MCP_SERVER_REGISTRY_PROGRAM_ID,
2628
)
2729
from .exceptions import (
@@ -40,20 +42,88 @@ def __init__(
4042
self,
4143
rpc_url: str = DEFAULT_DEVNET_RPC,
4244
commitment: Optional[Commitment] = None,
45+
enable_rpc_failover: bool = True,
4346
) -> None:
4447
"""
4548
Initialize client with RPC endpoint.
4649
4750
Args:
4851
rpc_url: Solana RPC endpoint URL
4952
commitment: Transaction commitment level
53+
enable_rpc_failover: Whether to use failover RPC endpoints
5054
"""
5155
self.rpc_url = rpc_url
56+
self.enable_rpc_failover = enable_rpc_failover
5257
self.commitment = commitment or Commitment("confirmed")
5358
self._client: Optional[AsyncClient] = None
59+
self._current_rpc_index = 0
5460
self.agent_program_id = PublicKey.from_string(AGENT_REGISTRY_PROGRAM_ID)
5561
self.mcp_program_id = PublicKey.from_string(MCP_SERVER_REGISTRY_PROGRAM_ID)
5662

63+
def _get_available_rpcs(self) -> List[str]:
64+
"""Get list of available RPC endpoints for failover."""
65+
if not self.enable_rpc_failover or self.rpc_url not in FALLBACK_DEVNET_RPCS:
66+
return [self.rpc_url]
67+
return FALLBACK_DEVNET_RPCS
68+
69+
async def _get_fresh_blockhash(self, max_attempts: int = 3) -> Hash:
70+
"""
71+
Get a fresh blockhash with retry logic.
72+
73+
Args:
74+
max_attempts: Maximum number of attempts to fetch blockhash
75+
76+
Returns:
77+
Fresh blockhash Hash object
78+
79+
Raises:
80+
ConnectionError: If unable to fetch blockhash after all attempts
81+
"""
82+
available_rpcs = self._get_available_rpcs()
83+
84+
for attempt in range(max_attempts):
85+
# Try current RPC first, then failover to others
86+
rpc_to_try = available_rpcs[self._current_rpc_index % len(available_rpcs)]
87+
88+
try:
89+
if self._client is None or self.rpc_url != rpc_to_try:
90+
# Switch to different RPC if needed
91+
if self._client:
92+
await self._client.close()
93+
self.rpc_url = rpc_to_try
94+
self._client = AsyncClient(self.rpc_url, commitment=self.commitment)
95+
96+
# Wait a moment for RPC to be ready
97+
if attempt > 0:
98+
await asyncio.sleep(0.5 + (attempt * 0.5))
99+
100+
blockhash_resp = await self._client.get_latest_blockhash(
101+
commitment=self.commitment
102+
)
103+
104+
if blockhash_resp.value and blockhash_resp.value.blockhash:
105+
logger.debug(f"Fresh blockhash obtained from {rpc_to_try}")
106+
return blockhash_resp.value.blockhash # Return Hash object directly
107+
else:
108+
raise ConnectionError("Blockhash response was empty")
109+
110+
except Exception as e:
111+
logger.warning(
112+
f"Failed to get blockhash from {rpc_to_try} "
113+
f"(attempt {attempt + 1}/{max_attempts}): {e}"
114+
)
115+
116+
# Try next RPC endpoint
117+
self._current_rpc_index += 1
118+
if attempt < max_attempts - 1:
119+
continue
120+
121+
# All attempts failed
122+
raise ConnectionError(
123+
f"Failed to get fresh blockhash after {max_attempts} attempts "
124+
f"across {len(available_rpcs)} RPC endpoints"
125+
)
126+
57127
@property
58128
def client(self) -> AsyncClient:
59129
"""Get or create async RPC client."""
@@ -82,16 +152,14 @@ async def health_check(self) -> bool:
82152
return False
83153

84154
# Test blockhash fetching (most common failure point)
85-
blockhash_resp = await self.client.get_latest_blockhash(
86-
commitment=self.commitment
87-
)
88-
if not blockhash_resp.value or not blockhash_resp.value.blockhash:
89-
logger.warning("Failed to fetch latest blockhash")
155+
try:
156+
await self._get_fresh_blockhash(max_attempts=2)
157+
logger.debug("RPC connection health check passed")
158+
return True
159+
except ConnectionError:
160+
logger.warning("Failed to fetch fresh blockhash during health check")
90161
return False
91162

92-
logger.debug("RPC connection health check passed")
93-
return True
94-
95163
except Exception as e:
96164
logger.warning(f"RPC health check failed: {e}")
97165
return False
@@ -238,22 +306,20 @@ async def send_transaction(
238306
last_error = None
239307
for attempt in range(max_retries):
240308
try:
241-
# Get fresh blockhash for each attempt
242-
blockhash_resp = await self.client.get_latest_blockhash(
243-
commitment=self.commitment
244-
)
309+
# Get fresh blockhash for each attempt using robust method
310+
fresh_blockhash = await self._get_fresh_blockhash(max_attempts=3)
245311

246312
# Wait a bit to ensure blockhash is fully propagated
247313
if attempt > 0:
248-
await asyncio.sleep(0.5)
314+
await asyncio.sleep(1.0 + (attempt * 0.5))
249315

250316
# Create a new transaction instance to avoid signature conflicts
251317
# Note: Cannot use deepcopy on Transaction objects as they
252318
# cannot be pickled. Use serialization instead.
253319
tx_copy = Transaction.from_bytes(bytes(transaction))
254320

255321
# Sign transaction with fresh blockhash
256-
tx_copy.sign(signers, blockhash_resp.value.blockhash)
322+
tx_copy.sign(signers, fresh_blockhash)
257323

258324
# Send transaction with additional retry-friendly options
259325
response = await self.client.send_transaction(
@@ -280,9 +346,10 @@ async def send_transaction(
280346
logger.warning(
281347
f"Blockhash error on attempt {attempt + 1}/{max_retries}: {e}"
282348
)
283-
# For blockhash errors, wait longer before retry
349+
# For blockhash errors, wait longer and force RPC switch
284350
if attempt < max_retries - 1:
285-
await asyncio.sleep(2.0 + (attempt * 1.0))
351+
self._current_rpc_index += 1 # Force RPC failover
352+
await asyncio.sleep(2.5 + (attempt * 1.0))
286353
else:
287354
logger.warning(
288355
f"Transaction attempt {attempt + 1}/{max_retries} failed: {e}"
@@ -298,44 +365,75 @@ async def send_transaction(
298365
)
299366

300367
async def simulate_transaction(
301-
self, transaction: Transaction, signers: List[Keypair]
368+
self, transaction: Transaction, signers: List[Keypair], max_retries: int = 3
302369
) -> Dict[str, Any]:
303370
"""
304-
Simulate transaction execution.
371+
Simulate transaction execution with retry logic for blockhash issues.
305372
306373
Args:
307374
transaction: Transaction to simulate
308375
signers: List of keypairs to sign the transaction
376+
max_retries: Maximum number of retry attempts
309377
310378
Returns:
311379
Simulation result
312380
313381
Raises:
314-
TransactionError: If simulation fails
382+
TransactionError: If simulation fails after retries
315383
"""
316-
try:
317-
# Get recent blockhash and sign transaction
318-
blockhash_resp = await self.client.get_latest_blockhash()
319-
# TODO: Update transaction with proper blockhash handling
320-
# transaction.recent_blockhash = blockhash_resp.value.blockhash # type: ignore[attr-defined] # noqa: E501
321-
transaction.sign(
322-
signers, blockhash_resp.value.blockhash
323-
) # type: ignore[arg-type]
324-
325-
# Simulate
326-
response = await self.client.simulate_transaction(
327-
transaction, commitment=self.commitment
328-
)
384+
last_error = None
329385

330-
return {
331-
"logs": response.value.logs,
332-
"err": response.value.err,
333-
"accounts": response.value.accounts,
334-
"units_consumed": response.value.units_consumed,
335-
}
386+
for attempt in range(max_retries):
387+
try:
388+
# Get fresh blockhash for each attempt using robust method
389+
fresh_blockhash = await self._get_fresh_blockhash(max_attempts=2)
336390

337-
except Exception as e:
338-
raise TransactionError(f"Transaction simulation failed: {e}")
391+
# Wait a moment for blockhash propagation on retries
392+
if attempt > 0:
393+
await asyncio.sleep(0.5 + (attempt * 0.3))
394+
395+
# Create a copy of the transaction to avoid conflicts
396+
tx_copy = Transaction.from_bytes(bytes(transaction))
397+
398+
# Sign transaction with fresh blockhash
399+
tx_copy.sign(signers, fresh_blockhash)
400+
401+
# Simulate
402+
response = await self.client.simulate_transaction(
403+
tx_copy, commitment=self.commitment
404+
)
405+
406+
return {
407+
"logs": response.value.logs,
408+
"err": response.value.err,
409+
"accounts": response.value.accounts,
410+
"units_consumed": response.value.units_consumed,
411+
}
412+
413+
except Exception as e:
414+
last_error = e
415+
error_msg = str(e).lower()
416+
417+
if "blockhash" in error_msg:
418+
logger.warning(
419+
f"Blockhash error in simulation attempt "
420+
f"{attempt + 1}/{max_retries}: {e}"
421+
)
422+
# Force RPC failover on blockhash errors
423+
if attempt < max_retries - 1:
424+
self._current_rpc_index += 1
425+
await asyncio.sleep(1.0 + (attempt * 0.5))
426+
else:
427+
logger.warning(
428+
f"Simulation attempt {attempt + 1}/{max_retries} failed: {e}"
429+
)
430+
if attempt < max_retries - 1:
431+
await asyncio.sleep(0.5 * (attempt + 1))
432+
433+
# All attempts failed
434+
raise TransactionError(
435+
f"Transaction simulation failed after {max_retries} attempts: {last_error}"
436+
)
339437

340438
async def get_balance(self, pubkey: PublicKey) -> int:
341439
"""

python/solana_ai_registries/constants.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
"""
99

1010
from decimal import Decimal
11-
from typing import Final, Union
11+
from typing import Final, List, Union
1212

1313
# ============================================================================
1414
# AGENT REGISTRY CONSTANTS
@@ -183,6 +183,14 @@
183183
DEFAULT_DEVNET_RPC: Final[str] = "https://api.devnet.solana.com"
184184
DEFAULT_TESTNET_RPC: Final[str] = "https://api.testnet.solana.com"
185185

186+
# Alternative RPC endpoints for failover
187+
FALLBACK_DEVNET_RPCS: Final[List[str]] = [
188+
"https://api.devnet.solana.com",
189+
"https://devnet.helius-rpc.com",
190+
"https://solana-devnet.g.alchemy.com/v2/demo",
191+
"https://rpc.ankr.com/solana_devnet",
192+
]
193+
186194
# Transaction Configuration
187195
DEFAULT_COMMITMENT: Final[str] = "confirmed"
188196
DEFAULT_TIMEOUT: Final[int] = 60 # seconds

0 commit comments

Comments
 (0)