Skip to main content

xmtp_id/scw_verifier/
cached.rs

1use crate::associations::AccountId;
2use crate::scw_verifier::{SmartContractSignatureVerifier, ValidationResponse, VerifierError};
3use alloy::primitives::{BlockNumber, Bytes, keccak256};
4use lru::LruCache;
5use parking_lot::Mutex;
6use std::num::NonZeroUsize;
7
8/// 32-byte cache key derived from all verification parameters via keccak256.
9/// Prevents cross-account cache poisoning while keeping memory constant per entry.
10/// See: https://github.com/xmtp/libxmtp/issues/3393
11type CacheKey = [u8; 32];
12
13/// Build a collision-resistant key from every verification parameter.
14///
15/// Length prefixes keep variable fields unambiguous. All fields are borrowed,
16/// so key construction does not clone the account or signature.
17fn build_cache_key(
18    account_id: &AccountId,
19    hash: &[u8; 32],
20    signature: &[u8],
21    block_number: Option<BlockNumber>,
22) -> CacheKey {
23    let chain_id = account_id.get_chain_id().as_bytes();
24    let account_address = account_id.get_account_address().as_bytes();
25    let bn_bytes = block_number.map(|bn| bn.to_be_bytes());
26
27    // Pre-allocate: 4-byte lengths for 3 variable fields + field data + 1 tag + 8 optional
28    let capacity = 4 + chain_id.len() + 4 + account_address.len() + 32 + 4 + signature.len() + 9;
29    let mut buf = Vec::with_capacity(capacity);
30
31    // Length-prefix variable-length fields for unambiguous encoding
32    buf.extend_from_slice(&(chain_id.len() as u32).to_be_bytes());
33    buf.extend_from_slice(chain_id);
34    buf.extend_from_slice(&(account_address.len() as u32).to_be_bytes());
35    buf.extend_from_slice(account_address);
36    buf.extend_from_slice(hash);
37    buf.extend_from_slice(&(signature.len() as u32).to_be_bytes());
38    buf.extend_from_slice(signature);
39    match bn_bytes {
40        Some(bytes) => {
41            buf.push(0x01);
42            buf.extend_from_slice(&bytes);
43        }
44        None => buf.push(0x00),
45    }
46    *keccak256(&buf)
47}
48
49/// A cached smart contract verifier.
50///
51/// This wraps MultiSmartContractSignatureVerifier (or any other verifier
52/// implementing SmartContractSignatureVerifier) and adds an in-memory LRU cache.
53pub struct CachedSmartContractSignatureVerifier {
54    verifier: Box<dyn SmartContractSignatureVerifier>,
55    cache: Mutex<LruCache<CacheKey, ValidationResponse>>,
56}
57
58impl CachedSmartContractSignatureVerifier {
59    /// Wrap a verifier with a bounded LRU verdict cache.
60    ///
61    /// Only checks tied to an explicit block can be cached. The caller must
62    /// provide a non-zero capacity. Verifier errors are never inserted.
63    pub fn new(
64        verifier: impl SmartContractSignatureVerifier + 'static,
65        cache_size: NonZeroUsize,
66    ) -> Result<Self, VerifierError> {
67        Ok(Self {
68            verifier: Box::new(verifier),
69            cache: Mutex::new(LruCache::new(cache_size)),
70        })
71    }
72}
73
74#[xmtp_common::async_trait]
75impl SmartContractSignatureVerifier for CachedSmartContractSignatureVerifier {
76    /// Verify a signature, reusing verdicts for explicit block numbers.
77    ///
78    /// Requests without a block number always reach the wrapped verifier because
79    /// latest-chain state can change without changing the request. Cache access
80    /// is short and synchronous; the chain call runs without holding the mutex.
81    async fn is_valid_signature(
82        &self,
83        account_id: AccountId,
84        hash: [u8; 32],
85        signature: Bytes,
86        block_number: Option<BlockNumber>,
87    ) -> Result<ValidationResponse, VerifierError> {
88        // Latest state can change without changing the request.
89        if block_number.is_none() {
90            return self
91                .verifier
92                .is_valid_signature(account_id, hash, signature, None)
93                .await;
94        }
95        let cache_key = build_cache_key(&account_id, &hash, &signature, block_number);
96
97        if let Some(cached_response) = {
98            let mut cache = self.cache.lock();
99            cache.get(&cache_key).cloned()
100        } {
101            return Ok(cached_response);
102        }
103
104        let response = self
105            .verifier
106            .is_valid_signature(account_id, hash, signature, block_number)
107            .await?;
108
109        let mut cache = self.cache.lock();
110        cache.put(cache_key, response.clone());
111
112        Ok(response)
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use std::sync::{
120        Arc,
121        atomic::{AtomicUsize, Ordering},
122    };
123    struct CountingVerifier {
124        calls: Arc<AtomicUsize>,
125        valid: bool,
126        error: bool,
127    }
128    #[xmtp_common::async_trait]
129    impl SmartContractSignatureVerifier for CountingVerifier {
130        async fn is_valid_signature(
131            &self,
132            _: AccountId,
133            _: [u8; 32],
134            _: Bytes,
135            block: Option<BlockNumber>,
136        ) -> Result<ValidationResponse, VerifierError> {
137            let call = self.calls.fetch_add(1, Ordering::SeqCst);
138            if self.error {
139                return Err(VerifierError::NoVerifier("eip155:1".into()));
140            }
141            Ok(ValidationResponse {
142                is_valid: self.valid,
143                block_number: Some(block.unwrap_or(call as u64)),
144                error: None,
145            })
146        }
147    }
148    #[xmtp_common::test(unwrap_try = true)]
149    async fn cache_preserves_numbered_verdicts_and_bypasses_latest() {
150        for valid in [true, false] {
151            let calls = Arc::new(AtomicUsize::new(0));
152            let cache = CachedSmartContractSignatureVerifier::new(
153                CountingVerifier {
154                    calls: calls.clone(),
155                    valid,
156                    error: false,
157                },
158                NonZeroUsize::new(1).unwrap(),
159            )?;
160            let account = AccountId::new_evm(1, "0xaaa".into());
161            for _ in 0..2 {
162                let result = cache
163                    .is_valid_signature(account.clone(), [0; 32], Bytes::new(), Some(1))
164                    .await?;
165                assert_eq!(result.is_valid, valid);
166            }
167            assert_eq!(calls.load(Ordering::SeqCst), 1);
168            let first = cache
169                .is_valid_signature(account.clone(), [0; 32], Bytes::new(), None)
170                .await?;
171            let second = cache
172                .is_valid_signature(account.clone(), [0; 32], Bytes::new(), None)
173                .await?;
174            assert_ne!(first.block_number, second.block_number);
175            assert_eq!(calls.load(Ordering::SeqCst), 3);
176            cache
177                .is_valid_signature(account.clone(), [0; 32], Bytes::new(), Some(2))
178                .await?;
179            cache
180                .is_valid_signature(account, [0; 32], Bytes::new(), Some(1))
181                .await?;
182            assert_eq!(calls.load(Ordering::SeqCst), 5);
183        }
184    }
185    #[xmtp_common::test(unwrap_try = true)]
186    async fn verifier_errors_are_never_cached() {
187        let calls = Arc::new(AtomicUsize::new(0));
188        let cache = CachedSmartContractSignatureVerifier::new(
189            CountingVerifier {
190                calls: calls.clone(),
191                valid: false,
192                error: true,
193            },
194            NonZeroUsize::new(1).unwrap(),
195        )?;
196        for _ in 0..2 {
197            assert!(matches!(
198                cache
199                    .is_valid_signature(
200                        AccountId::new_evm(1, "0xaaa".into()),
201                        [0; 32],
202                        Bytes::new(),
203                        Some(1)
204                    )
205                    .await,
206                Err(VerifierError::NoVerifier(_))
207            ));
208        }
209        assert_eq!(calls.load(Ordering::SeqCst), 2);
210    }
211    #[xmtp_common::test(unwrap_try = true)]
212    fn cache_key_binds_every_parameter() {
213        let account = AccountId::new("eip155:1".into(), "0xaaa".into());
214        let key = build_cache_key(&account, &[0; 32], &[1], Some(0));
215        assert_ne!(
216            key,
217            build_cache_key(
218                &AccountId::new("eip155:2".into(), "0xaaa".into()),
219                &[0; 32],
220                &[1],
221                Some(0)
222            )
223        );
224        assert_ne!(
225            key,
226            build_cache_key(
227                &AccountId::new("eip155:1".into(), "0xbbb".into()),
228                &[0; 32],
229                &[1],
230                Some(0)
231            )
232        );
233        assert_ne!(key, build_cache_key(&account, &[1; 32], &[1], Some(0)));
234        assert_ne!(key, build_cache_key(&account, &[0; 32], &[2], Some(0)));
235        assert_ne!(key, build_cache_key(&account, &[0; 32], &[1], Some(1)));
236        assert_ne!(key, build_cache_key(&account, &[0; 32], &[1], None));
237        let left = AccountId::new("ab".into(), "c".into());
238        let right = AccountId::new("a".into(), "bc".into());
239        assert_ne!(
240            build_cache_key(&left, &[0; 32], &[1], Some(0)),
241            build_cache_key(&right, &[0; 32], &[1], Some(0))
242        );
243    }
244}