loam_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 soroban_cli::commands::NetworkRunnable;
8use soroban_cli::utils::contract_hash;
9use soroban_cli::{commands as cli, CommandParser};
10use std::fmt::Debug;
11use std::hash::Hash;
12use std::process::Command;
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 LoamEnv {
20    Development,
21    Testing,
22    Staging,
23    Production,
24}
25
26impl std::fmt::Display for LoamEnv {
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, Copy)]
33pub struct Args {
34    #[arg(env = "LOAM_ENV", value_enum)]
35    pub env: Option<LoamEnv>,
36}
37
38#[derive(thiserror::Error, Debug)]
39pub enum Error {
40    #[error(transparent)]
41    EnvironmentsToml(#[from] env_toml::Error),
42    #[error("⛔ ️invalid network: must either specify a network name or both network_passphrase and rpc_url")]
43    MalformedNetwork,
44    #[error(transparent)]
45    ParsingNetwork(#[from] cli::network::Error),
46    #[error(transparent)]
47    GeneratingKey(#[from] cli::keys::generate::Error),
48    #[error("⛔ ️can only have one default account; marked as default: {0:?}")]
49    OnlyOneDefaultAccount(Vec<String>),
50    #[error("⛔ ️you need to provide at least one account, to use as the source account for contract deployment and other operations")]
51    NeedAtLeastOneAccount,
52    #[error("⛔ ️No contract named {0:?}")]
53    BadContractName(String),
54    #[error("⛔ ️Invalid contract ID: {0:?}")]
55    InvalidContractID(String),
56    #[error("⛔ ️An ID must be set for a contract in production or staging. E.g. <name>.id = C...")]
57    MissingContractID(String),
58    #[error("⛔ ️Unable to parse init script: {0:?}")]
59    InitParseFailure(String),
60    #[error("⛔ ️Failed to execute subcommand: {0:?}\n{1:?}")]
61    SubCommandExecutionFailure(String, String),
62    #[error(transparent)]
63    ContractInstall(#[from] cli::contract::install::Error),
64    #[error(transparent)]
65    ContractDeploy(#[from] cli::contract::deploy::wasm::Error),
66    #[error(transparent)]
67    ContractBindings(#[from] cli::contract::bindings::typescript::Error),
68    #[error(transparent)]
69    ContractFetch(#[from] cli::contract::fetch::Error),
70    #[error(transparent)]
71    ConfigLocator(#[from] soroban_cli::config::locator::Error),
72    #[error(transparent)]
73    ConfigNetwork(#[from] soroban_cli::config::network::Error),
74    #[error(transparent)]
75    ContractInvoke(#[from] cli::contract::invoke::Error),
76    #[error(transparent)]
77    Clap(#[from] clap::Error),
78    #[error(transparent)]
79    WasmHash(#[from] xdrError),
80    #[error(transparent)]
81    Io(#[from] std::io::Error),
82    #[error(transparent)]
83    Json(#[from] serde_json::Error),
84}
85
86impl Args {
87    pub async fn run(
88        &self,
89        workspace_root: &std::path::Path,
90        package_names: Vec<String>,
91    ) -> Result<(), Error> {
92        let Some(current_env) =
93            env_toml::Environment::get(workspace_root, &self.loam_env(LoamEnv::Production))?
94        else {
95            return Ok(());
96        };
97
98        Self::add_network_to_env(&current_env.network)?;
99        // Create the '.stellar' directory if it doesn't exist - for saving contract aliases and account aliases
100        std::fs::create_dir_all(workspace_root.join(".stellar"))
101            .map_err(soroban_cli::config::locator::Error::Io)?;
102        Self::handle_accounts(current_env.accounts.as_deref()).await?;
103        self.handle_contracts(
104            workspace_root,
105            current_env.contracts.as_ref(),
106            package_names,
107            &current_env.network,
108        )
109        .await?;
110
111        Ok(())
112    }
113
114    fn loam_env(self, default: LoamEnv) -> String {
115        self.env.unwrap_or(default).to_string().to_lowercase()
116    }
117
118    /// Parse the network settings from the environments.toml file and set `STELLAR_RPC_URL` and
119    /// `STELLAR_NETWORK_PASSPHRASE`.
120    ///
121    /// We could set `STELLAR_NETWORK` instead, but when importing contracts, we want to hard-code
122    /// the network passphrase. So if given a network name, we use soroban-cli to fetch the RPC url
123    /// & passphrase for that named network, and still set the environment variables.
124    fn add_network_to_env(network: &env_toml::Network) -> Result<(), Error> {
125        match &network {
126            Network {
127                name: Some(name), ..
128            } => {
129                let soroban_cli::config::network::Network {
130                    rpc_url,
131                    network_passphrase,
132                    ..
133                } = (soroban_cli::config::network::Args {
134                    network: Some(name.clone()),
135                    rpc_url: None,
136                    network_passphrase: None,
137                    rpc_headers: Vec::new(),
138                })
139                .get(&soroban_cli::config::locator::Args {
140                    global: false,
141                    config_dir: None,
142                })?;
143                eprintln!("🌐 using {name} network");
144                std::env::set_var("STELLAR_RPC_URL", rpc_url);
145                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", network_passphrase);
146            }
147            Network {
148                rpc_url: Some(rpc_url),
149                network_passphrase: Some(passphrase),
150                ..
151            } => {
152                std::env::set_var("STELLAR_RPC_URL", rpc_url);
153                std::env::set_var("STELLAR_NETWORK_PASSPHRASE", passphrase);
154                eprintln!("🌐 using network at {rpc_url}");
155            }
156            _ => return Err(Error::MalformedNetwork),
157        }
158
159        Ok(())
160    }
161
162    fn get_network_args(network: &Network) -> soroban_cli::config::network::Args {
163        soroban_cli::config::network::Args {
164            rpc_url: network.rpc_url.clone(),
165            network_passphrase: network.network_passphrase.clone(),
166            network: network.name.clone(),
167            rpc_headers: network.rpc_headers.clone().unwrap_or_default(),
168        }
169    }
170
171    fn get_config_locator(workspace_root: &std::path::Path) -> soroban_cli::config::locator::Args {
172        soroban_cli::config::locator::Args {
173            global: false,
174            config_dir: Some(workspace_root.to_path_buf()),
175        }
176    }
177
178    fn get_contract_alias(
179        name: &str,
180        workspace_root: &std::path::Path,
181    ) -> Result<Option<Contract>, soroban_cli::config::locator::Error> {
182        let config_dir = Self::get_config_locator(workspace_root);
183        let network_passphrase = std::env::var("STELLAR_NETWORK_PASSPHRASE")
184            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
185        config_dir.get_contract_id(name, &network_passphrase)
186    }
187
188    async fn contract_hash_matches(
189        &self,
190        contract_id: &Contract,
191        hash: &str,
192        network: &Network,
193        workspace_root: &std::path::Path,
194    ) -> Result<bool, Error> {
195        let result = cli::contract::fetch::Cmd {
196            contract_id: soroban_cli::config::ContractAddress::ContractId(*contract_id),
197            out_file: None,
198            locator: Self::get_config_locator(workspace_root),
199            network: Self::get_network_args(network),
200        }
201        .run_against_rpc_server(None, None)
202        .await;
203
204        match result {
205            Ok(result) => {
206                let ctrct_hash = contract_hash(&result)?;
207                Ok(hex::encode(ctrct_hash) == hash)
208            }
209            Err(e) => {
210                if e.to_string().contains("Contract not found") {
211                    Ok(false)
212                } else {
213                    Err(Error::ContractFetch(e))
214                }
215            }
216        }
217    }
218
219    fn save_contract_alias(
220        name: &str,
221        contract_id: &Contract,
222        network: &Network,
223        workspace_root: &std::path::Path,
224    ) -> Result<(), soroban_cli::config::locator::Error> {
225        let config_dir = Self::get_config_locator(workspace_root);
226        let passphrase = network
227            .network_passphrase
228            .clone()
229            .expect("You must set a network passphrase.");
230        config_dir.save_contract_id(&passphrase, contract_id, name)
231    }
232
233    fn write_contract_template(
234        self,
235        workspace_root: &std::path::Path,
236        name: &str,
237        contract_id: &str,
238    ) -> Result<(), Error> {
239        let allow_http = if self.loam_env(LoamEnv::Production) == "development" {
240            "\n  allowHttp: true,"
241        } else {
242            ""
243        };
244        let network = std::env::var("STELLAR_NETWORK_PASSPHRASE")
245            .expect("No STELLAR_NETWORK_PASSPHRASE environment variable set");
246        let template = format!(
247            r#"import * as Client from '{name}';
248import {{ rpcUrl }} from './util';
249    
250export default new Client.Client({{
251  networkPassphrase: '{network}',
252  contractId: '{contract_id}',
253  rpcUrl,{allow_http}
254  publicKey: undefined,
255}});
256"#
257        );
258        let path = workspace_root.join(format!("src/contracts/{name}.ts"));
259        std::fs::write(path, template)?;
260        Ok(())
261    }
262
263    async fn account_exists(account_name: &str) -> Result<bool, Error> {
264        // TODO: this is a workaround until generate is changed to not overwrite accounts
265        Ok(cli::keys::fund::Cmd::parse_arg_vec(&[account_name])?
266            .run()
267            .await
268            .is_ok())
269    }
270
271    async fn generate_contract_bindings(
272        self,
273        workspace_root: &std::path::Path,
274        name: &str,
275        contract_id: &str,
276    ) -> Result<(), Error> {
277        eprintln!("🎭 binding {name:?} contract");
278        cli::contract::bindings::typescript::Cmd::parse_arg_vec(&[
279            "--contract-id",
280            contract_id,
281            "--output-dir",
282            workspace_root
283                .join(format!("packages/{name}"))
284                .to_str()
285                .expect("we do not support non-utf8 paths"),
286            "--overwrite",
287        ])?
288        .run()
289        .await?;
290
291        eprintln!("🍽️ importing {name:?} contract");
292        self.write_contract_template(workspace_root, name, contract_id)?;
293
294        Ok(())
295    }
296
297    async fn handle_accounts(accounts: Option<&[env_toml::Account]>) -> Result<(), Error> {
298        let Some(accounts) = accounts else {
299            return Err(Error::NeedAtLeastOneAccount);
300        };
301
302        let default_account_candidates = accounts
303            .iter()
304            .filter(|&account| account.default)
305            .map(|account| account.name.clone())
306            .collect::<Vec<_>>();
307
308        let default_account = match (default_account_candidates.as_slice(), accounts) {
309            ([], []) => return Err(Error::NeedAtLeastOneAccount),
310            ([], [env_toml::Account { name, .. }, ..]) => name.clone(),
311            ([candidate], _) => candidate.to_string(),
312            _ => return Err(Error::OnlyOneDefaultAccount(default_account_candidates)),
313        };
314
315        for account in accounts {
316            if Self::account_exists(&account.name).await? {
317                eprintln!(
318                    "ℹ️ account {:?} already exists, skipping key creation",
319                    account.name
320                );
321            } else {
322                eprintln!("🔐 creating keys for {:?}", account.name);
323                cli::keys::generate::Cmd::parse_arg_vec(&[&account.name])?
324                    .run(&soroban_cli::commands::global::Args::default())
325                    .await?;
326            }
327        }
328
329        std::env::set_var("STELLAR_ACCOUNT", &default_account);
330
331        Ok(())
332    }
333
334    fn reorder_package_names(
335        package_names: &[String],
336        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
337    ) -> Vec<String> {
338        contracts.map_or_else(
339            || package_names.to_vec(),
340            |contracts| {
341                let mut reordered: Vec<String> = contracts
342                    .keys()
343                    .filter_map(|contract_name| {
344                        package_names
345                            .iter()
346                            .find(|&name| name == contract_name.as_ref())
347                            .cloned()
348                    })
349                    .collect();
350
351                reordered.extend(
352                    package_names
353                        .iter()
354                        .filter(|name| !contracts.contains_key(name.as_str()))
355                        .cloned(),
356                );
357
358                reordered
359            },
360        )
361    }
362
363    async fn handle_production_contracts(
364        &self,
365        workspace_root: &std::path::Path,
366        contracts: &IndexMap<Box<str>, env_toml::Contract>,
367    ) -> Result<(), Error> {
368        for (name, contract) in contracts.iter().filter(|(_, settings)| settings.client) {
369            if let Some(id) = &contract.id {
370                if stellar_strkey::Contract::from_string(id).is_err() {
371                    return Err(Error::InvalidContractID(id.to_string()));
372                }
373                self.generate_contract_bindings(workspace_root, name, &id.to_string())
374                    .await?;
375            } else {
376                return Err(Error::MissingContractID(name.to_string()));
377            }
378        }
379        Ok(())
380    }
381
382    async fn handle_contracts(
383        self,
384        workspace_root: &std::path::Path,
385        contracts: Option<&IndexMap<Box<str>, env_toml::Contract>>,
386        package_names: Vec<String>,
387        network: &Network,
388    ) -> Result<(), Error> {
389        if package_names.is_empty() {
390            return Ok(());
391        }
392        let env = self.loam_env(LoamEnv::Production);
393        if env == "production" || env == "staging" {
394            if let Some(contracts) = contracts {
395                self.handle_production_contracts(workspace_root, contracts)
396                    .await?;
397            }
398            return Ok(());
399        }
400
401        // ensure contract names are valid
402        if let Some(contracts) = contracts {
403            for (name, _) in contracts.iter().filter(|(_, settings)| settings.client) {
404                let wasm_path = workspace_root.join(format!("target/loam/{name}.wasm"));
405                if !wasm_path.exists() {
406                    return Err(Error::BadContractName(name.to_string()));
407                }
408            }
409        }
410        // Reorder package_names based on contracts order
411        let reordered_names = Self::reorder_package_names(&package_names, contracts);
412        for name in reordered_names {
413            let settings = match contracts {
414                Some(contracts) => contracts.get(&name as &str),
415                None => None,
416            };
417            let contract_id = if let Some(settings) = settings {
418                // Skip if contract is found and its `client` setting is false
419                if !settings.client {
420                    continue;
421                }
422                // If contract ID is set, use it directly
423                settings.id.clone()
424            } else {
425                None
426            };
427            let contract_id = if let Some(id) = contract_id {
428                // If we have a contract ID, use it
429                Contract::from_string(&id).map_err(|_| Error::InvalidContractID(id.clone()))?
430            } else {
431                // If we don't have a contract ID, proceed with installation and deployment
432                let wasm_path = workspace_root.join(format!("target/loam/{name}.wasm"));
433                if !wasm_path.exists() {
434                    return Err(Error::BadContractName(name.to_string()));
435                }
436                eprintln!("📲 installing {name:?} wasm bytecode on-chain...");
437                let hash = cli::contract::install::Cmd::parse_arg_vec(&[
438                    "--wasm",
439                    wasm_path
440                        .to_str()
441                        .expect("we do not support non-utf8 paths"),
442                ])?
443                .run_against_rpc_server(None, None)
444                .await?
445                .into_result()
446                .expect("no hash returned by 'contract install'")
447                .to_string();
448                eprintln!("    ↳ hash: {hash}");
449
450                // Check if we have an alias saved for this contract
451                let alias = Self::get_contract_alias(&name, workspace_root)?;
452                if let Some(contract_id) = alias {
453                    match self
454                        .contract_hash_matches(&contract_id, &hash, network, workspace_root)
455                        .await
456                    {
457                        Ok(true) => {
458                            eprintln!("✅ Contract {name:?} is up to date");
459                            continue;
460                        }
461                        Ok(false) => eprintln!("🔄 Updating contract {name:?}"),
462                        Err(e) => return Err(e),
463                    }
464                }
465
466                eprintln!("🪞 instantiating {name:?} smart contract");
467                let new_contract_id = cli::contract::deploy::wasm::Cmd::parse_arg_vec(&[
468                    "--alias",
469                    &name,
470                    "--wasm-hash",
471                    &hash,
472                ])?
473                .run_against_rpc_server(None, None)
474                .await?
475                .into_result()
476                .expect("no contract id returned by 'contract deploy'");
477                eprintln!("    ↳ contract_id: {new_contract_id}");
478
479                // Save the alias for future use
480                Self::save_contract_alias(&name, &new_contract_id, network, workspace_root)?;
481
482                new_contract_id
483            };
484
485            // Run init script if we're in development or test environment
486            if env == "development" || env == "testing" {
487                if let Some(settings) = settings {
488                    if let Some(init_script) = &settings.init {
489                        eprintln!("🚀 Running initialization script for {name:?}");
490                        self.run_init_script(&name, &contract_id, init_script)
491                            .await?;
492                    }
493                }
494            }
495            self.generate_contract_bindings(workspace_root, &name, &contract_id.to_string())
496                .await?;
497        }
498
499        Ok(())
500    }
501
502    fn resolve_line(re: &Regex, line: &str, shell: &str, flag: &str) -> Result<String, Error> {
503        let mut result = String::new();
504        let mut last_match = 0;
505        for cap in re.captures_iter(line) {
506            let whole_match = cap.get(0).unwrap();
507            result.push_str(&line[last_match..whole_match.start()]);
508            let cmd = &cap[1];
509            let output = Self::execute_subcommand(shell, flag, cmd)?;
510            result.push_str(&output);
511            last_match = whole_match.end();
512        }
513        result.push_str(&line[last_match..]);
514        Ok(result)
515    }
516
517    fn execute_subcommand(shell: &str, flag: &str, cmd: &str) -> Result<String, Error> {
518        match Command::new(shell).arg(flag).arg(cmd).output() {
519            Ok(output) => {
520                let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
521                let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
522
523                if output.status.success() {
524                    Ok(stdout)
525                } else {
526                    Err(Error::SubCommandExecutionFailure(cmd.to_string(), stderr))
527                }
528            }
529            Err(e) => Err(Error::SubCommandExecutionFailure(
530                cmd.to_string(),
531                e.to_string(),
532            )),
533        }
534    }
535
536    async fn run_init_script(
537        &self,
538        name: &str,
539        contract_id: &Contract,
540        init_script: &str,
541    ) -> Result<(), Error> {
542        let re = Regex::new(r"\$\((.*?)\)").expect("Invalid regex pattern");
543
544        let (shell, flag) = if cfg!(windows) {
545            ("cmd", "/C")
546        } else {
547            ("sh", "-c")
548        };
549
550        for line in init_script.lines() {
551            let line = line.trim();
552            if line.is_empty() {
553                continue;
554            }
555
556            // resolve any $() patterns
557            let resolved_line = Self::resolve_line(&re, line, shell, flag)?;
558            let parts = split(&resolved_line)
559                .ok_or_else(|| Error::InitParseFailure(resolved_line.to_string()))?;
560            let (source_account, command_parts): (Vec<_>, Vec<_>) = parts
561                .iter()
562                .partition(|&part| part.starts_with("STELLAR_ACCOUNT="));
563
564            let contract_id_arg = contract_id.to_string();
565            let mut args = vec!["--id", &contract_id_arg];
566            if let Some(account) = source_account.first() {
567                let account = account.strip_prefix("STELLAR_ACCOUNT=").unwrap();
568                args.extend_from_slice(&["--source-account", account]);
569            }
570            args.extend_from_slice(&["--"]);
571            args.extend(command_parts.iter().map(|s| s.as_str()));
572
573            eprintln!("  ↳ Executing: stellar contract invoke {}", args.join(" "));
574            let result = cli::contract::invoke::Cmd::parse_arg_vec(&args)?
575                .run_against_rpc_server(None, None)
576                .await?;
577            eprintln!("  ↳ Result: {result:?}");
578        }
579        eprintln!("✅ Initialization script for {name:?} completed successfully");
580        Ok(())
581    }
582}