1use solana_sdk::{
4 hash::Hash,
5 instruction::{AccountMeta, Instruction},
6 pubkey::Pubkey,
7 transaction::Transaction as SolanaTransaction,
8};
9use std::str::FromStr;
10
11use crate::{
12 constants::MAXIMUM_SOLANA_TX_ATTEMPTS,
13 models::{
14 EncodedSerializedTransaction, SolanaInstructionSpec, SolanaTransactionStatus,
15 TransactionError, TransactionRepoModel, TransactionStatus,
16 },
17 utils::base64_decode,
18};
19
20pub fn too_many_solana_attempts(tx: &TransactionRepoModel) -> bool {
27 tx.hashes.len() >= MAXIMUM_SOLANA_TX_ATTEMPTS
28}
29
30pub fn is_resubmitable(tx: &SolanaTransaction) -> bool {
44 tx.message.header.num_required_signatures <= 1
45}
46
47pub fn map_solana_status_to_transaction_status(
56 solana_status: SolanaTransactionStatus,
57) -> TransactionStatus {
58 match solana_status {
59 SolanaTransactionStatus::Processed => TransactionStatus::Mined,
60 SolanaTransactionStatus::Confirmed => TransactionStatus::Mined,
61 SolanaTransactionStatus::Finalized => TransactionStatus::Confirmed,
62 SolanaTransactionStatus::Failed => TransactionStatus::Failed,
63 }
64}
65
66pub fn decode_solana_transaction(
74 tx: &TransactionRepoModel,
75) -> Result<SolanaTransaction, TransactionError> {
76 let solana_data = tx.network_data.get_solana_transaction_data()?;
77
78 if let Some(transaction_str) = &solana_data.transaction {
79 decode_solana_transaction_from_string(transaction_str)
80 } else {
81 Err(TransactionError::ValidationError(
82 "Transaction not yet built - only available after preparation".to_string(),
83 ))
84 }
85}
86
87pub fn decode_solana_transaction_from_string(
89 encoded: &str,
90) -> Result<SolanaTransaction, TransactionError> {
91 let encoded_tx = EncodedSerializedTransaction::new(encoded.to_string());
92 SolanaTransaction::try_from(encoded_tx)
93 .map_err(|e| TransactionError::ValidationError(format!("Invalid transaction: {e}")))
94}
95
96pub fn convert_instruction_specs_to_instructions(
108 instructions: &[SolanaInstructionSpec],
109) -> Result<Vec<Instruction>, TransactionError> {
110 let mut solana_instructions = Vec::new();
111
112 for (idx, spec) in instructions.iter().enumerate() {
113 let program_id = Pubkey::from_str(&spec.program_id).map_err(|e| {
114 TransactionError::ValidationError(format!("Instruction {idx}: Invalid program_id: {e}"))
115 })?;
116
117 let accounts = spec
118 .accounts
119 .iter()
120 .enumerate()
121 .map(|(acc_idx, a)| {
122 let pubkey = Pubkey::from_str(&a.pubkey).map_err(|e| {
123 TransactionError::ValidationError(format!(
124 "Instruction {idx} account {acc_idx}: Invalid pubkey: {e}"
125 ))
126 })?;
127 Ok(AccountMeta {
128 pubkey,
129 is_signer: a.is_signer,
130 is_writable: a.is_writable,
131 })
132 })
133 .collect::<Result<Vec<_>, TransactionError>>()?;
134
135 let data = base64_decode(&spec.data).map_err(|e| {
136 TransactionError::ValidationError(format!(
137 "Instruction {idx}: Invalid base64 data: {e}"
138 ))
139 })?;
140
141 solana_instructions.push(Instruction {
142 program_id,
143 accounts,
144 data,
145 });
146 }
147
148 Ok(solana_instructions)
149}
150
151pub fn build_transaction_from_instructions(
161 instructions: &[SolanaInstructionSpec],
162 payer: &Pubkey,
163 recent_blockhash: Hash,
164) -> Result<SolanaTransaction, TransactionError> {
165 let solana_instructions = convert_instruction_specs_to_instructions(instructions)?;
166
167 let mut tx = SolanaTransaction::new_with_payer(&solana_instructions, Some(payer));
168 tx.message.recent_blockhash = recent_blockhash;
169 Ok(tx)
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use crate::{
176 models::{
177 NetworkTransactionData, NetworkType, SolanaAccountMeta, SolanaTransactionData,
178 TransactionStatus,
179 },
180 utils::base64_encode,
181 };
182 use chrono::Utc;
183 use solana_sdk::message::Message;
184 use solana_system_interface::instruction as system_instruction;
185
186 #[test]
187 fn test_decode_solana_transaction_invalid_data() {
188 let tx = TransactionRepoModel {
190 id: "test-tx".to_string(),
191 relayer_id: "test-relayer".to_string(),
192 status: TransactionStatus::Pending,
193 status_reason: None,
194 created_at: Utc::now().to_rfc3339(),
195 sent_at: None,
196 confirmed_at: None,
197 valid_until: None,
198 delete_at: None,
199 network_type: NetworkType::Solana,
200 network_data: NetworkTransactionData::Solana(SolanaTransactionData {
201 transaction: Some("invalid-base64!!!".to_string()),
202 ..Default::default()
203 }),
204 priced_at: None,
205 hashes: Vec::new(),
206 noop_count: None,
207 is_canceled: Some(false),
208 metadata: None,
209 };
210
211 let result = decode_solana_transaction(&tx);
212 assert!(result.is_err());
213
214 if let Err(TransactionError::ValidationError(msg)) = result {
215 assert!(msg.contains("Invalid transaction"));
216 } else {
217 panic!("Expected ValidationError");
218 }
219 }
220
221 #[test]
222 fn test_decode_solana_transaction_not_built() {
223 let tx = TransactionRepoModel {
225 id: "test-tx".to_string(),
226 relayer_id: "test-relayer".to_string(),
227 status: TransactionStatus::Pending,
228 status_reason: None,
229 created_at: Utc::now().to_rfc3339(),
230 sent_at: None,
231 confirmed_at: None,
232 valid_until: None,
233 delete_at: None,
234 network_type: NetworkType::Solana,
235 network_data: NetworkTransactionData::Solana(SolanaTransactionData {
236 transaction: None, ..Default::default()
238 }),
239 priced_at: None,
240 hashes: Vec::new(),
241 noop_count: None,
242 is_canceled: Some(false),
243 metadata: None,
244 };
245
246 let result = decode_solana_transaction(&tx);
247 assert!(result.is_err());
248
249 if let Err(TransactionError::ValidationError(msg)) = result {
250 assert!(msg.contains("not yet built"));
251 } else {
252 panic!("Expected ValidationError");
253 }
254 }
255
256 #[test]
257 fn test_convert_instruction_specs_to_instructions_success() {
258 let program_id = Pubkey::new_unique();
259 let account = Pubkey::new_unique();
260
261 let specs = vec![SolanaInstructionSpec {
262 program_id: program_id.to_string(),
263 accounts: vec![SolanaAccountMeta {
264 pubkey: account.to_string(),
265 is_signer: false,
266 is_writable: true,
267 }],
268 data: base64_encode(b"test data"),
269 }];
270
271 let result = convert_instruction_specs_to_instructions(&specs);
272 assert!(result.is_ok());
273
274 let instructions = result.unwrap();
275 assert_eq!(instructions.len(), 1);
276 assert_eq!(instructions[0].program_id, program_id);
277 assert_eq!(instructions[0].accounts.len(), 1);
278 assert_eq!(instructions[0].accounts[0].pubkey, account);
279 assert!(!instructions[0].accounts[0].is_signer);
280 assert!(instructions[0].accounts[0].is_writable);
281 }
282
283 #[test]
284 fn test_build_transaction_from_instructions_success() {
285 let payer = Pubkey::new_unique();
286 let program_id = Pubkey::new_unique();
287 let account = Pubkey::new_unique();
288 let blockhash = Hash::new_unique();
289
290 let instructions = vec![SolanaInstructionSpec {
291 program_id: program_id.to_string(),
292 accounts: vec![SolanaAccountMeta {
293 pubkey: account.to_string(),
294 is_signer: false,
295 is_writable: true,
296 }],
297 data: base64_encode(b"test data"),
298 }];
299
300 let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
301 assert!(result.is_ok());
302
303 let tx = result.unwrap();
304 assert_eq!(tx.message.account_keys[0], payer);
305 assert_eq!(tx.message.recent_blockhash, blockhash);
306 }
307
308 #[test]
309 fn test_build_transaction_invalid_program_id() {
310 let payer = Pubkey::new_unique();
311 let blockhash = Hash::new_unique();
312
313 let instructions = vec![SolanaInstructionSpec {
314 program_id: "invalid".to_string(),
315 accounts: vec![],
316 data: base64_encode(b"test"),
317 }];
318
319 let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
320 assert!(result.is_err());
321 }
322
323 #[test]
324 fn test_build_transaction_invalid_base64_data() {
325 let payer = Pubkey::new_unique();
326 let program_id = Pubkey::new_unique();
327 let blockhash = Hash::new_unique();
328
329 let instructions = vec![SolanaInstructionSpec {
330 program_id: program_id.to_string(),
331 accounts: vec![],
332 data: "not-valid-base64!!!".to_string(),
333 }];
334
335 let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
336 assert!(result.is_err());
337 }
338
339 #[test]
340 fn test_is_resubmitable_single_signer() {
341 let payer = Pubkey::new_unique();
342 let recipient = Pubkey::new_unique();
343 let instruction = system_instruction::transfer(&payer, &recipient, 1000);
344
345 let tx = SolanaTransaction::new_with_payer(&[instruction], Some(&payer));
347
348 assert!(is_resubmitable(&tx));
350 assert_eq!(tx.message.header.num_required_signatures, 1);
351 }
352
353 #[test]
354 fn test_is_resubmitable_multi_signer() {
355 let payer = Pubkey::new_unique();
356 let recipient = Pubkey::new_unique();
357 let additional_signer = Pubkey::new_unique();
358 let instruction = system_instruction::transfer(&payer, &recipient, 1000);
359
360 let mut message = Message::new(&[instruction], Some(&payer));
362 message.account_keys.push(additional_signer);
364 message.header.num_required_signatures = 2;
365
366 let tx = SolanaTransaction::new_unsigned(message);
367
368 assert!(!is_resubmitable(&tx));
370 assert_eq!(tx.message.header.num_required_signatures, 2);
371 }
372
373 #[test]
374 fn test_is_resubmitable_no_signers() {
375 let payer = Pubkey::new_unique();
376 let recipient = Pubkey::new_unique();
377 let instruction = system_instruction::transfer(&payer, &recipient, 1000);
378
379 let mut message = Message::new(&[instruction], Some(&payer));
381 message.header.num_required_signatures = 0;
382
383 let tx = SolanaTransaction::new_unsigned(message);
384
385 assert!(is_resubmitable(&tx));
387 assert_eq!(tx.message.header.num_required_signatures, 0);
388 }
389
390 #[test]
391 fn test_too_many_solana_attempts_under_limit() {
392 let tx = TransactionRepoModel {
393 id: "test-tx".to_string(),
394 relayer_id: "test-relayer".to_string(),
395 status: TransactionStatus::Pending,
396 status_reason: None,
397 created_at: Utc::now().to_rfc3339(),
398 sent_at: None,
399 confirmed_at: None,
400 valid_until: None,
401 delete_at: None,
402 network_type: NetworkType::Solana,
403 network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
404 priced_at: None,
405 hashes: vec!["hash1".to_string(), "hash2".to_string()], noop_count: None,
407 is_canceled: Some(false),
408 metadata: None,
409 };
410
411 assert!(!too_many_solana_attempts(&tx));
413 }
414
415 #[test]
416 fn test_too_many_solana_attempts_at_limit() {
417 let tx = TransactionRepoModel {
418 id: "test-tx".to_string(),
419 relayer_id: "test-relayer".to_string(),
420 status: TransactionStatus::Pending,
421 status_reason: None,
422 created_at: Utc::now().to_rfc3339(),
423 sent_at: None,
424 confirmed_at: None,
425 valid_until: None,
426 delete_at: None,
427 network_type: NetworkType::Solana,
428 network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
429 priced_at: None,
430 hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS], noop_count: None,
432 is_canceled: Some(false),
433 metadata: None,
434 };
435
436 assert!(too_many_solana_attempts(&tx));
438 }
439
440 #[test]
441 fn test_too_many_solana_attempts_over_limit() {
442 let tx = TransactionRepoModel {
443 id: "test-tx".to_string(),
444 relayer_id: "test-relayer".to_string(),
445 status: TransactionStatus::Pending,
446 status_reason: None,
447 created_at: Utc::now().to_rfc3339(),
448 sent_at: None,
449 confirmed_at: None,
450 valid_until: None,
451 delete_at: None,
452 network_type: NetworkType::Solana,
453 network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
454 priced_at: None,
455 hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS + 1], noop_count: None,
457 is_canceled: Some(false),
458 metadata: None,
459 };
460
461 assert!(too_many_solana_attempts(&tx));
463 }
464
465 #[test]
466 fn test_map_solana_status_to_transaction_status_processed() {
467 let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Processed);
468 assert_eq!(result, TransactionStatus::Mined);
469 }
470
471 #[test]
472 fn test_map_solana_status_to_transaction_status_confirmed() {
473 let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Confirmed);
474 assert_eq!(result, TransactionStatus::Mined);
475 }
476
477 #[test]
478 fn test_map_solana_status_to_transaction_status_finalized() {
479 let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Finalized);
480 assert_eq!(result, TransactionStatus::Confirmed);
481 }
482
483 #[test]
484 fn test_map_solana_status_to_transaction_status_failed() {
485 let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Failed);
486 assert_eq!(result, TransactionStatus::Failed);
487 }
488}