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