Skip to main content

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