leo_lang/cli/commands/
deploy.rs

1// Copyright (C) 2019-2025 Provable Inc.
2// This file is part of the Leo library.
3
4// The Leo library is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8
9// The Leo library is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12// GNU General Public License for more details.
13
14// You should have received a copy of the GNU General Public License
15// along with the Leo library. If not, see <https://www.gnu.org/licenses/>.
16
17use super::*;
18
19use check_transaction::TransactionStatus;
20use leo_ast::NetworkName;
21use leo_package::{Package, ProgramData, fetch_program_from_network};
22
23use aleo_std::StorageMode;
24#[cfg(not(feature = "only_testnet"))]
25use snarkvm::prelude::{CanaryV0, MainnetV0};
26use snarkvm::{
27    ledger::store::helpers::memory::BlockMemory,
28    prelude::{
29        ConsensusVersion,
30        Deployment,
31        Program,
32        ProgramID,
33        TestnetV0,
34        VM,
35        deployment_cost,
36        query::Query as SnarkVMQuery,
37        store::{ConsensusStore, helpers::memory::ConsensusMemory},
38    },
39};
40
41use colored::*;
42use itertools::Itertools;
43use std::{collections::HashSet, fs, path::PathBuf};
44
45/// Deploys an Aleo program.
46#[derive(Parser, Debug)]
47pub struct LeoDeploy {
48    #[clap(flatten)]
49    pub(crate) fee_options: FeeOptions,
50    #[clap(flatten)]
51    pub(crate) action: TransactionAction,
52    #[clap(flatten)]
53    pub(crate) env_override: EnvOptions,
54    #[clap(flatten)]
55    pub(crate) extra: ExtraOptions,
56    #[clap(long, help = "Skips deployment of any program that contains one of the given substrings.", value_delimiter = ',', num_args = 1..)]
57    pub(crate) skip: Vec<String>,
58    #[clap(flatten)]
59    pub(crate) build_options: BuildOptions,
60}
61
62pub struct Task<N: Network> {
63    pub id: ProgramID<N>,
64    pub program: Program<N>,
65    pub edition: Option<u16>,
66    pub is_local: bool,
67    pub priority_fee: Option<u64>,
68    pub record: Option<Record<N, Plaintext<N>>>,
69}
70
71impl Command for LeoDeploy {
72    type Input = Package;
73    type Output = ();
74
75    fn log_span(&self) -> Span {
76        tracing::span!(tracing::Level::INFO, "Leo")
77    }
78
79    fn prelude(&self, context: Context) -> Result<Self::Input> {
80        LeoBuild {
81            env_override: self.env_override.clone(),
82            options: {
83                let mut options = self.build_options.clone();
84                options.no_cache = true;
85                options
86            },
87        }
88        .execute(context)
89    }
90
91    fn apply(self, context: Context, input: Self::Input) -> Result<Self::Output> {
92        // Get the network, accounting for overrides.
93        let network = get_network(&self.env_override.network)?;
94        // Handle each network with the appropriate parameterization.
95        match network {
96            NetworkName::TestnetV0 => handle_deploy::<TestnetV0>(&self, context, network, input),
97            NetworkName::MainnetV0 => {
98                #[cfg(feature = "only_testnet")]
99                panic!("Mainnet chosen with only_testnet feature");
100                #[cfg(not(feature = "only_testnet"))]
101                handle_deploy::<MainnetV0>(&self, context, network, input)
102            }
103            NetworkName::CanaryV0 => {
104                #[cfg(feature = "only_testnet")]
105                panic!("Canary chosen with only_testnet feature");
106                #[cfg(not(feature = "only_testnet"))]
107                handle_deploy::<CanaryV0>(&self, context, network, input)
108            }
109        }
110    }
111}
112
113// A helper function to handle deployment logic.
114fn handle_deploy<N: Network>(
115    command: &LeoDeploy,
116    context: Context,
117    network: NetworkName,
118    package: Package,
119) -> Result<<LeoDeploy as Command>::Output> {
120    // Get the private key and associated address, accounting for overrides.
121    let private_key = get_private_key(&command.env_override.private_key)?;
122    let address =
123        Address::try_from(&private_key).map_err(|e| CliError::custom(format!("Failed to parse address: {e}")))?;
124
125    // Get the endpoint, accounting for overrides.
126    let endpoint = get_endpoint(&command.env_override.endpoint)?;
127
128    // Get whether the network is a devnet, accounting for overrides.
129    let is_devnet = get_is_devnet(command.env_override.devnet);
130
131    // If the consensus heights are provided, use them; otherwise, use the default heights for the network.
132    let consensus_heights =
133        command.env_override.consensus_heights.clone().unwrap_or_else(|| get_consensus_heights(network, is_devnet));
134    // Validate the provided consensus heights.
135    validate_consensus_heights(&consensus_heights)
136        .map_err(|e| CliError::custom(format!("⚠️ Invalid consensus heights: {e}")))?;
137    // Print the consensus heights being used.
138    let consensus_heights_string = consensus_heights.iter().format(",").to_string();
139    println!(
140        "\nπŸ“’ Using the following consensus heights: {consensus_heights_string}\n  To override, pass in `--consensus-heights` or override the environment variable `CONSENSUS_VERSION_HEIGHTS`.\n"
141    );
142
143    // Set the consensus heights in the environment.
144    #[allow(unsafe_code)]
145    unsafe {
146        // SAFETY:
147        //  - `CONSENSUS_VERSION_HEIGHTS` is only set once and is only read in `snarkvm::prelude::load_consensus_heights`.
148        //  - There are no concurrent threads running at this point in the execution.
149        // WHY:
150        //  - This is needed because there is no way to set the desired consensus heights for a particular `VM` instance
151        //    without using the environment variable `CONSENSUS_VERSION_HEIGHTS`. Which is itself read once, and stored in a `OnceLock`.
152        std::env::set_var("CONSENSUS_VERSION_HEIGHTS", consensus_heights_string);
153    }
154
155    // Get all the programs but tests.
156    let programs = package.programs.iter().filter(|program| !program.is_test).cloned();
157
158    let programs_and_bytecode: Vec<(leo_package::Program, String)> = programs
159        .into_iter()
160        .map(|program| {
161            let bytecode = match &program.data {
162                ProgramData::Bytecode(s) => s.clone(),
163                ProgramData::SourcePath { .. } => {
164                    // We need to read the bytecode from the filesystem.
165                    let aleo_name = format!("{}.aleo", program.name);
166                    let aleo_path = if package.manifest.program == aleo_name {
167                        // The main program in the package, so its .aleo file
168                        // will be in the build directory.
169                        package.build_directory().join("main.aleo")
170                    } else {
171                        // Some other dependency, so look in `imports`.
172                        package.imports_directory().join(aleo_name)
173                    };
174                    fs::read_to_string(aleo_path.clone())
175                        .map_err(|e| CliError::custom(format!("Failed to read file {}: {e}", aleo_path.display())))?
176                }
177            };
178
179            Ok((program, bytecode))
180        })
181        .collect::<Result<_>>()?;
182
183    // Parse the fee options.
184    let fee_options = parse_fee_options(&private_key, &command.fee_options, programs_and_bytecode.len())?;
185
186    let tasks: Vec<Task<N>> = programs_and_bytecode
187        .into_iter()
188        .zip(fee_options)
189        .map(|((program, bytecode), (_base_fee, priority_fee, record))| {
190            let id_str = format!("{}.aleo", program.name);
191            let id =
192                id_str.parse().map_err(|e| CliError::custom(format!("Failed to parse program ID {id_str}: {e}")))?;
193            let bytecode = bytecode.parse().map_err(|e| CliError::custom(format!("Failed to parse program: {e}")))?;
194            Ok(Task {
195                id,
196                program: bytecode,
197                edition: program.edition,
198                is_local: program.is_local,
199                priority_fee,
200                record,
201            })
202        })
203        .collect::<Result<_>>()?;
204
205    // Split the tasks into local and remote dependencies.
206    let (local, remote) = tasks.into_iter().partition::<Vec<_>, _>(|task| task.is_local);
207
208    // Get the skipped programs.
209    let skipped: HashSet<ProgramID<N>> = local
210        .iter()
211        .filter_map(|task| {
212            let id_string = task.id.to_string();
213            command.skip.iter().any(|skip| id_string.contains(skip)).then_some(task.id)
214        })
215        .collect();
216
217    // Get the consensus version.
218    let consensus_version =
219        get_consensus_version(&command.extra.consensus_version, &endpoint, network, &consensus_heights, &context)?;
220
221    // Print a summary of the deployment plan.
222    print_deployment_plan(
223        &private_key,
224        &address,
225        &endpoint,
226        &network,
227        &local,
228        &skipped,
229        &remote,
230        &check_tasks_for_warnings(&endpoint, network, &local, consensus_version, command),
231        consensus_version,
232        command,
233    );
234
235    // Prompt the user to confirm the plan.
236    if !confirm("Do you want to proceed with deployment?", command.extra.yes)? {
237        println!("❌ Deployment aborted.");
238        return Ok(());
239    }
240
241    // Initialize an RNG.
242    let rng = &mut rand::thread_rng();
243
244    // Initialize a new VM.
245    let vm = VM::from(ConsensusStore::<N, ConsensusMemory<N>>::open(StorageMode::Production)?)?;
246
247    // Load the remote dependencies into the VM.
248    let programs_and_editions = remote
249        .into_iter()
250        .map(|task| {
251            // Note: We default to edition 1 since snarkVM execute may produce spurious errors if the program does not have a constructor but uses edition 0.
252            (task.program, task.edition.unwrap_or(1))
253        })
254        .collect::<Vec<_>>();
255    vm.process().write().add_programs_with_editions(&programs_and_editions)?;
256
257    // Specify the query
258    let query = SnarkVMQuery::<N, BlockMemory<N>>::from(
259        endpoint
260            .parse::<Uri>()
261            .map_err(|e| CliError::custom(format!("Failed to parse endpoint URI '{endpoint}': {e}")))?,
262    );
263
264    // For each of the programs, generate a deployment transaction.
265    let mut transactions = Vec::new();
266    for Task { id, program, priority_fee, record, .. } in local {
267        // If the program is a local dependency that is not skipped, generate a deployment transaction.
268        if !skipped.contains(&id) {
269            // If the program contains an upgrade config, confirm with the user that they want to proceed.
270            if let Some(constructor) = program.constructor() {
271                println!(
272                    r"
273πŸ”§ Your program '{}' has the following constructor.
274──────────────────────────────────────────────
275{constructor}
276──────────────────────────────────────────────
277Once it is deployed, it CANNOT be changed.
278",
279                    id.to_string().bold()
280                );
281                if !confirm("Would you like to proceed?", command.extra.yes)? {
282                    println!("❌ Deployment aborted.");
283                    return Ok(());
284                }
285            }
286            println!("πŸ“¦ Creating deployment transaction for '{}'...\n", id.to_string().bold());
287            // Generate the transaction.
288            let transaction =
289                vm.deploy(&private_key, &program, record, priority_fee.unwrap_or(0), Some(&query), rng)
290                    .map_err(|e| CliError::custom(format!("Failed to generate deployment transaction: {e}")))?;
291            // Get the deployment.
292            let deployment = transaction.deployment().expect("Expected a deployment in the transaction");
293            // Print the deployment stats.
294            print_deployment_stats(&vm, &id.to_string(), deployment, priority_fee, consensus_version)?;
295            // Save the transaction.
296            transactions.push((id, transaction));
297        }
298        // Add the program to the VM.
299        vm.process().write().add_program(&program)?;
300    }
301
302    for (program_id, transaction) in transactions.iter() {
303        // Validate the deployment limits.
304        let deployment = transaction.deployment().expect("Expected a deployment in the transaction");
305        validate_deployment_limits(deployment, program_id, &network)?;
306    }
307
308    // If the `print` option is set, print the deployment transaction to the console.
309    // The transaction is printed in JSON format.
310    if command.action.print {
311        for (program_name, transaction) in transactions.iter() {
312            // Pretty-print the transaction.
313            let transaction_json = serde_json::to_string_pretty(transaction)
314                .map_err(|e| CliError::custom(format!("Failed to serialize transaction: {e}")))?;
315            println!("πŸ–¨οΈ Printing deployment for {program_name}\n{transaction_json}")
316        }
317    }
318
319    // If the `save` option is set, save each deployment transaction to a file in the specified directory.
320    // The file format is `program_name.deployment.json`.
321    // The directory is created if it doesn't exist.
322    if let Some(path) = &command.action.save {
323        // Create the directory if it doesn't exist.
324        std::fs::create_dir_all(path).map_err(|e| CliError::custom(format!("Failed to create directory: {e}")))?;
325        for (program_name, transaction) in transactions.iter() {
326            // Save the transaction to a file.
327            let file_path = PathBuf::from(path).join(format!("{program_name}.deployment.json"));
328            println!("πŸ’Ύ Saving deployment for {program_name} at {}", file_path.display());
329            let transaction_json = serde_json::to_string_pretty(transaction)
330                .map_err(|e| CliError::custom(format!("Failed to serialize transaction: {e}")))?;
331            std::fs::write(file_path, transaction_json)
332                .map_err(|e| CliError::custom(format!("Failed to write transaction to file: {e}")))?;
333        }
334    }
335
336    // If the `broadcast` option is set, broadcast each deployment transaction to the network.
337    if command.action.broadcast {
338        for (i, (program_id, transaction)) in transactions.iter().enumerate() {
339            println!("\nπŸ“‘ Broadcasting deployment for {}...", program_id.to_string().bold());
340            // Get and confirm the fee with the user.
341            let fee = transaction.fee_transition().expect("Expected a fee in the transaction");
342            if !confirm_fee(&fee, &private_key, &address, &endpoint, network, &context, command.extra.yes)? {
343                println!("⏩ Deployment skipped.");
344                continue;
345            }
346            let fee_id = fee.id().to_string();
347            let id = transaction.id().to_string();
348            let height_before = check_transaction::current_height(&endpoint, network)?;
349            // Broadcast the transaction to the network.
350            let (message, status) = handle_broadcast(
351                &format!("{endpoint}/{network}/transaction/broadcast"),
352                transaction,
353                &program_id.to_string(),
354            )?;
355
356            let fail_and_prompt = |msg| {
357                println!("❌ Failed to deploy program {program_id}: {msg}.");
358                let count = transactions.len() - i - 1;
359                // Check if the user wants to continue with the next deployment.
360                if count > 0 {
361                    confirm("Do you want to continue with the next deployment?", command.extra.yes)
362                } else {
363                    Ok(false)
364                }
365            };
366
367            match status {
368                200..=299 => {
369                    let status = check_transaction::check_transaction_with_message(
370                        &id,
371                        Some(&fee_id),
372                        &endpoint,
373                        network,
374                        height_before + 1,
375                        command.extra.max_wait,
376                        command.extra.blocks_to_check,
377                    )?;
378                    if status == Some(TransactionStatus::Accepted) {
379                        println!("βœ… Deployment confirmed!");
380                    } else if fail_and_prompt("could not find the transaction on the network")? {
381                        continue;
382                    } else {
383                        return Ok(());
384                    }
385                }
386                _ => {
387                    if fail_and_prompt(&message)? {
388                        continue;
389                    } else {
390                        return Ok(());
391                    }
392                }
393            }
394        }
395    }
396
397    Ok(())
398}
399
400/// Check the tasks to warn the user about any potential issues.
401/// The following properties are checked:
402/// - If the transaction is to be broadcast:
403///     - The program does not exist on the network.
404///     - If the consensus version is less than V9, the program does not use V9 features.
405///     - If the consensus version is V9 or greater, the program contains a constructor.
406fn check_tasks_for_warnings<N: Network>(
407    endpoint: &str,
408    network: NetworkName,
409    tasks: &[Task<N>],
410    consensus_version: ConsensusVersion,
411    command: &LeoDeploy,
412) -> Vec<String> {
413    let mut warnings = Vec::new();
414    for Task { id, is_local, program, .. } in tasks {
415        if !is_local || !command.action.broadcast {
416            continue;
417        }
418        // Check if the program exists on the network.
419        if fetch_program_from_network(&id.to_string(), endpoint, network).is_ok() {
420            warnings
421                .push(format!("The program '{id}' already exists on the network. Please use `leo upgrade` instead.",));
422        }
423        // Check if the program has a valid naming scheme.
424        if consensus_version >= ConsensusVersion::V7 {
425            if let Err(e) = program.check_program_naming_structure() {
426                warnings.push(format!(
427                    "The program '{id}' has an invalid naming scheme: {e}. The deployment will likely fail."
428                ));
429            }
430        }
431
432        // Check if the program contains restricted keywords.
433        if let Err(e) = program.check_restricted_keywords_for_consensus_version(consensus_version) {
434            warnings.push(format!(
435                "The program '{id}' contains restricted keywords for consensus version {}: {e}. The deployment will likely fail.",
436                consensus_version as u8
437            ));
438        }
439        // Check if the program uses V9 features.
440        if consensus_version < ConsensusVersion::V9 && program.contains_v9_syntax() {
441            warnings.push(format!("The program '{id}' uses V9 features but the consensus version is less than V9. The deployment will likely fail"));
442        }
443        // Check if the program contains a constructor.
444        if consensus_version >= ConsensusVersion::V9 && !program.contains_constructor() {
445            warnings
446                .push(format!("The program '{id}' does not contain a constructor. The deployment will likely fail",));
447        }
448    }
449    // Check for a consensus version mismatch.
450    if let Err(e) = check_consensus_version_mismatch(consensus_version, endpoint, network) {
451        warnings.push(format!("{e}. In some cases, the deployment may fail"));
452    }
453    warnings
454}
455
456/// Check if the number of variables and constraints are within the limits.
457pub(crate) fn validate_deployment_limits<N: Network>(
458    deployment: &Deployment<N>,
459    program_id: &ProgramID<N>,
460    network: &NetworkName,
461) -> Result<()> {
462    // Check if the number of variables is within the limits.
463    let combined_variables = deployment.num_combined_variables()?;
464    if combined_variables > N::MAX_DEPLOYMENT_VARIABLES {
465        return Err(CliError::variable_limit_exceeded(
466            program_id,
467            combined_variables,
468            N::MAX_DEPLOYMENT_VARIABLES,
469            network,
470        )
471        .into());
472    }
473
474    // Check if the number of constraints is within the limits.
475    let constraints = deployment.num_combined_constraints()?;
476    if constraints > N::MAX_DEPLOYMENT_CONSTRAINTS {
477        return Err(CliError::constraint_limit_exceeded(
478            program_id,
479            constraints,
480            N::MAX_DEPLOYMENT_CONSTRAINTS,
481            network,
482        )
483        .into());
484    }
485
486    Ok(())
487}
488
489/// Pretty‑print the deployment plan without using a table.
490#[allow(clippy::too_many_arguments)]
491pub(crate) fn print_deployment_plan<N: Network>(
492    private_key: &PrivateKey<N>,
493    address: &Address<N>,
494    endpoint: &str,
495    network: &NetworkName,
496    local: &[Task<N>],
497    skipped: &HashSet<ProgramID<N>>,
498    remote: &[Task<N>],
499    warnings: &[String],
500    consensus_version: ConsensusVersion,
501    command: &LeoDeploy,
502) {
503    use colored::*;
504
505    println!("\n{}", "πŸ› οΈ  Deployment Plan Summary".bold());
506    println!("{}", "──────────────────────────────────────────────".dimmed());
507
508    // ── Configuration ────────────────────────────────────────────────────
509    println!("{}", "πŸ”§ Configuration:".bold());
510    println!("  {:20}{}", "Private Key:".cyan(), format!("{}...", &private_key.to_string()[..24]).yellow());
511    println!("  {:20}{}", "Address:".cyan(), format!("{}...", &address.to_string()[..24]).yellow());
512    println!("  {:20}{}", "Endpoint:".cyan(), endpoint.yellow());
513    println!("  {:20}{}", "Network:".cyan(), network.to_string().yellow());
514    println!("  {:20}{}", "Consensus Version:".cyan(), (consensus_version as u8).to_string().yellow());
515
516    // ── Deployment tasks (bullet list) ───────────────────────────────────
517    println!("\n{}", "πŸ“¦ Deployment Tasks:".bold());
518    if local.is_empty() {
519        println!("  (none)");
520    } else {
521        for Task { id, priority_fee, record, .. } in local.iter().filter(|task| !skipped.contains(&task.id)) {
522            let priority_fee_str = priority_fee.map_or("0".into(), |v| v.to_string());
523            let record_str = if record.is_some() { "yes" } else { "no (public fee)" };
524            println!(
525                "  β€’ {}  β”‚ priority fee: {}  β”‚ fee record: {}",
526                id.to_string().cyan(),
527                priority_fee_str,
528                record_str
529            );
530        }
531    }
532
533    // ── Skipped programs ─────────────────────────────────────────────────
534    if !skipped.is_empty() {
535        println!("\n{}", "🚫 Skipped Programs:".bold().red());
536        for symbol in skipped {
537            println!("  β€’ {}", symbol.to_string().dimmed());
538        }
539    }
540
541    // ── Remote dependencies ──────────────────────────────────────────────
542    if !remote.is_empty() {
543        println!("\n{}", "🌐 Remote Dependencies:".bold().red());
544        println!("{}", "(Leo will not generate transactions for these programs)".bold().red());
545        for Task { id, .. } in remote {
546            println!("  β€’ {}", id.to_string().dimmed());
547        }
548    }
549
550    // ── Actions ──────────────────────────────────────────────────────────
551    println!("\n{}", "βš™οΈ Actions:".bold());
552    if command.action.print {
553        println!("  β€’ Transaction(s) will be printed to the console.");
554    } else {
555        println!("  β€’ Transaction(s) will NOT be printed to the console.");
556    }
557    if let Some(path) = &command.action.save {
558        println!("  β€’ Transaction(s) will be saved to {}", path.bold());
559    } else {
560        println!("  β€’ Transaction(s) will NOT be saved to a file.");
561    }
562    if command.action.broadcast {
563        println!("  β€’ Transaction(s) will be broadcast to {}", endpoint.bold());
564    } else {
565        println!("  β€’ Transaction(s) will NOT be broadcast to the network.");
566    }
567
568    // ── Warnings ─────────────────────────────────────────────────────────
569    if !warnings.is_empty() {
570        println!("\n{}", "⚠️ Warnings:".bold().red());
571        for warning in warnings {
572            println!("  β€’ {}", warning.dimmed());
573        }
574    }
575
576    println!("{}", "──────────────────────────────────────────────\n".dimmed());
577}
578
579/// Pretty‑print deployment statistics without a table, using the same UI
580/// conventions as `print_deployment_plan`.
581pub(crate) fn print_deployment_stats<N: Network>(
582    vm: &VM<N, ConsensusMemory<N>>,
583    program_id: &str,
584    deployment: &Deployment<N>,
585    priority_fee: Option<u64>,
586    consensus_version: ConsensusVersion,
587) -> Result<()> {
588    use colored::*;
589    use num_format::{Locale, ToFormattedString};
590
591    // ── Collect statistics ────────────────────────────────────────────────
592    let variables = deployment.num_combined_variables()?;
593    let constraints = deployment.num_combined_constraints()?;
594    let (base_fee, (storage_cost, synthesis_cost, constructor_cost, namespace_cost)) =
595        deployment_cost(&vm.process().read(), deployment, consensus_version)?;
596
597    let base_fee_cr = base_fee as f64 / 1_000_000.0;
598    let prio_fee_cr = priority_fee.unwrap_or(0) as f64 / 1_000_000.0;
599    let total_fee_cr = base_fee_cr + prio_fee_cr;
600
601    // ── Header ────────────────────────────────────────────────────────────
602    println!("\n{} {}", "πŸ“Š Deployment Summary for".bold(), program_id.bold());
603    println!("{}", "──────────────────────────────────────────────".dimmed());
604
605    // ── High‑level metrics ────────────────────────────────────────────────
606    println!("  {:22}{}", "Total Variables:".cyan(), variables.to_formatted_string(&Locale::en).yellow());
607    println!("  {:22}{}", "Total Constraints:".cyan(), constraints.to_formatted_string(&Locale::en).yellow());
608    println!(
609        "  {:22}{}",
610        "Max Variables:".cyan(),
611        N::MAX_DEPLOYMENT_VARIABLES.to_formatted_string(&Locale::en).green()
612    );
613    println!(
614        "  {:22}{}",
615        "Max Constraints:".cyan(),
616        N::MAX_DEPLOYMENT_CONSTRAINTS.to_formatted_string(&Locale::en).green()
617    );
618
619    // ── Cost breakdown ────────────────────────────────────────────────────
620    println!("\n{}", "πŸ’° Cost Breakdown (credits)".bold());
621    println!(
622        "  {:22}{}{:.6}",
623        "Transaction Storage:".cyan(),
624        "".yellow(), // spacer for alignment
625        storage_cost as f64 / 1_000_000.0
626    );
627    println!("  {:22}{}{:.6}", "Program Synthesis:".cyan(), "".yellow(), synthesis_cost as f64 / 1_000_000.0);
628    println!("  {:22}{}{:.6}", "Namespace:".cyan(), "".yellow(), namespace_cost as f64 / 1_000_000.0);
629    println!("  {:22}{}{:.6}", "Constructor:".cyan(), "".yellow(), constructor_cost as f64 / 1_000_000.0);
630    println!("  {:22}{}{:.6}", "Priority Fee:".cyan(), "".yellow(), prio_fee_cr);
631    println!("  {:22}{}{:.6}", "Total Fee:".cyan(), "".yellow(), total_fee_cr);
632
633    // ── Footer rule ───────────────────────────────────────────────────────
634    println!("{}", "──────────────────────────────────────────────".dimmed());
635    Ok(())
636}