1use sha2::{Digest, Sha256};
2use stellar_xdr::curr::{
3 self as xdr, Hash, InvokeHostFunctionOp, LedgerFootprint, Limits, Operation, OperationBody,
4 ReadXdr, SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanResources,
5 SorobanTransactionData, Transaction, TransactionEnvelope, TransactionExt,
6 TransactionSignaturePayload, TransactionSignaturePayloadTaggedTransaction,
7 TransactionV1Envelope, VecM, WriteXdr,
8};
9
10use soroban_rpc::{Error, LogEvents, LogResources, ResourceConfig, SimulateTransactionResponse};
11
12pub async fn simulate_and_assemble_transaction(
13 client: &soroban_rpc::Client,
14 tx: &Transaction,
15 resource_config: Option<ResourceConfig>,
16 resource_fee: Option<i64>,
17) -> Result<Assembled, Error> {
18 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
19 tx: tx.clone(),
20 signatures: VecM::default(),
21 });
22
23 tracing::trace!(
24 "Simulation transaction envelope: {}",
25 envelope.to_xdr_base64(Limits::none())?
26 );
27
28 let sim_res = client
29 .next_simulate_transaction_envelope(&envelope, None, resource_config)
30 .await?;
31 tracing::trace!("{sim_res:#?}");
32
33 if let Some(e) = &sim_res.error {
34 crate::log::event::all(&sim_res.events()?);
35 Err(Error::TransactionSimulationFailed(e.clone()))
36 } else {
37 Ok(Assembled::new(tx, sim_res, resource_fee)?)
38 }
39}
40
41pub struct Assembled {
42 pub(crate) txn: Transaction,
43 pub(crate) sim_res: SimulateTransactionResponse,
44 pub(crate) fee_bump_fee: Option<i64>,
45}
46
47impl Assembled {
49 pub fn new(
62 txn: &Transaction,
63 sim_res: SimulateTransactionResponse,
64 resource_fee: Option<i64>,
65 ) -> Result<Self, Error> {
66 assemble(txn, sim_res, resource_fee)
67 }
68
69 pub fn hash(&self, network_passphrase: &str) -> Result<[u8; 32], xdr::Error> {
80 let signature_payload = TransactionSignaturePayload {
81 network_id: Hash(Sha256::digest(network_passphrase).into()),
82 tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(self.txn.clone()),
83 };
84 Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into())
85 }
86
87 #[must_use]
89 pub fn transaction(&self) -> &Transaction {
90 &self.txn
91 }
92
93 #[must_use]
95 pub fn sim_response(&self) -> &SimulateTransactionResponse {
96 &self.sim_res
97 }
98
99 #[must_use]
100 pub fn fee_bump_fee(&self) -> Option<i64> {
101 self.fee_bump_fee
102 }
103
104 #[must_use]
105 pub fn bump_seq_num(mut self) -> Self {
106 self.txn.seq_num.0 += 1;
107 self
108 }
109
110 #[must_use]
113 pub fn auth_entries(&self) -> VecM<SorobanAuthorizationEntry> {
114 self.txn
115 .operations
116 .first()
117 .and_then(|op| match op.body {
118 OperationBody::InvokeHostFunction(ref body) => (matches!(
119 body.auth.first().map(|x| &x.root_invocation.function),
120 Some(&SorobanAuthorizedFunction::ContractFn(_))
121 ))
122 .then_some(body.auth.clone()),
123 _ => None,
124 })
125 .unwrap_or_default()
126 }
127
128 pub fn log(
131 &self,
132 log_events: Option<LogEvents>,
133 log_resources: Option<LogResources>,
134 ) -> Result<(), Error> {
135 if let TransactionExt::V1(SorobanTransactionData {
136 resources: resources @ SorobanResources { footprint, .. },
137 ..
138 }) = &self.txn.ext
139 {
140 if let Some(log) = log_resources {
141 log(resources);
142 }
143
144 if let Some(log) = log_events {
145 log(footprint, &[self.auth_entries()], &self.sim_res.events()?);
146 }
147 }
148 Ok(())
149 }
150
151 #[must_use]
152 pub fn requires_auth(&self) -> bool {
153 requires_auth(&self.txn).is_some()
154 }
155
156 #[must_use]
157 pub fn requires_fee_bump(&self) -> bool {
158 self.fee_bump_fee.is_some()
159 }
160
161 #[must_use]
162 pub fn is_view(&self) -> bool {
163 let TransactionExt::V1(SorobanTransactionData {
164 resources:
165 SorobanResources {
166 footprint: LedgerFootprint { read_write, .. },
167 ..
168 },
169 ..
170 }) = &self.txn.ext
171 else {
172 return false;
173 };
174 read_write.is_empty()
175 }
176
177 #[must_use]
179 pub fn set_max_instructions(mut self, instructions: u32) -> Self {
180 if let TransactionExt::V1(SorobanTransactionData {
181 resources:
182 SorobanResources {
183 instructions: ref mut i,
184 ..
185 },
186 ..
187 }) = &mut self.txn.ext
188 {
189 tracing::trace!("setting max instructions to {instructions} from {i}");
190 *i = instructions;
191 }
192 self
193 }
194}
195
196fn assemble(
201 raw: &Transaction,
202 simulation: SimulateTransactionResponse,
203 resource_fee: Option<i64>,
204) -> Result<Assembled, Error> {
205 let mut tx = raw.clone();
206
207 if tx.operations.len() != 1 {
212 return Err(Error::UnexpectedOperationCount {
213 count: tx.operations.len(),
214 });
215 }
216
217 let mut transaction_data = simulation.transaction_data()?;
218 let min_resource_fee = match resource_fee {
219 Some(rf) => {
220 tracing::trace!(
221 "overriding resource fee to {rf} (simulation suggested {})",
222 simulation.min_resource_fee
223 );
224 transaction_data.resource_fee = rf;
225 u64::try_from(rf).map_err(|_| {
229 Error::TransactionSubmissionFailed(String::from(
230 "TxMalformed - negative resource fee",
231 ))
232 })?
233 }
234 None => simulation.min_resource_fee,
236 };
237
238 let mut op = tx.operations[0].clone();
239 if let OperationBody::InvokeHostFunction(ref mut body) = &mut op.body {
240 if body.auth.is_empty() {
241 if simulation.results.len() != 1 {
242 return Err(Error::UnexpectedSimulateTransactionResultSize {
243 length: simulation.results.len(),
244 });
245 }
246
247 let auths = simulation
248 .results
249 .iter()
250 .map(|r| {
251 VecM::try_from(
252 r.auth
253 .iter()
254 .map(|v| SorobanAuthorizationEntry::from_xdr_base64(v, Limits::none()))
255 .collect::<Result<Vec<_>, _>>()?,
256 )
257 })
258 .collect::<Result<Vec<_>, _>>()?;
259 if !auths.is_empty() {
260 body.auth = auths[0].clone();
261 }
262 }
263 }
264
265 let total_fee: u64 = u64::from(raw.fee) + min_resource_fee;
268 let mut fee_bump_fee: Option<i64> = None;
269 if let Ok(tx_fee) = u32::try_from(total_fee) {
270 tx.fee = tx_fee;
271 } else {
272 tx.fee = 0;
276 let fee_bump_fee_u64 = total_fee + u64::from(raw.fee);
277 fee_bump_fee =
278 Some(i64::try_from(fee_bump_fee_u64).map_err(|_| Error::LargeFee(fee_bump_fee_u64))?);
279 }
280
281 tx.operations = vec![op].try_into()?;
282 tx.ext = TransactionExt::V1(transaction_data);
283 Ok(Assembled {
284 txn: tx,
285 sim_res: simulation,
286 fee_bump_fee,
287 })
288}
289
290fn requires_auth(txn: &Transaction) -> Option<xdr::Operation> {
291 let [op @ Operation {
292 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }),
293 ..
294 }] = txn.operations.as_slice()
295 else {
296 return None;
297 };
298 matches!(
299 auth.first().map(|x| &x.root_invocation.function),
300 Some(&SorobanAuthorizedFunction::ContractFn(_))
301 )
302 .then(move || op.clone())
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 use soroban_rpc::SimulateHostFunctionResultRaw;
310 use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
311 use stellar_xdr::curr::{
312 AccountId, ChangeTrustAsset, ChangeTrustOp, Hash, HostFunction, InvokeContractArgs,
313 InvokeHostFunctionOp, LedgerFootprint, Memo, MuxedAccount, Operation, Preconditions,
314 PublicKey, ScAddress, ScSymbol, ScVal, SequenceNumber, SorobanAuthorizedFunction,
315 SorobanAuthorizedInvocation, SorobanResources, SorobanTransactionData, Uint256, WriteXdr,
316 };
317
318 const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
319
320 fn transaction_data() -> SorobanTransactionData {
321 SorobanTransactionData {
322 resources: SorobanResources {
323 footprint: LedgerFootprint {
324 read_only: VecM::default(),
325 read_write: VecM::default(),
326 },
327 instructions: 0,
328 disk_read_bytes: 5,
329 write_bytes: 0,
330 },
331 resource_fee: 0,
332 ext: xdr::SorobanTransactionDataExt::V0,
333 }
334 }
335
336 fn simulation_response() -> SimulateTransactionResponse {
337 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
338 let fn_auth = &SorobanAuthorizationEntry {
339 credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
340 address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
341 source_bytes,
342 )))),
343 nonce: 0,
344 signature_expiration_ledger: 0,
345 signature: ScVal::Void,
346 }),
347 root_invocation: SorobanAuthorizedInvocation {
348 function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
349 contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(
350 [0; 32],
351 ))),
352 function_name: ScSymbol("fn".try_into().unwrap()),
353 args: VecM::default(),
354 }),
355 sub_invocations: VecM::default(),
356 },
357 };
358
359 SimulateTransactionResponse {
360 min_resource_fee: 115,
361 latest_ledger: 3,
362 results: vec![SimulateHostFunctionResultRaw {
363 auth: vec![fn_auth.to_xdr_base64(Limits::none()).unwrap()],
364 xdr: ScVal::U32(0).to_xdr_base64(Limits::none()).unwrap(),
365 }],
366 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
367 ..Default::default()
368 }
369 }
370
371 fn single_contract_fn_transaction() -> Transaction {
372 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
373 Transaction {
374 source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
375 fee: 100,
376 seq_num: SequenceNumber(0),
377 cond: Preconditions::None,
378 memo: Memo::None,
379 operations: vec![Operation {
380 source_account: None,
381 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
382 host_function: HostFunction::InvokeContract(InvokeContractArgs {
383 contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(
384 [0x0; 32],
385 ))),
386 function_name: ScSymbol::default(),
387 args: VecM::default(),
388 }),
389 auth: VecM::default(),
390 }),
391 }]
392 .try_into()
393 .unwrap(),
394 ext: TransactionExt::V0,
395 }
396 }
397
398 #[test]
399 fn test_assemble_transaction_updates_tx_data_from_simulation_response() {
400 let sim = simulation_response();
401 let txn = single_contract_fn_transaction();
402 let Ok(result) = assemble(&txn, sim, None) else {
403 panic!("assemble failed");
404 };
405
406 assert_eq!(215, result.txn.fee);
409
410 assert_eq!(TransactionExt::V1(transaction_data()), result.txn.ext);
412 }
413
414 #[test]
415 fn test_assemble_transaction_adds_the_auth_to_the_host_function() {
416 let sim = simulation_response();
417 let txn = single_contract_fn_transaction();
418 let Ok(result) = assemble(&txn, sim, None) else {
419 panic!("assemble failed");
420 };
421
422 assert_eq!(1, result.txn.operations.len());
423 let OperationBody::InvokeHostFunction(ref op) = result.txn.operations[0].body else {
424 panic!("unexpected operation type: {:#?}", result.txn.operations[0]);
425 };
426
427 assert_eq!(1, op.auth.len());
428 let auth = &op.auth[0];
429
430 let xdr::SorobanAuthorizedFunction::ContractFn(xdr::InvokeContractArgs {
431 ref function_name,
432 ..
433 }) = auth.root_invocation.function
434 else {
435 panic!("unexpected function type");
436 };
437 assert_eq!("fn".to_string(), format!("{}", function_name.0));
438
439 let xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
440 address:
441 xdr::ScAddress::Account(xdr::AccountId(xdr::PublicKey::PublicKeyTypeEd25519(address))),
442 ..
443 }) = &auth.credentials
444 else {
445 panic!("unexpected credentials type");
446 };
447 assert_eq!(
448 SOURCE.to_string(),
449 stellar_strkey::ed25519::PublicKey(address.0).to_string()
450 );
451 }
452
453 #[test]
454 fn test_assemble_transaction_errors_for_non_invokehostfn_ops() {
455 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
456 let txn = Transaction {
457 source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
458 fee: 100,
459 seq_num: SequenceNumber(0),
460 cond: Preconditions::None,
461 memo: Memo::None,
462 operations: vec![Operation {
463 source_account: None,
464 body: OperationBody::ChangeTrust(ChangeTrustOp {
465 line: ChangeTrustAsset::Native,
466 limit: 0,
467 }),
468 }]
469 .try_into()
470 .unwrap(),
471 ext: TransactionExt::V0,
472 };
473
474 let result = assemble(
475 &txn,
476 SimulateTransactionResponse {
477 min_resource_fee: 115,
478 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
479 latest_ledger: 3,
480 ..Default::default()
481 },
482 None,
483 );
484
485 match result {
486 Ok(_) => {}
487 Err(e) => panic!("expected assembled operation, got: {e:#?}"),
488 }
489 }
490
491 #[test]
492 fn test_assemble_transaction_errors_for_errors_for_mismatched_simulation() {
493 let txn = single_contract_fn_transaction();
494
495 let result = assemble(
496 &txn,
497 SimulateTransactionResponse {
498 min_resource_fee: 115,
499 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
500 latest_ledger: 3,
501 ..Default::default()
502 },
503 None,
504 );
505
506 match result {
507 Err(Error::UnexpectedSimulateTransactionResultSize { length }) => {
508 assert_eq!(0, length);
509 }
510 Ok(_) => panic!("expected error, got success"),
511 Err(e) => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {e:#?}"),
512 }
513 }
514
515 #[test]
516 fn test_assemble_transaction_calcs_fee() {
517 let mut sim = simulation_response();
518 sim.min_resource_fee = 12345;
519 let mut txn = single_contract_fn_transaction();
520 txn.fee = 10000;
521 let Ok(result) = assemble(&txn, sim, None) else {
522 panic!("assemble failed");
523 };
524
525 assert_eq!(12345 + 10000, result.txn.fee);
526 assert_eq!(None, result.fee_bump_fee);
527
528 let expected_tx_data = transaction_data();
530 assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext);
531 }
532
533 #[test]
534 fn test_assemble_transaction_fee_bump_fee_behavior() {
535 let mut txn = single_contract_fn_transaction();
544 let mut response = simulation_response();
545
546 let inclusion_fee: u32 = 500;
547 let inclusion_fee_i64: i64 = i64::from(inclusion_fee);
548 txn.fee = inclusion_fee;
549
550 response.min_resource_fee = (u32::MAX - inclusion_fee).into();
552
553 match assemble(&txn, response.clone(), None) {
554 Ok(assembled) => {
555 assert_eq!(assembled.txn.fee, u32::MAX);
556 assert_eq!(assembled.fee_bump_fee, None);
557 }
558 Err(e) => panic!("expected success, got error: {e:#?}"),
559 }
560
561 response.min_resource_fee = (u32::MAX - inclusion_fee + 1).into();
563 match assemble(&txn, response.clone(), None) {
564 Ok(assembled) => {
565 assert_eq!(assembled.txn.fee, 0);
566 assert_eq!(
567 assembled.fee_bump_fee,
568 Some(i64::try_from(response.min_resource_fee).unwrap() + inclusion_fee_i64 * 2)
569 );
570 }
571 Err(e) => panic!("expected success, got error: {e:#?}"),
572 }
573
574 response.min_resource_fee = u64::try_from(i64::MAX - (2 * inclusion_fee_i64) + 1).unwrap();
576 match assemble(&txn, response, None) {
577 Err(Error::LargeFee(fee)) => {
578 let expected = i64::MAX as u64 + 1;
579 assert_eq!(expected, fee, "expected {expected} != {fee} actual");
580 }
581 Ok(_) => panic!("expected error, got success"),
582 Err(e) => panic!("expected LargeFee error, got different error: {e:#?}"),
583 }
584 }
585
586 #[test]
587 fn test_assemble_transaction_with_resource_fee() {
588 let sim = simulation_response();
589 let mut txn = single_contract_fn_transaction();
590 txn.fee = 500;
591 let resource_fee = 12345i64;
592 let Ok(result) = assemble(&txn, sim, Some(resource_fee)) else {
593 panic!("assemble failed");
594 };
595
596 assert_eq!(12345 + 500, result.txn.fee);
599 assert_eq!(None, result.fee_bump_fee);
600
601 let mut expected_tx_data = transaction_data();
603 expected_tx_data.resource_fee = resource_fee;
604 assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext);
605 }
606
607 #[test]
610 fn test_assemble_transaction_input_resource_fee_negative_errors() {
611 let mut sim = simulation_response();
612 sim.min_resource_fee = 12345;
613 let mut txn = single_contract_fn_transaction();
614 txn.fee = 500;
615 let resource_fee = -1;
616 let result = assemble(&txn, sim, Some(resource_fee));
617
618 assert!(result.is_err());
619 }
620
621 #[test]
622 fn test_assemble_transaction_with_resource_fee_fee_bump_behavior() {
623 let mut txn = single_contract_fn_transaction();
632 let response = simulation_response();
633
634 let inclusion_fee: u32 = 500;
635 let inclusion_fee_i64: i64 = i64::from(inclusion_fee);
636 txn.fee = inclusion_fee;
637
638 let resource_fee: i64 = (u32::MAX - inclusion_fee).into();
640 match assemble(&txn, response.clone(), Some(resource_fee)) {
641 Ok(assembled) => {
642 assert_eq!(assembled.txn.fee, u32::MAX);
643 assert_eq!(assembled.fee_bump_fee, None);
644 }
645 Err(e) => panic!("expected success, got error: {e:#?}"),
646 }
647
648 let resource_fee: i64 = (u32::MAX - inclusion_fee + 1).into();
650 match assemble(&txn, response.clone(), Some(resource_fee)) {
651 Ok(assembled) => {
652 assert_eq!(assembled.txn.fee, 0);
653 assert_eq!(
654 assembled.fee_bump_fee,
655 Some(resource_fee + inclusion_fee_i64 * 2)
656 );
657 }
658 Err(e) => panic!("expected success, got error: {e:#?}"),
659 }
660
661 let resource_fee: i64 = i64::MAX - (2 * inclusion_fee_i64) + 1;
663 match assemble(&txn, response, Some(resource_fee)) {
664 Err(Error::LargeFee(fee)) => {
665 let expected = i64::MAX as u64 + 1;
666 assert_eq!(expected, fee, "expected {expected} != {fee} actual");
667 }
668 Ok(_) => panic!("expected error, got success"),
669 Err(e) => panic!("expected LargeFee error, got: {e:#?}"),
670 }
671 }
672}