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        };
209
210        let result = decode_solana_transaction(&tx);
211        assert!(result.is_err());
212
213        if let Err(TransactionError::ValidationError(msg)) = result {
214            assert!(msg.contains("Invalid transaction"));
215        } else {
216            panic!("Expected ValidationError");
217        }
218    }
219
220    #[test]
221    fn test_decode_solana_transaction_not_built() {
222        // Create a transaction that hasn't been built yet (transaction field is None)
223        let tx = TransactionRepoModel {
224            id: "test-tx".to_string(),
225            relayer_id: "test-relayer".to_string(),
226            status: TransactionStatus::Pending,
227            status_reason: None,
228            created_at: Utc::now().to_rfc3339(),
229            sent_at: None,
230            confirmed_at: None,
231            valid_until: None,
232            delete_at: None,
233            network_type: NetworkType::Solana,
234            network_data: NetworkTransactionData::Solana(SolanaTransactionData {
235                transaction: None, // Not built yet
236                ..Default::default()
237            }),
238            priced_at: None,
239            hashes: Vec::new(),
240            noop_count: None,
241            is_canceled: Some(false),
242        };
243
244        let result = decode_solana_transaction(&tx);
245        assert!(result.is_err());
246
247        if let Err(TransactionError::ValidationError(msg)) = result {
248            assert!(msg.contains("not yet built"));
249        } else {
250            panic!("Expected ValidationError");
251        }
252    }
253
254    #[test]
255    fn test_convert_instruction_specs_to_instructions_success() {
256        let program_id = Pubkey::new_unique();
257        let account = Pubkey::new_unique();
258
259        let specs = vec![SolanaInstructionSpec {
260            program_id: program_id.to_string(),
261            accounts: vec![SolanaAccountMeta {
262                pubkey: account.to_string(),
263                is_signer: false,
264                is_writable: true,
265            }],
266            data: base64_encode(b"test data"),
267        }];
268
269        let result = convert_instruction_specs_to_instructions(&specs);
270        assert!(result.is_ok());
271
272        let instructions = result.unwrap();
273        assert_eq!(instructions.len(), 1);
274        assert_eq!(instructions[0].program_id, program_id);
275        assert_eq!(instructions[0].accounts.len(), 1);
276        assert_eq!(instructions[0].accounts[0].pubkey, account);
277        assert!(!instructions[0].accounts[0].is_signer);
278        assert!(instructions[0].accounts[0].is_writable);
279    }
280
281    #[test]
282    fn test_build_transaction_from_instructions_success() {
283        let payer = Pubkey::new_unique();
284        let program_id = Pubkey::new_unique();
285        let account = Pubkey::new_unique();
286        let blockhash = Hash::new_unique();
287
288        let instructions = vec![SolanaInstructionSpec {
289            program_id: program_id.to_string(),
290            accounts: vec![SolanaAccountMeta {
291                pubkey: account.to_string(),
292                is_signer: false,
293                is_writable: true,
294            }],
295            data: base64_encode(b"test data"),
296        }];
297
298        let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
299        assert!(result.is_ok());
300
301        let tx = result.unwrap();
302        assert_eq!(tx.message.account_keys[0], payer);
303        assert_eq!(tx.message.recent_blockhash, blockhash);
304    }
305
306    #[test]
307    fn test_build_transaction_invalid_program_id() {
308        let payer = Pubkey::new_unique();
309        let blockhash = Hash::new_unique();
310
311        let instructions = vec![SolanaInstructionSpec {
312            program_id: "invalid".to_string(),
313            accounts: vec![],
314            data: base64_encode(b"test"),
315        }];
316
317        let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
318        assert!(result.is_err());
319    }
320
321    #[test]
322    fn test_build_transaction_invalid_base64_data() {
323        let payer = Pubkey::new_unique();
324        let program_id = Pubkey::new_unique();
325        let blockhash = Hash::new_unique();
326
327        let instructions = vec![SolanaInstructionSpec {
328            program_id: program_id.to_string(),
329            accounts: vec![],
330            data: "not-valid-base64!!!".to_string(),
331        }];
332
333        let result = build_transaction_from_instructions(&instructions, &payer, blockhash);
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_is_resubmitable_single_signer() {
339        let payer = Pubkey::new_unique();
340        let recipient = Pubkey::new_unique();
341        let instruction = system_instruction::transfer(&payer, &recipient, 1000);
342
343        // Create transaction with single signer
344        let tx = SolanaTransaction::new_with_payer(&[instruction], Some(&payer));
345
346        // Single signer - should be able to update blockhash
347        assert!(is_resubmitable(&tx));
348        assert_eq!(tx.message.header.num_required_signatures, 1);
349    }
350
351    #[test]
352    fn test_is_resubmitable_multi_signer() {
353        let payer = Pubkey::new_unique();
354        let recipient = Pubkey::new_unique();
355        let additional_signer = Pubkey::new_unique();
356        let instruction = system_instruction::transfer(&payer, &recipient, 1000);
357
358        // Create transaction with multiple signers
359        let mut message = Message::new(&[instruction], Some(&payer));
360        // Add additional signer
361        message.account_keys.push(additional_signer);
362        message.header.num_required_signatures = 2;
363
364        let tx = SolanaTransaction::new_unsigned(message);
365
366        // Multi-signer - cannot update blockhash
367        assert!(!is_resubmitable(&tx));
368        assert_eq!(tx.message.header.num_required_signatures, 2);
369    }
370
371    #[test]
372    fn test_is_resubmitable_no_signers() {
373        let payer = Pubkey::new_unique();
374        let recipient = Pubkey::new_unique();
375        let instruction = system_instruction::transfer(&payer, &recipient, 1000);
376
377        // Create transaction with no required signatures (edge case)
378        let mut message = Message::new(&[instruction], Some(&payer));
379        message.header.num_required_signatures = 0;
380
381        let tx = SolanaTransaction::new_unsigned(message);
382
383        // No signers (edge case) - should be able to update
384        assert!(is_resubmitable(&tx));
385        assert_eq!(tx.message.header.num_required_signatures, 0);
386    }
387
388    #[test]
389    fn test_too_many_solana_attempts_under_limit() {
390        let tx = TransactionRepoModel {
391            id: "test-tx".to_string(),
392            relayer_id: "test-relayer".to_string(),
393            status: TransactionStatus::Pending,
394            status_reason: None,
395            created_at: Utc::now().to_rfc3339(),
396            sent_at: None,
397            confirmed_at: None,
398            valid_until: None,
399            delete_at: None,
400            network_type: NetworkType::Solana,
401            network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
402            priced_at: None,
403            hashes: vec!["hash1".to_string(), "hash2".to_string()], // Less than limit
404            noop_count: None,
405            is_canceled: Some(false),
406        };
407
408        // Should not be too many attempts when under limit
409        assert!(!too_many_solana_attempts(&tx));
410    }
411
412    #[test]
413    fn test_too_many_solana_attempts_at_limit() {
414        let tx = TransactionRepoModel {
415            id: "test-tx".to_string(),
416            relayer_id: "test-relayer".to_string(),
417            status: TransactionStatus::Pending,
418            status_reason: None,
419            created_at: Utc::now().to_rfc3339(),
420            sent_at: None,
421            confirmed_at: None,
422            valid_until: None,
423            delete_at: None,
424            network_type: NetworkType::Solana,
425            network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
426            priced_at: None,
427            hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS], // Exactly at limit
428            noop_count: None,
429            is_canceled: Some(false),
430        };
431
432        // Should be too many attempts when at limit
433        assert!(too_many_solana_attempts(&tx));
434    }
435
436    #[test]
437    fn test_too_many_solana_attempts_over_limit() {
438        let tx = TransactionRepoModel {
439            id: "test-tx".to_string(),
440            relayer_id: "test-relayer".to_string(),
441            status: TransactionStatus::Pending,
442            status_reason: None,
443            created_at: Utc::now().to_rfc3339(),
444            sent_at: None,
445            confirmed_at: None,
446            valid_until: None,
447            delete_at: None,
448            network_type: NetworkType::Solana,
449            network_data: NetworkTransactionData::Solana(SolanaTransactionData::default()),
450            priced_at: None,
451            hashes: vec!["hash".to_string(); MAXIMUM_SOLANA_TX_ATTEMPTS + 1], // Over limit
452            noop_count: None,
453            is_canceled: Some(false),
454        };
455
456        // Should be too many attempts when over limit
457        assert!(too_many_solana_attempts(&tx));
458    }
459
460    #[test]
461    fn test_map_solana_status_to_transaction_status_processed() {
462        let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Processed);
463        assert_eq!(result, TransactionStatus::Mined);
464    }
465
466    #[test]
467    fn test_map_solana_status_to_transaction_status_confirmed() {
468        let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Confirmed);
469        assert_eq!(result, TransactionStatus::Mined);
470    }
471
472    #[test]
473    fn test_map_solana_status_to_transaction_status_finalized() {
474        let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Finalized);
475        assert_eq!(result, TransactionStatus::Confirmed);
476    }
477
478    #[test]
479    fn test_map_solana_status_to_transaction_status_failed() {
480        let result = map_solana_status_to_transaction_status(SolanaTransactionStatus::Failed);
481        assert_eq!(result, TransactionStatus::Failed);
482    }
483}