1use std::{
4 collections::{BTreeSet, HashMap},
5 fs,
6 path::{Path, PathBuf},
7};
8
9use alloy_json_abi::{
10 ContractObject, Error, Event, EventParam, Function, JsonAbi, Param, StateMutability,
11};
12use eyre::{Context, bail, eyre};
13use itertools::Itertools;
14
15#[derive(Clone, Copy)]
16struct InterfaceSpec {
17 solidity_name: &'static str,
18 abi: fn() -> JsonAbi,
19 inherits: &'static [&'static str],
20}
21
22impl InterfaceSpec {
23 const fn inherits(mut self, inherits: &'static [&'static str]) -> Self {
24 self.inherits = inherits;
25 self
26 }
27
28 const fn with_name(mut self, name: &'static str) -> Self {
29 self.solidity_name = name;
30 self
31 }
32}
33
34macro_rules! interface_spec {
35 ($ty:ident) => {
36 InterfaceSpec {
37 solidity_name: stringify!($ty),
38 abi: tempo_contracts::precompiles::$ty::abi::contract,
39 inherits: &[],
40 }
41 };
42}
43
44static INTERFACE_SPECS: &[InterfaceSpec] = &[
46 interface_spec!(INonce),
47 interface_spec!(IAccountKeychain),
48 interface_spec!(ITIP20),
49 interface_spec!(ITIP20Factory),
50 interface_spec!(IRolesAuth).with_name("ITIP20RolesAuth"),
51 interface_spec!(ITIP403Registry),
52 interface_spec!(ITIPFeeAMM).with_name("IFeeAMM"),
53 interface_spec!(IFeeManager).inherits(&["IFeeAMM"]),
54 interface_spec!(IStablecoinDEX),
55 interface_spec!(IValidatorConfig),
56 interface_spec!(IValidatorConfigV2),
57];
58
59type DiffEntries = Vec<(String, String)>;
61
62#[derive(Default)]
63struct AbiSurface {
64 functions: BTreeSet<String>,
65 errors: BTreeSet<String>,
66 events: BTreeSet<String>,
67}
68
69#[derive(Debug, clap::Args)]
70pub(crate) struct CheckAbi {
71 #[arg(long)]
73 only: Option<String>,
74
75 #[arg(long)]
77 tempo_std: Option<PathBuf>,
78}
79
80impl CheckAbi {
81 pub(crate) fn run(self) -> eyre::Result<()> {
82 let tempo_std_root = match self.tempo_std {
83 Some(p) => p,
84 None => find_workspace_root()?.join("tips/verify/lib/tempo-std"),
85 };
86 let artifacts_dir = tempo_std_root.join("out");
87
88 if !artifacts_dir.exists() {
89 bail!(
90 "tempo-std artifacts not found at {}. Run `forge build` in {} first.",
91 artifacts_dir.display(),
92 tempo_std_root.display(),
93 );
94 }
95
96 let specs_by_name: HashMap<&str, &InterfaceSpec> = INTERFACE_SPECS
97 .iter()
98 .map(|spec| (spec.solidity_name, spec))
99 .collect();
100
101 let (mut passed, mut checked, mut missing, mut prev_ok) = (0, 0, Vec::new(), false);
102 for spec in INTERFACE_SPECS {
103 if let Some(ref only) = self.only
104 && spec.solidity_name != only.as_str()
105 {
106 continue;
107 }
108
109 let artifact_path = artifacts_dir
110 .join(format!("{}.sol", spec.solidity_name))
111 .join(format!("{}.json", spec.solidity_name));
112
113 if !artifact_path.exists() {
114 if checked > 0 && prev_ok {
115 eprintln!();
116 }
117 eprintln!(" ⊘ {} — no Foundry artifact", spec.solidity_name);
118 missing.push(spec.solidity_name);
119 prev_ok = false;
120 continue;
121 }
122
123 let (rust_only, sol_only) = check_interface(spec, &artifact_path, &specs_by_name)?;
124 checked += 1;
125
126 let current_ok = rust_only.is_empty() && sol_only.is_empty();
127 if current_ok {
128 passed += 1;
129 }
130
131 if checked > 1 && !(prev_ok && current_ok) {
132 eprintln!();
133 }
134
135 let (status, suffix) = if current_ok {
136 (" ✓", "")
137 } else {
138 (" ✗", ":")
139 };
140 eprintln!("{status} {}{suffix}", spec.solidity_name);
141
142 print_grouped_diffs(&rust_only, "Solidity");
143 print_grouped_diffs(&sol_only, "Rust");
144
145 prev_ok = current_ok;
146 }
147
148 if checked == 0 && missing.is_empty() {
149 if let Some(ref only) = self.only {
150 bail!("No ABI interface found matching --only {only}");
151 }
152 bail!("No ABI interfaces found");
153 }
154
155 eprintln!();
156 if !missing.is_empty() || passed < checked {
157 eprintln!("Summary: {passed}/{checked} interfaces are ABI-compatible.");
158 if !missing.is_empty() {
159 eprintln!(
160 "Missing Foundry artifacts: {}",
161 missing.iter().copied().join(", ")
162 );
163 }
164 bail!("ABI compatibility check found differences or missing artifacts (see above)");
165 }
166
167 eprintln!("Summary: {checked}/{checked} interfaces are ABI-compatible.");
168 Ok(())
169 }
170}
171
172fn check_interface(
173 spec: &InterfaceSpec,
174 artifact_path: &Path,
175 all_specs: &HashMap<&str, &InterfaceSpec>,
176) -> eyre::Result<(DiffEntries, DiffEntries)> {
177 let rust_surface = surface_for_spec(spec, all_specs, &mut Vec::new())?;
178
179 let solidity_abi = load_foundry_abi(artifact_path)
180 .with_context(|| format!("parsing {}", artifact_path.display()))?;
181 let solidity_surface = surface_from_abi(&solidity_abi);
182
183 Ok(rust_surface.diff(&solidity_surface))
184}
185
186fn surface_for_spec(
187 spec: &InterfaceSpec,
188 all_specs: &HashMap<&str, &InterfaceSpec>,
189 visiting: &mut Vec<&'static str>,
190) -> eyre::Result<AbiSurface> {
191 if visiting.contains(&spec.solidity_name) {
192 let cycle = visiting
193 .iter()
194 .copied()
195 .chain(std::iter::once(spec.solidity_name))
196 .join(" -> ");
197 bail!("cyclic ABI inheritance detected: {cycle}");
198 }
199
200 visiting.push(spec.solidity_name);
201
202 let mut surface = surface_from_abi(&(spec.abi)());
203 for parent_name in spec.inherits {
204 let parent = all_specs.get(parent_name).ok_or_else(|| {
205 eyre!(
206 "{} inherits unknown interface {parent_name}",
207 spec.solidity_name
208 )
209 })?;
210 surface.extend(surface_for_spec(parent, all_specs, visiting)?);
211 }
212
213 visiting.pop();
214 Ok(surface)
215}
216
217fn load_foundry_abi(path: &Path) -> eyre::Result<JsonAbi> {
218 let content = fs::read_to_string(path)?;
219 let artifact: ContractObject = serde_json::from_str(&content)?;
220 artifact
221 .abi
222 .ok_or_else(|| eyre!("missing 'abi' field in {}", path.display()))
223}
224
225fn surface_from_abi(abi: &JsonAbi) -> AbiSurface {
226 AbiSurface {
227 functions: abi.functions().map(function_signature).collect(),
228 errors: abi.errors().map(error_signature).collect(),
229 events: abi.events().map(event_signature).collect(),
230 }
231}
232
233impl AbiSurface {
234 fn extend(&mut self, other: Self) {
235 self.functions.extend(other.functions);
236 self.errors.extend(other.errors);
237 self.events.extend(other.events);
238 }
239
240 fn diff(&self, other: &Self) -> (DiffEntries, DiffEntries) {
242 let mut only_self = Vec::new();
243 let mut only_other = Vec::new();
244 for (kind, a, b) in [
245 ("function", &self.functions, &other.functions),
246 ("error", &self.errors, &other.errors),
247 ("event", &self.events, &other.events),
248 ] {
249 for sig in a.difference(b) {
250 only_self.push((kind.to_string(), sig.clone()));
251 }
252 for sig in b.difference(a) {
253 only_other.push((kind.to_string(), sig.clone()));
254 }
255 }
256 (only_self, only_other)
257 }
258}
259
260fn function_signature(function: &Function) -> String {
261 let inputs = function.inputs.iter().map(param_type).join(",");
262 let mut signature = format!("{}({inputs})", function.name);
263
264 if !function.outputs.is_empty() {
265 let outputs = canonical_output_types(&function.outputs);
266 signature.push_str(&format!(" returns ({outputs})"));
267 }
268
269 signature.push_str(&format!(
270 " [{}]",
271 state_mutability(function.state_mutability)
272 ));
273 signature
274}
275
276fn error_signature(error: &Error) -> String {
277 let inputs = error.inputs.iter().map(param_type).join(",");
278 format!("{}({inputs})", error.name)
279}
280
281fn event_signature(event: &Event) -> String {
282 let inputs = event.inputs.iter().map(event_param_signature).join(",");
283 let mut signature = format!("{}({inputs})", event.name);
284 if event.anonymous {
285 signature.push_str(" [anonymous]");
286 }
287 signature
288}
289
290fn event_param_signature(param: &EventParam) -> String {
291 let ty = canonical_param_type(¶m.ty, ¶m.components);
292 if param.indexed {
293 format!("indexed {ty}")
294 } else {
295 ty
296 }
297}
298
299fn param_type(param: &Param) -> String {
300 canonical_param_type(¶m.ty, ¶m.components)
301}
302
303fn canonical_output_types(outputs: &[Param]) -> String {
305 match outputs {
306 [output] if output.ty == "tuple" => output.components.iter().map(param_type).join(","),
307 _ => outputs.iter().map(param_type).join(","),
308 }
309}
310
311fn canonical_param_type(ty: &str, components: &[Param]) -> String {
312 if components.is_empty() {
313 return ty.to_string();
314 }
315
316 let inner = components.iter().map(param_type).join(",");
317 let tuple = format!("({inner})");
318
319 if ty == "tuple" {
320 tuple
321 } else if let Some(suffix) = ty.strip_prefix("tuple") {
322 format!("{tuple}{suffix}")
323 } else {
324 ty.to_string()
325 }
326}
327
328fn state_mutability(state_mutability: StateMutability) -> &'static str {
329 match state_mutability {
330 StateMutability::Pure => "pure",
331 StateMutability::View => "view",
332 StateMutability::NonPayable => "nonpayable",
333 StateMutability::Payable => "payable",
334 }
335}
336
337fn print_grouped_diffs(diffs: &[(String, String)], missing_in: &str) {
338 let mut current_kind = "";
339 for (kind, sig) in diffs {
340 if kind != current_kind {
341 let plural = if diffs.iter().filter(|(k, _)| k == kind).count() > 1 {
342 "s"
343 } else {
344 ""
345 };
346 eprintln!(" {kind}{plural} missing in {missing_in}:");
347 current_kind = kind;
348 }
349 eprintln!(" {sig}");
350 }
351}
352
353fn find_workspace_root() -> eyre::Result<PathBuf> {
354 let output = std::process::Command::new("cargo")
355 .args(["metadata", "--no-deps", "--format-version=1"])
356 .output()
357 .context("failed to run cargo metadata")?;
358
359 if !output.status.success() {
360 bail!(
361 "cargo metadata failed: {}",
362 String::from_utf8_lossy(&output.stderr).trim()
363 );
364 }
365
366 let metadata: serde_json::Value =
367 serde_json::from_slice(&output.stdout).context("failed to parse cargo metadata")?;
368
369 let root = metadata
370 .get("workspace_root")
371 .and_then(|value| value.as_str())
372 .ok_or_else(|| eyre!("missing workspace_root in cargo metadata"))?;
373
374 Ok(PathBuf::from(root))
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380
381 #[test]
382 fn surface_from_abi_preserves_tuple_signatures() {
383 let abi = JsonAbi::parse([
384 "function addPerson(tuple(string,uint16) person)",
385 "event PersonAdded(uint indexed id, tuple(string,uint16) person)",
386 "error BadPerson(tuple(string,uint16) person)",
387 ])
388 .unwrap();
389
390 let surface = surface_from_abi(&abi);
391 assert!(
392 surface
393 .functions
394 .contains("addPerson((string,uint16)) [nonpayable]")
395 );
396 assert!(
397 surface
398 .events
399 .contains("PersonAdded(indexed uint256,(string,uint16))")
400 );
401 assert!(surface.errors.contains("BadPerson((string,uint16))"));
402 }
403
404 #[test]
405 fn surface_from_abi_tracks_returns_mutability_and_anonymous_events() {
406 let abi = JsonAbi::parse([
407 "function foo(uint256 value) external view returns (bool ok)",
408 "event Bar(address indexed from, uint256 amount) anonymous",
409 ])
410 .unwrap();
411
412 let surface = surface_from_abi(&abi);
413 assert!(
414 surface
415 .functions
416 .contains("foo(uint256) returns (bool) [view]")
417 );
418 assert!(
419 surface
420 .events
421 .contains("Bar(indexed address,uint256) [anonymous]")
422 );
423 }
424
425 #[test]
426 fn function_signature_treats_single_tuple_outputs_like_flat_outputs() {
427 let tuple_output =
428 JsonAbi::parse(["function pool() external view returns ((uint128,uint128) reserves)"])
429 .unwrap();
430 let flat_output = JsonAbi::parse([
431 "function pool() external view returns (uint128 reserveUserToken, uint128 reserveValidatorToken)",
432 ])
433 .unwrap();
434
435 let tuple_signature = tuple_output
436 .functions()
437 .next()
438 .map(function_signature)
439 .unwrap();
440 let flat_signature = flat_output
441 .functions()
442 .next()
443 .map(function_signature)
444 .unwrap();
445
446 assert_eq!(tuple_signature, flat_signature);
447 assert_eq!(tuple_signature, "pool() returns (uint128,uint128) [view]");
448 }
449
450 #[test]
451 fn diff_reports_symmetric_differences() {
452 let rust = AbiSurface {
453 functions: BTreeSet::from(["foo(uint256) [nonpayable]".to_string()]),
454 ..Default::default()
455 };
456 let solidity = AbiSurface {
457 functions: BTreeSet::from(["bar(uint256) [nonpayable]".to_string()]),
458 ..Default::default()
459 };
460
461 let (rust_only, sol_only) = rust.diff(&solidity);
462
463 assert_eq!(
464 rust_only,
465 [(
466 "function".to_string(),
467 "foo(uint256) [nonpayable]".to_string()
468 )]
469 );
470 assert_eq!(
471 sol_only,
472 [(
473 "function".to_string(),
474 "bar(uint256) [nonpayable]".to_string()
475 )]
476 );
477 }
478}