Skip to main content

tycho_ethereum/services/
account_extractor.rs

1use std::collections::{HashMap, HashSet};
2
3use alloy::{
4    primitives::{Address as AlloyAddress, B256},
5    rpc::types::{BlockId, BlockNumberOrTag},
6};
7use async_trait::async_trait;
8use chrono::DateTime;
9use futures::future::try_join_all;
10use tracing::{debug, info};
11use tycho_common::{
12    models::{blockchain::Block, contract::AccountDelta, Chain, ChangeType},
13    traits::{AccountExtractor, StorageSnapshotRequest},
14    Bytes,
15};
16
17use crate::{
18    rpc::{
19        errors::{RPCError, RequestError},
20        EthereumRpcClient,
21    },
22    BytesCodec,
23};
24
25/// `EVMAccountExtractor` is a struct that implements the `AccountExtractor` trait for Ethereum
26/// accounts.
27/// TODO: once the `chain` attribute is deprecated from AccountDelta,
28/// We can get rid of this struct and use the EthereumRpcClient directly
29/// to implement the `AccountExtractor` trait.
30pub struct EVMAccountExtractor {
31    rpc: EthereumRpcClient,
32    chain: Chain,
33}
34
35impl EVMAccountExtractor {
36    pub fn new(client: &EthereumRpcClient, chain: Chain) -> Self {
37        // As the client is a thin wrapper around an Arc, cloning is inexpensive.
38        Self { rpc: client.clone(), chain }
39    }
40
41    pub async fn get_block_data(&self, block_id: u64) -> Result<Block, RPCError> {
42        let block_id = BlockId::from(block_id);
43
44        let block = self
45            .rpc
46            .eth_get_block_by_number(block_id)
47            .await?;
48
49        Ok(Block {
50            number: block.header.number,
51            hash: block.header.hash.to_bytes(),
52            parent_hash: block.header.parent_hash.to_bytes(),
53            chain: self.chain,
54            ts: DateTime::from_timestamp(block.header.timestamp as i64, 0)
55                .ok_or_else(|| {
56                    RPCError::RequestError(RequestError::Other("Invalid timestamp in block".into()))
57                })?
58                .naive_utc(),
59        })
60    }
61}
62
63#[async_trait]
64impl AccountExtractor for EVMAccountExtractor {
65    type Error = RPCError;
66
67    async fn get_accounts_at_block(
68        &self,
69        block: &Block,
70        requests: &[StorageSnapshotRequest],
71    ) -> Result<HashMap<Bytes, AccountDelta>, Self::Error> {
72        let block_id = BlockNumberOrTag::Number(block.number);
73        let block_hash = B256::from_slice(&block.hash);
74
75        let mut updates = HashMap::new();
76
77        // Remove duplicates to avoid making more requests than necessary.
78        let unique_requests: Vec<StorageSnapshotRequest> = requests
79            .iter()
80            .cloned()
81            .collect::<HashSet<_>>()
82            .into_iter()
83            .collect();
84
85        // Convert addresses to AlloyAddress for easier handling
86        let alloy_addresses: Vec<AlloyAddress> = unique_requests
87            .iter()
88            .map(|request| AlloyAddress::from_bytes(&request.address))
89            .collect();
90
91        // Create a future for code and balance retrieval
92        let codes_and_balances_fut = self
93            .rpc
94            .fetch_accounts_code_and_balance(block_id, &alloy_addresses);
95
96        // Create futures for storage retrieval
97        let storage_futs = unique_requests
98            .iter()
99            .map(|req| {
100                let address = AlloyAddress::from_bytes(&req.address);
101
102                let fut = async move {
103                    if let Some(slots) = &req.slots {
104                        let slots = slots
105                            .iter()
106                            .map(B256::from_bytes)
107                            .collect::<Vec<_>>();
108
109                        self.rpc
110                            .get_selected_storage(block_id, address, &slots)
111                            .await
112                    } else {
113                        self.rpc
114                            .get_storage_range(address, block_hash)
115                            .await
116                            // Wrap the resulting hashmap values in Some to match the expected type
117                            .map(|result| {
118                                result
119                                    .into_iter()
120                                    .map(|(k, v)| (k, Some(v)))
121                                    .collect()
122                            })
123                    }
124                };
125
126                fut
127            })
128            .collect::<Vec<_>>();
129
130        let codes_and_balances = codes_and_balances_fut.await?;
131        debug!(block_number = block.number, "Successfully retrieved account code and balance data");
132
133        let storage_results = try_join_all(storage_futs).await?;
134        debug!(block_number = block.number, "Successfully retrieved account storage");
135
136        for (address, storage_result) in alloy_addresses
137            .iter()
138            .zip(storage_results)
139        {
140            let (code, balance) = codes_and_balances[address].clone();
141
142            // Convert the storage result from Alloy to Tycho types
143            let storage = storage_result
144                .into_iter()
145                .map(|(k, v)| (k.to_bytes(), v.map(|v| v.to_bytes())))
146                .collect::<HashMap<_, _>>();
147
148            let account_delta = AccountDelta::new(
149                self.chain,
150                address.to_bytes(),
151                storage,
152                Some(balance.to_bytes()),
153                Some(code),
154                ChangeType::Creation,
155            );
156
157            updates.insert(address.to_bytes(), account_delta);
158        }
159
160        info!(
161            total_accounts_processed = updates.len(),
162            block_number = block.number,
163            "Completed batch account extraction successfully"
164        );
165
166        Ok(updates)
167    }
168}
169
170#[cfg(test)]
171mod tests {
172    use std::str::FromStr;
173
174    use rstest::rstest;
175    use tracing::warn;
176    use tracing_test::traced_test;
177    use tycho_common::models::{Address, Chain};
178
179    use super::*;
180    use crate::test_fixtures::{
181        TestFixture, BALANCER_VAULT_EXPECTED_SLOTS, BALANCER_VAULT_STR, STETH_EXPECTED_SLOTS,
182        STETH_STR, TEST_SLOTS, TOKEN_ADDRESSES,
183    };
184
185    fn parse_address(address_str: &str) -> Address {
186        Address::from_str(address_str).expect("failed to parse address")
187    }
188
189    fn create_storage_request(
190        address_str: &str,
191        slots: Option<Vec<Bytes>>,
192    ) -> StorageSnapshotRequest {
193        StorageSnapshotRequest { address: parse_address(address_str), slots }
194    }
195
196    impl TestFixture {
197        fn create_evm_extractor(&self, batching: bool) -> EVMAccountExtractor {
198            let rpc_client = self.create_rpc_client(batching);
199
200            EVMAccountExtractor { rpc: rpc_client, chain: Chain::Ethereum }
201        }
202    }
203
204    /// Test the account extractor with various contracts and their storage slots.
205    ///
206    /// Note: The STETH test case processes a large number of storage slots (789,526 slots,
207    /// stETH is the 9th largest token by number of holders). This test takes around 2 minutes
208    /// to run and retrieves around 50MB of data.
209    #[rstest]
210    #[case(BALANCER_VAULT_STR, BALANCER_VAULT_EXPECTED_SLOTS)]
211    #[case(STETH_STR, STETH_EXPECTED_SLOTS)] // Large contract - takes ~2 mins, retrieves ~50MB
212    #[traced_test]
213    #[tokio::test]
214    #[ignore = "require RPC connection"]
215    async fn test_account_extractor(
216        #[case] address_str: &str,
217        #[case] expected_slot_count: usize,
218        #[values(false, true)] batching: bool,
219    ) -> Result<(), RPCError> {
220        let fixture = TestFixture::new();
221        let extractor = fixture.create_evm_extractor(batching);
222
223        // Warn about large contracts (STETH has 789k+ slots, takes ~2 mins, ~50MB data)
224        if expected_slot_count > 100_000 {
225            warn!(
226                "Testing large contract {} with {} storage slots - this will take ~2 minutes and retrieve ~50MB of data",
227                address_str, expected_slot_count
228            );
229        }
230
231        let requests = vec![create_storage_request(address_str, None)];
232
233        let updates = extractor
234            .get_accounts_at_block(&fixture.block, &requests)
235            .await?;
236
237        assert_eq!(updates.len(), 1, "Expected exactly 1 account update");
238
239        let update = updates
240            .get(&Bytes::from_str(address_str).expect("valid address"))
241            .expect("update exists");
242
243        assert_eq!(
244            update.slots.len(),
245            expected_slot_count,
246            "{} storage slot count mismatch. Expected: {}, Got: {}",
247            address_str,
248            expected_slot_count,
249            update.slots.len()
250        );
251
252        Ok(())
253    }
254
255    #[rstest]
256    #[traced_test]
257    #[tokio::test]
258    #[ignore = "require RPC connection"]
259    async fn test_get_storage_snapshots_plain(
260        #[values(false, true)] batching: bool,
261    ) -> Result<(), RPCError> {
262        let fixture = TestFixture::new();
263
264        let extractor = fixture.create_evm_extractor(batching);
265
266        let requests = vec![
267            create_storage_request(BALANCER_VAULT_STR, Some(vec![])),
268            create_storage_request(STETH_STR, Some(vec![])),
269        ];
270
271        let start_time = std::time::Instant::now();
272        let result = extractor
273            .get_accounts_at_block(&fixture.block, &requests)
274            .await?;
275        let duration = start_time.elapsed();
276        println!("Time taken to get storage snapshots: {duration:?}");
277
278        assert_eq!(result.len(), 2);
279
280        // First account check
281        let first_address = parse_address(BALANCER_VAULT_STR);
282        let first_delta = result
283            .get(&first_address)
284            .expect("first address should exist");
285        assert_eq!(first_delta.address, first_address);
286        assert_eq!(first_delta.chain, Chain::Ethereum);
287        assert!(first_delta.code().is_some());
288        assert!(first_delta.balance.is_some());
289        println!("Balance: {:?}", first_delta.balance);
290
291        // Second account check
292        let second_address = parse_address(STETH_STR);
293        let second_delta: &AccountDelta = result
294            .get(&second_address)
295            .expect("second address should exist");
296        assert_eq!(second_delta.address, second_address);
297        assert_eq!(second_delta.chain, Chain::Ethereum);
298        assert!(second_delta.code().is_some());
299        assert!(second_delta.balance.is_some());
300        println!("Balance: {:?}", second_delta.balance);
301
302        Ok(())
303    }
304
305    #[rstest]
306    #[traced_test]
307    #[tokio::test]
308    #[ignore = "require RPC connection"]
309    async fn test_get_storage_snapshots_with_specific_slots(
310        #[values(false, true)] batching: bool,
311    ) -> Result<(), RPCError> {
312        let fixture = TestFixture::new();
313        let extractor = fixture.create_evm_extractor(batching);
314
315        // Create request with specific slots
316        let slots = &*TEST_SLOTS;
317        let slots_request = slots
318            .keys()
319            .map(|k| k.to_bytes())
320            .collect();
321
322        let requests = vec![create_storage_request(BALANCER_VAULT_STR, Some(slots_request))];
323
324        let result = extractor
325            .get_accounts_at_block(&fixture.block, &requests)
326            .await?;
327
328        assert_eq!(result.len(), 1);
329
330        // Check the account delta
331        let address = parse_address(BALANCER_VAULT_STR);
332        let delta = result
333            .get(&address)
334            .expect("address should exist");
335
336        assert_eq!(delta.address, address);
337        assert_eq!(delta.chain, Chain::Ethereum);
338        assert!(delta.code().is_some());
339        assert!(delta.balance.is_some());
340
341        // Check that storage slots match what we requested
342        assert_eq!(delta.slots.len(), 3);
343        for (key, value) in slots.iter() {
344            assert!(delta
345                .slots
346                .contains_key(&key.to_bytes()));
347            assert_eq!(
348                delta
349                    .slots
350                    .get(&key.to_bytes())
351                    .and_then(|v| v.as_ref()),
352                Some(&value.to_bytes())
353            );
354        }
355
356        Ok(())
357    }
358
359    #[rstest]
360    #[traced_test]
361    #[tokio::test]
362    #[ignore = "require RPC connection"]
363    async fn test_get_storage_snapshots_with_empty_slot(
364        #[values(false, true)] batching: bool,
365    ) -> Result<(), RPCError> {
366        let fixture = TestFixture::new();
367        let extractor = fixture.create_evm_extractor(batching);
368
369        // Try to get a slot that was not initialized / is empty
370        let slots_request: Vec<Bytes> = vec![Bytes::from_str(
371            "0000000000000000000000000000000000000000000000000000000000000002",
372        )
373        .unwrap()];
374
375        let requests =
376            vec![create_storage_request(BALANCER_VAULT_STR, Some(slots_request.clone()))];
377
378        let result = extractor
379            .get_accounts_at_block(&fixture.block, &requests)
380            .await?;
381
382        assert_eq!(result.len(), 1);
383
384        // Check the account delta
385        let address = parse_address(BALANCER_VAULT_STR);
386        let delta = result
387            .get(&address)
388            .expect("address should exist");
389
390        assert_eq!(delta.address, address);
391        assert_eq!(delta.chain, Chain::Ethereum);
392        assert!(delta.code().is_some());
393        assert!(delta.balance.is_some());
394
395        // Check that storage slots match what we requested
396        assert_eq!(delta.slots.len(), 1);
397        assert_eq!(
398            delta
399                .slots
400                .get(&slots_request[0])
401                .unwrap(),
402            &None
403        );
404
405        Ok(())
406    }
407
408    #[rstest]
409    #[traced_test]
410    #[tokio::test]
411    #[ignore = "require RPC connection"]
412    async fn test_get_storage_snapshots_multiple_accounts(
413        #[values(false, true)] batching: bool,
414    ) -> Result<(), RPCError> {
415        let fixture = TestFixture::new();
416        let extractor = fixture.create_evm_extractor(batching);
417
418        // Create multiple requests with different token addresses
419        let requests: Vec<_> = TOKEN_ADDRESSES
420            .iter()
421            .map(|&addr| {
422                create_storage_request(
423                    addr,
424                    Some(vec![Bytes::from_str(
425                        "0000000000000000000000000000000000000000000000000000000000000000",
426                    )
427                    .unwrap()]),
428                )
429            })
430            .collect();
431
432        let start_time = std::time::Instant::now();
433        let result = extractor
434            .get_accounts_at_block(&fixture.block, &requests)
435            .await?;
436        let duration = start_time.elapsed();
437        println!(
438            "Time taken to get storage snapshots for {} accounts: {:?}",
439            requests.len(),
440            duration
441        );
442
443        assert_eq!(result.len(), TOKEN_ADDRESSES.len());
444
445        // Check each account has the required data
446        for addr_str in TOKEN_ADDRESSES.iter() {
447            let address = parse_address(addr_str);
448            let delta = result
449                .get(&address)
450                .expect("address should exist");
451
452            assert_eq!(delta.address, address);
453            assert_eq!(delta.chain, Chain::Ethereum);
454            assert!(delta.code().is_some());
455            assert!(delta.balance.is_some());
456            assert_eq!(delta.slots.len(), 1);
457
458            println!(
459                "Address: {}, Code size: {}, Has balance: {}",
460                addr_str,
461                delta.code().as_ref().unwrap().len(),
462                delta.balance.is_some()
463            );
464        }
465
466        Ok(())
467    }
468}