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