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