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