soroban_cli/
assembled.rs

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
42/// Represents an assembled transaction ready to be signed and submitted to the network.
43impl Assembled {
44    ///
45    /// Creates a new `Assembled` transaction.
46    ///
47    /// # Arguments
48    ///
49    /// * `txn` - The original transaction.
50    /// * `client` - The client used for simulation and submission.
51    ///
52    /// # Errors
53    ///
54    /// Returns an error if simulation fails or if assembling the transaction fails.
55    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    ///
61    /// Calculates the hash of the assembled transaction.
62    ///
63    /// # Arguments
64    ///
65    /// * `network_passphrase` - The network passphrase.
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if generating the hash fails.
70    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    ///  Create a transaction for restoring any data in the `restore_preamble` field of the `SimulateTransactionResponse`.
79    ///
80    /// # Errors
81    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    /// Returns a reference to the original transaction.
90    #[must_use]
91    pub fn transaction(&self) -> &Transaction {
92        &self.txn
93    }
94
95    /// Returns a reference to the simulation response.
96    #[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    ///
108    /// # Errors
109    #[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    ///
126    /// # Errors
127    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
187// Apply the result of a simulateTransaction onto a transaction envelope, preparing it for
188// submission to the network.
189///
190/// # Errors
191fn assemble(
192    raw: &Transaction,
193    simulation: &SimulateTransactionResponse,
194) -> Result<Transaction, Error> {
195    let mut tx = raw.clone();
196
197    // Right now simulate.results is one-result-per-function, and assumes there is only one
198    // operation in the txn, so we need to enforce that here. I (Paul) think that is a bug
199    // in soroban-rpc.simulateTransaction design, and we should fix it there.
200    // TODO: We should to better handling so non-soroban txns can be a passthrough here.
201    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    // Update transaction fees to meet the minimum resource fees.
237    let classic_tx_fee: u64 = DEFAULT_TRANSACTION_FEES.into();
238
239    // Choose larger of existing fee or inclusion + resource fee.
240    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        // validate it auto updated the tx fees from sim response fees
392        // since it was greater than tx.fee
393        assert_eq!(215, result.fee);
394
395        // validate it updated sorobantransactiondata block in the tx ext
396        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        //
500        // Test two separate cases:
501        //
502        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
503        //     fee does not overflow after adding the base inclusion fee (100).
504        //  2. Given a large resource fee that WILL exceed u32::MAX with the
505        //     base inclusion fee, ensure the overflow is caught with an error
506        //     rather than silently ignored.
507        let txn = single_contract_fn_transaction();
508        let mut response = simulation_response();
509
510        // sanity check so these can be adjusted if the above helper changes
511        assert_eq!(txn.fee, 100, "modified txn.fee: update the math below");
512
513        // 1: wiggle room math overflows but result fits
514        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        // 2: combo overflows, should throw
525        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}