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
13use crate::utils::XDR_DEPTH_LIMIT;
14
15pub async fn simulate_and_assemble_transaction(
16    client: &soroban_rpc::Client,
17    tx: &Transaction,
18    resource_config: Option<ResourceConfig>,
19    resource_fee: Option<i64>,
20    auth_mode: Option<AuthMode>,
21) -> Result<Assembled, Error> {
22    let envelope = TransactionEnvelope::Tx(TransactionV1Envelope {
23        tx: tx.clone(),
24        signatures: VecM::default(),
25    });
26
27    tracing::trace!(
28        "Simulation transaction envelope: {}",
29        envelope.to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))?
30    );
31
32    // Whether an explicit record mode was requested. In record mode the RPC re-records auth
33    // even when entries already exist, so the assembled output should adopt the recorded set.
34    let record_auth = matches!(
35        auth_mode,
36        Some(AuthMode::Record | AuthMode::RecordAllowNonRoot)
37    );
38
39    let sim_res = client
40        .next_simulate_transaction_envelope(&envelope, auth_mode, resource_config)
41        .await?;
42    tracing::trace!("{sim_res:#?}");
43
44    if let Some(e) = &sim_res.error {
45        crate::log::event::all(&sim_res.events()?);
46        Err(Error::TransactionSimulationFailed(e.clone()))
47    } else {
48        Ok(Assembled::new(tx, sim_res, resource_fee, record_auth)?)
49    }
50}
51
52pub struct Assembled {
53    pub(crate) txn: Transaction,
54    pub(crate) sim_res: SimulateTransactionResponse,
55    pub(crate) fee_bump_fee: Option<i64>,
56}
57
58/// Represents an assembled transaction ready to be signed and submitted to the network.
59impl Assembled {
60    ///
61    /// Creates a new `Assembled` transaction.
62    ///
63    /// # Arguments
64    ///
65    /// * `txn` - The original transaction.
66    /// * `sim_res` - The simulation response.
67    /// * `resource_fee` - Optional resource fee for the transaction. Will override the simulated resource fee if provided.
68    /// * `record_auth` - Whether an explicit record auth mode was requested. When `true`, the
69    ///   simulation-recorded auth entries replace any entries already on the transaction; when
70    ///   `false`, caller-provided entries are preserved and auth is only filled in when absent.
71    ///
72    /// # Errors
73    ///
74    /// Returns an error if simulation fails or if assembling the transaction fails.
75    pub fn new(
76        txn: &Transaction,
77        sim_res: SimulateTransactionResponse,
78        resource_fee: Option<i64>,
79        record_auth: bool,
80    ) -> Result<Self, Error> {
81        assemble(txn, sim_res, resource_fee, record_auth)
82    }
83
84    ///
85    /// Calculates the hash of the assembled transaction.
86    ///
87    /// # Arguments
88    ///
89    /// * `network_passphrase` - The network passphrase.
90    ///
91    /// # Errors
92    ///
93    /// Returns an error if generating the hash fails.
94    pub fn hash(&self, network_passphrase: &str) -> Result<[u8; 32], xdr::Error> {
95        let signature_payload = TransactionSignaturePayload {
96            network_id: Hash(Sha256::digest(network_passphrase).into()),
97            tagged_transaction: TransactionSignaturePayloadTaggedTransaction::Tx(self.txn.clone()),
98        };
99        Ok(Sha256::digest(signature_payload.to_xdr(Limits::depth(XDR_DEPTH_LIMIT))?).into())
100    }
101
102    /// Returns a reference to the original transaction.
103    #[must_use]
104    pub fn transaction(&self) -> &Transaction {
105        &self.txn
106    }
107
108    /// Returns a reference to the simulation response.
109    #[must_use]
110    pub fn sim_response(&self) -> &SimulateTransactionResponse {
111        &self.sim_res
112    }
113
114    #[must_use]
115    pub fn fee_bump_fee(&self) -> Option<i64> {
116        self.fee_bump_fee
117    }
118
119    #[must_use]
120    pub fn bump_seq_num(mut self) -> Self {
121        self.txn.seq_num.0 += 1;
122        self
123    }
124
125    ///
126    /// # Errors
127    #[must_use]
128    pub fn auth_entries(&self) -> VecM<SorobanAuthorizationEntry> {
129        self.txn
130            .operations
131            .first()
132            .and_then(|op| match op.body {
133                OperationBody::InvokeHostFunction(ref body) => (matches!(
134                    body.auth.first().map(|x| &x.root_invocation.function),
135                    Some(&SorobanAuthorizedFunction::ContractFn(_))
136                ))
137                .then_some(body.auth.clone()),
138                _ => None,
139            })
140            .unwrap_or_default()
141    }
142
143    ///
144    /// # Errors
145    pub fn log(
146        &self,
147        log_events: Option<LogEvents>,
148        log_resources: Option<LogResources>,
149    ) -> Result<(), Error> {
150        if let TransactionExt::V1(SorobanTransactionData {
151            resources: resources @ SorobanResources { footprint, .. },
152            ..
153        }) = &self.txn.ext
154        {
155            if let Some(log) = log_resources {
156                log(resources);
157            }
158
159            if let Some(log) = log_events {
160                log(footprint, &[self.auth_entries()], &self.sim_res.events()?);
161            }
162        }
163        Ok(())
164    }
165
166    #[must_use]
167    pub fn requires_fee_bump(&self) -> bool {
168        self.fee_bump_fee.is_some()
169    }
170
171    #[must_use]
172    pub fn is_view(&self) -> bool {
173        let TransactionExt::V1(SorobanTransactionData {
174            resources:
175                SorobanResources {
176                    footprint: LedgerFootprint { read_write, .. },
177                    ..
178                },
179            ..
180        }) = &self.txn.ext
181        else {
182            return false;
183        };
184        read_write.is_empty()
185    }
186
187    // TODO: Remove once `--instructions` is fully removed
188    #[must_use]
189    pub fn set_max_instructions(mut self, instructions: u32) -> Self {
190        if let TransactionExt::V1(SorobanTransactionData {
191            resources:
192                SorobanResources {
193                    instructions: ref mut i,
194                    ..
195                },
196            ..
197        }) = &mut self.txn.ext
198        {
199            tracing::trace!("setting max instructions to {instructions} from {i}");
200            *i = instructions;
201        }
202        self
203    }
204}
205
206// Apply the result of a simulateTransaction onto a transaction envelope, preparing it for
207// submission to the network.
208///
209/// # Errors
210fn assemble(
211    raw: &Transaction,
212    simulation: SimulateTransactionResponse,
213    resource_fee: Option<i64>,
214    record_auth: bool,
215) -> Result<Assembled, Error> {
216    let mut tx = raw.clone();
217
218    // Right now simulate.results is one-result-per-function, and assumes there is only one
219    // operation in the txn, so we need to enforce that here. I (Paul) think that is a bug
220    // in soroban-rpc.simulateTransaction design, and we should fix it there.
221    // TODO: We should to better handling so non-soroban txns can be a passthrough here.
222    if tx.operations.len() != 1 {
223        return Err(Error::UnexpectedOperationCount {
224            count: tx.operations.len(),
225        });
226    }
227
228    let mut transaction_data = simulation.transaction_data()?;
229    let min_resource_fee = match resource_fee {
230        Some(rf) => {
231            tracing::trace!(
232                "overriding resource fee to {rf} (simulation suggested {})",
233                simulation.min_resource_fee
234            );
235            transaction_data.resource_fee = rf;
236            // short circuit the submission error if the resource fee is negative
237            // technically, a negative resource fee is valid XDR so it won't panic earlier
238            // this should not occur as we validate resource fee before calling assemble
239            u64::try_from(rf).map_err(|_| {
240                Error::TransactionSubmissionFailed(String::from(
241                    "TxMalformed - negative resource fee",
242                ))
243            })?
244        }
245        // transaction_data is already set from simulation response
246        None => simulation.min_resource_fee,
247    };
248
249    let mut op = tx.operations[0].clone();
250    if let OperationBody::InvokeHostFunction(ref mut body) = &mut op.body {
251        // In an explicit record mode the RPC records fresh auth even when entries already
252        // exist, so replace the caller-provided entries with the recorded set. For enforce /
253        // unset we only fill in auth when none was provided, preserving caller-provided entries.
254        if body.auth.is_empty() || record_auth {
255            if simulation.results.len() != 1 {
256                return Err(Error::UnexpectedSimulateTransactionResultSize {
257                    length: simulation.results.len(),
258                });
259            }
260
261            let auths = simulation
262                .results
263                .iter()
264                .map(|r| {
265                    VecM::try_from(
266                        r.auth
267                            .iter()
268                            .map(|v| {
269                                SorobanAuthorizationEntry::from_xdr_base64(
270                                    v,
271                                    Limits::depth(XDR_DEPTH_LIMIT),
272                                )
273                            })
274                            .collect::<Result<Vec<_>, _>>()?,
275                    )
276                })
277                .collect::<Result<Vec<_>, _>>()?;
278            if !auths.is_empty() {
279                body.auth = auths[0].clone();
280            }
281        }
282    }
283
284    // If the incoming transaction already carries a resource fee (i.e. it was previously
285    // assembled), it is already folded into `raw.fee`. Subtract it before adding the simulated
286    // resource fee so re-assembling does not double-count it. Mirrors the JS SDK's
287    // `assembleTransaction` guard.
288    let mut inclusion_fee = u64::from(raw.fee);
289    if let TransactionExt::V1(SorobanTransactionData {
290        resource_fee: existing_resource_fee,
291        ..
292    }) = &raw.ext
293    {
294        if let Ok(existing_resource_fee) = u64::try_from(*existing_resource_fee) {
295            if inclusion_fee > existing_resource_fee {
296                inclusion_fee -= existing_resource_fee;
297            }
298        }
299    }
300
301    // Update the transaction fee to be the sum of the inclusion fee and the
302    // minimum resource fee from simulation.
303    let total_fee: u64 = inclusion_fee + min_resource_fee;
304    let mut fee_bump_fee: Option<i64> = None;
305    if let Ok(tx_fee) = u32::try_from(total_fee) {
306        tx.fee = tx_fee;
307    } else {
308        // Transaction needs a fee bump wrapper. Set the fee to 0 and assign the required fee
309        // to the fee_bump_fee field, which will be used later when constructing the FeeBumpTransaction.
310        // => fee_bump_fee = 2 * inclusion_fee + resource_fee
311        tx.fee = 0;
312        let fee_bump_fee_u64 = total_fee + inclusion_fee;
313        fee_bump_fee =
314            Some(i64::try_from(fee_bump_fee_u64).map_err(|_| Error::LargeFee(fee_bump_fee_u64))?);
315    }
316
317    tx.operations = vec![op].try_into()?;
318    tx.ext = TransactionExt::V1(transaction_data);
319    Ok(Assembled {
320        txn: tx,
321        sim_res: simulation,
322        fee_bump_fee,
323    })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    use soroban_rpc::SimulateHostFunctionResultRaw;
331    use stellar_strkey::ed25519::PublicKey as Ed25519PublicKey;
332    use stellar_xdr::{
333        AccountId, ChangeTrustAsset, ChangeTrustOp, Hash, HostFunction, InvokeContractArgs,
334        InvokeHostFunctionOp, LedgerFootprint, Memo, MuxedAccount, Operation, Preconditions,
335        PublicKey, ScAddress, ScSymbol, ScVal, SequenceNumber, SorobanAuthorizedFunction,
336        SorobanAuthorizedInvocation, SorobanResources, SorobanTransactionData, Uint256, WriteXdr,
337    };
338
339    const SOURCE: &str = "GBZXN7PIRZGNMHGA7MUUUF4GWPY5AYPV6LY4UV2GL6VJGIQRXFDNMADI";
340
341    fn transaction_data() -> SorobanTransactionData {
342        SorobanTransactionData {
343            resources: SorobanResources {
344                footprint: LedgerFootprint {
345                    read_only: VecM::default(),
346                    read_write: VecM::default(),
347                },
348                instructions: 0,
349                disk_read_bytes: 5,
350                write_bytes: 0,
351            },
352            resource_fee: 0,
353            ext: xdr::SorobanTransactionDataExt::V0,
354        }
355    }
356
357    fn simulation_response() -> SimulateTransactionResponse {
358        let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
359        let fn_auth = &SorobanAuthorizationEntry {
360            credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
361                address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
362                    source_bytes,
363                )))),
364                nonce: 0,
365                signature_expiration_ledger: 0,
366                signature: ScVal::Void,
367            }),
368            root_invocation: SorobanAuthorizedInvocation {
369                function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
370                    contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash([0; 32]))),
371                    function_name: ScSymbol("fn".try_into().unwrap()),
372                    args: VecM::default(),
373                }),
374                sub_invocations: VecM::default(),
375            },
376        };
377
378        SimulateTransactionResponse {
379            min_resource_fee: 115,
380            latest_ledger: 3,
381            results: vec![SimulateHostFunctionResultRaw {
382                auth: vec![fn_auth
383                    .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))
384                    .unwrap()],
385                xdr: ScVal::U32(0)
386                    .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))
387                    .unwrap(),
388            }],
389            transaction_data: transaction_data()
390                .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))
391                .unwrap(),
392            ..Default::default()
393        }
394    }
395
396    // Build a contract-function authorization entry with a distinguishable function name so
397    // tests can tell caller-provided auth apart from simulation-recorded auth.
398    fn contract_fn_auth(function_name: &str) -> SorobanAuthorizationEntry {
399        let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
400        SorobanAuthorizationEntry {
401            credentials: xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
402                address: ScAddress::Account(AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
403                    source_bytes,
404                )))),
405                nonce: 0,
406                signature_expiration_ledger: 0,
407                signature: ScVal::Void,
408            }),
409            root_invocation: SorobanAuthorizedInvocation {
410                function: SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
411                    contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash([0; 32]))),
412                    function_name: ScSymbol(function_name.try_into().unwrap()),
413                    args: VecM::default(),
414                }),
415                sub_invocations: VecM::default(),
416            },
417        }
418    }
419
420    // Return the function name of the first auth entry on the first operation.
421    fn first_auth_fn_name(txn: &Transaction) -> String {
422        let OperationBody::InvokeHostFunction(ref op) = txn.operations[0].body else {
423            panic!("expected InvokeHostFunction operation");
424        };
425        let SorobanAuthorizedFunction::ContractFn(InvokeContractArgs {
426            ref function_name, ..
427        }) = op.auth[0].root_invocation.function
428        else {
429            panic!("expected ContractFn auth");
430        };
431        format!("{}", function_name.0)
432    }
433
434    fn set_auth(txn: &mut Transaction, auth: Vec<SorobanAuthorizationEntry>) {
435        let mut op = txn.operations[0].clone();
436        let OperationBody::InvokeHostFunction(ref mut body) = op.body else {
437            panic!("expected InvokeHostFunction operation");
438        };
439        body.auth = auth.try_into().unwrap();
440        txn.operations = vec![op].try_into().unwrap();
441    }
442
443    fn single_contract_fn_transaction() -> Transaction {
444        let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
445        Transaction {
446            source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
447            fee: 100,
448            seq_num: SequenceNumber(0),
449            cond: Preconditions::None,
450            memo: Memo::None,
451            operations: vec![Operation {
452                source_account: None,
453                body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
454                    host_function: HostFunction::InvokeContract(InvokeContractArgs {
455                        contract_address: ScAddress::Contract(stellar_xdr::ContractId(Hash(
456                            [0x0; 32],
457                        ))),
458                        function_name: ScSymbol::default(),
459                        args: VecM::default(),
460                    }),
461                    auth: VecM::default(),
462                }),
463            }]
464            .try_into()
465            .unwrap(),
466            ext: TransactionExt::V0,
467        }
468    }
469
470    #[test]
471    fn test_assemble_transaction_updates_tx_data_from_simulation_response() {
472        let sim = simulation_response();
473        let txn = single_contract_fn_transaction();
474        let Ok(result) = assemble(&txn, sim, None, false) else {
475            panic!("assemble failed");
476        };
477
478        // validate it auto updated the tx fees from sim response fees
479        // since it was greater than tx.fee
480        assert_eq!(215, result.txn.fee);
481
482        // validate it updated sorobantransactiondata block in the tx ext
483        assert_eq!(TransactionExt::V1(transaction_data()), result.txn.ext);
484    }
485
486    #[test]
487    fn test_assemble_transaction_adds_the_auth_to_the_host_function() {
488        let sim = simulation_response();
489        let txn = single_contract_fn_transaction();
490        let Ok(result) = assemble(&txn, sim, None, false) else {
491            panic!("assemble failed");
492        };
493
494        assert_eq!(1, result.txn.operations.len());
495        let OperationBody::InvokeHostFunction(ref op) = result.txn.operations[0].body else {
496            panic!("unexpected operation type: {:#?}", result.txn.operations[0]);
497        };
498
499        assert_eq!(1, op.auth.len());
500        let auth = &op.auth[0];
501
502        let xdr::SorobanAuthorizedFunction::ContractFn(xdr::InvokeContractArgs {
503            ref function_name,
504            ..
505        }) = auth.root_invocation.function
506        else {
507            panic!("unexpected function type");
508        };
509        assert_eq!("fn".to_string(), format!("{}", function_name.0));
510
511        let xdr::SorobanCredentials::Address(xdr::SorobanAddressCredentials {
512            address:
513                xdr::ScAddress::Account(xdr::AccountId(xdr::PublicKey::PublicKeyTypeEd25519(address))),
514            ..
515        }) = &auth.credentials
516        else {
517            panic!("unexpected credentials type");
518        };
519        assert_eq!(
520            SOURCE.to_string(),
521            format!("{}", stellar_strkey::ed25519::PublicKey(address.0))
522        );
523    }
524
525    #[test]
526    fn test_assemble_transaction_errors_for_non_invokehostfn_ops() {
527        let source_bytes = Ed25519PublicKey::from_string(SOURCE).unwrap().0;
528        let txn = Transaction {
529            source_account: MuxedAccount::Ed25519(Uint256(source_bytes)),
530            fee: 100,
531            seq_num: SequenceNumber(0),
532            cond: Preconditions::None,
533            memo: Memo::None,
534            operations: vec![Operation {
535                source_account: None,
536                body: OperationBody::ChangeTrust(ChangeTrustOp {
537                    line: ChangeTrustAsset::Native,
538                    limit: 0,
539                }),
540            }]
541            .try_into()
542            .unwrap(),
543            ext: TransactionExt::V0,
544        };
545
546        let result = assemble(
547            &txn,
548            SimulateTransactionResponse {
549                min_resource_fee: 115,
550                transaction_data: transaction_data()
551                    .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))
552                    .unwrap(),
553                latest_ledger: 3,
554                ..Default::default()
555            },
556            None,
557            false,
558        );
559
560        match result {
561            Ok(_) => {}
562            Err(e) => panic!("expected assembled operation, got: {e:#?}"),
563        }
564    }
565
566    #[test]
567    fn test_assemble_transaction_errors_for_errors_for_mismatched_simulation() {
568        let txn = single_contract_fn_transaction();
569
570        let result = assemble(
571            &txn,
572            SimulateTransactionResponse {
573                min_resource_fee: 115,
574                transaction_data: transaction_data()
575                    .to_xdr_base64(Limits::depth(XDR_DEPTH_LIMIT))
576                    .unwrap(),
577                latest_ledger: 3,
578                ..Default::default()
579            },
580            None,
581            false,
582        );
583
584        match result {
585            Err(Error::UnexpectedSimulateTransactionResultSize { length }) => {
586                assert_eq!(0, length);
587            }
588            Ok(_) => panic!("expected error, got success"),
589            Err(e) => panic!("expected UnexpectedSimulateTransactionResultSize error, got: {e:#?}"),
590        }
591    }
592
593    #[test]
594    fn test_assemble_transaction_calcs_fee() {
595        let mut sim = simulation_response();
596        sim.min_resource_fee = 12345;
597        let mut txn = single_contract_fn_transaction();
598        txn.fee = 10000;
599        let Ok(result) = assemble(&txn, sim, None, false) else {
600            panic!("assemble failed");
601        };
602
603        assert_eq!(12345 + 10000, result.txn.fee);
604        assert_eq!(None, result.fee_bump_fee);
605
606        // validate it updated sorobantransactiondata block in the tx ext
607        let expected_tx_data = transaction_data();
608        assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext);
609    }
610
611    #[test]
612    fn test_assemble_transaction_fee_bump_fee_behavior() {
613        // Test three separate cases:
614        //
615        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
616        //     does not require a fee bump after adding the base inclusion fee (100).
617        //  2. Given a large resource fee that WILL exceed u32::MAX with the
618        //     base inclusion fee, ensure the fee is set to zero and the correct
619        //     fee_bump_fee is set on the Assembled struct.
620        //  3. Given a total fee over i64::MAX, ensure an error is returned.
621        let mut txn = single_contract_fn_transaction();
622        let mut response = simulation_response();
623
624        let inclusion_fee: u32 = 500;
625        let inclusion_fee_i64: i64 = i64::from(inclusion_fee);
626        txn.fee = inclusion_fee;
627
628        // 1: wiggle room math overflows but result fits
629        response.min_resource_fee = (u32::MAX - inclusion_fee).into();
630
631        match assemble(&txn, response.clone(), None, false) {
632            Ok(assembled) => {
633                assert_eq!(assembled.txn.fee, u32::MAX);
634                assert_eq!(assembled.fee_bump_fee, None);
635            }
636            Err(e) => panic!("expected success, got error: {e:#?}"),
637        }
638
639        // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total
640        response.min_resource_fee = (u32::MAX - inclusion_fee + 1).into();
641        match assemble(&txn, response.clone(), None, false) {
642            Ok(assembled) => {
643                assert_eq!(assembled.txn.fee, 0);
644                assert_eq!(
645                    assembled.fee_bump_fee,
646                    Some(i64::try_from(response.min_resource_fee).unwrap() + inclusion_fee_i64 * 2)
647                );
648            }
649            Err(e) => panic!("expected success, got error: {e:#?}"),
650        }
651
652        // 3: total fee exceeds i64::MAX, should error
653        response.min_resource_fee = u64::try_from(i64::MAX - (2 * inclusion_fee_i64) + 1).unwrap();
654        match assemble(&txn, response, None, false) {
655            Err(Error::LargeFee(fee)) => {
656                let expected = i64::MAX as u64 + 1;
657                assert_eq!(expected, fee, "expected {expected} != {fee} actual");
658            }
659            Ok(_) => panic!("expected error, got success"),
660            Err(e) => panic!("expected LargeFee error, got different error: {e:#?}"),
661        }
662    }
663
664    #[test]
665    fn test_assemble_transaction_with_resource_fee() {
666        let sim = simulation_response();
667        let mut txn = single_contract_fn_transaction();
668        txn.fee = 500;
669        let resource_fee = 12345i64;
670        let Ok(result) = assemble(&txn, sim, Some(resource_fee), false) else {
671            panic!("assemble failed");
672        };
673
674        // validate the assembled tx fee is the sum of the inclusion fee (txn.fee)
675        // and the resource fee
676        assert_eq!(12345 + 500, result.txn.fee);
677        assert_eq!(None, result.fee_bump_fee);
678
679        // validate it updated sorobantransactiondata block in the tx ext
680        let mut expected_tx_data = transaction_data();
681        expected_tx_data.resource_fee = resource_fee;
682        assert_eq!(TransactionExt::V1(expected_tx_data), result.txn.ext);
683    }
684
685    // This should never occur, as resource fee is validated before being passed into
686    // assemble. But test the behavior just in case.
687    #[test]
688    fn test_assemble_transaction_input_resource_fee_negative_errors() {
689        let mut sim = simulation_response();
690        sim.min_resource_fee = 12345;
691        let mut txn = single_contract_fn_transaction();
692        txn.fee = 500;
693        let resource_fee = -1;
694        let result = assemble(&txn, sim, Some(resource_fee), false);
695
696        assert!(result.is_err());
697    }
698
699    #[test]
700    fn test_assemble_transaction_with_resource_fee_fee_bump_behavior() {
701        // Test three separate cases:
702        //
703        //  1. Given a near-max (u32::MAX - 100) resource fee make sure the tx
704        //     does not require a fee bump after adding the base inclusion fee (100).
705        //  2. Given a large resource fee that WILL exceed u32::MAX with the
706        //     base inclusion fee, ensure the fee is set to zero and the correct
707        //     fee_bump_fee is set on the Assembled struct.
708        //  3. Given a total fee over i64::MAX, ensure an error is returned.
709        let mut txn = single_contract_fn_transaction();
710        let response = simulation_response();
711
712        let inclusion_fee: u32 = 500;
713        let inclusion_fee_i64: i64 = i64::from(inclusion_fee);
714        txn.fee = inclusion_fee;
715
716        // 1: wiggle room math overflows but result fits
717        let resource_fee: i64 = (u32::MAX - inclusion_fee).into();
718        match assemble(&txn, response.clone(), Some(resource_fee), false) {
719            Ok(assembled) => {
720                assert_eq!(assembled.txn.fee, u32::MAX);
721                assert_eq!(assembled.fee_bump_fee, None);
722            }
723            Err(e) => panic!("expected success, got error: {e:#?}"),
724        }
725
726        // 2: combo over u32::MAX, should set fee to 0 and fee_bump_fee to total
727        let resource_fee: i64 = (u32::MAX - inclusion_fee + 1).into();
728        match assemble(&txn, response.clone(), Some(resource_fee), false) {
729            Ok(assembled) => {
730                assert_eq!(assembled.txn.fee, 0);
731                assert_eq!(
732                    assembled.fee_bump_fee,
733                    Some(resource_fee + inclusion_fee_i64 * 2)
734                );
735            }
736            Err(e) => panic!("expected success, got error: {e:#?}"),
737        }
738
739        // 3: total fee exceeds i64::MAX, should error
740        let resource_fee: i64 = i64::MAX - (2 * inclusion_fee_i64) + 1;
741        match assemble(&txn, response, Some(resource_fee), false) {
742            Err(Error::LargeFee(fee)) => {
743                let expected = i64::MAX as u64 + 1;
744                assert_eq!(expected, fee, "expected {expected} != {fee} actual");
745            }
746            Ok(_) => panic!("expected error, got success"),
747            Err(e) => panic!("expected LargeFee error, got: {e:#?}"),
748        }
749    }
750
751    #[test]
752    fn test_assemble_transaction_replaces_auth_when_recording() {
753        // A transaction that already carries auth entries, re-simulated in a record mode,
754        // should adopt the simulation-recorded entries (function name "fn") instead of the
755        // pre-existing ones ("old_fn").
756        let sim = simulation_response();
757        let mut txn = single_contract_fn_transaction();
758        set_auth(&mut txn, vec![contract_fn_auth("old_fn")]);
759
760        let Ok(result) = assemble(&txn, sim, None, true) else {
761            panic!("assemble failed");
762        };
763
764        assert_eq!(1, result.txn.operations.len());
765        let OperationBody::InvokeHostFunction(ref op) = result.txn.operations[0].body else {
766            panic!("unexpected operation type: {:#?}", result.txn.operations[0]);
767        };
768        assert_eq!(1, op.auth.len());
769        assert_eq!("fn", first_auth_fn_name(&result.txn));
770    }
771
772    #[test]
773    fn test_assemble_transaction_preserves_auth_when_not_recording() {
774        // Without an explicit record mode, caller-provided auth entries ("old_fn") must be
775        // preserved even though the simulation recorded a different entry ("fn").
776        let sim = simulation_response();
777        let mut txn = single_contract_fn_transaction();
778        set_auth(&mut txn, vec![contract_fn_auth("old_fn")]);
779
780        let Ok(result) = assemble(&txn, sim, None, false) else {
781            panic!("assemble failed");
782        };
783
784        assert_eq!("old_fn", first_auth_fn_name(&result.txn));
785    }
786
787    #[test]
788    fn test_assemble_transaction_does_not_double_count_resource_fee() {
789        // An already-assembled transaction carries its resource fee both in `raw.fee` and in
790        // the SorobanTransactionData (ext V1). Re-assembling must subtract the existing
791        // resource fee from the inclusion fee before adding the simulated resource fee, rather
792        // than stacking them.
793        let mut sim = simulation_response();
794        sim.min_resource_fee = 115;
795        let mut txn = single_contract_fn_transaction();
796
797        // Inclusion fee (400) + already-folded resource fee (100) == 500.
798        let existing_resource_fee = 100;
799        txn.fee = 500;
800        let mut existing_data = transaction_data();
801        existing_data.resource_fee = existing_resource_fee;
802        txn.ext = TransactionExt::V1(existing_data);
803
804        let Ok(result) = assemble(&txn, sim, None, false) else {
805            panic!("assemble failed");
806        };
807
808        // 400 (inclusion) + 115 (simulated resource fee), NOT 500 + 115.
809        assert_eq!(400 + 115, result.txn.fee);
810        assert_eq!(None, result.fee_bump_fee);
811    }
812}