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