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