1use crate::{Account, Env, error::SorobanHelperError};
36use stellar_xdr::curr::{
37 Memo, Operation, Preconditions, SequenceNumber, Transaction, TransactionExt,
38};
39
40pub const DEFAULT_TRANSACTION_FEES: u32 = 100;
42
43#[derive(Clone)]
50pub struct TransactionBuilder {
51 pub fee: u32,
53 pub source_account: Account,
55 pub operations: Vec<Operation>,
57 pub memo: Memo,
59 pub preconditions: Preconditions,
61 pub env: Env,
63}
64
65impl TransactionBuilder {
66 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 pub fn set_env(mut self, env: Env) -> Self {
103 self.env = env;
104 self
105 }
106
107 pub fn add_operation(mut self, operation: Operation) -> Self {
119 self.operations.push(operation);
120 self
121 }
122
123 pub fn set_memo(mut self, memo: Memo) -> Self {
137 self.memo = memo;
138 self
139 }
140
141 pub fn set_preconditions(mut self, preconditions: Preconditions) -> Self {
155 self.preconditions = preconditions;
156 self
157 }
158
159 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 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); 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}