1use sha2::{Digest, Sha256};
2use stellar_xdr::curr::{
3 self as xdr, ExtensionPoint, Hash, InvokeHostFunctionOp, LedgerFootprint, Limits, Memo,
4 Operation, OperationBody, Preconditions, ReadXdr, RestoreFootprintOp,
5 SorobanAuthorizationEntry, SorobanAuthorizedFunction, SorobanResources, SorobanTransactionData,
6 Transaction, TransactionEnvelope, TransactionExt, TransactionSignaturePayload,
7 TransactionSignaturePayloadTaggedTransaction, TransactionV1Envelope, VecM, WriteXdr,
8};
9
10use soroban_rpc::{
11 Error, LogEvents, LogResources, ResourceConfig, RestorePreamble, SimulateTransactionResponse,
12};
13
14pub(crate) const DEFAULT_TRANSACTION_FEES: u32 = 100;
15
16pub async fn simulate_and_assemble_transaction(
17 client: &soroban_rpc::Client,
18 tx: &Transaction,
19 resource_config: Option<ResourceConfig>,
20) -> Result<Assembled, Error> {
21 let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
22 tx: tx.clone(),
23 signatures: VecM::default(),
24 });
25
26 tracing::trace!(
27 "Simulation transaction envelope: {}",
28 envelope.to_xdr_base64(Limits::none())?
29 );
30
31 let sim_res = client
32 .next_simulate_transaction_envelope(&envelope, None, resource_config)
33 .await?;
34 tracing::trace!("{sim_res:#?}");
35
36 if let Some(e) = &sim_res.error {
37 crate::log::event::all(&sim_res.events()?);
38 Err(Error::TransactionSimulationFailed(e.clone()))
39 } else {
40 Ok(Assembled::new(tx, sim_res)?)
41 }
42}
43
44pub struct Assembled {
45 pub(crate) txn: Transaction,
46 pub(crate) sim_res: SimulateTransactionResponse,
47}
48
49impl Assembled {
51 pub fn new(txn: &Transaction, sim_res: SimulateTransactionResponse) -> Result<Self, Error> {
63 let txn = assemble(txn, &sim_res)?;
64 Ok(Self { txn, sim_res })
65 }
66
67 pub fn hash(&self, network_passphrase: &str) -> Result<[u8; 32], xdr::Error> {
78 let signature_payload = TransactionSignaturePayload {
79 network_id: Hash(Sha256::digest(network_passphrase).into()),
80 tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(self.txn.clone()),
81 };
82 Ok(Sha256::digest(signature_payload.to_xdr(Limits::none())?).into())
83 }
84
85 pub fn restore_txn(&self) -> Result<Option<Transaction>, Error> {
89 if let Some(restore_preamble) = &self.sim_res.restore_preamble {
90 restore(self.transaction(), restore_preamble).map(Option::Some)
91 } else {
92 Ok(None)
93 }
94 }
95
96 #[must_use]
98 pub fn transaction(&self) -> &Transaction {
99 &self.txn
100 }
101
102 #[must_use]
104 pub fn sim_response(&self) -> &SimulateTransactionResponse {
105 &self.sim_res
106 }
107
108 #[must_use]
109 pub fn bump_seq_num(mut self) -> Self {
110 self.txn.seq_num.0 += 1;
111 self
112 }
113
114 #[must_use]
117 pub fn auth_entries(&self) -> VecM<SorobanAuthorizationEntry> {
118 self.txn
119 .operations
120 .first()
121 .and_then(|op| match op.body {
122 OperationBody::InvokeHostFunction(ref body) => (matches!(
123 body.auth.first().map(|x| &x.root_invocation.function),
124 Some(&SorobanAuthorizedFunction::ContractFn(_))
125 ))
126 .then_some(body.auth.clone()),
127 _ => None,
128 })
129 .unwrap_or_default()
130 }
131
132 pub fn log(
135 &self,
136 log_events: Option<LogEvents>,
137 log_resources: Option<LogResources>,
138 ) -> Result<(), Error> {
139 if let TransactionExt::V1(SorobanTransactionData {
140 resources: resources @ SorobanResources { footprint, .. },
141 ..
142 }) = &self.txn.ext
143 {
144 if let Some(log) = log_resources {
145 log(resources);
146 }
147
148 if let Some(log) = log_events {
149 log(footprint, &[self.auth_entries()], &self.sim_res.events()?);
150 }
151 }
152 Ok(())
153 }
154
155 #[must_use]
156 pub fn requires_auth(&self) -> bool {
157 requires_auth(&self.txn).is_some()
158 }
159
160 #[must_use]
161 pub fn is_view(&self) -> bool {
162 let TransactionExt::V1(SorobanTransactionData {
163 resources:
164 SorobanResources {
165 footprint: LedgerFootprint { read_write, .. },
166 ..
167 },
168 ..
169 }) = &self.txn.ext
170 else {
171 return false;
172 };
173 read_write.is_empty()
174 }
175
176 #[must_use]
177 pub fn set_max_instructions(mut self, instructions: u32) -> Self {
178 if let TransactionExt::V1(SorobanTransactionData {
179 resources:
180 SorobanResources {
181 instructions: ref mut i,
182 ..
183 },
184 ..
185 }) = &mut self.txn.ext
186 {
187 tracing::trace!("setting max instructions to {instructions} from {i}");
188 *i = instructions;
189 }
190 self
191 }
192}
193
194fn assemble(
199 raw: &Transaction,
200 simulation: &SimulateTransactionResponse,
201) -> Result<Transaction, 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 transaction_data = simulation.transaction_data()?;
215
216 let mut op = tx.operations[0].clone();
217 if let OperationBody::InvokeHostFunction(ref mut body) = &mut op.body {
218 if body.auth.is_empty() {
219 if simulation.results.len() != 1 {
220 return Err(Error::UnexpectedSimulateTransactionResultSize {
221 length: simulation.results.len(),
222 });
223 }
224
225 let auths = simulation
226 .results
227 .iter()
228 .map(|r| {
229 VecM::try_from(
230 r.auth
231 .iter()
232 .map(|v| SorobanAuthorizationEntry::from_xdr_base64(v, Limits::none()))
233 .collect::<Result<Vec<_>, _>>()?,
234 )
235 })
236 .collect::<Result<Vec<_>, _>>()?;
237 if !auths.is_empty() {
238 body.auth = auths[0].clone();
239 }
240 }
241 }
242
243 let classic_tx_fee: u64 = DEFAULT_TRANSACTION_FEES.into();
245
246 tx.fee = tx.fee.max(
248 u32::try_from(classic_tx_fee + simulation.min_resource_fee)
249 .map_err(|_| Error::LargeFee(simulation.min_resource_fee + classic_tx_fee))?,
250 );
251
252 tx.operations = vec![op].try_into()?;
253 tx.ext = TransactionExt::V1(transaction_data);
254 Ok(tx)
255}
256
257fn requires_auth(txn: &Transaction) -> Option<xdr::Operation> {
258 let [op @ Operation {
259 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp { auth, .. }),
260 ..
261 }] = txn.operations.as_slice()
262 else {
263 return None;
264 };
265 matches!(
266 auth.first().map(|x| &x.root_invocation.function),
267 Some(&SorobanAuthorizedFunction::ContractFn(_))
268 )
269 .then(move || op.clone())
270}
271
272fn restore(parent: &Transaction, restore: &RestorePreamble) -> Result<Transaction, Error> {
273 let transaction_data =
274 SorobanTransactionData::from_xdr_base64(&restore.transaction_data, Limits::none())?;
275 let fee = u32::try_from(restore.min_resource_fee)
276 .map_err(|_| Error::LargeFee(restore.min_resource_fee))?;
277 Ok(Transaction {
278 source_account: parent.source_account.clone(),
279 fee: parent
280 .fee
281 .checked_add(fee)
282 .ok_or(Error::LargeFee(restore.min_resource_fee))?,
283 seq_num: parent.seq_num.clone(),
284 cond: Preconditions::None,
285 memo: Memo::None,
286 operations: vec![Operation {
287 source_account: None,
288 body: OperationBody::RestoreFootprint(RestoreFootprintOp {
289 ext: ExtensionPoint::V0,
290 }),
291 }]
292 .try_into()?,
293 ext: TransactionExt::V1(transaction_data),
294 })
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300
301 use soroban_rpc::SimulateHostFunctionResultRaw;
302 use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
303 use stellar_xdr::curr::{
304 AccountId, ChangeTrustAsset, ChangeTrustOp, Hash, HostFunction, InvokeContractArgs,
305 InvokeHostFunctionOp, LedgerFootprint, Memo, MuxedAccount, Operation, Preconditions,
306 PublicKey, ScAddress, ScSymbol, ScVal, SequenceNumber, SorobanAuthorizedFunction,
307 SorobanAuthorizedInvocation, SorobanResources, SorobanTransactionData, Uint256, WriteXdr,
308 };
309
310 const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
311
312 fn transaction_data() -> SorobanTransactionData {
313 SorobanTransactionData {
314 resources: SorobanResources {
315 footprint: LedgerFootprint {
316 read_only: VecM::default(),
317 read_write: VecM::default(),
318 },
319 instructions: 0,
320 disk_read_bytes: 5,
321 write_bytes: 0,
322 },
323 resource_fee: 0,
324 ext: xdr::SorobanTransactionDataExt::V0,
325 }
326 }
327
328 fn simulation_response() -> SimulateTransactionResponse {
329 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
330 let fn_auth = &SorobanAuthorizationEntry {
331 credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
332 address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
333 source_bytes,
334 )))),
335 nonce: 0,
336 signature_expiration_ledger: 0,
337 signature: ScVal::Void,
338 }),
339 root_invocation: SorobanAuthorizedInvocation {
340 function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
341 contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(
342 [0; 32],
343 ))),
344 function_name: ScSymbol("fn".try_into().unwrap()),
345 args: VecM::default(),
346 }),
347 sub_invocations: VecM::default(),
348 },
349 };
350
351 SimulateTransactionResponse {
352 min_resource_fee: 115,
353 latest_ledger: 3,
354 results: vec![SimulateHostFunctionResultRaw {
355 auth: vec![fn_auth.to_xdr_base64(Limits::none()).unwrap()],
356 xdr: ScVal::U32(0).to_xdr_base64(Limits::none()).unwrap(),
357 }],
358 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
359 ..Default::default()
360 }
361 }
362
363 fn single_contract_fn_transaction() -> Transaction {
364 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
365 Transaction {
366 source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
367 fee: 100,
368 seq_num: SequenceNumber(0),
369 cond: Preconditions::None,
370 memo: Memo::None,
371 operations: vec![Operation {
372 source_account: None,
373 body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
374 host_function: HostFunction::InvokeContract(InvokeContractArgs {
375 contract_address: ScAddress::Contract(stellar_xdr::curr::ContractId(Hash(
376 [0x0; 32],
377 ))),
378 function_name: ScSymbol::default(),
379 args: VecM::default(),
380 }),
381 auth: VecM::default(),
382 }),
383 }]
384 .try_into()
385 .unwrap(),
386 ext: TransactionExt::V0,
387 }
388 }
389
390 #[test]
391 fn test_assemble_transaction_updates_tx_data_from_simulation_response() {
392 let sim = simulation_response();
393 let txn = single_contract_fn_transaction();
394 let Ok(result) = assemble(&txn, &sim) else {
395 panic!("assemble failed");
396 };
397
398 assert_eq!(215, result.fee);
401
402 assert_eq!(TransactionExt::V1(transaction_data()), result.ext);
404 }
405
406 #[test]
407 fn test_assemble_transaction_adds_the_auth_to_the_host_function() {
408 let sim = simulation_response();
409 let txn = single_contract_fn_transaction();
410 let Ok(result) = assemble(&txn, &sim) else {
411 panic!("assemble failed");
412 };
413
414 assert_eq!(1, result.operations.len());
415 let OperationBody::InvokeHostFunction(ref op) = result.operations[0].body else {
416 panic!("unexpected operation type: {:#?}", result.operations[0]);
417 };
418
419 assert_eq!(1, op.auth.len());
420 let auth = &op.auth[0];
421
422 let xdr::SorobanAuthorizedFunction::ContractFn(xdr::InvokeContractArgs {
423 ref function_name,
424 ..
425 }) = auth.root_invocation.function
426 else {
427 panic!("unexpected function type");
428 };
429 assert_eq!("fn".to_string(), format!("{}", function_name.0));
430
431 let xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
432 address:
433 xdr::ScAddress::Account(xdr::AccountId(xdr::PublicKey::PublicKeyTypeEd25519(address))),
434 ..
435 }) = &auth.credentials
436 else {
437 panic!("unexpected credentials type");
438 };
439 assert_eq!(
440 SOURCE.to_string(),
441 stellar_strkey::ed25519::PublicKey(address.0).to_string()
442 );
443 }
444
445 #[test]
446 fn test_assemble_transaction_errors_for_non_invokehostfn_ops() {
447 let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
448 let txn = Transaction {
449 source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
450 fee: 100,
451 seq_num: SequenceNumber(0),
452 cond: Preconditions::None,
453 memo: Memo::None,
454 operations: vec![Operation {
455 source_account: None,
456 body: OperationBody::ChangeTrust(ChangeTrustOp {
457 line: ChangeTrustAsset::Native,
458 limit: 0,
459 }),
460 }]
461 .try_into()
462 .unwrap(),
463 ext: TransactionExt::V0,
464 };
465
466 let result = assemble(
467 &txn,
468 &SimulateTransactionResponse {
469 min_resource_fee: 115,
470 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
471 latest_ledger: 3,
472 ..Default::default()
473 },
474 );
475
476 match result {
477 Ok(_) => {}
478 Err(e) => panic!("expected assembled operation, got: {e:#?}"),
479 }
480 }
481
482 #[test]
483 fn test_assemble_transaction_errors_for_errors_for_mismatched_simulation() {
484 let txn = single_contract_fn_transaction();
485
486 let result = assemble(
487 &txn,
488 &SimulateTransactionResponse {
489 min_resource_fee: 115,
490 transaction_data: transaction_data().to_xdr_base64(Limits::none()).unwrap(),
491 latest_ledger: 3,
492 ..Default::default()
493 },
494 );
495
496 match result {
497 Err(Error::UnexpectedSimulateTransactionResultSize { length }) => {
498 assert_eq!(0, length);
499 }
500 r => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {r:#?}"),
501 }
502 }
503
504 #[test]
505 fn test_assemble_transaction_overflow_behavior() {
506 let txn = single_contract_fn_transaction();
515 let mut response = simulation_response();
516
517 assert_eq!(txn.fee, 100, "modified txn.fee: update the math below");
519
520 response.min_resource_fee = (u32::MAX - 100).into();
522
523 match assemble(&txn, &response) {
524 Ok(asstxn) => {
525 let expected = u32::MAX;
526 assert_eq!(asstxn.fee, expected);
527 }
528 r => panic!("expected success, got: {r:#?}"),
529 }
530
531 response.min_resource_fee = (u32::MAX - 99).into();
533
534 match assemble(&txn, &response) {
535 Err(Error::LargeFee(fee)) => {
536 let expected = u64::from(u32::MAX) + 1;
537 assert_eq!(expected, fee, "expected {expected} != {fee} actual");
538 }
539 r => panic!("expected LargeFee error, got: {r:#?}"),
540 }
541 }
542}