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