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