Skip to main content

leo_lang/cli/commands/
abi.rs

1// Copyright (C) 2019-2026 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 leo_ast::{AleoProgram, DiGraph, DiGraphError, NetworkName};
20use leo_errors::LeoError;
21
22use snarkvm::{
23    prelude::{CanaryV0, MainnetV0, Network, Process as SvmProcess, TestnetV0},
24    synthesizer::program::Program as SvmProgram,
25};
26
27use indexmap::IndexMap;
28use std::{
29    collections::HashMap,
30    path::{Path, PathBuf},
31    str::FromStr,
32};
33
34/// Generate ABI from an Aleo bytecode file.
35#[derive(Parser, Debug)]
36pub struct LeoAbi {
37    /// Path to the .aleo file.
38    #[clap(value_name = "FILE")]
39    file: PathBuf,
40
41    /// Network for parsing (mainnet, testnet, canary).
42    #[clap(long, short, default_value = "testnet")]
43    network: NetworkName,
44
45    /// Without `--satisfies`, the output directory: writes `<PATH>/<program>.abi.json` for the input and for each
46    /// declared dependency (created if missing; existing files overwritten); when omitted, every ABI is printed to
47    /// stdout, separated by `=== <name> ===` headers. With `--satisfies`, the file path to write the JSON
48    /// compatibility report to instead of printing a human-readable one.
49    #[clap(long, short, value_name = "PATH")]
50    output: Option<PathBuf>,
51
52    /// Directory containing the program's `.aleo` imports. Two layouts are supported:
53    /// per-unit (`<DIR>/<name>/<name>.aleo`) and legacy flat (`<DIR>/<name>.aleo`).
54    /// Defaults to the parent's parent when the input is at `<root>/<unit>/<unit>.aleo`,
55    /// otherwise a sibling `imports/` directory next to the input.
56    #[clap(long, value_name = "DIR")]
57    imports_dir: Option<PathBuf>,
58
59    /// Check whether the input program satisfies an interface standard, instead of printing its
60    /// ABI. The standard is a JSON file containing an ABI. The input satisfies the standard when
61    /// the standard's public interface is a subset of the input's. Exits non-zero when it does
62    /// not. With `--output`, the report is written there as JSON.
63    #[clap(long, value_name = "FILE")]
64    satisfies: Option<PathBuf>,
65}
66
67impl Command for LeoAbi {
68    type Input = ();
69    type Output = ();
70
71    fn log_span(&self) -> Span {
72        tracing::span!(tracing::Level::INFO, "Leo")
73    }
74
75    fn prelude(&self, _: Context) -> Result<Self::Input> {
76        Ok(())
77    }
78
79    fn apply(self, _context: Context, _: Self::Input) -> Result<Self::Output> {
80        if !self.file.exists() {
81            return Err(crate::errors::cli_invalid_input(format!("File not found: {}", self.file.display())).into());
82        }
83
84        match self.file.extension().and_then(|s| s.to_str()) {
85            Some("aleo") => {}
86            _ => {
87                return Err(crate::errors::cli_invalid_input(format!(
88                    "Expected a .aleo file, got: {}",
89                    self.file.display()
90                ))
91                .into());
92            }
93        }
94
95        let content = std::fs::read_to_string(&self.file).map_err(crate::errors::cli_io_error)?;
96        let file_name = self.file.file_name().and_then(|s| s.to_str()).unwrap_or("unknown");
97
98        let imports_dir = self.imports_dir.clone().or_else(|| leo_package::default_aleo_imports_directory(&self.file));
99
100        // `Process::add_program` is contextual, so dependencies must be loaded in topological order before the main
101        // program.
102        let (main_aleo, dep_aleos) = match &imports_dir {
103            Some(dir) => disassemble_with_imports(file_name, &content, self.network, dir)?,
104            None => (
105                leo_disassembler::disassemble_from_str_for_network(file_name, &content, self.network)
106                    .map_err(|e| crate::errors::failed_to_parse_aleo_file(file_name, e))?,
107                Vec::new(),
108            ),
109        };
110
111        let main_abi = leo_abi::aleo::generate(&main_aleo);
112
113        // Satisfies mode: check the input's interface against a JSON ABI standard rather than printing its ABI.
114        if let Some(standard) = &self.satisfies {
115            let standard_abi = load_standard_abi(standard)?;
116            let problems = leo_abi::compatibility::check_compatibility(&main_abi, &standard_abi);
117            return report_compatibility(&problems, &main_abi.program, &standard_abi.program, self.output.as_deref());
118        }
119
120        let dep_abis: IndexMap<String, _> =
121            dep_aleos.into_iter().map(|(name, aleo)| (name, leo_abi::aleo::generate(&aleo))).collect();
122
123        match self.output {
124            Some(dir) => write_abis_to_directory(&dir, &main_abi, &dep_abis)?,
125            None => print_abis_to_stdout(&main_abi, &dep_abis)?,
126        }
127
128        Ok(())
129    }
130}
131
132/// Loads the ABI of an interface standard for the satisfies check from a JSON file (any `.json`
133/// file), parsed directly as a serialized [`leo_abi::Program`].
134fn load_standard_abi(path: &Path) -> Result<leo_abi::Program> {
135    if !path.exists() {
136        return Err(crate::errors::cli_invalid_input(format!("File not found: {}", path.display())).into());
137    }
138    match path.extension().and_then(|s| s.to_str()) {
139        Some("json") => {
140            let text = std::fs::read_to_string(path).map_err(crate::errors::cli_io_error)?;
141            serde_json::from_str(&text).map_err(|e| {
142                crate::errors::cli_invalid_input(format!("could not parse ABI JSON `{}`: {e}", path.display())).into()
143            })
144        }
145        _ => Err(crate::errors::cli_invalid_input(format!(
146            "Expected a JSON file containing an ABI for `--satisfies`, got: {}",
147            path.display()
148        ))
149        .into()),
150    }
151}
152
153/// Reports the satisfies `problems` (empty means satisfied) and returns a `not_compatible` error
154/// when there are any, so the command exits non-zero. With `output`, writes a JSON report to that
155/// file; otherwise prints a human-readable report to stdout.
156fn report_compatibility(problems: &[String], program: &str, standard: &str, output: Option<&Path>) -> Result<()> {
157    if let Some(path) = output {
158        let value = serde_json::json!({ "satisfied": problems.is_empty(), "problems": problems });
159        let text =
160            serde_json::to_string_pretty(&value).map_err(|e| crate::errors::failed_to_serialize_abi(e.to_string()))?;
161        std::fs::write(path, text).map_err(crate::errors::failed_to_write_abi)?;
162        tracing::info!("Compatibility report written to '{}'.", path.display());
163    } else if problems.is_empty() {
164        eprintln!("`{program}` satisfies `{standard}`");
165    } else {
166        eprintln!("`{program}` does not satisfy `{standard}`:");
167        for problem in problems {
168            eprintln!("  - {problem}");
169        }
170    }
171
172    if problems.is_empty() {
173        Ok(())
174    } else {
175        Err(crate::errors::not_compatible(program, standard, problems.len()).into())
176    }
177}
178
179/// Pretty-prints `abi` as JSON.
180fn abi_to_json(abi: &leo_abi::Program) -> Result<String> {
181    serde_json::to_string_pretty(abi).map_err(|e| crate::errors::failed_to_serialize_abi(e.to_string()).into())
182}
183
184/// Prints the main ABI followed by each dependency under a `=== <name> ===` header. The main entry is a bare JSON
185/// document so callers that only need the no-imports case keep working unchanged.
186fn print_abis_to_stdout(main: &leo_abi::Program, deps: &IndexMap<String, leo_abi::Program>) -> Result<()> {
187    println!("{}", abi_to_json(main)?);
188    for (name, abi) in deps {
189        println!();
190        println!("=== {name} ===");
191        println!("{}", abi_to_json(abi)?);
192    }
193    Ok(())
194}
195
196/// Writes the main ABI and each dependency ABI to `<dir>/<name>.abi.json`. `dir` is created if missing; existing
197/// files are overwritten.
198fn write_abis_to_directory(
199    dir: &Path,
200    main: &leo_abi::Program,
201    deps: &IndexMap<String, leo_abi::Program>,
202) -> Result<()> {
203    std::fs::create_dir_all(dir).map_err(crate::errors::failed_to_write_abi)?;
204    let write = |name: &str, abi: &leo_abi::Program| -> Result<()> {
205        let path = dir.join(format!("{name}.abi.json"));
206        std::fs::write(&path, abi_to_json(abi)?).map_err(crate::errors::failed_to_write_abi)?;
207        tracing::info!("ABI written to '{}'.", path.display());
208        Ok(())
209    };
210    write(&main.program, main)?;
211    for (name, abi) in deps {
212        write(name, abi)?;
213    }
214    Ok(())
215}
216
217/// Disassembles `bytecode` and its declared transitive imports, resolving each dependency from `imports_dir`.
218/// Returns the main program plus an ordered list of `(name, AleoProgram)` pairs where dependencies precede their
219/// dependents (post-order). Names already loaded as network builtins (e.g. `credits.aleo`) are skipped silently.
220fn disassemble_with_imports(
221    name: &str,
222    bytecode: &str,
223    network: NetworkName,
224    imports_dir: &Path,
225) -> Result<(AleoProgram, Vec<(String, AleoProgram)>), LeoError> {
226    match network {
227        NetworkName::MainnetV0 => disassemble_with_imports_typed::<MainnetV0>(name, bytecode, imports_dir),
228        NetworkName::TestnetV0 => disassemble_with_imports_typed::<TestnetV0>(name, bytecode, imports_dir),
229        NetworkName::CanaryV0 => disassemble_with_imports_typed::<CanaryV0>(name, bytecode, imports_dir),
230    }
231}
232
233/// Typed implementation of [`disassemble_with_imports`] specialised to a concrete `Network`. Parses the main
234/// program once, walks its transitive imports from `imports_dir`, hands each parsed program to
235/// `leo_disassembler::validate_and_disassemble` in topological order, and finally does the same for the main
236/// program once all dependencies are loaded.
237fn disassemble_with_imports_typed<N: Network>(
238    name: &str,
239    bytecode: &str,
240    imports_dir: &Path,
241) -> Result<(AleoProgram, Vec<(String, AleoProgram)>), LeoError> {
242    let mut process = SvmProcess::<N>::load().map_err(crate::errors::failed_to_load_process)?;
243    let main = SvmProgram::<N>::from_str(bytecode)
244        .map_err(|_| crate::errors::failed_to_parse_aleo_file(name, "invalid Aleo bytecode"))?;
245    let deps = load_and_disassemble_imports(&main, imports_dir, &mut process)?;
246    let main_aleo = leo_disassembler::validate_and_disassemble(name, main, &mut process)?;
247    Ok((main_aleo, deps))
248}
249
250/// Walks `program`'s transitive imports from `imports_dir`, hands each parsed program to
251/// `leo_disassembler::validate_and_disassemble` in topological order, and returns the disassembled programs in
252/// the same order. Names already loaded in `process` are skipped, so network builtins don't need to be on disk.
253fn load_and_disassemble_imports<N: Network>(
254    program: &SvmProgram<N>,
255    imports_dir: &Path,
256    process: &mut SvmProcess<N>,
257) -> Result<Vec<(String, AleoProgram)>, LeoError> {
258    let mut parsed: HashMap<String, SvmProgram<N>> = HashMap::new();
259    let mut graph: DiGraph<String> = DiGraph::default();
260    let mut worklist: Vec<String> = program
261        .imports()
262        .iter()
263        .filter(|(id, _)| !process.contains_program(id))
264        .map(|(id, _)| id.to_string())
265        .collect();
266
267    while let Some(name) = worklist.pop() {
268        if parsed.contains_key(&name) {
269            continue;
270        }
271        let path = leo_package::aleo_import_path(imports_dir, &name);
272        let text =
273            std::fs::read_to_string(&path).map_err(|e| crate::errors::failed_to_read_import(path.display(), e))?;
274        let imported = SvmProgram::<N>::from_str(&text)
275            .map_err(|_| crate::errors::failed_to_parse_aleo_file(name.clone(), "invalid Aleo bytecode"))?;
276        graph.add_node(name.clone());
277        for (nested_id, _) in imported.imports() {
278            if process.contains_program(nested_id) {
279                continue;
280            }
281            let nested_name = nested_id.to_string();
282            graph.add_edge(name.clone(), nested_name.clone());
283            worklist.push(nested_name);
284        }
285        parsed.insert(name, imported);
286    }
287
288    let ordered = graph.post_order().map_err(|DiGraphError::CycleDetected(cycle)| {
289        let path = cycle.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(" -> ");
290        crate::errors::circular_import(path)
291    })?;
292    let mut disassembled: Vec<(String, AleoProgram)> = Vec::with_capacity(ordered.len());
293    for name in ordered {
294        // `post_order` returns only names inserted into `graph`, and every such name was inserted into `parsed` on
295        // the same loop iteration, so `remove` cannot fail.
296        let svm = parsed.remove(&name).expect("post_order yielded a name absent from `parsed`");
297        let aleo = leo_disassembler::validate_and_disassemble(&name, svm, process)?;
298        disassembled.push((name, aleo));
299    }
300    Ok(disassembled)
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    use leo_span::create_session_if_not_set_then;
308
309    /// `leo abi` on a `.aleo` file that declares imports must load each dependency from an imports directory before
310    /// disassembling.
311    #[test]
312    fn disassemble_with_imports_loads_dependencies() {
313        create_session_if_not_set_then(|_| {
314            let dep_src = "\
315program dep.aleo;
316
317function id:
318    input r0 as u32.private;
319    output r0 as u32.private;
320";
321            let main_src = "\
322import dep.aleo;
323
324program importer.aleo;
325
326function call_id:
327    input r0 as u32.private;
328    call dep.aleo/id r0 into r1;
329    output r1 as u32.private;
330";
331
332            let dir = tempfile::tempdir().unwrap();
333            std::fs::write(dir.path().join("dep.aleo"), dep_src).unwrap();
334
335            let (main, deps) = disassemble_with_imports("importer.aleo", main_src, NetworkName::TestnetV0, dir.path())
336                .expect("expected disassembly with imports to succeed");
337            assert_eq!(main.stub_id.to_string(), "importer.aleo");
338            assert_eq!(main.imports.iter().map(|i| i.to_string()).collect::<Vec<_>>(), vec!["dep.aleo".to_string()]);
339            assert_eq!(deps.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), vec!["dep.aleo"]);
340            assert_eq!(deps[0].1.stub_id.to_string(), "dep.aleo");
341        });
342    }
343
344    /// Transitive imports must load in dependency order: a leaf appears before any program that imports it.
345    #[test]
346    fn disassemble_with_imports_orders_transitive_dependencies() {
347        create_session_if_not_set_then(|_| {
348            let c_src = "\
349program c.aleo;
350
351function id_c:
352    input r0 as u32.private;
353    output r0 as u32.private;
354";
355            let b_src = "\
356import c.aleo;
357
358program b.aleo;
359
360function id_b:
361    input r0 as u32.private;
362    call c.aleo/id_c r0 into r1;
363    output r1 as u32.private;
364";
365            let a_src = "\
366import b.aleo;
367
368program a.aleo;
369
370function id_a:
371    input r0 as u32.private;
372    call b.aleo/id_b r0 into r1;
373    output r1 as u32.private;
374";
375
376            let dir = tempfile::tempdir().unwrap();
377            std::fs::write(dir.path().join("b.aleo"), b_src).unwrap();
378            std::fs::write(dir.path().join("c.aleo"), c_src).unwrap();
379
380            let (main, deps) = disassemble_with_imports("a.aleo", a_src, NetworkName::TestnetV0, dir.path())
381                .expect("expected transitive disassembly to succeed");
382            assert_eq!(main.stub_id.to_string(), "a.aleo");
383            assert_eq!(deps.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), vec!["c.aleo", "b.aleo"]);
384        });
385    }
386
387    /// Network builtins like `credits.aleo` are already in the `Process`, so they must be skipped silently — the
388    /// command must not require them to be present in the imports directory.
389    #[test]
390    fn disassemble_with_imports_handles_network_builtin() {
391        create_session_if_not_set_then(|_| {
392            let main_src = "\
393import credits.aleo;
394
395program network_user.aleo;
396
397function noop:
398    input r0 as u32.private;
399    output r0 as u32.private;
400";
401
402            let dir = tempfile::tempdir().unwrap();
403            let (main, deps) =
404                disassemble_with_imports("network_user.aleo", main_src, NetworkName::TestnetV0, dir.path())
405                    .expect("expected disassembly to succeed without credits.aleo on disk");
406            assert_eq!(main.stub_id.to_string(), "network_user.aleo");
407            assert!(deps.is_empty(), "network builtins must not appear in the dependency list");
408        });
409    }
410
411    /// A cycle in the declared imports must surface a descriptive error (with the offending names) rather than a
412    /// generic message or a panic.
413    #[test]
414    fn disassemble_with_imports_reports_circular_dependency() {
415        create_session_if_not_set_then(|_| {
416            let a_src = "\
417import b.aleo;
418
419program a.aleo;
420
421function id_a:
422    input r0 as u32.private;
423    call b.aleo/id_b r0 into r1;
424    output r1 as u32.private;
425";
426            let b_src = "\
427import a.aleo;
428
429program b.aleo;
430
431function id_b:
432    input r0 as u32.private;
433    call a.aleo/id_a r0 into r1;
434    output r1 as u32.private;
435";
436
437            let dir = tempfile::tempdir().unwrap();
438            std::fs::write(dir.path().join("a.aleo"), a_src).unwrap();
439            std::fs::write(dir.path().join("b.aleo"), b_src).unwrap();
440
441            let err = disassemble_with_imports("a.aleo", a_src, NetworkName::TestnetV0, dir.path())
442                .expect_err("expected disassembly to fail on a circular import");
443            let msg = err.to_string();
444            assert!(msg.contains("circular import"), "unexpected error: {msg}");
445            assert!(msg.contains("a.aleo") && msg.contains("b.aleo"), "cycle path not surfaced: {msg}");
446        });
447    }
448
449    /// A program that lists an import not present in the imports directory must surface a clear error rather than
450    /// panicking.
451    #[test]
452    fn disassemble_with_imports_reports_missing_dependency() {
453        create_session_if_not_set_then(|_| {
454            let main_src = "\
455import dep.aleo;
456
457program importer.aleo;
458
459function call_id:
460    input r0 as u32.private;
461    call dep.aleo/id r0 into r1;
462    output r1 as u32.private;
463";
464            let dir = tempfile::tempdir().unwrap();
465            let err = disassemble_with_imports("importer.aleo", main_src, NetworkName::TestnetV0, dir.path())
466                .expect_err("expected disassembly to fail when an import is missing");
467            assert!(err.to_string().contains("dep.aleo"), "unexpected error: {err}");
468        });
469    }
470
471    /// Main bytecode that fails grammatical parsing must surface a clear error naming the main program.
472    #[test]
473    fn disassemble_with_imports_rejects_malformed_main() {
474        create_session_if_not_set_then(|_| {
475            let dir = tempfile::tempdir().unwrap();
476            let err = disassemble_with_imports(
477                "importer.aleo",
478                "this is not valid Aleo bytecode",
479                NetworkName::TestnetV0,
480                dir.path(),
481            )
482            .expect_err("expected disassembly to fail on malformed main bytecode");
483            assert!(err.to_string().contains("importer.aleo"), "unexpected error: {err}");
484        });
485    }
486
487    /// A dependency file whose contents are not valid Aleo bytecode must surface a clear error naming the dep.
488    #[test]
489    fn disassemble_with_imports_rejects_malformed_dependency() {
490        create_session_if_not_set_then(|_| {
491            let main_src = "\
492import dep.aleo;
493
494program importer.aleo;
495
496function call_id:
497    input r0 as u32.private;
498    call dep.aleo/id r0 into r1;
499    output r1 as u32.private;
500";
501            let dir = tempfile::tempdir().unwrap();
502            std::fs::write(dir.path().join("dep.aleo"), "this is not valid Aleo bytecode").unwrap();
503            let err = disassemble_with_imports("importer.aleo", main_src, NetworkName::TestnetV0, dir.path())
504                .expect_err("expected disassembly to fail on malformed dependency bytecode");
505            assert!(err.to_string().contains("dep.aleo"), "unexpected error: {err}");
506        });
507    }
508
509    /// A diamond-shaped import graph (`a → {b, c}`, `b → d`, `c → d`) must yield `d` exactly once, before both
510    /// `b` and `c`. Exercises the worklist's already-parsed short-circuit.
511    #[test]
512    fn disassemble_with_imports_dedups_diamond_imports() {
513        create_session_if_not_set_then(|_| {
514            let d_src = "\
515program d.aleo;
516
517function id_d:
518    input r0 as u32.private;
519    output r0 as u32.private;
520";
521            let b_src = "\
522import d.aleo;
523
524program b.aleo;
525
526function id_b:
527    input r0 as u32.private;
528    call d.aleo/id_d r0 into r1;
529    output r1 as u32.private;
530";
531            let c_src = "\
532import d.aleo;
533
534program c.aleo;
535
536function id_c:
537    input r0 as u32.private;
538    call d.aleo/id_d r0 into r1;
539    output r1 as u32.private;
540";
541            let a_src = "\
542import b.aleo;
543import c.aleo;
544
545program a.aleo;
546
547function combine:
548    input r0 as u32.private;
549    call b.aleo/id_b r0 into r1;
550    call c.aleo/id_c r1 into r2;
551    output r2 as u32.private;
552";
553
554            let dir = tempfile::tempdir().unwrap();
555            std::fs::write(dir.path().join("d.aleo"), d_src).unwrap();
556            std::fs::write(dir.path().join("b.aleo"), b_src).unwrap();
557            std::fs::write(dir.path().join("c.aleo"), c_src).unwrap();
558
559            let (main, deps) = disassemble_with_imports("a.aleo", a_src, NetworkName::TestnetV0, dir.path())
560                .expect("expected diamond-imports disassembly to succeed");
561            assert_eq!(main.stub_id.to_string(), "a.aleo");
562
563            let names: Vec<&str> = deps.iter().map(|(n, _)| n.as_str()).collect();
564            assert_eq!(names.iter().filter(|&&n| n == "d.aleo").count(), 1, "d.aleo not deduped: {names:?}");
565            assert_eq!(names.len(), 3, "expected exactly 3 deps, got: {names:?}");
566            let pos = |needle: &str| {
567                names.iter().position(|&n| n == needle).unwrap_or_else(|| panic!("{needle} missing from {names:?}"))
568            };
569            assert!(pos("d.aleo") < pos("b.aleo"), "topo order violated: {names:?}");
570            assert!(pos("d.aleo") < pos("c.aleo"), "topo order violated: {names:?}");
571        });
572    }
573
574    /// A network builtin reached transitively (`a → b`, `b → credits`) must be filtered out of the dependency
575    /// walk too, not just at the top level. Exercises the nested-import builtin filter.
576    #[test]
577    fn disassemble_with_imports_skips_nested_network_builtin() {
578        create_session_if_not_set_then(|_| {
579            let b_src = "\
580import credits.aleo;
581
582program b.aleo;
583
584function id_b:
585    input r0 as u32.private;
586    output r0 as u32.private;
587";
588            let a_src = "\
589import b.aleo;
590
591program a.aleo;
592
593function id_a:
594    input r0 as u32.private;
595    call b.aleo/id_b r0 into r1;
596    output r1 as u32.private;
597";
598
599            let dir = tempfile::tempdir().unwrap();
600            std::fs::write(dir.path().join("b.aleo"), b_src).unwrap();
601
602            let (main, deps) = disassemble_with_imports("a.aleo", a_src, NetworkName::TestnetV0, dir.path())
603                .expect("expected disassembly with transitive credits.aleo import to succeed");
604            assert_eq!(main.stub_id.to_string(), "a.aleo");
605            let names: Vec<&str> = deps.iter().map(|(n, _)| n.as_str()).collect();
606            assert_eq!(names, vec!["b.aleo"], "credits.aleo must be skipped silently, got: {names:?}");
607        });
608    }
609
610    /// A minimal but well-formed ABI JSON document (no items), enough to exercise the parser.
611    const SAMPLE_ABI_JSON: &str = r#"{
612  "program": "token.aleo",
613  "structs": [],
614  "records": [],
615  "mappings": [],
616  "storage_variables": [],
617  "functions": [],
618  "views": []
619}"#;
620
621    #[test]
622    fn load_standard_abi_reads_json() {
623        let dir = tempfile::tempdir().unwrap();
624        let path = dir.path().join("token.abi.json");
625        std::fs::write(&path, SAMPLE_ABI_JSON).unwrap();
626
627        let loaded = load_standard_abi(&path).expect("expected JSON ABI to load");
628        assert_eq!(loaded.program, "token.aleo");
629    }
630
631    #[test]
632    fn load_standard_abi_rejects_aleo() {
633        // The standard must be a JSON file; a `.aleo` bytecode file is rejected.
634        let dir = tempfile::tempdir().unwrap();
635        let path = dir.path().join("token.aleo");
636        std::fs::write(&path, "irrelevant").unwrap();
637
638        let err = load_standard_abi(&path).expect_err("expected a .aleo standard to be rejected");
639        assert!(err.to_string().contains("JSON file containing an ABI"), "unexpected error: {err}");
640    }
641
642    #[test]
643    fn load_standard_abi_rejects_invalid_json() {
644        // A `.json` file that is not valid JSON at all.
645        let dir = tempfile::tempdir().unwrap();
646        let path = dir.path().join("broken.json");
647        std::fs::write(&path, "{ not valid json").unwrap();
648
649        let err = load_standard_abi(&path).expect_err("expected invalid JSON to be rejected");
650        assert!(err.to_string().contains("could not parse ABI JSON"), "unexpected error: {err}");
651    }
652
653    #[test]
654    fn load_standard_abi_rejects_non_abi_json() {
655        // Valid JSON, but not an ABI: it lacks the required fields (e.g. `program`).
656        let dir = tempfile::tempdir().unwrap();
657        let path = dir.path().join("notabi.json");
658        std::fs::write(&path, r#"{ "hello": "world" }"#).unwrap();
659
660        let err = load_standard_abi(&path).expect_err("expected non-ABI JSON to be rejected");
661        assert!(err.to_string().contains("could not parse ABI JSON"), "unexpected error: {err}");
662    }
663}