1use anyhow::{anyhow, Result};
2use bitcoin::bip32::{DerivationPath, Xpriv};
3use bitcoin::secp256k1::Secp256k1;
4use bitcoin::Network as BitcoinNetwork;
5use bip39::Mnemonic;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::Path;
9use std::str::FromStr;
10use tokio::fs;
11use tokio::io::AsyncWriteExt;
12use tokio::process::Command;
13use std::process::Stdio;
14use tokio::io::AsyncBufReadExt;
15use tempfile::NamedTempFile;
16
17pub struct NetworkConfig {
18 pub stacks_node: String,
19}
20
21pub fn network_config(network: &str) -> NetworkConfig {
22 match network {
23 "devnet" => NetworkConfig { stacks_node: "http://localhost:3999".into() },
24 "testnet" => NetworkConfig { stacks_node: "https://api.testnet.hiro.so".into() },
25 "mainnet" => NetworkConfig { stacks_node: "https://api.hiro.so".into() },
26 other => panic!("Unknown network: {other}"),
27 }
28}
29
30#[derive(Debug, Deserialize)]
31struct ClarinetToml {
32 contracts: Option<HashMap<String, ContractEntry>>,
33}
34
35#[derive(Debug, Deserialize)]
36struct ContractEntry {
37 path: String,
38}
39
40#[derive(Debug, Deserialize)]
41struct DeploymentPlanFile {
42 plan: DeploymentPlan,
43}
44
45#[derive(Debug, Deserialize)]
46struct DeploymentPlan {
47 batches: Vec<DeploymentBatch>,
48}
49
50#[derive(Debug, Deserialize)]
51struct DeploymentBatch {
52 transactions: Vec<DeploymentTransaction>,
53}
54
55#[derive(Debug, Deserialize, Clone)]
56struct DeploymentTransaction {
57 #[serde(rename = "transaction-type")]
58 transaction_type: String,
59 #[serde(rename = "contract-name")]
60 contract_name: Option<String>,
61 #[serde(rename = "expected-sender")]
62 expected_sender: Option<String>,
63 cost: Option<u64>,
64 path: Option<String>,
65 #[serde(rename = "clarity-version")]
66 clarity_version: Option<u8>,
67}
68
69#[derive(Debug, Deserialize)]
70struct AccountResponse {
71 nonce: u64,
72}
73
74#[derive(Debug, Deserialize)]
75struct CoreInfoResponse {
76 burn_block_height: u64,
77 stacks_tip_height: u64,
78}
79
80#[derive(Serialize)]
81struct DeploymentInfo {
82 contract_id: String,
83 tx_id: String,
84 block_height: u64,
85}
86
87#[derive(Serialize)]
88struct DeploymentFile {
89 network: String,
90 deployed_at: String,
91 contracts: HashMap<String, DeploymentInfo>,
92}
93
94pub async fn deploy(network: &str) -> Result<()> {
97 if !Path::new("contracts/Clarinet.toml").exists() {
98 return Err(anyhow!(
99 "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
100 ));
101 }
102
103 if network == "testnet" || network == "mainnet" {
104 validate_settings_mnemonic(network)?;
105 }
106
107 let config = network_config(network);
108 println!("🚀 Deploying to {} ({})", network, config.stacks_node);
109
110 if network == "devnet" {
111 wait_for_node(&config.stacks_node).await?;
112 }
113
114 deploy_via_clarinet(network).await
115}
116
117async fn deploy_via_clarinet(network: &str) -> Result<()> {
120 let fee_flag = "--low-cost";
125
126 let contracts_dir = std::path::Path::new("contracts");
127 let ordered = resolve_deployment_order(contracts_dir).await?;
128 reorder_clarinet_toml(contracts_dir, &ordered).await?;
129
130 if network == "testnet" || network == "mainnet" {
133 println!("[deploy] Checking for contract name conflicts on {}...", network);
134 auto_version_conflicting_contracts(network).await?;
135 }
136
137 let clarinet_output = run_generate_and_apply(network, fee_flag).await?;
139
140 if clarinet_output.contains("ContractAlreadyExists") {
143 println!("[deploy] Unexpected conflict after versioning — re-resolving and retrying...");
144 auto_version_conflicting_contracts(network).await?;
145 let clarinet_output2 = run_generate_and_apply(network, fee_flag).await?;
146 return write_deployments_json_from_output(network, &clarinet_output2).await;
147 }
148
149 write_deployments_json_from_output(network, &clarinet_output).await
150}
151
152async fn reorder_clarinet_toml(
153 contracts_dir: &std::path::Path,
154 order: &[String],
155) -> anyhow::Result<()> {
156 let path = contracts_dir.join("Clarinet.toml");
157 let raw = fs::read_to_string(&path).await?;
158
159 let first_contract = raw.find("\n[contracts.").unwrap_or(raw.len());
162 let header = raw[..first_contract].to_string();
163
164 let mut blocks: HashMap<String, String> = HashMap::new();
166 let mut current_name: Option<String> = None;
167 let mut current_block = String::new();
168
169 for line in raw[first_contract..].lines() {
170 if let Some(name) = line.trim().strip_prefix("[contracts.").and_then(|s| s.strip_suffix(']')) {
171 if let Some(prev) = current_name.take() {
172 blocks.insert(prev, current_block.trim().to_string());
173 }
174 current_name = Some(name.to_string());
175 current_block = format!("{line}\n");
176 } else if current_name.is_some() {
177 current_block.push_str(line);
178 current_block.push('\n');
179 }
180 }
181 if let Some(prev) = current_name {
182 blocks.insert(prev, current_block.trim().to_string());
183 }
184
185 let mut output = header;
187 for name in order {
188 if let Some(block) = blocks.get(name) {
189 output.push('\n');
190 output.push_str(block);
191 output.push('\n');
192 }
193 }
194
195 fs::write(&path, output).await?;
196 println!("[deploy] Clarinet.toml reordered to respect dependency graph.");
197 Ok(())
198}
199
200async fn run_generate_and_apply(network: &str, fee_flag: &str) -> Result<String> {
202 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
204 if Path::new(&plan_path).exists() {
205 fs::remove_file(&plan_path).await?;
206 }
207
208 println!("[deploy] Generating deployment plan...");
209 let gen = Command::new("clarinet")
210 .args(["deployments", "generate", &format!("--{network}"), fee_flag])
211 .current_dir("contracts")
212 .status()
213 .await
214 .map_err(|_| anyhow!(
215 "clarinet is required. Install: brew install clarinet OR cargo install clarinet"
216 ))?;
217
218 if !gen.success() {
219 return Err(anyhow!(
220 "Failed to generate deployment plan.\n\
221 • Run `clarinet check` to validate your contracts.\n\
222 • Ensure settings/{}.toml has a valid mnemonic.",
223 capitalize(network)
224 ));
225 }
226
227 check_plan_fee(network)?;
228
229 if network == "devnet" {
230 return run_apply_devnet_direct(network).await;
231 }
232
233 println!("[deploy] Applying deployment plan to {}...", network);
234 let mut child = Command::new("clarinet")
235 .args(["deployments", "apply", "--no-dashboard", &format!("--{network}")])
236 .current_dir("contracts")
237 .stdin(Stdio::piped())
238 .stdout(Stdio::piped())
239 .stderr(Stdio::inherit())
240 .spawn()?;
241
242 let mut stdin = child.stdin.take().ok_or_else(|| anyhow!("Failed to open stdin"))?;
243 let stdout = child.stdout.take().ok_or_else(|| anyhow!("Failed to open stdout"))?;
244
245
246 let contracts_dir = std::path::Path::new("contracts");
247 let expected_count = resolve_deployment_order(contracts_dir).await?.len();
248
249 let mut confirmed_count = 0;
250 let mut broadcast_count = 0;
251
252
253 let mut reader = tokio::io::BufReader::new(stdout).lines();
254 let mut captured_stdout = String::new();
255
256
257 while let Ok(Some(line)) = reader.next_line().await {
258 println!("{}", line);
259 captured_stdout.push_str(&line);
260 captured_stdout.push('\n');
261
262 if line.contains("REDEPLOYMENT REQUIRED") || line.contains("out of sync") {
263 println!("[deploy] Error: Devnet is out of sync. You may need to restart Clarinet or increment contract version.");
264 let _ = child.kill().await;
265 return Err(anyhow!("Devnet redeployment required. Check your contract versions."));
266 }
267
268 if line.contains("Overwrite?") || line.contains("Confirm?") || line.contains("[Y/n]") {
270 let _ = stdin.write_all(b"y\n").await;
271 let _ = stdin.flush().await;
272 }
273 if line.contains("Broadcasted") && line.contains("ContractPublish(") {
274 broadcast_count += 1;
275 println!("[deploy] Broadcast progress: {}/{}", broadcast_count, expected_count);
276 }
277
278 if line.contains("Confirmed Publish") || line.contains("Published") {
279 confirmed_count += 1;
280 println!("[deploy] Confirmation progress: {}/{}", confirmed_count, expected_count);
281 }
282
283 if confirmed_count >= expected_count {
284 println!("[deploy] All contracts confirmed. Finalizing JSON...");
285 let _ = child.kill().await; break;
287 }
288
289 if broadcast_count >= expected_count {
290 println!("[deploy] All contracts broadcasted. Finalizing JSON...");
291 let _ = child.kill().await; break;
293 }
294
295 }
296 Ok(captured_stdout)
297}
298
299async fn run_apply_devnet_direct(network: &str) -> Result<String> {
300 println!("[deploy] Applying deployment plan to devnet...");
301 let plan = read_deployment_plan(network).await?;
302 let transactions = flatten_contract_publishes(&plan);
303 if transactions.is_empty() {
304 return Err(anyhow!("No contract publish transactions found in the devnet deployment plan."));
305 }
306
307 let settings_raw = fs::read_to_string("contracts/settings/Devnet.toml").await?;
308 let mnemonic = parse_mnemonic(&settings_raw)
309 .ok_or_else(|| anyhow!("No deployer mnemonic found in contracts/settings/Devnet.toml"))?;
310 let derivation = parse_deployer_derivation(&settings_raw)
311 .unwrap_or_else(|| "m/44'/5757'/0'/0/0".to_string());
312 let sender_key = derive_private_key_from_mnemonic(&mnemonic, &derivation)?;
313
314 let expected_sender = transactions
315 .first()
316 .and_then(|tx| tx.expected_sender.clone())
317 .ok_or_else(|| anyhow!("No expected sender found in the devnet deployment plan."))?;
318 let mut nonce = fetch_local_core_nonce(&expected_sender).await?;
319 let script_path = write_devnet_broadcast_script()?;
320 let mut captured_stdout = String::new();
321
322 println!("[deploy] Broadcasting transactions to http://localhost:20443");
323
324 for tx in transactions {
325 let contract_name = tx.contract_name.clone()
326 .ok_or_else(|| anyhow!("Missing contract name in deployment plan."))?;
327 let contract_path = tx.path.clone()
328 .ok_or_else(|| anyhow!("Missing contract path for {contract_name} in deployment plan."))?;
329 let fee = tx.cost.unwrap_or(0);
330 let args = serde_json::json!({
331 "contractName": contract_name,
332 "codePath": contract_path,
333 "senderKey": sender_key,
334 "fee": fee.to_string(),
335 "nonce": nonce.to_string(),
336 "clarityVersion": tx.clarity_version,
337 });
338
339 let output = Command::new("node")
340 .arg(&script_path)
341 .arg(args.to_string())
342 .current_dir("contracts")
343 .output()
344 .await
345 .map_err(|_| anyhow!("node is required to deploy directly to devnet"))?;
346
347 if !output.status.success() {
348 let stderr = String::from_utf8_lossy(&output.stderr);
349 let stdout = String::from_utf8_lossy(&output.stdout);
350 return Err(anyhow!(
351 "Direct devnet deployment failed for {}.\nstdout:\n{}\nstderr:\n{}",
352 tx.contract_name.as_deref().unwrap_or("unknown contract"),
353 stdout.trim(),
354 stderr.trim(),
355 ));
356 }
357
358 let stdout = String::from_utf8_lossy(&output.stdout);
359 let result: serde_json::Value = serde_json::from_str(stdout.trim())
360 .map_err(|e| anyhow!("Failed to parse devnet broadcast response: {e}\nRaw output: {}", stdout.trim()))?;
361 let txid = result
362 .get("txid")
363 .and_then(|value| value.as_str())
364 .ok_or_else(|| anyhow!("Devnet broadcast response did not include a txid: {}", stdout.trim()))?;
365
366 println!("🟦 Publish {}.{} Transaction broadcast {}", expected_sender, tx.contract_name.as_deref().unwrap_or(""), txid);
367 captured_stdout.push_str(&format!(
368 "Broadcasted ContractPublish(StandardPrincipalData({}), ContractName(\"{}\"), \"{}\")\n",
369 expected_sender,
370 tx.contract_name.as_deref().unwrap_or(""),
371 txid,
372 ));
373 nonce += 1;
374 }
375
376 Ok(captured_stdout)
377}
378
379async fn read_deployment_plan(network: &str) -> Result<DeploymentPlanFile> {
380 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
381 let raw = fs::read_to_string(&plan_path).await
382 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
383 serde_yaml::from_str(&raw)
384 .map_err(|e| anyhow!("Failed to parse deployment plan at {plan_path}: {e}"))
385}
386
387fn flatten_contract_publishes(plan: &DeploymentPlanFile) -> Vec<DeploymentTransaction> {
388 plan.plan
389 .batches
390 .iter()
391 .flat_map(|batch| batch.transactions.iter())
392 .filter(|tx| tx.transaction_type == "contract-publish")
393 .cloned()
394 .collect()
395}
396
397fn write_devnet_broadcast_script() -> Result<std::path::PathBuf> {
398 let mut file = NamedTempFile::new()?;
399 let script = r#"
400import fs from 'fs';
401import { createRequire } from 'module';
402
403const require = createRequire(`${process.cwd()}/package.json`);
404const {
405 makeContractDeploy,
406 AnchorMode,
407 PostConditionMode,
408 broadcastRawTransaction,
409} = require('@stacks/transactions');
410
411const input = JSON.parse(process.argv[2]);
412const codeBody = fs.readFileSync(input.codePath, 'utf8');
413
414const transaction = await makeContractDeploy({
415 contractName: input.contractName,
416 codeBody,
417 senderKey: input.senderKey,
418 fee: BigInt(input.fee),
419 nonce: BigInt(input.nonce),
420 network: 'testnet',
421 anchorMode: AnchorMode.OnChainOnly,
422 postConditionMode: PostConditionMode.Allow,
423 ...(typeof input.clarityVersion === 'number' ? { clarityVersion: input.clarityVersion } : {}),
424});
425
426const response = await broadcastRawTransaction(
427 transaction.serialize(),
428 'http://localhost:20443/v2/transactions',
429);
430
431console.log(JSON.stringify(response));
432if (!response?.txid) {
433 process.exit(1);
434}
435"#;
436 use std::io::Write;
437 file.write_all(script.as_bytes())?;
438 let (_, path) = file.keep()?;
439 Ok(path)
440}
441
442async fn fetch_local_core_nonce(address: &str) -> Result<u64> {
443 let client = reqwest::Client::builder()
444 .timeout(std::time::Duration::from_secs(3))
445 .build()?;
446 let url = format!("http://localhost:20443/v2/accounts/{address}?proof=0");
447 let response = client.get(&url).send().await
448 .map_err(|e| anyhow!("Failed to fetch local core account state from {url}: {e}"))?;
449
450 if !response.status().is_success() {
451 let status = response.status();
452 let body = response.text().await.unwrap_or_default();
453 return Err(anyhow!(
454 "Local core node returned {} for {}: {}",
455 status,
456 url,
457 body
458 ));
459 }
460
461 let account: AccountResponse = response.json().await?;
462 Ok(account.nonce)
463}
464
465fn derive_private_key_from_mnemonic(mnemonic: &str, derivation: &str) -> Result<String> {
466 let mnemonic = Mnemonic::parse_normalized(mnemonic)
467 .map_err(|e| anyhow!("Invalid mnemonic in devnet settings: {e}"))?;
468 let seed = mnemonic.to_seed_normalized("");
469 let secp = Secp256k1::new();
470 let root = Xpriv::new_master(BitcoinNetwork::Testnet, &seed)
471 .map_err(|e| anyhow!("Failed to derive root key from mnemonic: {e}"))?;
472 let path = DerivationPath::from_str(derivation)
473 .map_err(|e| anyhow!("Invalid devnet derivation path {derivation}: {e}"))?;
474 let child = root.derive_priv(&secp, &path)
475 .map_err(|e| anyhow!("Failed to derive child key {derivation}: {e}"))?;
476 Ok(format!("{}01", hex::encode(child.private_key.secret_bytes())))
477}
478
479pub async fn resolve_deployment_order(contracts_dir: &std::path::Path) -> anyhow::Result<Vec<String>> {
480 let clarinet_raw = fs::read_to_string(contracts_dir.join("Clarinet.toml")).await?;
481 let clarinet: ClarinetToml = toml::from_str(&clarinet_raw)
482 .map_err(|e| anyhow::anyhow!("Failed to parse Clarinet.toml: {e}"))?;
483
484 let contract_map = clarinet.contracts.unwrap_or_default();
485 let known: HashSet<String> = contract_map.keys().cloned().collect();
486
487 let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
489
490 for (name, entry) in &contract_map {
491 let clar_path = contracts_dir.join(&entry.path);
492 let source = fs::read_to_string(&clar_path).await.unwrap_or_default();
493 let deps = parse_local_deps(&source, &known);
494
495 if !deps.is_empty() {
496 println!("[deploy] {name} depends on: {}", deps.join(", "));
497 }
498
499 dep_graph.insert(name.clone(), deps);
500 }
501
502 let order = topological_sort(&dep_graph)?;
503 println!("[deploy] Deployment order: {}", order.join(" → "));
504
505 Ok(order)
506}
507
508fn check_plan_fee(network: &str) -> Result<()> {
510 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
511 let plan_raw = std::fs::read_to_string(&plan_path).unwrap_or_default();
512
513 let total_micro_stx: u64 = plan_raw
515 .lines()
516 .filter_map(|line| {
517 let trimmed = line.trim();
518 if trimmed.starts_with("cost:") {
519 trimmed.split_whitespace().nth(1)?.parse::<u64>().ok()
520 } else {
521 None
522 }
523 })
524 .sum();
525 if total_micro_stx > 0 {
526 println!("[deploy] Estimated fee: {:.6} STX", total_micro_stx as f64 / 1_000_000.0);
527 }
528
529 Ok(())
530}
531
532
533async fn auto_version_conflicting_contracts(network: &str) -> Result<()> {
534 let config = network_config(network);
535 let client = reqwest::Client::builder()
536 .timeout(std::time::Duration::from_secs(5))
537 .build()?;
538
539 let _ = Command::new("clarinet")
540 .args(["deployments", "generate", &format!("--{}", network), "--low-cost"])
541 .current_dir("contracts")
542 .status()
543 .await;
544
545 let deployer = get_deployer_from_plan(network).await?;
546 println!("[deploy] Using derived deployer address: {}", deployer);
547
548 let base_dir = Path::new("contracts");
549 let clarinet_path = base_dir.join("Clarinet.toml");
550 let clarinet_raw = fs::read_to_string(&clarinet_path).await?;
551 let mut clarinet_content = clarinet_raw.clone();
552
553 let clarinet_struct: ClarinetToml = toml::from_str(&clarinet_raw)?;
554 let contracts = clarinet_struct.contracts.unwrap_or_default();
555
556 let mut any_changes = false;
557
558 for (current_name, entry) in &contracts {
559 let base_name = strip_version_suffix(current_name);
560
561 let correct_name = find_next_free_name(&client, &config.stacks_node, &deployer, &base_name).await?;
563
564 if current_name == &correct_name {
565 continue;
566 }
567
568 println!("[deploy] Conflict detected: '{}' already exists on-chain. Renaming to '{}'", current_name, correct_name);
569
570 let old_file_path = base_dir.join(&entry.path);
571 let new_rel_path = format!("contracts/{}.clar", correct_name);
572 let new_file_path = base_dir.join(&new_rel_path);
573
574 if old_file_path.exists() {
575 fs::rename(&old_file_path, &new_file_path).await?;
576 println!("[deploy] Renamed file: {} -> {}", entry.path, new_rel_path);
577 }
578
579 let old_header = format!("[contracts.{}]", current_name);
580 let new_header = format!("[contracts.{}]", correct_name);
581 clarinet_content = clarinet_content.replace(&old_header, &new_header);
582
583 let old_path_line = format!("path = \"{}\"", entry.path);
584 let new_path_line = format!("path = \"{}\"", new_rel_path);
585 clarinet_content = clarinet_content.replace(&old_path_line, &new_path_line);
586
587 let dot_old_name = format!(".{}", current_name);
588 let dot_new_name = format!(".{}", correct_name);
589
590 for (_, other_entry) in &contracts {
591 let p = base_dir.join(&other_entry.path);
592
593 let target_file = if p == old_file_path { &new_file_path } else { &p };
594
595 if target_file.exists() {
596 let source = fs::read_to_string(target_file).await?;
597 if source.contains(&dot_old_name) {
598 let updated_source = source.replace(&dot_old_name, &dot_new_name);
599 fs::write(target_file, updated_source).await?;
600 println!("[deploy] Updated internal reference in {}", target_file.display());
601 }
602 }
603 }
604
605 any_changes = true;
606 }
607
608 if any_changes {
609 fs::write(&clarinet_path, &clarinet_content).await?;
610
611 for plan_name in [
614 "default.devnet-plan.yaml",
615 "default.simnet-plan.yaml",
616 "default.testnet-plan.yaml",
617 "default.mainnet-plan.yaml",
618 ] {
619 let plan_path = base_dir.join("deployments").join(plan_name);
620 let _ = fs::remove_file(plan_path).await;
621 }
622
623 println!("[deploy] Clarinet.toml updated with new versions.");
624 let _ = Command::new("stacksdapp").arg("generate").status().await;
626 }
627
628 Ok(())
629}
630
631async fn get_deployer_from_plan(network: &str) -> Result<String> {
633 let plan_path = format!("contracts/deployments/default.{}-plan.yaml", network);
634 let content = fs::read_to_string(&plan_path).await
635 .map_err(|_| anyhow!("Clarinet plan not found at {}. Is the path correct?", plan_path))?;
636
637 for line in content.lines() {
638 let trimmed = line.trim();
639 if trimmed.starts_with("expected-sender:") {
640 return Ok(trimmed.split(':').nth(1).unwrap_or("").trim().to_string());
641 }
642 }
643 Err(anyhow!("Could not find 'expected-sender' in the deployment plan. Check your mnemonic in settings."))
644}
645async fn find_next_free_name(
648 client: &reqwest::Client,
649 node: &str,
650 deployer: &str,
651 base_name: &str,
652) -> Result<String> {
653 let url = format!("{node}/v2/contracts/source/{deployer}/{base_name}");
655 let base_free = !client.get(&url).send().await
656 .map(|r| r.status().is_success())
657 .unwrap_or(false);
658
659 if base_free {
660 return Ok(base_name.to_string());
661 }
662
663 let mut version = 2u32;
665 loop {
666 let candidate = format!("{base_name}-v{version}");
667 let url = format!("{node}/v2/contracts/interface/{deployer}/{candidate}");
668 let taken = client.get(&url).send().await
669 .map(|r| r.status().is_success())
670 .unwrap_or(false);
671 if !taken {
672 return Ok(candidate);
673 }
674 version += 1;
675 if version > 99 {
676 return Err(anyhow!(
677 "Could not find a free version for '{base_name}' (tried up to v99). Consider using a fresh deployer address."
678 ));
679 }
680 }
681}
682
683
684fn strip_version_suffix(name: &str) -> String {
686 if let Some(idx) = name.rfind("-v") {
688 let suffix = &name[idx + 2..];
689 if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
690 return name[..idx].to_string();
691 }
692 }
693 name.to_string()
694}
695
696fn validate_settings_mnemonic(network: &str) -> Result<()> {
699 let path = format!("contracts/settings/{}.toml", capitalize(network));
700 let raw = std::fs::read_to_string(&path)
701 .map_err(|_| anyhow!("Settings file not found: {path}"))?;
702 let mnemonic = parse_mnemonic(&raw).unwrap_or_default();
703 if mnemonic.is_empty() || mnemonic.contains('<') || mnemonic.contains('>') {
704 return Err(anyhow!(
705 "No valid mnemonic in {path}.\n\
706 Add your deployer seed phrase:\n\n\
707 [accounts.deployer]\n\
708 mnemonic = \"your 24 words here\"\n\n\
709 Get testnet STX: https://explorer.hiro.so/sandbox/faucet?chain=testnet"
710 ));
711 }
712 Ok(())
713}
714
715fn parse_mnemonic(toml_raw: &str) -> Option<String> {
716 let mut in_deployer = false;
717 for line in toml_raw.lines() {
718 let trimmed = line.trim();
719 if trimmed == "[accounts.deployer]" { in_deployer = true; continue; }
720 if trimmed.starts_with('[') { in_deployer = false; }
721 if in_deployer && trimmed.starts_with("mnemonic") {
722 if let Some(val) = trimmed.splitn(2, '=').nth(1) {
723 return Some(val.trim().trim_matches('"').to_string());
724 }
725 }
726 }
727 None
728}
729
730fn parse_deployer_derivation(toml_raw: &str) -> Option<String> {
731 let mut in_deployer = false;
732 for line in toml_raw.lines() {
733 let trimmed = line.trim();
734 if trimmed == "[accounts.deployer]" { in_deployer = true; continue; }
735 if trimmed.starts_with('[') { in_deployer = false; }
736 if in_deployer && trimmed.starts_with("derivation") {
737 if let Some(val) = trimmed.splitn(2, '=').nth(1) {
738 return Some(val.trim().trim_matches('"').to_string());
739 }
740 }
741 }
742 None
743}
744
745async fn wait_for_node(url: &str) -> Result<()> {
746 let client = reqwest::Client::builder()
747 .timeout(std::time::Duration::from_secs(2))
748 .build()?;
749 println!("[deploy] Waiting for Stacks node at {url}...");
750 for attempt in 1..=60 {
751 if client.get(&format!("{url}/v2/info")).send().await
752 .map(|r| r.status().is_success()).unwrap_or(false)
753 {
754 println!("[deploy] ✔ Node is ready");
755 return Ok(());
756 }
757 if attempt % 10 == 0 { println!("[deploy] Still waiting... ({attempt}s)"); }
758 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
759 }
760 Err(anyhow!(
761 "Stacks node at {url} did not become ready after 60s.\n\
762 Make sure `stacksdapp dev` is running and Docker is started."
763 ))
764}
765
766async fn write_deployments_json_from_output(network: &str, output: &str) -> Result<()> {
767 let mut txid_map: HashMap<String, String> = HashMap::new();
768 let mut actual_deployer = None;
769 for line in output.lines() {
770 if line.contains("Broadcasted") {
771 if let Some(start) = line.find("StandardPrincipalData(") {
773 let rest = &line[start + "StandardPrincipalData(".len()..];
774 if let Some(end) = rest.find(')') {
775 actual_deployer = Some(rest[..end].to_string());
776 }
777 }
778
779 let cn_marker = "ContractName(\"";
781 if let Some(pos) = line.find(cn_marker) {
782 let rest = &line[pos + cn_marker.len()..];
783 if let Some(end) = rest.find('"') {
784 let contract_name = rest[..end].to_string();
785
786 let parts: Vec<&str> = line.split('"').collect();
789 for part in parts {
790 if part.len() == 64 && part.chars().all(|c| c.is_ascii_hexdigit()) {
791 txid_map.insert(contract_name.clone(), part.to_string());
792 }
793 }
794 }
795 }
796 }
797 }
798 let settings_file = format!("contracts/settings/{}.toml", capitalize(network));
799 let settings_raw = fs::read_to_string(&settings_file).await.unwrap_or_default();
800
801 let deployer_address = actual_deployer
802 .or_else(|| parse_deployer_address_from_settings(&settings_raw))
803 .unwrap_or_else(|| "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM".to_string());
804
805 let clarinet_raw = fs::read_to_string("contracts/Clarinet.toml").await?;
806 let clarinet: ClarinetToml = toml::from_str(&clarinet_raw)
807 .map_err(|e| anyhow!("Failed to parse Clarinet.toml: {e}"))?;
808 let contract_names: Vec<String> = clarinet
809 .contracts
810 .as_ref()
811 .map(|contracts| contracts.keys().cloned().collect())
812 .unwrap_or_default();
813
814 if network == "devnet" {
815 wait_for_devnet_contracts(&deployer_address, &contract_names).await?;
816 }
817
818 let mut contracts_map = HashMap::new();
819 let timestamp = chrono::Utc::now().to_rfc3339();
820
821 for name in contract_names {
822 let contract_id = format!("{deployer_address}.{name}");
823 let txid = txid_map.get(&name)
824 .map(|t| format!("0x{t}"))
825 .unwrap_or_default();
826 println!(" ✔ {name} | txid {} | address {contract_id}",
827 if txid.is_empty() { "(pending)" } else { &txid });
828 contracts_map.insert(name.clone(), DeploymentInfo {
829 contract_id, tx_id: txid, block_height: 0,
830 });
831 }
832
833 let json = serde_json::to_string_pretty(&DeploymentFile {
834 network: network.to_string(),
835 deployed_at: timestamp,
836 contracts: contracts_map,
837 })?;
838
839 let out_path = Path::new("frontend/src/generated/deployments.json");
840 if let Some(p) = out_path.parent() { fs::create_dir_all(p).await?; }
841 fs::write(out_path, &json).await?;
842 println!("\n[deploy] Written to {}", out_path.display());
843 Ok(())
844}
845
846async fn wait_for_devnet_contracts(deployer: &str, contract_names: &[String]) -> Result<()> {
847 if contract_names.is_empty() {
848 return Ok(());
849 }
850
851 let client = reqwest::Client::builder()
852 .timeout(std::time::Duration::from_secs(3))
853 .build()?;
854 let node = "http://localhost:20443";
855 let initial_info = fetch_local_core_info().await.ok();
856
857 println!("[deploy] Verifying contract publish on local devnet core node...");
858 for attempt in 1..=30 {
859 let mut pending = Vec::new();
860
861 for contract_name in contract_names {
862 let url = format!(
863 "{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0"
864 );
865 let deployed = client
866 .get(&url)
867 .send()
868 .await
869 .map(|response| response.status().is_success())
870 .unwrap_or(false);
871
872 if !deployed {
873 pending.push(contract_name.clone());
874 }
875 }
876
877 if pending.is_empty() {
878 println!("[deploy] ✔ Local devnet core node reports all contracts deployed");
879 return Ok(());
880 }
881
882 if attempt == 1 || attempt % 5 == 0 {
883 println!(
884 "[deploy] Waiting for devnet core to expose: {}",
885 pending.join(", ")
886 );
887 }
888
889 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
890 }
891
892 let nonce = fetch_local_core_nonce(deployer).await.unwrap_or_default();
893 let stacks_api_healthy = probe_stacks_api_health().await.unwrap_or(false);
894 let final_info = fetch_local_core_info().await.ok();
895 let stall_hint = match (initial_info, final_info) {
896 (Some(start), Some(end))
897 if start.burn_block_height == end.burn_block_height
898 && start.stacks_tip_height == end.stacks_tip_height =>
899 {
900 format!(
901 "Local devnet appears stalled: burn block height stayed at {} and stacks tip height stayed at {} while waiting for confirmation.",
902 end.burn_block_height, end.stacks_tip_height
903 )
904 }
905 _ => "Local devnet tip did move during the wait, so the publish appears to be stuck independently of tip progression.".to_string(),
906 };
907
908 Err(anyhow!(
909 "Devnet deploy did not finalize on the local Stacks core node.\n\
910 The contract source never became available at http://localhost:20443 and the deployer nonce is still {nonce}.\n\
911 This means the publish did not finalize on core, even if the explorer/mempool UI appears to show it.\n\
912 {stall_hint}\n\
913 {api_hint}\n\
914 Try restarting devnet with `stacksdapp clean` and `stacksdapp dev`, then deploy again."
915 ,
916 stall_hint = stall_hint,
917 api_hint = if stacks_api_healthy {
918 "Local stacks-api responded normally, so the failure is on the core-chain side."
919 } else {
920 "Local stacks-api/indexer also appears unhealthy, so the explorer UI may be stale or misleading."
921 }
922 ))
923}
924
925async fn probe_stacks_api_health() -> Result<bool> {
926 let client = reqwest::Client::builder()
927 .timeout(std::time::Duration::from_secs(2))
928 .build()?;
929 Ok(client
930 .get("http://localhost:3999/v2/info")
931 .send()
932 .await
933 .map(|response| response.status().is_success())
934 .unwrap_or(false))
935}
936
937async fn fetch_local_core_info() -> Result<CoreInfoResponse> {
938 let client = reqwest::Client::builder()
939 .timeout(std::time::Duration::from_secs(2))
940 .build()?;
941 let response = client
942 .get("http://localhost:20443/v2/info")
943 .send()
944 .await?;
945 let response = response.error_for_status()?;
946 Ok(response.json().await?)
947}
948
949
950fn parse_deployer_address_from_settings(toml_raw: &str) -> Option<String> {
951 for line in toml_raw.lines() {
952 let line = line.trim();
953 if line.starts_with("# stx_address:") {
954 return line.split(':').nth(1).map(|s| s.trim().to_string());
955 }
956 }
957 None
958}
959fn parse_local_deps(source: &str, known_contracts: &HashSet<String>) -> Vec<String> {
960 let mut deps = Vec::new();
961
962 for line in source.lines() {
963 let trimmed = line.trim();
964
965 for pattern in &["contract-call? .", "use-trait "] {
968 if let Some(pos) = trimmed.find(pattern) {
969 let after = &trimmed[pos + pattern.len()..];
970 let name: String = after
972 .chars()
973 .take_while(|c| !c.is_whitespace() && *c != '.')
974 .collect();
975
976 if !name.is_empty() && known_contracts.contains(&name) {
977 deps.push(name);
978 }
979 }
980 }
981 }
982
983 deps.sort();
984 deps.dedup();
985 deps
986}
987
988fn topological_sort(
989 contracts: &HashMap<String, Vec<String>>, ) -> anyhow::Result<Vec<String>> {
991 let mut in_degree: HashMap<&str, usize> = contracts
993 .keys()
994 .map(|k| (k.as_str(), 0))
995 .collect();
996
997 for deps in contracts.values() {
998 for dep in deps {
999 *in_degree.entry(dep.as_str()).or_insert(0) += 1;
1000 }
1001 }
1002
1003 let mut queue: VecDeque<&str> = in_degree
1005 .iter()
1006 .filter(|(_, °)| deg == 0)
1007 .map(|(&name, _)| name)
1008 .collect();
1009
1010 let mut queue_vec: Vec<&str> = queue.drain(..).collect();
1012 queue_vec.sort();
1013 queue.extend(queue_vec);
1014
1015 let mut sorted = Vec::new();
1016
1017 while let Some(node) = queue.pop_front() {
1018 sorted.push(node.to_string());
1019
1020 let mut next: Vec<&str> = contracts
1022 .iter()
1023 .filter(|(_, deps)| deps.iter().any(|d| d == node))
1024 .map(|(name, _)| name.as_str())
1025 .collect();
1026 next.sort();
1027
1028 for dependent in next {
1029 let deg = in_degree.entry(dependent).or_insert(0);
1030 *deg = deg.saturating_sub(1);
1031 if *deg == 0 {
1032 queue.push_back(dependent);
1033 }
1034 }
1035 }
1036
1037 if sorted.len() != contracts.len() {
1038 return Err(anyhow::anyhow!(
1039 "Circular contract dependency detected.\n\
1040 Check your contracts for circular contract-call? references.\n\
1041 Involved contracts: {}",
1042 contracts
1043 .keys()
1044 .filter(|k| !sorted.contains(k))
1045 .cloned()
1046 .collect::<Vec<_>>()
1047 .join(", ")
1048 ));
1049 }
1050
1051 Ok(sorted)
1052}
1053
1054fn capitalize(s: &str) -> String {
1055 let mut c = s.chars();
1056 match c.next() {
1057 None => String::new(),
1058 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1059 }
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::strip_version_suffix;
1065
1066 #[test]
1067 fn test_strip_version_suffix() {
1068 assert_eq!(strip_version_suffix("counter"), "counter");
1069 assert_eq!(strip_version_suffix("counter-v2"), "counter");
1070 assert_eq!(strip_version_suffix("counter-v3"), "counter");
1071 assert_eq!(strip_version_suffix("counter-v10"), "counter");
1072 assert_eq!(strip_version_suffix("my-token-v2"), "my-token");
1073 assert_eq!(strip_version_suffix("counter-v"), "counter-v");
1075 assert_eq!(strip_version_suffix("counter-vault"), "counter-vault");
1076 }
1077}