Skip to main content

stacksdapp_deployer/
lib.rs

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