Skip to main content

tycho_common/models/
blockchain.rs

1use std::collections::{hash_map::Entry, BTreeMap, HashMap, HashSet};
2
3use chrono::NaiveDateTime;
4use deepsize::DeepSizeOf;
5use serde::{ser::SerializeStruct, Deserialize, Serialize, Serializer};
6use tracing::warn;
7
8use crate::{
9    dto,
10    models::{
11        contract::{AccountBalance, AccountDelta},
12        protocol::{ComponentBalance, ProtocolComponent, ProtocolComponentStateDelta},
13        token::Token,
14        Address, Balance, BlockHash, Chain, Code, ComponentId, EntryPointId, MergeError, StoreKey,
15        StoreVal,
16    },
17    Bytes,
18};
19
20#[derive(Clone, Default, PartialEq, Serialize, Deserialize, Debug)]
21pub struct Block {
22    pub number: u64,
23    pub chain: Chain,
24    pub hash: Bytes,
25    pub parent_hash: Bytes,
26    pub ts: NaiveDateTime,
27}
28
29impl Block {
30    pub fn new(
31        number: u64,
32        chain: Chain,
33        hash: Bytes,
34        parent_hash: Bytes,
35        ts: NaiveDateTime,
36    ) -> Self {
37        Block { hash, parent_hash, number, chain, ts }
38    }
39}
40
41// Manual impl as `NaiveDateTime` structure referenced in `ts` does not implement DeepSizeOf
42impl DeepSizeOf for Block {
43    fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize {
44        self.chain
45            .deep_size_of_children(context) +
46            self.hash.deep_size_of_children(context) +
47            self.parent_hash
48                .deep_size_of_children(context)
49    }
50}
51
52#[derive(Clone, Default, PartialEq, Debug, Eq, Hash, DeepSizeOf)]
53pub struct Transaction {
54    pub hash: Bytes,
55    pub block_hash: Bytes,
56    pub from: Bytes,
57    pub to: Option<Bytes>,
58    pub index: u64,
59}
60
61impl Transaction {
62    pub fn new(hash: Bytes, block_hash: Bytes, from: Bytes, to: Option<Bytes>, index: u64) -> Self {
63        Transaction { hash, block_hash, from, to, index }
64    }
65}
66
67pub struct BlockTransactionDeltas<T> {
68    pub extractor: String,
69    pub chain: Chain,
70    pub block: Block,
71    pub revert: bool,
72    pub deltas: Vec<TransactionDeltaGroup<T>>,
73}
74
75#[allow(dead_code)]
76pub struct TransactionDeltaGroup<T> {
77    changes: T,
78    protocol_component: HashMap<String, ProtocolComponent>,
79    component_balances: HashMap<String, ComponentBalance>,
80    component_tvl: HashMap<String, f64>,
81    tx: Transaction,
82}
83
84#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, DeepSizeOf)]
85pub struct BlockAggregatedChanges {
86    pub extractor: String,
87    pub chain: Chain,
88    pub block: Block,
89    pub finalized_block_height: u64,
90    pub db_committed_block_height: Option<u64>,
91    pub revert: bool,
92    pub state_deltas: HashMap<String, ProtocolComponentStateDelta>,
93    pub account_deltas: HashMap<Bytes, AccountDelta>,
94    pub new_tokens: HashMap<Address, Token>,
95    pub new_protocol_components: HashMap<String, ProtocolComponent>,
96    pub deleted_protocol_components: HashMap<String, ProtocolComponent>,
97    pub component_balances: HashMap<ComponentId, HashMap<Bytes, ComponentBalance>>,
98    pub account_balances: HashMap<Address, HashMap<Address, AccountBalance>>,
99    pub component_tvl: HashMap<String, f64>,
100    pub dci_update: DCIUpdate,
101    /// The index of the partial block. None if it's a full block.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub partial_block_index: Option<u32>,
104}
105
106impl BlockAggregatedChanges {
107    #[allow(clippy::too_many_arguments)]
108    pub fn new(
109        extractor: &str,
110        chain: Chain,
111        block: Block,
112        db_committed_block_height: Option<u64>,
113        finalized_block_height: u64,
114        revert: bool,
115        state_deltas: HashMap<String, ProtocolComponentStateDelta>,
116        account_deltas: HashMap<Bytes, AccountDelta>,
117        new_tokens: HashMap<Address, Token>,
118        new_components: HashMap<String, ProtocolComponent>,
119        deleted_components: HashMap<String, ProtocolComponent>,
120        component_balances: HashMap<ComponentId, HashMap<Bytes, ComponentBalance>>,
121        account_balances: HashMap<Address, HashMap<Address, AccountBalance>>,
122        component_tvl: HashMap<String, f64>,
123        dci_update: DCIUpdate,
124    ) -> Self {
125        Self {
126            extractor: extractor.to_string(),
127            chain,
128            block,
129            db_committed_block_height,
130            finalized_block_height,
131            revert,
132            state_deltas,
133            account_deltas,
134            new_tokens,
135            new_protocol_components: new_components,
136            deleted_protocol_components: deleted_components,
137            component_balances,
138            account_balances,
139            component_tvl,
140            dci_update,
141            partial_block_index: None,
142        }
143    }
144
145    pub fn drop_state(&self) -> Self {
146        Self {
147            extractor: self.extractor.clone(),
148            chain: self.chain,
149            block: self.block.clone(),
150            db_committed_block_height: self.db_committed_block_height,
151            finalized_block_height: self.finalized_block_height,
152            revert: self.revert,
153            account_deltas: HashMap::new(),
154            state_deltas: HashMap::new(),
155            new_tokens: self.new_tokens.clone(),
156            new_protocol_components: self.new_protocol_components.clone(),
157            deleted_protocol_components: self.deleted_protocol_components.clone(),
158            component_balances: self.component_balances.clone(),
159            account_balances: self.account_balances.clone(),
160            component_tvl: self.component_tvl.clone(),
161            dci_update: self.dci_update.clone(),
162            partial_block_index: self.partial_block_index,
163        }
164    }
165
166    pub fn is_partial(&self) -> bool {
167        self.partial_block_index.is_some()
168    }
169}
170
171impl std::fmt::Display for BlockAggregatedChanges {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        write!(f, "block_number: {}, extractor: {}", self.block.number, self.extractor)
174    }
175}
176
177pub trait BlockScoped {
178    fn block(&self) -> Block;
179}
180
181impl BlockScoped for BlockAggregatedChanges {
182    fn block(&self) -> Block {
183        self.block.clone()
184    }
185}
186
187impl From<dto::Block> for Block {
188    fn from(value: dto::Block) -> Self {
189        Self {
190            number: value.number,
191            chain: value.chain.into(),
192            hash: value.hash,
193            parent_hash: value.parent_hash,
194            ts: value.ts,
195        }
196    }
197}
198
199#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, DeepSizeOf)]
200pub struct DCIUpdate {
201    pub new_entrypoints: HashMap<ComponentId, HashSet<EntryPoint>>,
202    pub new_entrypoint_params: HashMap<EntryPointId, HashSet<(TracingParams, ComponentId)>>,
203    pub trace_results: HashMap<EntryPointId, TracingResult>,
204}
205
206/// Changes grouped by their respective transaction.
207#[derive(Debug, Clone, PartialEq, Default, DeepSizeOf)]
208pub struct TxWithChanges {
209    pub tx: Transaction,
210    pub protocol_components: HashMap<ComponentId, ProtocolComponent>,
211    pub account_deltas: HashMap<Address, AccountDelta>,
212    pub state_updates: HashMap<ComponentId, ProtocolComponentStateDelta>,
213    pub balance_changes: HashMap<ComponentId, HashMap<Address, ComponentBalance>>,
214    pub account_balance_changes: HashMap<Address, HashMap<Address, AccountBalance>>,
215    pub entrypoints: HashMap<ComponentId, HashSet<EntryPoint>>,
216    pub entrypoint_params: HashMap<EntryPointId, HashSet<(TracingParams, ComponentId)>>,
217}
218
219impl TxWithChanges {
220    #[allow(clippy::too_many_arguments)]
221    pub fn new(
222        tx: Transaction,
223        protocol_components: HashMap<ComponentId, ProtocolComponent>,
224        account_deltas: HashMap<Address, AccountDelta>,
225        protocol_states: HashMap<ComponentId, ProtocolComponentStateDelta>,
226        balance_changes: HashMap<ComponentId, HashMap<Address, ComponentBalance>>,
227        account_balance_changes: HashMap<Address, HashMap<Address, AccountBalance>>,
228        entrypoints: HashMap<ComponentId, HashSet<EntryPoint>>,
229        entrypoint_params: HashMap<EntryPointId, HashSet<(TracingParams, ComponentId)>>,
230    ) -> Self {
231        Self {
232            tx,
233            account_deltas,
234            protocol_components,
235            state_updates: protocol_states,
236            balance_changes,
237            account_balance_changes,
238            entrypoints,
239            entrypoint_params,
240        }
241    }
242
243    /// Merges this update with another one.
244    ///
245    /// The method combines two [`TxWithChanges`] instances if they are on the same block.
246    ///
247    /// NB: It is expected that `other` is a more recent update than `self` is and the two are
248    /// combined accordingly.
249    ///
250    /// # Errors
251    /// Returns a `MergeError` if any of the above conditions are violated.
252    pub fn merge(&mut self, other: TxWithChanges) -> Result<(), MergeError> {
253        if self.tx.block_hash != other.tx.block_hash {
254            return Err(MergeError::BlockMismatch(
255                "TxWithChanges".to_string(),
256                self.tx.block_hash.clone(),
257                other.tx.block_hash,
258            ));
259        }
260        if self.tx.index > other.tx.index {
261            return Err(MergeError::TransactionOrderError(
262                "TxWithChanges".to_string(),
263                self.tx.index,
264                other.tx.index,
265            ));
266        }
267
268        self.tx = other.tx;
269
270        // Merge new protocol components
271        // Log a warning if a new protocol component for the same id already exists, because this
272        // should never happen.
273        for (key, value) in other.protocol_components {
274            match self.protocol_components.entry(key) {
275                Entry::Occupied(mut entry) => {
276                    warn!(
277                        "Overwriting new protocol component for id {} with a new one. This should never happen! Please check logic",
278                        entry.get().id
279                    );
280                    entry.insert(value);
281                }
282                Entry::Vacant(entry) => {
283                    entry.insert(value);
284                }
285            }
286        }
287
288        // Merge account deltas
289        for (address, update) in other.account_deltas.clone().into_iter() {
290            match self.account_deltas.entry(address) {
291                Entry::Occupied(mut e) => {
292                    e.get_mut().merge(update)?;
293                }
294                Entry::Vacant(e) => {
295                    e.insert(update);
296                }
297            }
298        }
299
300        // Merge protocol state updates
301        for (key, value) in other.state_updates {
302            match self.state_updates.entry(key) {
303                Entry::Occupied(mut entry) => {
304                    entry.get_mut().merge(value)?;
305                }
306                Entry::Vacant(entry) => {
307                    entry.insert(value);
308                }
309            }
310        }
311
312        // Merge component balance changes
313        for (component_id, balance_changes) in other.balance_changes {
314            let token_balances = self
315                .balance_changes
316                .entry(component_id)
317                .or_default();
318            for (token, balance) in balance_changes {
319                token_balances.insert(token, balance);
320            }
321        }
322
323        // Merge account balance changes
324        for (account_addr, balance_changes) in other.account_balance_changes {
325            let token_balances = self
326                .account_balance_changes
327                .entry(account_addr)
328                .or_default();
329            for (token, balance) in balance_changes {
330                token_balances.insert(token, balance);
331            }
332        }
333
334        // Merge new entrypoints
335        for (component_id, entrypoints) in other.entrypoints {
336            self.entrypoints
337                .entry(component_id)
338                .or_default()
339                .extend(entrypoints);
340        }
341
342        // Merge new entrypoint params
343        for (entrypoint_id, params) in other.entrypoint_params {
344            self.entrypoint_params
345                .entry(entrypoint_id)
346                .or_default()
347                .extend(params);
348        }
349
350        Ok(())
351    }
352}
353
354#[derive(Copy, Clone, Debug, PartialEq)]
355pub enum BlockTag {
356    /// Finalized block
357    Finalized,
358    /// Safe block
359    Safe,
360    /// Latest block
361    Latest,
362    /// Earliest block (genesis)
363    Earliest,
364    /// Pending block (not yet part of the blockchain)
365    Pending,
366    /// Block by number
367    Number(u64),
368}
369#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, DeepSizeOf)]
370pub struct EntryPoint {
371    /// Entry point id
372    pub external_id: String,
373    /// The address of the contract to trace.
374    pub target: Address,
375    /// The signature of the function to trace.
376    pub signature: String,
377}
378
379impl EntryPoint {
380    pub fn new(external_id: String, target: Address, signature: String) -> Self {
381        Self { external_id, target, signature }
382    }
383}
384
385impl From<dto::EntryPoint> for EntryPoint {
386    fn from(value: dto::EntryPoint) -> Self {
387        Self { external_id: value.external_id, target: value.target, signature: value.signature }
388    }
389}
390
391/// A struct that combines an entry point with its associated tracing params.
392#[derive(Debug, Clone, PartialEq, Eq, Hash, DeepSizeOf)]
393pub struct EntryPointWithTracingParams {
394    /// The entry point to trace, containing the target contract address and function signature
395    pub entry_point: EntryPoint,
396    /// The tracing parameters for this entry point
397    pub params: TracingParams,
398}
399
400impl From<dto::EntryPointWithTracingParams> for EntryPointWithTracingParams {
401    fn from(value: dto::EntryPointWithTracingParams) -> Self {
402        match value.params {
403            dto::TracingParams::RPCTracer(ref tracer_params) => Self {
404                entry_point: EntryPoint {
405                    external_id: value.entry_point.external_id,
406                    target: value.entry_point.target,
407                    signature: value.entry_point.signature,
408                },
409                params: TracingParams::RPCTracer(RPCTracerParams {
410                    caller: tracer_params.caller.clone(),
411                    calldata: tracer_params.calldata.clone(),
412                    state_overrides: tracer_params
413                        .state_overrides
414                        .clone()
415                        .map(|s| {
416                            s.into_iter()
417                                .map(|(k, v)| (k, v.into()))
418                                .collect()
419                        }),
420                    prune_addresses: tracer_params.prune_addresses.clone(),
421                }),
422            },
423        }
424    }
425}
426
427impl EntryPointWithTracingParams {
428    pub fn new(entry_point: EntryPoint, params: TracingParams) -> Self {
429        Self { entry_point, params }
430    }
431}
432
433impl std::fmt::Display for EntryPointWithTracingParams {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        let tracer_type = match &self.params {
436            TracingParams::RPCTracer(_) => "RPC",
437        };
438        write!(f, "{} [{}]", self.entry_point.external_id, tracer_type)
439    }
440}
441
442#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash, DeepSizeOf)]
443/// An entry point to trace. Different types of entry points tracing will be supported in the
444/// future. Like RPC debug tracing, symbolic execution, etc.
445pub enum TracingParams {
446    /// Uses RPC calls to retrieve the called addresses and retriggers
447    RPCTracer(RPCTracerParams),
448}
449
450impl std::fmt::Display for TracingParams {
451    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
452        match self {
453            TracingParams::RPCTracer(params) => write!(f, "RPC: {params}"),
454        }
455    }
456}
457
458impl From<dto::TracingParams> for TracingParams {
459    fn from(value: dto::TracingParams) -> Self {
460        match value {
461            dto::TracingParams::RPCTracer(tracer_params) => {
462                TracingParams::RPCTracer(tracer_params.into())
463            }
464        }
465    }
466}
467
468#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash, DeepSizeOf)]
469pub enum StorageOverride {
470    Diff(BTreeMap<StoreKey, StoreVal>),
471    Replace(BTreeMap<StoreKey, StoreVal>),
472}
473
474impl From<dto::StorageOverride> for StorageOverride {
475    fn from(value: dto::StorageOverride) -> Self {
476        match value {
477            dto::StorageOverride::Diff(diff) => StorageOverride::Diff(diff),
478            dto::StorageOverride::Replace(replace) => StorageOverride::Replace(replace),
479        }
480    }
481}
482
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Eq, Hash, DeepSizeOf)]
484pub struct AccountOverrides {
485    pub slots: Option<StorageOverride>,
486    pub native_balance: Option<Balance>,
487    pub code: Option<Code>,
488}
489
490impl From<dto::AccountOverrides> for AccountOverrides {
491    fn from(value: dto::AccountOverrides) -> Self {
492        Self {
493            slots: value.slots.map(|s| s.into()),
494            native_balance: value.native_balance,
495            code: value.code,
496        }
497    }
498}
499
500#[derive(Debug, Clone, PartialEq, Deserialize, Eq, Hash, DeepSizeOf)]
501pub struct RPCTracerParams {
502    /// The caller address of the transaction, if not provided tracing will use the default value
503    /// for an address defined by the VM.
504    pub caller: Option<Address>,
505    /// The call data used for the tracing call, this needs to include the function selector
506    pub calldata: Bytes,
507    /// Optionally allow for state overrides so that the call works as expected
508    pub state_overrides: Option<BTreeMap<Address, AccountOverrides>>,
509    /// Addresses to prune from trace results. Useful for hooks that use mock
510    /// accounts/routers that shouldn't be tracked in the final DCI results.
511    pub prune_addresses: Option<Vec<Address>>,
512}
513
514impl From<dto::RPCTracerParams> for RPCTracerParams {
515    fn from(value: dto::RPCTracerParams) -> Self {
516        Self {
517            caller: value.caller,
518            calldata: value.calldata,
519            state_overrides: value.state_overrides.map(|overrides| {
520                overrides
521                    .into_iter()
522                    .map(|(address, account_overrides)| (address, account_overrides.into()))
523                    .collect()
524            }),
525            prune_addresses: value.prune_addresses,
526        }
527    }
528}
529
530impl RPCTracerParams {
531    pub fn new(caller: Option<Address>, calldata: Bytes) -> Self {
532        Self { caller, calldata, state_overrides: None, prune_addresses: None }
533    }
534
535    pub fn with_state_overrides(mut self, state: BTreeMap<Address, AccountOverrides>) -> Self {
536        self.state_overrides = Some(state);
537        self
538    }
539
540    pub fn with_prune_addresses(mut self, addresses: Vec<Address>) -> Self {
541        self.prune_addresses = Some(addresses);
542        self
543    }
544}
545
546impl std::fmt::Display for RPCTracerParams {
547    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
548        let caller_str = match &self.caller {
549            Some(addr) => format!("caller={addr}"),
550            None => String::new(),
551        };
552
553        let calldata_str = if self.calldata.len() >= 8 {
554            format!(
555                "calldata=0x{}..({} bytes)",
556                hex::encode(&self.calldata[..8]),
557                self.calldata.len()
558            )
559        } else {
560            format!("calldata={}", self.calldata)
561        };
562
563        let overrides_str = match &self.state_overrides {
564            Some(overrides) if !overrides.is_empty() => {
565                format!(", {} state override(s)", overrides.len())
566            }
567            _ => String::new(),
568        };
569
570        write!(f, "{caller_str}, {calldata_str}{overrides_str}")
571    }
572}
573
574// Ensure serialization order, required by the storage layer
575impl Serialize for RPCTracerParams {
576    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
577    where
578        S: Serializer,
579    {
580        // Count fields: always serialize caller and calldata, plus optional fields
581        let mut field_count = 2;
582        if self.state_overrides.is_some() {
583            field_count += 1;
584        }
585        if self.prune_addresses.is_some() {
586            field_count += 1;
587        }
588
589        let mut state = serializer.serialize_struct("RPCTracerEntryPoint", field_count)?;
590        state.serialize_field("caller", &self.caller)?;
591        state.serialize_field("calldata", &self.calldata)?;
592
593        // Only serialize optional fields if they are present
594        if let Some(ref overrides) = self.state_overrides {
595            state.serialize_field("state_overrides", overrides)?;
596        }
597        if let Some(ref prune_addrs) = self.prune_addresses {
598            state.serialize_field("prune_addresses", prune_addrs)?;
599        }
600
601        state.end()
602    }
603}
604
605#[derive(
606    Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, DeepSizeOf,
607)]
608pub struct AddressStorageLocation {
609    pub key: StoreKey,
610    pub offset: u8,
611}
612
613impl AddressStorageLocation {
614    pub fn new(key: StoreKey, offset: u8) -> Self {
615        Self { key, offset }
616    }
617}
618
619#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, DeepSizeOf)]
620pub struct TracingResult {
621    /// A set of (address, storage slot) pairs representing state that contain a called address.
622    /// If any of these storage slots change, the execution path might change.
623    pub retriggers: HashSet<(Address, AddressStorageLocation)>,
624    /// A map of all addresses that were called during the trace with a list of storage slots that
625    /// were accessed.
626    pub accessed_slots: HashMap<Address, HashSet<StoreKey>>,
627}
628
629impl TracingResult {
630    pub fn new(
631        retriggers: HashSet<(Address, AddressStorageLocation)>,
632        accessed_slots: HashMap<Address, HashSet<StoreKey>>,
633    ) -> Self {
634        Self { retriggers, accessed_slots }
635    }
636
637    /// Merges this tracing result with another one.
638    ///
639    /// The method combines two [`TracingResult`] instances.
640    pub fn merge(&mut self, other: TracingResult) {
641        self.retriggers.extend(other.retriggers);
642        for (address, slots) in other.accessed_slots {
643            self.accessed_slots
644                .entry(address)
645                .or_default()
646                .extend(slots);
647        }
648    }
649}
650
651#[derive(Debug, Clone, PartialEq, DeepSizeOf)]
652/// Represents a traced entry point and the results of the tracing operation.
653pub struct TracedEntryPoint {
654    /// The combined entry point and tracing params that was traced
655    pub entry_point_with_params: EntryPointWithTracingParams,
656    /// The block hash of the block that the entry point was traced on.
657    pub detection_block_hash: BlockHash,
658    /// The results of the tracing operation
659    pub tracing_result: TracingResult,
660}
661
662impl TracedEntryPoint {
663    pub fn new(
664        entry_point_with_params: EntryPointWithTracingParams,
665        detection_block_hash: BlockHash,
666        result: TracingResult,
667    ) -> Self {
668        Self { entry_point_with_params, detection_block_hash, tracing_result: result }
669    }
670
671    pub fn entry_point_id(&self) -> String {
672        self.entry_point_with_params
673            .entry_point
674            .external_id
675            .clone()
676    }
677}
678
679impl std::fmt::Display for TracedEntryPoint {
680    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
681        write!(
682            f,
683            "[{}: {} retriggers, {} accessed addresses]",
684            self.entry_point_id(),
685            self.tracing_result.retriggers.len(),
686            self.tracing_result.accessed_slots.len()
687        )
688    }
689}
690
691#[cfg(test)]
692pub mod fixtures {
693    use std::str::FromStr;
694
695    use rstest::rstest;
696
697    use super::*;
698    use crate::models::ChangeType;
699
700    // PERF: duplicated in crate::extractor::models::fixtures — consider a `test-utils`
701    // feature flag to share test fixtures cross-crate.
702    pub fn create_transaction(hash: &str, block: &str, index: u64) -> Transaction {
703        Transaction::new(
704            hash.parse().unwrap(),
705            block.parse().unwrap(),
706            Bytes::zero(20),
707            Some(Bytes::zero(20)),
708            index,
709        )
710    }
711
712    /// Returns a pre-built `TxWithChanges` for testing.
713    ///
714    /// Both indices share the same keys (component `"pool_0"`, token `0xaa..`, contract `0xbb..`)
715    /// but with different values, so "later wins" precedence can be verified across all fields:
716    ///
717    /// - Index 0: tx_index=1, component_balance {token=800, token2=300}, account_balance
718    ///   {token=500, token2=150}, slots {1=>100, 2=>200}, state {"reserve"=>1000, "fee"=>50}, 1
719    ///   entrypoint, ChangeType::Creation
720    /// - Index 1: tx_index=2, component_balance {token=1000}, account_balance {token=700}, slots
721    ///   {1=>300} (overlaps slot 1), state {"reserve"=>2000} (overlaps), 2 entrypoints (superset),
722    ///   ChangeType::Update
723    // PERF: duplicated in crate::extractor::models::fixtures — consider a `test-utils`
724    // feature flag to share test fixtures cross-crate.
725    pub fn tx_with_changes(index: u8) -> TxWithChanges {
726        let token = Bytes::from(vec![0xaa; 20]);
727        let token2 = Bytes::from(vec![0xcc; 20]);
728        let contract = Bytes::from(vec![0xbb; 20]);
729        let c_id = "pool_0".to_string();
730
731        match index {
732            0 => {
733                let tx = create_transaction("0x01", "0x00", 1);
734                TxWithChanges {
735                    tx: tx.clone(),
736                    protocol_components: HashMap::from([(
737                        c_id.clone(),
738                        ProtocolComponent { id: c_id.clone(), ..Default::default() },
739                    )]),
740                    account_deltas: HashMap::from([(
741                        contract.clone(),
742                        AccountDelta::new(
743                            Chain::Ethereum,
744                            contract.clone(),
745                            HashMap::from([
746                                (
747                                    Bytes::from(1u64).lpad(32, 0),
748                                    Some(Bytes::from(100u64).lpad(32, 0)),
749                                ),
750                                (
751                                    Bytes::from(2u64).lpad(32, 0),
752                                    Some(Bytes::from(200u64).lpad(32, 0)),
753                                ),
754                            ]),
755                            None,
756                            Some(Bytes::from(vec![0; 4])),
757                            ChangeType::Creation,
758                        ),
759                    )]),
760                    state_updates: HashMap::from([(
761                        c_id.clone(),
762                        ProtocolComponentStateDelta::new(
763                            &c_id,
764                            HashMap::from([
765                                ("reserve".into(), Bytes::from(1000u64).lpad(32, 0)),
766                                ("fee".into(), Bytes::from(50u64).lpad(32, 0)),
767                            ]),
768                            HashSet::new(),
769                        ),
770                    )]),
771                    balance_changes: HashMap::from([(
772                        c_id.clone(),
773                        HashMap::from([
774                            (
775                                token.clone(),
776                                ComponentBalance {
777                                    token: token.clone(),
778                                    balance: Bytes::from(800_u64).lpad(32, 0),
779                                    balance_float: 800.0,
780                                    component_id: c_id.clone(),
781                                    modify_tx: tx.hash.clone(),
782                                },
783                            ),
784                            (
785                                token2.clone(),
786                                ComponentBalance {
787                                    token: token2.clone(),
788                                    balance: Bytes::from(300_u64).lpad(32, 0),
789                                    balance_float: 300.0,
790                                    component_id: c_id.clone(),
791                                    modify_tx: tx.hash.clone(),
792                                },
793                            ),
794                        ]),
795                    )]),
796                    account_balance_changes: HashMap::from([(
797                        contract.clone(),
798                        HashMap::from([
799                            (
800                                token.clone(),
801                                AccountBalance {
802                                    token: token.clone(),
803                                    balance: Bytes::from(500_u64).lpad(32, 0),
804                                    modify_tx: tx.hash.clone(),
805                                    account: contract.clone(),
806                                },
807                            ),
808                            (
809                                token2,
810                                AccountBalance {
811                                    token: Bytes::from(vec![0xcc; 20]),
812                                    balance: Bytes::from(150_u64).lpad(32, 0),
813                                    modify_tx: tx.hash,
814                                    account: contract,
815                                },
816                            ),
817                        ]),
818                    )]),
819                    entrypoints: HashMap::from([(
820                        c_id.clone(),
821                        HashSet::from([EntryPoint::new(
822                            "ep_0".into(),
823                            Bytes::zero(20),
824                            "fn_a()".into(),
825                        )]),
826                    )]),
827                    entrypoint_params: HashMap::from([(
828                        "ep_0".into(),
829                        HashSet::from([(
830                            TracingParams::RPCTracer(RPCTracerParams::new(
831                                None,
832                                Bytes::from(vec![1]),
833                            )),
834                            c_id,
835                        )]),
836                    )]),
837                }
838            }
839            1 => {
840                let tx = create_transaction("0x02", "0x00", 2);
841                TxWithChanges {
842                    tx: tx.clone(),
843                    protocol_components: HashMap::from([(
844                        c_id.clone(),
845                        ProtocolComponent { id: c_id.clone(), ..Default::default() },
846                    )]),
847                    account_deltas: HashMap::from([(
848                        contract.clone(),
849                        AccountDelta::new(
850                            Chain::Ethereum,
851                            contract.clone(),
852                            HashMap::from([(
853                                Bytes::from(1u64).lpad(32, 0),
854                                Some(Bytes::from(300u64).lpad(32, 0)),
855                            )]),
856                            None,
857                            None,
858                            ChangeType::Update,
859                        ),
860                    )]),
861                    state_updates: HashMap::from([(
862                        c_id.clone(),
863                        ProtocolComponentStateDelta::new(
864                            &c_id,
865                            HashMap::from([("reserve".into(), Bytes::from(2000u64).lpad(32, 0))]),
866                            HashSet::new(),
867                        ),
868                    )]),
869                    balance_changes: HashMap::from([(
870                        c_id.clone(),
871                        HashMap::from([(
872                            token.clone(),
873                            ComponentBalance {
874                                token: token.clone(),
875                                balance: Bytes::from(1000_u64).lpad(32, 0),
876                                balance_float: 1000.0,
877                                component_id: c_id.clone(),
878                                modify_tx: tx.hash.clone(),
879                            },
880                        )]),
881                    )]),
882                    account_balance_changes: HashMap::from([(
883                        contract.clone(),
884                        HashMap::from([(
885                            token.clone(),
886                            AccountBalance {
887                                token: token.clone(),
888                                balance: Bytes::from(700_u64).lpad(32, 0),
889                                modify_tx: tx.hash,
890                                account: contract,
891                            },
892                        )]),
893                    )]),
894                    entrypoints: HashMap::from([(
895                        c_id.clone(),
896                        HashSet::from([
897                            EntryPoint::new("ep_0".into(), Bytes::zero(20), "fn_a()".into()),
898                            EntryPoint::new("ep_1".into(), Bytes::zero(20), "fn_b()".into()),
899                        ]),
900                    )]),
901                    entrypoint_params: HashMap::from([(
902                        "ep_1".into(),
903                        HashSet::from([(
904                            TracingParams::RPCTracer(RPCTracerParams::new(
905                                None,
906                                Bytes::from(vec![2]),
907                            )),
908                            c_id,
909                        )]),
910                    )]),
911                }
912            }
913            _ => panic!("tx_with_changes: index must be 0 or 1, got {index}"),
914        }
915    }
916
917    #[test]
918    fn test_merge_tx_with_changes() {
919        let mut changes1 = tx_with_changes(0);
920        let changes2 = tx_with_changes(1);
921
922        let token = Bytes::from(vec![0xaa; 20]);
923        let contract = Bytes::from(vec![0xbb; 20]);
924        let c_id = "pool_0".to_string();
925
926        assert!(changes1.merge(changes2).is_ok());
927
928        // After merge, balances should reflect changes2 ("later wins")
929        assert_eq!(
930            changes1.balance_changes[&c_id][&token].balance,
931            Bytes::from(1000_u64).lpad(32, 0),
932        );
933        assert_eq!(
934            changes1.account_balance_changes[&contract][&token].balance,
935            Bytes::from(700_u64).lpad(32, 0),
936        );
937        // tx should be updated to changes2's tx
938        assert_eq!(changes1.tx.hash, Bytes::from(vec![2]));
939        // Entrypoints should be merged (union of both)
940        assert_eq!(changes1.entrypoints[&c_id].len(), 2);
941        let mut sigs: Vec<_> = changes1.entrypoints[&c_id]
942            .iter()
943            .map(|ep| ep.signature.clone())
944            .collect();
945        sigs.sort();
946        assert_eq!(sigs, vec!["fn_a()", "fn_b()"]);
947    }
948
949    #[rstest]
950    #[case::mismatched_blocks(
951        fixtures::create_transaction("0x01", "0x0abc", 1),
952        fixtures::create_transaction("0x02", "0x0def", 2)
953    )]
954    #[case::older_transaction(
955        fixtures::create_transaction("0x02", "0x0abc", 2),
956        fixtures::create_transaction("0x01", "0x0abc", 1)
957    )]
958    fn test_merge_errors(#[case] tx1: Transaction, #[case] tx2: Transaction) {
959        let mut changes1 = TxWithChanges { tx: tx1, ..Default::default() };
960
961        let changes2 = TxWithChanges { tx: tx2, ..Default::default() };
962
963        assert!(changes1.merge(changes2).is_err());
964    }
965
966    #[test]
967    fn test_rpc_tracer_entry_point_serialization_order() {
968        use std::str::FromStr;
969
970        use serde_json;
971
972        let entry_point = RPCTracerParams::new(
973            Some(Address::from_str("0x1234567890123456789012345678901234567890").unwrap()),
974            Bytes::from_str("0xabcdef").unwrap(),
975        );
976
977        let serialized = serde_json::to_string(&entry_point).unwrap();
978
979        // Verify that "caller" comes before "calldata" in the serialized output
980        assert!(serialized.find("\"caller\"").unwrap() < serialized.find("\"calldata\"").unwrap());
981
982        // Verify we can deserialize it back
983        let deserialized: RPCTracerParams = serde_json::from_str(&serialized).unwrap();
984        assert_eq!(entry_point, deserialized);
985    }
986
987    #[test]
988    fn test_tracing_result_merge() {
989        let address1 = Address::from_str("0x1234567890123456789012345678901234567890").unwrap();
990        let address2 = Address::from_str("0x2345678901234567890123456789012345678901").unwrap();
991        let address3 = Address::from_str("0x3456789012345678901234567890123456789012").unwrap();
992
993        let store_key1 = StoreKey::from(vec![1, 2, 3, 4]);
994        let store_key2 = StoreKey::from(vec![5, 6, 7, 8]);
995
996        let mut result1 = TracingResult::new(
997            HashSet::from([(
998                address1.clone(),
999                AddressStorageLocation::new(store_key1.clone(), 12),
1000            )]),
1001            HashMap::from([
1002                (address2.clone(), HashSet::from([store_key1.clone()])),
1003                (address3.clone(), HashSet::from([store_key2.clone()])),
1004            ]),
1005        );
1006
1007        let result2 = TracingResult::new(
1008            HashSet::from([(
1009                address3.clone(),
1010                AddressStorageLocation::new(store_key2.clone(), 12),
1011            )]),
1012            HashMap::from([
1013                (address1.clone(), HashSet::from([store_key1.clone()])),
1014                (address2.clone(), HashSet::from([store_key2.clone()])),
1015            ]),
1016        );
1017
1018        result1.merge(result2);
1019
1020        // Verify retriggers were merged
1021        assert_eq!(result1.retriggers.len(), 2);
1022        assert!(result1
1023            .retriggers
1024            .contains(&(address1.clone(), AddressStorageLocation::new(store_key1.clone(), 12))));
1025        assert!(result1
1026            .retriggers
1027            .contains(&(address3.clone(), AddressStorageLocation::new(store_key2.clone(), 12))));
1028
1029        // Verify accessed slots were merged
1030        assert_eq!(result1.accessed_slots.len(), 3);
1031        assert!(result1
1032            .accessed_slots
1033            .contains_key(&address1));
1034        assert!(result1
1035            .accessed_slots
1036            .contains_key(&address2));
1037        assert!(result1
1038            .accessed_slots
1039            .contains_key(&address3));
1040
1041        assert_eq!(
1042            result1
1043                .accessed_slots
1044                .get(&address2)
1045                .unwrap(),
1046            &HashSet::from([store_key1.clone(), store_key2.clone()])
1047        );
1048    }
1049
1050    #[test]
1051    fn test_entry_point_with_tracing_params_display() {
1052        use std::str::FromStr;
1053
1054        let entry_point = EntryPoint::new(
1055            "uniswap_v3_pool_swap".to_string(),
1056            Address::from_str("0x1234567890123456789012345678901234567890").unwrap(),
1057            "swapExactETHForTokens(uint256,address[],address,uint256)".to_string(),
1058        );
1059
1060        let tracing_params = TracingParams::RPCTracer(RPCTracerParams::new(
1061            Some(Address::from_str("0x9876543210987654321098765432109876543210").unwrap()),
1062            Bytes::from_str("0xabcdef").unwrap(),
1063        ));
1064
1065        let entry_point_with_params = EntryPointWithTracingParams::new(entry_point, tracing_params);
1066
1067        let display_output = entry_point_with_params.to_string();
1068        assert_eq!(display_output, "uniswap_v3_pool_swap [RPC]");
1069    }
1070
1071    #[test]
1072    fn test_traced_entry_point_display() {
1073        use std::str::FromStr;
1074
1075        let entry_point = EntryPoint::new(
1076            "uniswap_v3_pool_swap".to_string(),
1077            Address::from_str("0x1234567890123456789012345678901234567890").unwrap(),
1078            "swapExactETHForTokens(uint256,address[],address,uint256)".to_string(),
1079        );
1080
1081        let tracing_params = TracingParams::RPCTracer(RPCTracerParams::new(
1082            Some(Address::from_str("0x9876543210987654321098765432109876543210").unwrap()),
1083            Bytes::from_str("0xabcdef").unwrap(),
1084        ));
1085
1086        let entry_point_with_params = EntryPointWithTracingParams::new(entry_point, tracing_params);
1087
1088        // Create tracing result with 2 retriggers and 3 accessed addresses
1089        let address1 = Address::from_str("0x1111111111111111111111111111111111111111").unwrap();
1090        let address2 = Address::from_str("0x2222222222222222222222222222222222222222").unwrap();
1091        let address3 = Address::from_str("0x3333333333333333333333333333333333333333").unwrap();
1092
1093        let store_key1 = StoreKey::from(vec![1, 2, 3, 4]);
1094        let store_key2 = StoreKey::from(vec![5, 6, 7, 8]);
1095
1096        let tracing_result = TracingResult::new(
1097            HashSet::from([
1098                (address1.clone(), AddressStorageLocation::new(store_key1.clone(), 0)),
1099                (address2.clone(), AddressStorageLocation::new(store_key2.clone(), 12)),
1100            ]),
1101            HashMap::from([
1102                (address1.clone(), HashSet::from([store_key1.clone()])),
1103                (address2.clone(), HashSet::from([store_key2.clone()])),
1104                (address3.clone(), HashSet::from([store_key1.clone()])),
1105            ]),
1106        );
1107
1108        let traced_entry_point = TracedEntryPoint::new(
1109            entry_point_with_params,
1110            Bytes::from_str("0xabcdef1234567890").unwrap(),
1111            tracing_result,
1112        );
1113
1114        let display_output = traced_entry_point.to_string();
1115        assert_eq!(display_output, "[uniswap_v3_pool_swap: 2 retriggers, 3 accessed addresses]");
1116    }
1117}