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    #[allow(clippy::too_many_lines)]
261    pub async fn execute(
262        &self,
263        config: &config::Args,
264        quiet: bool,
265        no_cache: bool,
266    ) -> Result<TxnResult<String>, Error> {
267        self.auth_mode.validate_not_enforce()?;
268
269        let print = print::Print::new(quiet);
270        let network = config.get_network()?;
271
272        tracing::trace!(?network);
273
274        let contract_id = self
275            .contract_id
276            .resolve_contract_id(&config.locator, &network.network_passphrase)?;
277
278        let spec_entries = self.spec_entries()?;
279
280        if let Some(spec_entries) = &spec_entries {
281            // For testing wasm arg parsing
282            build_host_function_parameters(&contract_id, &self.slop, spec_entries, config)?;
283        }
284
285        let client = network.rpc_client()?;
286
287        let global_args = global::Args {
288            locator: config.locator.clone(),
289            filter_logs: Vec::default(),
290            quiet,
291            verbose: false,
292            very_verbose: false,
293            no_cache,
294        };
295
296        let spec_entries = get_remote_contract_spec(
297            &contract_id.0,
298            &config.locator,
299            &config.network,
300            Some(&global_args),
301            Some(config),
302        )
303        .await
304        .map_err(Error::from)?;
305
306        let params =
307            build_host_function_parameters(&contract_id, &self.slop, &spec_entries, config)?;
308
309        let (function, spec, host_function_params, signers) = params;
310
311        // `self.build_only` will be checked again below and the fn will return a TxnResult::Txn
312        // if the user passed the --build-only flag
313        let (should_send, cached_simulation) = if self.build_only {
314            (ShouldSend::Yes, None)
315        } else {
316            let assembled = self
317                .simulate(&host_function_params, &default_account_entry(), &client)
318                .await?;
319            let should_send = self.should_send_tx(&assembled.sim_res)?;
320            (should_send, Some(assembled))
321        };
322
323        let account_details = if should_send == ShouldSend::Yes {
324            client
325                .verify_network_passphrase(Some(&network.network_passphrase))
326                .await?;
327
328            client
329                .get_account(&config.source_account()?.to_string())
330                .await?
331        } else {
332            if should_send == ShouldSend::DefaultNo {
333                print.infoln(
334                    "Simulation identified as read-only. Send by rerunning with `--send=yes`.",
335                );
336            }
337
338            let assembled = cached_simulation.expect(
339                "cached_simulation should be available when should_send != Yes and not build_only",
340            );
341            let sim_res = assembled.sim_response();
342            let return_value = sim_res.results()?;
343            let events = sim_res.events()?;
344
345            crate::log::event::all(&events);
346            // Note: Only events from the invoked contract will be decoded with named parameters.
347            // Events emitted by other contracts (e.g., token transfers during a swap) will
348            // fall back to raw format since we only have the spec for the invoked contract.
349            crate::log::event::contract_with_spec(&events, &print, Some(&spec));
350
351            return Ok(output_to_string(&spec, &return_value[0].xdr, &function)?);
352        };
353
354        let sequence: i64 = account_details.seq_num.into();
355        let AccountId(PublicKey::PublicKeyTypeEd25519(account_id)) = account_details.account_id;
356
357        let tx = Box::new(build_invoke_contract_tx(
358            host_function_params.clone(),
359            sequence + 1,
360            config.get_inclusion_fee()?,
361            account_id,
362        )?);
363
364        if self.build_only {
365            return Ok(TxnResult::Txn(tx));
366        }
367
368        let res = sim_sign_and_send_tx::<Error>(
369            &client,
370            &tx,
371            config,
372            &self.resources,
373            &signers,
374            self.auth_mode.to_rpc(),
375            quiet,
376            no_cache,
377        )
378        .await?;
379
380        let return_value = res.return_value()?;
381        let events = extract_events(&res.result_meta.unwrap_or_default());
382
383        crate::log::event::all(&events);
384        // Note: Only events from the invoked contract will be decoded with named parameters.
385        // Events emitted by other contracts (e.g., token transfers during a swap) will
386        // fall back to raw format since we only have the spec for the invoked contract.
387        crate::log::event::contract_with_spec(&events, &print, Some(&spec));
388
389        Ok(output_to_string(&spec, &return_value, &function)?)
390    }
391}
392
393const DEFAULT_ACCOUNT_ID: AccountId = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])));
394
395fn default_account_entry() -> AccountEntry {
396    AccountEntry {
397        account_id: DEFAULT_ACCOUNT_ID,
398        balance: 0,
399        seq_num: SequenceNumber(0),
400        num_sub_entries: 0,
401        inflation_dest: None,
402        flags: 0,
403        home_domain: String32::from(unsafe { StringM::<32>::from_str("TEST").unwrap_unchecked() }),
404        thresholds: Thresholds([0; 4]),
405        signers: unsafe { [].try_into().unwrap_unchecked() },
406        ext: AccountEntryExt::V0,
407    }
408}
409
410fn build_invoke_contract_tx(
411    parameters: InvokeContractArgs,
412    sequence: i64,
413    fee: u32,
414    source_account_id: Uint256,
415) -> Result<Transaction, Error> {
416    let op = Operation {
417        source_account: None,
418        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
419            host_function: HostFunction::InvokeContract(parameters),
420            auth: VecM::default(),
421        }),
422    };
423    Ok(Transaction {
424        source_account: MuxedAccount::Ed25519(source_account_id),
425        fee,
426        seq_num: SequenceNumber(sequence),
427        cond: Preconditions::None,
428        memo: Memo::None,
429        operations: vec![op].try_into()?,
430        ext: TransactionExt::V0,
431    })
432}
433
434#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum, Default)]
435pub enum Send {
436    /// Send transaction if simulation indicates there are ledger writes,
437    /// published events, or auth required, otherwise return simulation result
438    #[default]
439    Default,
440    /// Do not send transaction, return simulation result
441    No,
442    /// Always send transaction
443    Yes,
444}
445
446#[derive(Debug, PartialEq)]
447enum ShouldSend {
448    DefaultNo,
449    No,
450    Yes,
451}
452
453fn has_write(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
454    Ok(!sim_res
455        .transaction_data()?
456        .resources
457        .footprint
458        .read_write
459        .is_empty())
460}
461
462fn has_published_event(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
463    Ok(sim_res.events()?.iter().any(
464        |DiagnosticEvent {
465             event: ContractEvent { type_, .. },
466             ..
467         }| matches!(type_, ContractEventType::Contract),
468    ))
469}
470
471fn has_auth(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
472    Ok(sim_res
473        .results()?
474        .iter()
475        .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty()))
476}