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 #[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(¤t_env.network)?;
109 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 ¤t_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 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 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") .arg("--no-workspaces") .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") .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 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 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 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 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 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 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 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}