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