Skip to main content

stacksdapp_deployer/
lib.rs

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/// Result metadata for deploy (used by `--json` and scripting).
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct DeployOutcome {
65    /// `dry-run`, `broadcast`, or `confirmed`
66    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            // Core RPC — stacks-api is off by default (snapshot fast-boot).
78            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    /// Clarinet epoch gate for this batch (e.g. "3.4"). Publishes before this
116    /// burn height are accepted to mempool then never mined.
117    #[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
166// ── Entry point ───────────────────────────────────────────────────────────────
167
168pub async fn wait_for_devnet_node() -> Result<()> {
169    // Prefer stacks core (:20443). The indexer API (:3999) can look healthy while
170    // core is still booting or stalled — which breaks broadcast/deploy.
171    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                // Tip already advanced before we started watching — core is live.
185                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
245// ── Core deploy ───────────────────────────────────────────────────────────────
246
247struct DeployWriteSnapshot {
248    clarinet_toml: Option<Vec<u8>>,
249    deployment_files: HashMap<std::path::PathBuf, Vec<u8>>,
250}
251
252/// Full contract tree snapshot before auto-version renames (Clarinet.toml + all .clar sources).
253struct 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
752/// Quiet clarinet helper — captures stderr for errors, hides upgrade spam.
753async 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        // Filter clarinet upgrade nags from the error surface
769        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    // Prefer in-tree binary if present; fall back to PATH.
785    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
836/// Run `clarinet deployments generate` then `apply`, returning stdout.
837async 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    // Pre-Clarinet 3.23: stacks-node 3.4 rejects @stacks/transactions v7 C6 wire encoding.
939    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    // Clarinet apply coordinates devnet mining with the bitcoin controller; prefer it on 3.23+.
958    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    // Pre-answer cost/continue prompts (Clarinet may not echo them on stdout).
1007    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        // Auto-answer Clarinet prompts silently (consent already obtained).
1071        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            // Intent only — success is counted when Broadcasted/txid appears above.
1100            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    // Do not kill Clarinet after seeing a publish intent — let it finish posting.
1115    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    // Devnet burn/epoch gating is handled in run_apply_devnet before this path is reached.
1244
1245    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            // Resync in case a prior run advanced the chain nonce.
1263            next_nonce = fetch_core_nonce(&node, &expected_sender)
1264                .await?
1265                .max(next_nonce);
1266            // Still record a synthetic success marker so deployments.json is written.
1267            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        // Devnet: confirm on core before the next broadcast (15s blocks need this).
1383        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
1493/// Clarinet's default Devnet epoch activation burn heights (Stacks.toml).
1494fn 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        // Clarinet 3.23+ devnet snapshot activates epoch 4.0 at burn 163 (was 152 on 3.21).
1508        "4.0" => Some(epoch_40_burn_height()),
1509        _ => None,
1510    }
1511}
1512
1513/// Burn height for epoch 4.0 on Clarinet devnet (163 since Clarinet 3.23.0, 152 on older).
1514fn 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        // Clarity 5 activates with epoch 3.4.
1531        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
1608/// Node bridge for direct publishes (devnet + testnet/mainnet Clarity 5/6).
1609/// Payload (including senderKey) is read from stdin — never from argv — so keys do not appear in `ps`.
1610const 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    // Build dependency map: name → [local deps]
1726    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            // Dependency details are intentionally quiet — shown at summary level.
1740        }
1741
1742        dep_graph.insert(name.clone(), deps);
1743    }
1744
1745    let order = topological_sort(&dep_graph)?;
1746
1747    Ok(order)
1748}
1749
1750// ── Auto-versioning ───────────────────────────────────────────────────────────
1751fn 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    // Parse total cost from the YAML — look for "cost: <number>" lines and sum them
1760    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        // Find the next available name on the network
1801        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
1883/// Helper to parse the address Clarinet derived in the plan file
1884async 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    // Check unversioned first (e.g. "counter")
1911    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    // Find next free versioned name
1919    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
1955/// Strip trailing -vN suffix: "counter-v2" → "counter", "foo-v10" → "foo"
1956fn strip_version_suffix(name: &str) -> String {
1957    // Find last occurrence of -v followed by digits at end of string
1958    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
1967// ── Helpers ───────────────────────────────────────────────────────────────────
1968
1969fn 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    // Direct-deploy skip when the contract is already live on local core.
2145    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    // Ensure bindings are fresh after rename (quiet).
2271    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    // Quietly wait for local core to expose published contracts.
2403    // 15s block times need more than 30s; epoch-gated publishes may confirm a bit later.
2404    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    // Start with contracts that have no dependencies.
2643    let mut queue: VecDeque<&str> = in_degree
2644        .iter()
2645        .filter(|(_, &deg)| deg == 0)
2646        .map(|(&name, _)| name)
2647        .collect();
2648
2649    // Sort for deterministic output
2650    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        // Reduce in-degree for contracts that depend on this one.
2660        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        // should not strip non-version suffixes
2719        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        // C5 must not inherit C6's burn height when epoch is unspecified.
2794        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}