openzeppelin_monitor/models/blockchain/solana/
transaction.rs

1//! Solana transaction data structures.
2//!
3//! Note: These structures are based on the Solana RPC implementation:
4//! <https://solana.com/docs/rpc/http/gettransaction>
5
6use serde::{Deserialize, Serialize};
7use std::ops::Deref;
8
9/// Information about a Solana transaction
10///
11/// This structure represents the response from the Solana RPC endpoint
12/// and matches the format defined in the Solana JSON-RPC specification.
13#[derive(Debug, Serialize, Deserialize, Clone, Default)]
14#[serde(rename_all = "camelCase")]
15pub struct TransactionInfo {
16	/// The transaction signature (base58 encoded)
17	pub signature: String,
18
19	/// The slot this transaction was processed in
20	pub slot: u64,
21
22	/// Timestamp when the block containing this transaction was produced
23	pub block_time: Option<i64>,
24
25	/// The transaction message
26	pub transaction: TransactionMessage,
27
28	/// Transaction metadata
29	pub meta: Option<TransactionMeta>,
30}
31
32/// The transaction message containing accounts and instructions
33#[derive(Debug, Serialize, Deserialize, Clone, Default)]
34#[serde(rename_all = "camelCase")]
35pub struct TransactionMessage {
36	/// List of account keys used in the transaction
37	pub account_keys: Vec<String>,
38
39	/// Recent blockhash used for the transaction
40	pub recent_blockhash: String,
41
42	/// Instructions in the transaction
43	pub instructions: Vec<Instruction>,
44
45	/// Address table lookups (for versioned transactions)
46	#[serde(default)]
47	pub address_table_lookups: Vec<AddressTableLookup>,
48}
49
50/// A single instruction in a Solana transaction
51#[derive(Debug, Serialize, Deserialize, Clone, Default)]
52#[serde(rename_all = "camelCase")]
53pub struct Instruction {
54	/// Index of the program ID in the account keys array
55	pub program_id_index: u8,
56
57	/// Indexes of the accounts used by this instruction
58	pub accounts: Vec<u8>,
59
60	/// Instruction data (base58 or base64 encoded depending on encoding)
61	pub data: String,
62
63	/// Parsed instruction data (if available, only for known programs)
64	#[serde(skip_serializing_if = "Option::is_none")]
65	pub parsed: Option<ParsedInstruction>,
66
67	/// Program name (if parsed)
68	#[serde(skip_serializing_if = "Option::is_none")]
69	pub program: Option<String>,
70
71	/// Program ID (if parsed format)
72	#[serde(skip_serializing_if = "Option::is_none")]
73	pub program_id: Option<String>,
74}
75
76/// Parsed instruction data for known programs
77#[derive(Debug, Serialize, Deserialize, Clone, Default)]
78#[serde(rename_all = "camelCase")]
79pub struct ParsedInstruction {
80	/// The type of instruction
81	#[serde(rename = "type")]
82	pub instruction_type: String,
83
84	/// Instruction-specific data
85	pub info: serde_json::Value,
86}
87
88/// Address table lookup for versioned transactions
89#[derive(Debug, Serialize, Deserialize, Clone, Default)]
90#[serde(rename_all = "camelCase")]
91pub struct AddressTableLookup {
92	/// The account key of the address lookup table
93	pub account_key: String,
94
95	/// Indexes of writable accounts
96	pub writable_indexes: Vec<u8>,
97
98	/// Indexes of readonly accounts
99	pub readonly_indexes: Vec<u8>,
100}
101
102/// Transaction metadata including status and logs
103#[derive(Debug, Serialize, Deserialize, Clone, Default)]
104#[serde(rename_all = "camelCase")]
105pub struct TransactionMeta {
106	/// Error if transaction failed
107	pub err: Option<serde_json::Value>,
108
109	/// Fee paid for the transaction (in lamports)
110	pub fee: u64,
111
112	/// Account balances before the transaction
113	pub pre_balances: Vec<u64>,
114
115	/// Account balances after the transaction
116	pub post_balances: Vec<u64>,
117
118	/// Token balances before the transaction
119	#[serde(default)]
120	pub pre_token_balances: Vec<TokenBalance>,
121
122	/// Token balances after the transaction
123	#[serde(default)]
124	pub post_token_balances: Vec<TokenBalance>,
125
126	/// Inner instructions (cross-program invocations)
127	#[serde(default)]
128	pub inner_instructions: Vec<InnerInstruction>,
129
130	/// Log messages from the transaction
131	#[serde(default)]
132	pub log_messages: Vec<String>,
133
134	/// Compute units consumed
135	#[serde(skip_serializing_if = "Option::is_none")]
136	pub compute_units_consumed: Option<u64>,
137
138	/// Addresses loaded from address lookup tables (for v0 transactions)
139	#[serde(default)]
140	pub loaded_addresses: Option<LoadedAddresses>,
141}
142
143/// Addresses loaded from address lookup tables in versioned transactions
144#[derive(Debug, Serialize, Deserialize, Clone, Default)]
145#[serde(rename_all = "camelCase")]
146pub struct LoadedAddresses {
147	/// Writable addresses loaded from lookup tables
148	#[serde(default)]
149	pub writable: Vec<String>,
150
151	/// Readonly addresses loaded from lookup tables
152	#[serde(default)]
153	pub readonly: Vec<String>,
154}
155
156/// Token balance information
157#[derive(Debug, Serialize, Deserialize, Clone, Default)]
158#[serde(rename_all = "camelCase")]
159pub struct TokenBalance {
160	/// Account index in the transaction
161	pub account_index: u8,
162
163	/// Token mint address
164	pub mint: String,
165
166	/// Token account owner
167	#[serde(skip_serializing_if = "Option::is_none")]
168	pub owner: Option<String>,
169
170	/// Token program ID
171	#[serde(skip_serializing_if = "Option::is_none")]
172	pub program_id: Option<String>,
173
174	/// UI token amount
175	pub ui_token_amount: UiTokenAmount,
176}
177
178/// UI-friendly token amount
179#[derive(Debug, Serialize, Deserialize, Clone, Default)]
180#[serde(rename_all = "camelCase")]
181pub struct UiTokenAmount {
182	/// Token amount as a string
183	pub amount: String,
184
185	/// Number of decimals
186	pub decimals: u8,
187
188	/// UI amount as a float (may be None for very large amounts)
189	pub ui_amount: Option<f64>,
190
191	/// UI amount as a string
192	pub ui_amount_string: String,
193}
194
195/// Inner instruction (cross-program invocation)
196#[derive(Debug, Serialize, Deserialize, Clone, Default)]
197#[serde(rename_all = "camelCase")]
198pub struct InnerInstruction {
199	/// Index of the instruction that generated these inner instructions
200	pub index: u8,
201
202	/// The inner instructions
203	pub instructions: Vec<Instruction>,
204}
205
206/// Wrapper around TransactionInfo that provides additional functionality
207///
208/// This type implements convenience methods for working with Solana transactions
209/// while maintaining compatibility with the RPC response format.
210#[derive(Debug, Serialize, Deserialize, Clone, Default)]
211pub struct Transaction(pub TransactionInfo);
212
213impl Transaction {
214	/// Get the transaction signature
215	pub fn signature(&self) -> &str {
216		&self.0.signature
217	}
218
219	/// Get the slot number
220	pub fn slot(&self) -> u64 {
221		self.0.slot
222	}
223
224	/// Check if the transaction was successful
225	pub fn is_success(&self) -> bool {
226		self.0
227			.meta
228			.as_ref()
229			.map(|m| m.err.is_none())
230			.unwrap_or(false)
231	}
232
233	/// Get the log messages from the transaction
234	pub fn logs(&self) -> &[String] {
235		self.0
236			.meta
237			.as_ref()
238			.map(|m| m.log_messages.as_slice())
239			.unwrap_or(&[])
240	}
241
242	/// Get the fee paid for the transaction
243	pub fn fee(&self) -> u64 {
244		self.0.meta.as_ref().map(|m| m.fee).unwrap_or(0)
245	}
246
247	/// Get all program IDs invoked in this transaction
248	pub fn program_ids(&self) -> Vec<String> {
249		let account_keys = &self.0.transaction.account_keys;
250		self.0
251			.transaction
252			.instructions
253			.iter()
254			.filter_map(|ix| {
255				// First check if there's a parsed program_id
256				if let Some(program_id) = &ix.program_id {
257					return Some(program_id.clone());
258				}
259				// Otherwise, look up by index
260				let idx = ix.program_id_index as usize;
261				account_keys.get(idx).cloned()
262			})
263			.collect()
264	}
265
266	/// Get the fee payer address (first account in account_keys by Solana convention)
267	/// Returns None if account_keys is empty
268	pub fn fee_payer(&self) -> Option<&str> {
269		self.0
270			.transaction
271			.account_keys
272			.first()
273			.filter(|s| !s.is_empty())
274			.map(|s| s.as_str())
275	}
276
277	/// Get all account addresses involved in the transaction
278	/// Includes both static account_keys and addresses loaded from lookup tables (ALTs)
279	pub fn accounts(&self) -> Vec<String> {
280		let mut accounts = self.0.transaction.account_keys.clone();
281
282		// Include addresses loaded from address lookup tables (v0 transactions)
283		if let Some(meta) = &self.0.meta {
284			if let Some(loaded) = &meta.loaded_addresses {
285				accounts.extend(loaded.writable.iter().cloned());
286				accounts.extend(loaded.readonly.iter().cloned());
287			}
288		}
289
290		accounts
291	}
292}
293
294impl From<TransactionInfo> for Transaction {
295	fn from(tx: TransactionInfo) -> Self {
296		Self(tx)
297	}
298}
299
300impl Deref for Transaction {
301	type Target = TransactionInfo;
302
303	fn deref(&self) -> &Self::Target {
304		&self.0
305	}
306}
307
308#[cfg(test)]
309mod tests {
310	use super::*;
311
312	fn create_test_transaction(success: bool) -> TransactionInfo {
313		TransactionInfo {
314			signature: "5wHu1qwD7q5ifaN5nwdcDqNFF53GJqa7nLp2BLPASe7FPYoWZL3YBrJmVL6nrMtwKjNFin1F"
315				.to_string(),
316			slot: 123456789,
317			block_time: Some(1234567890),
318			transaction: TransactionMessage {
319				account_keys: vec![
320					"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".to_string(),
321					"11111111111111111111111111111111".to_string(),
322				],
323				recent_blockhash: "4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZAMdL4VZHirAn".to_string(),
324				instructions: vec![Instruction {
325					program_id_index: 0,
326					accounts: vec![1],
327					data: "3Bxs4h24hBtQy9rw".to_string(),
328					parsed: None,
329					program: None,
330					program_id: None,
331				}],
332				address_table_lookups: vec![],
333			},
334			meta: Some(TransactionMeta {
335				err: if success {
336					None
337				} else {
338					Some(serde_json::json!({"InstructionError": [0, "Custom"]}))
339				},
340				fee: 5000,
341				pre_balances: vec![1000000000, 0],
342				post_balances: vec![999995000, 0],
343				pre_token_balances: vec![],
344				post_token_balances: vec![],
345				inner_instructions: vec![],
346				log_messages: vec![
347					"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA invoke [1]".to_string(),
348					"Program TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA success".to_string(),
349				],
350				compute_units_consumed: Some(2000),
351				loaded_addresses: None,
352			}),
353		}
354	}
355
356	#[test]
357	fn test_transaction_wrapper_methods() {
358		let tx_info = create_test_transaction(true);
359		let transaction = Transaction(tx_info);
360
361		assert_eq!(
362			transaction.signature(),
363			"5wHu1qwD7q5ifaN5nwdcDqNFF53GJqa7nLp2BLPASe7FPYoWZL3YBrJmVL6nrMtwKjNFin1F"
364		);
365		assert_eq!(transaction.slot(), 123456789);
366		assert!(transaction.is_success());
367		assert_eq!(transaction.fee(), 5000);
368		assert_eq!(transaction.logs().len(), 2);
369	}
370
371	#[test]
372	fn test_failed_transaction() {
373		let tx_info = create_test_transaction(false);
374		let transaction = Transaction(tx_info);
375
376		assert!(!transaction.is_success());
377	}
378
379	#[test]
380	fn test_program_ids() {
381		let tx_info = create_test_transaction(true);
382		let transaction = Transaction(tx_info);
383
384		let program_ids = transaction.program_ids();
385		assert_eq!(program_ids.len(), 1);
386		assert_eq!(
387			program_ids[0],
388			"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
389		);
390	}
391
392	#[test]
393	fn test_transaction_from_info() {
394		let tx_info = create_test_transaction(true);
395		let transaction = Transaction::from(tx_info);
396
397		assert_eq!(
398			transaction.signature(),
399			"5wHu1qwD7q5ifaN5nwdcDqNFF53GJqa7nLp2BLPASe7FPYoWZL3YBrJmVL6nrMtwKjNFin1F"
400		);
401	}
402
403	#[test]
404	fn test_transaction_deref() {
405		let tx_info = create_test_transaction(true);
406		let transaction = Transaction(tx_info);
407
408		// Test that we can access TransactionInfo fields through deref
409		assert_eq!(
410			transaction.signature,
411			"5wHu1qwD7q5ifaN5nwdcDqNFF53GJqa7nLp2BLPASe7FPYoWZL3YBrJmVL6nrMtwKjNFin1F"
412		);
413		assert_eq!(transaction.slot, 123456789);
414	}
415
416	#[test]
417	fn test_default_implementation() {
418		let transaction = Transaction::default();
419
420		assert_eq!(transaction.signature(), "");
421		assert_eq!(transaction.slot(), 0);
422		assert!(!transaction.is_success());
423		assert_eq!(transaction.fee(), 0);
424		assert!(transaction.logs().is_empty());
425		assert!(transaction.fee_payer().is_none());
426		assert!(transaction.accounts().is_empty());
427	}
428
429	#[test]
430	fn test_fee_payer() {
431		let tx_info = create_test_transaction(true);
432		let transaction = Transaction(tx_info);
433
434		// First account key is the fee payer by Solana convention
435		let fee_payer = transaction.fee_payer();
436		assert!(fee_payer.is_some());
437		assert_eq!(
438			fee_payer.unwrap(),
439			"TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
440		);
441	}
442
443	#[test]
444	fn test_accounts() {
445		let tx_info = create_test_transaction(true);
446		let transaction = Transaction(tx_info);
447
448		let accounts = transaction.accounts();
449		assert_eq!(accounts.len(), 2);
450		assert_eq!(accounts[0], "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
451		assert_eq!(accounts[1], "11111111111111111111111111111111");
452	}
453
454	#[test]
455	fn test_accounts_with_loaded_addresses() {
456		let mut tx_info = create_test_transaction(true);
457
458		// Add loaded addresses from address lookup tables (ALTs)
459		if let Some(ref mut meta) = tx_info.meta {
460			meta.loaded_addresses = Some(LoadedAddresses {
461				writable: vec!["WritableALTAddress111111111111111111".to_string()],
462				readonly: vec!["ReadonlyALTAddress111111111111111111".to_string()],
463			});
464		}
465
466		let transaction = Transaction(tx_info);
467		let accounts = transaction.accounts();
468
469		// Should include both static account_keys and loaded addresses
470		assert_eq!(accounts.len(), 4);
471		assert_eq!(accounts[0], "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA");
472		assert_eq!(accounts[1], "11111111111111111111111111111111");
473		assert_eq!(accounts[2], "WritableALTAddress111111111111111111");
474		assert_eq!(accounts[3], "ReadonlyALTAddress111111111111111111");
475	}
476
477	#[test]
478	fn test_serde_serialization() {
479		let tx_info = create_test_transaction(true);
480		let transaction = Transaction(tx_info);
481
482		// Test serialization
483		let serialized = serde_json::to_string(&transaction).unwrap();
484
485		// Test deserialization
486		let deserialized: Transaction = serde_json::from_str(&serialized).unwrap();
487
488		assert_eq!(
489			deserialized.signature(),
490			"5wHu1qwD7q5ifaN5nwdcDqNFF53GJqa7nLp2BLPASe7FPYoWZL3YBrJmVL6nrMtwKjNFin1F"
491		);
492		assert_eq!(deserialized.slot(), 123456789);
493		assert!(deserialized.is_success());
494	}
495
496	#[test]
497	fn test_parsed_instruction() {
498		let parsed = ParsedInstruction {
499			instruction_type: "transfer".to_string(),
500			info: serde_json::json!({
501				"source": "ABC123",
502				"destination": "DEF456",
503				"amount": "1000000"
504			}),
505		};
506
507		assert_eq!(parsed.instruction_type, "transfer");
508		assert_eq!(parsed.info["amount"], "1000000");
509	}
510}