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::{
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
49/// Represents an assembled transaction ready to be signed and submitted to the network.
50impl Assembled {
51    ///
52    /// Creates a new `Assembled` transaction.
53    ///
54    /// # Arguments
55    ///
56    /// * `txn` - The original transaction.
57    /// * `client` - The client used for simulation and submission.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if simulation fails or if assembling the transaction fails.
62    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    ///
68    /// Calculates the hash of the assembled transaction.
69    ///
70    /// # Arguments
71    ///
72    /// * `network_passphrase` - The network passphrase.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if generating the hash fails.
77    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    ///  Create a transaction for restoring any data in the `restore_preamble` field of the `SimulateTransactionResponse`.
86    ///
87    /// # Errors
88    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    /// Returns a reference to the original transaction.
97    #[must_use]
98    pub fn transaction(&self) -> &Transaction {
99        &self.txn
100    }
101
102    /// Returns a reference to the simulation response.
103    #[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    ///
115    /// # Errors
116    #[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    ///
133    /// # Errors
134    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
194// Apply the result of a simulateTransaction onto a transaction envelope, preparing it for
195// submission to the network.
196///
197/// # Errors
198fn assemble(
199    raw: &Transaction,
200    simulation: &SimulateTransactionResponse,
201) -> Result<Transaction, Error> {
202    let mut tx = raw.clone();
203
204    // Right now simulate.results is one-result-per-function, and assumes there is only one
205    // operation in the txn, so we need to enforce that here. I (Paul) think that is a bug
206    // in soroban-rpc.simulateTransaction design, and we should fix it there.
207    // TODO: We should to better handling so non-soroban txns can be a passthrough here.
208    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    // Update transaction fees to meet the minimum resource fees.
244    let classic_tx_fee: u64 = DEFAULT_TRANSACTION_FEES.into();
245
246    // Choose larger of existing fee or inclusion + resource fee.
247    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        // validate it auto updated the tx fees from sim response fees
399        // since it was greater than tx.fee
400        assert_eq!(215, result.fee);
401
402        // validate it updated sorobantransactiondata block in the tx ext
403        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        //
507        // Test two separate cases:
508        //
509        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
510        //     fee does not overflow after adding the base inclusion fee (100).
511        //  2. Given a large resource fee that WILL exceed u32::MAX with the
512        //     base inclusion fee, ensure the overflow is caught with an error
513        //     rather than silently ignored.
514        let txn = single_contract_fn_transaction();
515        let mut response = simulation_response();
516
517        // sanity check so these can be adjusted if the above helper changes
518        assert_eq!(txn.fee, 100, "modified txn.fee: update the math below");
519
520        // 1: wiggle room math overflows but result fits
521        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        // 2: combo overflows, should throw
532        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}