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