Skip to main content

taceo_nodes_common/web3/
erc165.rs

1//! ERC-165 interface detection utilities.
2//!
3//! Provides helpers for querying whether an on-chain contract implements a
4//! given interface according to [EIP-165](https://eips.ethereum.org/EIPS/eip-165).
5//!
6//! The implementation is inspired by `OpenZeppelin`'s
7//! [`ERC165Checker.sol`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/5e28952cbdc0eb7d19ee62580ab31b30c2376e48/contracts/utils/introspection/ERC165Checker.sol).
8//!
9//! # Usage
10//!
11//! For most use cases, call
12//! [`HttpRpcProvider::erc165_supports_interface_unchecked`] directly. It queries
13//! whether the target contract reports support for the given interface and
14//! returns `Ok(())` or `Err(`[`ERC165ConfirmError::Unsupported`]`)`. It does
15//! **not** enforce that the contract is ERC-165 compliant — if you only care
16//! that the interface is supported, this is the right method to use.
17//!
18//! Use [`HttpRpcProvider::erc165_supports_interface`] when you also need to
19//! enforce strict ERC-165 compliance — i.e., the contract must not claim to
20//! support the invalid interface `0xffffffff`. The queries are batched through
21//! Multicall3.
22//!
23//! Use [`HttpRpcProvider::ensure_erc165_conform`] to verify ERC-165 compliance
24//! independently of a specific interface query.
25//!
26//! * [`HttpRpcProvider::erc165_supports_interface_unchecked`] – queries
27//!   interface support without enforcing ERC-165 compliance. Preferred for most
28//!   callers.
29//! * [`HttpRpcProvider::erc165_supports_interface`] – checks interface support
30//!   **and** strict ERC-165 compliance in one batched RPC request.
31//! * [`HttpRpcProvider::ensure_erc165_conform`] – verifies ERC-165 compliance
32//!   only.
33//! * [`erc165_interface_selector`] – computes the ERC-165 interface identifier
34//!   by XOR-ing the given function selectors.
35
36use alloy::{
37    primitives::{Address, FixedBytes},
38    providers::{MulticallError, Provider},
39    sol,
40    transports::{TransportError, TransportErrorKind},
41};
42
43use crate::web3::{HttpRpcProvider, erc165::ERC165::ERC165Instance};
44
45sol!(
46    #[allow(clippy::exhaustive_structs, reason="comes from sol macro")]
47    #[allow(clippy::exhaustive_enums, reason="comes from sol macro")]
48    #[sol(rpc)]
49    interface ERC165 {
50        /// @notice Query if a contract implements an interface
51        /// @param interfaceID The interface identifier, as specified in ERC-165
52        /// @dev Interface identification is specified in ERC-165. This function
53        ///  uses less than 30,000 gas.
54        /// @return `true` if the contract implements `interfaceID` and
55        ///  `interfaceID` is not 0xffffffff, `false` otherwise
56        function supportsInterface(bytes4 interfaceID) external view returns (bool);
57    }
58);
59
60/// The four-byte selector of `supportsInterface(bytes4)` (`0x01ffc9a7`).
61///
62/// A contract that implements ERC-165 must return `true` when queried
63/// with this selector. Equivalent to `type(IERC165).interfaceId` in
64/// Solidity.
65pub const ERC_165_SUPPORTS_INTERFACE_SELECTOR: [u8; 4] = [0x01, 0xff, 0xc9, 0xa7];
66/// The sentinel interface identifier (`0xffffffff`).
67///
68/// Per the EIP-165 specification, no compliant contract may claim
69/// support for this value. Corresponds to `_INTERFACE_ID_INVALID` in
70/// `OpenZeppelin`'s `ERC165Checker`.
71pub const INVALID_INTERFACE_SELECTOR: [u8; 4] = [0xff, 0xff, 0xff, 0xff];
72
73/// Computes an ERC-165 interface identifier from an iterator of function selectors.
74///
75/// The interface identifier is defined as the XOR of all function selectors
76/// that belong to the interface (see [EIP-165](https://eips.ethereum.org/EIPS/eip-165)).
77///
78/// # Arguments
79///
80/// * `selectors` – iterator yielding the four-byte selectors of every
81///   function in the interface.
82#[must_use]
83pub fn erc165_interface_selector(selectors: impl IntoIterator<Item = [u8; 4]>) -> FixedBytes<4> {
84    FixedBytes::from(selectors.into_iter().fold([0u8; 4], |mut acc, selector| {
85        for (a, b) in acc.iter_mut().zip(selector) {
86            *a ^= b;
87        }
88        acc
89    }))
90}
91
92/// Maps an alloy `supportsInterface` call result into a unit result.
93///
94/// * `Ok(true)` – the contract confirmed support → `Ok(())`.
95/// * `Ok(false)` – the contract denied support → `Err(Unsupported)`.
96/// * `ZeroData` error – the address has no deployed code → `Err(NotAContract)`.
97/// * `TransportError` – RPC transport failure → propagated as-is.
98/// * Any other error – treated as unsupported → `Err(Unsupported)`.
99fn unwrap_erc165_call(
100    call: Result<bool, alloy::contract::Error>,
101) -> Result<(), ERC165ConfirmError> {
102    match call {
103        Ok(true) => Ok(()),
104        Err(alloy::contract::Error::ZeroData(_, _)) => Err(ERC165ConfirmError::NotAContract),
105        // There was an RPC transport error
106        Err(alloy::contract::Error::TransportError(TransportError::Transport(transport_error))) => {
107            Err(ERC165ConfirmError::TransportError(transport_error))
108        }
109        // every other error means it does not support the interface
110        Ok(false) | Err(_) => Err(ERC165ConfirmError::Unsupported),
111    }
112}
113
114/// Errors returned by the ERC-165 conformance and interface-support checks.
115#[derive(Debug, thiserror::Error)]
116#[non_exhaustive]
117pub enum ERC165ConfirmError {
118    /// The target address does not contain a deployed contract
119    /// (the call returned zero data).
120    #[error("The requested address is not a deployed contract")]
121    NotAContract,
122    /// The contract does not conform to the requested interface.
123    #[error("The contract does not support the requested interface")]
124    Unsupported,
125    /// An RPC transport error occurred while querying the contract.
126    #[error(transparent)]
127    TransportError(#[from] TransportErrorKind),
128}
129
130impl From<MulticallError> for ERC165ConfirmError {
131    fn from(error: MulticallError) -> Self {
132        match error {
133            MulticallError::NoReturnData | MulticallError::DecodeError(_) => {
134                ERC165ConfirmError::NotAContract
135            }
136            MulticallError::TransportError(TransportError::Transport(transport_error)) => {
137                ERC165ConfirmError::TransportError(transport_error)
138            }
139            MulticallError::ValueTx
140            | MulticallError::CallFailed(_)
141            | MulticallError::TransportError(_) => ERC165ConfirmError::Unsupported,
142        }
143    }
144}
145
146impl HttpRpcProvider {
147    /// Checks whether the contract at `address` correctly implements ERC-165.
148    ///
149    /// The check follows the procedure defined in
150    /// [EIP-165](https://eips.ethereum.org/EIPS/eip-165):
151    ///
152    /// 1. `supportsInterface(0x01ffc9a7)` must return `true`.
153    /// 2. `supportsInterface(0xffffffff)` must return `false`.
154    ///
155    /// Both queries are batched into one RPC request through Multicall3 at its
156    /// canonical address.
157    ///
158    /// Inspired by `OpenZeppelin`'s
159    /// [`ERC165Checker.supportsERC165`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/5e28952cbdc0eb7d19ee62580ab31b30c2376e48/contracts/utils/introspection/ERC165Checker.sol#L24).
160    ///
161    /// # Errors
162    ///
163    /// * [`ERC165ConfirmError::NotAContract`] – the address has no deployed code.
164    /// * [`ERC165ConfirmError::Unsupported`] – the contract is not ERC-165
165    ///   conformant: either it does not respond to `supportsInterface(0x01ffc9a7)`,
166    ///   or it incorrectly claims to support the invalid interface `0xffffffff`.
167    /// * [`ERC165ConfirmError::TransportError`] – an RPC transport failure.
168    pub async fn ensure_erc165_conform(&self, address: Address) -> Result<(), ERC165ConfirmError> {
169        let maybe_erc165 = ERC165Instance::new(address, self.inner());
170        let supports_erc165_call =
171            maybe_erc165.supportsInterface(FixedBytes::from(ERC_165_SUPPORTS_INTERFACE_SELECTOR));
172        let supports_invalid_interface_call =
173            maybe_erc165.supportsInterface(FixedBytes::from(INVALID_INTERFACE_SELECTOR));
174        let (supports_erc165, supports_invalid) = self
175            .inner()
176            .multicall()
177            .add(supports_erc165_call)
178            .add(supports_invalid_interface_call)
179            .aggregate()
180            .await?;
181
182        if supports_erc165 && !supports_invalid {
183            Ok(())
184        } else {
185            Err(ERC165ConfirmError::Unsupported)
186        }
187    }
188
189    /// Queries whether the contract at `address` supports the interface
190    /// identified by the XOR of the given `selectors`, **without** first
191    /// verifying ERC-165 conformance.
192    ///
193    /// Inspired by `OpenZeppelin`'s
194    /// [`ERC165Checker.supportsERC165InterfaceUnchecked`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/5e28952cbdc0eb7d19ee62580ab31b30c2376e48/contracts/utils/introspection/ERC165Checker.sol#L107).
195    ///
196    /// # Errors
197    ///
198    /// Returns [`ERC165ConfirmError`] if the contract does not support the
199    /// requested interface, on transport failures, or if the target address
200    /// is not a deployed contract.
201    ///
202    /// # Note
203    ///
204    /// This method does not verify strict ERC-165 compliance. Use
205    /// [`HttpRpcProvider::erc165_supports_interface`] if you also want to ensure
206    /// the contract does not claim to support the invalid interface `0xffffffff`.
207    pub async fn erc165_supports_interface_unchecked(
208        &self,
209        address: Address,
210        selectors: impl IntoIterator<Item = [u8; 4]>,
211    ) -> Result<(), ERC165ConfirmError> {
212        let erc165 = ERC165Instance::new(address, self.inner());
213        let supports_interface = erc165
214            .supportsInterface(erc165_interface_selector(selectors))
215            .call()
216            .await;
217        unwrap_erc165_call(supports_interface)
218    }
219
220    /// Checks whether the contract at `address` supports the interface
221    /// identified by the XOR of the given `selectors`.
222    ///
223    /// This method performs the **full** ERC-165 verification:
224    ///
225    /// The requested interface query and both ERC-165 conformance queries are
226    /// batched into one RPC request through Multicall3 at its canonical address.
227    ///
228    /// Inspired by `OpenZeppelin`'s
229    /// [`ERC165Checker.supportsInterface`](https://github.com/OpenZeppelin/openzeppelin-contracts/blob/5e28952cbdc0eb7d19ee62580ab31b30c2376e48/contracts/utils/introspection/ERC165Checker.sol#L36).
230    ///
231    /// # Errors
232    ///
233    /// Returns [`ERC165ConfirmError`] if the contract does not support the
234    /// requested interface, on transport failures, if the target address is
235    /// not a contract, or if the contract violates the EIP-165 spec.
236    pub async fn erc165_supports_interface(
237        &self,
238        address: Address,
239        selectors: impl IntoIterator<Item = [u8; 4]>,
240    ) -> Result<(), ERC165ConfirmError> {
241        let maybe_erc165 = ERC165Instance::new(address, self.inner());
242        let supports_interface_call =
243            maybe_erc165.supportsInterface(erc165_interface_selector(selectors));
244        let supports_erc165_call =
245            maybe_erc165.supportsInterface(FixedBytes::from(ERC_165_SUPPORTS_INTERFACE_SELECTOR));
246        let supports_invalid_interface_call =
247            maybe_erc165.supportsInterface(FixedBytes::from(INVALID_INTERFACE_SELECTOR));
248        let (supports_interface, supports_erc165, supports_invalid) = self
249            .inner()
250            .multicall()
251            .add(supports_interface_call)
252            .add(supports_erc165_call)
253            .add(supports_invalid_interface_call)
254            .aggregate()
255            .await?;
256
257        if supports_interface && supports_erc165 && !supports_invalid {
258            Ok(())
259        } else {
260            Err(ERC165ConfirmError::Unsupported)
261        }
262    }
263}
264
265#[cfg(test)]
266mod tests {
267    #[cfg(feature = "web3-asserter")]
268    use alloy::{
269        primitives::{Bytes, U256, address},
270        providers::mock::Asserter,
271        sol_types::SolValue,
272    };
273    use alloy::{sol, sol_types::SolCall};
274
275    use crate::web3::erc165::ERC165;
276    #[cfg(feature = "web3-asserter")]
277    use crate::web3::{HttpRpcProvider, erc165::ERC165ConfirmError};
278
279    sol! {
280        interface Solidity101 {
281            function hello() external pure;
282            function world(int256) external pure;
283        }
284    }
285
286    #[test]
287    fn test_selector_hashes() {
288        assert_eq!(
289            super::erc165_interface_selector([ERC165::supportsInterfaceCall::SELECTOR]),
290            super::ERC_165_SUPPORTS_INTERFACE_SELECTOR
291        );
292        assert_eq!(super::erc165_interface_selector([]), [0, 0, 0, 0]);
293
294        let selectors = [
295            Solidity101::helloCall::SELECTOR,
296            Solidity101::worldCall::SELECTOR,
297        ];
298        assert_eq!(
299            super::erc165_interface_selector(selectors),
300            [0xc6, 0xbe, 0x8b, 0x58]
301        );
302        assert_eq!(
303            super::erc165_interface_selector(selectors.into_iter().rev()),
304            [0xc6, 0xbe, 0x8b, 0x58],
305            "selector order should not matter"
306        );
307        assert_ne!(
308            super::erc165_interface_selector([
309                Solidity101::helloCall::SELECTOR,
310                Solidity101::worldCall::SELECTOR,
311                Solidity101::helloCall::SELECTOR,
312            ]),
313            [0xc6, 0xbe, 0x8b, 0x58],
314            "repeating a selector should change the interface identifier"
315        );
316    }
317
318    #[cfg(feature = "web3-asserter")]
319    fn aggregate_response(values: impl IntoIterator<Item = bool>) -> Bytes {
320        let return_data = values
321            .into_iter()
322            .map(|value| Bytes::from(value.abi_encode()))
323            .collect::<Vec<_>>();
324        Bytes::from((U256::ZERO, return_data).abi_encode_params())
325    }
326
327    #[cfg(feature = "web3-asserter")]
328    fn provider_with_response(response: &Bytes) -> (HttpRpcProvider, Asserter) {
329        let asserter = Asserter::new();
330        asserter.push_success(response);
331        let provider = HttpRpcProvider::with_mock_asserter(asserter.clone());
332        (provider, asserter)
333    }
334
335    #[cfg(feature = "web3-asserter")]
336    #[tokio::test]
337    async fn ensure_erc165_conform_handles_contract_responses() {
338        for (values, should_succeed) in [
339            ([true, false], true),
340            ([false, false], false),
341            ([true, true], false),
342        ] {
343            let (provider, asserter) = provider_with_response(&aggregate_response(values));
344            let result = provider
345                .ensure_erc165_conform(address!("0000000000000000000000000000000000000001"))
346                .await;
347
348            if should_succeed {
349                result.expect("mocked contract should be ERC-165 conformant");
350            } else {
351                assert!(
352                    matches!(result, Err(ERC165ConfirmError::Unsupported)),
353                    "non-conformant response should be unsupported"
354                );
355            }
356            assert!(
357                asserter.read_q().is_empty(),
358                "the check should consume exactly one RPC response"
359            );
360        }
361    }
362
363    #[cfg(feature = "web3-asserter")]
364    #[tokio::test]
365    async fn ensure_erc165_conform_maps_call_errors() {
366        let (provider, asserter) = provider_with_response(&Bytes::new());
367        let result = provider
368            .ensure_erc165_conform(address!("0000000000000000000000000000000000000001"))
369            .await;
370        assert!(
371            matches!(result, Err(ERC165ConfirmError::NotAContract)),
372            "empty return data should identify a non-contract"
373        );
374        assert!(asserter.read_q().is_empty(), "response should be consumed");
375
376        let provider = HttpRpcProvider::with_mock_asserter(Asserter::new());
377        let result = provider
378            .ensure_erc165_conform(address!("0000000000000000000000000000000000000001"))
379            .await;
380        assert!(
381            matches!(result, Err(ERC165ConfirmError::TransportError(_))),
382            "an empty mock queue should produce a transport error"
383        );
384    }
385
386    #[cfg(feature = "web3-asserter")]
387    #[tokio::test]
388    async fn erc165_supports_interface_handles_contract_responses() {
389        for (values, should_succeed) in [
390            ([true, true, false], true),
391            ([false, true, false], false),
392            ([true, false, false], false),
393            ([true, true, true], false),
394        ] {
395            let (provider, asserter) = provider_with_response(&aggregate_response(values));
396            let result = provider
397                .erc165_supports_interface(
398                    address!("0000000000000000000000000000000000000001"),
399                    [ERC165::supportsInterfaceCall::SELECTOR],
400                )
401                .await;
402
403            if should_succeed {
404                result.expect("mocked contract should support the requested interface");
405            } else {
406                assert!(
407                    matches!(result, Err(ERC165ConfirmError::Unsupported)),
408                    "unsupported or non-conformant response should be rejected"
409                );
410            }
411            assert!(
412                asserter.read_q().is_empty(),
413                "the check should consume exactly one RPC response"
414            );
415        }
416    }
417
418    #[cfg(feature = "web3-asserter")]
419    #[tokio::test]
420    async fn erc165_supports_interface_maps_call_errors() {
421        let (provider, asserter) = provider_with_response(&Bytes::new());
422        let result = provider
423            .erc165_supports_interface(
424                address!("0000000000000000000000000000000000000001"),
425                [ERC165::supportsInterfaceCall::SELECTOR],
426            )
427            .await;
428        assert!(
429            matches!(result, Err(ERC165ConfirmError::NotAContract)),
430            "empty return data should identify a non-contract"
431        );
432        assert!(asserter.read_q().is_empty(), "response should be consumed");
433
434        let provider = HttpRpcProvider::with_mock_asserter(Asserter::new());
435        let result = provider
436            .erc165_supports_interface(
437                address!("0000000000000000000000000000000000000001"),
438                [ERC165::supportsInterfaceCall::SELECTOR],
439            )
440            .await;
441        assert!(
442            matches!(result, Err(ERC165ConfirmError::TransportError(_))),
443            "an empty mock queue should produce a transport error"
444        );
445    }
446
447    #[cfg(feature = "web3-asserter")]
448    #[tokio::test]
449    async fn erc165_supports_interface_unchecked_handles_contract_responses() {
450        for (value, should_succeed) in [(true, true), (false, false)] {
451            let response = Bytes::from(value.abi_encode());
452            let (provider, asserter) = provider_with_response(&response);
453            let result = provider
454                .erc165_supports_interface_unchecked(
455                    address!("0000000000000000000000000000000000000001"),
456                    [ERC165::supportsInterfaceCall::SELECTOR],
457                )
458                .await;
459
460            if should_succeed {
461                result.expect("mocked contract should support the requested interface");
462            } else {
463                assert!(
464                    matches!(result, Err(ERC165ConfirmError::Unsupported)),
465                    "false response should be unsupported"
466                );
467            }
468            assert!(
469                asserter.read_q().is_empty(),
470                "the check should consume exactly one RPC response"
471            );
472        }
473    }
474
475    #[cfg(feature = "web3-asserter")]
476    #[tokio::test]
477    async fn erc165_supports_interface_unchecked_maps_call_errors() {
478        let (provider, asserter) = provider_with_response(&Bytes::new());
479        let result = provider
480            .erc165_supports_interface_unchecked(
481                address!("0000000000000000000000000000000000000001"),
482                [ERC165::supportsInterfaceCall::SELECTOR],
483            )
484            .await;
485        assert!(
486            matches!(result, Err(ERC165ConfirmError::NotAContract)),
487            "empty return data should identify a non-contract"
488        );
489        assert!(asserter.read_q().is_empty(), "response should be consumed");
490
491        let provider = HttpRpcProvider::with_mock_asserter(Asserter::new());
492        let result = provider
493            .erc165_supports_interface_unchecked(
494                address!("0000000000000000000000000000000000000001"),
495                [ERC165::supportsInterfaceCall::SELECTOR],
496            )
497            .await;
498        assert!(
499            matches!(result, Err(ERC165ConfirmError::TransportError(_))),
500            "an empty mock queue should produce a transport error"
501        );
502    }
503}