Skip to main content

soroban_cli/commands/contract/
upload.rs

1use std::array::TryFromSliceError;
2use std::fmt::Debug;
3use std::num::ParseIntError;
4use std::path::{Path, PathBuf};
5
6use crate::xdr::{
7    self, ContractCodeEntryExt, Error as XdrError, Hash, HostFunction, InvokeHostFunctionOp,
8    LedgerEntryData, Limits, OperationBody, ReadXdr, ScMetaEntry, ScMetaV0, Transaction,
9    TransactionResult, TransactionResultResult, VecM, WriteXdr,
10};
11use clap::Parser;
12
13use super::{build, restore};
14use crate::commands::tx::fetch;
15use crate::{
16    commands::{
17        global,
18        txn_result::{TxnEnvelopeResult, TxnResult},
19        HEADING_TRANSACTION,
20    },
21    config::{self, data, network},
22    key,
23    print::Print,
24    rpc,
25    tx::{
26        builder::{self, TxExt},
27        sim_sign_and_send_tx,
28    },
29    utils, wasm,
30};
31
32const CONTRACT_META_SDK_KEY: &str = "rssdkver";
33const PUBLIC_NETWORK_PASSPHRASE: &str = "Public Global Stellar Network ; September 2015";
34
35#[derive(Parser, Debug, Clone)]
36#[group(skip)]
37pub struct Cmd {
38    #[command(flatten)]
39    pub config: config::Args,
40
41    #[command(flatten)]
42    pub resources: crate::resources::Args,
43
44    #[command(flatten)]
45    pub auth_mode: crate::auth_mode::Args,
46
47    /// Path to wasm binary. When omitted inside a Cargo workspace, builds the
48    /// project automatically. Required when outside a Cargo workspace.
49    #[arg(long)]
50    pub wasm: Option<PathBuf>,
51
52    #[arg(long, short = 'i', default_value = "false")]
53    /// Whether to ignore safety checks when deploying contracts
54    pub ignore_checks: bool,
55
56    /// Build the transaction and only write the base64 xdr to stdout
57    #[arg(long, help_heading = HEADING_TRANSACTION)]
58    pub build_only: bool,
59
60    /// Package to build when --wasm is not provided
61    #[arg(long, help_heading = "Build Options", conflicts_with = "wasm")]
62    pub package: Option<String>,
63    #[command(flatten)]
64    pub build_args: build::BuildArgs,
65}
66
67#[derive(thiserror::Error, Debug)]
68pub enum Error {
69    #[error("error parsing int: {0}")]
70    ParseIntError(#[from] ParseIntError),
71
72    #[error("internal conversion error: {0}")]
73    TryFromSliceError(#[from] TryFromSliceError),
74
75    #[error("xdr processing error: {0}")]
76    Xdr(#[from] XdrError),
77
78    #[error(transparent)]
79    Rpc(#[from] rpc::Error),
80
81    #[error(transparent)]
82    Config(#[from] config::Error),
83
84    #[error(transparent)]
85    Wasm(#[from] wasm::Error),
86
87    #[error("unexpected ({length}) simulate transaction result length")]
88    UnexpectedSimulateTransactionResultSize { length: usize },
89
90    #[error(transparent)]
91    Restore(#[from] restore::Error),
92
93    #[error("cannot parse WASM file {wasm}: {error}")]
94    CannotParseWasm {
95        wasm: std::path::PathBuf,
96        error: wasm::Error,
97    },
98
99    #[error("the deployed smart contract {wasm} was built with Soroban Rust SDK v{version}, a release candidate version not intended for use with the Stellar Public Network. To deploy anyway, use --ignore-checks")]
100    ContractCompiledWithReleaseCandidateSdk {
101        wasm: std::path::PathBuf,
102        version: String,
103    },
104
105    #[error(transparent)]
106    Network(#[from] network::Error),
107
108    #[error(transparent)]
109    Data(#[from] data::Error),
110
111    #[error(transparent)]
112    Builder(#[from] builder::Error),
113
114    #[error(transparent)]
115    Fee(#[from] fetch::fee::Error),
116
117    #[error(transparent)]
118    Fetch(#[from] fetch::Error),
119
120    #[error(transparent)]
121    Build(#[from] build::Error),
122
123    #[error(transparent)]
124    AuthMode(#[from] crate::auth_mode::Error),
125
126    #[error("no buildable contracts found in workspace (no packages with crate-type cdylib)")]
127    NoBuildableContracts,
128
129    #[error("no WASM file specified; use --wasm to provide a contract file")]
130    WasmNotProvided,
131
132    #[error("--build-only is not supported without --wasm")]
133    BuildOnlyNotSupported,
134
135    #[error("--wasm is required when not in a Cargo workspace; no Cargo.toml found")]
136    NotInCargoProject,
137}
138
139impl Cmd {
140    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
141        self.auth_mode.validate_not_enforce()?;
142
143        if self.build_only && self.wasm.is_none() {
144            return Err(Error::BuildOnlyNotSupported);
145        }
146
147        let wasm_paths = self.resolve_wasm_paths(global_args)?;
148
149        for wasm_path in &wasm_paths {
150            let res = self
151                .upload_wasm(
152                    wasm_path,
153                    &self.config,
154                    global_args.quiet,
155                    global_args.no_cache,
156                )
157                .await?
158                .to_envelope();
159
160            match res {
161                TxnEnvelopeResult::TxnEnvelope(tx) => {
162                    println!("{}", tx.to_xdr_base64(Limits::none())?);
163                }
164                TxnEnvelopeResult::Res(hash) => println!("{}", hex::encode(hash)),
165            }
166        }
167        Ok(())
168    }
169
170    /// Programmatic API for uploading a single WASM file.
171    /// Expects `self.wasm` to be set. Used by deploy command internally.
172    #[allow(clippy::too_many_lines)]
173    #[allow(unused_variables)]
174    pub async fn execute(
175        &self,
176        config: &config::Args,
177        quiet: bool,
178        no_cache: bool,
179    ) -> Result<TxnResult<Hash>, Error> {
180        let wasm_path = self.wasm.clone().ok_or(Error::WasmNotProvided)?;
181        self.upload_wasm(&wasm_path, config, quiet, no_cache).await
182    }
183
184    fn resolve_wasm_paths(&self, global_args: &global::Args) -> Result<Vec<PathBuf>, Error> {
185        if let Some(wasm) = &self.wasm {
186            Ok(vec![wasm.clone()])
187        } else {
188            let build_cmd = build::Cmd {
189                package: self.package.clone(),
190                build_args: self.build_args.clone(),
191                ..build::Cmd::default()
192            };
193            let contracts = build_cmd.run(global_args).map_err(|e| match e {
194                build::Error::Metadata(_) => Error::NotInCargoProject,
195                other => other.into(),
196            })?;
197
198            if contracts.is_empty() {
199                return Err(Error::NoBuildableContracts);
200            }
201
202            Ok(contracts.into_iter().map(|c| c.path).collect())
203        }
204    }
205
206    #[allow(clippy::too_many_lines)]
207    #[allow(unused_variables)]
208    async fn upload_wasm(
209        &self,
210        wasm_path: &Path,
211        config: &config::Args,
212        quiet: bool,
213        no_cache: bool,
214    ) -> Result<TxnResult<Hash>, Error> {
215        self.auth_mode.validate_not_enforce()?;
216
217        let print = Print::new(quiet);
218        let wasm_path = wasm_path.to_path_buf();
219        let wasm_args = wasm::Args {
220            wasm: wasm_path.clone(),
221        };
222        let contract = wasm_args.read()?;
223        let network = config.get_network()?;
224        let client = network.rpc_client()?;
225        client
226            .verify_network_passphrase(Some(&network.network_passphrase))
227            .await?;
228        let wasm_spec = &wasm_args.parse().map_err(|e| Error::CannotParseWasm {
229            wasm: wasm_path.clone(),
230            error: e,
231        })?;
232
233        // Check Rust SDK version if using the public network.
234        if let Some(rs_sdk_ver) = get_contract_meta_sdk_version(wasm_spec) {
235            if rs_sdk_ver.contains("rc")
236                && !self.ignore_checks
237                && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE
238            {
239                return Err(Error::ContractCompiledWithReleaseCandidateSdk {
240                    wasm: wasm_path.clone(),
241                    version: rs_sdk_ver,
242                });
243            } else if rs_sdk_ver.contains("rc")
244                && network.network_passphrase == PUBLIC_NETWORK_PASSPHRASE
245            {
246                tracing::warn!("the deployed smart contract {path} was built with Soroban Rust SDK v{rs_sdk_ver}, a release candidate version not intended for use with the Stellar Public Network", path = wasm_path.display());
247            }
248        }
249
250        // Get the account sequence number
251        let source_account = config.source_account()?;
252
253        let account_details = client
254            .get_account(&source_account.clone().to_string())
255            .await?;
256        let sequence: i64 = account_details.seq_num.into();
257
258        let (tx_without_preflight, hash) = build_install_contract_code_tx(
259            &contract,
260            sequence + 1,
261            config.get_inclusion_fee()?,
262            &source_account,
263        )?;
264
265        if self.build_only {
266            return Ok(TxnResult::Txn(Box::new(tx_without_preflight)));
267        }
268
269        let should_check = true;
270
271        if should_check {
272            let code_key =
273                xdr::LedgerKey::ContractCode(xdr::LedgerKeyContractCode { hash: hash.clone() });
274            let contract_data = client.get_ledger_entries(&[code_key]).await?;
275
276            // Skip install if the contract is already installed, and the contract has an extension version that isn't V0.
277            // In protocol 21 extension V1 was added that stores additional information about a contract making execution
278            // of the contract cheaper. So if folks want to reinstall we should let them which is why the install will still
279            // go ahead if the contract has a V0 extension.
280            if let Some(entries) = contract_data.entries {
281                if let Some(entry_result) = entries.first() {
282                    let entry: LedgerEntryData =
283                        LedgerEntryData::from_xdr_base64(&entry_result.xdr, Limits::none())?;
284
285                    match &entry {
286                        LedgerEntryData::ContractCode(code) => {
287                            // Skip reupload if this isn't V0 because V1 extension already
288                            // exists.
289                            if code.ext.ne(&ContractCodeEntryExt::V0) {
290                                print.infoln("Skipping install because wasm already installed");
291                                return Ok(TxnResult::Res(hash));
292                            }
293                        }
294                        _ => {
295                            tracing::warn!("Entry retrieved should be of type ContractCode");
296                        }
297                    }
298                }
299            }
300        }
301
302        let txn_resp = sim_sign_and_send_tx::<Error>(
303            &client,
304            &tx_without_preflight,
305            config,
306            &self.resources,
307            &[],
308            self.auth_mode.to_rpc(),
309            quiet,
310            no_cache,
311        )
312        .await?;
313
314        // Currently internal errors are not returned if the contract code is expired
315        if let Some(TransactionResult {
316            result: TransactionResultResult::TxInternalError,
317            ..
318        }) = txn_resp.result
319        {
320            // Now just need to restore it and don't have to install again
321            restore::Cmd {
322                key: key::Args {
323                    contract_id: None,
324                    key: None,
325                    key_xdr: None,
326                    wasm: Some(wasm_path.clone()),
327                    wasm_hash: None,
328                    durability: super::Durability::Persistent,
329                },
330                config: config.clone(),
331                resources: self.resources.clone(),
332                ledgers_to_extend: None,
333                ttl_ledger_only: true,
334                build_only: self.build_only,
335            }
336            .execute(config, quiet, no_cache)
337            .await?;
338        }
339
340        if !no_cache {
341            data::write_spec(&hash.to_string(), &wasm_spec.spec)?;
342        }
343
344        Ok(TxnResult::Res(hash))
345    }
346}
347
348fn get_contract_meta_sdk_version(wasm_spec: &soroban_spec_tools::contract::Spec) -> Option<String> {
349    let rs_sdk_version_option = if let Some(_meta) = &wasm_spec.meta_base64 {
350        wasm_spec.meta.iter().find(|entry| match entry {
351            ScMetaEntry::ScMetaV0(ScMetaV0 { key, .. }) => {
352                key.to_utf8_string_lossy().contains(CONTRACT_META_SDK_KEY)
353            }
354        })
355    } else {
356        None
357    };
358
359    if let Some(rs_sdk_version_entry) = &rs_sdk_version_option {
360        match rs_sdk_version_entry {
361            ScMetaEntry::ScMetaV0(ScMetaV0 { val, .. }) => {
362                return Some(val.to_utf8_string_lossy());
363            }
364        }
365    }
366
367    None
368}
369
370pub(crate) fn build_install_contract_code_tx(
371    source_code: &[u8],
372    sequence: i64,
373    fee: u32,
374    source: &xdr::MuxedAccount,
375) -> Result<(Transaction, Hash), Error> {
376    let hash = utils::contract_hash(source_code)?;
377
378    let op = xdr::Operation {
379        source_account: None,
380        body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
381            host_function: HostFunction::UploadContractWasm(source_code.try_into()?),
382            auth: VecM::default(),
383        }),
384    };
385    let tx = Transaction::new_tx(source.clone(), fee, sequence, op);
386
387    Ok((tx, hash))
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn test_build_install_contract_code() {
396        let result = build_install_contract_code_tx(
397            b"foo",
398            300,
399            1,
400            &stellar_strkey::ed25519::PublicKey::from_payload(
401                utils::parse_secret_key("SBFGFF27Y64ZUGFAIG5AMJGQODZZKV2YQKAVUUN4HNE24XZXD2OEUVUP")
402                    .unwrap()
403                    .verifying_key()
404                    .as_bytes(),
405            )
406            .unwrap()
407            .to_string()
408            .parse()
409            .unwrap(),
410        );
411
412        assert!(result.is_ok());
413    }
414}