apex_sdk_substrate/
transaction.rs

1//! Substrate transaction execution and extrinsic building
2//!
3//! This module provides comprehensive transaction functionality including:
4//! - Extrinsic building and submission
5//! - Fee estimation
6//! - Transaction signing
7//! - Retry logic with exponential backoff
8//! - Transaction confirmation tracking
9
10use crate::{Error, Metrics, Result, Sr25519Signer, Wallet};
11use std::time::Duration;
12use subxt::{OnlineClient, PolkadotConfig};
13use tokio::time::sleep;
14use tracing::{debug, info, warn};
15
16/// Batch transaction execution mode
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum BatchMode {
19    /// Batch calls optimistically - continue even if some fail
20    /// Uses `Utility::batch`
21    Optimistic,
22    /// All-or-nothing batch - revert all if any fails
23    /// Uses `Utility::batch_all`
24    AllOrNothing,
25    /// Force batch - always succeeds, marks failed calls
26    /// Uses `Utility::force_batch`
27    Force,
28}
29
30impl Default for BatchMode {
31    fn default() -> Self {
32        Self::Optimistic
33    }
34}
35
36/// Represents a single call in a batch transaction
37#[derive(Debug, Clone)]
38pub struct BatchCall {
39    /// Pallet index in the runtime
40    pub pallet_index: u8,
41    /// Call index within the pallet
42    pub call_index: u8,
43    /// Encoded call arguments
44    pub args_encoded: Vec<u8>,
45}
46
47impl BatchCall {
48    /// Create a new batch call
49    pub fn new(pallet_index: u8, call_index: u8, args_encoded: Vec<u8>) -> Self {
50        Self {
51            pallet_index,
52            call_index,
53            args_encoded,
54        }
55    }
56}
57
58/// Fee estimation configuration
59#[derive(Debug, Clone)]
60pub struct FeeConfig {
61    /// Fee multiplier for safety margin (default: 1.2)
62    pub multiplier: f64,
63    /// Maximum fee willing to pay (in Planck/smallest unit)
64    pub max_fee: Option<u128>,
65    /// Tip to include with transaction
66    pub tip: u128,
67}
68
69impl Default for FeeConfig {
70    fn default() -> Self {
71        Self {
72            multiplier: 1.2,
73            max_fee: None,
74            tip: 0,
75        }
76    }
77}
78
79impl FeeConfig {
80    /// Create a new fee configuration with default values
81    pub fn new() -> Self {
82        Self::default()
83    }
84
85    /// Set the fee multiplier
86    pub fn with_multiplier(mut self, multiplier: f64) -> Self {
87        self.multiplier = multiplier;
88        self
89    }
90
91    /// Set the maximum fee
92    pub fn with_max_fee(mut self, max_fee: u128) -> Self {
93        self.max_fee = Some(max_fee);
94        self
95    }
96
97    /// Set the tip amount
98    pub fn with_tip(mut self, tip: u128) -> Self {
99        self.tip = tip;
100        self
101    }
102}
103
104/// Retry configuration for transaction submission
105#[derive(Debug, Clone)]
106pub struct RetryConfig {
107    /// Maximum number of retry attempts
108    pub max_retries: u32,
109    /// Initial retry delay
110    pub initial_delay: Duration,
111    /// Maximum retry delay
112    pub max_delay: Duration,
113    /// Exponential backoff multiplier
114    pub backoff_multiplier: f64,
115}
116
117impl Default for RetryConfig {
118    fn default() -> Self {
119        Self {
120            max_retries: 3,
121            initial_delay: Duration::from_secs(2),
122            max_delay: Duration::from_secs(30),
123            backoff_multiplier: 2.0,
124        }
125    }
126}
127
128impl RetryConfig {
129    /// Create a new retry configuration with default values
130    pub fn new() -> Self {
131        Self::default()
132    }
133
134    /// Set the maximum number of retries
135    pub fn with_max_retries(mut self, max_retries: u32) -> Self {
136        self.max_retries = max_retries;
137        self
138    }
139
140    /// Set the initial delay
141    pub fn with_initial_delay(mut self, delay: Duration) -> Self {
142        self.initial_delay = delay;
143        self
144    }
145}
146
147/// Transaction executor for building and submitting extrinsics
148pub struct TransactionExecutor {
149    client: OnlineClient<PolkadotConfig>,
150    fee_config: FeeConfig,
151    retry_config: RetryConfig,
152    metrics: Metrics,
153}
154
155impl TransactionExecutor {
156    /// Create a new transaction executor
157    pub fn new(client: OnlineClient<PolkadotConfig>, metrics: Metrics) -> Self {
158        Self {
159            client,
160            fee_config: FeeConfig::default(),
161            retry_config: RetryConfig::default(),
162            metrics,
163        }
164    }
165
166    /// Set the fee configuration
167    pub fn with_fee_config(mut self, fee_config: FeeConfig) -> Self {
168        self.fee_config = fee_config;
169        self
170    }
171
172    /// Set the retry configuration
173    pub fn with_retry_config(mut self, retry_config: RetryConfig) -> Self {
174        self.retry_config = retry_config;
175        self
176    }
177
178    /// Submit a balance transfer transaction
179    pub async fn transfer(&self, from: &Wallet, to: &str, amount: u128) -> Result<String> {
180        info!(
181            "Submitting transfer from {} to {} of {} units",
182            from.address(),
183            to,
184            amount
185        );
186
187        // Parse destination address
188        use sp_core::crypto::Ss58Codec;
189        let dest = sp_core::sr25519::Public::from_ss58check(to)
190            .map_err(|e| Error::Transaction(format!("Invalid destination address: {}", e)))?;
191
192        // Build the transfer call using dynamic transactions
193        use subxt::dynamic::Value;
194
195        let dest_value = Value::unnamed_variant("Id", vec![Value::from_bytes(dest.0)]);
196
197        let transfer_call = subxt::dynamic::tx(
198            "Balances",
199            "transfer_keep_alive",
200            vec![dest_value, Value::u128(amount)],
201        );
202
203        // Submit with retry logic
204        self.submit_extrinsic_with_retry(&transfer_call, from).await
205    }
206
207    /// Submit an extrinsic with retry logic
208    async fn submit_extrinsic_with_retry<Call>(
209        &self,
210        call: &Call,
211        signer: &Wallet,
212    ) -> Result<String>
213    where
214        Call: subxt::tx::Payload,
215    {
216        let mut attempts = 0;
217        let mut delay = self.retry_config.initial_delay;
218
219        loop {
220            attempts += 1;
221            self.metrics.record_transaction_attempt();
222
223            match self.submit_extrinsic(call, signer).await {
224                Ok(hash) => {
225                    self.metrics.record_transaction_success();
226                    return Ok(hash);
227                }
228                Err(e) => {
229                    if attempts >= self.retry_config.max_retries {
230                        warn!("Transaction failed after {} attempts: {}", attempts, e);
231                        self.metrics.record_transaction_failure();
232                        return Err(e);
233                    }
234
235                    warn!(
236                        "Transaction attempt {} failed: {}. Retrying in {:?}",
237                        attempts, e, delay
238                    );
239                    sleep(delay).await;
240
241                    // Exponential backoff with jitter
242                    delay = Duration::from_secs_f64(
243                        (delay.as_secs_f64() * self.retry_config.backoff_multiplier)
244                            .min(self.retry_config.max_delay.as_secs_f64()),
245                    );
246                }
247            }
248        }
249    }
250
251    /// Submit an extrinsic and wait for it to be included in a block
252    async fn submit_extrinsic<Call>(&self, call: &Call, signer: &Wallet) -> Result<String>
253    where
254        Call: subxt::tx::Payload,
255    {
256        debug!("Submitting extrinsic");
257
258        // Get the pair from wallet and create our custom signer
259        let pair = signer
260            .sr25519_pair()
261            .ok_or_else(|| Error::Transaction("Wallet does not have SR25519 key".to_string()))?;
262
263        // Create a signer from the pair using our custom implementation
264        let apex_signer = Sr25519Signer::new(pair.clone());
265
266        // Submit and watch the transaction
267        let mut progress = self
268            .client
269            .tx()
270            .sign_and_submit_then_watch_default(call, &apex_signer)
271            .await
272            .map_err(|e| Error::Transaction(format!("Failed to submit transaction: {}", e)))?;
273
274        // Wait for finalization
275        while let Some(event) = progress.next().await {
276            let event =
277                event.map_err(|e| Error::Transaction(format!("Transaction error: {}", e)))?;
278
279            if event.as_in_block().is_some() {
280                info!("Transaction included in block");
281            }
282
283            if let Some(finalized) = event.as_finalized() {
284                let tx_hash = format!("0x{}", hex::encode(finalized.extrinsic_hash()));
285                info!("Transaction finalized: {}", tx_hash);
286
287                // Wait for success
288                finalized
289                    .wait_for_success()
290                    .await
291                    .map_err(|e| Error::Transaction(format!("Transaction failed: {}", e)))?;
292
293                return Ok(tx_hash);
294            }
295        }
296
297        Err(Error::Transaction(
298            "Transaction stream ended without finalization".to_string(),
299        ))
300    }
301
302    /// Estimate fees for a transaction
303    ///
304    /// # Arguments
305    /// * `pallet` - The pallet name (e.g., "Balances")
306    /// * `call` - The call name (e.g., "transfer_keep_alive")
307    /// * `args` - The call arguments as dynamic values
308    /// * `from` - The sender wallet for signing context
309    ///
310    /// Returns the estimated fee in Planck (smallest unit)
311    pub async fn estimate_fee(
312        &self,
313        pallet: &str,
314        call: &str,
315        args: Vec<subxt::dynamic::Value>,
316        _from: &Wallet,
317    ) -> Result<u128> {
318        debug!("Estimating fee for {}::{}", pallet, call);
319
320        // Build the call
321        let tx = subxt::dynamic::tx(pallet, call, args);
322
323        // Create a partial extrinsic for fee estimation
324        // We need the encoded call data to estimate fees
325        let payload = self
326            .client
327            .tx()
328            .create_unsigned(&tx)
329            .map_err(|e| Error::Transaction(format!("Failed to create unsigned tx: {}", e)))?;
330
331        // Get the encoded bytes
332        let encoded = payload.encoded();
333
334        // Query fee details using state_call RPC
335        // The TransactionPaymentApi_query_info runtime call provides fee information
336        let call_data = {
337            use parity_scale_codec::Encode;
338            // Prepare the runtime API call parameters
339            // query_info(extrinsic: Vec<u8>, len: u32) -> RuntimeDispatchInfo
340            let params = (encoded, encoded.len() as u32);
341            params.encode()
342        };
343
344        // Call the runtime API
345        let result = self
346            .client
347            .runtime_api()
348            .at_latest()
349            .await
350            .map_err(|e| Error::Connection(format!("Failed to get latest block: {}", e)))?
351            .call_raw("TransactionPaymentApi_query_info", Some(&call_data))
352            .await
353            .map_err(|e| Error::Transaction(format!("Failed to query fee info: {}", e)))?;
354
355        // Decode the RuntimeDispatchInfo
356        // It contains: weight, class, and partial_fee
357        // The response is a RuntimeDispatchInfo struct
358        // We primarily care about partial_fee (last field, u128)
359        // Simple approach: extract the last 16 bytes as u128
360        if result.len() >= 16 {
361            let fee_bytes = &result[result.len() - 16..];
362            let mut fee_array = [0u8; 16];
363            fee_array.copy_from_slice(fee_bytes);
364            let base_fee = u128::from_le_bytes(fee_array);
365
366            // Apply multiplier for safety margin
367            let estimated_fee = (base_fee as f64 * self.fee_config.multiplier) as u128;
368
369            // Check against max fee if configured
370            if let Some(max_fee) = self.fee_config.max_fee {
371                if estimated_fee > max_fee {
372                    return Err(Error::Transaction(format!(
373                        "Estimated fee {} exceeds maximum {}",
374                        estimated_fee, max_fee
375                    )));
376                }
377            }
378
379            debug!(
380                "Estimated fee: {} (base: {}, multiplier: {})",
381                estimated_fee, base_fee, self.fee_config.multiplier
382            );
383
384            Ok(estimated_fee + self.fee_config.tip)
385        } else {
386            warn!("Unexpected fee query response format, using fallback");
387            // Fallback to a conservative estimate
388            Ok(1_000_000u128) // 1 million Planck
389        }
390    }
391
392    /// Estimate fees for a simple balance transfer (convenience method)
393    pub async fn estimate_transfer_fee(
394        &self,
395        to: &str,
396        amount: u128,
397        from: &Wallet,
398    ) -> Result<u128> {
399        use sp_core::crypto::{AccountId32, Ss58Codec};
400        let to_account = AccountId32::from_ss58check(to)
401            .map_err(|e| Error::Transaction(format!("Invalid recipient address: {}", e)))?;
402
403        let to_bytes: &[u8] = to_account.as_ref();
404
405        self.estimate_fee(
406            "Balances",
407            "transfer_keep_alive",
408            vec![
409                subxt::dynamic::Value::from_bytes(to_bytes),
410                subxt::dynamic::Value::u128(amount),
411            ],
412            from,
413        )
414        .await
415    }
416
417    /// Execute a batch of transactions using the Utility pallet
418    ///
419    /// # Arguments
420    /// * `calls` - Vector of calls to execute in batch
421    /// * `wallet` - The wallet to sign the batch transaction
422    /// * `batch_mode` - The batch execution mode (see BatchMode)
423    ///
424    /// Returns the transaction hash of the batch extrinsic
425    pub async fn execute_batch(
426        &self,
427        calls: Vec<BatchCall>,
428        wallet: &Wallet,
429        batch_mode: BatchMode,
430    ) -> Result<String> {
431        debug!(
432            "Executing batch of {} calls with mode {:?}",
433            calls.len(),
434            batch_mode
435        );
436        self.metrics.record_transaction_attempt();
437
438        if calls.is_empty() {
439            return Err(Error::Transaction("Cannot execute empty batch".to_string()));
440        }
441
442        // Convert BatchCalls to dynamic values
443        // Note: This is a simplified implementation. For production use,
444        // generate typed metadata using `subxt codegen` for better type safety
445        let call_values: Vec<subxt::dynamic::Value> = calls
446            .into_iter()
447            .map(|call| {
448                // Create the encoded call bytes (pallet_index + call_index + args)
449                let mut call_bytes = Vec::new();
450                call_bytes.push(call.pallet_index);
451                call_bytes.push(call.call_index);
452                call_bytes.extend_from_slice(&call.args_encoded);
453
454                // Return as a Value containing the bytes
455                subxt::dynamic::Value::from_bytes(&call_bytes)
456            })
457            .collect();
458
459        // Wrap calls in a composite for the batch
460        let calls_value = subxt::dynamic::Value::unnamed_composite(call_values);
461
462        // Determine which batch call to use
463        let batch_call_name = match batch_mode {
464            BatchMode::Optimistic => "batch",
465            BatchMode::AllOrNothing => "batch_all",
466            BatchMode::Force => "force_batch",
467        };
468
469        debug!("Using Utility::{} for batch execution", batch_call_name);
470
471        // Create the batch transaction
472        let tx = subxt::dynamic::tx("Utility", batch_call_name, vec![calls_value]);
473
474        // Get the pair from wallet and create our custom signer
475        let pair = wallet
476            .sr25519_pair()
477            .ok_or_else(|| Error::Transaction("Wallet does not have SR25519 key".to_string()))?;
478
479        // Create a signer from the pair using our custom implementation
480        let apex_signer = Sr25519Signer::new(pair.clone());
481
482        // Sign and submit
483        let mut signed_tx = self
484            .client
485            .tx()
486            .sign_and_submit_then_watch_default(&tx, &apex_signer)
487            .await
488            .map_err(|e| Error::Transaction(format!("Failed to submit batch: {}", e)))?;
489
490        // Wait for finalization
491        while let Some(event) = signed_tx.next().await {
492            let event =
493                event.map_err(|e| Error::Transaction(format!("Batch transaction error: {}", e)))?;
494
495            if event.as_in_block().is_some() {
496                info!("Batch transaction included in block");
497            }
498
499            if let Some(finalized) = event.as_finalized() {
500                let tx_hash = format!("0x{}", hex::encode(finalized.extrinsic_hash()));
501                info!("Batch transaction finalized: {}", tx_hash);
502
503                // Wait for success
504                finalized
505                    .wait_for_success()
506                    .await
507                    .map_err(|e| Error::Transaction(format!("Batch transaction failed: {}", e)))?;
508
509                self.metrics.record_transaction_success();
510                return Ok(tx_hash);
511            }
512        }
513
514        Err(Error::Transaction(
515            "Batch transaction stream ended without finalization".to_string(),
516        ))
517    }
518
519    /// Execute a batch of balance transfers
520    ///
521    /// Convenience method for batching multiple transfers
522    pub async fn execute_batch_transfers(
523        &self,
524        transfers: Vec<(String, u128)>, // (recipient, amount) pairs
525        wallet: &Wallet,
526        batch_mode: BatchMode,
527    ) -> Result<String> {
528        use sp_core::crypto::{AccountId32, Ss58Codec};
529
530        // Convert transfers to BatchCalls
531        let mut calls = Vec::new();
532
533        for (recipient, amount) in transfers {
534            let to_account = AccountId32::from_ss58check(&recipient).map_err(|e| {
535                Error::Transaction(format!("Invalid recipient {}: {}", recipient, e))
536            })?;
537
538            let to_bytes: &[u8] = to_account.as_ref();
539
540            // Encode the transfer call arguments
541            use parity_scale_codec::Encode;
542            let args = (to_bytes, amount).encode();
543
544            calls.push(BatchCall {
545                pallet_index: 5, // Balances pallet (typical index, may vary by chain)
546                call_index: 3,   // transfer_keep_alive (typical index, may vary by chain)
547                args_encoded: args,
548            });
549        }
550
551        self.execute_batch(calls, wallet, batch_mode).await
552    }
553}
554
555/// Builder for constructing extrinsics
556#[allow(dead_code)]
557pub struct ExtrinsicBuilder {
558    client: OnlineClient<PolkadotConfig>,
559    pallet: Option<String>,
560    call: Option<String>,
561    args: Vec<subxt::dynamic::Value>,
562}
563
564impl ExtrinsicBuilder {
565    /// Create a new extrinsic builder
566    pub fn new(client: OnlineClient<PolkadotConfig>) -> Self {
567        Self {
568            client,
569            pallet: None,
570            call: None,
571            args: Vec::new(),
572        }
573    }
574
575    /// Set the pallet name
576    pub fn pallet(mut self, pallet: impl Into<String>) -> Self {
577        self.pallet = Some(pallet.into());
578        self
579    }
580
581    /// Set the call name
582    pub fn call(mut self, call: impl Into<String>) -> Self {
583        self.call = Some(call.into());
584        self
585    }
586
587    /// Add an argument
588    pub fn arg(mut self, arg: subxt::dynamic::Value) -> Self {
589        self.args.push(arg);
590        self
591    }
592
593    /// Add multiple arguments
594    pub fn args(mut self, args: Vec<subxt::dynamic::Value>) -> Self {
595        self.args.extend(args);
596        self
597    }
598
599    /// Build the dynamic transaction payload
600    #[allow(clippy::result_large_err)]
601    pub fn build(self) -> Result<impl subxt::tx::Payload> {
602        let pallet = self
603            .pallet
604            .ok_or_else(|| Error::Transaction("Pallet not set".to_string()))?;
605        let call = self
606            .call
607            .ok_or_else(|| Error::Transaction("Call not set".to_string()))?;
608
609        Ok(subxt::dynamic::tx(&pallet, &call, self.args))
610    }
611}
612
613#[cfg(test)]
614mod tests {
615    use super::*;
616
617    #[test]
618    fn test_fee_config() {
619        let config = FeeConfig::new()
620            .with_multiplier(1.5)
621            .with_max_fee(1_000_000)
622            .with_tip(100);
623
624        assert_eq!(config.multiplier, 1.5);
625        assert_eq!(config.max_fee, Some(1_000_000));
626        assert_eq!(config.tip, 100);
627    }
628
629    #[test]
630    fn test_retry_config() {
631        let config = RetryConfig::new()
632            .with_max_retries(5)
633            .with_initial_delay(Duration::from_secs(1));
634
635        assert_eq!(config.max_retries, 5);
636        assert_eq!(config.initial_delay, Duration::from_secs(1));
637    }
638
639    #[test]
640    fn test_extrinsic_builder() {
641        // We can't test the full build without a client, but we can test the builder pattern
642        let pallet = Some("Balances".to_string());
643        let call = Some("transfer".to_string());
644
645        assert!(pallet.is_some());
646        assert!(call.is_some());
647    }
648}