soroban_rs/
transaction.rs

1//! # Soroban Transaction Building
2//!
3//! This module provides functionality for creating and configuring Stellar transactions
4//! for use with Soroban smart contracts. It handles transaction construction, fee calculation,
5//! and simulation to ensure transactions are properly resourced.
6//!
7//! ## Features
8//!
9//! - Transaction building
10//! - Automatic sequence number handling
11//! - Fee calculation based on transaction simulation
12//! - Transaction optimization through simulation
13//!
14//! ## Example
15//!
16//! ```rust,no_run
17//! use soroban_rs::{Account, Env, EnvConfigs, TransactionBuilder};
18//! use stellar_xdr::curr::{Memo, Operation, Preconditions};
19//!
20//! async fn example(account: &mut Account, env: &Env, operation: Operation) {
21//!     // Create a transaction builder
22//!     let tx_builder = TransactionBuilder::new(account, env)
23//!         .add_operation(operation)
24//!         .set_memo(Memo::Text("Example transaction".try_into().unwrap()))
25//!         .set_preconditions(Preconditions::None);
26//!     
27//!     // Build a transaction with simulation to set proper fees
28//!     let tx = tx_builder.simulate_and_build(env, account).await.unwrap();
29//!     
30//!     // Sign and submit the transaction
31//!     let tx_envelope = account.sign_transaction(&tx, &env.network_id()).unwrap();
32//!     env.send_transaction(&tx_envelope).await.unwrap();
33//! }
34//! ```
35use crate::{error::SorobanHelperError, Account, Env};
36use stellar_xdr::curr::{
37    Memo, Operation, Preconditions, SequenceNumber, SorobanCredentials, Transaction, TransactionExt,
38};
39
40/// Default transaction fee in stroops (0.00001 XLM)
41pub const DEFAULT_TRANSACTION_FEES: u32 = 100;
42
43/// Builder for creating and configuring Stellar transactions.
44///
45/// TransactionBuilder provides an API for building Stellar transactions
46/// for Soroban operations. It handles sequence number retrieval, fee calculation,
47/// and transaction simulation to ensure transactions have the correct resources
48/// allocated for Soroban execution.
49#[derive(Clone)]
50pub struct TransactionBuilder {
51    /// Transaction fee in stroops
52    pub fee: u32,
53    /// Account that will be the source of the transaction
54    pub source_account: Account,
55    /// List of operations to include in the transaction
56    pub operations: Vec<Operation>,
57    /// Optional memo to attach to the transaction
58    pub memo: Memo,
59    /// Optional preconditions for transaction execution
60    pub preconditions: Preconditions,
61    /// Environment for network interaction
62    pub env: Env,
63}
64
65impl TransactionBuilder {
66    /// Creates a new transaction builder for the specified account and environment.
67    ///
68    /// The builder is initialized with default values:
69    /// - Default transaction fee
70    /// - Empty operations list
71    /// - No memo
72    /// - No preconditions
73    ///
74    /// # Parameters
75    ///
76    /// * `source_account` - The account that will be the source of the transaction
77    /// * `env` - The environment for network interaction
78    ///
79    /// # Returns
80    ///
81    /// A new TransactionBuilder instance
82    pub fn new(source_account: &Account, env: &Env) -> Self {
83        Self {
84            fee: DEFAULT_TRANSACTION_FEES,
85            source_account: source_account.clone(),
86            operations: Vec::new(),
87            memo: Memo::None,
88            preconditions: Preconditions::None,
89            env: env.clone(),
90        }
91    }
92
93    /// Sets the environment for the transaction builder.
94    ///
95    /// # Parameters
96    ///
97    /// * `env` - The new environment to use
98    ///
99    /// # Returns
100    ///
101    /// The updated TransactionBuilder
102    pub fn set_env(mut self, env: Env) -> Self {
103        self.env = env;
104        self
105    }
106
107    /// Adds an operation to the transaction.
108    ///
109    /// https://developers.stellar.org/docs/learn/fundamentals/transactions/operations-and-transactions#operations
110    ///
111    /// # Parameters
112    ///
113    /// * `operation` - The operation to add
114    ///
115    /// # Returns
116    ///
117    /// The updated TransactionBuilder
118    pub fn add_operation(mut self, operation: Operation) -> Self {
119        self.operations.push(operation);
120        self
121    }
122
123    /// Sets the memo for the transaction.
124    ///
125    /// Memos can be used to attach additional information to a transaction.
126    /// They are not used by the protocol but can be used by applications.
127    /// https://developers.stellar.org/docs/learn/encyclopedia/transactions-specialized/memos
128    ///
129    /// # Parameters
130    ///
131    /// * `memo` - The memo to set
132    ///
133    /// # Returns
134    ///
135    /// The updated TransactionBuilder
136    pub fn set_memo(mut self, memo: Memo) -> Self {
137        self.memo = memo;
138        self
139    }
140
141    /// Sets the preconditions for the transaction.
142    ///
143    /// Preconditions specify requirements that must be met for a transaction
144    /// to be valid, such as time bounds or ledger bounds.
145    /// https://developers.stellar.org/docs/learn/fundamentals/transactions/operations-and-transactions#preconditions
146    ///
147    /// # Parameters
148    ///
149    /// * `preconditions` - The preconditions to set
150    ///
151    /// # Returns
152    ///
153    /// The updated TransactionBuilder
154    pub fn set_preconditions(mut self, preconditions: Preconditions) -> Self {
155        self.preconditions = preconditions;
156        self
157    }
158
159    /// Builds a transaction without simulation.
160    ///
161    /// This method retrieves the source account's current sequence number
162    /// and constructs a transaction with the configured parameters.
163    ///
164    /// # Returns
165    ///
166    /// A transaction ready to be signed, or an error if the build fails
167    ///
168    /// # Errors
169    ///
170    /// Returns error if:
171    /// - Operations cannot be converted to XDR
172    /// - Sequence number cannot be retrieved
173    pub async fn build(self) -> Result<Transaction, SorobanHelperError> {
174        let operations = self.operations.try_into().map_err(|e| {
175            SorobanHelperError::XdrEncodingFailed(format!("Failed to convert operations: {}", e))
176        })?;
177
178        let seq_num = self
179            .source_account
180            .get_sequence(&self.env)
181            .await
182            .map_err(|e| {
183                SorobanHelperError::XdrEncodingFailed(format!(
184                    "Failed to get sequence number: {}",
185                    e
186                ))
187            })?;
188
189        Ok(Transaction {
190            fee: self.fee,
191            seq_num: SequenceNumber::from(seq_num.increment().value()),
192            source_account: self.source_account.account_id().into(),
193            cond: self.preconditions,
194            memo: self.memo,
195            operations,
196            ext: TransactionExt::V0,
197        })
198    }
199
200    /// Builds a transaction with simulation to determine proper fees and resources.
201    ///
202    /// This method:
203    /// 1. Builds a transaction with default fees
204    /// 2. Simulates the transaction to determine required resources
205    /// 3. Updates the transaction with the correct fees and resource data
206    ///
207    /// This is the recommended way to build Soroban transactions, as it ensures
208    /// they have sufficient fees and resources for execution.
209    ///
210    /// # Parameters
211    ///
212    /// * `env` - The environment for transaction simulation
213    /// * `account` - The account to use for signing the simulation transaction
214    ///
215    /// # Returns
216    ///
217    /// A transaction optimized for Soroban execution, or an error if the build fails
218    ///
219    /// # Errors
220    ///
221    /// Returns error if:
222    /// - Transaction building fails
223    /// - Transaction signing fails
224    /// - Simulation fails
225    /// - Fee calculation results in a value too large for u32
226    pub async fn simulate_and_build(
227        self,
228        env: &Env,
229        source_account: &Account,
230    ) -> Result<Transaction, SorobanHelperError> {
231        let tx = self.build().await?;
232        let tx_envelope = source_account.sign_transaction_unsafe(&tx, &env.network_id())?;
233        let simulation = env.simulate_transaction(&tx_envelope).await?;
234
235        let updated_fee = DEFAULT_TRANSACTION_FEES.max(
236            u32::try_from(
237                (tx.operations.len() as u64 * DEFAULT_TRANSACTION_FEES as u64)
238                    + simulation.min_resource_fee,
239            )
240            .map_err(|_| {
241                SorobanHelperError::InvalidArgument("Transaction fee too high".to_string())
242            })?,
243        );
244
245        if simulation.error.is_some() {
246            println!(
247                "[WARN] Transaction simulation failed with error: {:?}",
248                simulation.error
249            );
250        }
251
252        let sim_results = simulation.results().unwrap_or_default();
253        for result in &sim_results {
254            for auth in &result.auth {
255                if matches!(auth.credentials, SorobanCredentials::Address(_)) {
256                    return Err(SorobanHelperError::NotSupported(
257                        "Address authorization not yet supported".to_string(),
258                    ));
259                }
260            }
261        }
262
263        let mut tx = Transaction {
264            fee: updated_fee,
265            seq_num: tx.seq_num,
266            source_account: tx.source_account,
267            cond: tx.cond,
268            memo: tx.memo,
269            operations: tx.operations,
270            ext: tx.ext,
271        };
272
273        if let Ok(tx_data) = simulation.transaction_data().map_err(|e| {
274            SorobanHelperError::TransactionFailed(format!("Failed to get transaction data: {}", e))
275        }) {
276            tx.ext = TransactionExt::V1(tx_data);
277        }
278
279        Ok(tx)
280    }
281}
282
283#[cfg(test)]
284mod test {
285    use crate::{
286        mock::{
287            mock_account_entry, mock_contract_id, mock_env, mock_signer1, mock_simulate_tx_response,
288        },
289        operation::Operations,
290        transaction::DEFAULT_TRANSACTION_FEES,
291        Account, TransactionBuilder,
292    };
293    use stellar_xdr::curr::{Memo, Preconditions, TimeBounds, TimePoint};
294
295    #[tokio::test]
296    async fn test_build_transaction() {
297        let account = Account::single(mock_signer1());
298        let get_account_result = Ok(mock_account_entry(&account.account_id().0.to_string()));
299
300        let env = mock_env(Some(get_account_result), None, None);
301        let contract_id = mock_contract_id(account.clone(), &env);
302        let operation = Operations::invoke_contract(&contract_id, "test", vec![]).unwrap();
303        let transaction = TransactionBuilder::new(&account, &env)
304            .add_operation(operation)
305            .build()
306            .await
307            .unwrap();
308
309        assert!(transaction.source_account.account_id() == account.account_id());
310        assert!(transaction.operations.len() == 1);
311        assert!(transaction.fee == DEFAULT_TRANSACTION_FEES);
312    }
313
314    #[tokio::test]
315    async fn test_simulate_and_build() {
316        let simulation_fee = 42;
317
318        let account = Account::single(mock_signer1());
319        let get_account_result = Ok(mock_account_entry(&account.account_id().0.to_string()));
320        let simulate_tx_result = Ok(mock_simulate_tx_response(Some(simulation_fee)));
321
322        let env = mock_env(Some(get_account_result), Some(simulate_tx_result), None);
323        let contract_id = mock_contract_id(account.clone(), &env);
324        let operation = Operations::invoke_contract(&contract_id, "test", vec![]).unwrap();
325        let tx_builder = TransactionBuilder::new(&account, &env).add_operation(operation.clone());
326
327        let tx = tx_builder.simulate_and_build(&env, &account).await.unwrap();
328
329        assert!(tx.fee == 142); // DEFAULT_TRANSACTION_FEE + SIMULATION_FEE
330        assert!(tx.operations.len() == 1);
331        assert!(tx.operations[0].body == operation.body);
332    }
333
334    #[tokio::test]
335    async fn test_set_env() {
336        let account = Account::single(mock_signer1());
337        let first_env = mock_env(None, None, None);
338        let second_env = mock_env(None, None, None);
339
340        let tx_builder = TransactionBuilder::new(&account, &first_env);
341        assert_eq!(
342            tx_builder.env.network_passphrase(),
343            first_env.network_passphrase()
344        );
345
346        let updated_builder = tx_builder.set_env(second_env.clone());
347        assert_eq!(
348            updated_builder.env.network_passphrase(),
349            second_env.network_passphrase()
350        );
351    }
352
353    #[tokio::test]
354    async fn test_set_memo() {
355        let account = Account::single(mock_signer1());
356        let env = mock_env(None, None, None);
357
358        let memo_text = "Test memo";
359        let memo = Memo::Text(memo_text.as_bytes().try_into().unwrap());
360
361        let tx_builder = TransactionBuilder::new(&account, &env);
362        assert!(matches!(tx_builder.memo, Memo::None));
363
364        let updated_builder = tx_builder.set_memo(memo.clone());
365        assert!(matches!(updated_builder.memo, Memo::Text(_)));
366
367        if let Memo::Text(text) = updated_builder.memo {
368            assert_eq!(text.as_slice(), memo_text.as_bytes());
369        }
370    }
371
372    #[tokio::test]
373    async fn test_set_preconditions() {
374        let account = Account::single(mock_signer1());
375        let env = mock_env(None, None, None);
376
377        let min_time = TimePoint(100);
378        let max_time = TimePoint(200);
379        let time_bounds = TimeBounds { min_time, max_time };
380        let preconditions = Preconditions::Time(time_bounds);
381
382        let tx_builder = TransactionBuilder::new(&account, &env);
383        assert!(matches!(tx_builder.preconditions, Preconditions::None));
384
385        let updated_builder = tx_builder.set_preconditions(preconditions);
386        assert!(matches!(
387            updated_builder.preconditions,
388            Preconditions::Time(_)
389        ));
390
391        if let Preconditions::Time(tb) = updated_builder.preconditions {
392            assert_eq!(tb.min_time.0, 100);
393            assert_eq!(tb.max_time.0, 200);
394        }
395    }
396
397    #[tokio::test]
398    async fn test_add_operation() {
399        let account = Account::single(mock_signer1());
400        let env = mock_env(None, None, None);
401        let contract_id = mock_contract_id(account.clone(), &env);
402
403        let operation1 = Operations::invoke_contract(&contract_id, "function1", vec![]).unwrap();
404        let operation2 = Operations::invoke_contract(&contract_id, "function2", vec![]).unwrap();
405
406        let tx_builder = TransactionBuilder::new(&account, &env);
407        assert_eq!(tx_builder.operations.len(), 0);
408
409        let builder_with_one_op = tx_builder.add_operation(operation1.clone());
410        assert_eq!(builder_with_one_op.operations.len(), 1);
411        assert_eq!(builder_with_one_op.operations[0].body, operation1.body);
412
413        let builder_with_two_ops = builder_with_one_op.add_operation(operation2.clone());
414        assert_eq!(builder_with_two_ops.operations.len(), 2);
415        assert_eq!(builder_with_two_ops.operations[0].body, operation1.body);
416        assert_eq!(builder_with_two_ops.operations[1].body, operation2.body);
417    }
418}