stellar_scaffold_cli/commands/build/
clients.rs

1#![allow(clippy::struct_excessive_bools)]
2use crate::commands::build::env_toml;
3use indexmap::IndexMap;
4use regex::Regex;
5use serde_json;
6use shlex::split;
7use std::fmt::Debug;
8use std::hash::Hash;
9use std::process::Command;
10use stellar_cli::commands::NetworkRunnable;
11use stellar_cli::utils::contract_hash;
12use stellar_cli::{commands as cli, CommandParser};
13use stellar_strkey::{self, Contract};
14use stellar_xdr::curr::Error as xdrError;
15
16use super::env_toml::Network;
17
18#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, clap::ValueEnum)]
19pub enum ScaffoldEnv {
20    Development,
21    Testing,
22    Staging,
23    Production,
24}
25
26impl std::fmt::Display for ScaffoldEnv {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        write!(f, "{}", format!("{self:?}").to_lowercase())
29    }
30}
31
32#[derive(clap::Args, Debug, Clone)]
33pub struct Args {
34    #[arg(env = "STELLAR_SCAFFOLD_ENV", value_enum)]
35    pub env: Option<ScaffoldEnv>,
36    #[arg(skip)]
37    pub workspace_root: Option<std::path::PathBuf>,
38    /// Directory where wasm files are located
39    #[arg(skip)]
40    pub out_dir: Option<std::path::PathBuf>,
41}
42
43#[derive(thiserror::Error, Debug)]
44pub enum Error {
45    #[error(transparent)]
46    EnvironmentsToml(#[from] env_toml::Error),
47    #[error("⛔ ️invalid network: must either specify a network name or both network_passphrase and rpc_url")]
48    MalformedNetwork,
49    #[error(transparent)]
50    ParsingNetwork(#[from] cli::network::Error),
51    #[error(transparent)]
52    GeneratingKey(#[from] cli::keys::generate::Error),
53    #[error("⛔ ️can only have one default account; marked as default: {0:?}")]
54    OnlyOneDefaultAccount(Vec<String>),
55    #[error("⛔ ️you need to provide at least one account, to use as the source account for contract deployment and other operations")]
56    NeedAtLeastOneAccount,
57    #[error("⛔ ️No contract named {0:?}")]
58    BadContractName(String),
59    #[error("⛔ ️Invalid contract ID: {0:?}")]
60    InvalidContractID(String),
61    #[error("⛔ ️An ID must be set for a contract in production or staging. E.g. <name>.id = C...")]
62    MissingContractID(String),
63    #[error("⛔ ️Unable to parse script: {0:?}")]
64    ScriptParseFailure(String),
65    #[error("⛔ ️Failed to execute subcommand: {0:?}\n{1:?}")]
66    SubCommandExecutionFailure(String, String),
67    #[error(transparent)]
68    ContractInstall(#[from] cli::contract::upload::Error),
69    #[error(transparent)]
70    ContractDeploy(#[from] cli::contract::deploy::wasm::Error),
71    #[error(transparent)]
72    ContractBindings(#[from] cli::contract::bindings::typescript::Error),
73    #[error(transparent)]
74    ContractFetch(#[from] cli::contract::fetch::Error),
75    #[error(transparent)]
76    ConfigLocator(#[from] stellar_cli::config::locator::Error),
77    #[error(transparent)]
78    ConfigNetwork(#[from] stellar_cli::config::network::Error),
79    #[error(transparent)]
80    ContractInvoke(#[from] cli::contract::invoke::Error),
81    #[error(transparent)]
82    Clap(#[from] clap::Error),
83    #[error(transparent)]
84    WasmHash(#[from] xdrError),
85    #[error(transparent)]
86    Io(#[from] std::io::Error),
87    #[error(transparent)]
88    Json(#[from] serde_json::Error),
89    #[error("⛔ ️Failed to run npm command in {0:?}: {1:?}")]
90    NpmCommandFailure(std::path::PathBuf, String),
91}
92
93impl Args {
94    pub async fn run(&self, package_names: Vec<String>) -> Result<(), Error> {
95        let workspace_root = self
96            .workspace_root
97            .as_ref()
98            .expect("workspace_root must be set before running");
99
100        let Some(current_env) = env_toml::Environment::get(
101            workspace_root,
102            &self.clone().stellar_scaffold_env(ScaffoldEnv::Production),
103        )?
104        else {
105            return Ok(());
106        };
107
108        Self::add_network_to_env(&current_env.network)?;
109        // Create the '.stellar' directory if it doesn't exist
110        std::fs::create_dir_all(workspace_root.join(".stellar"))
111            .map_err(stellar_cli::config::locator::Error::Io)?;
112        Self::handle_accounts(current_env.accounts.as_deref()).await?;
113        self.clone()
114            .handle_contracts(
115                current_env.contracts.as_ref(),
116                package_names,
117                &current_env.network,
118            )
119            .await?;
120
121        Ok(())
122    }
123
124    fn stellar_scaffold_env(self, default: ScaffoldEnv) -> String {
125        self.env.unwrap_or(default).to_string().to_lowercase()
126    }
127
128    /// Parse the network settings from the environments.toml file and set `STELLAR_RPC_URL` and
129    /// `STELLAR_NETWORK_PASSPHRASE`.
130    ///
131    /// We could set `STELLAR_NETWORK` instead, but when importing contracts, we want to hard-code
132    /// the network passphrase. So if given a network name, we use soroban-cli to fetch the RPC url
133    /// & passphrase for that named network, and still set the environment variables.
134    fn add_network_to_env(network: &env_toml::Network) -> Result<(), Error> {
135        match &network {
136            Network {
137                name: Some(name), ..
138            } => {
139                let stellar_cli::config::network::Network {
140                    rpc_url,
141                    network_passphrase,
142                    ..
143                } = (stellar_cli::config::network::Args {
144                    network: Some(name.clone()),
145                    rpc_url: None,
146                    network_passphrase: None,
147                    rpc_headers: Vec::new(),
148                })
149                .get(&stellar_cli::config::locator::Args {
150                    global: false,
151                    config_dir: None,
152                })?;
153                eprintln!("🌐 using {name} network");
154                std::env::set_var("STELLAR_RPC_URL", rpc_url);
155                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", network_passphrase);
156            }
157            Network {
158                rpc_url: Some(rpc_url),
159                network_passphrase: Some(passphrase),
160                ..
161            } => {
162                std::env::set_var("STELLAR_RPC_URL", rpc_url);
163                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", passphrase);
164                eprintln!("🌐 using network at {rpc_url}");
165            }
166            _ => return Err(Error::MalformedNetwork),
167        }
168
169        Ok(())
170    }
171
172    fn get_network_args(network: &Network) -> stellar_cli::config::network::Args {
173        stellar_cli::config::network::Args {
174            rpc_url: network.rpc_url.clone(),
175            network_passphrase: network.network_passphrase.clone(),
176            network: network.name.clone(),
177            rpc_headers: network.rpc_headers.clone().unwrap_or_default(),
178        }
179    }
180
181    fn get_config_locator(&self) -> stellar_cli::config::locator::Args {
182        let workspace_root = self
183            .workspace_root
184            .as_ref()
185            .expect("workspace_root not set");
186        stellar_cli::config::locator::Args {
187            global: false,
188            config_dir: Some(workspace_root.clone()),
189        }
190    }
191
192    fn get_contract_alias(
193        &self,
194        name: &str,
195    ) -> Result<Option<Contract>, stellar_cli::config::locator::Error> {
196        let config_dir = self.get_config_locator();
197        let network_passphrase = std::env::var("STELLAR_NETWORK_PASSPHRASE")
198            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
199        config_dir.get_contract_id(name, &network_passphrase)
200    }
201
202    async fn contract_hash_matches(
203        &self,
204        contract_id: &Contract,
205        hash: &str,
206        network: &Network,
207    ) -> Result<bool, Error> {
208        let result = cli::contract::fetch::Cmd {
209            contract_id: stellar_cli::config::UnresolvedContract::Resolved(*contract_id),
210            out_file: None,
211            locator: self.get_config_locator(),
212            network: Self::get_network_args(network),
213        }
214        .run_against_rpc_server(None, None)
215        .await;
216
217        match result {
218            Ok(result) => {
219                let ctrct_hash = contract_hash(&result)?;
220                Ok(hex::encode(ctrct_hash) == hash)
221            }
222            Err(e) => {
223                if e.to_string().contains("Contract not found") {
224                    Ok(false)
225                } else {
226                    Err(Error::ContractFetch(e))
227                }
228            }
229        }
230    }
231
232    fn save_contract_alias(
233        &self,
234        name: &str,
235        contract_id: &Contract,
236        network: &Network,
237    ) -> Result<(), stellar_cli::config::locator::Error> {
238        let config_dir = self.get_config_locator();
239        let passphrase = network
240            .network_passphrase
241            .clone()
242            .expect("You must set a network passphrase.");
243        config_dir.save_contract_id(&passphrase, contract_id, name)
244    }
245
246    fn write_contract_template(self, name: &str, contract_id: &str) -> Result<(), Error> {
247        let allow_http =
248            if self.clone().stellar_scaffold_env(ScaffoldEnv::Production) == "development" {
249                "\n  allowHttp: true,"
250            } else {
251                ""
252            };
253        let network = std::env::var("STELLAR_NETWORK_PASSPHRASE")
254            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
255        let template = format!(
256            r"import * as Client from '{name}';
257import {{ rpcUrl }} from './util';
258    
259export default new Client.Client({{
260  networkPassphrase: '{network}',
261  contractId: '{contract_id}',
262  rpcUrl,{allow_http}
263  publicKey: undefined,
264}});
265"
266        );
267        let workspace_root = self
268            .workspace_root
269            .as_ref()
270            .expect("workspace_root not set");
271        let path = workspace_root.join(format!("src/contracts/{name}.ts"));
272        std::fs::write(path, template)?;
273        Ok(())
274    }
275
276    async fn generate_contract_bindings(self, name: &str, contract_id: &str) -> Result<(), Error> {
277        eprintln!("🎭 binding {name:?} contract");
278        let workspace_root = self
279            .workspace_root
280            .as_ref()
281            .expect("workspace_root not set");
282        let output_dir = workspace_root.join(format!("packages/{name}"));
283        cli::contract::bindings::typescript::Cmd::parse_arg_vec(&[
284            "--contract-id",
285            contract_id,
286            "--output-dir",
287            output_dir
288                .to_str()
289                .expect("we do not support non-utf8 paths"),
290            "--overwrite",
291        ])?
292        .run()
293        .await?;
294
295        eprintln!("🍽️ importing {name:?} contract");
296        self.write_contract_template(name, contract_id)?;
297
298        // Run `npm i` in the output directory
299        eprintln!("🔧 running 'npm install' in {output_dir:?}");
300        let output = std::process::Command::new("npm")
301            .current_dir(&output_dir)
302            .arg("install")
303            .arg("--loglevel=error") // Reduce noise from warnings
304            .arg("--no-workspaces") // fix issue where stellar sometimes isnt installed locally causing tsc to fail
305            .output()?;
306
307        if !output.status.success() {
308            return Err(Error::NpmCommandFailure(
309                output_dir.clone(),
310                format!(
311                    "npm install failed with status: {:?}\nError: {}",
312                    output.status.code(),
313                    String::from_utf8_lossy(&output.stderr)
314                ),
315            ));
316        }
317        eprintln!("✅ 'npm install' succeeded in {output_dir:?}");
318
319        eprintln!("🔨 running 'npm run build' in {output_dir:?}");
320        let output = std::process::Command::new("npm")
321            .current_dir(&output_dir)
322            .arg("run")
323            .arg("build")
324            .arg("--loglevel=error") // Reduce noise from warnings
325            .output()?;
326
327        if !output.status.success() {
328            return Err(Error::NpmCommandFailure(
329                output_dir.clone(),
330                format!(
331                    "npm run build failed with status: {:?}\nError: {}",
332                    output.status.code(),
333                    String::from_utf8_lossy(&output.stderr)
334                ),
335            ));
336        }
337        eprintln!("✅ 'npm run build' succeeded in {output_dir:?}");
338        Ok(())
339    }
340
341    async fn handle_accounts(accounts: Option<&[env_toml::Account]>) -> Result<(), Error> {
342        let Some(accounts) = accounts else {
343            return Err(Error::NeedAtLeastOneAccount);
344        };
345
346        let default_account_candidates = accounts
347            .iter()
348            .filter(|&account| account.default)
349            .map(|account| account.name.clone())
350            .collect::<Vec<_>>();
351
352        let default_account = match (default_account_candidates.as_slice(), accounts) {
353            ([], []) => return Err(Error::NeedAtLeastOneAccount),
354            ([], [env_toml::Account { name, .. }, ..]) => name.clone(),
355            ([candidate], _) => candidate.to_string(),
356            _ => return Err(Error::OnlyOneDefaultAccount(default_account_candidates)),
357        };
358
359        for account in accounts {
360            eprintln!("🔐 creating keys for {:?}", account.name);
361            cli::keys::generate::Cmd::parse_arg_vec(&[&account.name, "--fund"])?
362                .run(&stellar_cli::commands::global::Args::default())
363                .await
364                .or_else(|e| {
365                    if e.to_string().contains("already exists") {
366                        // ignore "already exists" errors
367                        eprintln!("{e}");
368                        Ok(())
369                    } else {
370                        Err(e)
371                    }
372                })?;
373        }
374
375        std::env::set_var("STELLAR_ACCOUNT", &default_account);
376
377        Ok(())
378    }
379
380    fn maintain_user_ordering(
381        package_names: &[String],
382        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
383    ) -> Vec<String> {
384        contracts.map_or_else(
385            || package_names.to_vec(),
386            |contracts| {
387                let mut reordered: Vec<String> = contracts
388                    .keys()
389                    .filter_map(|contract_name| {
390                        package_names
391                            .iter()
392                            .find(|&name| name == contract_name.as_ref())
393                            .cloned()
394                    })
395                    .collect();
396
397                reordered.extend(
398                    package_names
399                        .iter()
400                        .filter(|name| !contracts.contains_key(name.as_str()))
401                        .cloned(),
402                );
403
404                reordered
405            },
406        )
407    }
408
409    async fn handle_production_contracts(
410        &self,
411        contracts: &IndexMap<Box<str>, env_toml::Contract>,
412    ) -> Result<(), Error> {
413        for (name, contract) in contracts.iter().filter(|(_, settings)| settings.client) {
414            if let Some(id) = &contract.id {
415                if stellar_strkey::Contract::from_string(id).is_err() {
416                    return Err(Error::InvalidContractID(id.to_string()));
417                }
418                self.clone()
419                    .generate_contract_bindings(name, &id.to_string())
420                    .await?;
421            } else {
422                return Err(Error::MissingContractID(name.to_string()));
423            }
424        }
425        Ok(())
426    }
427
428    async fn handle_contracts(
429        self,
430        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
431        package_names: Vec<String>,
432        network: &Network,
433    ) -> Result<(), Error> {
434        if package_names.is_empty() {
435            return Ok(());
436        }
437
438        let env = self.clone().stellar_scaffold_env(ScaffoldEnv::Production);
439        if env == "production" || env == "staging" {
440            if let Some(contracts) = contracts {
441                self.handle_production_contracts(contracts).await?;
442            }
443            return Ok(());
444        }
445
446        self.validate_contract_names(contracts)?;
447
448        let names = Self::maintain_user_ordering(&package_names, contracts);
449        for name in names {
450            let settings = contracts
451                .and_then(|contracts| contracts.get(name.as_str()))
452                .cloned()
453                .unwrap_or_default();
454
455            // Skip if client generation is disabled
456            if !settings.client {
457                continue;
458            }
459
460            self.process_single_contract(&name, settings, network, &env)
461                .await?;
462        }
463
464        Ok(())
465    }
466
467    fn get_wasm_path(&self, contract_name: &str) -> std::path::PathBuf {
468        // Check if out_dir was specified and use it, otherwise fall back to target directory
469        if let Some(out_dir) = &self.out_dir {
470            out_dir.join(format!("{contract_name}.wasm"))
471        } else {
472            let workspace_root = self
473                .workspace_root
474                .as_ref()
475                .expect("workspace_root not set");
476            let target_dir = workspace_root.join("target");
477            stellar_build::stellar_wasm_out_file(&target_dir, contract_name)
478        }
479    }
480
481    fn validate_contract_names(
482        &self,
483        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
484    ) -> Result<(), Error> {
485        if let Some(contracts) = contracts {
486            for (name, _) in contracts.iter().filter(|(_, settings)| settings.client) {
487                let wasm_path = self.get_wasm_path(name);
488                if !wasm_path.exists() {
489                    return Err(Error::BadContractName(name.to_string()));
490                }
491            }
492        }
493        Ok(())
494    }
495
496    async fn process_single_contract(
497        &self,
498        name: &str,
499        settings: env_toml::Contract,
500        network: &Network,
501        env: &str,
502    ) -> Result<(), Error> {
503        // First check if we have an ID in settings
504        let contract_id = if let Some(id) = &settings.id {
505            Contract::from_string(id).map_err(|_| Error::InvalidContractID(id.clone()))?
506        } else {
507            let wasm_path = self.get_wasm_path(name);
508            if !wasm_path.exists() {
509                return Err(Error::BadContractName(name.to_string()));
510            }
511
512            let hash = self.upload_contract_wasm(name, &wasm_path).await?;
513
514            // Check existing alias - if it exists and matches hash, we can return early
515            if let Some(existing_contract_id) = self.get_contract_alias(name)? {
516                if self
517                    .contract_hash_matches(&existing_contract_id, &hash, network)
518                    .await?
519                {
520                    eprintln!("✅ Contract {name:?} is up to date");
521                    return Ok(());
522                }
523                eprintln!("🔄 Updating contract {name:?}");
524            }
525
526            // Deploy new contract if we got here
527            let contract_id = self.deploy_contract(name, &hash, &settings).await?;
528            self.save_contract_alias(name, &contract_id, network)?;
529            contract_id
530        };
531
532        // Run after_deploy script if in development or test environment
533        if (env == "development" || env == "testing") && settings.after_deploy.is_some() {
534            eprintln!("🚀 Running after_deploy script for {name:?}");
535            self.run_after_deploy_script(
536                name,
537                &contract_id,
538                settings.after_deploy.as_ref().unwrap(),
539            )
540            .await?;
541        }
542
543        self.clone()
544            .generate_contract_bindings(name, &contract_id.to_string())
545            .await?;
546
547        Ok(())
548    }
549
550    async fn upload_contract_wasm(
551        &self,
552        name: &str,
553        wasm_path: &std::path::Path,
554    ) -> Result<String, Error> {
555        eprintln!("📲 installing {name:?} wasm bytecode on-chain...");
556        let hash = cli::contract::upload::Cmd::parse_arg_vec(&[
557            "--wasm",
558            wasm_path
559                .to_str()
560                .expect("we do not support non-utf8 paths"),
561        ])?
562        .run_against_rpc_server(None, None)
563        .await?
564        .into_result()
565        .expect("no hash returned by 'contract upload'")
566        .to_string();
567        eprintln!("    ↳ hash: {hash}");
568        Ok(hash)
569    }
570
571    fn parse_script_line(line: &str) -> Result<(Option<String>, Vec<String>), Error> {
572        let re = Regex::new(r"\$\((.*?)\)").expect("Invalid regex pattern");
573        let (shell, flag) = if cfg!(windows) {
574            ("cmd", "/C")
575        } else {
576            ("sh", "-c")
577        };
578
579        let resolved_line = Self::resolve_line(&re, line, shell, flag)?;
580        let parts = split(&resolved_line)
581            .ok_or_else(|| Error::ScriptParseFailure(resolved_line.to_string()))?;
582
583        let (source_account, command_parts): (Vec<_>, Vec<_>) = parts
584            .iter()
585            .partition(|&part| part.starts_with("STELLAR_ACCOUNT="));
586
587        let source = source_account.first().map(|account| {
588            account
589                .strip_prefix("STELLAR_ACCOUNT=")
590                .unwrap()
591                .to_string()
592        });
593
594        Ok((
595            source,
596            command_parts.iter().map(|s| (*s).to_string()).collect(),
597        ))
598    }
599
600    async fn deploy_contract(
601        &self,
602        name: &str,
603        hash: &str,
604        settings: &env_toml::Contract,
605    ) -> Result<Contract, Error> {
606        let mut deploy_args = vec![
607            "--alias".to_string(),
608            name.to_string(),
609            "--wasm-hash".to_string(),
610            hash.to_string(),
611        ];
612
613        if let Some(constructor_script) = &settings.constructor_args {
614            let (source_account, mut args) = Self::parse_script_line(constructor_script)?;
615
616            if let Some(account) = source_account {
617                deploy_args.extend_from_slice(&["--source-account".to_string(), account]);
618            }
619
620            deploy_args.push("--".to_string());
621            deploy_args.append(&mut args);
622        }
623
624        eprintln!("🪞 instantiating {name:?} smart contract");
625        let deploy_arg_refs: Vec<&str> = deploy_args
626            .iter()
627            .map(std::string::String::as_str)
628            .collect();
629        let contract_id = cli::contract::deploy::wasm::Cmd::parse_arg_vec(&deploy_arg_refs)?
630            .run_against_rpc_server(None, None)
631            .await?
632            .into_result()
633            .expect("no contract id returned by 'contract deploy'");
634        eprintln!("    ↳ contract_id: {contract_id}");
635
636        Ok(contract_id)
637    }
638
639    fn resolve_line(re: &Regex, line: &str, shell: &str, flag: &str) -> Result<String, Error> {
640        let mut result = String::new();
641        let mut last_match = 0;
642        for cap in re.captures_iter(line) {
643            let whole_match = cap.get(0).unwrap();
644            result.push_str(&line[last_match..whole_match.start()]);
645            let cmd = &cap[1];
646            let output = Self::execute_subcommand(shell, flag, cmd)?;
647            result.push_str(&output);
648            last_match = whole_match.end();
649        }
650        result.push_str(&line[last_match..]);
651        Ok(result)
652    }
653
654    fn execute_subcommand(shell: &str, flag: &str, cmd: &str) -> Result<String, Error> {
655        match Command::new(shell).arg(flag).arg(cmd).output() {
656            Ok(output) => {
657                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
658                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
659
660                if output.status.success() {
661                    Ok(stdout)
662                } else {
663                    Err(Error::SubCommandExecutionFailure(cmd.to_string(), stderr))
664                }
665            }
666            Err(e) => Err(Error::SubCommandExecutionFailure(
667                cmd.to_string(),
668                e.to_string(),
669            )),
670        }
671    }
672
673    async fn run_after_deploy_script(
674        &self,
675        name: &str,
676        contract_id: &Contract,
677        after_deploy_script: &str,
678    ) -> Result<(), Error> {
679        for line in after_deploy_script.lines() {
680            let line = line.trim();
681            if line.is_empty() {
682                continue;
683            }
684
685            let (source_account, command_parts) = Self::parse_script_line(line)?;
686
687            let contract_id_arg = contract_id.to_string();
688            let mut args = vec!["--id", &contract_id_arg];
689            if let Some(account) = source_account.as_ref() {
690                args.extend_from_slice(&["--source-account", account]);
691            }
692            args.extend_from_slice(&["--"]);
693            args.extend(command_parts.iter().map(std::string::String::as_str));
694
695            eprintln!("  ↳ Executing: stellar contract invoke {}", args.join(" "));
696            let result = cli::contract::invoke::Cmd::parse_arg_vec(&args)?
697                .run_against_rpc_server(None, None)
698                .await?;
699            eprintln!("  ↳ Result: {result:?}");
700        }
701        eprintln!("✅ After deploy script for {name:?} completed successfully");
702        Ok(())
703    }
704}