openzeppelin_monitor/services/blockchain/clients/solana/
error.rs

1//! Solana client error types
2//!
3//! Provides error handling for Solana RPC requests, response parsing, input validation,
4//! and Solana-specific error conditions.
5
6use crate::utils::logging::error::{ErrorContext, TraceableError};
7use std::collections::HashMap;
8use thiserror::Error;
9
10/// Solana client error type
11#[derive(Debug, Error)]
12pub enum SolanaClientError {
13	/// Requested slot is not available (skipped or not yet produced)
14	#[error("Slot {slot} is not available: {reason}")]
15	SlotNotAvailable {
16		slot: u64,
17		reason: String,
18		context: Box<ErrorContext>,
19	},
20
21	/// Block data not available for the requested slot
22	#[error("Block not available for slot {slot}: {reason}")]
23	BlockNotAvailable {
24		slot: u64,
25		reason: String,
26		context: Box<ErrorContext>,
27	},
28
29	/// Transaction not found
30	#[error("Transaction not found: {signature}")]
31	TransactionNotFound {
32		signature: String,
33		context: Box<ErrorContext>,
34	},
35
36	/// Failure in making an RPC request
37	#[error("Solana RPC request failed: {0}")]
38	RpcError(Box<ErrorContext>),
39
40	/// Failure in parsing the Solana RPC response
41	#[error("Failed to parse Solana RPC response: {0}")]
42	ResponseParseError(Box<ErrorContext>),
43
44	/// Invalid input provided to the Solana client
45	#[error("Invalid input: {0}")]
46	InvalidInput(Box<ErrorContext>),
47
48	/// The response from the Solana RPC does not match the expected format
49	#[error("Unexpected response structure from Solana RPC: {0}")]
50	UnexpectedResponseStructure(Box<ErrorContext>),
51
52	/// Program/IDL not found
53	#[error("Program IDL not found for: {program_id}")]
54	IdlNotFound {
55		program_id: String,
56		context: Box<ErrorContext>,
57	},
58
59	/// Instruction decoding error
60	#[error("Failed to decode instruction: {0}")]
61	InstructionDecodeError(Box<ErrorContext>),
62}
63
64impl SolanaClientError {
65	/// Creates a SlotNotAvailable error
66	pub fn slot_not_available(
67		slot: u64,
68		reason: impl Into<String>,
69		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
70		metadata: Option<HashMap<String, String>>,
71	) -> Self {
72		let reason = reason.into();
73		let message = format!("Slot {} is not available: {}", slot, &reason);
74		Self::SlotNotAvailable {
75			slot,
76			reason,
77			context: Box::new(ErrorContext::new_with_log(message, source, metadata)),
78		}
79	}
80
81	/// Creates a BlockNotAvailable error
82	pub fn block_not_available(
83		slot: u64,
84		reason: impl Into<String>,
85		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
86		metadata: Option<HashMap<String, String>>,
87	) -> Self {
88		let reason = reason.into();
89		let message = format!("Block not available for slot {}: {}", slot, &reason);
90		Self::BlockNotAvailable {
91			slot,
92			reason,
93			context: Box::new(ErrorContext::new_with_log(message, source, metadata)),
94		}
95	}
96
97	/// Creates a TransactionNotFound error
98	pub fn transaction_not_found(
99		signature: impl Into<String>,
100		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
101		metadata: Option<HashMap<String, String>>,
102	) -> Self {
103		let signature = signature.into();
104		let message = format!("Transaction not found: {}", &signature);
105		Self::TransactionNotFound {
106			signature,
107			context: Box::new(ErrorContext::new_with_log(message, source, metadata)),
108		}
109	}
110
111	/// Creates an RPC error
112	pub fn rpc_error(
113		message: impl Into<String>,
114		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
115		metadata: Option<HashMap<String, String>>,
116	) -> Self {
117		Self::RpcError(Box::new(ErrorContext::new_with_log(
118			message, source, metadata,
119		)))
120	}
121
122	/// Creates a response parse error
123	pub fn response_parse_error(
124		message: impl Into<String>,
125		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
126		metadata: Option<HashMap<String, String>>,
127	) -> Self {
128		Self::ResponseParseError(Box::new(ErrorContext::new_with_log(
129			message, source, metadata,
130		)))
131	}
132
133	/// Creates an invalid input error
134	pub fn invalid_input(
135		msg: impl Into<String>,
136		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
137		metadata: Option<HashMap<String, String>>,
138	) -> Self {
139		Self::InvalidInput(Box::new(ErrorContext::new_with_log(msg, source, metadata)))
140	}
141
142	/// Creates an unexpected response structure error
143	pub fn unexpected_response_structure(
144		msg: impl Into<String>,
145		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
146		metadata: Option<HashMap<String, String>>,
147	) -> Self {
148		Self::UnexpectedResponseStructure(Box::new(ErrorContext::new_with_log(
149			msg, source, metadata,
150		)))
151	}
152
153	/// Creates an IDL not found error
154	pub fn idl_not_found(
155		program_id: impl Into<String>,
156		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
157		metadata: Option<HashMap<String, String>>,
158	) -> Self {
159		let program_id = program_id.into();
160		let message = format!("Program IDL not found for: {}", &program_id);
161		Self::IdlNotFound {
162			program_id,
163			context: Box::new(ErrorContext::new_with_log(message, source, metadata)),
164		}
165	}
166
167	/// Creates an instruction decode error
168	pub fn instruction_decode_error(
169		message: impl Into<String>,
170		source: Option<Box<dyn std::error::Error + Send + Sync + 'static>>,
171		metadata: Option<HashMap<String, String>>,
172	) -> Self {
173		Self::InstructionDecodeError(Box::new(ErrorContext::new_with_log(
174			message, source, metadata,
175		)))
176	}
177
178	/// Checks if this is a slot not available error
179	pub fn is_slot_not_available(&self) -> bool {
180		matches!(self, Self::SlotNotAvailable { .. })
181	}
182
183	/// Checks if this is a block not available error
184	pub fn is_block_not_available(&self) -> bool {
185		matches!(self, Self::BlockNotAvailable { .. })
186	}
187
188	/// Checks if this is a transaction not found error
189	pub fn is_transaction_not_found(&self) -> bool {
190		matches!(self, Self::TransactionNotFound { .. })
191	}
192}
193
194impl TraceableError for SolanaClientError {
195	fn trace_id(&self) -> String {
196		match self {
197			SolanaClientError::SlotNotAvailable { context, .. } => context.trace_id.clone(),
198			SolanaClientError::BlockNotAvailable { context, .. } => context.trace_id.clone(),
199			SolanaClientError::TransactionNotFound { context, .. } => context.trace_id.clone(),
200			SolanaClientError::RpcError(context) => context.trace_id.clone(),
201			SolanaClientError::ResponseParseError(context) => context.trace_id.clone(),
202			SolanaClientError::InvalidInput(context) => context.trace_id.clone(),
203			SolanaClientError::UnexpectedResponseStructure(context) => context.trace_id.clone(),
204			SolanaClientError::IdlNotFound { context, .. } => context.trace_id.clone(),
205			SolanaClientError::InstructionDecodeError(context) => context.trace_id.clone(),
206		}
207	}
208}
209
210/// Known Solana RPC error codes
211pub mod error_codes {
212	/// Block not available (slot was skipped or not produced yet)
213	pub const BLOCK_NOT_AVAILABLE: i64 = -32004;
214	/// Slot was skipped
215	pub const SLOT_SKIPPED: i64 = -32007;
216	/// Long-term storage query error
217	pub const LONG_TERM_STORAGE_SLOT_SKIPPED: i64 = -32009;
218	/// Transaction version not supported
219	#[allow(dead_code)]
220	pub const UNSUPPORTED_TRANSACTION_VERSION: i64 = -32015;
221	/// Invalid parameters
222	#[allow(dead_code)]
223	pub const INVALID_PARAMS: i64 = -32602;
224	/// Internal error
225	#[allow(dead_code)]
226	pub const INTERNAL_ERROR: i64 = -32603;
227}
228
229/// Checks if the given RPC error code indicates a skipped/unavailable slot
230pub fn is_slot_unavailable_error(code: i64) -> bool {
231	matches!(
232		code,
233		error_codes::BLOCK_NOT_AVAILABLE
234			| error_codes::SLOT_SKIPPED
235			| error_codes::LONG_TERM_STORAGE_SLOT_SKIPPED
236	)
237}
238
239#[cfg(test)]
240mod tests {
241	use super::*;
242
243	#[test]
244	fn test_slot_not_available_error_formatting() {
245		let error = SolanaClientError::slot_not_available(12345, "Slot was skipped", None, None);
246		assert_eq!(
247			error.to_string(),
248			"Slot 12345 is not available: Slot was skipped"
249		);
250		if let SolanaClientError::SlotNotAvailable {
251			slot,
252			reason,
253			context,
254		} = error
255		{
256			assert_eq!(slot, 12345);
257			assert_eq!(reason, "Slot was skipped");
258			assert!(!context.trace_id.is_empty());
259		} else {
260			panic!("Expected SlotNotAvailable variant");
261		}
262	}
263
264	#[test]
265	fn test_block_not_available_error_formatting() {
266		let error = SolanaClientError::block_not_available(12345, "Block cleaned up", None, None);
267		assert_eq!(
268			error.to_string(),
269			"Block not available for slot 12345: Block cleaned up"
270		);
271		if let SolanaClientError::BlockNotAvailable {
272			slot,
273			reason,
274			context,
275		} = error
276		{
277			assert_eq!(slot, 12345);
278			assert_eq!(reason, "Block cleaned up");
279			assert!(!context.trace_id.is_empty());
280		} else {
281			panic!("Expected BlockNotAvailable variant");
282		}
283	}
284
285	#[test]
286	fn test_transaction_not_found_error_formatting() {
287		let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
288		let error = SolanaClientError::transaction_not_found(sig, None, None);
289		assert_eq!(error.to_string(), format!("Transaction not found: {}", sig));
290	}
291
292	#[test]
293	fn test_rpc_error_formatting() {
294		let error_message = "Random Solana RPC error".to_string();
295		let error = SolanaClientError::rpc_error(error_message.clone(), None, None);
296		assert_eq!(
297			error.to_string(),
298			format!("Solana RPC request failed: {}", error_message)
299		);
300		if let SolanaClientError::RpcError(context) = error {
301			assert_eq!(context.message, error_message);
302			assert!(!context.trace_id.is_empty());
303		} else {
304			panic!("Expected RpcError variant");
305		}
306	}
307
308	#[test]
309	fn test_response_parse_error_formatting() {
310		let error_message = "Failed to parse Solana RPC response".to_string();
311		let error = SolanaClientError::response_parse_error(error_message.clone(), None, None);
312		assert_eq!(
313			error.to_string(),
314			format!("Failed to parse Solana RPC response: {}", error_message)
315		);
316	}
317
318	#[test]
319	fn test_invalid_input_error_formatting() {
320		let error_message = "Invalid input provided to Solana client".to_string();
321		let error = SolanaClientError::invalid_input(error_message.clone(), None, None);
322		assert_eq!(
323			error.to_string(),
324			format!("Invalid input: {}", error_message)
325		);
326	}
327
328	#[test]
329	fn test_idl_not_found_error_formatting() {
330		let program_id = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA";
331		let error = SolanaClientError::idl_not_found(program_id, None, None);
332		assert_eq!(
333			error.to_string(),
334			format!("Program IDL not found for: {}", program_id)
335		);
336	}
337
338	#[test]
339	fn test_error_type_checks() {
340		let slot_error = SolanaClientError::slot_not_available(123, "skipped", None, None);
341		assert!(slot_error.is_slot_not_available());
342		assert!(!slot_error.is_block_not_available());
343		assert!(!slot_error.is_transaction_not_found());
344
345		let block_error = SolanaClientError::block_not_available(123, "cleaned", None, None);
346		assert!(!block_error.is_slot_not_available());
347		assert!(block_error.is_block_not_available());
348
349		let tx_error = SolanaClientError::transaction_not_found("abc", None, None);
350		assert!(tx_error.is_transaction_not_found());
351	}
352
353	#[test]
354	fn test_is_slot_unavailable_error() {
355		assert!(is_slot_unavailable_error(error_codes::BLOCK_NOT_AVAILABLE));
356		assert!(is_slot_unavailable_error(error_codes::SLOT_SKIPPED));
357		assert!(is_slot_unavailable_error(
358			error_codes::LONG_TERM_STORAGE_SLOT_SKIPPED
359		));
360		assert!(!is_slot_unavailable_error(error_codes::INVALID_PARAMS));
361		assert!(!is_slot_unavailable_error(error_codes::INTERNAL_ERROR));
362	}
363
364	#[test]
365	fn test_all_error_variants_have_trace_id() {
366		let create_context_with_id = || {
367			let context = ErrorContext::new("test message", None, None);
368			let original_id = context.trace_id.clone();
369			(context, original_id)
370		};
371
372		let errors_with_ids: Vec<(SolanaClientError, String)> = vec![
373			{
374				let (ctx, id) = create_context_with_id();
375				(
376					SolanaClientError::SlotNotAvailable {
377						slot: 0,
378						reason: "".to_string(),
379						context: Box::new(ctx),
380					},
381					id,
382				)
383			},
384			{
385				let (ctx, id) = create_context_with_id();
386				(
387					SolanaClientError::BlockNotAvailable {
388						slot: 0,
389						reason: "".to_string(),
390						context: Box::new(ctx),
391					},
392					id,
393				)
394			},
395			{
396				let (ctx, id) = create_context_with_id();
397				(
398					SolanaClientError::TransactionNotFound {
399						signature: "".to_string(),
400						context: Box::new(ctx),
401					},
402					id,
403				)
404			},
405			{
406				let (ctx, id) = create_context_with_id();
407				(SolanaClientError::RpcError(Box::new(ctx)), id)
408			},
409			{
410				let (ctx, id) = create_context_with_id();
411				(SolanaClientError::ResponseParseError(Box::new(ctx)), id)
412			},
413			{
414				let (ctx, id) = create_context_with_id();
415				(SolanaClientError::InvalidInput(Box::new(ctx)), id)
416			},
417			{
418				let (ctx, id) = create_context_with_id();
419				(
420					SolanaClientError::UnexpectedResponseStructure(Box::new(ctx)),
421					id,
422				)
423			},
424			{
425				let (ctx, id) = create_context_with_id();
426				(
427					SolanaClientError::IdlNotFound {
428						program_id: "".to_string(),
429						context: Box::new(ctx),
430					},
431					id,
432				)
433			},
434			{
435				let (ctx, id) = create_context_with_id();
436				(SolanaClientError::InstructionDecodeError(Box::new(ctx)), id)
437			},
438		];
439
440		for (error, original_id) in errors_with_ids {
441			let propagated_id = error.trace_id();
442			assert!(
443				!propagated_id.is_empty(),
444				"Error {:?} should have a non-empty trace_id",
445				error
446			);
447			assert_eq!(
448				propagated_id, original_id,
449				"Trace ID for {:?} was not propagated consistently",
450				error
451			);
452		}
453	}
454}