Skip to main content

soroban_cli/commands/contract/deploy/
wasm.rs

1use std::array::TryFromSliceError;
2use std::ffi::OsString;
3use std::fmt::Debug;
4use std::num::ParseIntError;
5
6use clap::Parser;
7use rand::Rng;
8use soroban_spec_tools::contract as contract_spec;
9
10use crate::config::address::AliasName;
11use crate::resources;
12use crate::tx::sim_sign_and_send_tx;
13use crate::xdr::{
14    AccountId, ContractExecutable, ContractIdPreimage, ContractIdPreimageFromAddress,
15    CreateContractArgs, CreateContractArgsV2, Error as XdrError, Hash, HostFunction,
16    InvokeContractArgs, InvokeHostFunctionOp, Limits, Memo, MuxedAccount, Operation, OperationBody,
17    Preconditions, PublicKey, ScAddress, SequenceNumber, Transaction, TransactionExt, Uint256,
18    VecM, WriteXdr,
19};
20
21use crate::commands::tx::fetch;
22use crate::{
23    commands::{
24        contract::{self, arg_parsing, build, id::wasm::get_contract_id, upload},
25        global,
26        txn_result::{TxnEnvelopeResult, TxnResult},
27        HEADING_TRANSACTION,
28    },
29    config::{self, data, locator, network},
30    print::Print,
31    rpc,
32    utils::{self, rpc::get_remote_wasm_from_hash},
33    wasm,
34};
35
36pub const CONSTRUCTOR_FUNCTION_NAME: &str = "__constructor";
37
38#[derive(Parser, Debug, Clone)]
39#[command(group(
40    clap::ArgGroup::new("wasm_src")
41        .required(false)
42        .args(&["wasm", "wasm_hash"]),
43))]
44#[group(skip)]
45pub struct Cmd {
46    /// WASM file to deploy. When neither --wasm nor --wasm-hash is provided
47    /// inside a Cargo workspace, builds the project automatically. One of
48    /// --wasm or --wasm-hash is required when outside a Cargo workspace.
49    #[arg(long, group = "wasm_src")]
50    pub wasm: Option<std::path::PathBuf>,
51    /// Hash of the already installed/deployed WASM file
52    #[arg(long = "wasm-hash", conflicts_with = "wasm", group = "wasm_src")]
53    pub wasm_hash: Option<String>,
54    /// Custom salt 32-byte salt for the token id
55    #[arg(long)]
56    pub salt: Option<String>,
57    #[command(flatten)]
58    pub config: config::Args,
59    #[arg(long, short = 'i', default_value = "false")]
60    /// Whether to ignore safety checks when deploying contracts
61    pub ignore_checks: bool,
62    /// The alias that will be used to save the contract's id.
63    /// Whenever used, `--alias` will always overwrite the existing contract id
64    /// configuration without asking for confirmation.
65    #[arg(long)]
66    pub alias: Option<AliasName>,
67    #[command(flatten)]
68    pub resources: resources::Args,
69    #[command(flatten)]
70    pub auth_mode: crate::auth_mode::Args,
71    /// Build the transaction and only write the base64 xdr to stdout
72    #[arg(long, help_heading = HEADING_TRANSACTION)]
73    pub build_only: bool,
74    /// If provided, will be passed to the contract's `__constructor` function with provided arguments for that function as `--arg-name value`
75    #[arg(last = true, id = "CONTRACT_CONSTRUCTOR_ARGS")]
76    pub slop: Vec<OsString>,
77    /// Package to build when auto-building without --wasm
78    #[arg(long, help_heading = "Build Options", conflicts_with = "wasm_src")]
79    pub package: Option<String>,
80    #[command(flatten)]
81    pub build_args: build::BuildArgs,
82}
83
84#[derive(thiserror::Error, Debug)]
85pub enum Error {
86    #[error(transparent)]
87    Install(#[from] upload::Error),
88
89    #[error("error parsing int: {0}")]
90    ParseIntError(#[from] ParseIntError),
91
92    #[error("internal conversion error: {0}")]
93    TryFromSliceError(#[from] TryFromSliceError),
94
95    #[error("xdr processing error: {0}")]
96    Xdr(#[from] XdrError),
97
98    #[error("cannot parse salt: {salt}")]
99    CannotParseSalt { salt: String },
100
101    #[error("cannot parse contract ID {contract_id}: {error}")]
102    CannotParseContractId {
103        contract_id: String,
104        error: stellar_strkey::DecodeError,
105    },
106
107    #[error("cannot parse WASM hash {wasm_hash}: {error}")]
108    CannotParseWasmHash {
109        wasm_hash: String,
110        error: stellar_strkey::DecodeError,
111    },
112
113    #[error("Must provide either --wasm or --wasm-hash")]
114    WasmNotProvided,
115
116    #[error(transparent)]
117    Rpc(#[from] rpc::Error),
118
119    #[error(transparent)]
120    Config(#[from] config::Error),
121
122    #[error(transparent)]
123    StrKey(#[from] stellar_strkey::DecodeError),
124
125    #[error(transparent)]
126    Infallible(#[from] std::convert::Infallible),
127
128    #[error(transparent)]
129    WasmId(#[from] contract::id::wasm::Error),
130
131    #[error(transparent)]
132    Data(#[from] data::Error),
133
134    #[error(transparent)]
135    Network(#[from] network::Error),
136
137    #[error(transparent)]
138    Wasm(#[from] wasm::Error),
139
140    #[error(transparent)]
141    Locator(#[from] locator::Error),
142
143    #[error(transparent)]
144    ContractSpec(#[from] contract_spec::Error),
145
146    #[error(transparent)]
147    ArgParse(#[from] arg_parsing::Error),
148
149    #[error("Only ed25519 accounts are allowed")]
150    OnlyEd25519AccountsAllowed,
151
152    #[error(transparent)]
153    Fee(#[from] fetch::fee::Error),
154
155    #[error(transparent)]
156    Fetch(#[from] fetch::Error),
157
158    #[error(transparent)]
159    Build(#[from] build::Error),
160
161    #[error(transparent)]
162    AuthMode(#[from] crate::auth_mode::Error),
163
164    #[error("no buildable contracts found in workspace (no packages with crate-type cdylib)")]
165    NoBuildableContracts,
166
167    #[error("--alias is not supported when deploying multiple contracts; aliases are derived from package names automatically")]
168    AliasNotSupported,
169
170    #[error("workspace package '{0}' resolves to the reserved contract alias '{0}'; rename the package, or deploy it on its own with `--package {0} --alias <name>`")]
171    ReservedPackageAlias(String),
172
173    #[error("--salt is not supported when deploying multiple contracts")]
174    SaltNotSupported,
175
176    #[error("constructor arguments are not supported when deploying multiple contracts")]
177    ConstructorArgsNotSupported,
178
179    #[error("--build-only is not supported without --wasm or --wasm-hash")]
180    BuildOnlyNotSupported,
181
182    #[error(
183        "--wasm or --wasm-hash is required when not in a Cargo workspace; no Cargo.toml found"
184    )]
185    NotInCargoProject,
186}
187
188impl Cmd {
189    pub async fn run(&self, global_args: &global::Args) -> Result<(), Error> {
190        self.auth_mode.validate_not_enforce()?;
191
192        if self.build_only && self.wasm.is_none() && self.wasm_hash.is_none() {
193            return Err(Error::BuildOnlyNotSupported);
194        }
195
196        let built_contracts = self.resolve_contracts(global_args)?;
197
198        // Aliases derived from workspace package names are assigned per-iteration
199        // inside the deploy loop, so validate them all up front: a package named
200        // after a reserved alias must fail before any contract is deployed
201        // on-chain, not partway through the loop.
202        if let Some(name) = reserved_package_alias(self.alias.as_ref(), &built_contracts) {
203            return Err(Error::ReservedPackageAlias(name));
204        }
205
206        // When --wasm-hash is used, no built contracts are returned.
207        // Deploy directly with the hash.
208        if built_contracts.is_empty() {
209            Self::run_single(self, global_args).await?;
210        } else {
211            if built_contracts.len() > 1 {
212                if self.alias.is_some() {
213                    return Err(Error::AliasNotSupported);
214                }
215
216                if self.salt.is_some() {
217                    return Err(Error::SaltNotSupported);
218                }
219
220                if !self.slop.is_empty() {
221                    return Err(Error::ConstructorArgsNotSupported);
222                }
223            }
224
225            for contract in &built_contracts {
226                let mut cmd = self.clone();
227                cmd.wasm = Some(contract.path.clone());
228
229                // When auto-building and no explicit --alias, use the
230                // package name as alias.
231                if cmd.alias.is_none() && !contract.name.is_empty() {
232                    if let Ok(alias) = contract.name.parse::<AliasName>() {
233                        cmd.alias = Some(alias);
234                    }
235                }
236
237                Self::run_single(&cmd, global_args).await?;
238            }
239        }
240        Ok(())
241    }
242
243    async fn run_single(cmd: &Cmd, global_args: &global::Args) -> Result<(), Error> {
244        // Validate the finalized alias (explicit or package-derived) at the
245        // point of use, before any on-chain work. `run` rejects a reserved
246        // package name up front to avoid a partial multi-contract deploy; this
247        // is the single guard for the single-contract and `--wasm-hash` paths.
248        if let Some(alias) = &cmd.alias {
249            crate::config::alias::validate_reserved_aliases(alias)?;
250        }
251
252        let res = cmd
253            .execute(&cmd.config, global_args.quiet, global_args.no_cache)
254            .await?
255            .to_envelope();
256
257        match res {
258            TxnEnvelopeResult::TxnEnvelope(tx) => {
259                println!("{}", tx.to_xdr_base64(Limits::none())?);
260            }
261            TxnEnvelopeResult::Res(contract) => {
262                let network = cmd.config.get_network()?;
263
264                if let Some(alias) = cmd.alias.clone() {
265                    if let Some(existing_contract) = cmd
266                        .config
267                        .locator
268                        .get_contract_id(&alias, &network.network_passphrase)?
269                    {
270                        let print = Print::new(global_args.quiet);
271                        print.warnln(format!(
272                            "Overwriting existing alias '{alias}' that currently links to contract ID: {existing_contract}"
273                        ));
274                    }
275
276                    cmd.config.locator.save_contract_id(
277                        &network.network_passphrase,
278                        &contract,
279                        &alias,
280                    )?;
281                }
282
283                println!("{contract}");
284            }
285        }
286        Ok(())
287    }
288
289    fn resolve_contracts(
290        &self,
291        global_args: &global::Args,
292    ) -> Result<Vec<build::BuiltContract>, Error> {
293        // If --wasm is explicitly provided, use it (no package name available)
294        if let Some(wasm) = &self.wasm {
295            return Ok(vec![build::BuiltContract {
296                name: String::new(),
297                path: wasm.clone(),
298            }]);
299        }
300
301        // If --wasm-hash is provided, no WASM file paths needed
302        if self.wasm_hash.is_some() {
303            return Ok(vec![]);
304        }
305
306        // Neither provided: auto-build
307        let build_cmd = build::Cmd {
308            package: self.package.clone(),
309            build_args: self.build_args.clone(),
310            ..build::Cmd::default()
311        };
312        let contracts = build_cmd.run(global_args).map_err(|e| match e {
313            build::Error::Metadata(_) => Error::NotInCargoProject,
314            other => other.into(),
315        })?;
316
317        if contracts.is_empty() {
318            return Err(Error::NoBuildableContracts);
319        }
320
321        Ok(contracts)
322    }
323
324    #[allow(clippy::too_many_lines)]
325    #[allow(unused_variables)]
326    pub async fn execute(
327        &self,
328        config: &config::Args,
329        quiet: bool,
330        no_cache: bool,
331    ) -> Result<TxnResult<stellar_strkey::Contract>, Error> {
332        self.auth_mode.validate_not_enforce()?;
333
334        let print = Print::new(quiet);
335        let wasm_hash = if let Some(wasm) = &self.wasm {
336            let is_build = self.build_only;
337            let hash = if is_build {
338                wasm::Args { wasm: wasm.clone() }.hash()?
339            } else {
340                print.infoln("Uploading contract WASM…");
341                upload::Cmd {
342                    wasm: Some(wasm.clone()),
343                    config: config.clone(),
344                    resources: self.resources.clone(),
345                    auth_mode: self.auth_mode.clone(),
346                    ignore_checks: self.ignore_checks,
347                    build_only: is_build,
348                    package: None,
349                    build_args: build::BuildArgs::default(),
350                }
351                .execute(config, quiet, no_cache)
352                .await?
353                .into_result()
354                .expect("the value (hash) is expected because it should always be available since build-only is a shared parameter")
355            };
356            hex::encode(hash)
357        } else {
358            self.wasm_hash
359                .as_ref()
360                .ok_or(Error::WasmNotProvided)?
361                .clone()
362        };
363
364        let wasm_hash = Hash(
365            utils::contract_id_from_str(&wasm_hash)
366                .map_err(|e| Error::CannotParseWasmHash {
367                    wasm_hash: wasm_hash.clone(),
368                    error: e,
369                })?
370                .0,
371        );
372
373        print.infoln(format!("Deploying contract using wasm hash {wasm_hash}").as_str());
374
375        let network = config.get_network()?;
376        let salt: [u8; 32] = match &self.salt {
377            Some(h) => soroban_spec_tools::utils::padded_hex_from_str(h, 32)
378                .map_err(|_| Error::CannotParseSalt { salt: h.clone() })?
379                .try_into()
380                .map_err(|_| Error::CannotParseSalt { salt: h.clone() })?,
381            None => rand::thread_rng().gen::<[u8; 32]>(),
382        };
383
384        let client = network.rpc_client()?;
385        let MuxedAccount::Ed25519(bytes) = config.source_account()? else {
386            return Err(Error::OnlyEd25519AccountsAllowed);
387        };
388        let source_account = AccountId(PublicKey::PublicKeyTypeEd25519(bytes));
389        let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
390            address: ScAddress::Account(source_account.clone()),
391            salt: Uint256(salt),
392        });
393        let contract_id =
394            get_contract_id(contract_id_preimage.clone(), &network.network_passphrase)?;
395        let raw_wasm = if let Some(wasm) = self.wasm.as_ref() {
396            wasm::Args { wasm: wasm.clone() }.read()?
397        } else {
398            if self.build_only {
399                return Err(Error::WasmNotProvided);
400            }
401            get_remote_wasm_from_hash(&client, &wasm_hash).await?
402        };
403        let entries = soroban_spec_tools::contract::Spec::new(&raw_wasm)?.spec;
404        let res = soroban_spec_tools::Spec::new(entries.clone().as_slice());
405        let (constructor_params, constructor_signers) =
406            if let Ok(func) = res.find_function(CONSTRUCTOR_FUNCTION_NAME) {
407                if func.inputs.is_empty() {
408                    (None, vec![])
409                } else {
410                    let mut slop = vec![OsString::from(CONSTRUCTOR_FUNCTION_NAME)];
411                    slop.extend_from_slice(&self.slop);
412                    let (_, _, invoke_args, signers) = arg_parsing::build_constructor_parameters(
413                        &stellar_strkey::Contract(contract_id.0),
414                        &slop,
415                        &entries,
416                        config,
417                    )?;
418                    (Some(invoke_args), signers)
419                }
420            } else {
421                (None, vec![])
422            };
423
424        // For network operations, verify the network passphrase
425        client
426            .verify_network_passphrase(Some(&network.network_passphrase))
427            .await?;
428
429        // Get the account sequence number
430        let account_details = client.get_account(&source_account.to_string()).await?;
431        let sequence: i64 = account_details.seq_num.into();
432        let txn = Box::new(build_create_contract_tx(
433            wasm_hash,
434            sequence + 1,
435            config.get_inclusion_fee()?,
436            source_account,
437            contract_id_preimage,
438            constructor_params.as_ref(),
439        )?);
440
441        if self.build_only {
442            print.checkln("Transaction built!");
443            return Ok(TxnResult::Txn(txn));
444        }
445
446        sim_sign_and_send_tx::<Error>(
447            &client,
448            &txn,
449            config,
450            &self.resources,
451            &constructor_signers,
452            self.auth_mode.to_rpc(),
453            quiet,
454            no_cache,
455        )
456        .await?;
457
458        if let Some(url) = utils::lab_url_for_contract(&network, &contract_id) {
459            print.linkln(url);
460        }
461        print.checkln("Deployed!");
462
463        Ok(TxnResult::Res(contract_id))
464    }
465}
466
467/// Returns the name of the first built contract whose package-derived alias
468/// would be reserved. Explicit `--alias` is validated separately (and rejected
469/// entirely for multi-contract deploys), so an explicit alias short-circuits.
470fn reserved_package_alias(
471    explicit_alias: Option<&AliasName>,
472    built_contracts: &[build::BuiltContract],
473) -> Option<String> {
474    if explicit_alias.is_some() {
475        return None;
476    }
477
478    built_contracts.iter().find_map(|contract| {
479        (!contract.name.is_empty() && crate::config::alias::is_reserved(&contract.name))
480            .then(|| contract.name.clone())
481    })
482}
483
484fn build_create_contract_tx(
485    wasm_hash: Hash,
486    sequence: i64,
487    fee: u32,
488    key: AccountId,
489    contract_id_preimage: ContractIdPreimage,
490    constructor_params: Option<&InvokeContractArgs>,
491) -> Result<Transaction, Error> {
492    let op = if let Some(InvokeContractArgs { args, .. }) = constructor_params {
493        Operation {
494            source_account: None,
495            body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
496                host_function: HostFunction::CreateContractV2(CreateContractArgsV2 {
497                    contract_id_preimage,
498                    executable: ContractExecutable::Wasm(wasm_hash),
499                    constructor_args: args.clone(),
500                }),
501                auth: VecM::default(),
502            }),
503        }
504    } else {
505        Operation {
506            source_account: None,
507            body: OperationBody::InvokeHostFunction(InvokeHostFunctionOp {
508                host_function: HostFunction::CreateContract(CreateContractArgs {
509                    contract_id_preimage,
510                    executable: ContractExecutable::Wasm(wasm_hash),
511                }),
512                auth: VecM::default(),
513            }),
514        }
515    };
516    let tx = Transaction {
517        source_account: key.into(),
518        fee,
519        seq_num: SequenceNumber(sequence),
520        cond: Preconditions::None,
521        memo: Memo::None,
522        operations: vec![op].try_into()?,
523        ext: TransactionExt::V0,
524    };
525
526    Ok(tx)
527}
528
529#[cfg(test)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn test_build_create_contract() {
535        let hash = hex::decode("0000000000000000000000000000000000000000000000000000000000000000")
536            .unwrap()
537            .try_into()
538            .unwrap();
539        let salt = [0u8; 32];
540        let key =
541            &utils::parse_secret_key("SBFGFF27Y64ZUGFAIG5AMJGQODZZKV2YQKAVUUN4HNE24XZXD2OEUVUP")
542                .unwrap();
543        let source_account = AccountId(PublicKey::PublicKeyTypeEd25519(Uint256(
544            key.verifying_key().to_bytes(),
545        )));
546
547        let contract_id_preimage = ContractIdPreimage::Address(ContractIdPreimageFromAddress {
548            address: ScAddress::Account(source_account.clone()),
549            salt: Uint256(salt),
550        });
551
552        let result = build_create_contract_tx(
553            Hash(hash),
554            300,
555            1,
556            source_account,
557            contract_id_preimage,
558            None,
559        );
560
561        assert!(result.is_ok());
562    }
563
564    fn built(name: &str) -> build::BuiltContract {
565        build::BuiltContract {
566            name: name.to_string(),
567            path: std::path::PathBuf::new(),
568        }
569    }
570
571    #[test]
572    fn reserved_package_alias_flags_reserved_package_before_deploy() {
573        let native = crate::config::alias::NATIVE;
574        let contracts = [built("adapter"), built(native), built("token")];
575
576        assert_eq!(
577            reserved_package_alias(None, &contracts),
578            Some(native.to_string())
579        );
580    }
581
582    #[test]
583    fn reserved_package_alias_ignores_regular_packages() {
584        let contracts = [built("adapter"), built("token")];
585
586        assert_eq!(reserved_package_alias(None, &contracts), None);
587    }
588
589    #[test]
590    fn reserved_package_alias_skipped_with_explicit_alias() {
591        // An explicit --alias is validated on its own path; a reserved package
592        // name is irrelevant because the derived alias is never used.
593        let alias = "my-contract".parse::<AliasName>().unwrap();
594        let contracts = [built(crate::config::alias::NATIVE)];
595
596        assert_eq!(reserved_package_alias(Some(&alias), &contracts), None);
597    }
598}