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