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