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