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