Skip to main content

soroban_cli/
assembled.rs

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
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    /// * `sim_res` - The simulation response.
58    /// * `resource_fee` - Optional resource fee for the transaction. Will override the simulated resource fee if provided.
59    ///
60    /// # Errors
61    ///
62    /// Returns an error if simulation fails or if assembling the transaction fails.
63    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    ///
72    /// Calculates the hash of the assembled transaction.
73    ///
74    /// # Arguments
75    ///
76    /// * `network_passphrase` - The network passphrase.
77    ///
78    /// # Errors
79    ///
80    /// Returns an error if generating the hash fails.
81    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    /// 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 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    ///
113    /// # Errors
114    #[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    ///
131    /// # Errors
132    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    // TODO: Remove once `--instructions` is fully removed
175    #[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
193// Apply the result of a simulateTransaction onto a transaction envelope, preparing it for
194// submission to the network.
195///
196/// # Errors
197fn assemble(
198    raw: &Transaction,
199    simulation: SimulateTransactionResponse,
200    resource_fee: Option<i64>,
201) -> Result<Assembled, 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 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            // short circuit the submission error if the resource fee is negative
223            // technically, a negative resource fee is valid XDR so it won't panic earlier
224            // this should not occur as we validate resource fee before calling assemble
225            u64::try_from(rf).map_err(|_| {
226                Error::TransactionSubmissionFailed(String::from(
227                    "TxMalformed - negative resource fee",
228                ))
229            })?
230        }
231        // transaction_data is already set from simulation response
232        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    // Update the transaction fee to be the sum of the inclusion fee and the
263    // minimum resource fee from simulation.
264    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        // Transaction needs a fee bump wrapper. Set the fee to 0 and assign the required fee
270        // to the fee_bump_fee field, which will be used later when constructing the FeeBumpTransaction.
271        // => fee_bump_fee = 2 * inclusion_fee + resource_fee
272        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        // validate it auto updated the tx fees from sim response fees
387        // since it was greater than tx.fee
388        assert_eq!(215, result.txn.fee);
389
390        // validate it updated sorobantransactiondata block in the tx ext
391        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        // validate it updated sorobantransactiondata block in the tx ext
509        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        // Test three separate cases:
516        //
517        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
518        //     does not require a fee bump after adding the base inclusion fee (100).
519        //  2. Given a large resource fee that WILL exceed u32::MAX with the
520        //     base inclusion fee, ensure the fee is set to zero and the correct
521        //     fee_bump_fee is set on the Assembled struct.
522        //  3. Given a total fee over i64::MAX, ensure an error is returned.
523        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        // 1: wiggle room math overflows but result fits
531        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        // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total
542        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        // 3: total fee exceeds i64::MAX, should error
555        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        // validate the assembled tx fee is the sum of the inclusion fee (txn.fee)
577        // and the resource fee
578        assert_eq!(12345 + 500, result.txn.fee);
579        assert_eq!(None, result.fee_bump_fee);
580
581        // validate it updated sorobantransactiondata block in the tx ext
582        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    // This should never occur, as resource fee is validated before being passed into
588    // assemble. But test the behavior just in case.
589    #[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        // Test three separate cases:
604        //
605        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
606        //     does not require a fee bump after adding the base inclusion fee (100).
607        //  2. Given a large resource fee that WILL exceed u32::MAX with the
608        //     base inclusion fee, ensure the fee is set to zero and the correct
609        //     fee_bump_fee is set on the Assembled struct.
610        //  3. Given a total fee over i64::MAX, ensure an error is returned.
611        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        // 1: wiggle room math overflows but result fits
619        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        // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total
629        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        // 3: total fee exceeds i64::MAX, should error
642        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}