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!(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
60/// List of `(kind, signature)` pairs, e.g. `("function", "foo(uint256) [view]")`.
61type 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    /// Only check a specific interface (by Solidity name, e.g. "ITIP20").
73    #[arg(long)]
74    only: Option<String>,
75
76    /// Path to a tempo-std repo root (uses the workspace submodule by default).
77    #[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    /// Returns `(only_in_self, only_in_other)` diffs grouped by kind.
242    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(&param.ty, &param.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(&param.ty, &param.components)
302}
303
304/// Flattens a single bare-tuples so that if they share the same abi encoding they are equivalent.
305fn 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}