1313from solana .rpc .async_api import AsyncClient
1414from solana .rpc .commitment import Commitment
1515from solana .rpc .types import TxOpts
16+ from solders .hash import Hash
1617from solders .instruction import AccountMeta , Instruction
1718from solders .keypair import Keypair
1819from solders .pubkey import Pubkey as PublicKey
2223from .constants import (
2324 AGENT_REGISTRY_PROGRAM_ID ,
2425 DEFAULT_DEVNET_RPC ,
26+ FALLBACK_DEVNET_RPCS ,
2527 MCP_SERVER_REGISTRY_PROGRAM_ID ,
2628)
2729from .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 """
0 commit comments