Skip to main content

soroban_cli/
assembled.rs

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