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