Skip to main content

soroban_cli/commands/contract/
invoke.rs

1use std::convert::{Infallible, TryInto};
2use std::ffi::OsString;
3use std::num::ParseIntError;
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::{fmt::Debug, fs, io};
7
8use clap::{Parser, ValueEnum};
9use soroban_rpc::{Client, SimulateHostFunctionResult, SimulateTransactionResponse};
10use soroban_spec::read::FromWasmError;
11
12use super::super::events;
13use super::arg_parsing;
14use crate::assembled::Assembled;
15use crate::commands::tx::fetch;
16use crate::log::extract_events;
17use crate::print::Print;
18use crate::tx::sim_sign_and_send_tx;
19use crate::utils::deprecate_message;
20use crate::{
21    assembled::simulate_and_assemble_transaction,
22    commands::{
23        contract::arg_parsing::{build_host_function_parameters, output_to_string},
24        global,
25        tx::fetch::fee,
26        txn_result::{TxnEnvelopeResult, TxnResult},
27        HEADING_TRANSACTION,
28    },
29    config::{self, data, locator, network},
30    get_spec::{self, get_remote_contract_spec},
31    print, rpc,
32    xdr::{
33        self, AccountEntry, AccountEntryExt, AccountId, ContractEvent, ContractEventType,
34        DiagnosticEvent, HostFunction, InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo,
35        MuxedAccount, Operation, OperationBody, Preconditions, PublicKey, ScSpecEntry,
36        SequenceNumber, String32, StringM, Thresholds, Transaction, TransactionExt, Uint256, VecM,
37        WriteXdr,
38    },
39    Pwd,
40};
41use soroban_spec_tools::contract;
42
43#[derive(Parser, Debug, Default, Clone)]
44#[allow(clippy::struct_excessive_bools)]
45#[group(skip)]
46pub struct Cmd {
47    /// Contract ID to invoke
48    #[arg(long = "id", env = "STELLAR_CONTRACT_ID")]
49    pub contract_id: config::UnresolvedContract,
50
51    // For testing only
52    #[arg(skip)]
53    pub wasm: Option<std::path::PathBuf>,
54
55    /// ⚠️ Deprecated, use `--send=no`. View the result simulating and do not sign and submit transaction.
56    #[arg(long, env = "STELLAR_INVOKE_VIEW")]
57    pub is_view: bool,
58
59    /// Function name as subcommand, then arguments for that function as `--arg-name value`
60    #[arg(last = true, id = "CONTRACT_FN_AND_ARGS")]
61    pub slop: Vec<OsString>,
62
63    #[command(flatten)]
64    pub config: config::Args,
65
66    #[command(flatten)]
67    pub resources: crate::resources::Args,
68
69    #[command(flatten)]
70    pub auth_mode: crate::auth_mode::Args,
71
72    /// Whether or not to send a transaction
73    #[arg(long, value_enum, default_value_t, env = "STELLAR_SEND")]
74    pub send: Send,
75
76    /// Build the transaction and only write the base64 xdr to stdout
77    #[arg(long, help_heading = HEADING_TRANSACTION)]
78    pub build_only: bool,
79}
80
81impl FromStr for Cmd {
82    type Err = clap::error::Error;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        use clap::{CommandFactory, FromArgMatches};
86        Self::from_arg_matches_mut(&mut Self::command().get_matches_from(s.split_whitespace()))
87    }
88}
89
90impl Pwd for Cmd {
91    fn set_pwd(&mut self, pwd: &Path) {
92        self.config.set_pwd(pwd);
93    }
94}
95
96#[derive(thiserror::Error, Debug)]
97pub enum Error {
98    #[error("cannot add contract to ledger entries: {0}")]
99    CannotAddContractToLedgerEntries(xdr::Error),
100
101    #[error("reading file {0:?}: {1}")]
102    CannotReadContractFile(PathBuf, io::Error),
103
104    #[error("committing file {filepath}: {error}")]
105    CannotCommitEventsFile {
106        filepath: std::path::PathBuf,
107        error: events::Error,
108    },
109
110    #[error("parsing contract spec: {0}")]
111    CannotParseContractSpec(FromWasmError),
112
113    #[error(transparent)]
114    Xdr(#[from] xdr::Error),
115
116    #[error("error parsing int: {0}")]
117    ParseIntError(#[from] ParseIntError),
118
119    #[error(transparent)]
120    Rpc(#[from] rpc::Error),
121
122    #[error("missing operation result")]
123    MissingOperationResult,
124
125    #[error("error loading signing key: {0}")]
126    SignatureError(#[from] ed25519_dalek::SignatureError),
127
128    #[error(transparent)]
129    Config(#[from] config::Error),
130
131    #[error("unexpected ({length}) simulate transaction result length")]
132    UnexpectedSimulateTransactionResultSize { length: usize },
133
134    #[error(transparent)]
135    Clap(#[from] clap::Error),
136
137    #[error(transparent)]
138    Locator(#[from] locator::Error),
139
140    #[error("Contract Error\n{0}: {1}")]
141    ContractInvoke(String, String),
142
143    #[error(transparent)]
144    StrKey(#[from] stellar_strkey::DecodeError),
145
146    #[error(transparent)]
147    ContractSpec(#[from] contract::Error),
148
149    #[error(transparent)]
150    Io(#[from] std::io::Error),
151
152    #[error(transparent)]
153    Data(#[from] data::Error),
154
155    #[error(transparent)]
156    Network(#[from] network::Error),
157
158    #[error(transparent)]
159    GetSpecError(#[from] get_spec::Error),
160
161    #[error(transparent)]
162    ArgParsing(#[from] arg_parsing::Error),
163
164    #[error(transparent)]
165    Fee(#[from] fee::Error),
166
167    #[error(transparent)]
168    Fetch(#[from] fetch::Error),
169
170    #[error(transparent)]
171    AuthMode(#[from] crate::auth_mode::Error),
172}
173
174impl From<Infallible> for Error {
175    fn from(_: Infallible) -> Self {
176        unreachable!()
177    }
178}
179
180impl Cmd {
181    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
182        let print = Print::new(global_args.quiet);
183        let res = self.invoke(global_args).await?.to_envelope();
184
185        if self.is_view {
186            deprecate_message(print, "--is-view", "Use `--send=no` instead.");
187        }
188
189        match res {
190            TxnEnvelopeResult::TxnEnvelope(tx) => println!("{}", tx.to_xdr_base64(Limits::none())?),
191            TxnEnvelopeResult::Res(output) => {
192                println!("{output}");
193            }
194        }
195        Ok(())
196    }
197
198    pub async fn invoke(&self, global_args: &global::Args) -> Result<TxnResult<String>, Error> {
199        self.execute(&self.config, global_args.quiet, global_args.no_cache)
200            .await
201    }
202
203    pub fn read_wasm(&self) -> Result<Option<Vec<u8>>, Error> {
204        Ok(if let Some(wasm) = self.wasm.as_ref() {
205            Some(fs::read(wasm).map_err(|e| Error::CannotReadContractFile(wasm.clone(), e))?)
206        } else {
207            None
208        })
209    }
210
211    pub fn spec_entries(&self) -> Result<Option<Vec<ScSpecEntry>>, Error> {
212        self.read_wasm()?
213            .map(|wasm| {
214                soroban_spec::read::from_wasm(&wasm).map_err(Error::CannotParseContractSpec)
215            })
216            .transpose()
217    }
218
219    fn should_send_tx(&self, sim_res: &SimulateTransactionResponse) -> Result<ShouldSend, Error> {
220        Ok(match self.send {
221            Send::Default => {
222                if self.is_view {
223                    ShouldSend::No
224                } else if has_write(sim_res)? || has_published_event(sim_res)? || has_auth(sim_res)?
225                {
226                    ShouldSend::Yes
227                } else {
228                    ShouldSend::DefaultNo
229                }
230            }
231            Send::No => ShouldSend::No,
232            Send::Yes => ShouldSend::Yes,
233        })
234    }
235
236    /// Uses a default account to check if the tx should be sent after the simulation. The transaction
237    /// should be recreated with the real source account later.
238    async fn simulate(
239        &self,
240        host_function_params: &InvokeContractArgs,
241        account_details: &AccountEntry,
242        rpc_client: &Client,
243    ) -> Result<Assembled, Error> {
244        let sequence: i64 = account_details.seq_num.0;
245        let AccountId(PublicKey::PublicKeyTypeEd25519(account_id)) =
246            account_details.account_id.clone();
247
248        let tx =
249            build_invoke_contract_tx(host_function_params.clone(), sequence + 1, 100, account_id)?;
250        Ok(simulate_and_assemble_transaction(
251            rpc_client,
252            &tx,
253            self.resources.resource_config(),
254            self.resources.resource_fee,
255            self.auth_mode.to_rpc(),
256        )
257        .await?)
258    }
259
260    /// Run the invocation and return only the decoded result, discarding the
261    /// transaction hash. Kept as the stable entry point for callers that just
262    /// need the rendered output (e.g. `contract invoke` itself).
263    pub async fn execute(
264        &self,
265        config: &config::Args,
266        quiet: bool,
267        no_cache: bool,
268    ) -> Result<TxnResult<String>, Error> {
269        Ok(
270            match self.execute_with_receipt(config, quiet, no_cache).await? {
271                TxnResult::Txn(tx) => TxnResult::Txn(tx),
272                TxnResult::Res(receipt) => TxnResult::Res(receipt.output),
273            },
274        )
275    }
276
277    /// Run the invocation and return a [`InvokeReceipt`] pairing the decoded
278    /// result with the submitted transaction's hash. Typed clients (such as
279    /// `stellar token`) use this to build machine-readable receipts.
280    #[allow(clippy::too_many_lines)]
281    pub async fn execute_with_receipt(
282        &self,
283        config: &config::Args,
284        quiet: bool,
285        no_cache: bool,
286    ) -> Result<TxnResult<InvokeReceipt>, Error> {
287        self.auth_mode.validate_not_enforce()?;
288
289        let print = print::Print::new(quiet);
290        let network = config.get_network()?;
291
292        tracing::trace!(?network);
293
294        let contract_id = self
295            .contract_id
296            .resolve_contract_id(&config.locator, &network.network_passphrase)?;
297
298        let spec_entries = self.spec_entries()?;
299
300        if let Some(spec_entries) = &spec_entries {
301            // For testing wasm arg parsing
302            build_host_function_parameters(&contract_id, &self.slop, spec_entries, config)?;
303        }
304
305        let client = network.rpc_client()?;
306
307        let global_args = global::Args {
308            locator: config.locator.clone(),
309            filter_logs: Vec::default(),
310            quiet,
311            verbose: false,
312            very_verbose: false,
313            no_cache,
314        };
315
316        let spec_entries = get_remote_contract_spec(
317            &contract_id.0,
318            &config.locator,
319            &config.network,
320            Some(&global_args),
321            Some(config),
322        )
323        .await
324        .map_err(Error::from)?;
325
326        let params =
327            build_host_function_parameters(&contract_id, &self.slop, &spec_entries, config)?;
328
329        let (function, spec, host_function_params, signers) = params;
330
331        // `self.build_only` will be checked again below and the fn will return a TxnResult::Txn
332        // if the user passed the --build-only flag
333        let (should_send, cached_simulation) = if self.build_only {
334            (ShouldSend::Yes, None)
335        } else {
336            let assembled = self
337                .simulate(&host_function_params, &default_account_entry(), &client)
338                .await?;
339            let should_send = self.should_send_tx(&assembled.sim_res)?;
340            (should_send, Some(assembled))
341        };
342
343        let account_details = if should_send == ShouldSend::Yes {
344            client
345                .verify_network_passphrase(Some(&network.network_passphrase))
346                .await?;
347
348            client
349                .get_account(&config.source_account()?.to_string())
350                .await?
351        } else {
352            if should_send == ShouldSend::DefaultNo {
353                print.infoln(
354                    "Simulation identified as read-only. Send by rerunning with `--send=yes`.",
355                );
356            }
357
358            let assembled = cached_simulation.expect(
359                "cached_simulation should be available when should_send != Yes and not build_only",
360            );
361            let sim_res = assembled.sim_response();
362            let return_value = sim_res.results()?;
363            let events = sim_res.events()?;
364
365            crate::log::event::all(&events);
366            // Note: Only events from the invoked contract will be decoded with named parameters.
367            // Events emitted by other contracts (e.g., token transfers during a swap) will
368            // fall back to raw format since we only have the spec for the invoked contract.
369            crate::log::event::contract_with_spec(&events, &print, Some(&spec));
370
371            let output = output_to_string(&spec, &return_value[0].xdr, &function)?
372                .into_result()
373                .expect("output_to_string always returns a result");
374            return Ok(TxnResult::Res(InvokeReceipt {
375                tx_hash: None,
376                output,
377            }));
378        };
379
380        let sequence: i64 = account_details.seq_num.into();
381        let AccountId(PublicKey::PublicKeyTypeEd25519(account_id)) = account_details.account_id;
382
383        let tx = Box::new(build_invoke_contract_tx(
384            host_function_params.clone(),
385            sequence + 1,
386            config.get_inclusion_fee()?,
387            account_id,
388        )?);
389
390        if self.build_only {
391            return Ok(TxnResult::Txn(tx));
392        }
393
394        let res = sim_sign_and_send_tx::<Error>(
395            &client,
396            &tx,
397            config,
398            &self.resources,
399            &signers,
400            self.auth_mode.to_rpc(),
401            quiet,
402            no_cache,
403        )
404        .await?;
405
406        let tx_hash = res.tx_hash.clone();
407        let return_value = res.return_value()?;
408        let events = extract_events(&res.result_meta.unwrap_or_default());
409
410        crate::log::event::all(&events);
411        // Note: Only events from the invoked contract will be decoded with named parameters.
412        // Events emitted by other contracts (e.g., token transfers during a swap) will
413        // fall back to raw format since we only have the spec for the invoked contract.
414        crate::log::event::contract_with_spec(&events, &print, Some(&spec));
415
416        let output = output_to_string(&spec, &return_value, &function)?
417            .into_result()
418            .expect("output_to_string always returns a result");
419        Ok(TxnResult::Res(InvokeReceipt { tx_hash, output }))
420    }
421}
422
423/// The outcome of a submitted contract invocation: the decoded return value
424/// alongside the hash of the transaction that produced it.
425#[derive(Debug, Clone)]
426pub struct InvokeReceipt {
427    /// Hex-encoded hash of the submitted transaction, or `None` when the
428    /// invocation resolved by simulation only (read-only) and was never sent.
429    pub tx_hash: Option<String>,
430    /// The decoded return value, rendered as a string (JSON for most types).
431    pub output: String,
432}
433
434const DEFAULT_ACCOUNT_ID: AccountId = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])));
435
436fn default_account_entry() -> AccountEntry {
437    AccountEntry {
438        account_id: DEFAULT_ACCOUNT_ID,
439        balance: 0,
440        seq_num: SequenceNumber(0),
441        num_sub_entries: 0,
442        inflation_dest: None,
443        flags: 0,
444        home_domain: String32::from(unsafe { StringM::<32>::from_str("TEST").unwrap_unchecked() }),
445        thresholds: Thresholds([0; 4]),
446        signers: unsafe { [].try_into().unwrap_unchecked() },
447        ext: AccountEntryExt::V0,
448    }
449}
450
451fn build_invoke_contract_tx(
452    parameters: InvokeContractArgs,
453    sequence: i64,
454    fee: u32,
455    source_account_id: Uint256,
456) -> Result<Transaction, Error> {
457    let op = Operation {
458        source_account: None,
459        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
460            host_function: HostFunction::InvokeContract(parameters),
461            auth: VecM::default(),
462        }),
463    };
464    Ok(Transaction {
465        source_account: MuxedAccount::Ed25519(source_account_id),
466        fee,
467        seq_num: SequenceNumber(sequence),
468        cond: Preconditions::None,
469        memo: Memo::None,
470        operations: vec![op].try_into()?,
471        ext: TransactionExt::V0,
472    })
473}
474
475#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum, Default)]
476pub enum Send {
477    /// Send transaction if simulation indicates there are ledger writes,
478    /// published events, or auth required, otherwise return simulation result
479    #[default]
480    Default,
481    /// Do not send transaction, return simulation result
482    No,
483    /// Always send transaction
484    Yes,
485}
486
487#[derive(Debug, PartialEq)]
488enum ShouldSend {
489    DefaultNo,
490    No,
491    Yes,
492}
493
494fn has_write(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
495    Ok(!sim_res
496        .transaction_data()?
497        .resources
498        .footprint
499        .read_write
500        .is_empty())
501}
502
503fn has_published_event(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
504    Ok(sim_res.events()?.iter().any(
505        |DiagnosticEvent {
506             event: ContractEvent { type_, .. },
507             ..
508         }| matches!(type_, ContractEventType::Contract),
509    ))
510}
511
512fn has_auth(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
513    Ok(sim_res
514        .results()?
515        .iter()
516        .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty()))
517}