Skip to main content

tempo_xtask/
check_abi.rs

1//! ABI compatibility checker between Rust `sol!` bindings and tempo-std Solidity interfaces.
2
3use 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
44// `tempo-std` is the published Solidity interface surface for Tempo precompiles.
45static 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
59/// List of `(kind, signature)` pairs, e.g. `("function", "foo(uint256) [view]")`.
60type 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    /// Only check a specific interface (by Solidity name, e.g. "ITIP20").
72    #[arg(long)]
73    only: Option<String>,
74
75    /// Path to a tempo-std repo root (uses the workspace submodule by default).
76    #[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    /// Returns `(only_in_self, only_in_other)` diffs grouped by kind.
241    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(&param.ty, &param.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(&param.ty, &param.components)
301}
302
303/// Flattens a single bare-tuples so that if they share the same abi encoding they are equivalent.
304fn 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}