Skip to main content

xmtp_id/scw_verifier/
chain_rpc_verifier.rs

1//! Interaction with [ERC-1271](https://eips.ethereum.org/EIPS/eip-1271) smart contracts.
2use crate::associations::AccountId;
3use crate::scw_verifier::{BlockStamp, ChainBlocks, SmartContractSignatureVerifier};
4use alloy::eips::BlockNumberOrTag;
5use alloy::network::TransactionBuilder;
6use alloy::primitives::{Address, BlockNumber, Bytes, FixedBytes};
7use alloy::providers::{DynProvider, Provider, ProviderBuilder};
8use alloy::{sol, sol_types::SolConstructor};
9use hex::FromHexError;
10use std::sync::Arc;
11
12use super::{ValidationResponse, VerifierError};
13
14// https://github.com/AmbireTech/signature-validator/blob/7706bda/index.ts#L13
15// Contract from AmbireTech that is also used by Viem.
16// Note that this is not a complete ERC-6492 implementation as it lacks Prepare/Side-effect logic compared to official reference implementation, so it might evolve in the future.
17// For now it's accepted as [Coinbase Smart Wallet doc](https://github.com/AmbireTech/signature-validator/blob/7706bda/index.ts#L13) uses it for offchain verification.
18const VALIDATE_SIG_OFFCHAIN_BYTECODE: &str = include_str!("signature_validation.hex");
19
20sol!(
21    contract VerifySig {
22      constructor (
23        address _signer,
24        bytes32 _hash,
25        bytes memory _signature
26      );
27    }
28);
29
30#[derive(Debug, Clone)]
31pub struct RpcSmartContractWalletVerifier {
32    provider: Arc<DynProvider>,
33}
34
35impl RpcSmartContractWalletVerifier {
36    pub fn new(provider_url: String) -> Result<Self, VerifierError> {
37        Ok(Self {
38            provider: Arc::new(
39                ProviderBuilder::new()
40                    .connect_http(provider_url.parse()?)
41                    .erased(),
42            ),
43        })
44    }
45
46    pub fn new_from_provider(provider: impl Provider + 'static) -> Self {
47        Self {
48            provider: Arc::new(DynProvider::new(provider)),
49        }
50    }
51}
52
53#[xmtp_common::async_trait]
54impl SmartContractSignatureVerifier for RpcSmartContractWalletVerifier {
55    async fn is_valid_signature(
56        &self,
57        signer: AccountId,
58        hash: [u8; 32],
59        signature: Bytes,
60        block_number: Option<BlockNumber>,
61    ) -> Result<ValidationResponse, VerifierError> {
62        let code = hex::decode(VALIDATE_SIG_OFFCHAIN_BYTECODE.trim())?;
63        let account_address: Address = signer
64            .account_address
65            .parse()
66            .map_err(|_| FromHexError::InvalidStringLength)?;
67        let call = VerifySig::constructorCall::new((
68            account_address,
69            FixedBytes::<32>::new(hash),
70            signature,
71        ));
72
73        let data = call.abi_encode();
74        let data = [code, data].concat();
75        let block_number = match block_number {
76            Some(bn) => bn,
77            None => self
78                .provider
79                .get_block_number()
80                .await
81                .map_err(VerifierError::Provider)?,
82        };
83        let mut tx = self.provider.transaction_request();
84        tx.set_input(data);
85        let result = self.provider.call(tx).block(block_number.into()).await?;
86
87        // Check if result indicates valid signature (0x01)
88        let expected_valid = Bytes::from_static(&[0x01]);
89        let is_valid = result == expected_valid;
90
91        Ok(ValidationResponse {
92            is_valid,
93            block_number: Some(block_number),
94            error: None,
95        })
96    }
97}
98
99impl RpcSmartContractWalletVerifier {
100    /// The block at `number`, or a retryable error when the chain has none.
101    async fn block(&self, number: BlockNumberOrTag) -> Result<BlockStamp, VerifierError> {
102        let block =
103            self.provider
104                .get_block_by_number(number)
105                .await?
106                .ok_or(VerifierError::MissingBlock(
107                    number.as_number().unwrap_or_default(),
108                ))?;
109        Ok(BlockStamp {
110            number: block.header.number,
111            timestamp: block.header.timestamp,
112        })
113    }
114}
115
116/// One provider serves one chain, so the chain id is already resolved.
117#[xmtp_common::async_trait]
118impl ChainBlocks for RpcSmartContractWalletVerifier {
119    async fn head(&self, _: &str) -> Result<BlockStamp, VerifierError> {
120        self.block(BlockNumberOrTag::Latest).await
121    }
122
123    async fn timestamp(&self, _: &str, number: BlockNumber) -> Result<u64, VerifierError> {
124        Ok(self.block(number.into()).await?.timestamp)
125    }
126}
127
128// Anvil does not work with WASM
129// because its a wrapper over the system-binary
130#[cfg(all(test, not(target_arch = "wasm32")))]
131pub(crate) mod tests {
132    #![allow(clippy::unwrap_used)]
133    use crate::utils::test::{
134        EthereumProvider, SignatureWithNonce, SmartWalletContext, docker_provider,
135        docker_smart_wallet,
136    };
137
138    use super::*;
139    use alloy::dyn_abi::SolType;
140    use alloy::primitives::{B256, U256};
141    use alloy::providers::ext::AnvilApi;
142    use alloy::signers::Signer;
143    use std::time::Duration;
144    use xmtp_common::RetryableError;
145
146    /// Admission freshness compares a signature's block with the head, so the
147    /// verifier must report the chain's own head and past block timestamps,
148    /// and a block the chain has not produced must be a retryable error rather
149    /// than a verdict on the signature.
150    #[rstest::rstest]
151    #[xmtp_common::timeout(Duration::from_secs(30))]
152    #[tokio::test]
153    async fn chain_blocks_report_the_head_and_past_timestamps(
154        #[future] docker_provider: EthereumProvider,
155    ) {
156        let provider = docker_provider.await.provider;
157        provider.anvil_mine(Some(2), Some(100)).await.unwrap();
158        let verifier = RpcSmartContractWalletVerifier::new_from_provider(provider.clone());
159        let head = verifier.head("eip155:31337").await.unwrap();
160        assert!(head.number <= provider.get_block_number().await.unwrap());
161        let block = provider
162            .get_block_by_number(head.number.into())
163            .await
164            .unwrap()
165            .unwrap();
166        assert_eq!(head.timestamp, block.header.timestamp);
167        let earlier = verifier
168            .timestamp("eip155:31337", head.number - 1)
169            .await
170            .unwrap();
171        assert!(earlier <= head.timestamp);
172        let missing = verifier
173            .timestamp("eip155:31337", head.number + 1_000_000)
174            .await
175            .unwrap_err();
176        assert!(matches!(missing, VerifierError::MissingBlock(_)));
177        assert!(missing.is_retryable());
178    }
179
180    #[rstest::rstest]
181    #[xmtp_common::timeout(Duration::from_secs(30))]
182    #[tokio::test]
183    async fn test_coinbase_smart_wallet(#[future] docker_smart_wallet: SmartWalletContext) {
184        let SmartWalletContext {
185            factory,
186            sw,
187            owner0,
188            owner1,
189            sw_address,
190        } = docker_smart_wallet.await;
191        let provider = factory.provider();
192        let chain_id = provider.get_chain_id().await.unwrap();
193        let hash = B256::random();
194        let replay_safe_hash = sw.replaySafeHash(hash).call().await.unwrap();
195        let verifier = RpcSmartContractWalletVerifier::new_from_provider(provider.clone());
196        let sig0 = owner0.sign_hash(&replay_safe_hash).await.unwrap();
197        let account_id = AccountId::new_evm(chain_id, format!("{}", sw_address));
198
199        let res = verifier
200            .is_valid_signature(
201                account_id.clone(),
202                *hash,
203                SignatureWithNonce::abi_encode(&(U256::from(0), Bytes::from(sig0.as_bytes())))
204                    .into(),
205                None,
206            )
207            .await
208            .unwrap();
209        assert!(res.is_valid);
210
211        // verify owner1 is a valid owner
212        let sig1 = owner1.sign_hash(&replay_safe_hash).await.unwrap();
213        let res = verifier
214            .is_valid_signature(
215                account_id.clone(),
216                *hash,
217                SignatureWithNonce::abi_encode(&(U256::from(1), Bytes::from(sig1.as_bytes())))
218                    .into(),
219                None,
220            )
221            .await
222            .unwrap();
223        assert!(res.is_valid);
224
225        // owner0 signature must not be used to verify owner1
226        let res = verifier
227            .is_valid_signature(
228                account_id.clone(),
229                *hash,
230                SignatureWithNonce::abi_encode(&(U256::from(1), Bytes::from(sig0.as_bytes())))
231                    .into(),
232                None,
233            )
234            .await
235            .unwrap();
236        assert!(!res.is_valid);
237    }
238
239    #[rstest::rstest]
240    #[xmtp_common::timeout(Duration::from_secs(60))]
241    #[tokio::test]
242    // verifies: IDENT-060
243    async fn test_smart_wallet_time_travel(#[future] docker_smart_wallet: SmartWalletContext) {
244        let SmartWalletContext {
245            factory,
246            sw,
247            owner1,
248            sw_address,
249            ..
250        } = docker_smart_wallet.await;
251
252        let provider = factory.provider();
253        let verifier = RpcSmartContractWalletVerifier::new_from_provider(provider.clone());
254        let chain_id = provider.get_chain_id().await.unwrap();
255        let hash = B256::random();
256        let replay_safe_hash = sw.replaySafeHash(hash).call().await.unwrap();
257        let sig1 = owner1.sign_hash(&replay_safe_hash).await.unwrap();
258        let account_id = AccountId::new_evm(chain_id, format!("{}", sw_address));
259        let block_number = provider.get_block_number().await.unwrap();
260        println!("{}", block_number);
261        provider.anvil_mine(Some(50), None).await.unwrap();
262        println!("{}", provider.get_block_number().await.unwrap());
263        // remove owner1 and check owner1 is no longer a valid owner
264        let _tx = sw
265            .removeOwnerAtIndex(U256::from(1))
266            .from(owner1.address())
267            .send()
268            .await
269            .unwrap()
270            .get_receipt()
271            .await
272            .unwrap();
273
274        let res = verifier
275            .is_valid_signature(
276                account_id.clone(),
277                *hash,
278                SignatureWithNonce::abi_encode(&(U256::from(1), sig1.as_bytes())).into(),
279                None,
280            )
281            .await;
282        assert!(res.is_err());
283        // when verify a non-existing owner, it errors
284        // time travel to the pre-removel block number and verify owner1 WAS a valid owner
285
286        let res = verifier
287            .is_valid_signature(
288                account_id.clone(),
289                *hash,
290                SignatureWithNonce::abi_encode(&(U256::from(1), sig1.as_bytes())).into(),
291                Some(block_number),
292            )
293            .await
294            .unwrap();
295        assert!(res.is_valid);
296    }
297
298    // Testing ERC-6492 with deployed / undeployed coinbase smart wallet(ERC-1271) contracts, and EOA.
299    #[rstest::rstest]
300    #[xmtp_common::timeout(Duration::from_secs(60))]
301    #[tokio::test]
302    async fn test_is_valid_signature(#[future] docker_smart_wallet: SmartWalletContext) {
303        let SmartWalletContext {
304            factory,
305            sw,
306            owner0: owner,
307            sw_address,
308            ..
309        } = docker_smart_wallet.await;
310        let provider = factory.provider();
311        let chain_id = provider.get_chain_id().await.unwrap();
312        let hash = B256::random();
313        let replay_safe_hash = sw.replaySafeHash(hash).call().await.unwrap();
314        let verifier = RpcSmartContractWalletVerifier::new_from_provider(provider.clone());
315        let signature = owner.sign_hash(&replay_safe_hash).await.unwrap();
316        let signature: Bytes =
317            SignatureWithNonce::abi_encode(&(U256::from(0), signature.as_bytes())).into();
318        let account_id = AccountId::new_evm(chain_id, format!("{}", sw_address));
319
320        // Testing ERC-6492 signatures with deployed ERC-1271.
321        assert!(
322            verifier
323                .is_valid_signature(account_id.clone(), *hash, signature.clone(), None)
324                .await
325                .unwrap()
326                .is_valid
327        );
328
329        assert!(
330            !verifier
331                .is_valid_signature(account_id.clone(), *B256::random(), signature, None)
332                .await
333                .unwrap()
334                .is_valid
335        );
336
337        // Testing if EOA wallet signature is valid on ERC-6492
338        let signature = owner.sign_hash(&hash).await.unwrap();
339        let owner_account_id = AccountId::new_evm(chain_id, format!("{:?}", owner.address()));
340        assert!(
341            verifier
342                .is_valid_signature(
343                    owner_account_id.clone(),
344                    *hash,
345                    signature.as_bytes().into(),
346                    None
347                )
348                .await
349                .unwrap()
350                .is_valid
351        );
352
353        assert!(
354            !verifier
355                .is_valid_signature(
356                    owner_account_id,
357                    *B256::random(),
358                    signature.as_bytes().into(),
359                    None
360                )
361                .await
362                .unwrap()
363                .is_valid
364        );
365    }
366}