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