apex_sdk/
transaction.rs

1//! Transaction building and execution
2
3use crate::error::{Error, Result};
4use apex_sdk_types::{Address, Chain, TransactionStatus};
5use serde::{Deserialize, Serialize};
6use sha3::{Digest, Keccak256};
7
8/// Transaction builder for creating cross-chain transactions
9pub struct TransactionBuilder {
10    from: Option<Address>,
11    to: Option<Address>,
12    amount: Option<u128>,
13    source_chain: Option<Chain>,
14    destination_chain: Option<Chain>,
15    data: Option<Vec<u8>>,
16    gas_limit: Option<u64>,
17    nonce: Option<u64>,
18}
19
20impl TransactionBuilder {
21    /// Create a new transaction builder
22    pub fn new() -> Self {
23        Self {
24            from: None,
25            to: None,
26            amount: None,
27            source_chain: None,
28            destination_chain: None,
29            data: None,
30            gas_limit: None,
31            nonce: None,
32        }
33    }
34
35    /// Set the sender address (Substrate)
36    ///
37    /// **Security Note**: This method does not validate the address format.
38    /// Use `from_substrate_account_checked()` for validated input, or call `.build_validated()`
39    /// instead of `.build()` to validate all addresses before building the transaction.
40    pub fn from_substrate_account(mut self, address: impl Into<String>) -> Self {
41        self.from = Some(Address::substrate(address));
42        self
43    }
44
45    /// Set the sender address (Substrate) with immediate validation
46    ///
47    /// This validates the SS58 format and checksum immediately.
48    /// Returns an error if the address is invalid.
49    pub fn from_substrate_account_checked(mut self, address: impl Into<String>) -> Result<Self> {
50        self.from = Some(
51            Address::substrate_checked(address)
52                .map_err(|e| Error::InvalidAddress(e.to_string()))?,
53        );
54        Ok(self)
55    }
56
57    /// Set the sender address (EVM)
58    ///
59    /// **Security Note**: This method does not validate the address format.
60    /// Use `from_evm_address_checked()` for validated input, or call `.build_validated()`
61    /// instead of `.build()` to validate all addresses before building the transaction.
62    pub fn from_evm_address(mut self, address: impl Into<String>) -> Self {
63        self.from = Some(Address::evm(address));
64        self
65    }
66
67    /// Set the sender address (EVM) with immediate validation
68    ///
69    /// This validates the EVM format and EIP-55 checksum immediately.
70    /// Returns an error if the address is invalid.
71    pub fn from_evm_address_checked(mut self, address: impl Into<String>) -> Result<Self> {
72        self.from =
73            Some(Address::evm_checked(address).map_err(|e| Error::InvalidAddress(e.to_string()))?);
74        Ok(self)
75    }
76
77    /// Set the sender address
78    pub fn from(mut self, address: Address) -> Self {
79        self.from = Some(address);
80        self
81    }
82
83    /// Set the recipient address (EVM)
84    ///
85    /// **Security Note**: This method does not validate the address format.
86    /// Use `to_evm_address_checked()` for validated input, or call `.build_validated()`
87    /// instead of `.build()` to validate all addresses before building the transaction.
88    pub fn to_evm_address(mut self, address: impl Into<String>) -> Self {
89        self.to = Some(Address::evm(address));
90        self
91    }
92
93    /// Set the recipient address (EVM) with immediate validation
94    ///
95    /// This validates the EVM format and EIP-55 checksum immediately.
96    /// Returns an error if the address is invalid.
97    pub fn to_evm_address_checked(mut self, address: impl Into<String>) -> Result<Self> {
98        self.to =
99            Some(Address::evm_checked(address).map_err(|e| Error::InvalidAddress(e.to_string()))?);
100        Ok(self)
101    }
102
103    /// Set the recipient address (Substrate)
104    ///
105    /// **Security Note**: This method does not validate the address format.
106    /// Use `to_substrate_account_checked()` for validated input, or call `.build_validated()`
107    /// instead of `.build()` to validate all addresses before building the transaction.
108    pub fn to_substrate_account(mut self, address: impl Into<String>) -> Self {
109        self.to = Some(Address::substrate(address));
110        self
111    }
112
113    /// Set the recipient address (Substrate) with immediate validation
114    ///
115    /// This validates the SS58 format and checksum immediately.
116    /// Returns an error if the address is invalid.
117    pub fn to_substrate_account_checked(mut self, address: impl Into<String>) -> Result<Self> {
118        self.to = Some(
119            Address::substrate_checked(address)
120                .map_err(|e| Error::InvalidAddress(e.to_string()))?,
121        );
122        Ok(self)
123    }
124
125    /// Set the recipient address
126    pub fn to(mut self, address: Address) -> Self {
127        self.to = Some(address);
128        self
129    }
130
131    /// Set the transfer amount
132    pub fn amount(mut self, amount: u128) -> Self {
133        self.amount = Some(amount);
134        self
135    }
136
137    /// Set the source chain
138    pub fn on_chain(mut self, chain: Chain) -> Self {
139        self.source_chain = Some(chain);
140        self
141    }
142
143    /// Set transaction data/payload
144    pub fn with_data(mut self, data: Vec<u8>) -> Self {
145        self.data = Some(data);
146        self
147    }
148
149    /// Set gas limit
150    pub fn with_gas_limit(mut self, limit: u64) -> Self {
151        self.gas_limit = Some(limit);
152        self
153    }
154
155    /// Set transaction nonce (for uniqueness and replay protection)
156    pub fn with_nonce(mut self, nonce: u64) -> Self {
157        self.nonce = Some(nonce);
158        self
159    }
160
161    /// Build the transaction
162    pub fn build(self) -> Result<Transaction> {
163        let from = self
164            .from
165            .ok_or_else(|| Error::transaction("Sender address required"))?;
166        let to = self
167            .to
168            .ok_or_else(|| Error::transaction("Recipient address required"))?;
169        let amount = self
170            .amount
171            .ok_or_else(|| Error::transaction("Amount required"))?;
172
173        // Determine source and destination chains based on addresses if not specified
174        let source_chain = self.source_chain.unwrap_or(match &from {
175            Address::Substrate(_) => Chain::Polkadot,
176            Address::Evm(_) => Chain::Ethereum,
177        });
178
179        let destination_chain = self.destination_chain.unwrap_or(match &to {
180            Address::Substrate(_) => Chain::Polkadot,
181            Address::Evm(_) => Chain::Ethereum,
182        });
183
184        Ok(Transaction {
185            from,
186            to,
187            amount,
188            source_chain,
189            destination_chain,
190            data: self.data,
191            gas_limit: self.gas_limit,
192            nonce: self.nonce,
193        })
194    }
195}
196
197impl Default for TransactionBuilder {
198    fn default() -> Self {
199        Self::new()
200    }
201}
202
203/// Represents a blockchain transaction
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct Transaction {
206    /// Sender address
207    pub from: Address,
208    /// Recipient address
209    pub to: Address,
210    /// Amount to transfer
211    pub amount: u128,
212    /// Source blockchain
213    pub source_chain: Chain,
214    /// Destination blockchain
215    pub destination_chain: Chain,
216    /// Transaction data/payload
217    pub data: Option<Vec<u8>>,
218    /// Gas limit
219    pub gas_limit: Option<u64>,
220    /// Nonce for transaction uniqueness (prevents replay attacks)
221    #[serde(default)]
222    pub nonce: Option<u64>,
223}
224
225impl Transaction {
226    /// Check if this is a cross-chain transaction
227    pub fn is_cross_chain(&self) -> bool {
228        self.source_chain != self.destination_chain
229    }
230
231    /// Get transaction hash using Keccak256
232    ///
233    /// This implementation ensures deterministic hashing by:
234    /// 1. Using canonical name representation instead of Debug formatting
235    /// 2. Explicitly marking presence/absence of optional fields
236    /// 3. Including all fields in a fixed order
237    ///
238    /// # Determinism Guarantee
239    ///
240    /// The same transaction parameters will always produce the same hash,
241    /// regardless of the order of construction or serialization format changes.
242    pub fn hash(&self) -> String {
243        let mut hasher = Keccak256::new();
244
245        // Hash transaction data in deterministic order with explicit field markers
246
247        // Field 1: from address
248        hasher.update(b"from:");
249        hasher.update(self.from.as_str().as_bytes());
250
251        // Field 2: to address
252        hasher.update(b"to:");
253        hasher.update(self.to.as_str().as_bytes());
254
255        // Field 3: amount (always present)
256        hasher.update(b"amount:");
257        hasher.update(self.amount.to_le_bytes());
258
259        // Field 4: source chain (use canonical name)
260        hasher.update(b"source_chain:");
261        hasher.update(self.source_chain.name().as_bytes());
262
263        // Field 5: destination chain (use canonical name)
264        hasher.update(b"destination_chain:");
265        hasher.update(self.destination_chain.name().as_bytes());
266
267        // Field 6: data (optional - mark presence)
268        hasher.update(b"data:");
269        if let Some(ref data) = self.data {
270            hasher.update(b"some:");
271            hasher.update((data.len() as u64).to_le_bytes());
272            hasher.update(data);
273        } else {
274            hasher.update(b"none");
275        }
276
277        // Field 7: gas_limit (optional - mark presence)
278        hasher.update(b"gas_limit:");
279        if let Some(gas_limit) = self.gas_limit {
280            hasher.update(b"some:");
281            hasher.update(gas_limit.to_le_bytes());
282        } else {
283            hasher.update(b"none");
284        }
285
286        // Field 8: nonce (optional - mark presence)
287        hasher.update(b"nonce:");
288        if let Some(nonce) = self.nonce {
289            hasher.update(b"some:");
290            hasher.update(nonce.to_le_bytes());
291        } else {
292            hasher.update(b"none");
293        }
294
295        let result = hasher.finalize();
296        format!("0x{}", hex::encode(result))
297    }
298}
299
300/// Transaction execution result
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TransactionResult {
303    /// Transaction hash on source chain
304    pub source_tx_hash: String,
305    /// Transaction hash on destination chain (for cross-chain)
306    pub destination_tx_hash: Option<String>,
307    /// Transaction status
308    pub status: TransactionStatus,
309    /// Block number where transaction was included
310    pub block_number: Option<u64>,
311    /// Gas used
312    pub gas_used: Option<u64>,
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_transaction_builder_new() {
321        let builder = TransactionBuilder::new();
322        assert!(builder.from.is_none());
323        assert!(builder.to.is_none());
324        assert!(builder.amount.is_none());
325    }
326
327    #[test]
328    fn test_transaction_builder_default() {
329        let builder = TransactionBuilder::default();
330        assert!(builder.from.is_none());
331        assert!(builder.to.is_none());
332    }
333
334    #[test]
335    fn test_transaction_builder_evm_to_evm() {
336        let tx = TransactionBuilder::new()
337            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
338            .to_evm_address("0x1234567890123456789012345678901234567890")
339            .amount(1000)
340            .build();
341
342        assert!(tx.is_ok());
343        let tx = tx.unwrap();
344        assert_eq!(tx.amount, 1000);
345        assert!(!tx.is_cross_chain());
346        assert_eq!(tx.source_chain, Chain::Ethereum);
347        assert_eq!(tx.destination_chain, Chain::Ethereum);
348    }
349
350    #[test]
351    fn test_transaction_builder_substrate_to_evm() {
352        let tx = TransactionBuilder::new()
353            .from_substrate_account("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY")
354            .to_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
355            .amount(500)
356            .build();
357
358        assert!(tx.is_ok());
359        let tx = tx.unwrap();
360        assert!(tx.is_cross_chain());
361        assert_eq!(tx.source_chain, Chain::Polkadot);
362        assert_eq!(tx.destination_chain, Chain::Ethereum);
363    }
364
365    #[test]
366    fn test_transaction_builder_substrate_to_substrate() {
367        let tx = TransactionBuilder::new()
368            .from_substrate_account("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY")
369            .to_substrate_account("5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty")
370            .amount(2000)
371            .build();
372
373        assert!(tx.is_ok());
374        let tx = tx.unwrap();
375        assert!(!tx.is_cross_chain());
376        assert_eq!(tx.source_chain, Chain::Polkadot);
377        assert_eq!(tx.destination_chain, Chain::Polkadot);
378    }
379
380    #[test]
381    fn test_transaction_builder_with_explicit_chain() {
382        let tx = TransactionBuilder::new()
383            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
384            .to_evm_address("0x1234567890123456789012345678901234567890")
385            .amount(1000)
386            .on_chain(Chain::Polygon)
387            .build();
388
389        assert!(tx.is_ok());
390        let tx = tx.unwrap();
391        assert_eq!(tx.source_chain, Chain::Polygon);
392    }
393
394    #[test]
395    fn test_transaction_builder_missing_from() {
396        let result = TransactionBuilder::new()
397            .to_evm_address("0x1234567890123456789012345678901234567890")
398            .amount(100)
399            .build();
400
401        assert!(result.is_err());
402        match result {
403            Err(Error::Transaction(msg, _)) => {
404                assert!(msg.contains("Sender address required"));
405            }
406            _ => panic!("Expected Transaction error"),
407        }
408    }
409
410    #[test]
411    fn test_transaction_builder_missing_to() {
412        let result = TransactionBuilder::new()
413            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
414            .amount(100)
415            .build();
416
417        assert!(result.is_err());
418        match result {
419            Err(Error::Transaction(msg, _)) => {
420                assert!(msg.contains("Recipient address required"));
421            }
422            _ => panic!("Expected Transaction error"),
423        }
424    }
425
426    #[test]
427    fn test_transaction_builder_missing_amount() {
428        let result = TransactionBuilder::new()
429            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
430            .to_evm_address("0x1234567890123456789012345678901234567890")
431            .build();
432
433        assert!(result.is_err());
434        match result {
435            Err(Error::Transaction(msg, _)) => {
436                assert!(msg.contains("Amount required"));
437            }
438            _ => panic!("Expected Transaction error"),
439        }
440    }
441
442    #[test]
443    fn test_transaction_with_data() {
444        let tx = TransactionBuilder::new()
445            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
446            .to_evm_address("0x1234567890123456789012345678901234567890")
447            .amount(1000)
448            .with_data(vec![1, 2, 3, 4])
449            .with_gas_limit(21000)
450            .build();
451
452        assert!(tx.is_ok());
453        let tx = tx.unwrap();
454        assert_eq!(tx.data, Some(vec![1, 2, 3, 4]));
455        assert_eq!(tx.gas_limit, Some(21000));
456    }
457
458    #[test]
459    fn test_transaction_with_empty_data() {
460        let tx = TransactionBuilder::new()
461            .from_evm_address("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7")
462            .to_evm_address("0x1234567890123456789012345678901234567890")
463            .amount(1000)
464            .with_data(vec![])
465            .build();
466
467        assert!(tx.is_ok());
468        let tx = tx.unwrap();
469        assert_eq!(tx.data, Some(vec![]));
470    }
471
472    #[test]
473    fn test_transaction_is_cross_chain() {
474        let tx = Transaction {
475            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
476            to: Address::substrate("5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"),
477            amount: 1000,
478            source_chain: Chain::Ethereum,
479            destination_chain: Chain::Polkadot,
480            data: None,
481            gas_limit: None,
482            nonce: None,
483        };
484
485        assert!(tx.is_cross_chain());
486    }
487
488    #[test]
489    fn test_transaction_is_not_cross_chain() {
490        let tx = Transaction {
491            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
492            to: Address::evm("0x1234567890123456789012345678901234567890"),
493            amount: 1000,
494            source_chain: Chain::Ethereum,
495            destination_chain: Chain::Ethereum,
496            data: None,
497            gas_limit: None,
498            nonce: None,
499        };
500
501        assert!(!tx.is_cross_chain());
502    }
503
504    #[test]
505    fn test_transaction_hash() {
506        let tx = Transaction {
507            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
508            to: Address::evm("0x1234567890123456789012345678901234567890"),
509            amount: 1000,
510            source_chain: Chain::Ethereum,
511            destination_chain: Chain::Ethereum,
512            data: None,
513            gas_limit: None,
514            nonce: None,
515        };
516
517        let hash = tx.hash();
518        assert!(hash.starts_with("0x"));
519        assert_eq!(hash.len(), 66); // 0x + 64 hex chars
520    }
521
522    #[test]
523    fn test_transaction_hash_determinism() {
524        // Create identical transactions
525        let tx1 = Transaction {
526            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
527            to: Address::evm("0x1234567890123456789012345678901234567890"),
528            amount: 1000,
529            source_chain: Chain::Ethereum,
530            destination_chain: Chain::Ethereum,
531            data: Some(vec![1, 2, 3, 4]),
532            gas_limit: Some(21000),
533            nonce: Some(42),
534        };
535
536        let tx2 = Transaction {
537            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
538            to: Address::evm("0x1234567890123456789012345678901234567890"),
539            amount: 1000,
540            source_chain: Chain::Ethereum,
541            destination_chain: Chain::Ethereum,
542            data: Some(vec![1, 2, 3, 4]),
543            gas_limit: Some(21000),
544            nonce: Some(42),
545        };
546
547        // Same parameters should produce same hash
548        assert_eq!(tx1.hash(), tx2.hash());
549    }
550
551    #[test]
552    fn test_transaction_hash_changes_with_nonce() {
553        let tx1 = Transaction {
554            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
555            to: Address::evm("0x1234567890123456789012345678901234567890"),
556            amount: 1000,
557            source_chain: Chain::Ethereum,
558            destination_chain: Chain::Ethereum,
559            data: None,
560            gas_limit: None,
561            nonce: Some(1),
562        };
563
564        let tx2 = Transaction {
565            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
566            to: Address::evm("0x1234567890123456789012345678901234567890"),
567            amount: 1000,
568            source_chain: Chain::Ethereum,
569            destination_chain: Chain::Ethereum,
570            data: None,
571            gas_limit: None,
572            nonce: Some(2),
573        };
574
575        // Different nonce should produce different hash
576        assert_ne!(tx1.hash(), tx2.hash());
577    }
578
579    #[test]
580    fn test_transaction_hash_none_vs_some_data() {
581        let tx_none = Transaction {
582            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
583            to: Address::evm("0x1234567890123456789012345678901234567890"),
584            amount: 1000,
585            source_chain: Chain::Ethereum,
586            destination_chain: Chain::Ethereum,
587            data: None,
588            gas_limit: None,
589            nonce: None,
590        };
591
592        let tx_empty = Transaction {
593            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
594            to: Address::evm("0x1234567890123456789012345678901234567890"),
595            amount: 1000,
596            source_chain: Chain::Ethereum,
597            destination_chain: Chain::Ethereum,
598            data: Some(vec![]),
599            gas_limit: None,
600            nonce: None,
601        };
602
603        // None and Some(empty) should produce different hashes
604        assert_ne!(tx_none.hash(), tx_empty.hash());
605    }
606
607    #[test]
608    fn test_transaction_clone() {
609        let tx = Transaction {
610            from: Address::evm("0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb7"),
611            to: Address::evm("0x1234567890123456789012345678901234567890"),
612            amount: 1000,
613            source_chain: Chain::Ethereum,
614            destination_chain: Chain::Ethereum,
615            data: Some(vec![1, 2, 3]),
616            gas_limit: Some(21000),
617            nonce: Some(5),
618        };
619
620        let cloned = tx.clone();
621        assert_eq!(tx.amount, cloned.amount);
622        assert_eq!(tx.data, cloned.data);
623        assert_eq!(tx.gas_limit, cloned.gas_limit);
624        assert_eq!(tx.nonce, cloned.nonce);
625    }
626
627    #[test]
628    fn test_transaction_result_serialization() {
629        let result = TransactionResult {
630            source_tx_hash: "0xabc123".to_string(),
631            destination_tx_hash: Some("0xdef456".to_string()),
632            status: TransactionStatus::Confirmed {
633                block_hash: "0xblock123".to_string(),
634                block_number: Some(12345),
635            },
636            block_number: Some(12345),
637            gas_used: Some(21000),
638        };
639
640        let json = serde_json::to_string(&result).unwrap();
641        let deserialized: TransactionResult = serde_json::from_str(&json).unwrap();
642
643        assert_eq!(result.source_tx_hash, deserialized.source_tx_hash);
644        assert_eq!(result.destination_tx_hash, deserialized.destination_tx_hash);
645        assert_eq!(result.block_number, deserialized.block_number);
646        assert_eq!(result.gas_used, deserialized.gas_used);
647    }
648}