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