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::{Account, Env, error::SorobanHelperError};
36use stellar_xdr::curr::{
37    Memo, Operation, Preconditions, SequenceNumber, 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        account: &Account,
230    ) -> Result<Transaction, SorobanHelperError> {
231        let tx = self.build().await?;
232        let tx_envelope = 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        let mut tx = Transaction {
246            fee: updated_fee,
247            seq_num: tx.seq_num,
248            source_account: tx.source_account,
249            cond: tx.cond,
250            memo: tx.memo,
251            operations: tx.operations,
252            ext: tx.ext,
253        };
254
255        if let Ok(tx_data) = simulation.transaction_data().map_err(|e| {
256            SorobanHelperError::TransactionFailed(format!("Failed to get transaction data: {}", e))
257        }) {
258            tx.ext = TransactionExt::V1(tx_data);
259        }
260
261        Ok(tx)
262    }
263}
264
265#[cfg(test)]
266mod test {
267    use crate::{
268        Account, TransactionBuilder,
269        mock::{
270            mock_account_entry, mock_contract_id, mock_env, mock_signer1, mock_simulate_tx_response,
271        },
272        operation::Operations,
273        transaction::DEFAULT_TRANSACTION_FEES,
274    };
275    use stellar_xdr::curr::{Memo, Preconditions, TimeBounds, TimePoint};
276
277    #[tokio::test]
278    async fn test_build_transaction() {
279        let account = Account::single(mock_signer1());
280        let get_account_result = Ok(mock_account_entry(&account.account_id().0.to_string()));
281
282        let env = mock_env(Some(get_account_result), None, None);
283        let contract_id = mock_contract_id(account.clone(), &env);
284        let operation = Operations::invoke_contract(&contract_id, "test", vec![]).unwrap();
285        let transaction = TransactionBuilder::new(&account, &env)
286            .add_operation(operation)
287            .build()
288            .await
289            .unwrap();
290
291        assert!(transaction.source_account.account_id() == account.account_id());
292        assert!(transaction.operations.len() == 1);
293        assert!(transaction.fee == DEFAULT_TRANSACTION_FEES);
294    }
295
296    #[tokio::test]
297    async fn test_simulate_and_build() {
298        let simulation_fee = 42;
299
300        let account = Account::single(mock_signer1());
301        let get_account_result = Ok(mock_account_entry(&account.account_id().0.to_string()));
302        let simulate_tx_result = Ok(mock_simulate_tx_response(Some(simulation_fee)));
303
304        let env = mock_env(Some(get_account_result), Some(simulate_tx_result), None);
305        let contract_id = mock_contract_id(account.clone(), &env);
306        let operation = Operations::invoke_contract(&contract_id, "test", vec![]).unwrap();
307        let tx_builder = TransactionBuilder::new(&account, &env).add_operation(operation.clone());
308
309        let tx = tx_builder.simulate_and_build(&env, &account).await.unwrap();
310
311        assert!(tx.fee == 142); // DEFAULT_TRANSACTION_FEE + SIMULATION_FEE 
312        assert!(tx.operations.len() == 1);
313        assert!(tx.operations[0].body == operation.body);
314    }
315
316    #[tokio::test]
317    async fn test_set_env() {
318        let account = Account::single(mock_signer1());
319        let first_env = mock_env(None, None, None);
320        let second_env = mock_env(None, None, None);
321
322        let tx_builder = TransactionBuilder::new(&account, &first_env);
323        assert_eq!(
324            tx_builder.env.network_passphrase(),
325            first_env.network_passphrase()
326        );
327
328        let updated_builder = tx_builder.set_env(second_env.clone());
329        assert_eq!(
330            updated_builder.env.network_passphrase(),
331            second_env.network_passphrase()
332        );
333    }
334
335    #[tokio::test]
336    async fn test_set_memo() {
337        let account = Account::single(mock_signer1());
338        let env = mock_env(None, None, None);
339
340        let memo_text = "Test memo";
341        let memo = Memo::Text(memo_text.as_bytes().try_into().unwrap());
342
343        let tx_builder = TransactionBuilder::new(&account, &env);
344        assert!(matches!(tx_builder.memo, Memo::None));
345
346        let updated_builder = tx_builder.set_memo(memo.clone());
347        assert!(matches!(updated_builder.memo, Memo::Text(_)));
348
349        if let Memo::Text(text) = updated_builder.memo {
350            assert_eq!(text.as_slice(), memo_text.as_bytes());
351        }
352    }
353
354    #[tokio::test]
355    async fn test_set_preconditions() {
356        let account = Account::single(mock_signer1());
357        let env = mock_env(None, None, None);
358
359        let min_time = TimePoint(100);
360        let max_time = TimePoint(200);
361        let time_bounds = TimeBounds { min_time, max_time };
362        let preconditions = Preconditions::Time(time_bounds);
363
364        let tx_builder = TransactionBuilder::new(&account, &env);
365        assert!(matches!(tx_builder.preconditions, Preconditions::None));
366
367        let updated_builder = tx_builder.set_preconditions(preconditions);
368        assert!(matches!(
369            updated_builder.preconditions,
370            Preconditions::Time(_)
371        ));
372
373        if let Preconditions::Time(tb) = updated_builder.preconditions {
374            assert_eq!(tb.min_time.0, 100);
375            assert_eq!(tb.max_time.0, 200);
376        }
377    }
378
379    #[tokio::test]
380    async fn test_add_operation() {
381        let account = Account::single(mock_signer1());
382        let env = mock_env(None, None, None);
383        let contract_id = mock_contract_id(account.clone(), &env);
384
385        let operation1 = Operations::invoke_contract(&contract_id, "function1", vec![]).unwrap();
386        let operation2 = Operations::invoke_contract(&contract_id, "function2", vec![]).unwrap();
387
388        let tx_builder = TransactionBuilder::new(&account, &env);
389        assert_eq!(tx_builder.operations.len(), 0);
390
391        let builder_with_one_op = tx_builder.add_operation(operation1.clone());
392        assert_eq!(builder_with_one_op.operations.len(), 1);
393        assert_eq!(builder_with_one_op.operations[0].body, operation1.body);
394
395        let builder_with_two_ops = builder_with_one_op.add_operation(operation2.clone());
396        assert_eq!(builder_with_two_ops.operations.len(), 2);
397        assert_eq!(builder_with_two_ops.operations[0].body, operation1.body);
398        assert_eq!(builder_with_two_ops.operations[1].body, operation2.body);
399    }
400}