1use anyhow::{anyhow, Result};
2use bip39::Mnemonic;
3use bitcoin::bip32::{DerivationPath, Xpriv};
4use bitcoin::secp256k1::Secp256k1;
5use bitcoin::Network as BitcoinNetwork;
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet, VecDeque};
8use std::path::Path;
9use std::process::Stdio;
10use std::str::FromStr;
11use tempfile::NamedTempFile;
12use tokio::fs;
13use tokio::io::AsyncBufReadExt;
14use tokio::io::AsyncWriteExt;
15use tokio::process::Command;
16
17pub struct NetworkConfig {
18 pub stacks_node: String,
19}
20
21pub fn network_config(network: &str) -> Result<NetworkConfig> {
22 match network {
23 "devnet" => Ok(NetworkConfig {
24 stacks_node: "http://localhost:3999".into(),
25 }),
26 "testnet" => Ok(NetworkConfig {
27 stacks_node: "https://api.testnet.hiro.so".into(),
28 }),
29 "mainnet" => Ok(NetworkConfig {
30 stacks_node: "https://api.hiro.so".into(),
31 }),
32 other => Err(anyhow!(
33 "Unknown network '{other}'. Expected one of: devnet | testnet | mainnet"
34 )),
35 }
36}
37
38#[derive(Debug, Deserialize)]
39struct ClarinetToml {
40 contracts: Option<HashMap<String, ContractEntry>>,
41}
42
43#[derive(Debug, Deserialize)]
44struct ContractEntry {
45 path: String,
46}
47
48#[derive(Debug, Deserialize, Serialize)]
49struct DeploymentPlanFile {
50 plan: DeploymentPlan,
51}
52
53#[derive(Debug, Deserialize, Serialize)]
54struct DeploymentPlan {
55 batches: Vec<DeploymentBatch>,
56}
57
58#[derive(Debug, Deserialize, Serialize)]
59struct DeploymentBatch {
60 transactions: Vec<DeploymentTransaction>,
61}
62
63#[derive(Debug, Deserialize, Serialize, Clone)]
64struct DeploymentTransaction {
65 #[serde(rename = "transaction-type")]
66 transaction_type: String,
67 #[serde(rename = "contract-name")]
68 contract_name: Option<String>,
69 #[serde(rename = "expected-sender")]
70 expected_sender: Option<String>,
71 cost: Option<u64>,
72 path: Option<String>,
73 #[serde(rename = "clarity-version")]
74 clarity_version: Option<u8>,
75}
76
77#[derive(Debug, Deserialize)]
78struct AccountResponse {
79 nonce: u64,
80}
81
82#[derive(Debug, Deserialize)]
83struct CoreInfoResponse {
84 burn_block_height: u64,
85 stacks_tip_height: u64,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89struct DeploymentInfo {
90 contract_id: String,
91 tx_id: String,
92 block_height: u64,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
96struct DeploymentFile {
97 network: String,
98 deployed_at: String,
99 contracts: HashMap<String, DeploymentInfo>,
100}
101
102pub async fn wait_for_devnet_node() -> Result<()> {
105 wait_for_node("http://localhost:3999").await
106}
107
108pub async fn deploy(network: &str, contract: Option<&str>, dry_run: bool) -> Result<()> {
109 if !Path::new("contracts/Clarinet.toml").exists() {
110 return Err(anyhow!(
111 "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
112 ));
113 }
114
115 if network == "testnet" || network == "mainnet" {
116 validate_settings_mnemonic(network)?;
117 }
118
119 let config = network_config(network)?;
120 println!("🚀 Deploying to {} ({})", network, config.stacks_node);
121 if let Some(name) = contract {
122 println!("[deploy] Contract filter enabled: {name}");
123 }
124 if dry_run {
125 println!("[deploy] Dry run enabled: plan will not be applied.");
126 }
127
128 if network == "devnet" {
129 wait_for_node(&config.stacks_node).await?;
130 }
131
132 deploy_via_clarinet(network, contract, dry_run).await
133}
134
135async fn deploy_via_clarinet(network: &str, contract: Option<&str>, dry_run: bool) -> Result<()> {
138 let fee_flag = "--low-cost";
139
140 let contracts_dir = std::path::Path::new("contracts");
141 let ordered = resolve_deployment_order(contracts_dir).await?;
142 if let Some(name) = contract {
143 ensure_contract_exists(&ordered, name)?;
144 }
145 reorder_clarinet_toml(contracts_dir, &ordered).await?;
146
147 if network == "testnet" || network == "mainnet" {
148 println!(
149 "[deploy] Checking for contract name conflicts on {}...",
150 network
151 );
152 auto_version_conflicting_contracts(network, contract).await?;
153 }
154
155 let clarinet_output = run_generate_and_apply(network, fee_flag, contract, dry_run).await?;
156
157 if dry_run {
158 return Ok(());
159 }
160 if clarinet_output.contains("ContractAlreadyExists") {
161 println!("[deploy] Unexpected conflict after versioning — re-resolving and retrying...");
162 auto_version_conflicting_contracts(network, contract).await?;
163 let clarinet_output2 = run_generate_and_apply(network, fee_flag, contract, dry_run).await?;
164 return write_deployments_json_from_output(network, &clarinet_output2, contract).await;
165 }
166
167 write_deployments_json_from_output(network, &clarinet_output, contract).await
168}
169
170async fn reorder_clarinet_toml(
171 contracts_dir: &std::path::Path,
172 order: &[String],
173) -> anyhow::Result<()> {
174 let path = contracts_dir.join("Clarinet.toml");
175 let raw = fs::read_to_string(&path).await?;
176
177 let first_contract = raw.find("\n[contracts.").unwrap_or(raw.len());
178 let header = raw[..first_contract].to_string();
179
180 let mut blocks: HashMap<String, String> = HashMap::new();
182 let mut current_name: Option<String> = None;
183 let mut current_block = String::new();
184
185 for line in raw[first_contract..].lines() {
186 if let Some(name) = line
187 .trim()
188 .strip_prefix("[contracts.")
189 .and_then(|s| s.strip_suffix(']'))
190 {
191 if let Some(prev) = current_name.take() {
192 blocks.insert(prev, current_block.trim().to_string());
193 }
194 current_name = Some(name.to_string());
195 current_block = format!("{line}\n");
196 } else if current_name.is_some() {
197 current_block.push_str(line);
198 current_block.push('\n');
199 }
200 }
201 if let Some(prev) = current_name {
202 blocks.insert(prev, current_block.trim().to_string());
203 }
204
205 let mut output = header;
207 for name in order {
208 if let Some(block) = blocks.get(name) {
209 output.push('\n');
210 output.push_str(block);
211 output.push('\n');
212 }
213 }
214
215 fs::write(&path, output).await?;
216 println!("[deploy] Clarinet.toml reordered to respect dependency graph.");
217 Ok(())
218}
219
220async fn run_generate_and_apply(
222 network: &str,
223 fee_flag: &str,
224 contract: Option<&str>,
225 dry_run: bool,
226) -> Result<String> {
227 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
229 if Path::new(&plan_path).exists() {
230 fs::remove_file(&plan_path).await?;
231 }
232
233 println!("[deploy] Generating deployment plan...");
234 let gen = Command::new("clarinet")
235 .args(["deployments", "generate", &format!("--{network}"), fee_flag])
236 .current_dir("contracts")
237 .status()
238 .await
239 .map_err(|_| {
240 anyhow!(
241 "clarinet is required. Install: brew install clarinet OR cargo install clarinet"
242 )
243 })?;
244
245 if !gen.success() {
246 return Err(anyhow!(
247 "Failed to generate deployment plan.\n\
248 • Run `clarinet check` to validate your contracts.\n\
249 • Ensure settings/{}.toml has a valid mnemonic.",
250 capitalize(network)
251 ));
252 }
253
254 if let Some(contract_name) = contract {
255 filter_plan_to_contract(network, contract_name).await?;
256 println!("[deploy] Filtered deployment plan to contract: {contract_name}");
257 }
258
259 let total_micro_stx = check_plan_fee(network)?;
260 let contracts = deployment_contract_names_from_plan(network).await?;
261 println!("[deploy] Plan contracts: {}", contracts.join(", "));
262
263 if dry_run {
264 println!(
265 "[deploy] Dry run complete. No transactions were broadcast.\n\
266 [deploy] Re-run without --dry-run to apply this plan."
267 );
268 return Ok(String::new());
269 }
270
271 if network == "devnet" {
272 return run_apply_devnet_direct(network).await;
273 }
274 if network == "mainnet" {
275 let deployer = get_deployer_from_plan(network).await?;
276 confirm_mainnet_deploy(&deployer, &contracts, total_micro_stx)?;
277 }
278
279 println!("[deploy] Applying deployment plan to {}...", network);
280 let mut child = Command::new("clarinet")
281 .args([
282 "deployments",
283 "apply",
284 "--no-dashboard",
285 &format!("--{network}"),
286 ])
287 .current_dir("contracts")
288 .stdin(Stdio::piped())
289 .stdout(Stdio::piped())
290 .stderr(Stdio::inherit())
291 .spawn()?;
292
293 let mut stdin = child
294 .stdin
295 .take()
296 .ok_or_else(|| anyhow!("Failed to open stdin"))?;
297 let stdout = child
298 .stdout
299 .take()
300 .ok_or_else(|| anyhow!("Failed to open stdout"))?;
301
302 let expected_count = deployment_contract_names_from_plan(network).await?.len();
303
304 let mut confirmed_count = 0;
305 let mut broadcast_count = 0;
306
307 let mut reader = tokio::io::BufReader::new(stdout).lines();
308 let mut captured_stdout = String::new();
309
310 while let Ok(Some(line)) = reader.next_line().await {
311 println!("{}", line);
312 captured_stdout.push_str(&line);
313 captured_stdout.push('\n');
314
315 if line.contains("REDEPLOYMENT REQUIRED") || line.contains("out of sync") {
316 println!("[deploy] Error: Devnet is out of sync. You may need to restart Clarinet or increment contract version.");
317 let _ = child.kill().await;
318 return Err(anyhow!(
319 "Devnet redeployment required. Check your contract versions."
320 ));
321 }
322
323 if line.contains("Overwrite?") {
325 let answer = if contract.is_some() { b"n\n" } else { b"y\n" };
326 let _ = stdin.write_all(answer).await;
327 let _ = stdin.flush().await;
328 } else if line.contains("Confirm?") || line.contains("Continue [Y/n]?") {
329 let _ = stdin.write_all(b"y\n").await;
330 let _ = stdin.flush().await;
331 } else if line.contains("[Y/n]") {
332 let _ = stdin.write_all(b"y\n").await;
333 let _ = stdin.flush().await;
334 }
335 if line.contains("Broadcasted") && line.contains("ContractPublish(") {
336 broadcast_count += 1;
337 println!(
338 "[deploy] Broadcast progress: {}/{}",
339 broadcast_count, expected_count
340 );
341 }
342
343 if line.contains("Confirmed Publish") || line.contains("Published") {
344 confirmed_count += 1;
345 println!(
346 "[deploy] Confirmation progress: {}/{}",
347 confirmed_count, expected_count
348 );
349 }
350
351 if confirmed_count >= expected_count {
352 println!("[deploy] All contracts confirmed. Finalizing JSON...");
353 let _ = child.kill().await; break;
355 }
356
357 if broadcast_count >= expected_count {
358 println!("[deploy] All contracts broadcasted. Finalizing JSON...");
359 let _ = child.kill().await; break;
361 }
362 }
363 Ok(captured_stdout)
364}
365
366async fn run_apply_devnet_direct(network: &str) -> Result<String> {
367 println!("[deploy] Applying deployment plan to devnet...");
368 let plan = read_deployment_plan(network).await?;
369 let transactions = flatten_contract_publishes(&plan);
370 if transactions.is_empty() {
371 return Err(anyhow!(
372 "No contract publish transactions found in the devnet deployment plan."
373 ));
374 }
375
376 let settings_raw = fs::read_to_string("contracts/settings/Devnet.toml").await?;
377 let mnemonic = parse_mnemonic(&settings_raw)
378 .ok_or_else(|| anyhow!("No deployer mnemonic found in contracts/settings/Devnet.toml"))?;
379 let derivation = parse_deployer_derivation(&settings_raw)
380 .unwrap_or_else(|| "m/44'/5757'/0'/0/0".to_string());
381 let sender_key = derive_private_key_from_mnemonic(&mnemonic, &derivation)?;
382
383 let expected_sender = transactions
384 .first()
385 .and_then(|tx| tx.expected_sender.clone())
386 .ok_or_else(|| anyhow!("No expected sender found in the devnet deployment plan."))?;
387 let mut nonce = fetch_local_core_nonce(&expected_sender).await?;
388 let script_path = write_devnet_broadcast_script()?;
389 let mut captured_stdout = String::new();
390
391 println!("[deploy] Broadcasting transactions to http://localhost:20443");
392
393 for tx in transactions {
394 let contract_name = tx
395 .contract_name
396 .clone()
397 .ok_or_else(|| anyhow!("Missing contract name in deployment plan."))?;
398 let contract_path = tx.path.clone().ok_or_else(|| {
399 anyhow!("Missing contract path for {contract_name} in deployment plan.")
400 })?;
401 let fee = tx.cost.unwrap_or(0);
402 let args = serde_json::json!({
403 "contractName": contract_name,
404 "codePath": contract_path,
405 "senderKey": sender_key,
406 "fee": fee.to_string(),
407 "nonce": nonce.to_string(),
408 "clarityVersion": tx.clarity_version,
409 });
410
411 let output = Command::new("node")
412 .arg(&script_path)
413 .arg(args.to_string())
414 .current_dir("contracts")
415 .output()
416 .await
417 .map_err(|_| anyhow!("node is required to deploy directly to devnet"))?;
418
419 if !output.status.success() {
420 let stderr = String::from_utf8_lossy(&output.stderr);
421 let stdout = String::from_utf8_lossy(&output.stdout);
422 return Err(anyhow!(
423 "Direct devnet deployment failed for {}.\nstdout:\n{}\nstderr:\n{}",
424 tx.contract_name.as_deref().unwrap_or("unknown contract"),
425 stdout.trim(),
426 stderr.trim(),
427 ));
428 }
429
430 let stdout = String::from_utf8_lossy(&output.stdout);
431 let result: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
432 anyhow!(
433 "Failed to parse devnet broadcast response: {e}\nRaw output: {}",
434 stdout.trim()
435 )
436 })?;
437 let txid = result
438 .get("txid")
439 .and_then(|value| value.as_str())
440 .ok_or_else(|| {
441 anyhow!(
442 "Devnet broadcast response did not include a txid: {}",
443 stdout.trim()
444 )
445 })?;
446
447 println!(
448 "🟦 Publish {}.{} Transaction broadcast {}",
449 expected_sender,
450 tx.contract_name.as_deref().unwrap_or(""),
451 txid
452 );
453 captured_stdout.push_str(&format!(
454 "Broadcasted ContractPublish(StandardPrincipalData({}), ContractName(\"{}\"), \"{}\")\n",
455 expected_sender,
456 tx.contract_name.as_deref().unwrap_or(""),
457 txid,
458 ));
459 nonce += 1;
460 }
461
462 Ok(captured_stdout)
463}
464
465async fn read_deployment_plan(network: &str) -> Result<DeploymentPlanFile> {
466 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
467 let raw = fs::read_to_string(&plan_path)
468 .await
469 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
470 serde_yaml::from_str(&raw)
471 .map_err(|e| anyhow!("Failed to parse deployment plan at {plan_path}: {e}"))
472}
473
474fn flatten_contract_publishes(plan: &DeploymentPlanFile) -> Vec<DeploymentTransaction> {
475 plan.plan
476 .batches
477 .iter()
478 .flat_map(|batch| batch.transactions.iter())
479 .filter(|tx| tx.transaction_type == "contract-publish")
480 .cloned()
481 .collect()
482}
483
484fn write_devnet_broadcast_script() -> Result<std::path::PathBuf> {
485 let mut file = NamedTempFile::new()?;
486 let script = r#"
487import fs from 'fs';
488import { createRequire } from 'module';
489
490const require = createRequire(`${process.cwd()}/package.json`);
491const {
492 makeContractDeploy,
493 AnchorMode,
494 PostConditionMode,
495 broadcastRawTransaction,
496} = require('@stacks/transactions');
497
498const input = JSON.parse(process.argv[2]);
499const codeBody = fs.readFileSync(input.codePath, 'utf8');
500
501const transaction = await makeContractDeploy({
502 contractName: input.contractName,
503 codeBody,
504 senderKey: input.senderKey,
505 fee: BigInt(input.fee),
506 nonce: BigInt(input.nonce),
507 network: 'testnet',
508 anchorMode: AnchorMode.OnChainOnly,
509 postConditionMode: PostConditionMode.Allow,
510 ...(typeof input.clarityVersion === 'number' ? { clarityVersion: input.clarityVersion } : {}),
511});
512
513const response = await broadcastRawTransaction(
514 transaction.serialize(),
515 'http://localhost:20443/v2/transactions',
516);
517
518console.log(JSON.stringify(response));
519if (!response?.txid) {
520 process.exit(1);
521}
522"#;
523 use std::io::Write;
524 file.write_all(script.as_bytes())?;
525 let (_, path) = file.keep()?;
526 Ok(path)
527}
528
529async fn fetch_local_core_nonce(address: &str) -> Result<u64> {
530 let client = reqwest::Client::builder()
531 .timeout(std::time::Duration::from_secs(3))
532 .build()?;
533 let url = format!("http://localhost:20443/v2/accounts/{address}?proof=0");
534 let response = client
535 .get(&url)
536 .send()
537 .await
538 .map_err(|e| anyhow!("Failed to fetch local core account state from {url}: {e}"))?;
539
540 if !response.status().is_success() {
541 let status = response.status();
542 let body = response.text().await.unwrap_or_default();
543 return Err(anyhow!(
544 "Local core node returned {} for {}: {}",
545 status,
546 url,
547 body
548 ));
549 }
550
551 let account: AccountResponse = response.json().await?;
552 Ok(account.nonce)
553}
554
555fn derive_private_key_from_mnemonic(mnemonic: &str, derivation: &str) -> Result<String> {
556 let mnemonic = Mnemonic::parse_normalized(mnemonic)
557 .map_err(|e| anyhow!("Invalid mnemonic in devnet settings: {e}"))?;
558 let seed = mnemonic.to_seed_normalized("");
559 let secp = Secp256k1::new();
560 let root = Xpriv::new_master(BitcoinNetwork::Testnet, &seed)
561 .map_err(|e| anyhow!("Failed to derive root key from mnemonic: {e}"))?;
562 let path = DerivationPath::from_str(derivation)
563 .map_err(|e| anyhow!("Invalid devnet derivation path {derivation}: {e}"))?;
564 let child = root
565 .derive_priv(&secp, &path)
566 .map_err(|e| anyhow!("Failed to derive child key {derivation}: {e}"))?;
567 Ok(format!(
568 "{}01",
569 hex::encode(child.private_key.secret_bytes())
570 ))
571}
572
573pub async fn resolve_deployment_order(
574 contracts_dir: &std::path::Path,
575) -> anyhow::Result<Vec<String>> {
576 let clarinet_raw = fs::read_to_string(contracts_dir.join("Clarinet.toml")).await?;
577 let clarinet: ClarinetToml = toml::from_str(&clarinet_raw)
578 .map_err(|e| anyhow::anyhow!("Failed to parse Clarinet.toml: {e}"))?;
579
580 let contract_map = clarinet.contracts.unwrap_or_default();
581 let known: HashSet<String> = contract_map.keys().cloned().collect();
582
583 let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
585
586 for (name, entry) in &contract_map {
587 let clar_path = contracts_dir.join(&entry.path);
588 let source = fs::read_to_string(&clar_path).await.unwrap_or_default();
589 let deps = parse_local_deps(&source, &known);
590
591 if !deps.is_empty() {
592 println!("[deploy] {name} depends on: {}", deps.join(", "));
593 }
594
595 dep_graph.insert(name.clone(), deps);
596 }
597
598 let order = topological_sort(&dep_graph)?;
599 println!("[deploy] Deployment order: {}", order.join(" → "));
600
601 Ok(order)
602}
603
604fn check_plan_fee(network: &str) -> Result<u64> {
606 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
607 let plan_raw = std::fs::read_to_string(&plan_path).unwrap_or_default();
608
609 let total_micro_stx: u64 = plan_raw
611 .lines()
612 .filter_map(|line| {
613 let trimmed = line.trim();
614 if trimmed.starts_with("cost:") {
615 trimmed.split_whitespace().nth(1)?.parse::<u64>().ok()
616 } else {
617 None
618 }
619 })
620 .sum();
621 if total_micro_stx > 0 {
622 println!(
623 "[deploy] Estimated fee: {:.6} STX",
624 total_micro_stx as f64 / 1_000_000.0
625 );
626 }
627
628 Ok(total_micro_stx)
629}
630
631async fn auto_version_conflicting_contracts(network: &str, contract: Option<&str>) -> Result<()> {
632 let config = network_config(network)?;
633 let client = reqwest::Client::builder()
634 .timeout(std::time::Duration::from_secs(5))
635 .build()?;
636
637 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
638 if Path::new(&plan_path).exists() {
639 let _ = fs::remove_file(&plan_path).await;
640 }
641
642 let _ = Command::new("clarinet")
643 .args([
644 "deployments",
645 "generate",
646 &format!("--{}", network),
647 "--low-cost",
648 ])
649 .current_dir("contracts")
650 .status()
651 .await;
652
653 let deployer = get_deployer_from_plan(network).await?;
654 println!("[deploy] Using derived deployer address: {}", deployer);
655
656 let base_dir = Path::new("contracts");
657 let clarinet_path = base_dir.join("Clarinet.toml");
658 let clarinet_raw = fs::read_to_string(&clarinet_path).await?;
659 let mut clarinet_content = clarinet_raw.clone();
660
661 let clarinet_struct: ClarinetToml = toml::from_str(&clarinet_raw)?;
662 let contracts = clarinet_struct.contracts.unwrap_or_default();
663
664 let mut any_changes = false;
665
666 for (current_name, entry) in &contracts {
667 if contract.is_some() && contract != Some(current_name.as_str()) {
668 continue;
669 }
670 let base_name = strip_version_suffix(current_name);
671
672 let correct_name =
674 find_next_free_name(&client, &config.stacks_node, &deployer, &base_name).await?;
675
676 if current_name == &correct_name {
677 continue;
678 }
679
680 println!(
681 "[deploy] Conflict detected: '{}' already exists on-chain. Renaming to '{}'",
682 current_name, correct_name
683 );
684
685 let old_file_path = base_dir.join(&entry.path);
686 let new_rel_path = format!("contracts/{}.clar", correct_name);
687 let new_file_path = base_dir.join(&new_rel_path);
688
689 if old_file_path.exists() {
690 fs::rename(&old_file_path, &new_file_path).await?;
691 println!("[deploy] Renamed file: {} -> {}", entry.path, new_rel_path);
692 }
693
694 let old_header = format!("[contracts.{}]", current_name);
695 let new_header = format!("[contracts.{}]", correct_name);
696 clarinet_content = clarinet_content.replace(&old_header, &new_header);
697
698 let old_path_line = format!("path = \"{}\"", entry.path);
699 let new_path_line = format!("path = \"{}\"", new_rel_path);
700 clarinet_content = clarinet_content.replace(&old_path_line, &new_path_line);
701
702 let dot_old_name = format!(".{}", current_name);
703 let dot_new_name = format!(".{}", correct_name);
704
705 for (_, other_entry) in &contracts {
706 let p = base_dir.join(&other_entry.path);
707
708 let target_file = if p == old_file_path {
709 &new_file_path
710 } else {
711 &p
712 };
713
714 if target_file.exists() {
715 let source = fs::read_to_string(target_file).await?;
716 if source.contains(&dot_old_name) {
717 let updated_source = source.replace(&dot_old_name, &dot_new_name);
718 fs::write(target_file, updated_source).await?;
719 println!(
720 "[deploy] Updated internal reference in {}",
721 target_file.display()
722 );
723 }
724 }
725 }
726
727 any_changes = true;
728 }
729
730 if any_changes {
731 fs::write(&clarinet_path, &clarinet_content).await?;
732
733 for plan_name in [
734 "default.devnet-plan.yaml",
735 "default.simnet-plan.yaml",
736 "default.testnet-plan.yaml",
737 "default.mainnet-plan.yaml",
738 ] {
739 let plan_path = base_dir.join("deployments").join(plan_name);
740 let _ = fs::remove_file(plan_path).await;
741 }
742
743 println!("[deploy] Clarinet.toml updated with new versions.");
744 let _ = Command::new("stacksdapp").arg("generate").status().await;
746 }
747
748 Ok(())
749}
750
751async fn get_deployer_from_plan(network: &str) -> Result<String> {
753 let plan_path = format!("contracts/deployments/default.{}-plan.yaml", network);
754 let content = fs::read_to_string(&plan_path).await.map_err(|_| {
755 anyhow!(
756 "Clarinet plan not found at {}. Is the path correct?",
757 plan_path
758 )
759 })?;
760
761 for line in content.lines() {
762 let trimmed = line.trim();
763 if trimmed.starts_with("expected-sender:") {
764 return Ok(trimmed.split(':').nth(1).unwrap_or("").trim().to_string());
765 }
766 }
767 Err(anyhow!(
768 "Could not find 'expected-sender' in the deployment plan. Check your mnemonic in settings."
769 ))
770}
771
772async fn find_next_free_name(
773 client: &reqwest::Client,
774 node: &str,
775 deployer: &str,
776 base_name: &str,
777) -> Result<String> {
778 let url = format!("{node}/v2/contracts/source/{deployer}/{base_name}");
780 let base_free = !client
781 .get(&url)
782 .send()
783 .await
784 .map(|r| r.status().is_success())
785 .unwrap_or(false);
786
787 if base_free {
788 return Ok(base_name.to_string());
789 }
790
791 let mut version = 2u32;
793 loop {
794 let candidate = format!("{base_name}-v{version}");
795 let url = format!("{node}/v2/contracts/interface/{deployer}/{candidate}");
796 let taken = client
797 .get(&url)
798 .send()
799 .await
800 .map(|r| r.status().is_success())
801 .unwrap_or(false);
802 if !taken {
803 return Ok(candidate);
804 }
805 version += 1;
806 if version > 99 {
807 return Err(anyhow!(
808 "Could not find a free version for '{base_name}' (tried up to v99). Consider using a fresh deployer address."
809 ));
810 }
811 }
812}
813
814fn strip_version_suffix(name: &str) -> String {
816 if let Some(idx) = name.rfind("-v") {
818 let suffix = &name[idx + 2..];
819 if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
820 return name[..idx].to_string();
821 }
822 }
823 name.to_string()
824}
825
826fn validate_settings_mnemonic(network: &str) -> Result<()> {
829 let path = format!("contracts/settings/{}.toml", capitalize(network));
830 let raw =
831 std::fs::read_to_string(&path).map_err(|_| anyhow!("Settings file not found: {path}"))?;
832 let mnemonic = parse_mnemonic(&raw).unwrap_or_default();
833 if mnemonic.is_empty() || mnemonic.contains('<') || mnemonic.contains('>') {
834 return Err(anyhow!(
835 "No valid mnemonic in {path}.\n\
836 Add your deployer seed phrase:\n\n\
837 [accounts.deployer]\n\
838 mnemonic = \"your 24 words here\"\n\n\
839 Get testnet STX: https://explorer.hiro.so/sandbox/faucet?chain=testnet"
840 ));
841 }
842 Ok(())
843}
844
845fn parse_mnemonic(toml_raw: &str) -> Option<String> {
846 let mut in_deployer = false;
847 for line in toml_raw.lines() {
848 let trimmed = line.trim();
849 if trimmed == "[accounts.deployer]" {
850 in_deployer = true;
851 continue;
852 }
853 if trimmed.starts_with('[') {
854 in_deployer = false;
855 }
856 if in_deployer && trimmed.starts_with("mnemonic") {
857 if let Some(val) = trimmed.splitn(2, '=').nth(1) {
858 return Some(val.trim().trim_matches('"').to_string());
859 }
860 }
861 }
862 None
863}
864
865fn parse_deployer_derivation(toml_raw: &str) -> Option<String> {
866 let mut in_deployer = false;
867 for line in toml_raw.lines() {
868 let trimmed = line.trim();
869 if trimmed == "[accounts.deployer]" {
870 in_deployer = true;
871 continue;
872 }
873 if trimmed.starts_with('[') {
874 in_deployer = false;
875 }
876 if in_deployer && trimmed.starts_with("derivation") {
877 if let Some(val) = trimmed.splitn(2, '=').nth(1) {
878 return Some(val.trim().trim_matches('"').to_string());
879 }
880 }
881 }
882 None
883}
884
885async fn wait_for_node(url: &str) -> Result<()> {
886 let client = reqwest::Client::builder()
887 .timeout(std::time::Duration::from_secs(2))
888 .build()?;
889 println!("[deploy] Waiting for Stacks node at {url}...");
890 for attempt in 1..=60 {
891 if client
892 .get(&format!("{url}/v2/info"))
893 .send()
894 .await
895 .map(|r| r.status().is_success())
896 .unwrap_or(false)
897 {
898 println!("[deploy] ✔ Node is ready");
899 return Ok(());
900 }
901 if attempt % 10 == 0 {
902 println!("[deploy] Still waiting... ({attempt}s)");
903 }
904 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
905 }
906 Err(anyhow!(
907 "Stacks node at {url} did not become ready after 60s.\n\
908 Make sure `stacksdapp dev` is running and Docker is started."
909 ))
910}
911
912async fn write_deployments_json_from_output(
913 network: &str,
914 output: &str,
915 contract: Option<&str>,
916) -> Result<()> {
917 let mut txid_map: HashMap<String, String> = HashMap::new();
918 let mut actual_deployer = None;
919 for line in output.lines() {
920 if line.contains("Broadcasted") {
921 if let Some(start) = line.find("StandardPrincipalData(") {
923 let rest = &line[start + "StandardPrincipalData(".len()..];
924 if let Some(end) = rest.find(')') {
925 actual_deployer = Some(rest[..end].to_string());
926 }
927 }
928
929 let cn_marker = "ContractName(\"";
931 if let Some(pos) = line.find(cn_marker) {
932 let rest = &line[pos + cn_marker.len()..];
933 if let Some(end) = rest.find('"') {
934 let contract_name = rest[..end].to_string();
935
936 let parts: Vec<&str> = line.split('"').collect();
939 for part in parts {
940 if part.len() == 64 && part.chars().all(|c| c.is_ascii_hexdigit()) {
941 txid_map.insert(contract_name.clone(), part.to_string());
942 }
943 }
944 }
945 }
946 }
947 }
948 let settings_file = format!("contracts/settings/{}.toml", capitalize(network));
949 let settings_raw = fs::read_to_string(&settings_file).await.unwrap_or_default();
950
951 let deployer_address = actual_deployer
952 .or_else(|| parse_deployer_address_from_settings(&settings_raw))
953 .unwrap_or_else(|| "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM".to_string());
954
955 let clarinet_raw = fs::read_to_string("contracts/Clarinet.toml").await?;
956 let clarinet: ClarinetToml =
957 toml::from_str(&clarinet_raw).map_err(|e| anyhow!("Failed to parse Clarinet.toml: {e}"))?;
958 let mut contract_names: Vec<String> = clarinet
959 .contracts
960 .as_ref()
961 .map(|contracts| contracts.keys().cloned().collect())
962 .unwrap_or_default();
963 if let Some(contract_name) = contract {
964 contract_names.retain(|name| name == contract_name);
965 }
966
967 if network == "devnet" {
968 wait_for_devnet_contracts(&deployer_address, &contract_names).await?;
969 }
970
971 let mut contracts_map = if contract.is_some() {
972 load_existing_deployments_for_network(network).await?
973 } else {
974 HashMap::new()
975 };
976 let timestamp = chrono::Utc::now().to_rfc3339();
977
978 for name in contract_names {
979 let contract_id = format!("{deployer_address}.{name}");
980 let txid = txid_map
981 .get(&name)
982 .map(|t| format!("0x{t}"))
983 .unwrap_or_default();
984 println!(
985 " ✔ {name} | txid {} | address {contract_id}",
986 if txid.is_empty() { "(pending)" } else { &txid }
987 );
988 contracts_map.insert(
989 name.clone(),
990 DeploymentInfo {
991 contract_id,
992 tx_id: txid,
993 block_height: 0,
994 },
995 );
996 }
997
998 let json = serde_json::to_string_pretty(&DeploymentFile {
999 network: network.to_string(),
1000 deployed_at: timestamp,
1001 contracts: contracts_map,
1002 })?;
1003
1004 let out_path = Path::new("frontend/src/generated/deployments.json");
1005 if let Some(p) = out_path.parent() {
1006 fs::create_dir_all(p).await?;
1007 }
1008 fs::write(out_path, &json).await?;
1009 println!("\n[deploy] Written to {}", out_path.display());
1010 Ok(())
1011}
1012
1013async fn load_existing_deployments_for_network(
1014 network: &str,
1015) -> Result<HashMap<String, DeploymentInfo>> {
1016 let path = Path::new("frontend/src/generated/deployments.json");
1017 let raw = match fs::read_to_string(path).await {
1018 Ok(content) => content,
1019 Err(_) => return Ok(HashMap::new()),
1020 };
1021
1022 let parsed: DeploymentFile = match serde_json::from_str(&raw) {
1023 Ok(file) => file,
1024 Err(_) => return Ok(HashMap::new()),
1025 };
1026
1027 if parsed.network == network {
1028 Ok(parsed.contracts)
1029 } else {
1030 Ok(HashMap::new())
1031 }
1032}
1033
1034fn ensure_contract_exists(known: &[String], contract: &str) -> Result<()> {
1035 if known.iter().any(|name| name == contract) {
1036 return Ok(());
1037 }
1038 Err(anyhow!(
1039 "Contract '{contract}' was not found in contracts/Clarinet.toml.\nAvailable contracts: {}",
1040 if known.is_empty() {
1041 "<none>".to_string()
1042 } else {
1043 known.join(", ")
1044 }
1045 ))
1046}
1047
1048async fn filter_plan_to_contract(network: &str, contract_name: &str) -> Result<()> {
1049 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
1050 let raw = fs::read_to_string(&plan_path)
1051 .await
1052 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
1053 let mut yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
1054 .map_err(|e| anyhow!("Failed to parse deployment plan YAML at {plan_path}: {e}"))?;
1055 let mut found = false;
1056
1057 let batches = yaml
1058 .get_mut("plan")
1059 .and_then(|plan| plan.get_mut("batches"))
1060 .and_then(|batches| batches.as_sequence_mut())
1061 .ok_or_else(|| anyhow!("Deployment plan is missing plan.batches"))?;
1062
1063 for batch in batches.iter_mut() {
1064 let Some(transactions) = batch
1065 .get_mut("transactions")
1066 .and_then(|t| t.as_sequence_mut())
1067 else {
1068 continue;
1069 };
1070
1071 transactions.retain(|tx| {
1072 let tx_type = tx
1073 .get("transaction-type")
1074 .and_then(|v| v.as_str())
1075 .unwrap_or("");
1076 if tx_type != "contract-publish" {
1077 return true;
1078 }
1079
1080 let keep = tx.get("contract-name").and_then(|v| v.as_str()) == Some(contract_name);
1081 if keep {
1082 found = true;
1083 }
1084 keep
1085 });
1086 }
1087
1088 batches.retain(|batch| {
1089 batch
1090 .get("transactions")
1091 .and_then(|t| t.as_sequence())
1092 .map(|txs| !txs.is_empty())
1093 .unwrap_or(false)
1094 });
1095
1096 if !found {
1097 return Err(anyhow!(
1098 "Contract '{contract_name}' is not present in the generated deployment plan.\n\
1099 Ensure the contract exists and passes `clarinet check`."
1100 ));
1101 }
1102
1103 let rendered = serde_yaml::to_string(&yaml)?;
1104 fs::write(&plan_path, rendered).await?;
1105 Ok(())
1106}
1107
1108async fn deployment_contract_names_from_plan(network: &str) -> Result<Vec<String>> {
1109 let plan = read_deployment_plan(network).await?;
1110 let names = flatten_contract_publishes(&plan)
1111 .into_iter()
1112 .filter_map(|tx| tx.contract_name)
1113 .collect::<Vec<_>>();
1114 if names.is_empty() {
1115 return Err(anyhow!(
1116 "No contract publish transactions found in deployment plan for {network}."
1117 ));
1118 }
1119 Ok(names)
1120}
1121
1122async fn wait_for_devnet_contracts(deployer: &str, contract_names: &[String]) -> Result<()> {
1123 if contract_names.is_empty() {
1124 return Ok(());
1125 }
1126
1127 let client = reqwest::Client::builder()
1128 .timeout(std::time::Duration::from_secs(3))
1129 .build()?;
1130 let node = "http://localhost:20443";
1131 let initial_info = fetch_local_core_info().await.ok();
1132
1133 println!("[deploy] Verifying contract publish on local devnet core node...");
1134 for attempt in 1..=30 {
1135 let mut pending = Vec::new();
1136
1137 for contract_name in contract_names {
1138 let url = format!("{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0");
1139 let deployed = client
1140 .get(&url)
1141 .send()
1142 .await
1143 .map(|response| response.status().is_success())
1144 .unwrap_or(false);
1145
1146 if !deployed {
1147 pending.push(contract_name.clone());
1148 }
1149 }
1150
1151 if pending.is_empty() {
1152 println!("[deploy] ✔ Local devnet core node reports all contracts deployed");
1153 return Ok(());
1154 }
1155
1156 if attempt == 1 || attempt % 5 == 0 {
1157 println!(
1158 "[deploy] Waiting for devnet core to expose: {}",
1159 pending.join(", ")
1160 );
1161 }
1162
1163 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1164 }
1165
1166 let nonce = fetch_local_core_nonce(deployer).await.unwrap_or_default();
1167 let stacks_api_healthy = probe_stacks_api_health().await.unwrap_or(false);
1168 let final_info = fetch_local_core_info().await.ok();
1169 let stall_hint = match (initial_info, final_info) {
1170 (Some(start), Some(end))
1171 if start.burn_block_height == end.burn_block_height
1172 && start.stacks_tip_height == end.stacks_tip_height =>
1173 {
1174 format!(
1175 "Local devnet appears stalled: burn block height stayed at {} and stacks tip height stayed at {} while waiting for confirmation.",
1176 end.burn_block_height, end.stacks_tip_height
1177 )
1178 }
1179 _ => "Local devnet tip did move during the wait, so the publish appears to be stuck independently of tip progression.".to_string(),
1180 };
1181
1182 Err(anyhow!(
1183 "Devnet deploy did not finalize on the local Stacks core node.\n\
1184 The contract source never became available at http://localhost:20443 and the deployer nonce is still {nonce}.\n\
1185 This means the publish did not finalize on core, even if the explorer/mempool UI appears to show it.\n\
1186 {stall_hint}\n\
1187 {api_hint}\n\
1188 Try restarting devnet with `stacksdapp clean` and `stacksdapp dev`, then deploy again."
1189 ,
1190 stall_hint = stall_hint,
1191 api_hint = if stacks_api_healthy {
1192 "Local stacks-api responded normally, so the failure is on the core-chain side."
1193 } else {
1194 "Local stacks-api/indexer also appears unhealthy, so the explorer UI may be stale or misleading."
1195 }
1196 ))
1197}
1198
1199async fn probe_stacks_api_health() -> Result<bool> {
1200 let client = reqwest::Client::builder()
1201 .timeout(std::time::Duration::from_secs(2))
1202 .build()?;
1203 Ok(client
1204 .get("http://localhost:3999/v2/info")
1205 .send()
1206 .await
1207 .map(|response| response.status().is_success())
1208 .unwrap_or(false))
1209}
1210
1211async fn fetch_local_core_info() -> Result<CoreInfoResponse> {
1212 let client = reqwest::Client::builder()
1213 .timeout(std::time::Duration::from_secs(2))
1214 .build()?;
1215 let response = client.get("http://localhost:20443/v2/info").send().await?;
1216 let response = response.error_for_status()?;
1217 Ok(response.json().await?)
1218}
1219
1220fn parse_deployer_address_from_settings(toml_raw: &str) -> Option<String> {
1221 for line in toml_raw.lines() {
1222 let line = line.trim();
1223 if line.starts_with("# stx_address:") {
1224 return line.split(':').nth(1).map(|s| s.trim().to_string());
1225 }
1226 }
1227 None
1228}
1229fn parse_local_deps(source: &str, known_contracts: &HashSet<String>) -> Vec<String> {
1230 let mut deps = Vec::new();
1231
1232 for line in source.lines() {
1233 let trimmed = line.trim();
1234
1235 for pattern in &["contract-call? .", "use-trait "] {
1238 if let Some(pos) = trimmed.find(pattern) {
1239 let after = &trimmed[pos + pattern.len()..];
1240 let name: String = after
1242 .chars()
1243 .take_while(|c| !c.is_whitespace() && *c != '.')
1244 .collect();
1245
1246 if !name.is_empty() && known_contracts.contains(&name) {
1247 deps.push(name);
1248 }
1249 }
1250 }
1251 }
1252
1253 deps.sort();
1254 deps.dedup();
1255 deps
1256}
1257
1258fn topological_sort(contracts: &HashMap<String, Vec<String>>) -> anyhow::Result<Vec<String>> {
1259 let mut in_degree: HashMap<&str, usize> = HashMap::new();
1260 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
1261
1262 for (name, deps) in contracts {
1263 in_degree.insert(name.as_str(), deps.len());
1264 for dep in deps {
1265 dependents
1266 .entry(dep.as_str())
1267 .or_default()
1268 .push(name.as_str());
1269 }
1270 }
1271
1272 let mut queue: VecDeque<&str> = in_degree
1274 .iter()
1275 .filter(|(_, °)| deg == 0)
1276 .map(|(&name, _)| name)
1277 .collect();
1278
1279 let mut queue_vec: Vec<&str> = queue.drain(..).collect();
1281 queue_vec.sort();
1282 queue.extend(queue_vec);
1283
1284 let mut sorted = Vec::new();
1285
1286 while let Some(node) = queue.pop_front() {
1287 sorted.push(node.to_string());
1288
1289 let mut next = dependents.get(node).cloned().unwrap_or_default();
1291 next.sort();
1292
1293 for dependent in next {
1294 let deg = in_degree.entry(dependent).or_insert(0);
1295 *deg = deg.saturating_sub(1);
1296 if *deg == 0 {
1297 queue.push_back(dependent);
1298 }
1299 }
1300 }
1301
1302 if sorted.len() != contracts.len() {
1303 return Err(anyhow::anyhow!(
1304 "Circular contract dependency detected.\n\
1305 Check your contracts for circular contract-call? references.\n\
1306 Involved contracts: {}",
1307 contracts
1308 .keys()
1309 .filter(|k| !sorted.contains(k))
1310 .cloned()
1311 .collect::<Vec<_>>()
1312 .join(", ")
1313 ));
1314 }
1315
1316 Ok(sorted)
1317}
1318
1319fn capitalize(s: &str) -> String {
1320 let mut c = s.chars();
1321 match c.next() {
1322 None => String::new(),
1323 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1324 }
1325}
1326
1327fn confirm_mainnet_deploy(
1328 deployer: &str,
1329 contracts: &[String],
1330 total_micro_stx: u64,
1331) -> Result<()> {
1332 use std::io::{self, Write};
1333
1334 println!("\n⚠️ Mainnet deployment confirmation required");
1335 println!(" Network: mainnet");
1336 println!(" Deployer: {deployer}");
1337 println!(
1338 " Estimated fee: {:.6} STX",
1339 total_micro_stx as f64 / 1_000_000.0
1340 );
1341 println!(
1342 " Contracts ({}): {}",
1343 contracts.len(),
1344 contracts.join(", ")
1345 );
1346 print!("\nType 'y' to continue with MAINNET broadcast: ");
1347 io::stdout().flush()?;
1348
1349 let mut input = String::new();
1350 io::stdin().read_line(&mut input)?;
1351 if input.trim() != "y" {
1352 return Err(anyhow!("Mainnet deployment aborted by user."));
1353 }
1354
1355 Ok(())
1356}
1357
1358#[cfg(test)]
1359mod tests {
1360 use super::{strip_version_suffix, topological_sort};
1361 use std::collections::HashMap;
1362
1363 #[test]
1364 fn test_strip_version_suffix() {
1365 assert_eq!(strip_version_suffix("counter"), "counter");
1366 assert_eq!(strip_version_suffix("counter-v2"), "counter");
1367 assert_eq!(strip_version_suffix("counter-v3"), "counter");
1368 assert_eq!(strip_version_suffix("counter-v10"), "counter");
1369 assert_eq!(strip_version_suffix("my-token-v2"), "my-token");
1370 assert_eq!(strip_version_suffix("counter-v"), "counter-v");
1372 assert_eq!(strip_version_suffix("counter-vault"), "counter-vault");
1373 }
1374
1375 #[test]
1376 fn test_topological_sort_respects_dependencies() {
1377 let mut graph = HashMap::new();
1378 graph.insert("a".to_string(), vec![]);
1379 graph.insert("b".to_string(), vec!["a".to_string()]);
1380 graph.insert("c".to_string(), vec!["b".to_string()]);
1381
1382 let order = topological_sort(&graph).expect("topological sort should succeed");
1383 let idx_a = order.iter().position(|name| name == "a").unwrap();
1384 let idx_b = order.iter().position(|name| name == "b").unwrap();
1385 let idx_c = order.iter().position(|name| name == "c").unwrap();
1386 assert!(idx_a < idx_b && idx_b < idx_c);
1387 }
1388
1389 #[test]
1390 fn test_topological_sort_cycle_detection() {
1391 let mut graph = HashMap::new();
1392 graph.insert("a".to_string(), vec!["b".to_string()]);
1393 graph.insert("b".to_string(), vec!["a".to_string()]);
1394
1395 let err = topological_sort(&graph).expect_err("cycle should fail");
1396 assert!(
1397 err.to_string()
1398 .contains("Circular contract dependency detected"),
1399 "unexpected error: {err}"
1400 );
1401 }
1402}