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