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        // `self.fee.build_only` will be checked again below and the fn will return a TxnResult::Txn
248        // if the user passed the --build-only flag
249        let (should_send, cached_simulation) = if self.fee.build_only {
250            (ShouldSend::Yes, None)
251        } else {
252            let assembled = self
253                .simulate(&host_function_params, &default_account_entry(), &client)
254                .await?;
255            let should_send = self.should_send_tx(&assembled.sim_res)?;
256            (should_send, Some(assembled))
257        };
258
259        let account_details = if should_send == ShouldSend::Yes {
260            client
261                .verify_network_passphrase(Some(&network.network_passphrase))
262                .await?;
263
264            client
265                .get_account(&config.source_account().await?.to_string())
266                .await?
267        } else {
268            if should_send == ShouldSend::DefaultNo {
269                print.infoln(
270                    "Simulation identified as read-only. Send by rerunning with `--send=yes`.",
271                );
272            }
273
274            let assembled = cached_simulation.expect(
275                "cached_simulation should be available when should_send != Yes and not build_only",
276            );
277            let sim_res = assembled.sim_response();
278            let return_value = sim_res.results()?;
279            let events = sim_res.events()?;
280
281            crate::log::event::all(&events);
282            crate::log::event::contract(&events, &print);
283
284            return Ok(output_to_string(&spec, &return_value[0].xdr, &function)?);
285        };
286
287        let sequence: i64 = account_details.seq_num.into();
288        let AccountId(PublicKey::PublicKeyTypeEd25519(account_id)) = account_details.account_id;
289
290        let tx = Box::new(build_invoke_contract_tx(
291            host_function_params.clone(),
292            sequence + 1,
293            self.fee.fee,
294            account_id,
295        )?);
296
297        if self.fee.build_only {
298            return Ok(TxnResult::Txn(tx));
299        }
300
301        let txn = simulate_and_assemble_transaction(&client, &tx).await?;
302        let assembled = self.fee.apply_to_assembled_txn(txn);
303        let mut txn = Box::new(assembled.transaction().clone());
304        let sim_res = assembled.sim_response();
305
306        if global_args.is_none_or(|a| !a.no_cache) {
307            data::write(sim_res.clone().into(), &network.rpc_uri()?)?;
308        }
309
310        let global::Args { no_cache, .. } = global_args.cloned().unwrap_or_default();
311
312        // Need to sign all auth entries
313        if let Some(tx) = config.sign_soroban_authorizations(&txn, &signers).await? {
314            txn = Box::new(tx);
315        }
316
317        let res = client
318            .send_transaction_polling(&config.sign(*txn).await?)
319            .await?;
320
321        if !no_cache {
322            data::write(res.clone().try_into()?, &network.rpc_uri()?)?;
323        }
324
325        let return_value = res.return_value()?;
326        let events = extract_events(&res.result_meta.unwrap_or_default());
327
328        crate::log::event::all(&events);
329        crate::log::event::contract(&events, &print);
330
331        Ok(output_to_string(&spec, &return_value, &function)?)
332    }
333}
334
335const DEFAULT_ACCOUNT_ID: AccountId = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256([0; 32])));
336
337fn default_account_entry() -> AccountEntry {
338    AccountEntry {
339        account_id: DEFAULT_ACCOUNT_ID,
340        balance: 0,
341        seq_num: SequenceNumber(0),
342        num_sub_entries: 0,
343        inflation_dest: None,
344        flags: 0,
345        home_domain: String32::from(unsafe { StringM::<32>::from_str("TEST").unwrap_unchecked() }),
346        thresholds: Thresholds([0; 4]),
347        signers: unsafe { [].try_into().unwrap_unchecked() },
348        ext: AccountEntryExt::V0,
349    }
350}
351
352fn build_invoke_contract_tx(
353    parameters: InvokeContractArgs,
354    sequence: i64,
355    fee: u32,
356    source_account_id: Uint256,
357) -> Result<Transaction, Error> {
358    let op = Operation {
359        source_account: None,
360        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
361            host_function: HostFunction::InvokeContract(parameters),
362            auth: VecM::default(),
363        }),
364    };
365    Ok(Transaction {
366        source_account: MuxedAccount::Ed25519(source_account_id),
367        fee,
368        seq_num: SequenceNumber(sequence),
369        cond: Preconditions::None,
370        memo: Memo::None,
371        operations: vec![op].try_into()?,
372        ext: TransactionExt::V0,
373    })
374}
375
376#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, ValueEnum, Default)]
377pub enum Send {
378    /// Send transaction if simulation indicates there are ledger writes,
379    /// published events, or auth required, otherwise return simulation result
380    #[default]
381    Default,
382    /// Do not send transaction, return simulation result
383    No,
384    /// Always send transaction
385    Yes,
386}
387
388#[derive(Debug, PartialEq)]
389enum ShouldSend {
390    DefaultNo,
391    No,
392    Yes,
393}
394
395fn has_write(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
396    Ok(!sim_res
397        .transaction_data()?
398        .resources
399        .footprint
400        .read_write
401        .is_empty())
402}
403
404fn has_published_event(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
405    Ok(sim_res.events()?.iter().any(
406        |DiagnosticEvent {
407             event: ContractEvent { type_, .. },
408             ..
409         }| matches!(type_, ContractEventType::Contract),
410    ))
411}
412
413fn has_auth(sim_res: &SimulateTransactionResponse) -> Result<bool, Error> {
414    Ok(sim_res
415        .results()?
416        .iter()
417        .any(|SimulateHostFunctionResult { auth, .. }| !auth.is_empty()))
418}