1use anyhow::{anyhow, Result};
2use bip39::Mnemonic;
3use bitcoin::bip32::{DerivationPath, Xpriv};
4use bitcoin::secp256k1::Secp256k1;
5use bitcoin::Network as BitcoinNetwork;
6use reqwest::StatusCode;
7use serde::{Deserialize, Serialize};
8use std::collections::{HashMap, HashSet, VecDeque};
9use std::path::{Path, PathBuf};
10use std::process::Stdio;
11use std::str::FromStr;
12use tempfile::NamedTempFile;
13use tokio::fs;
14use tokio::io::AsyncBufReadExt;
15use tokio::io::AsyncWriteExt;
16use tokio::process::Command;
17
18pub mod devnet_recovery;
19mod ui;
20use devnet_recovery::{
21 ensure_devnet_chain_mining, fetch_local_core_info_optional,
22 recover_devnet_if_stalled_during_wait, LocalCoreInfo,
23};
24use ui::DeployUi;
25
26static CLARINET_VERSION: std::sync::OnceLock<Option<(u32, u32, u32)>> = std::sync::OnceLock::new();
27
28fn installed_clarinet_version() -> Option<(u32, u32, u32)> {
29 *CLARINET_VERSION.get_or_init(|| {
30 let output = std::process::Command::new("clarinet")
31 .arg("--version")
32 .output()
33 .ok()?;
34 let text = String::from_utf8_lossy(&output.stdout);
35 parse_semver_triple(text.trim().trim_start_matches("clarinet "))
36 })
37}
38
39fn parse_semver_triple(raw: &str) -> Option<(u32, u32, u32)> {
40 let version = raw.split_whitespace().next()?.trim_start_matches('v');
41 let mut parts = version.split('.');
42 let major = parts.next()?.parse().ok()?;
43 let minor = parts.next()?.parse().ok()?;
44 let patch = parts
45 .next()
46 .unwrap_or("0")
47 .split('-')
48 .next()
49 .unwrap_or("0")
50 .parse()
51 .ok()?;
52 Some((major, minor, patch))
53}
54
55pub fn clarinet_version_at_least(major: u32, minor: u32, patch: u32) -> bool {
56 match installed_clarinet_version() {
57 Some((m, mi, p)) => (m, mi, p) >= (major, minor, patch),
58 None => false,
59 }
60}
61
62#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DeployOutcome {
65 pub status: &'static str,
67 pub block_height: Option<u64>,
68}
69
70pub struct NetworkConfig {
71 pub stacks_node: String,
72}
73
74pub fn network_config(network: &str) -> Result<NetworkConfig> {
75 match network {
76 "devnet" => Ok(NetworkConfig {
77 stacks_node: "http://localhost:20443".into(),
79 }),
80 "testnet" => Ok(NetworkConfig {
81 stacks_node: "https://api.testnet.hiro.so".into(),
82 }),
83 "mainnet" => Ok(NetworkConfig {
84 stacks_node: "https://api.hiro.so".into(),
85 }),
86 other => Err(anyhow!(
87 "Unknown network '{other}'. Expected one of: devnet | testnet | mainnet"
88 )),
89 }
90}
91
92#[derive(Debug, Deserialize)]
93struct ClarinetToml {
94 contracts: Option<HashMap<String, ContractEntry>>,
95}
96
97#[derive(Debug, Deserialize)]
98struct ContractEntry {
99 path: String,
100}
101
102#[derive(Debug, Deserialize, Serialize)]
103struct DeploymentPlanFile {
104 plan: DeploymentPlan,
105}
106
107#[derive(Debug, Deserialize, Serialize)]
108struct DeploymentPlan {
109 batches: Vec<DeploymentBatch>,
110}
111
112#[derive(Debug, Deserialize, Serialize)]
113struct DeploymentBatch {
114 transactions: Vec<DeploymentTransaction>,
115 #[serde(default)]
118 epoch: Option<String>,
119}
120
121#[derive(Debug, Deserialize, Serialize, Clone)]
122struct DeploymentTransaction {
123 #[serde(rename = "transaction-type")]
124 transaction_type: String,
125 #[serde(rename = "contract-name")]
126 contract_name: Option<String>,
127 #[serde(rename = "expected-sender")]
128 expected_sender: Option<String>,
129 cost: Option<u64>,
130 path: Option<String>,
131 #[serde(rename = "clarity-version")]
132 clarity_version: Option<u8>,
133}
134
135#[derive(Debug, Clone)]
136struct PlannedPublish {
137 tx: DeploymentTransaction,
138 epoch: Option<String>,
139}
140
141#[derive(Debug, Deserialize)]
142struct AccountResponse {
143 nonce: u64,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147struct DeploymentInfo {
148 contract_id: String,
149 tx_id: String,
150 block_height: u64,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154struct DeploymentFile {
155 network: String,
156 deployed_at: String,
157 contracts: HashMap<String, DeploymentInfo>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161struct PlannedRename {
162 from: String,
163 to: String,
164}
165
166pub async fn wait_for_devnet_node() -> Result<()> {
169 wait_for_devnet_core_ready().await
172}
173
174async fn wait_for_devnet_core_ready() -> Result<()> {
175 let client = reqwest::Client::builder()
176 .timeout(std::time::Duration::from_secs(3))
177 .build()?;
178 let mut first_tip: Option<u64> = None;
179 for attempt in 1..=90 {
180 if let Ok(Some(height)) = fetch_core_tip_height(&client).await {
181 match first_tip {
182 None => first_tip = Some(height),
183 Some(first) if height > first => return Ok(()),
184 Some(_) if attempt >= 3 => return Ok(()),
186 _ => {}
187 }
188 }
189 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
190 }
191 Err(anyhow!(
192 "Local Stacks core at http://localhost:20443 did not become ready after 180s.\n\
193 Make sure `stacksdapp dev` is running, Docker is started, and the tip is advancing.\n\
194 If ports conflict or the tip stalls: stacksdapp clean --force && stacksdapp dev"
195 ))
196}
197
198async fn fetch_core_tip_height(client: &reqwest::Client) -> Result<Option<u64>> {
199 let response = client.get("http://localhost:20443/v2/info").send().await?;
200 if !response.status().is_success() {
201 return Ok(None);
202 }
203 let json: serde_json::Value = response.json().await?;
204 Ok(json.get("stacks_tip_height").and_then(|v| v.as_u64()))
205}
206
207pub async fn deploy(
208 network: &str,
209 contract: Option<&str>,
210 dry_run: bool,
211 yes: bool,
212 wait_confirm: bool,
213 no_auto_version: bool,
214) -> Result<DeployOutcome> {
215 if !Path::new("contracts/Clarinet.toml").exists() {
216 return Err(anyhow!(
217 "No scaffold-stacks project found. Run from the directory created by stacksdapp new"
218 ));
219 }
220
221 if network == "testnet" || network == "mainnet" {
222 validate_settings_mnemonic(network)?;
223 }
224
225 let config = network_config(network)?;
226 let ui = DeployUi::start(network, &config.stacks_node);
227
228 if network == "devnet" {
229 wait_for_devnet_node().await?;
230 }
231
232 deploy_via_clarinet(
233 &ui,
234 network,
235 contract,
236 dry_run,
237 yes,
238 wait_confirm,
239 no_auto_version,
240 &config.stacks_node,
241 )
242 .await
243}
244
245struct DeployWriteSnapshot {
248 clarinet_toml: Option<Vec<u8>>,
249 deployment_files: HashMap<std::path::PathBuf, Vec<u8>>,
250}
251
252struct RenameSnapshot {
254 files: HashMap<std::path::PathBuf, Vec<u8>>,
255}
256
257async fn snapshot_deploy_writes(contracts_dir: &Path) -> Result<DeployWriteSnapshot> {
258 let clarinet_path = contracts_dir.join("Clarinet.toml");
259 let clarinet_toml = if clarinet_path.is_file() {
260 Some(fs::read(&clarinet_path).await?)
261 } else {
262 None
263 };
264
265 let mut deployment_files = HashMap::new();
266 let deployments_dir = contracts_dir.join("deployments");
267 if deployments_dir.is_dir() {
268 let mut entries = fs::read_dir(&deployments_dir).await?;
269 while let Some(entry) = entries.next_entry().await? {
270 if entry.file_type().await?.is_file() {
271 let path = entry.path();
272 deployment_files.insert(path.clone(), fs::read(&path).await?);
273 }
274 }
275 }
276
277 Ok(DeployWriteSnapshot {
278 clarinet_toml,
279 deployment_files,
280 })
281}
282
283async fn restore_deploy_writes(contracts_dir: &Path, snapshot: &DeployWriteSnapshot) -> Result<()> {
284 let clarinet_path = contracts_dir.join("Clarinet.toml");
285 match &snapshot.clarinet_toml {
286 Some(bytes) => fs::write(&clarinet_path, bytes).await?,
287 None if clarinet_path.is_file() => {
288 fs::remove_file(&clarinet_path).await?;
289 }
290 _ => {}
291 }
292
293 let deployments_dir = contracts_dir.join("deployments");
294 if deployments_dir.is_dir() {
295 let mut entries = fs::read_dir(&deployments_dir).await?;
296 while let Some(entry) = entries.next_entry().await? {
297 if entry.file_type().await?.is_file()
298 && !snapshot.deployment_files.contains_key(&entry.path())
299 {
300 fs::remove_file(entry.path()).await?;
301 }
302 }
303 }
304
305 for (path, bytes) in &snapshot.deployment_files {
306 if let Some(parent) = path.parent() {
307 fs::create_dir_all(parent).await?;
308 }
309 fs::write(path, bytes).await?;
310 }
311
312 Ok(())
313}
314
315async fn snapshot_contract_state(contracts_dir: &Path) -> Result<RenameSnapshot> {
316 let clarinet_path = contracts_dir.join("Clarinet.toml");
317 let clarinet_raw = fs::read_to_string(&clarinet_path).await?;
318 let clarinet_struct: ClarinetToml = toml::from_str(&clarinet_raw)?;
319 let mut files = HashMap::new();
320 files.insert(clarinet_path, clarinet_raw.into_bytes());
321 if let Some(contracts) = clarinet_struct.contracts {
322 for entry in contracts.values() {
323 let path = contracts_dir.join(&entry.path);
324 if path.is_file() {
325 files.insert(path.clone(), fs::read(&path).await?);
326 }
327 }
328 }
329 Ok(RenameSnapshot { files })
330}
331
332async fn restore_rename_snapshot(contracts_dir: &Path, snapshot: &RenameSnapshot) -> Result<()> {
333 let contracts_src = contracts_dir.join("contracts");
334 if contracts_src.is_dir() {
335 let mut entries = fs::read_dir(&contracts_src).await?;
336 while let Some(entry) = entries.next_entry().await? {
337 let path = entry.path();
338 if path.extension().is_some_and(|ext| ext == "clar")
339 && !snapshot.files.contains_key(&path)
340 {
341 let _ = fs::remove_file(&path).await;
342 }
343 }
344 }
345
346 for (path, content) in &snapshot.files {
347 if let Some(parent) = path.parent() {
348 fs::create_dir_all(parent).await?;
349 }
350 fs::write(path, content).await?;
351 }
352 Ok(())
353}
354
355async fn ensure_rename_snapshot(
356 contracts_dir: &Path,
357 slot: &mut Option<RenameSnapshot>,
358) -> Result<()> {
359 if slot.is_none() {
360 *slot = Some(snapshot_contract_state(contracts_dir).await?);
361 }
362 Ok(())
363}
364
365#[allow(clippy::too_many_arguments)]
366async fn deploy_via_clarinet(
367 ui: &DeployUi,
368 network: &str,
369 contract: Option<&str>,
370 dry_run: bool,
371 yes: bool,
372 wait_confirm: bool,
373 no_auto_version: bool,
374 stacks_node: &str,
375) -> Result<DeployOutcome> {
376 let contracts_dir = std::path::Path::new("contracts");
377
378 let step = ui.begin_step("Analyzing project");
379 let ordered = match resolve_deployment_order(contracts_dir).await {
380 Ok(o) => o,
381 Err(e) => {
382 step.fail();
383 return Err(e);
384 }
385 };
386 if let Some(name) = contract {
387 if let Err(e) = ensure_contract_exists(&ordered, name) {
388 step.fail();
389 return Err(e);
390 }
391 }
392 step.finish();
393
394 if dry_run {
395 let snapshot = snapshot_deploy_writes(contracts_dir).await?;
396 let result = run_deploy_pipeline(
397 ui,
398 network,
399 contract,
400 dry_run,
401 yes,
402 wait_confirm,
403 no_auto_version,
404 stacks_node,
405 contracts_dir,
406 &ordered,
407 )
408 .await;
409 restore_deploy_writes(contracts_dir, &snapshot).await?;
410 return result;
411 }
412
413 run_deploy_pipeline(
414 ui,
415 network,
416 contract,
417 dry_run,
418 yes,
419 wait_confirm,
420 no_auto_version,
421 stacks_node,
422 contracts_dir,
423 &ordered,
424 )
425 .await
426}
427
428#[allow(clippy::too_many_arguments)]
429async fn run_deploy_pipeline(
430 ui: &DeployUi,
431 network: &str,
432 contract: Option<&str>,
433 dry_run: bool,
434 yes: bool,
435 wait_confirm: bool,
436 no_auto_version: bool,
437 stacks_node: &str,
438 contracts_dir: &Path,
439 ordered: &[String],
440) -> Result<DeployOutcome> {
441 let clarinet_path = contracts_dir.join("Clarinet.toml");
442 let clarinet_backup = if !dry_run {
443 Some(fs::read(&clarinet_path).await?)
444 } else {
445 None
446 };
447 let mut rename_snapshot: Option<RenameSnapshot> = None;
448
449 let result = run_deploy_pipeline_inner(
450 ui,
451 network,
452 contract,
453 dry_run,
454 yes,
455 wait_confirm,
456 no_auto_version,
457 stacks_node,
458 contracts_dir,
459 ordered,
460 &mut rename_snapshot,
461 )
462 .await;
463
464 if result.is_err() {
465 if let Some(snapshot) = rename_snapshot {
466 let _ = restore_rename_snapshot(contracts_dir, &snapshot).await;
467 }
468 if let Some(bytes) = clarinet_backup {
469 let _ = fs::write(&clarinet_path, bytes).await;
470 }
471 }
472 result
473}
474
475#[allow(clippy::too_many_arguments)]
476async fn run_deploy_pipeline_inner(
477 ui: &DeployUi,
478 network: &str,
479 contract: Option<&str>,
480 dry_run: bool,
481 yes: bool,
482 wait_confirm: bool,
483 no_auto_version: bool,
484 stacks_node: &str,
485 contracts_dir: &Path,
486 ordered: &[String],
487 rename_snapshot: &mut Option<RenameSnapshot>,
488) -> Result<DeployOutcome> {
489 let fee_flag = "--low-cost";
490
491 let step = ui.begin_step("Resolving contract dependencies");
492 if let Err(e) = reorder_clarinet_toml(contracts_dir, ordered).await {
493 step.fail();
494 return Err(e);
495 }
496 step.finish();
497
498 let mut effective_contract = contract.map(str::to_string);
499
500 if network == "testnet" || network == "mainnet" {
501 let step = ui.begin_step("Checking existing contracts...");
502 let renames = match plan_conflicting_contract_renames(network, contract).await {
503 Ok(r) => r,
504 Err(e) => {
505 step.fail();
506 return Err(e);
507 }
508 };
509 step.finish();
510 for rename in &renames {
511 ui.step_detail(&format!("{} already exists", rename.from));
512 ui.step_detail(&format!("renamed → {}", rename.to));
513 }
514 if renames.is_empty() {
515 ui.step_detail("no conflicts");
516 }
517
518 let (total_micro_stx, contracts) =
519 build_deployment_preview(network, fee_flag, contract).await?;
520
521 if dry_run {
522 ui.dry_run_done(&contracts, total_micro_stx);
523 if !renames.is_empty() {
524 ui.step_detail(
525 "dry run note: conflicting contract names would be versioned on apply",
526 );
527 }
528 return Ok(DeployOutcome {
529 status: "dry-run",
530 block_height: None,
531 });
532 }
533
534 if no_auto_version && !renames.is_empty() {
535 let conflicts: Vec<String> = renames
536 .iter()
537 .map(|r| {
538 format!(
539 "{} (network has {}; would rename to {})",
540 r.from, r.from, r.to
541 )
542 })
543 .collect();
544 return Err(anyhow!(
545 "Contract name conflict on {network}: {}.\n\
546 Omit --no-auto-version to allow auto-versioning (e.g. counter → counter-v2), \
547 or pick a new contract name.\n\
548 Tip: commit your project before deploying to testnet/mainnet.",
549 conflicts.join(", ")
550 ));
551 }
552
553 let deployer = get_deployer_from_plan(network).await?;
554 ui.print_summary(&deployer, &contracts, total_micro_stx);
555 if !ui.confirm_continue(yes)? {
556 return Err(anyhow!(
557 "{} deployment aborted by user.",
558 capitalize(network)
559 ));
560 }
561
562 if !renames.is_empty() {
563 ensure_rename_snapshot(contracts_dir, rename_snapshot).await?;
564 let step = ui.begin_step("Applying versioned contract names");
565 if let Err(e) = apply_contract_renames(&renames).await {
566 step.fail();
567 return Err(e);
568 }
569 step.finish();
570 effective_contract = effective_contract
571 .as_deref()
572 .map(|name| map_contract_name_after_renames(name, &renames));
573 }
574
575 let clarinet_output = run_generate_and_apply(
576 ui,
577 network,
578 fee_flag,
579 effective_contract.as_deref(),
580 false,
581 true,
582 true,
583 )
584 .await?;
585
586 if clarinet_output.contains("ContractAlreadyExists") {
587 ui.step_detail("Conflict after versioning — retrying...");
588 let retry_renames =
589 plan_conflicting_contract_renames(network, effective_contract.as_deref()).await?;
590 if !retry_renames.is_empty() {
591 if no_auto_version {
592 return Err(anyhow!(
593 "Contract still exists on {network} after versioning. \
594 Remove --no-auto-version or choose a new contract name."
595 ));
596 }
597 ensure_rename_snapshot(contracts_dir, rename_snapshot).await?;
598 apply_contract_renames(&retry_renames).await?;
599 effective_contract = effective_contract
600 .as_deref()
601 .map(|name| map_contract_name_after_renames(name, &retry_renames));
602 }
603 let clarinet_output2 = run_generate_and_apply(
604 ui,
605 network,
606 fee_flag,
607 effective_contract.as_deref(),
608 false,
609 true,
610 true,
611 )
612 .await?;
613 return write_deployments_json_from_output(
614 ui,
615 network,
616 &clarinet_output2,
617 effective_contract.as_deref(),
618 wait_confirm,
619 stacks_node,
620 )
621 .await;
622 }
623
624 return write_deployments_json_from_output(
625 ui,
626 network,
627 &clarinet_output,
628 effective_contract.as_deref(),
629 wait_confirm,
630 stacks_node,
631 )
632 .await;
633 }
634
635 let clarinet_output =
636 run_generate_and_apply(ui, network, fee_flag, contract, dry_run, yes, false).await?;
637
638 if dry_run {
639 return Ok(DeployOutcome {
640 status: "dry-run",
641 block_height: None,
642 });
643 }
644
645 write_deployments_json_from_output(
646 ui,
647 network,
648 &clarinet_output,
649 contract,
650 wait_confirm,
651 stacks_node,
652 )
653 .await
654}
655
656async fn reorder_clarinet_toml(
657 contracts_dir: &std::path::Path,
658 order: &[String],
659) -> anyhow::Result<()> {
660 let path = contracts_dir.join("Clarinet.toml");
661 let raw = fs::read_to_string(&path).await?;
662
663 let mut header = String::new();
664 let mut blocks: HashMap<String, String> = HashMap::new();
665 let mut suffix = String::new();
666 let mut current_name: Option<String> = None;
667 let mut current_block = String::new();
668 let mut seen_contracts = false;
669 let mut in_suffix = false;
670
671 for line in raw.lines() {
672 let trimmed = line.trim();
673 let next_contract = trimmed
674 .strip_prefix("[contracts.")
675 .and_then(|s| s.strip_suffix(']'));
676
677 if let Some(name) = next_contract {
678 seen_contracts = true;
679 in_suffix = false;
680 if let Some(prev) = current_name.take() {
681 blocks.insert(prev, current_block.trim().to_string());
682 }
683 current_name = Some(name.to_string());
684 current_block = format!("{line}\n");
685 continue;
686 }
687
688 if current_name.is_some() && trimmed.starts_with('[') && !trimmed.starts_with("[contracts.")
689 {
690 if let Some(prev) = current_name.take() {
691 blocks.insert(prev, current_block.trim().to_string());
692 }
693 in_suffix = true;
694 }
695
696 if current_name.is_some() {
697 current_block.push_str(line);
698 current_block.push('\n');
699 } else if in_suffix {
700 suffix.push_str(line);
701 suffix.push('\n');
702 } else {
703 header.push_str(line);
704 header.push('\n');
705 }
706 }
707 if let Some(prev) = current_name {
708 blocks.insert(prev, current_block.trim().to_string());
709 }
710 if !seen_contracts {
711 return Ok(());
712 }
713
714 let mut output = header.trim_end_matches('\n').to_string();
715 let mut emitted: HashSet<&str> = HashSet::new();
716 for name in order {
717 if let Some(block) = blocks.get(name) {
718 if !output.is_empty() {
719 output.push('\n');
720 }
721 output.push('\n');
722 output.push_str(block);
723 output.push('\n');
724 emitted.insert(name.as_str());
725 }
726 }
727 let mut remaining: Vec<&String> = blocks
728 .keys()
729 .filter(|name| !emitted.contains(name.as_str()))
730 .collect();
731 remaining.sort();
732 for name in remaining {
733 if let Some(block) = blocks.get(name) {
734 if !output.is_empty() {
735 output.push('\n');
736 }
737 output.push('\n');
738 output.push_str(block);
739 output.push('\n');
740 }
741 }
742 if !suffix.trim().is_empty() {
743 output.push('\n');
744 output.push_str(suffix.trim_end_matches('\n'));
745 output.push('\n');
746 }
747
748 fs::write(&path, output).await?;
749 Ok(())
750}
751
752async fn run_clarinet_quiet(args: &[&str]) -> Result<()> {
754 let output = Command::new("clarinet")
755 .args(args)
756 .current_dir("contracts")
757 .stdout(Stdio::null())
758 .stderr(Stdio::piped())
759 .output()
760 .await
761 .map_err(|_| {
762 anyhow!(
763 "clarinet is required. Install: brew install clarinet OR cargo install clarinet"
764 )
765 })?;
766 if !output.status.success() {
767 let err = String::from_utf8_lossy(&output.stderr);
768 let filtered: String = err
770 .lines()
771 .filter(|l| !l.contains("A new release of clarinet"))
772 .collect::<Vec<_>>()
773 .join("\n");
774 return Err(anyhow!(
775 "clarinet {} failed.\n{}",
776 args.join(" "),
777 filtered.trim()
778 ));
779 }
780 Ok(())
781}
782
783async fn run_generate_quiet() -> Result<()> {
784 let bin = std::env::current_exe().unwrap_or_else(|_| "stacksdapp".into());
786 let status = Command::new(&bin)
787 .args(["-q", "generate"])
788 .stdout(Stdio::null())
789 .stderr(Stdio::null())
790 .status()
791 .await;
792 match status {
793 Ok(s) if s.success() => Ok(()),
794 Ok(s) => {
795 let fallback = Command::new("stacksdapp")
796 .args(["-q", "generate"])
797 .stdout(Stdio::null())
798 .stderr(Stdio::null())
799 .status()
800 .await;
801 match fallback {
802 Ok(fallback_status) if fallback_status.success() => Ok(()),
803 Ok(fallback_status) => Err(anyhow!(
804 "Failed to regenerate TypeScript bindings: in-tree binary exited with {s}, PATH fallback exited with {fallback_status}."
805 )),
806 Err(err) => Err(anyhow!(
807 "Failed to regenerate TypeScript bindings: in-tree binary exited with {s}, PATH fallback could not start: {err}"
808 )),
809 }
810 }
811 Err(err) => Err(anyhow!("Failed to run stacksdapp generate: {err}")),
812 }
813}
814
815async fn build_deployment_preview(
816 network: &str,
817 fee_flag: &str,
818 contract: Option<&str>,
819) -> Result<(u64, Vec<String>)> {
820 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
821 if Path::new(&plan_path).exists() {
822 fs::remove_file(&plan_path).await?;
823 }
824
825 let net_flag = format!("--{network}");
826 run_clarinet_quiet(&["deployments", "generate", &net_flag, fee_flag]).await?;
827 if let Some(contract_name) = contract {
828 filter_plan_to_contract(network, contract_name).await?;
829 }
830
831 let total_micro_stx = check_plan_fee(network)?;
832 let contracts = deployment_contract_names_from_plan(network).await?;
833 Ok((total_micro_stx, contracts))
834}
835
836async fn run_generate_and_apply(
838 ui: &DeployUi,
839 network: &str,
840 fee_flag: &str,
841 contract: Option<&str>,
842 dry_run: bool,
843 yes: bool,
844 skip_remote_confirmation: bool,
845) -> Result<String> {
846 let step = ui.begin_step("Generating deployment artifacts");
847 if let Err(e) = build_deployment_preview(network, fee_flag, contract).await {
848 step.fail();
849 return Err(anyhow!(
850 "Failed to generate deployment plan.\n\
851 • Run `clarinet check` to validate your contracts.\n\
852 • Ensure settings/{}.toml has a valid mnemonic.\n{e}",
853 capitalize(network)
854 ));
855 }
856 step.finish();
857
858 if !dry_run {
859 let step = ui.begin_step("Exporting TypeScript bindings");
860 if let Err(e) = run_generate_quiet().await {
861 step.fail();
862 return Err(e);
863 }
864 step.finish();
865 }
866
867 let step = ui.begin_step("Building deployment plan");
868 let total_micro_stx = match check_plan_fee(network) {
869 Ok(v) => v,
870 Err(e) => {
871 step.fail();
872 return Err(e);
873 }
874 };
875 let contracts = match deployment_contract_names_from_plan(network).await {
876 Ok(c) => c,
877 Err(e) => {
878 step.fail();
879 return Err(e);
880 }
881 };
882 step.finish();
883
884 if dry_run {
885 ui.dry_run_done(&contracts, total_micro_stx);
886 return Ok(String::new());
887 }
888
889 if network == "devnet" {
890 return run_apply_devnet(ui, network, &contracts, contract.is_some()).await;
891 }
892
893 let deployer = get_deployer_from_plan(network).await?;
894 if !skip_remote_confirmation {
895 ui.print_summary(&deployer, &contracts, total_micro_stx);
896
897 if !ui.confirm_continue(yes)? {
898 return Err(anyhow!(
899 "{} deployment aborted by user.",
900 capitalize(network)
901 ));
902 }
903 }
904
905 let plan = read_deployment_plan(network).await?;
906 if plan_uses_direct_broadcast(&plan) {
907 return run_apply_direct(ui, network).await;
908 }
909
910 run_clarinet_deployments_apply(ui, network, &contracts, contract.is_some(), None).await
911}
912
913async fn run_apply_devnet(
914 ui: &DeployUi,
915 network: &str,
916 contracts: &[String],
917 single_contract: bool,
918) -> Result<String> {
919 let plan = read_deployment_plan(network).await?;
920 let transactions = flatten_contract_publishes(&plan);
921 if transactions.is_empty() {
922 return Err(anyhow!(
923 "No contract publish transactions found in the devnet deployment plan."
924 ));
925 }
926
927 let required_burn = transactions
928 .iter()
929 .map(|item| min_burn_height_for_publish(item.epoch.as_deref(), item.tx.clarity_version))
930 .max()
931 .unwrap_or(0);
932 if required_burn > 0 {
933 wait_for_burn_height(required_burn).await?;
934 }
935
936 ensure_devnet_chain_mining("broadcast").await?;
937
938 let needs_clarinet_apply_for_c6 = transactions
940 .iter()
941 .any(|item| item.tx.clarity_version.unwrap_or(0) >= 6)
942 && !clarinet_version_at_least(3, 23, 0);
943
944 if needs_clarinet_apply_for_c6 {
945 ui.step_ok("Preparing Clarinet devnet broadcast (Clarity 6)");
946 let plan_path = write_contracts_only_plan_file(network).await?;
947 return run_clarinet_deployments_apply(
948 ui,
949 network,
950 contracts,
951 single_contract,
952 Some(plan_path.as_path()),
953 )
954 .await;
955 }
956
957 if clarinet_version_at_least(3, 23, 0) {
959 ui.step_ok("Preparing Clarinet devnet broadcast");
960 match run_clarinet_deployments_apply(ui, network, contracts, single_contract, None).await {
961 Ok(stdout) => return Ok(stdout),
962 Err(clarinet_err) => {
963 stacksdapp_shell::println_human_safe(format!(
964 "[deploy] Clarinet devnet apply failed ({clarinet_err:#}); trying direct broadcast..."
965 ));
966 }
967 }
968 }
969
970 run_apply_direct(ui, network).await
971}
972
973async fn run_clarinet_deployments_apply(
974 ui: &DeployUi,
975 network: &str,
976 contracts: &[String],
977 single_contract: bool,
978 plan_path: Option<&Path>,
979) -> Result<String> {
980 ui.broadcasting_start();
981
982 let mut args = vec![
983 "deployments".to_string(),
984 "apply".to_string(),
985 "--no-dashboard".to_string(),
986 ];
987 if let Some(path) = plan_path {
988 args.push("-p".to_string());
989 args.push(path.display().to_string());
990 } else {
991 args.push(format!("--{network}"));
992 }
993
994 let mut child = Command::new("clarinet")
995 .args(&args)
996 .current_dir("contracts")
997 .stdin(Stdio::piped())
998 .stdout(Stdio::piped())
999 .stderr(Stdio::piped())
1000 .spawn()?;
1001
1002 let mut stdin = child
1003 .stdin
1004 .take()
1005 .ok_or_else(|| anyhow!("Failed to open stdin"))?;
1006 let _ = stdin.write_all(b"y\n").await;
1008 let _ = stdin.flush().await;
1009 let stdout = child
1010 .stdout
1011 .take()
1012 .ok_or_else(|| anyhow!("Failed to open stdout"))?;
1013 let stderr = child
1014 .stderr
1015 .take()
1016 .ok_or_else(|| anyhow!("Failed to open stderr"))?;
1017
1018 let expected_count = contracts.len().max(1);
1019 let mut confirmed_count = 0usize;
1020 let mut broadcast_count = 0usize;
1021 let mut captured_stdout = String::new();
1022 let mut last_txid_by_name: HashMap<String, String> = HashMap::new();
1023 let mut stdout_reader = tokio::io::BufReader::new(stdout).lines();
1024 let mut stderr_reader = tokio::io::BufReader::new(stderr).lines();
1025
1026 ui.render_bar(0, expected_count);
1027
1028 loop {
1029 let line = tokio::select! {
1030 line = stdout_reader.next_line() => line,
1031 line = stderr_reader.next_line() => line,
1032 };
1033
1034 let Some(line) = line? else {
1035 break;
1036 };
1037
1038 captured_stdout.push_str(&line);
1039 captured_stdout.push('\n');
1040
1041 if line.contains("REDEPLOYMENT REQUIRED") || line.contains("out of sync") {
1042 let _ = child.kill().await;
1043 return Err(anyhow!(
1044 "Devnet redeployment required. Check your contract versions."
1045 ));
1046 }
1047
1048 if line.contains("ContractAlreadyExists") {
1049 let _ = child.kill().await;
1050 return Err(anyhow!(
1051 "Contract already exists on devnet.\n\
1052 Run `stacksdapp clean` and restart `stacksdapp dev`, or redeploy with a new contract name.\n\
1053 Output:\n{}",
1054 captured_stdout.trim()
1055 ));
1056 }
1057
1058 if line.contains("Error publishing transactions")
1059 || line.contains("unable to post transaction")
1060 || line.starts_with("x Error")
1061 || (line.contains("➡ Error(") && line.contains("Publish ST1"))
1062 {
1063 let _ = child.kill().await;
1064 return Err(anyhow!(
1065 "clarinet deployments apply failed.\nOutput:\n{}",
1066 captured_stdout.trim()
1067 ));
1068 }
1069
1070 if line.contains("Overwrite?") {
1072 let answer = if single_contract { b"n\n" } else { b"y\n" };
1073 let _ = stdin.write_all(answer).await;
1074 let _ = stdin.flush().await;
1075 } else if line.contains("Confirm?")
1076 || line.contains("Continue [Y/n]?")
1077 || line.contains("[Y/n]")
1078 {
1079 let _ = stdin.write_all(b"y\n").await;
1080 let _ = stdin.flush().await;
1081 }
1082
1083 if line.contains("Broadcasted") && line.contains("ContractPublish(") {
1084 broadcast_count += 1;
1085 if let Some((name, txid)) = parse_broadcast_line(&line) {
1086 last_txid_by_name.insert(name, txid);
1087 }
1088 ui.render_bar(broadcast_count, expected_count);
1089 } else if line.contains("\"txid\"") && !line.contains("\"error\"") {
1090 if let Some(txid) = line
1091 .split('"')
1092 .find(|part| part.len() == 64 && part.chars().all(|c| c.is_ascii_hexdigit()))
1093 {
1094 if let Some(name) = contracts.get(broadcast_count) {
1095 last_txid_by_name.insert(name.clone(), txid.to_string());
1096 }
1097 }
1098 } else if let Some(name) = parse_clarinet_publish_line(&line) {
1099 last_txid_by_name
1101 .entry(name)
1102 .or_insert_with(|| "pending".to_string());
1103 }
1104
1105 if line.contains("Confirmed Publish") || line.contains("Published") {
1106 confirmed_count += 1;
1107 }
1108
1109 if confirmed_count >= expected_count || broadcast_count >= expected_count {
1110 break;
1111 }
1112 }
1113
1114 let status = tokio::time::timeout(std::time::Duration::from_secs(120), child.wait())
1116 .await
1117 .map_err(|_| anyhow!("Timed out waiting for clarinet deployments apply to finish"))??;
1118
1119 if broadcast_count == 0 && captured_stdout.contains("Publish ST1") {
1120 return Err(anyhow!(
1121 "clarinet deployments apply did not confirm any broadcasts.\nOutput:\n{}",
1122 captured_stdout.trim()
1123 ));
1124 }
1125 if !status.success() && broadcast_count == 0 {
1126 return Err(anyhow!(
1127 "clarinet deployments apply failed with status {status}.\nOutput:\n{}",
1128 captured_stdout.trim()
1129 ));
1130 }
1131 if !status.success() && broadcast_count > 0 && broadcast_count < expected_count {
1132 write_partial_deployments_from_output(ui, network, &captured_stdout, None).await?;
1133 return Err(anyhow!(
1134 "Partial {network} deployment: {broadcast_count}/{expected_count} contracts broadcast and recorded in deployments.json.\n\
1135 clarinet deployments apply failed with status {status}.\nOutput:\n{}",
1136 captured_stdout.trim()
1137 ));
1138 }
1139
1140 if broadcast_count < expected_count {
1141 ui.render_bar(expected_count, expected_count);
1142 }
1143
1144 for name in contracts {
1145 if let Some(txid) = last_txid_by_name.get(name) {
1146 ui.contract_broadcast_ok(name, txid);
1147 if txid != "pending" && txid != "already-deployed" {
1148 captured_stdout.push_str(&format!(
1149 "Broadcasted ContractPublish(StandardPrincipalData(ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM), ContractName(\"{name}\"), \"{txid}\")\n"
1150 ));
1151 }
1152 }
1153 }
1154
1155 Ok(captured_stdout)
1156}
1157
1158async fn write_contracts_only_plan_file(network: &str) -> Result<PathBuf> {
1159 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
1160 let raw = fs::read_to_string(&plan_path)
1161 .await
1162 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
1163 let mut yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
1164 .map_err(|e| anyhow!("Failed to parse deployment plan YAML at {plan_path}: {e}"))?;
1165 filter_plan_contract_publishes_only(&mut yaml);
1166 let rendered = serde_yaml::to_string(&yaml)?;
1167 let mut file = NamedTempFile::new()?;
1168 use std::io::Write;
1169 file.write_all(rendered.as_bytes())?;
1170 let (_, path) = file
1171 .keep()
1172 .map_err(|e| anyhow!("Failed to persist filtered plan: {e:?}"))?;
1173 Ok(path)
1174}
1175
1176fn filter_plan_contract_publishes_only(yaml: &mut serde_yaml::Value) {
1177 let Some(batches) = yaml
1178 .get_mut("plan")
1179 .and_then(|plan| plan.get_mut("batches"))
1180 .and_then(|batches| batches.as_sequence_mut())
1181 else {
1182 return;
1183 };
1184
1185 for batch in batches.iter_mut() {
1186 let Some(transactions) = batch
1187 .get_mut("transactions")
1188 .and_then(|t| t.as_sequence_mut())
1189 else {
1190 continue;
1191 };
1192 transactions.retain(|tx| {
1193 tx.get("transaction-type").and_then(|v| v.as_str()) == Some("contract-publish")
1194 });
1195 }
1196
1197 batches.retain(|batch| {
1198 batch
1199 .get("transactions")
1200 .and_then(|t| t.as_sequence())
1201 .map(|txs| !txs.is_empty())
1202 .unwrap_or(false)
1203 });
1204}
1205
1206fn plan_uses_direct_broadcast(plan: &DeploymentPlanFile) -> bool {
1207 flatten_contract_publishes(plan)
1208 .iter()
1209 .any(|item| item.tx.clarity_version.unwrap_or(0) >= 5)
1210}
1211
1212async fn run_apply_direct(ui: &DeployUi, network: &str) -> Result<String> {
1213 ui.step_ok(if network == "devnet" {
1214 "Preparing direct devnet broadcast"
1215 } else {
1216 "Preparing direct broadcast (@stacks/transactions)"
1217 });
1218 let plan = read_deployment_plan(network).await?;
1219 let transactions = flatten_contract_publishes(&plan);
1220 if transactions.is_empty() {
1221 return Err(anyhow!(
1222 "No contract publish transactions found in the {network} deployment plan."
1223 ));
1224 }
1225
1226 let settings_path = stacksdapp_shell::settings_relative_path(network);
1227 let settings_raw = fs::read_to_string(&settings_path).await?;
1228 let mnemonic = parse_mnemonic(&settings_raw).ok_or_else(|| {
1229 anyhow!("No deployer mnemonic found in contracts/settings/{network}.toml")
1230 })?;
1231 let derivation = parse_deployer_derivation(&settings_raw)
1232 .unwrap_or_else(|| "m/44'/5757'/0'/0/0".to_string());
1233 let sender_key = derive_private_key_from_mnemonic(&mnemonic, &derivation)?;
1234
1235 let node = network_config(network)?.stacks_node;
1236 let expected_sender = transactions
1237 .first()
1238 .and_then(|item| item.tx.expected_sender.clone())
1239 .ok_or_else(|| anyhow!("No expected sender found in the {network} deployment plan."))?;
1240 let script_path = write_broadcast_script()?;
1241 let mut captured_stdout = String::new();
1242
1243 ui.broadcasting_start();
1246 let expected = transactions.len().max(1);
1247 ui.render_bar(0, expected);
1248
1249 let mut broadcast_count = 0usize;
1250 let mut next_nonce = fetch_core_nonce(&node, &expected_sender).await?;
1251 for item in transactions.into_iter() {
1252 let tx = item.tx;
1253 let contract_name = tx
1254 .contract_name
1255 .clone()
1256 .ok_or_else(|| anyhow!("Missing contract name in deployment plan."))?;
1257
1258 if contract_source_exists(&node, &expected_sender, &contract_name).await? {
1259 stacksdapp_shell::println_human_safe(format!(
1260 "[deploy] {contract_name} already on core — skipping broadcast."
1261 ));
1262 next_nonce = fetch_core_nonce(&node, &expected_sender)
1264 .await?
1265 .max(next_nonce);
1266 captured_stdout.push_str(&format!(
1268 "Broadcasted ContractPublish(StandardPrincipalData({}), ContractName(\"{contract_name}\"), \"already-deployed\")\n",
1269 expected_sender,
1270 ));
1271 broadcast_count += 1;
1272 ui.render_bar(broadcast_count, expected);
1273 ui.contract_broadcast_ok(&contract_name, "already-deployed");
1274 continue;
1275 }
1276
1277 let contract_path = tx.path.clone().ok_or_else(|| {
1278 anyhow!("Missing contract path for {contract_name} in deployment plan.")
1279 })?;
1280 let fee = tx.cost.unwrap_or(10_000).max(1);
1281 let nonce = next_nonce;
1282 let args = serde_json::json!({
1283 "contractName": contract_name,
1284 "codePath": contract_path,
1285 "senderKey": sender_key,
1286 "fee": fee.to_string(),
1287 "nonce": nonce.to_string(),
1288 "clarityVersion": tx.clarity_version,
1289 "network": network,
1290 "baseUrl": node,
1291 });
1292
1293 let mut child = Command::new("node")
1294 .arg(&script_path)
1295 .current_dir("contracts")
1296 .stdin(Stdio::piped())
1297 .stdout(Stdio::piped())
1298 .stderr(Stdio::piped())
1299 .spawn()
1300 .map_err(|_| anyhow!("node is required to deploy directly to devnet"))?;
1301
1302 {
1303 let mut stdin = child
1304 .stdin
1305 .take()
1306 .ok_or_else(|| anyhow!("Failed to open node stdin for devnet broadcast"))?;
1307 stdin.write_all(args.to_string().as_bytes()).await?;
1308 }
1309
1310 let output = child
1311 .wait_with_output()
1312 .await
1313 .map_err(|e| anyhow!("Failed waiting for node broadcast process: {e}"))?;
1314
1315 if !output.status.success() {
1316 let stderr = String::from_utf8_lossy(&output.stderr);
1317 let stdout = String::from_utf8_lossy(&output.stdout);
1318 if broadcast_count > 0 {
1319 write_partial_deployments_from_output(ui, network, &captured_stdout, None).await?;
1320 return Err(anyhow!(
1321 "Partial devnet deployment: {broadcast_count}/{expected} contracts broadcast and recorded in deployments.json.\n\
1322 Failed on {contract_name}.\nstdout:\n{}\nstderr:\n{}",
1323 stdout.trim(),
1324 stderr.trim()
1325 ));
1326 }
1327 return Err(anyhow!(
1328 "Direct devnet deployment failed for {contract_name}.\nstdout:\n{}\nstderr:\n{}",
1329 stdout.trim(),
1330 stderr.trim(),
1331 ));
1332 }
1333
1334 let stdout = String::from_utf8_lossy(&output.stdout);
1335 let result: serde_json::Value = match serde_json::from_str(stdout.trim()) {
1336 Ok(value) => value,
1337 Err(e) => {
1338 if broadcast_count > 0 {
1339 write_partial_deployments_from_output(ui, network, &captured_stdout, None)
1340 .await?;
1341 }
1342 return Err(anyhow!(
1343 "Failed to parse devnet broadcast response: {e}\nRaw output: {}",
1344 stdout.trim()
1345 ));
1346 }
1347 };
1348 if result.get("error").is_some() || result.get("reason").is_some() {
1349 if broadcast_count > 0 {
1350 write_partial_deployments_from_output(ui, network, &captured_stdout, None).await?;
1351 }
1352 return Err(anyhow!(
1353 "Devnet broadcast rejected for {contract_name}: {}",
1354 stdout.trim()
1355 ));
1356 }
1357 let txid = match result.get("txid").and_then(|value| value.as_str()) {
1358 Some(txid) => txid,
1359 None => {
1360 if broadcast_count > 0 {
1361 write_partial_deployments_from_output(ui, network, &captured_stdout, None)
1362 .await?;
1363 }
1364 return Err(anyhow!(
1365 "Devnet broadcast response did not include a txid: {}",
1366 stdout.trim()
1367 ));
1368 }
1369 };
1370
1371 captured_stdout.push_str(&format!(
1372 "Broadcasted ContractPublish(StandardPrincipalData({}), ContractName(\"{}\"), \"{}\")\n",
1373 expected_sender,
1374 tx.contract_name.as_deref().unwrap_or(""),
1375 txid,
1376 ));
1377 broadcast_count += 1;
1378 ui.render_bar(broadcast_count, expected);
1379 ui.contract_broadcast_ok(tx.contract_name.as_deref().unwrap_or(""), txid);
1380 next_nonce = nonce.saturating_add(1);
1381
1382 if network == "devnet" {
1384 wait_for_single_devnet_contract(&expected_sender, &contract_name, 120).await?;
1385 next_nonce = fetch_core_nonce(&node, &expected_sender)
1386 .await?
1387 .max(next_nonce);
1388 }
1389 }
1390
1391 Ok(captured_stdout)
1392}
1393
1394async fn wait_for_single_devnet_contract(
1395 deployer: &str,
1396 contract_name: &str,
1397 timeout_secs: u64,
1398) -> Result<()> {
1399 let client = reqwest::Client::builder()
1400 .timeout(std::time::Duration::from_secs(3))
1401 .build()?;
1402 let node = "http://localhost:20443";
1403 let project = devnet_recovery::devnet_project_name();
1404 let mut last_core = fetch_local_core_info_optional().await;
1405 let mut last_recovery = std::time::Instant::now() - std::time::Duration::from_secs(300);
1406
1407 for attempt in 1..=timeout_secs {
1408 let url = format!("{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0");
1409 let deployed = client
1410 .get(&url)
1411 .send()
1412 .await
1413 .map(|response| response.status().is_success())
1414 .unwrap_or(false);
1415 if deployed {
1416 return Ok(());
1417 }
1418
1419 if attempt % 15 == 0 {
1420 if let Some(prev) = last_core.as_ref() {
1421 if last_recovery.elapsed() >= std::time::Duration::from_secs(45)
1422 && recover_devnet_if_stalled_during_wait(prev, project.as_deref()).await
1423 {
1424 last_recovery = std::time::Instant::now();
1425 }
1426 }
1427 last_core = fetch_local_core_info_optional().await;
1428 }
1429
1430 if attempt == 1 || attempt % 10 == 0 {
1431 stacksdapp_shell::println_human_safe(format!(
1432 "[deploy] Waiting for {contract_name} on local core ({attempt}s)..."
1433 ));
1434 }
1435 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
1436 }
1437
1438 ensure_devnet_chain_mining(&format!("confirm {contract_name}")).await?;
1439 if contract_source_exists(node, deployer, contract_name).await? {
1440 return Ok(());
1441 }
1442
1443 Err(anyhow!(
1444 "Timed out waiting for {contract_name} to confirm on local devnet after {timeout_secs}s."
1445 ))
1446}
1447
1448async fn write_partial_deployments_from_output(
1449 ui: &DeployUi,
1450 network: &str,
1451 captured_stdout: &str,
1452 contract: Option<&str>,
1453) -> Result<()> {
1454 let stacks_node = network_config(network)?.stacks_node;
1455 let _ = write_deployments_json_from_output(
1456 ui,
1457 network,
1458 captured_stdout,
1459 contract,
1460 false,
1461 &stacks_node,
1462 )
1463 .await?;
1464 Ok(())
1465}
1466
1467async fn read_deployment_plan(network: &str) -> Result<DeploymentPlanFile> {
1468 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
1469 let raw = fs::read_to_string(&plan_path)
1470 .await
1471 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
1472 serde_yaml::from_str(&raw)
1473 .map_err(|e| anyhow!("Failed to parse deployment plan at {plan_path}: {e}"))
1474}
1475
1476fn flatten_contract_publishes(plan: &DeploymentPlanFile) -> Vec<PlannedPublish> {
1477 plan.plan
1478 .batches
1479 .iter()
1480 .flat_map(|batch| {
1481 batch
1482 .transactions
1483 .iter()
1484 .filter(|tx| tx.transaction_type == "contract-publish")
1485 .map(|tx| PlannedPublish {
1486 tx: tx.clone(),
1487 epoch: batch.epoch.clone(),
1488 })
1489 })
1490 .collect()
1491}
1492
1493fn clarinet_default_epoch_start(epoch: &str) -> Option<u64> {
1495 match epoch.trim() {
1496 "2.0" | "2.05" => Some(100),
1497 "2.1" => Some(101),
1498 "2.2" => Some(102),
1499 "2.3" => Some(103),
1500 "2.4" => Some(104),
1501 "2.5" => Some(108),
1502 "3.0" => Some(142),
1503 "3.1" => Some(144),
1504 "3.2" => Some(146),
1505 "3.3" => Some(148),
1506 "3.4" => Some(150),
1507 "4.0" => Some(epoch_40_burn_height()),
1509 _ => None,
1510 }
1511}
1512
1513fn epoch_40_burn_height() -> u64 {
1515 if clarinet_version_at_least(3, 23, 0) {
1516 163
1517 } else {
1518 152
1519 }
1520}
1521
1522fn min_burn_height_for_publish(epoch: Option<&str>, clarity_version: Option<u8>) -> u64 {
1523 if let Some(epoch) = epoch {
1524 if let Some(height) = clarinet_default_epoch_start(epoch) {
1525 return height;
1526 }
1527 }
1528 match clarity_version {
1529 Some(v) if v >= 6 => epoch_40_burn_height(),
1530 Some(v) if v >= 5 => 150,
1532 Some(v) if v >= 4 => 142,
1533 _ => 0,
1534 }
1535}
1536
1537async fn wait_for_burn_height(min_burn: u64) -> Result<()> {
1538 let mut last_log = std::time::Instant::now() - std::time::Duration::from_secs(30);
1539 let mut core_failures = 0u32;
1540 for _ in 0..180 {
1541 match fetch_local_core_info().await {
1542 Ok(info) if info.burn_block_height >= min_burn => {
1543 stacksdapp_shell::println_human_safe(format!(
1544 "[deploy] Burn height {} reached (needed ≥ {min_burn} for plan epoch) — broadcasting.",
1545 info.burn_block_height
1546 ));
1547 return Ok(());
1548 }
1549 Ok(info) => {
1550 core_failures = 0;
1551 if last_log.elapsed() >= std::time::Duration::from_secs(8) {
1552 stacksdapp_shell::println_human_safe(format!(
1553 "[deploy] Waiting for epoch burn height {min_burn} (currently {})...",
1554 info.burn_block_height
1555 ));
1556 last_log = std::time::Instant::now();
1557 }
1558 }
1559 Err(_) => {
1560 core_failures += 1;
1561 if core_failures >= 5 {
1562 return Err(anyhow!(
1563 "Local stacks-node stopped responding while waiting for burn height ≥ {min_burn}.\n\
1564 Devnet often stalls or restarts near epoch 4.0 activation (~burn 161→163).\n\
1565 Run `stacksdapp clean --force`, restart `stacksdapp dev` (Clarinet 3.23+ uses the epoch 4.0 snapshot), wait until burn ≥ {min_burn}, then deploy again."
1566 ));
1567 }
1568 if last_log.elapsed() >= std::time::Duration::from_secs(8) {
1569 stacksdapp_shell::println_human_safe(
1570 "[deploy] Waiting for local stacks-node before epoch check...",
1571 );
1572 last_log = std::time::Instant::now();
1573 }
1574 }
1575 }
1576 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
1577 }
1578 Err(anyhow!(
1579 "Timed out waiting for burn height ≥ {min_burn}.\n\
1580 Clarity 5 (epoch 3.4 / burn ≥ 150) and Clarity 6 (epoch 4.0 / burn ≥ {}) contracts cannot mine before that height on Clarinet Devnet.\n\
1581 Keep `stacksdapp dev` running and retry deploy once burn height catches up.\n\
1582 If burn height stalls, run `stacksdapp clean --force` and restart devnet (Clarinet 3.23+ pulls a new epoch 4.0 snapshot).",
1583 epoch_40_burn_height()
1584 ))
1585}
1586
1587async fn contract_source_exists(node: &str, deployer: &str, contract_name: &str) -> Result<bool> {
1588 let client = reqwest::Client::builder()
1589 .timeout(std::time::Duration::from_secs(3))
1590 .build()?;
1591 let url = format!("{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0");
1592 Ok(client
1593 .get(&url)
1594 .send()
1595 .await
1596 .map(|response| response.status().is_success())
1597 .unwrap_or(false))
1598}
1599
1600fn write_broadcast_script() -> Result<std::path::PathBuf> {
1601 let mut file = NamedTempFile::new()?;
1602 use std::io::Write;
1603 file.write_all(BROADCAST_SCRIPT.as_bytes())?;
1604 let (_, path) = file.keep()?;
1605 Ok(path)
1606}
1607
1608const BROADCAST_SCRIPT: &str = r#"
1611import fs from 'fs';
1612import path from 'path';
1613import { createRequire } from 'module';
1614
1615function loadStacksTransactions() {
1616 const roots = [process.cwd(), path.join(process.cwd(), '..', 'frontend')];
1617 const errors = [];
1618 for (const root of roots) {
1619 try {
1620 const require = createRequire(path.join(root, 'package.json'));
1621 return require('@stacks/transactions');
1622 } catch (err) {
1623 errors.push(`${root}: ${err?.message || err}`);
1624 }
1625 }
1626 throw new Error(
1627 `Unable to load @stacks/transactions. Tried contracts/ and frontend/. ${errors.join(' | ')}`
1628 );
1629}
1630
1631const {
1632 makeContractDeploy,
1633 AnchorMode,
1634 PostConditionMode,
1635 broadcastTransaction,
1636} = loadStacksTransactions();
1637
1638// Read deploy payload from stdin so the private key never appears in process argv.
1639const chunks = [];
1640for await (const chunk of process.stdin) {
1641 chunks.push(chunk);
1642}
1643const input = JSON.parse(Buffer.concat(chunks).toString('utf8'));
1644const codeBody = fs.readFileSync(input.codePath, 'utf8');
1645
1646const transaction = await makeContractDeploy({
1647 contractName: input.contractName,
1648 codeBody,
1649 senderKey: input.senderKey,
1650 fee: BigInt(input.fee),
1651 nonce: BigInt(input.nonce),
1652 network: input.network,
1653 anchorMode: AnchorMode.OnChainOnly,
1654 postConditionMode: PostConditionMode.Deny,
1655 ...(typeof input.clarityVersion === 'number' ? { clarityVersion: input.clarityVersion } : {}),
1656});
1657
1658const response = await broadcastTransaction({
1659 transaction,
1660 client: { baseUrl: input.baseUrl },
1661});
1662
1663// @stacks/transactions returns error JSON (no throw) on non-2xx — treat as failure.
1664if (response?.error || response?.reason || !response?.txid) {
1665 console.log(JSON.stringify(response));
1666 process.exit(1);
1667}
1668console.log(JSON.stringify(response));
1669"#;
1670
1671async fn fetch_core_nonce(node: &str, address: &str) -> Result<u64> {
1672 let client = reqwest::Client::builder()
1673 .timeout(std::time::Duration::from_secs(3))
1674 .build()?;
1675 let url = format!("{node}/v2/accounts/{address}?proof=0");
1676 let response = client
1677 .get(&url)
1678 .send()
1679 .await
1680 .map_err(|e| anyhow!("Failed to fetch local core account state from {url}: {e}"))?;
1681
1682 if !response.status().is_success() {
1683 let status = response.status();
1684 let body = response.text().await.unwrap_or_default();
1685 return Err(anyhow!(
1686 "Local core node returned {} for {}: {}",
1687 status,
1688 url,
1689 body
1690 ));
1691 }
1692
1693 let account: AccountResponse = response.json().await?;
1694 Ok(account.nonce)
1695}
1696
1697fn derive_private_key_from_mnemonic(mnemonic: &str, derivation: &str) -> Result<String> {
1698 let mnemonic = Mnemonic::parse_normalized(mnemonic)
1699 .map_err(|e| anyhow!("Invalid mnemonic in devnet settings: {e}"))?;
1700 let seed = mnemonic.to_seed_normalized("");
1701 let secp = Secp256k1::new();
1702 let root = Xpriv::new_master(BitcoinNetwork::Testnet, &seed)
1703 .map_err(|e| anyhow!("Failed to derive root key from mnemonic: {e}"))?;
1704 let path = DerivationPath::from_str(derivation)
1705 .map_err(|e| anyhow!("Invalid devnet derivation path {derivation}: {e}"))?;
1706 let child = root
1707 .derive_priv(&secp, &path)
1708 .map_err(|e| anyhow!("Failed to derive child key {derivation}: {e}"))?;
1709 Ok(format!(
1710 "{}01",
1711 hex::encode(child.private_key.secret_bytes())
1712 ))
1713}
1714
1715pub async fn resolve_deployment_order(
1716 contracts_dir: &std::path::Path,
1717) -> anyhow::Result<Vec<String>> {
1718 let clarinet_raw = fs::read_to_string(contracts_dir.join("Clarinet.toml")).await?;
1719 let clarinet: ClarinetToml = toml::from_str(&clarinet_raw)
1720 .map_err(|e| anyhow::anyhow!("Failed to parse Clarinet.toml: {e}"))?;
1721
1722 let contract_map = clarinet.contracts.unwrap_or_default();
1723 let known: HashSet<String> = contract_map.keys().cloned().collect();
1724
1725 let mut dep_graph: HashMap<String, Vec<String>> = HashMap::new();
1727
1728 for (name, entry) in &contract_map {
1729 let clar_path = contracts_dir.join(&entry.path);
1730 let source = fs::read_to_string(&clar_path).await.map_err(|e| {
1731 anyhow!(
1732 "Contract source for '{name}' not found at {}: {e}",
1733 clar_path.display()
1734 )
1735 })?;
1736 let deps = parse_local_deps(&source, &known);
1737
1738 if !deps.is_empty() {
1739 }
1741
1742 dep_graph.insert(name.clone(), deps);
1743 }
1744
1745 let order = topological_sort(&dep_graph)?;
1746
1747 Ok(order)
1748}
1749
1750fn check_plan_fee(network: &str) -> Result<u64> {
1752 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
1753 let plan_raw = std::fs::read_to_string(&plan_path).map_err(|e| {
1754 anyhow!(
1755 "Deployment plan not found at {plan_path}: {e}. Run clarinet deployments generate first."
1756 )
1757 })?;
1758
1759 let total_micro_stx: u64 = plan_raw
1761 .lines()
1762 .filter_map(|line| {
1763 let trimmed = line.trim();
1764 if trimmed.starts_with("cost:") {
1765 trimmed.split_whitespace().nth(1)?.parse::<u64>().ok()
1766 } else {
1767 None
1768 }
1769 })
1770 .sum();
1771
1772 Ok(total_micro_stx)
1773}
1774
1775async fn plan_conflicting_contract_renames(
1776 network: &str,
1777 contract: Option<&str>,
1778) -> Result<Vec<PlannedRename>> {
1779 let config = network_config(network)?;
1780 let client = reqwest::Client::builder()
1781 .timeout(std::time::Duration::from_secs(5))
1782 .build()?;
1783 let _ = build_deployment_preview(network, "--low-cost", contract).await?;
1784 let deployer = get_deployer_from_plan(network).await?;
1785
1786 let base_dir = Path::new("contracts");
1787 let clarinet_path = base_dir.join("Clarinet.toml");
1788 let clarinet_raw = fs::read_to_string(&clarinet_path).await?;
1789 let clarinet_struct: ClarinetToml = toml::from_str(&clarinet_raw)?;
1790 let contracts = clarinet_struct.contracts.unwrap_or_default();
1791
1792 let mut renames: Vec<PlannedRename> = Vec::new();
1793
1794 for current_name in contracts.keys() {
1795 if contract.is_some() && contract != Some(current_name.as_str()) {
1796 continue;
1797 }
1798 let base_name = strip_version_suffix(current_name);
1799
1800 let correct_name =
1802 find_next_free_name(&client, &config.stacks_node, &deployer, &base_name).await?;
1803
1804 if current_name == &correct_name {
1805 continue;
1806 }
1807 renames.push(PlannedRename {
1808 from: current_name.clone(),
1809 to: correct_name,
1810 });
1811 }
1812
1813 Ok(renames)
1814}
1815
1816async fn apply_contract_renames(renames: &[PlannedRename]) -> Result<()> {
1817 if renames.is_empty() {
1818 return Ok(());
1819 }
1820
1821 let base_dir = Path::new("contracts");
1822 let clarinet_path = base_dir.join("Clarinet.toml");
1823 let clarinet_raw = fs::read_to_string(&clarinet_path).await?;
1824 let clarinet_struct: ClarinetToml = toml::from_str(&clarinet_raw)?;
1825 let mut contracts = clarinet_struct.contracts.unwrap_or_default();
1826 let mut clarinet_content = clarinet_raw;
1827
1828 for rename in renames {
1829 let entry = contracts.remove(&rename.from).ok_or_else(|| {
1830 anyhow!(
1831 "Contract '{}' disappeared before rename could be applied.",
1832 rename.from
1833 )
1834 })?;
1835 let old_file_path = base_dir.join(&entry.path);
1836 let new_rel_path = format!("contracts/{}.clar", rename.to);
1837 let new_file_path = base_dir.join(&new_rel_path);
1838
1839 if old_file_path.exists() {
1840 fs::rename(&old_file_path, &new_file_path).await?;
1841 }
1842
1843 let old_header = format!("[contracts.{}]", rename.from);
1844 let new_header = format!("[contracts.{}]", rename.to);
1845 clarinet_content = clarinet_content.replace(&old_header, &new_header);
1846
1847 let old_path_line = format!("path = \"{}\"", entry.path);
1848 let new_path_line = format!("path = \"{}\"", new_rel_path);
1849 clarinet_content = clarinet_content.replace(&old_path_line, &new_path_line);
1850
1851 contracts.insert(rename.to.clone(), ContractEntry { path: new_rel_path });
1852 }
1853
1854 for entry in contracts.values() {
1855 let path = base_dir.join(&entry.path);
1856 if !path.exists() {
1857 continue;
1858 }
1859 let original = fs::read_to_string(&path).await?;
1860 let updated = renames.iter().fold(original.clone(), |acc, rename| {
1861 replace_contract_reference(&acc, &rename.from, &rename.to)
1862 });
1863 if updated != original {
1864 fs::write(&path, updated).await?;
1865 }
1866 }
1867
1868 fs::write(&clarinet_path, &clarinet_content).await?;
1869
1870 for plan_name in [
1871 "default.devnet-plan.yaml",
1872 "default.simnet-plan.yaml",
1873 "default.testnet-plan.yaml",
1874 "default.mainnet-plan.yaml",
1875 ] {
1876 let plan_path = base_dir.join("deployments").join(plan_name);
1877 let _ = fs::remove_file(plan_path).await;
1878 }
1879
1880 Ok(())
1881}
1882
1883async fn get_deployer_from_plan(network: &str) -> Result<String> {
1885 let plan_path = format!("contracts/deployments/default.{}-plan.yaml", network);
1886 let content = fs::read_to_string(&plan_path).await.map_err(|_| {
1887 anyhow!(
1888 "Clarinet plan not found at {}. Is the path correct?",
1889 plan_path
1890 )
1891 })?;
1892
1893 for line in content.lines() {
1894 let trimmed = line.trim();
1895 if trimmed.starts_with("expected-sender:") {
1896 return Ok(trimmed.split(':').nth(1).unwrap_or("").trim().to_string());
1897 }
1898 }
1899 Err(anyhow!(
1900 "Could not find 'expected-sender' in the deployment plan. Check your mnemonic in settings."
1901 ))
1902}
1903
1904async fn find_next_free_name(
1905 client: &reqwest::Client,
1906 node: &str,
1907 deployer: &str,
1908 base_name: &str,
1909) -> Result<String> {
1910 let url = format!("{node}/v2/contracts/source/{deployer}/{base_name}");
1912 let base_taken = contract_exists(client, &url).await?;
1913
1914 if !base_taken {
1915 return Ok(base_name.to_string());
1916 }
1917
1918 let mut version = 2u32;
1920 loop {
1921 let candidate = format!("{base_name}-v{version}");
1922 let url = format!("{node}/v2/contracts/source/{deployer}/{candidate}");
1923 let taken = contract_exists(client, &url).await?;
1924 if !taken {
1925 return Ok(candidate);
1926 }
1927 version += 1;
1928 if version > 99 {
1929 return Err(anyhow!(
1930 "Could not find a free version for '{base_name}' (tried up to v99). Consider using a fresh deployer address."
1931 ));
1932 }
1933 }
1934}
1935
1936async fn contract_exists(client: &reqwest::Client, url: &str) -> Result<bool> {
1937 let response = client
1938 .get(url)
1939 .send()
1940 .await
1941 .map_err(|e| anyhow!("Failed to query remote contract state at {url}: {e}"))?;
1942 interpret_contract_lookup(response.status(), url)
1943}
1944
1945fn interpret_contract_lookup(status: StatusCode, url: &str) -> Result<bool> {
1946 match status {
1947 StatusCode::OK => Ok(true),
1948 StatusCode::NOT_FOUND => Ok(false),
1949 other => Err(anyhow!(
1950 "Remote contract lookup at {url} returned unexpected status {other}. Refusing to guess whether the name is free."
1951 )),
1952 }
1953}
1954
1955fn strip_version_suffix(name: &str) -> String {
1957 if let Some(idx) = name.rfind("-v") {
1959 let suffix = &name[idx + 2..];
1960 if !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) {
1961 return name[..idx].to_string();
1962 }
1963 }
1964 name.to_string()
1965}
1966
1967fn validate_settings_mnemonic(network: &str) -> Result<()> {
1970 let path = stacksdapp_shell::settings_relative_path(network);
1971 let raw =
1972 std::fs::read_to_string(&path).map_err(|_| anyhow!("Settings file not found: {path}"))?;
1973 let parsed = stacksdapp_shell::parse_deployer_mnemonic(&raw).ok_or_else(|| {
1974 anyhow!(
1975 "No [accounts.deployer].mnemonic in {path}.\n\
1976 Add your deployer seed phrase:\n\n\
1977 [accounts.deployer]\n\
1978 mnemonic = \"your 24 words here\"\n\n\
1979 Get testnet STX: https://explorer.hiro.so/sandbox/faucet?chain=testnet"
1980 )
1981 })?;
1982
1983 if stacksdapp_shell::is_mnemonic_placeholder(&parsed.mnemonic) {
1984 return Err(anyhow!(
1985 "No valid mnemonic in {path}.\n\
1986 Add your deployer seed phrase:\n\n\
1987 [accounts.deployer]\n\
1988 mnemonic = \"your 24 words here\"\n\n\
1989 Get testnet STX: https://explorer.hiro.so/sandbox/faucet?chain=testnet"
1990 ));
1991 }
1992
1993 if stacksdapp_shell::is_public_devnet_mnemonic(&parsed.mnemonic) {
1994 return Err(anyhow!(
1995 "This is a public devnet mnemonic in {path} (line {}).\n\
1996 Devnet seeds are publicly known — use a fresh wallet for {network}.\n\
1997 Generate a new seed and fund it via the testnet faucet.",
1998 parsed.line_number,
1999 network = network
2000 ));
2001 }
2002
2003 if let Err(detail) = stacksdapp_shell::validate_mnemonic_word_format(&parsed.mnemonic) {
2004 return Err(anyhow!(
2005 "Invalid deployer mnemonic in {path} (line {}): {detail}",
2006 parsed.line_number
2007 ));
2008 }
2009
2010 Ok(())
2011}
2012
2013fn parse_mnemonic(toml_raw: &str) -> Option<String> {
2014 let mut in_deployer = false;
2015 for line in toml_raw.lines() {
2016 let trimmed = line.trim();
2017 if trimmed == "[accounts.deployer]" {
2018 in_deployer = true;
2019 continue;
2020 }
2021 if trimmed.starts_with('[') {
2022 in_deployer = false;
2023 }
2024 if in_deployer && trimmed.starts_with("mnemonic") {
2025 if let Some((_, val)) = trimmed.split_once('=') {
2026 return Some(val.trim().trim_matches('"').to_string());
2027 }
2028 }
2029 }
2030 None
2031}
2032
2033fn parse_deployer_derivation(toml_raw: &str) -> Option<String> {
2034 let mut in_deployer = false;
2035 for line in toml_raw.lines() {
2036 let trimmed = line.trim();
2037 if trimmed == "[accounts.deployer]" {
2038 in_deployer = true;
2039 continue;
2040 }
2041 if trimmed.starts_with('[') {
2042 in_deployer = false;
2043 }
2044 if in_deployer && trimmed.starts_with("derivation") {
2045 if let Some((_, val)) = trimmed.split_once('=') {
2046 return Some(val.trim().trim_matches('"').to_string());
2047 }
2048 }
2049 }
2050 None
2051}
2052
2053fn map_contract_name_after_renames(name: &str, renames: &[PlannedRename]) -> String {
2054 renames
2055 .iter()
2056 .find(|rename| rename.from == name)
2057 .map(|rename| rename.to.clone())
2058 .unwrap_or_else(|| name.to_string())
2059}
2060
2061fn replace_contract_reference(source: &str, old_name: &str, new_name: &str) -> String {
2062 let needle = format!(".{old_name}");
2063 let mut out = String::with_capacity(source.len());
2064 let mut idx = 0usize;
2065
2066 while let Some(rel) = source[idx..].find(&needle) {
2067 let start = idx + rel;
2068 let after = start + needle.len();
2069 let next = source[after..].chars().next();
2070 let is_boundary =
2071 next.is_none_or(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_'));
2072 if is_boundary {
2073 out.push_str(&source[idx..start]);
2074 out.push('.');
2075 out.push_str(new_name);
2076 idx = after;
2077 } else {
2078 out.push_str(&source[idx..after]);
2079 idx = after;
2080 }
2081 }
2082
2083 out.push_str(&source[idx..]);
2084 out
2085}
2086
2087fn collect_txids_from_clarinet_output(output: &str) -> HashMap<String, String> {
2088 let mut map = HashMap::new();
2089 let mut last_publish: Option<String> = None;
2090
2091 for line in output.lines() {
2092 if let Some(name) = parse_clarinet_publish_line(line) {
2093 last_publish = Some(name);
2094 }
2095
2096 if line.contains("Broadcasted") {
2097 if let Some((name, txid)) = parse_broadcast_line(line) {
2098 map.insert(name, txid);
2099 }
2100 }
2101
2102 if line.contains("\"txid\"") && !line.contains("\"error\"") {
2103 if let Some(txid) = line
2104 .split('"')
2105 .find(|part| part.len() == 64 && part.chars().all(|c| c.is_ascii_hexdigit()))
2106 {
2107 if let Some(name) = last_publish.clone() {
2108 map.entry(name).or_insert_with(|| txid.to_string());
2109 }
2110 }
2111 }
2112 }
2113
2114 map
2115}
2116
2117fn parse_clarinet_publish_line(line: &str) -> Option<String> {
2118 let marker = "Publish ST1";
2119 let pos = line.find(marker)?;
2120 let rest = &line[pos + marker.len()..];
2121 let dot = rest.find('.')?;
2122 let name = rest[dot + 1..].trim();
2123 if name.is_empty() {
2124 None
2125 } else {
2126 Some(name.to_string())
2127 }
2128}
2129
2130fn parse_broadcast_line(line: &str) -> Option<(String, String)> {
2131 let cn_marker = "ContractName(\"";
2132 let name = {
2133 let pos = line.find(cn_marker)?;
2134 let rest = &line[pos + cn_marker.len()..];
2135 let end = rest.find('"')?;
2136 rest[..end].to_string()
2137 };
2138 if let Some(txid) = line
2139 .split('"')
2140 .find(|part| part.len() == 64 && part.chars().all(|c| c.is_ascii_hexdigit()))
2141 {
2142 return Some((name, txid.to_string()));
2143 }
2144 if line.contains("\"already-deployed\"") {
2146 return Some((name, "already-deployed".to_string()));
2147 }
2148 None
2149}
2150
2151async fn write_deployments_json_from_output(
2152 ui: &DeployUi,
2153 network: &str,
2154 output: &str,
2155 contract: Option<&str>,
2156 wait_confirm: bool,
2157 stacks_node: &str,
2158) -> Result<DeployOutcome> {
2159 let txid_map = collect_txids_from_clarinet_output(output);
2160 let mut actual_deployer = None;
2161 for line in output.lines() {
2162 if line.contains("Broadcasted") {
2163 if let Some(start) = line.find("StandardPrincipalData(") {
2164 let rest = &line[start + "StandardPrincipalData(".len()..];
2165 if let Some(end) = rest.find(')') {
2166 actual_deployer = Some(rest[..end].to_string());
2167 }
2168 }
2169 }
2170 }
2171 let settings_file = format!("contracts/settings/{}.toml", capitalize(network));
2172 let settings_raw = fs::read_to_string(&settings_file).await.unwrap_or_default();
2173
2174 let deployer_address = actual_deployer
2175 .or_else(|| parse_deployer_address_from_settings(&settings_raw))
2176 .unwrap_or_else(|| "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM".to_string());
2177
2178 let clarinet_raw = fs::read_to_string("contracts/Clarinet.toml").await?;
2179 let clarinet: ClarinetToml =
2180 toml::from_str(&clarinet_raw).map_err(|e| anyhow!("Failed to parse Clarinet.toml: {e}"))?;
2181 let mut contract_names: Vec<String> = clarinet
2182 .contracts
2183 .as_ref()
2184 .map(|contracts| contracts.keys().cloned().collect())
2185 .unwrap_or_default();
2186 if let Some(contract_name) = contract {
2187 contract_names.retain(|name| name == contract_name);
2188 }
2189
2190 if network == "devnet" {
2191 wait_for_devnet_contracts(&deployer_address, &contract_names).await?;
2192 }
2193
2194 let mut deploy_status = if network == "devnet" {
2195 "confirmed"
2196 } else {
2197 "broadcast"
2198 };
2199 let mut block_height: Option<u64> = None;
2200
2201 if (network == "testnet" || network == "mainnet") && wait_confirm {
2202 ui.waiting_confirmation();
2203 block_height =
2204 wait_for_remote_contracts(stacks_node, &deployer_address, &contract_names).await?;
2205 deploy_status = "confirmed";
2206 }
2207
2208 let confirmed_height = block_height.unwrap_or(0);
2209
2210 let mut contracts_map = if contract.is_some() {
2211 load_existing_deployments_for_network(network).await?
2212 } else {
2213 HashMap::new()
2214 };
2215 let timestamp = chrono::Utc::now().to_rfc3339();
2216
2217 let mut success_entries: Vec<(String, String, String)> = Vec::new();
2218
2219 for name in &contract_names {
2220 let contract_id = format!("{deployer_address}.{name}");
2221 let broadcast_marker = format!("ContractName(\"{name}\")");
2222 let publish_marker = format!(".{name}");
2223 let was_broadcast = output.lines().any(|line| {
2224 (line.contains("Broadcasted") && line.contains(&broadcast_marker))
2225 || (line.contains("Publish ST1") && line.contains(&publish_marker))
2226 });
2227 let txid = match txid_map.get(name) {
2228 Some(t) if t == "already-deployed" || t == "pending" => String::new(),
2229 Some(t) => {
2230 if t.starts_with("0x") {
2231 t.clone()
2232 } else {
2233 format!("0x{t}")
2234 }
2235 }
2236 None if was_broadcast => {
2237 stacksdapp_shell::debug(
2238 1,
2239 format!(
2240 "deploy: broadcast for '{name}' succeeded but txid was not in clarinet output"
2241 ),
2242 );
2243 String::new()
2244 }
2245 None => String::new(),
2246 };
2247 success_entries.push((name.clone(), contract_id.clone(), txid.clone()));
2248 contracts_map.insert(
2249 name.clone(),
2250 DeploymentInfo {
2251 contract_id,
2252 tx_id: txid,
2253 block_height: confirmed_height,
2254 },
2255 );
2256 }
2257
2258 let json = serde_json::to_string_pretty(&DeploymentFile {
2259 network: network.to_string(),
2260 deployed_at: timestamp,
2261 contracts: contracts_map,
2262 })?;
2263
2264 let out_path = Path::new("frontend/src/generated/deployments.json");
2265 if let Some(p) = out_path.parent() {
2266 fs::create_dir_all(p).await?;
2267 }
2268 fs::write(out_path, &json).await?;
2269
2270 run_generate_quiet().await?;
2272
2273 ui.success(&success_entries, deploy_status);
2274 Ok(DeployOutcome {
2275 status: deploy_status,
2276 block_height,
2277 })
2278}
2279
2280async fn load_existing_deployments_for_network(
2281 network: &str,
2282) -> Result<HashMap<String, DeploymentInfo>> {
2283 let path = Path::new("frontend/src/generated/deployments.json");
2284 let raw = match fs::read_to_string(path).await {
2285 Ok(content) => content,
2286 Err(_) => return Ok(HashMap::new()),
2287 };
2288
2289 let parsed: DeploymentFile = match serde_json::from_str(&raw) {
2290 Ok(file) => file,
2291 Err(_) => return Ok(HashMap::new()),
2292 };
2293
2294 if parsed.network == network {
2295 Ok(parsed.contracts)
2296 } else {
2297 Ok(HashMap::new())
2298 }
2299}
2300
2301fn ensure_contract_exists(known: &[String], contract: &str) -> Result<()> {
2302 if known.iter().any(|name| name == contract) {
2303 return Ok(());
2304 }
2305 Err(anyhow!(
2306 "Contract '{contract}' was not found in contracts/Clarinet.toml.\nAvailable contracts: {}",
2307 if known.is_empty() {
2308 "<none>".to_string()
2309 } else {
2310 known.join(", ")
2311 }
2312 ))
2313}
2314
2315async fn filter_plan_to_contract(network: &str, contract_name: &str) -> Result<()> {
2316 let plan_path = format!("contracts/deployments/default.{network}-plan.yaml");
2317 let raw = fs::read_to_string(&plan_path)
2318 .await
2319 .map_err(|e| anyhow!("Failed to read deployment plan at {plan_path}: {e}"))?;
2320 let mut yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
2321 .map_err(|e| anyhow!("Failed to parse deployment plan YAML at {plan_path}: {e}"))?;
2322 let mut found = false;
2323
2324 let batches = yaml
2325 .get_mut("plan")
2326 .and_then(|plan| plan.get_mut("batches"))
2327 .and_then(|batches| batches.as_sequence_mut())
2328 .ok_or_else(|| anyhow!("Deployment plan is missing plan.batches"))?;
2329
2330 for batch in batches.iter_mut() {
2331 let Some(transactions) = batch
2332 .get_mut("transactions")
2333 .and_then(|t| t.as_sequence_mut())
2334 else {
2335 continue;
2336 };
2337
2338 transactions.retain(|tx| {
2339 let tx_type = tx
2340 .get("transaction-type")
2341 .and_then(|v| v.as_str())
2342 .unwrap_or("");
2343 if tx_type != "contract-publish" {
2344 return true;
2345 }
2346
2347 let keep = tx.get("contract-name").and_then(|v| v.as_str()) == Some(contract_name);
2348 if keep {
2349 found = true;
2350 }
2351 keep
2352 });
2353 }
2354
2355 batches.retain(|batch| {
2356 batch
2357 .get("transactions")
2358 .and_then(|t| t.as_sequence())
2359 .map(|txs| !txs.is_empty())
2360 .unwrap_or(false)
2361 });
2362
2363 if !found {
2364 return Err(anyhow!(
2365 "Contract '{contract_name}' is not present in the generated deployment plan.\n\
2366 Ensure the contract exists and passes `clarinet check`."
2367 ));
2368 }
2369
2370 let rendered = serde_yaml::to_string(&yaml)?;
2371 fs::write(&plan_path, rendered).await?;
2372 Ok(())
2373}
2374
2375async fn deployment_contract_names_from_plan(network: &str) -> Result<Vec<String>> {
2376 let plan = read_deployment_plan(network).await?;
2377 let names = flatten_contract_publishes(&plan)
2378 .into_iter()
2379 .filter_map(|item| item.tx.contract_name)
2380 .collect::<Vec<_>>();
2381 if names.is_empty() {
2382 return Err(anyhow!(
2383 "No contract publish transactions found in deployment plan for {network}."
2384 ));
2385 }
2386 Ok(names)
2387}
2388
2389async fn wait_for_devnet_contracts(deployer: &str, contract_names: &[String]) -> Result<()> {
2390 if contract_names.is_empty() {
2391 return Ok(());
2392 }
2393
2394 let client = reqwest::Client::builder()
2395 .timeout(std::time::Duration::from_secs(3))
2396 .build()?;
2397 let node = "http://localhost:20443";
2398 let initial_info = fetch_local_core_info().await.ok();
2399
2400 ui_log_devnet_wait_start();
2401
2402 for attempt in 1..=90 {
2405 let mut pending = Vec::new();
2406
2407 for contract_name in contract_names {
2408 let url = format!("{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0");
2409 let deployed = client
2410 .get(&url)
2411 .send()
2412 .await
2413 .map(|response| response.status().is_success())
2414 .unwrap_or(false);
2415
2416 if !deployed {
2417 pending.push(contract_name.clone());
2418 }
2419 }
2420
2421 if pending.is_empty() {
2422 return Ok(());
2423 }
2424
2425 if attempt == 1 || attempt % 5 == 0 {
2426 ui_log_devnet_wait_tick(attempt, &pending);
2427 }
2428 tokio::time::sleep(std::time::Duration::from_secs(1)).await;
2429 }
2430
2431 let nonce = fetch_core_nonce("http://localhost:20443", deployer)
2432 .await
2433 .unwrap_or_default();
2434 let stacks_api_healthy = probe_stacks_api_health().await.unwrap_or(false);
2435 let final_info = fetch_local_core_info().await.ok();
2436 let stall_hint = match (initial_info, final_info) {
2437 (Some(start), Some(end))
2438 if start.burn_block_height == end.burn_block_height
2439 && start.stacks_tip_height == end.stacks_tip_height =>
2440 {
2441 format!(
2442 "Local devnet appears stalled: burn block height stayed at {} and stacks tip height stayed at {} while waiting for confirmation.",
2443 end.burn_block_height, end.stacks_tip_height
2444 )
2445 }
2446 _ => format!(
2447 "Local devnet tip did move during the wait, so the publish appears to be stuck independently of tip progression.\n\
2448 Common cause: Clarity 5/6 contracts were broadcast before their epoch burn height (150 for C5, {} for C6) — they sit unmined. Re-run deploy after stacksdapp waits for the required epoch.",
2449 epoch_40_burn_height()
2450 ),
2451 };
2452
2453 Err(anyhow!(
2454 "Devnet deploy did not finalize on the local Stacks core node.\n\
2455 The contract source never became available at http://localhost:20443 and the deployer nonce is still {nonce}.\n\
2456 This means the publish did not finalize on core, even if the explorer/mempool UI appears to show it.\n\
2457 {stall_hint}\n\
2458 {api_hint}\n\
2459 Try restarting devnet with `stacksdapp clean` and `stacksdapp dev`, then deploy again."
2460 ,
2461 stall_hint = stall_hint,
2462 api_hint = if stacks_api_healthy {
2463 "Local stacks-api responded normally, so the failure is on the core-chain side."
2464 } else {
2465 "Local stacks-api/indexer also appears unhealthy, so the explorer UI may be stale or misleading."
2466 }
2467 ))
2468}
2469
2470async fn wait_for_remote_contracts(
2471 stacks_node: &str,
2472 deployer: &str,
2473 contract_names: &[String],
2474) -> Result<Option<u64>> {
2475 if contract_names.is_empty() {
2476 return Ok(None);
2477 }
2478
2479 let client = reqwest::Client::builder()
2480 .timeout(std::time::Duration::from_secs(8))
2481 .build()?;
2482 let node = stacks_node.trim_end_matches('/');
2483
2484 ui_log_wait_start();
2485
2486 for attempt in 1..=120 {
2487 let mut pending = Vec::new();
2488 for contract_name in contract_names {
2489 let url = format!("{node}/v2/contracts/source/{deployer}/{contract_name}?proof=0");
2490 let deployed = client
2491 .get(&url)
2492 .send()
2493 .await
2494 .map(|response| response.status().is_success())
2495 .unwrap_or(false);
2496 if !deployed {
2497 pending.push(contract_name.clone());
2498 }
2499 }
2500
2501 if pending.is_empty() {
2502 let tip = fetch_local_core_info_optional()
2503 .await
2504 .map(|info| info.stacks_tip_height);
2505 return Ok(tip);
2506 }
2507
2508 let _ = attempt;
2509 if attempt == 1 || attempt % 5 == 0 {
2510 ui_log_wait_tick(attempt, &pending);
2511 }
2512 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
2513 }
2514
2515 Err(anyhow!(
2516 "Timed out waiting for on-chain confirmation after broadcast.\n\
2517 Contracts still pending: {}.\n\
2518 Transactions were submitted — verify txids on the explorer.\n\
2519 Re-run without --wait-confirm for the default fast broadcast-only flow.",
2520 contract_names.join(", ")
2521 ))
2522}
2523
2524fn ui_log_wait_start() {
2525 if !stacksdapp_shell::is_quiet() {
2526 println!("Broadcast complete — waiting for chain confirmation (--wait-confirm)...");
2527 }
2528}
2529
2530fn ui_log_devnet_wait_start() {
2531 if !stacksdapp_shell::is_quiet() {
2532 println!(
2533 "Broadcast complete — waiting for local devnet confirmation (typically 15–90s)..."
2534 );
2535 }
2536}
2537
2538fn ui_log_devnet_wait_tick(attempt: u32, pending: &[String]) {
2539 if !stacksdapp_shell::is_quiet() {
2540 eprintln!(
2541 " confirming on local core ({attempt}s) — waiting for: {}",
2542 pending.join(", ")
2543 );
2544 }
2545}
2546
2547fn ui_log_wait_tick(attempt: u32, pending: &[String]) {
2548 if !stacksdapp_shell::is_quiet() {
2549 let secs = attempt as u64 * 2;
2550 eprintln!(
2551 " still waiting ({secs}s) — pending: {}",
2552 pending.join(", ")
2553 );
2554 }
2555}
2556
2557async fn probe_stacks_api_health() -> Result<bool> {
2558 let client = reqwest::Client::builder()
2559 .timeout(std::time::Duration::from_secs(2))
2560 .build()?;
2561 Ok(client
2562 .get("http://localhost:3999/v2/info")
2563 .send()
2564 .await
2565 .map(|response| response.status().is_success())
2566 .unwrap_or(false))
2567}
2568
2569async fn fetch_local_core_info() -> Result<LocalCoreInfo> {
2570 fetch_local_core_info_optional()
2571 .await
2572 .ok_or_else(|| anyhow!("Local stacks-node at http://localhost:20443 is not responding."))
2573}
2574
2575fn parse_deployer_address_from_settings(toml_raw: &str) -> Option<String> {
2576 for line in toml_raw.lines() {
2577 let line = line.trim();
2578 if line.starts_with("# stx_address:") {
2579 return line.split(':').nth(1).map(|s| s.trim().to_string());
2580 }
2581 }
2582 None
2583}
2584fn parse_local_deps(source: &str, known_contracts: &HashSet<String>) -> Vec<String> {
2585 let mut deps = Vec::new();
2586
2587 for line in source.lines() {
2588 let without_comments = line.split(";;").next().unwrap_or("").trim();
2589 if without_comments.is_empty() {
2590 continue;
2591 }
2592
2593 let mut call_scan = without_comments;
2594 while let Some(pos) = call_scan.find("contract-call? .") {
2595 let after = &call_scan[pos + "contract-call? .".len()..];
2596 let name: String = after
2597 .chars()
2598 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
2599 .collect();
2600 if !name.is_empty() && known_contracts.contains(&name) {
2601 deps.push(name);
2602 }
2603 call_scan = after;
2604 }
2605
2606 let mut trait_scan = without_comments;
2607 while let Some(pos) = trait_scan.find("use-trait ") {
2608 let after = &trait_scan[pos + "use-trait ".len()..];
2609 if let Some(dot_pos) = after.find('.') {
2610 let contract_ref = &after[dot_pos + 1..];
2611 let name: String = contract_ref
2612 .chars()
2613 .take_while(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
2614 .collect();
2615 if !name.is_empty() && known_contracts.contains(&name) {
2616 deps.push(name);
2617 }
2618 }
2619 trait_scan = after;
2620 }
2621 }
2622
2623 deps.sort();
2624 deps.dedup();
2625 deps
2626}
2627
2628fn topological_sort(contracts: &HashMap<String, Vec<String>>) -> anyhow::Result<Vec<String>> {
2629 let mut in_degree: HashMap<&str, usize> = HashMap::new();
2630 let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
2631
2632 for (name, deps) in contracts {
2633 in_degree.insert(name.as_str(), deps.len());
2634 for dep in deps {
2635 dependents
2636 .entry(dep.as_str())
2637 .or_default()
2638 .push(name.as_str());
2639 }
2640 }
2641
2642 let mut queue: VecDeque<&str> = in_degree
2644 .iter()
2645 .filter(|(_, °)| deg == 0)
2646 .map(|(&name, _)| name)
2647 .collect();
2648
2649 let mut queue_vec: Vec<&str> = queue.drain(..).collect();
2651 queue_vec.sort();
2652 queue.extend(queue_vec);
2653
2654 let mut sorted = Vec::new();
2655
2656 while let Some(node) = queue.pop_front() {
2657 sorted.push(node.to_string());
2658
2659 let mut next = dependents.get(node).cloned().unwrap_or_default();
2661 next.sort();
2662
2663 for dependent in next {
2664 let deg = in_degree.entry(dependent).or_insert(0);
2665 *deg = deg.saturating_sub(1);
2666 if *deg == 0 {
2667 queue.push_back(dependent);
2668 }
2669 }
2670 }
2671
2672 if sorted.len() != contracts.len() {
2673 return Err(anyhow::anyhow!(
2674 "Circular contract dependency detected.\n\
2675 Check your contracts for circular contract-call? references.\n\
2676 Involved contracts: {}",
2677 contracts
2678 .keys()
2679 .filter(|k| !sorted.contains(k))
2680 .cloned()
2681 .collect::<Vec<_>>()
2682 .join(", ")
2683 ));
2684 }
2685
2686 Ok(sorted)
2687}
2688
2689fn capitalize(s: &str) -> String {
2690 let mut c = s.chars();
2691 match c.next() {
2692 None => String::new(),
2693 Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
2694 }
2695}
2696
2697#[cfg(test)]
2698mod tests {
2699 use super::{
2700 interpret_contract_lookup, map_contract_name_after_renames, parse_broadcast_line,
2701 parse_local_deps, reorder_clarinet_toml, replace_contract_reference, restore_deploy_writes,
2702 snapshot_deploy_writes, strip_version_suffix, topological_sort, PlannedRename,
2703 };
2704 use reqwest::StatusCode;
2705 use std::collections::{HashMap, HashSet};
2706 use std::fs;
2707 use std::sync::Mutex;
2708
2709 static CWD_TEST_LOCK: Mutex<()> = Mutex::new(());
2710
2711 #[test]
2712 fn test_strip_version_suffix() {
2713 assert_eq!(strip_version_suffix("counter"), "counter");
2714 assert_eq!(strip_version_suffix("counter-v2"), "counter");
2715 assert_eq!(strip_version_suffix("counter-v3"), "counter");
2716 assert_eq!(strip_version_suffix("counter-v10"), "counter");
2717 assert_eq!(strip_version_suffix("my-token-v2"), "my-token");
2718 assert_eq!(strip_version_suffix("counter-v"), "counter-v");
2720 assert_eq!(strip_version_suffix("counter-vault"), "counter-vault");
2721 }
2722
2723 #[test]
2724 fn test_topological_sort_respects_dependencies() {
2725 let mut graph = HashMap::new();
2726 graph.insert("a".to_string(), vec![]);
2727 graph.insert("b".to_string(), vec!["a".to_string()]);
2728 graph.insert("c".to_string(), vec!["b".to_string()]);
2729
2730 let order = topological_sort(&graph).expect("topological sort should succeed");
2731 let idx_a = order.iter().position(|name| name == "a").unwrap();
2732 let idx_b = order.iter().position(|name| name == "b").unwrap();
2733 let idx_c = order.iter().position(|name| name == "c").unwrap();
2734 assert!(idx_a < idx_b && idx_b < idx_c);
2735 }
2736
2737 #[test]
2738 fn test_topological_sort_cycle_detection() {
2739 let mut graph = HashMap::new();
2740 graph.insert("a".to_string(), vec!["b".to_string()]);
2741 graph.insert("b".to_string(), vec!["a".to_string()]);
2742
2743 let err = topological_sort(&graph).expect_err("cycle should fail");
2744 assert!(
2745 err.to_string()
2746 .contains("Circular contract dependency detected"),
2747 "unexpected error: {err}"
2748 );
2749 }
2750
2751 #[test]
2752 fn devnet_broadcast_script_reads_payload_from_stdin_not_argv() {
2753 assert!(
2754 !super::BROADCAST_SCRIPT.contains("process.argv"),
2755 "sender key must not be passed via argv"
2756 );
2757 assert!(
2758 super::BROADCAST_SCRIPT.contains("process.stdin"),
2759 "deploy payload must be read from stdin"
2760 );
2761 assert!(
2762 super::BROADCAST_SCRIPT.contains("broadcastTransaction"),
2763 "must use @stacks/transactions broadcastTransaction (v7+)"
2764 );
2765 assert!(
2766 !super::BROADCAST_SCRIPT.contains("broadcastRawTransaction"),
2767 "broadcastRawTransaction was removed from @stacks/transactions v7"
2768 );
2769 }
2770
2771 #[test]
2772 fn min_burn_height_gates_clarity5_and_epoch_34() {
2773 assert_eq!(
2774 super::min_burn_height_for_publish(Some("3.4"), Some(5)),
2775 150
2776 );
2777 assert_eq!(super::min_burn_height_for_publish(None, Some(5)), 150);
2778 assert_eq!(
2779 super::min_burn_height_for_publish(Some("3.0"), Some(3)),
2780 142
2781 );
2782 assert_eq!(super::min_burn_height_for_publish(None, Some(2)), 0);
2783 }
2784
2785 #[test]
2786 fn min_burn_height_gates_clarity6_and_epoch_40() {
2787 let c6_height = super::epoch_40_burn_height();
2788 assert_eq!(
2789 super::min_burn_height_for_publish(Some("4.0"), Some(6)),
2790 c6_height
2791 );
2792 assert_eq!(super::min_burn_height_for_publish(None, Some(6)), c6_height);
2793 assert_eq!(super::min_burn_height_for_publish(None, Some(5)), 150);
2795 }
2796
2797 #[test]
2798 fn collect_txids_from_clarinet_output_finds_broadcast_lines() {
2799 let output = r#"Publish ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.counter
2800Broadcasted ContractPublish(StandardPrincipalData(ST1PQ), ContractName("counter"), "abc123def4567890abc123def4567890abc123def4567890abc123def4567890")
2801"#;
2802 let map = super::collect_txids_from_clarinet_output(output);
2803 assert_eq!(
2804 map.get("counter").map(String::as_str),
2805 Some("abc123def4567890abc123def4567890abc123def4567890abc123def4567890")
2806 );
2807 }
2808
2809 #[test]
2810 fn flatten_publishes_keeps_batch_epoch() {
2811 let plan = super::DeploymentPlanFile {
2812 plan: super::DeploymentPlan {
2813 batches: vec![super::DeploymentBatch {
2814 epoch: Some("3.4".into()),
2815 transactions: vec![super::DeploymentTransaction {
2816 transaction_type: "contract-publish".into(),
2817 contract_name: Some("counter".into()),
2818 expected_sender: Some("ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM".into()),
2819 cost: Some(4850),
2820 path: Some("contracts/counter.clar".into()),
2821 clarity_version: Some(5),
2822 }],
2823 }],
2824 },
2825 };
2826 let pubs = super::flatten_contract_publishes(&plan);
2827 assert_eq!(pubs.len(), 1);
2828 assert_eq!(pubs[0].epoch.as_deref(), Some("3.4"));
2829 assert_eq!(pubs[0].tx.contract_name.as_deref(), Some("counter"));
2830 }
2831
2832 #[test]
2833 fn test_parse_local_deps_detects_contract_calls_and_traits() {
2834 let known = HashSet::from([
2835 "token".to_string(),
2836 "trait-source".to_string(),
2837 "counter".to_string(),
2838 ]);
2839 let deps = parse_local_deps(
2840 r#"
2841 (contract-call? .token transfer u1 tx-sender tx-sender none)
2842 (use-trait ft-trait .trait-source.sip-010-trait)
2843 ;; (contract-call? .ignored nope)
2844 (begin (contract-call? .counter get-count))
2845 "#,
2846 &known,
2847 );
2848 assert_eq!(
2849 deps,
2850 vec![
2851 "counter".to_string(),
2852 "token".to_string(),
2853 "trait-source".to_string()
2854 ]
2855 );
2856 }
2857
2858 #[test]
2859 fn test_replace_contract_reference_preserves_prefixes() {
2860 let source = "(contract-call? .counter-helper ping)\n(contract-call? .counter get)\n(use-trait ft .counter.sip)";
2861 let updated = replace_contract_reference(source, "counter", "counter-v2");
2862 assert!(updated.contains(".counter-helper"));
2863 assert!(updated.contains(".counter-v2 get"));
2864 assert!(updated.contains(".counter-v2.sip"));
2865 }
2866
2867 #[test]
2868 fn test_map_contract_name_after_renames() {
2869 let renames = vec![PlannedRename {
2870 from: "counter".into(),
2871 to: "counter-v2".into(),
2872 }];
2873 assert_eq!(
2874 map_contract_name_after_renames("counter", &renames),
2875 "counter-v2"
2876 );
2877 assert_eq!(map_contract_name_after_renames("other", &renames), "other");
2878 }
2879
2880 #[tokio::test]
2881 async fn test_reorder_clarinet_toml_preserves_suffix_sections() {
2882 let tmp = tempfile::tempdir().unwrap();
2883 let contracts_dir = tmp.path().join("contracts");
2884 fs::create_dir_all(&contracts_dir).unwrap();
2885 fs::write(
2886 contracts_dir.join("Clarinet.toml"),
2887 r#"[project]
2888name = "demo"
2889
2890[contracts.b]
2891path = "contracts/b.clar"
2892
2893[contracts.a]
2894path = "contracts/a.clar"
2895
2896[repl.analysis]
2897passes = ["check_checker"]
2898"#,
2899 )
2900 .unwrap();
2901
2902 reorder_clarinet_toml(&contracts_dir, &["a".into(), "b".into()])
2903 .await
2904 .unwrap();
2905
2906 let updated = fs::read_to_string(contracts_dir.join("Clarinet.toml")).unwrap();
2907 let idx_a = updated.find("[contracts.a]").unwrap();
2908 let idx_b = updated.find("[contracts.b]").unwrap();
2909 let idx_suffix = updated.find("[repl.analysis]").unwrap();
2910 assert!(idx_a < idx_b);
2911 assert!(idx_b < idx_suffix);
2912 assert!(updated.contains("passes = [\"check_checker\"]"));
2913 }
2914
2915 #[test]
2916 fn test_interpret_contract_lookup_fails_closed() {
2917 assert!(interpret_contract_lookup(StatusCode::OK, "http://example").unwrap());
2918 assert!(!interpret_contract_lookup(StatusCode::NOT_FOUND, "http://example").unwrap());
2919 assert!(
2920 interpret_contract_lookup(StatusCode::INTERNAL_SERVER_ERROR, "http://example").is_err()
2921 );
2922 }
2923
2924 #[test]
2925 fn test_parse_broadcast_line_extracts_txid() {
2926 let txid = "abc123def4567890abc123def4567890abc123def4567890abc123def4567890";
2927 let line = format!(
2928 r#"Broadcasted ContractPublish(StandardPrincipalData(ST1PQ), ContractName("counter"), "{txid}")"#
2929 );
2930 let (name, parsed_txid) = parse_broadcast_line(&line).expect("should parse");
2931 assert_eq!(name, "counter");
2932 assert_eq!(parsed_txid, txid);
2933 }
2934
2935 #[test]
2936 fn test_parse_broadcast_line_rejects_missing_txid() {
2937 assert!(parse_broadcast_line(r#"Broadcasted ContractName("counter")"#).is_none());
2938 }
2939
2940 #[tokio::test]
2941 async fn test_reorder_clarinet_toml_keeps_contracts_missing_from_order() {
2942 let tmp = tempfile::tempdir().unwrap();
2943 let contracts_dir = tmp.path().join("contracts");
2944 fs::create_dir_all(&contracts_dir).unwrap();
2945 fs::write(
2946 contracts_dir.join("Clarinet.toml"),
2947 r#"[project]
2948name = "demo"
2949
2950[contracts.a]
2951path = "contracts/a.clar"
2952
2953[contracts.b]
2954path = "contracts/b.clar"
2955
2956[contracts.c]
2957path = "contracts/c.clar"
2958"#,
2959 )
2960 .unwrap();
2961
2962 reorder_clarinet_toml(&contracts_dir, &["a".into()])
2963 .await
2964 .unwrap();
2965
2966 let updated = fs::read_to_string(contracts_dir.join("Clarinet.toml")).unwrap();
2967 assert!(updated.contains("[contracts.a]"));
2968 assert!(updated.contains("[contracts.b]"));
2969 assert!(updated.contains("[contracts.c]"));
2970 }
2971
2972 #[tokio::test]
2973 async fn deploy_write_snapshot_roundtrip_restores_files() {
2974 let tmp = tempfile::tempdir().unwrap();
2975 let contracts_dir = tmp.path().join("contracts");
2976 let deployments_dir = contracts_dir.join("deployments");
2977 fs::create_dir_all(&deployments_dir).unwrap();
2978 fs::write(contracts_dir.join("Clarinet.toml"), "original = true\n").unwrap();
2979 fs::write(
2980 deployments_dir.join("default.devnet-plan.yaml"),
2981 "cost: 100\n",
2982 )
2983 .unwrap();
2984
2985 let snapshot = snapshot_deploy_writes(&contracts_dir).await.unwrap();
2986 fs::write(contracts_dir.join("Clarinet.toml"), "mutated = true\n").unwrap();
2987 fs::write(deployments_dir.join("new-plan.yaml"), "cost: 1\n").unwrap();
2988
2989 restore_deploy_writes(&contracts_dir, &snapshot)
2990 .await
2991 .unwrap();
2992
2993 let clarinet = fs::read_to_string(contracts_dir.join("Clarinet.toml")).unwrap();
2994 assert!(clarinet.contains("original = true"));
2995 assert!(!deployments_dir.join("new-plan.yaml").exists());
2996 let plan = fs::read_to_string(deployments_dir.join("default.devnet-plan.yaml")).unwrap();
2997 assert!(plan.contains("cost: 100"));
2998 }
2999
3000 #[test]
3001 fn partial_devnet_error_message_documents_recorded_state() {
3002 let err = format!(
3003 "Partial devnet deployment: {}/{} contracts broadcast and recorded in deployments.json.",
3004 1, 2
3005 );
3006 assert!(err.contains("recorded in deployments.json"));
3007 }
3008
3009 #[test]
3010 fn validate_settings_rejects_public_devnet_mnemonic_on_testnet() {
3011 let _lock = CWD_TEST_LOCK.lock().unwrap();
3012 let tmp = tempfile::tempdir().unwrap();
3013 let settings_dir = tmp.path().join("contracts/settings");
3014 fs::create_dir_all(&settings_dir).unwrap();
3015 fs::write(
3016 settings_dir.join("Testnet.toml"),
3017 format!(
3018 "[accounts.deployer]\nmnemonic = \"{}\"\n",
3019 stacksdapp_shell::PUBLIC_DEVNET_MNEMONICS[0]
3020 ),
3021 )
3022 .unwrap();
3023 let prev = std::env::current_dir().unwrap();
3024 std::env::set_current_dir(tmp.path()).unwrap();
3025 let err = super::validate_settings_mnemonic("testnet").unwrap_err();
3026 std::env::set_current_dir(prev).unwrap();
3027 assert!(err.to_string().contains("public devnet mnemonic"));
3028 }
3029
3030 #[test]
3031 fn validate_settings_rejects_invalid_word_count() {
3032 let _lock = CWD_TEST_LOCK.lock().unwrap();
3033 let tmp = tempfile::tempdir().unwrap();
3034 let settings_dir = tmp.path().join("contracts/settings");
3035 fs::create_dir_all(&settings_dir).unwrap();
3036 fs::write(
3037 settings_dir.join("Testnet.toml"),
3038 "[accounts.deployer]\nmnemonic = \"one two three\"\n",
3039 )
3040 .unwrap();
3041 let prev = std::env::current_dir().unwrap();
3042 std::env::set_current_dir(tmp.path()).unwrap();
3043 let err = super::validate_settings_mnemonic("testnet").unwrap_err();
3044 std::env::set_current_dir(prev).unwrap();
3045 assert!(err.to_string().contains("mnemonic has 3 words"));
3046 }
3047
3048 #[tokio::test]
3049 async fn rename_snapshot_restores_clar_and_clarinet_after_simulated_rename() {
3050 let tmp = tempfile::tempdir().unwrap();
3051 let contracts_dir = tmp.path().join("contracts");
3052 let src_dir = contracts_dir.join("contracts");
3053 fs::create_dir_all(&src_dir).unwrap();
3054 fs::write(
3055 contracts_dir.join("Clarinet.toml"),
3056 "[contracts.counter]\npath = \"contracts/counter.clar\"\n",
3057 )
3058 .unwrap();
3059 fs::write(
3060 src_dir.join("counter.clar"),
3061 "(define-read-only (get) (ok u0))",
3062 )
3063 .unwrap();
3064
3065 let snapshot = super::snapshot_contract_state(&contracts_dir)
3066 .await
3067 .unwrap();
3068 fs::rename(
3069 src_dir.join("counter.clar"),
3070 src_dir.join("counter-v2.clar"),
3071 )
3072 .unwrap();
3073 fs::write(
3074 contracts_dir.join("Clarinet.toml"),
3075 "[contracts.counter-v2]\npath = \"contracts/counter-v2.clar\"\n",
3076 )
3077 .unwrap();
3078
3079 super::restore_rename_snapshot(&contracts_dir, &snapshot)
3080 .await
3081 .unwrap();
3082
3083 assert!(src_dir.join("counter.clar").exists());
3084 assert!(!src_dir.join("counter-v2.clar").exists());
3085 let clarinet = fs::read_to_string(contracts_dir.join("Clarinet.toml")).unwrap();
3086 assert!(clarinet.contains("[contracts.counter]"));
3087 }
3088}