stellar_scaffold_cli/commands/build/
clients.rs

1#![allow(clippy::struct_excessive_bools)]
2use super::env_toml::Network;
3use crate::arg_parsing;
4use crate::arg_parsing::ArgParser;
5use crate::commands::build::clients::Error::UpgradeArgsError;
6use crate::commands::build::env_toml;
7use indexmap::IndexMap;
8use regex::Regex;
9use serde_json;
10use shlex::split;
11use std::hash::Hash;
12use std::path::Path;
13use std::process::Command;
14use std::{fmt::Debug, path::PathBuf};
15use stellar_cli::{
16    commands as cli,
17    commands::contract::info::shared::{
18        self as contract_spec, fetch, Args as FetchArgs, Error as FetchError,
19    },
20    commands::NetworkRunnable,
21    print::Print,
22    utils::contract_hash,
23    utils::contract_spec::Spec,
24    CommandParser,
25};
26use stellar_strkey::{self, Contract};
27use stellar_xdr::curr::ScSpecEntry::FunctionV0;
28use stellar_xdr::curr::{Error as xdrError, ScSpecEntry, ScSpecTypeBytesN, ScSpecTypeDef};
29
30#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
31pub enum ScaffoldEnv {
32    Development,
33    Testing,
34    Staging,
35    Production,
36}
37
38impl std::fmt::Display for ScaffoldEnv {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        write!(f, "{}", format!("{self:?}").to_lowercase())
41    }
42}
43
44#[derive(clap::Args, Clone, Debug)]
45pub struct Args {
46    #[arg(env = "STELLAR_SCAFFOLD_ENV", value_enum)]
47    pub env: Option<ScaffoldEnv>,
48    #[arg(skip)]
49    pub workspace_root: Option<std::path::PathBuf>,
50    /// Directory where wasm files are located
51    #[arg(skip)]
52    pub out_dir: Option<std::path::PathBuf>,
53    #[arg(skip)]
54    pub global_args: Option<stellar_cli::commands::global::Args>,
55}
56
57#[derive(thiserror::Error, Debug)]
58pub enum Error {
59    #[error(transparent)]
60    EnvironmentsToml(#[from] env_toml::Error),
61    #[error("⛔ ️invalid network: must either specify a network name or both network_passphrase and rpc_url"
62    )]
63    MalformedNetwork,
64    #[error(transparent)]
65    ParsingNetwork(#[from] cli::network::Error),
66    #[error(transparent)]
67    GeneratingKey(#[from] cli::keys::generate::Error),
68    #[error("⛔ ️can only have one default account; marked as default: {0:?}")]
69    OnlyOneDefaultAccount(Vec<String>),
70    #[error(transparent)]
71    InvalidPublicKey(#[from] cli::keys::public_key::Error),
72    #[error(transparent)]
73    AddressParsing(#[from] stellar_cli::config::address::Error),
74    #[error("⛔ ️you need to provide at least one account, to use as the source account for contract deployment and other operations"
75    )]
76    NeedAtLeastOneAccount,
77    #[error("⛔ ️No contract named {0:?}")]
78    BadContractName(String),
79    #[error("⛔ ️Invalid contract ID: {0:?}")]
80    InvalidContractID(String),
81    #[error("⛔ ️An ID must be set for a contract in production or staging. E.g. <name>.id = C...")]
82    MissingContractID(String),
83    #[error("⛔ ️Unable to parse script: {0:?}")]
84    ScriptParseFailure(String),
85    #[error(transparent)]
86    RpcClient(#[from] soroban_rpc::Error),
87    #[error("⛔ ️Failed to execute subcommand: {0:?}\n{1:?}")]
88    SubCommandExecutionFailure(String, String),
89    #[error(transparent)]
90    ContractInstall(#[from] cli::contract::upload::Error),
91    #[error(transparent)]
92    ContractDeploy(#[from] cli::contract::deploy::wasm::Error),
93    #[error(transparent)]
94    ContractBindings(#[from] cli::contract::bindings::typescript::Error),
95    #[error(transparent)]
96    ContractFetch(#[from] cli::contract::fetch::Error),
97    #[error(transparent)]
98    ConfigLocator(#[from] stellar_cli::config::locator::Error),
99    #[error(transparent)]
100    ConfigNetwork(#[from] stellar_cli::config::network::Error),
101    #[error(transparent)]
102    ContractInvoke(#[from] cli::contract::invoke::Error),
103    #[error(transparent)]
104    ContractInfo(#[from] cli::contract::info::interface::Error),
105    #[error(transparent)]
106    Clap(#[from] clap::Error),
107    #[error(transparent)]
108    WasmHash(#[from] xdrError),
109    #[error(transparent)]
110    Io(#[from] std::io::Error),
111    #[error(transparent)]
112    Json(#[from] serde_json::Error),
113    #[error("⛔ ️Failed to run npm command in {0:?}: {1:?}")]
114    NpmCommandFailure(std::path::PathBuf, String),
115    #[error(transparent)]
116    AccountFund(#[from] cli::keys::fund::Error),
117    #[error("Failed to get upgrade operator: {0:?}")]
118    UpgradeArgsError(arg_parsing::Error),
119    #[error(transparent)]
120    FetchError(#[from] FetchError),
121    #[error(transparent)]
122    SpecError(#[from] stellar_cli::get_spec::contract_spec::Error),
123}
124
125impl Args {
126    fn printer(&self) -> Print {
127        Print::new(self.global_args.as_ref().is_some_and(|args| args.quiet))
128    }
129
130    pub async fn run(&self, package_names: Vec<String>) -> Result<(), Error> {
131        let workspace_root = self
132            .workspace_root
133            .as_ref()
134            .expect("workspace_root must be set before running");
135
136        let Some(current_env) = env_toml::Environment::get(
137            workspace_root,
138            &self.stellar_scaffold_env(ScaffoldEnv::Development),
139        )?
140        else {
141            return Ok(());
142        };
143
144        self.add_network_to_env(&current_env.network)?;
145        // Create the '.config' directory if it doesn't exist
146        std::fs::create_dir_all(workspace_root.join(".config/stellar"))
147            .map_err(stellar_cli::config::locator::Error::Io)?;
148        self.clone()
149            .handle_accounts(current_env.accounts.as_deref(), &current_env.network)
150            .await?;
151        self.clone()
152            .handle_contracts(
153                current_env.contracts.as_ref(),
154                package_names,
155                &current_env.network,
156            )
157            .await?;
158
159        Ok(())
160    }
161
162    fn stellar_scaffold_env(&self, default: ScaffoldEnv) -> String {
163        self.env.unwrap_or(default).to_string().to_lowercase()
164    }
165
166    /// Parse the network settings from the environments.toml file and set `STELLAR_RPC_URL` and
167    /// `STELLAR_NETWORK_PASSPHRASE`.
168    ///
169    /// We could set `STELLAR_NETWORK` instead, but when importing contracts, we want to hard-code
170    /// the network passphrase. So if given a network name, we use soroban-cli to fetch the RPC url
171    /// & passphrase for that named network, and still set the environment variables.
172    fn add_network_to_env(&self, network: &env_toml::Network) -> Result<(), Error> {
173        let printer = self.printer();
174        match &network {
175            Network {
176                name: Some(name), ..
177            } => {
178                let stellar_cli::config::network::Network {
179                    rpc_url,
180                    network_passphrase,
181                    ..
182                } = (stellar_cli::config::network::Args {
183                    network: Some(name.clone()),
184                    rpc_url: None,
185                    network_passphrase: None,
186                    rpc_headers: Vec::new(),
187                })
188                .get(&stellar_cli::config::locator::Args {
189                    global: false,
190                    config_dir: None,
191                })?;
192                printer.infoln(format!("Using {name} network"));
193                std::env::set_var("STELLAR_RPC_URL", rpc_url);
194                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", network_passphrase);
195            }
196            Network {
197                rpc_url: Some(rpc_url),
198                network_passphrase: Some(passphrase),
199                ..
200            } => {
201                std::env::set_var("STELLAR_RPC_URL", rpc_url);
202                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", passphrase);
203                printer.infoln(format!("Using network at {rpc_url}"));
204            }
205            _ => return Err(Error::MalformedNetwork),
206        }
207
208        Ok(())
209    }
210
211    fn get_network_args(network: &Network) -> stellar_cli::config::network::Args {
212        stellar_cli::config::network::Args {
213            rpc_url: network.rpc_url.clone(),
214            network_passphrase: network.network_passphrase.clone(),
215            network: network.name.clone(),
216            rpc_headers: network.rpc_headers.clone().unwrap_or_default(),
217        }
218    }
219
220    fn get_config_dir(&self) -> PathBuf {
221        self.workspace_root
222            .as_ref()
223            .expect("workspace_root not set")
224            .join(".config")
225            .join("stellar")
226    }
227
228    fn get_config_locator(&self) -> stellar_cli::config::locator::Args {
229        let config_dir = Some(self.get_config_dir());
230        stellar_cli::config::locator::Args {
231            global: false,
232            config_dir,
233        }
234    }
235
236    fn get_contract_alias(
237        &self,
238        name: &str,
239    ) -> Result<Option<Contract>, stellar_cli::config::locator::Error> {
240        let config_dir = self.get_config_locator();
241        let network_passphrase = std::env::var("STELLAR_NETWORK_PASSPHRASE")
242            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
243        config_dir.get_contract_id(name, &network_passphrase)
244    }
245
246    async fn get_contract_hash(
247        &self,
248        contract_id: &Contract,
249        network: &Network,
250    ) -> Result<Option<String>, Error> {
251        let result = cli::contract::fetch::Cmd {
252            contract_id: stellar_cli::config::UnresolvedContract::Resolved(*contract_id),
253            out_file: None,
254            locator: self.get_config_locator(),
255            network: Self::get_network_args(network),
256        }
257        .run_against_rpc_server(self.global_args.as_ref(), None)
258        .await;
259
260        match result {
261            Ok(result) => {
262                let ctrct_hash = contract_hash(&result)?;
263                Ok(Some(hex::encode(ctrct_hash)))
264            }
265            Err(e) => {
266                if e.to_string().contains("Contract not found") {
267                    Ok(None)
268                } else {
269                    Err(Error::ContractFetch(e))
270                }
271            }
272        }
273    }
274
275    fn save_contract_alias(
276        &self,
277        name: &str,
278        contract_id: &Contract,
279        network: &Network,
280    ) -> Result<(), stellar_cli::config::locator::Error> {
281        let config_dir = self.get_config_locator();
282        let passphrase = network
283            .network_passphrase
284            .clone()
285            .expect("You must set a network passphrase.");
286        config_dir.save_contract_id(&passphrase, contract_id, name)
287    }
288
289    fn create_contract_template(&self, name: &str, contract_id: &str) -> Result<(), Error> {
290        let allow_http = if ["development", "test"]
291            .contains(&self.stellar_scaffold_env(ScaffoldEnv::Production).as_str())
292        {
293            "\n  allowHttp: true,"
294        } else {
295            ""
296        };
297        let network = std::env::var("STELLAR_NETWORK_PASSPHRASE")
298            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
299        let template = format!(
300            r"import * as Client from '{name}';
301import {{ rpcUrl }} from './util';
302
303export default new Client.Client({{
304  networkPassphrase: '{network}',
305  contractId: '{contract_id}',
306  rpcUrl,{allow_http}
307  publicKey: undefined,
308}});
309"
310        );
311        let workspace_root = self
312            .workspace_root
313            .as_ref()
314            .expect("workspace_root not set");
315        let path = workspace_root.join(format!("src/contracts/{name}.ts"));
316        std::fs::write(path, template)?;
317        Ok(())
318    }
319
320    async fn generate_contract_bindings(&self, name: &str, contract_id: &str) -> Result<(), Error> {
321        let printer = self.printer();
322        printer.infoln(format!("Binding {name:?} contract"));
323        let workspace_root = self
324            .workspace_root
325            .as_ref()
326            .expect("workspace_root not set");
327        let final_output_dir = workspace_root.join(format!("packages/{name}"));
328
329        // Create a temporary directory for building the new client
330        let temp_dir = workspace_root.join(format!("target/packages/{name}"));
331        let temp_dir_display = temp_dir.display();
332        cli::contract::bindings::typescript::Cmd::parse_arg_vec(&[
333            "--contract-id",
334            contract_id,
335            "--output-dir",
336            temp_dir.to_str().expect("we do not support non-utf8 paths"),
337            "--config-dir",
338            self.get_config_dir()
339                .to_str()
340                .expect("we do not support non-utf8 paths"),
341            "--overwrite",
342        ])?
343        .run_against_rpc_server(self.global_args.as_ref(), None)
344        .await?;
345
346        // Run `npm i` in the temp directory
347        printer.infoln(format!("Running 'npm install' in {temp_dir_display:?}"));
348        let output = std::process::Command::new("npm")
349            .current_dir(&temp_dir)
350            .arg("install")
351            .arg("--loglevel=error") // Reduce noise from warnings
352            .arg("--no-workspaces") // fix issue where stellar sometimes isnt installed locally causing tsc to fail
353            .output()?;
354
355        if !output.status.success() {
356            // Clean up temp directory on failure
357            let _ = std::fs::remove_dir_all(&temp_dir);
358            return Err(Error::NpmCommandFailure(
359                temp_dir.clone(),
360                format!(
361                    "npm install failed with status: {:?}\nError: {}",
362                    output.status.code(),
363                    String::from_utf8_lossy(&output.stderr)
364                ),
365            ));
366        }
367        printer.checkln(format!("'npm install' succeeded in {temp_dir_display}"));
368
369        printer.infoln(format!("Running 'npm run build' in {temp_dir_display}"));
370        let output = std::process::Command::new("npm")
371            .current_dir(&temp_dir)
372            .arg("run")
373            .arg("build")
374            .arg("--loglevel=error") // Reduce noise from warnings
375            .output()?;
376
377        if !output.status.success() {
378            // Clean up temp directory on failure
379            let _ = std::fs::remove_dir_all(&temp_dir);
380            return Err(Error::NpmCommandFailure(
381                temp_dir.clone(),
382                format!(
383                    "npm run build failed with status: {:?}\nError: {}",
384                    output.status.code(),
385                    String::from_utf8_lossy(&output.stderr)
386                ),
387            ));
388        }
389        printer.checkln(format!("'npm run build' succeeded in {temp_dir_display}"));
390
391        // Now atomically replace the old directory with the new one
392        if final_output_dir.exists() {
393            for p in ["dist/index.d.ts", "dist/index.js", "src/index.ts"]
394                .iter()
395                .map(Path::new)
396            {
397                std::fs::copy(temp_dir.join(p), final_output_dir.join(p))?;
398            }
399            printer.checkln(format!("Client {name:?} updated successfully"));
400        } else {
401            std::fs::create_dir_all(&final_output_dir)?;
402            // No existing directory, just move temp to final location
403            std::fs::rename(&temp_dir, &final_output_dir)?;
404            printer.checkln(format!("Client {name:?} created successfully"));
405            // Run npm install in the final output directory to ensure proper linking
406            let output = std::process::Command::new("npm")
407                .current_dir(&final_output_dir)
408                .arg("install")
409                .arg("--loglevel=error")
410                .output()?;
411
412            if !output.status.success() {
413                return Err(Error::NpmCommandFailure(
414                    final_output_dir.clone(),
415                    format!(
416                        "npm install in final directory failed with status: {:?}\nError: {}",
417                        output.status.code(),
418                        String::from_utf8_lossy(&output.stderr)
419                    ),
420                ));
421            }
422        }
423
424        self.create_contract_template(name, contract_id)?;
425        Ok(())
426    }
427
428    async fn handle_accounts(
429        &self,
430        accounts: Option<&[env_toml::Account]>,
431        network: &Network,
432    ) -> Result<(), Error> {
433        let printer = self.printer();
434        let Some(accounts) = accounts else {
435            return Err(Error::NeedAtLeastOneAccount);
436        };
437
438        let default_account_candidates = accounts
439            .iter()
440            .filter(|&account| account.default)
441            .map(|account| account.name.clone())
442            .collect::<Vec<_>>();
443
444        let default_account = match (default_account_candidates.as_slice(), accounts) {
445            ([], []) => return Err(Error::NeedAtLeastOneAccount),
446            ([], [env_toml::Account { name, .. }, ..]) => name.clone(),
447            ([candidate], _) => candidate.to_string(),
448            _ => return Err(Error::OnlyOneDefaultAccount(default_account_candidates)),
449        };
450        let config = self.get_config_locator();
451
452        for account in accounts {
453            printer.infoln(format!("Creating keys for {:?}", account.name));
454            // Use provided global args or create default
455            let args =
456                self.global_args
457                    .clone()
458                    .unwrap_or_else(|| stellar_cli::commands::global::Args {
459                        locator: config.clone(),
460                        ..Default::default()
461                    });
462
463            let generate_cmd = cli::keys::generate::Cmd {
464                name: account.name.clone().parse()?,
465                fund: true,
466                config_locator: config.clone(),
467                network: Self::get_network_args(network),
468                seed: None,
469                hd_path: None,
470                as_secret: false,
471                secure_store: false,
472                overwrite: false,
473            };
474
475            match generate_cmd.run(&args).await {
476                Err(e) if e.to_string().contains("already exists") => {
477                    printer.blankln(e);
478                    // Check if account exists on chain
479                    let rpc_client = soroban_rpc::Client::new(
480                        network
481                            .rpc_url
482                            .as_ref()
483                            .expect("network contains the RPC url"),
484                    )?;
485
486                    let public_key_cmd = cli::keys::public_key::Cmd {
487                        name: account.name.parse()?,
488                        locator: config.clone(),
489                        hd_path: None,
490                    };
491                    let address = public_key_cmd.public_key().await?;
492
493                    if (rpc_client.get_account(&address.to_string()).await).is_err() {
494                        printer.infoln("Account not found on chain, funding...");
495                        let fund_cmd = cli::keys::fund::Cmd {
496                            network: Self::get_network_args(network),
497                            address: public_key_cmd,
498                        };
499                        fund_cmd.run(&args).await?;
500                    }
501                }
502                other_result => other_result?,
503            }
504        }
505
506        std::env::set_var("STELLAR_ACCOUNT", &default_account);
507        Ok(())
508    }
509
510    fn maintain_user_ordering(
511        package_names: &[String],
512        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
513    ) -> Vec<String> {
514        contracts.map_or_else(
515            || package_names.to_vec(),
516            |contracts| {
517                let mut reordered: Vec<String> = contracts
518                    .keys()
519                    .filter_map(|contract_name| {
520                        package_names
521                            .iter()
522                            .find(|&name| name == contract_name.as_ref())
523                            .cloned()
524                    })
525                    .collect();
526
527                reordered.extend(
528                    package_names
529                        .iter()
530                        .filter(|name| !contracts.contains_key(name.as_str()))
531                        .cloned(),
532                );
533
534                reordered
535            },
536        )
537    }
538
539    async fn handle_production_contracts(
540        &self,
541        contracts: &IndexMap<Box<str>, env_toml::Contract>,
542    ) -> Result<(), Error> {
543        for (name, contract) in contracts.iter().filter(|(_, settings)| settings.client) {
544            if let Some(id) = &contract.id {
545                if stellar_strkey::Contract::from_string(id).is_err() {
546                    return Err(Error::InvalidContractID(id.to_string()));
547                }
548                self.clone()
549                    .generate_contract_bindings(name, &id.to_string())
550                    .await?;
551            } else {
552                return Err(Error::MissingContractID(name.to_string()));
553            }
554        }
555        Ok(())
556    }
557
558    async fn handle_contracts(
559        &self,
560        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
561        package_names: Vec<String>,
562        network: &Network,
563    ) -> Result<(), Error> {
564        let printer = self.printer();
565        if package_names.is_empty() {
566            return Ok(());
567        }
568
569        let env = self.stellar_scaffold_env(ScaffoldEnv::Production);
570        if env == "production" || env == "staging" {
571            if let Some(contracts) = contracts {
572                self.handle_production_contracts(contracts).await?;
573            }
574            return Ok(());
575        }
576
577        self.validate_contract_names(contracts)?;
578
579        let names = Self::maintain_user_ordering(&package_names, contracts);
580
581        let mut results: Vec<(String, Result<(), String>)> = Vec::new();
582
583        for name in names {
584            let settings = contracts
585                .and_then(|contracts| contracts.get(name.as_str()))
586                .cloned()
587                .unwrap_or_default();
588
589            // Skip if client generation is disabled
590            if !settings.client {
591                continue;
592            }
593
594            match self
595                .process_single_contract(&name, settings, network, &env)
596                .await
597            {
598                Ok(()) => {
599                    printer.checkln(format!("Successfully generated client for: {name}"));
600                    results.push((name, Ok(())));
601                }
602                Err(e) => {
603                    printer.errorln(format!("Failed to generate client for: {name}"));
604                    results.push((name, Err(e.to_string())));
605                }
606            }
607        }
608
609        // Partition results into successes and failures
610        let (successes, failures): (Vec<_>, Vec<_>) =
611            results.into_iter().partition(|(_, result)| result.is_ok());
612
613        // Print summary
614        printer.infoln("Client Generation Summary:");
615        printer.blankln(format!("Successfully processed: {}", successes.len()));
616        printer.blankln(format!("Failed: {}", failures.len()));
617
618        if !failures.is_empty() {
619            printer.infoln("Failures:");
620            for (name, result) in &failures {
621                if let Err(e) = result {
622                    printer.blankln(format!("{name}: {e}"));
623                }
624            }
625        }
626
627        Ok(())
628    }
629
630    fn get_wasm_path(&self, contract_name: &str) -> std::path::PathBuf {
631        // Check if out_dir was specified and use it, otherwise fall back to target directory
632        if let Some(out_dir) = &self.out_dir {
633            out_dir.join(format!("{contract_name}.wasm"))
634        } else {
635            let workspace_root = self
636                .workspace_root
637                .as_ref()
638                .expect("workspace_root not set");
639            let target_dir = workspace_root.join("target");
640            stellar_build::stellar_wasm_out_file(&target_dir, contract_name)
641        }
642    }
643
644    fn validate_contract_names(
645        &self,
646        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
647    ) -> Result<(), Error> {
648        if let Some(contracts) = contracts {
649            for (name, _) in contracts.iter().filter(|(_, settings)| settings.client) {
650                let wasm_path = self.get_wasm_path(name);
651                if !wasm_path.exists() {
652                    return Err(Error::BadContractName(name.to_string()));
653                }
654            }
655        }
656        Ok(())
657    }
658
659    fn get_package_dir(&self, name: &str) -> Result<std::path::PathBuf, Error> {
660        let workspace_root = self
661            .workspace_root
662            .as_ref()
663            .expect("workspace_root must be set before running");
664        let package_dir = workspace_root.join(format!("packages/{name}"));
665        if !package_dir.exists() {
666            return Err(Error::BadContractName(name.to_string()));
667        }
668        Ok(package_dir)
669    }
670
671    async fn process_single_contract(
672        &self,
673        name: &str,
674        settings: env_toml::Contract,
675        network: &Network,
676        env: &str,
677    ) -> Result<(), Error> {
678        let printer = self.printer();
679        // First check if we have an ID in settings
680        let contract_id = if let Some(id) = &settings.id {
681            Contract::from_string(id).map_err(|_| Error::InvalidContractID(id.clone()))?
682        } else {
683            let wasm_path = self.get_wasm_path(name);
684            if !wasm_path.exists() {
685                return Err(Error::BadContractName(name.to_string()));
686            }
687            let new_hash = self.upload_contract_wasm(name, &wasm_path).await?;
688            let mut upgraded_contract = None;
689
690            // Check existing alias - if it exists and matches hash, we can return early
691            if let Some(existing_contract_id) = self.get_contract_alias(name)? {
692                let hash = self
693                    .get_contract_hash(&existing_contract_id, network)
694                    .await?;
695                if let Some(current_hash) = hash {
696                    if current_hash == new_hash {
697                        printer.checkln(format!("Contract {name:?} is up to date"));
698                        // If there is not a package at packages/<name>, generate bindings
699                        if self.get_package_dir(name).is_err() {
700                            self.generate_contract_bindings(
701                                name,
702                                &existing_contract_id.to_string(),
703                            )
704                            .await?;
705                        }
706                        return Ok(());
707                    }
708                    upgraded_contract = self
709                        .try_upgrade_contract(
710                            name,
711                            existing_contract_id,
712                            &current_hash,
713                            &new_hash,
714                            network,
715                        )
716                        .await?;
717                }
718                printer.infoln(format!("Updating contract {name:?}"));
719            }
720
721            // Deploy new contract if we got here (don't deploy if we already run an upgrade)
722            let contract_id = if let Some(upgraded) = upgraded_contract {
723                upgraded
724            } else {
725                self.deploy_contract(name, &new_hash, &settings).await?
726            };
727            // Run after_deploy script if in development or test environment
728            if let Some(after_deploy) = settings.after_deploy.as_deref() {
729                if env == "development" || env == "testing" {
730                    printer.infoln(format!("Running after_deploy script for {name:?}"));
731                    self.run_after_deploy_script(name, &contract_id, after_deploy)
732                        .await?;
733                }
734            }
735            self.save_contract_alias(name, &contract_id, network)?;
736            contract_id
737        };
738
739        self.generate_contract_bindings(name, &contract_id.to_string())
740            .await?;
741
742        Ok(())
743    }
744
745    async fn upload_contract_wasm(
746        &self,
747        name: &str,
748        wasm_path: &std::path::Path,
749    ) -> Result<String, Error> {
750        let printer = self.printer();
751        printer.infoln(format!("Installing {name:?} wasm bytecode on-chain..."));
752        let hash = cli::contract::upload::Cmd::parse_arg_vec(&[
753            "--wasm",
754            wasm_path
755                .to_str()
756                .expect("we do not support non-utf8 paths"),
757            "--config-dir",
758            self.get_config_dir()
759                .to_str()
760                .expect("we do not support non-utf8 paths"),
761        ])?
762        .run_against_rpc_server(self.global_args.as_ref(), None)
763        .await?
764        .into_result()
765        .expect("no hash returned by 'contract upload'")
766        .to_string();
767        printer.infoln(format!("    ↳ hash: {hash}"));
768        Ok(hash)
769    }
770
771    fn parse_script_line(line: &str) -> Result<(Option<String>, Vec<String>), Error> {
772        let re = Regex::new(r"\$\((.*?)\)").expect("Invalid regex pattern");
773        let (shell, flag) = if cfg!(windows) {
774            ("cmd", "/C")
775        } else {
776            ("sh", "-c")
777        };
778
779        let resolved_line = Self::resolve_line(&re, line, shell, flag)?;
780        let parts = split(&resolved_line)
781            .ok_or_else(|| Error::ScriptParseFailure(resolved_line.to_string()))?;
782
783        let (source_account, command_parts): (Vec<_>, Vec<_>) = parts
784            .iter()
785            .partition(|&part| part.starts_with("STELLAR_ACCOUNT="));
786
787        let source = source_account.first().map(|account| {
788            account
789                .strip_prefix("STELLAR_ACCOUNT=")
790                .unwrap()
791                .to_string()
792        });
793
794        Ok((
795            source,
796            command_parts.iter().map(|s| (*s).to_string()).collect(),
797        ))
798    }
799
800    async fn deploy_contract(
801        &self,
802        name: &str,
803        hash: &str,
804        settings: &env_toml::Contract,
805    ) -> Result<Contract, Error> {
806        let printer = self.printer();
807        let mut deploy_args = vec![
808            "--alias".to_string(),
809            name.to_string(),
810            "--wasm-hash".to_string(),
811            hash.to_string(),
812            "--config-dir".to_string(),
813            self.get_config_dir()
814                .to_str()
815                .expect("we do not support non-utf8 paths")
816                .to_string(),
817        ];
818
819        if let Some(constructor_script) = &settings.constructor_args {
820            let (source_account, mut args) = Self::parse_script_line(constructor_script)?;
821
822            if let Some(account) = source_account {
823                deploy_args.extend_from_slice(&["--source-account".to_string(), account]);
824            }
825
826            deploy_args.push("--".to_string());
827            deploy_args.append(&mut args);
828        }
829
830        printer.infoln(format!("Instantiating {name:?} smart contract"));
831        let deploy_arg_refs: Vec<&str> = deploy_args
832            .iter()
833            .map(std::string::String::as_str)
834            .collect();
835        let contract_id = cli::contract::deploy::wasm::Cmd::parse_arg_vec(&deploy_arg_refs)?
836            .run_against_rpc_server(self.global_args.as_ref(), None)
837            .await?
838            .into_result()
839            .expect("no contract id returned by 'contract deploy'");
840        printer.infoln(format!("    ↳ contract_id: {contract_id}"));
841
842        Ok(contract_id)
843    }
844
845    async fn try_upgrade_contract(
846        &self,
847        name: &str,
848        existing_contract_id: Contract,
849        existing_hash: &str,
850        hash: &str,
851        network: &Network,
852    ) -> Result<Option<Contract>, Error> {
853        let printer = self.printer();
854        let existing_spec = fetch_contract_spec(existing_hash, network).await?;
855        let spec_to_upgrade = fetch_contract_spec(hash, network).await?;
856        let Some(legacy_upgradeable) = Self::is_legacy_upgradeable(existing_spec) else {
857            return Ok(None);
858        };
859
860        if Self::is_legacy_upgradeable(spec_to_upgrade).is_none() {
861            printer.warnln("New WASM is not upgradable. Contract will be redeployed instead of being upgraded.");
862            return Ok(None);
863        }
864
865        printer
866            .infoln("Upgradable contract found, will use 'upgrade' function instead of redeploy");
867
868        let existing_contract_id_str = existing_contract_id.to_string();
869        let mut redeploy_args = vec![
870            "--id",
871            existing_contract_id_str.as_str(),
872            "--",
873            "upgrade",
874            "--new_wasm_hash",
875            hash,
876        ];
877
878        let invoke_cmd = if legacy_upgradeable {
879            let upgrade_operator = ArgParser::get_upgrade_args(name).map_err(UpgradeArgsError)?;
880            redeploy_args.push("--operator");
881            redeploy_args.push(&upgrade_operator);
882            cli::contract::invoke::Cmd::parse_arg_vec(&redeploy_args)
883        } else {
884            cli::contract::invoke::Cmd::parse_arg_vec(&redeploy_args)
885        }?;
886        printer.infoln(format!("Upgrading {name:?} smart contract"));
887        invoke_cmd
888            .run_against_rpc_server(self.global_args.as_ref(), None)
889            .await?
890            .into_result()
891            .expect("no result returned by 'contract invoke'");
892        printer.infoln(format!("Contract upgraded: {existing_contract_id}"));
893
894        Ok(Some(existing_contract_id))
895    }
896
897    /// Returns: none if not upgradable, Some(true) if legacy upgradeable, Some(false) if new upgradeable
898    fn is_legacy_upgradeable(spec: Vec<ScSpecEntry>) -> Option<bool> {
899        spec.iter()
900            .filter_map(|x| if let FunctionV0(e) = x { Some(e) } else { None })
901            .filter(|x| x.name.to_string() == "upgrade")
902            .find(|x| {
903                x.inputs
904                    .iter()
905                    .any(|y| matches!(y.type_, ScSpecTypeDef::BytesN(ScSpecTypeBytesN { n: 32 })))
906            })
907            .map(|x| x.inputs.iter().any(|y| y.type_ == ScSpecTypeDef::Address))
908    }
909
910    fn resolve_line(re: &Regex, line: &str, shell: &str, flag: &str) -> Result<String, Error> {
911        let mut result = String::new();
912        let mut last_match = 0;
913        for cap in re.captures_iter(line) {
914            let whole_match = cap.get(0).unwrap();
915            result.push_str(&line[last_match..whole_match.start()]);
916            let cmd = &cap[1];
917            let output = Self::execute_subcommand(shell, flag, cmd)?;
918            result.push_str(&output);
919            last_match = whole_match.end();
920        }
921        result.push_str(&line[last_match..]);
922        Ok(result)
923    }
924
925    fn execute_subcommand(shell: &str, flag: &str, cmd: &str) -> Result<String, Error> {
926        match Command::new(shell).arg(flag).arg(cmd).output() {
927            Ok(output) => {
928                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
929                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
930
931                if output.status.success() {
932                    Ok(stdout)
933                } else {
934                    Err(Error::SubCommandExecutionFailure(cmd.to_string(), stderr))
935                }
936            }
937            Err(e) => Err(Error::SubCommandExecutionFailure(
938                cmd.to_string(),
939                e.to_string(),
940            )),
941        }
942    }
943
944    async fn run_after_deploy_script(
945        &self,
946        name: &str,
947        contract_id: &Contract,
948        after_deploy_script: &str,
949    ) -> Result<(), Error> {
950        let printer = self.printer();
951        let config_dir_path = self.get_config_dir();
952        let config_dir = config_dir_path.to_str().unwrap();
953        for line in after_deploy_script.lines() {
954            let line = line.trim();
955            if line.is_empty() {
956                continue;
957            }
958
959            let (source_account, command_parts) = Self::parse_script_line(line)?;
960
961            let contract_id_arg = contract_id.to_string();
962            let mut args = vec!["--id", &contract_id_arg, "--config-dir", config_dir];
963            if let Some(account) = source_account.as_ref() {
964                args.extend_from_slice(&["--source-account", account]);
965            }
966            args.extend_from_slice(&["--"]);
967            args.extend(command_parts.iter().map(std::string::String::as_str));
968
969            printer.infoln(format!(
970                "  ↳ Executing: stellar contract invoke {}",
971                args.join(" ")
972            ));
973            let result = cli::contract::invoke::Cmd::parse_arg_vec(&args)?
974                .run_against_rpc_server(self.global_args.as_ref(), None)
975                .await?;
976            printer.infoln(format!("  ↳ Result: {result:?}"));
977        }
978        printer.checkln(format!(
979            "After deploy script for {name:?} completed successfully"
980        ));
981        Ok(())
982    }
983}
984
985async fn fetch_contract_spec(
986    wasm_hash: &str,
987    network: &Network,
988) -> Result<Vec<ScSpecEntry>, Error> {
989    let fetched = fetch(
990        &FetchArgs {
991            wasm_hash: Some(wasm_hash.to_string()),
992            network: network.into(),
993            ..Default::default()
994        },
995        // Quiets the output of the fetch command
996        &Print::new(true),
997    )
998    .await?;
999
1000    match fetched.contract {
1001        contract_spec::Contract::Wasm { wasm_bytes } => Ok(Spec::new(&wasm_bytes)?.spec),
1002        contract_spec::Contract::StellarAssetContract => unreachable!(),
1003    }
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008    use super::*;
1009    use tempfile::TempDir;
1010
1011    #[test]
1012    fn test_get_package_dir() {
1013        let temp_dir = TempDir::new().unwrap();
1014        let package_path = temp_dir.path().join("packages/existing_package");
1015        std::fs::create_dir_all(&package_path).unwrap();
1016        let args = Args {
1017            env: Some(ScaffoldEnv::Development),
1018            workspace_root: Some(temp_dir.path().to_path_buf()),
1019            out_dir: None,
1020            global_args: None,
1021        };
1022        let result = args.get_package_dir("existing_package");
1023        assert!(result.is_ok());
1024        let path = result.unwrap();
1025        assert_eq!(path.file_name().unwrap(), "existing_package");
1026    }
1027
1028    #[test]
1029    fn test_get_package_dir_nonexistent() {
1030        let args = Args {
1031            env: Some(ScaffoldEnv::Development),
1032            workspace_root: Some(std::path::PathBuf::from("tests/nonexistent_workspace")),
1033            out_dir: None,
1034            global_args: None,
1035        };
1036        let result = args.get_package_dir("nonexistent_package");
1037        assert!(result.is_err());
1038        if let Err(Error::BadContractName(name)) = result {
1039            assert_eq!(name, "nonexistent_package");
1040        } else {
1041            panic!("Expected BadContractName error");
1042        }
1043    }
1044}