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(¤t_env.network)?;
99 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 ¤t_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 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 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 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 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 if !settings.client {
420 continue;
421 }
422 settings.id.clone()
424 } else {
425 None
426 };
427 let contract_id = if let Some(id) = contract_id {
428 Contract::from_string(&id).map_err(|_| Error::InvalidContractID(id.clone()))?
430 } else {
431 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 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 Self::save_contract_alias(&name, &new_contract_id, network, workspace_root)?;
481
482 new_contract_id
483 };
484
485 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 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}