1use 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#[derive(Parser, Debug)]
36pub struct LeoAbi {
37 #[clap(value_name = "FILE")]
39 file: PathBuf,
40
41 #[clap(long, short, default_value = "testnet")]
43 network: NetworkName,
44
45 #[clap(long, short, value_name = "PATH")]
50 output: Option<PathBuf>,
51
52 #[clap(long, value_name = "DIR")]
57 imports_dir: Option<PathBuf>,
58
59 #[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 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 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
132fn 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
153fn 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
179fn 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
184fn 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
196fn 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
217fn 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
233fn 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
250fn 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 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 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}