openzeppelin_monitor/services/blockchain/transports/solana/
http.rs

1//! Solana transport implementation for blockchain interactions.
2//!
3//! This module provides a client implementation for interacting with Solana nodes
4//! by wrapping the HttpTransportClient. This allows for consistent behavior with other
5//! transport implementations while providing specific Solana-focused functionality.
6
7use reqwest_middleware::ClientWithMiddleware;
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Value};
10
11use crate::{
12	models::Network,
13	services::blockchain::transports::{
14		BlockchainTransport, HttpTransportClient, RotatingTransport, TransportError,
15	},
16};
17
18/// Solana RPC method constants
19pub mod rpc_methods {
20	/// Get the current slot height
21	pub const GET_SLOT: &str = "getSlot";
22	/// Get block data for a specific slot
23	pub const GET_BLOCK: &str = "getBlock";
24	/// Get multiple blocks
25	pub const GET_BLOCKS: &str = "getBlocks";
26	/// Get transaction details by signature
27	pub const GET_TRANSACTION: &str = "getTransaction";
28	/// Get the health status of the node
29	pub const GET_HEALTH: &str = "getHealth";
30	/// Get the version of the node
31	pub const GET_VERSION: &str = "getVersion";
32	/// Get account info
33	pub const GET_ACCOUNT_INFO: &str = "getAccountInfo";
34	/// Get multiple accounts
35	#[allow(dead_code)]
36	pub const GET_MULTIPLE_ACCOUNTS: &str = "getMultipleAccounts";
37	/// Get program accounts
38	pub const GET_PROGRAM_ACCOUNTS: &str = "getProgramAccounts";
39	/// Get block height
40	pub const GET_BLOCK_HEIGHT: &str = "getBlockHeight";
41	/// Get block time
42	pub const GET_BLOCK_TIME: &str = "getBlockTime";
43	/// Get first available block
44	pub const GET_FIRST_AVAILABLE_BLOCK: &str = "getFirstAvailableBlock";
45	/// Get slot leader
46	#[allow(dead_code)]
47	pub const GET_SLOT_LEADER: &str = "getSlotLeader";
48	/// Get minimum ledger slot
49	pub const MINIMUM_LEDGER_SLOT: &str = "minimumLedgerSlot";
50}
51
52/// Configuration options for getBlock RPC calls
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54#[serde(rename_all = "camelCase")]
55pub struct GetBlockConfig {
56	/// Encoding format for account data
57	#[serde(skip_serializing_if = "Option::is_none")]
58	pub encoding: Option<String>,
59	/// Transaction details level
60	#[serde(skip_serializing_if = "Option::is_none")]
61	pub transaction_details: Option<String>,
62	/// Whether to include rewards
63	#[serde(skip_serializing_if = "Option::is_none")]
64	pub rewards: Option<bool>,
65	/// Commitment level
66	#[serde(skip_serializing_if = "Option::is_none")]
67	pub commitment: Option<String>,
68	/// Max supported transaction version
69	#[serde(skip_serializing_if = "Option::is_none")]
70	pub max_supported_transaction_version: Option<u8>,
71}
72
73impl GetBlockConfig {
74	/// Creates a config for fetching full transaction data with JSON encoding
75	pub fn full() -> Self {
76		Self {
77			encoding: Some("json".to_string()),
78			transaction_details: Some("full".to_string()),
79			rewards: Some(true),
80			commitment: Some("finalized".to_string()),
81			max_supported_transaction_version: Some(0),
82		}
83	}
84
85	/// Creates a config for fetching block with signatures only (lighter)
86	pub fn signatures_only() -> Self {
87		Self {
88			encoding: Some("json".to_string()),
89			transaction_details: Some("signatures".to_string()),
90			rewards: Some(false),
91			commitment: Some("finalized".to_string()),
92			max_supported_transaction_version: Some(0),
93		}
94	}
95
96	/// Creates a config for fetching block metadata only (no transactions)
97	pub fn none() -> Self {
98		Self {
99			encoding: Some("json".to_string()),
100			transaction_details: Some("none".to_string()),
101			rewards: Some(false),
102			commitment: Some("finalized".to_string()),
103			max_supported_transaction_version: Some(0),
104		}
105	}
106}
107
108/// Configuration options for getTransaction RPC calls
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110#[serde(rename_all = "camelCase")]
111pub struct GetTransactionConfig {
112	/// Encoding format
113	#[serde(skip_serializing_if = "Option::is_none")]
114	pub encoding: Option<String>,
115	/// Commitment level
116	#[serde(skip_serializing_if = "Option::is_none")]
117	pub commitment: Option<String>,
118	/// Max supported transaction version
119	#[serde(skip_serializing_if = "Option::is_none")]
120	pub max_supported_transaction_version: Option<u8>,
121}
122
123impl GetTransactionConfig {
124	/// Creates a default config for fetching transaction with JSON encoding
125	pub fn json() -> Self {
126		Self {
127			encoding: Some("json".to_string()),
128			commitment: Some("finalized".to_string()),
129			max_supported_transaction_version: Some(0),
130		}
131	}
132
133	/// Creates a config for fetching transaction with JSON parsed encoding
134	pub fn json_parsed() -> Self {
135		Self {
136			encoding: Some("jsonParsed".to_string()),
137			commitment: Some("finalized".to_string()),
138			max_supported_transaction_version: Some(0),
139		}
140	}
141}
142
143/// Commitment levels for Solana RPC requests
144#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
145#[serde(rename_all = "lowercase")]
146pub enum Commitment {
147	/// The node will query the most recent block confirmed by the cluster
148	#[default]
149	Finalized,
150	/// The node will query the most recent block voted on by supermajority
151	Confirmed,
152	/// The node will query its most recent block (may not be finalized)
153	Processed,
154}
155
156impl Commitment {
157	/// Returns the commitment as a string
158	pub fn as_str(&self) -> &'static str {
159		match self {
160			Commitment::Finalized => "finalized",
161			Commitment::Confirmed => "confirmed",
162			Commitment::Processed => "processed",
163		}
164	}
165}
166
167/// A client for interacting with Solana blockchain nodes
168///
169/// This implementation wraps the HttpTransportClient to provide consistent
170/// behavior with other transport implementations while offering Solana-specific
171/// functionality. It handles connection management, request retries, and
172/// endpoint rotation for Solana networks.
173#[derive(Clone, Debug)]
174pub struct SolanaTransportClient {
175	/// The underlying HTTP transport client that handles actual RPC communications
176	http_client: HttpTransportClient,
177}
178
179impl SolanaTransportClient {
180	/// Creates a new Solana transport client by initializing an HTTP transport client
181	///
182	/// # Arguments
183	/// * `network` - Network configuration containing RPC URLs and other network details
184	///
185	/// # Returns
186	/// * `Result<Self, anyhow::Error>` - A new client instance or connection error
187	pub async fn new(network: &Network) -> Result<Self, anyhow::Error> {
188		// Use getHealth as the test connection method - it's lightweight and indicates node health
189		let test_connection_payload =
190			Some(r#"{"id":1,"jsonrpc":"2.0","method":"getHealth"}"#.to_string());
191		let http_client = HttpTransportClient::new(network, test_connection_payload).await?;
192		Ok(Self { http_client })
193	}
194
195	/// Creates a new Solana transport client with a custom test payload
196	///
197	/// # Arguments
198	/// * `network` - Network configuration containing RPC URLs and other network details
199	/// * `test_payload` - Optional custom test connection payload
200	///
201	/// # Returns
202	/// * `Result<Self, anyhow::Error>` - A new client instance or connection error
203	pub async fn with_test_payload(
204		network: &Network,
205		test_payload: Option<String>,
206	) -> Result<Self, anyhow::Error> {
207		let http_client = HttpTransportClient::new(network, test_payload).await?;
208		Ok(Self { http_client })
209	}
210
211	/// Gets the underlying HTTP client for direct access if needed
212	pub fn http_client(&self) -> &HttpTransportClient {
213		&self.http_client
214	}
215
216	// ========== Convenience RPC Methods ==========
217
218	/// Gets the current slot number
219	///
220	/// # Arguments
221	/// * `commitment` - Optional commitment level (defaults to finalized)
222	///
223	/// # Returns
224	/// * `Result<Value, TransportError>` - The current slot number or error
225	pub async fn get_slot(&self, commitment: Option<Commitment>) -> Result<Value, TransportError> {
226		let params = commitment.map(|c| json!([{ "commitment": c.as_str() }]));
227		self.http_client
228			.send_raw_request(rpc_methods::GET_SLOT, params)
229			.await
230	}
231
232	/// Gets block data for a specific slot
233	///
234	/// # Arguments
235	/// * `slot` - The slot number to fetch
236	/// * `config` - Optional configuration for the request
237	///
238	/// # Returns
239	/// * `Result<Value, TransportError>` - Block data or error
240	pub async fn get_block(
241		&self,
242		slot: u64,
243		config: Option<GetBlockConfig>,
244	) -> Result<Value, TransportError> {
245		let config = config.unwrap_or_else(GetBlockConfig::full);
246		let params = json!([slot, config]);
247		self.http_client
248			.send_raw_request(rpc_methods::GET_BLOCK, Some(params))
249			.await
250	}
251
252	/// Gets a range of available block slots
253	///
254	/// # Arguments
255	/// * `start_slot` - Start slot (inclusive)
256	/// * `end_slot` - Optional end slot (inclusive). If None, returns blocks up to current
257	/// * `commitment` - Optional commitment level
258	///
259	/// # Returns
260	/// * `Result<Value, TransportError>` - Array of slot numbers or error
261	pub async fn get_blocks(
262		&self,
263		start_slot: u64,
264		end_slot: Option<u64>,
265		commitment: Option<Commitment>,
266	) -> Result<Value, TransportError> {
267		let params = match (end_slot, commitment) {
268			(Some(end), Some(c)) => json!([start_slot, end, { "commitment": c.as_str() }]),
269			(Some(end), None) => json!([start_slot, end]),
270			(None, Some(c)) => json!([start_slot, null, { "commitment": c.as_str() }]),
271			(None, None) => json!([start_slot]),
272		};
273		self.http_client
274			.send_raw_request(rpc_methods::GET_BLOCKS, Some(params))
275			.await
276	}
277
278	/// Gets transaction details by signature
279	///
280	/// # Arguments
281	/// * `signature` - The transaction signature (base58 encoded)
282	/// * `config` - Optional configuration for the request
283	///
284	/// # Returns
285	/// * `Result<Value, TransportError>` - Transaction data or error
286	pub async fn get_transaction(
287		&self,
288		signature: &str,
289		config: Option<GetTransactionConfig>,
290	) -> Result<Value, TransportError> {
291		let config = config.unwrap_or_else(GetTransactionConfig::json);
292		let params = json!([signature, config]);
293		self.http_client
294			.send_raw_request(rpc_methods::GET_TRANSACTION, Some(params))
295			.await
296	}
297
298	/// Gets account info for a given public key
299	///
300	/// # Arguments
301	/// * `pubkey` - The account's public key (base58 encoded)
302	/// * `commitment` - Optional commitment level
303	///
304	/// # Returns
305	/// * `Result<Value, TransportError>` - Account info or error
306	pub async fn get_account_info(
307		&self,
308		pubkey: &str,
309		commitment: Option<Commitment>,
310	) -> Result<Value, TransportError> {
311		let config = json!({
312			"encoding": "jsonParsed",
313			"commitment": commitment.unwrap_or_default().as_str()
314		});
315		let params = json!([pubkey, config]);
316		self.http_client
317			.send_raw_request(rpc_methods::GET_ACCOUNT_INFO, Some(params))
318			.await
319	}
320
321	/// Gets program accounts for a given program ID
322	///
323	/// # Arguments
324	/// * `program_id` - The program's public key (base58 encoded)
325	/// * `filters` - Optional filters to apply
326	/// * `commitment` - Optional commitment level
327	///
328	/// # Returns
329	/// * `Result<Value, TransportError>` - Array of program accounts or error
330	pub async fn get_program_accounts(
331		&self,
332		program_id: &str,
333		filters: Option<Vec<Value>>,
334		commitment: Option<Commitment>,
335	) -> Result<Value, TransportError> {
336		let mut config = json!({
337			"encoding": "jsonParsed",
338			"commitment": commitment.unwrap_or_default().as_str()
339		});
340
341		if let Some(f) = filters {
342			config["filters"] = json!(f);
343		}
344
345		let params = json!([program_id, config]);
346		self.http_client
347			.send_raw_request(rpc_methods::GET_PROGRAM_ACCOUNTS, Some(params))
348			.await
349	}
350
351	/// Gets the current block height
352	///
353	/// # Arguments
354	/// * `commitment` - Optional commitment level
355	///
356	/// # Returns
357	/// * `Result<Value, TransportError>` - Current block height or error
358	pub async fn get_block_height(
359		&self,
360		commitment: Option<Commitment>,
361	) -> Result<Value, TransportError> {
362		let params = commitment.map(|c| json!([{ "commitment": c.as_str() }]));
363		self.http_client
364			.send_raw_request(rpc_methods::GET_BLOCK_HEIGHT, params)
365			.await
366	}
367
368	/// Gets the estimated production time of a block
369	///
370	/// # Arguments
371	/// * `slot` - The slot number
372	///
373	/// # Returns
374	/// * `Result<Value, TransportError>` - Unix timestamp or error
375	pub async fn get_block_time(&self, slot: u64) -> Result<Value, TransportError> {
376		let params = json!([slot]);
377		self.http_client
378			.send_raw_request(rpc_methods::GET_BLOCK_TIME, Some(params))
379			.await
380	}
381
382	/// Gets the slot of the lowest confirmed block
383	///
384	/// # Returns
385	/// * `Result<Value, TransportError>` - First available slot or error
386	pub async fn get_first_available_block(&self) -> Result<Value, TransportError> {
387		self.http_client
388			.send_raw_request::<Value>(rpc_methods::GET_FIRST_AVAILABLE_BLOCK, None)
389			.await
390	}
391
392	/// Gets the minimum slot that the node has information about
393	///
394	/// # Returns
395	/// * `Result<Value, TransportError>` - Minimum ledger slot or error
396	pub async fn minimum_ledger_slot(&self) -> Result<Value, TransportError> {
397		self.http_client
398			.send_raw_request::<Value>(rpc_methods::MINIMUM_LEDGER_SLOT, None)
399			.await
400	}
401
402	/// Gets the node version
403	///
404	/// # Returns
405	/// * `Result<Value, TransportError>` - Version info or error
406	pub async fn get_version(&self) -> Result<Value, TransportError> {
407		self.http_client
408			.send_raw_request::<Value>(rpc_methods::GET_VERSION, None)
409			.await
410	}
411
412	/// Gets the health status of the node
413	///
414	/// # Returns
415	/// * `Result<Value, TransportError>` - Health status or error
416	pub async fn get_health(&self) -> Result<Value, TransportError> {
417		self.http_client
418			.send_raw_request::<Value>(rpc_methods::GET_HEALTH, None)
419			.await
420	}
421}
422
423#[async_trait::async_trait]
424impl BlockchainTransport for SolanaTransportClient {
425	/// Gets the current active RPC URL
426	///
427	/// # Returns
428	/// * `String` - The currently active RPC endpoint URL
429	async fn get_current_url(&self) -> String {
430		self.http_client.get_current_url().await
431	}
432
433	/// Sends a raw JSON-RPC request to the Solana node
434	///
435	/// # Arguments
436	/// * `method` - The JSON-RPC method to call
437	/// * `params` - Optional parameters to pass with the request
438	///
439	/// # Returns
440	/// * `Result<Value, TransportError>` - The JSON response or error
441	async fn send_raw_request<P>(
442		&self,
443		method: &str,
444		params: Option<P>,
445	) -> Result<Value, TransportError>
446	where
447		P: Into<Value> + Send + Clone + Serialize,
448	{
449		self.http_client.send_raw_request(method, params).await
450	}
451
452	/// Update endpoint manager with a new client
453	///
454	/// # Arguments
455	/// * `client` - The new client to use for the endpoint manager
456	fn update_endpoint_manager_client(
457		&mut self,
458		client: ClientWithMiddleware,
459	) -> Result<(), anyhow::Error> {
460		self.http_client.update_endpoint_manager_client(client)
461	}
462}
463
464#[async_trait::async_trait]
465impl RotatingTransport for SolanaTransportClient {
466	/// Tests connection to a specific URL
467	///
468	/// # Arguments
469	/// * `url` - The URL to test connection with
470	///
471	/// # Returns
472	/// * `Result<(), anyhow::Error>` - Success or error status
473	async fn try_connect(&self, url: &str) -> Result<(), anyhow::Error> {
474		self.http_client.try_connect(url).await
475	}
476
477	/// Updates the client to use a new URL
478	///
479	/// # Arguments
480	/// * `url` - The new URL to use for subsequent requests
481	///
482	/// # Returns
483	/// * `Result<(), anyhow::Error>` - Success or error status
484	async fn update_client(&self, url: &str) -> Result<(), anyhow::Error> {
485		self.http_client.update_client(url).await
486	}
487}
488
489#[cfg(test)]
490mod tests {
491	use super::*;
492
493	#[test]
494	fn test_solana_transport_implements_traits() {
495		// This test ensures the types implement the required traits at compile time
496		fn assert_blockchain_transport<T: BlockchainTransport>() {}
497		fn assert_rotating_transport<T: RotatingTransport>() {}
498		fn assert_send_sync<T: Send + Sync>() {}
499		fn assert_clone<T: Clone>() {}
500
501		assert_blockchain_transport::<SolanaTransportClient>();
502		assert_rotating_transport::<SolanaTransportClient>();
503		assert_send_sync::<SolanaTransportClient>();
504		assert_clone::<SolanaTransportClient>();
505	}
506}