openzeppelin_relayer/domain/transaction/solana/
utils.rs

1//! Utility functions for Solana transaction domain logic.
2
3use 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
20/// Checks if a Solana transaction has exceeded the maximum number of resubmission attempts.
21///
22/// Each time a transaction is resubmitted with a fresh blockhash, a new signature is generated
23/// and appended to tx.hashes. This function checks if that limit has been exceeded.
24///
25/// Similar to EVM's `too_many_attempts` but tailored for Solana's resubmission behavior.
26pub fn too_many_solana_attempts(tx: &TransactionRepoModel) -> bool {
27    tx.hashes.len() >= MAXIMUM_SOLANA_TX_ATTEMPTS
28}
29
30/// Determines if a transaction's blockhash can be safely updated.
31///
32/// A blockhash can only be updated if the transaction requires a single signature (the relayer).
33/// Multi-signer transactions cannot have their blockhash updated because it would invalidate
34/// the existing signatures from other parties.
35///
36/// # Returns
37/// - `true` if the transaction has only one required signer (relayer can update blockhash)
38/// - `false` if the transaction has multiple required signers (blockhash is locked)
39///
40/// # Use Cases
41/// - **Prepare phase**: Decide whether to fetch a fresh blockhash
42/// - **Submit phase**: Decide whether BlockhashNotFound error is retriable
43pub fn is_resubmitable(tx: &SolanaTransaction) -> bool {
44    tx.message.header.num_required_signatures <= 1
45}
46
47/// Maps Solana on-chain transaction status to repository transaction status.
48///
49/// This mapping is used consistently across status checks to ensure uniform
50/// status transitions:
51/// - `Processed` → `Mined`: Transaction included in a block
52/// - `Confirmed` → `Mined`: Transaction confirmed by supermajority
53/// - `Finalized` → `Confirmed`: Transaction finalized (irreversible)
54/// - `Failed` → `Failed`: Transaction failed on-chain
55pub 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
66/// Decodes a Solana transaction from the transaction repository model.
67///
68/// Extracts the Solana transaction data and deserializes it into a SolanaTransaction.
69/// This is a pure helper function that can be used anywhere in the Solana transaction domain.
70///
71/// Note: This only works for transactions that have already been built (transaction field is Some).
72/// For instructions-based transactions that haven't been prepared yet, this will return an error.
73pub 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
87/// Decodes a Solana transaction from a base64-encoded string.
88pub 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
96/// Converts instruction specifications to Solana SDK instructions.
97///
98/// Validates and converts each instruction specification by:
99/// - Parsing program IDs and account pubkeys from base58 strings
100/// - Decoding base64 instruction data
101///
102/// # Arguments
103/// * `instructions` - Array of instruction specifications from the request
104///
105/// # Returns
106/// Vector of Solana SDK `Instruction` objects ready to be included in a transaction
107pub 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
151/// Builds a Solana transaction from instruction specifications.
152///
153/// # Arguments
154/// * `instructions` - Array of instruction specifications
155/// * `payer` - Public key of the fee payer (must be the first signer)
156/// * `recent_blockhash` - Recent blockhash from the network
157///
158/// # Returns
159/// A fully formed transaction ready to be signed
160pub 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        // Create a transaction with invalid base64 data
189        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        // Create a transaction that hasn't been built yet (transaction field is None)
224        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, // Not built yet
237                ..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        // Create transaction with single signer
346        let tx = SolanaTransaction::new_with_payer(&[instruction], Some(&payer));
347
348        // Single signer - should be able to update blockhash
349        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        // Create transaction with multiple signers
361        let mut message = Message::new(&[instruction], Some(&payer));
362        // Add additional signer
363        message.account_keys.push(additional_signer);
364        message.header.num_required_signatures = 2;
365
366        let tx = SolanaTransaction::new_unsigned(message);
367
368        // Multi-signer - cannot update blockhash
369        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        // Create transaction with no required signatures (edge case)
380        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        // No signers (edge case) - should be able to update
386        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()], // Less than limit
406            noop_count: None,
407            is_canceled: Some(false),
408            metadata: None,
409        };
410
411        // Should not be too many attempts when under limit
412        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], // Exactly at limit
431            noop_count: None,
432            is_canceled: Some(false),
433            metadata: None,
434        };
435
436        // Should be too many attempts when at limit
437        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], // Over limit
456            noop_count: None,
457            is_canceled: Some(false),
458            metadata: None,
459        };
460
461        // Should be too many attempts when over limit
462        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}