1use crate::{error::SorobanHelperError, Account, Env};
36use stellar_xdr::curr::{
37 Memo, Operation, Preconditions, SequenceNumber, SorobanCredentials, 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 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); 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}