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