Skip to main content

tycho_simulation/evm/
pending.rs

1use std::{
2    collections::{HashMap, HashSet},
3    sync::Arc,
4};
5
6use thiserror::Error;
7use tokio::sync::{mpsc::UnboundedReceiver, watch};
8use tycho_client::feed::{synchronizer::Snapshot, BlockHeader, FeedMessage};
9use tycho_common::{
10    models::{
11        blockchain::{Block, BlockAggregatedChanges, DCIUpdate, PendingBlock},
12        protocol::{ComponentBalance, ProtocolComponent, ProtocolComponentStateDelta},
13        Chain,
14    },
15    traits::TxDeltaIndexer,
16    Bytes,
17};
18
19use crate::{
20    evm::decoder::{StreamDecodeError, TychoStreamDecoder},
21    protocol::models::Update,
22};
23
24/// An ephemeral [`Update`] tagged with a caller-supplied label.
25///
26/// The label is an opaque string chosen by the caller to distinguish parallel bundle evaluations
27/// (e.g. bundle ID, strategy name). It is separate from `update.block_number_or_timestamp`,
28/// which carries the target block the bundle was evaluated against.
29pub struct PendingUpdate {
30    pub label: String,
31    pub update: Update,
32}
33
34#[derive(Debug, Error)]
35pub enum PendingError {
36    /// Returned when the parent of `pending.block()` has not been confirmed. Use
37    /// [`subscribe_confirmed_block`](PendingBlockProcessor::subscribe_confirmed_block) to wait
38    /// for the right block before calling.
39    #[error("parent block {needed} not yet confirmed (current: {current})")]
40    ParentNotYetConfirmed { needed: u64, current: u64 },
41    #[error("decoder error: {0}")]
42    Decoder(#[from] StreamDecodeError),
43    #[error("indexer error for extractor '{extractor}': {message}")]
44    Indexer { extractor: String, message: String },
45}
46
47/// Wires one or more [`TxDeltaIndexer`]s to an existing [`TychoStreamDecoder`], enabling
48/// ephemeral simulation of candidate transaction bundles against the correct parent state
49/// for a specific target block.
50///
51/// # Block targeting
52///
53/// Call [`subscribe_confirmed_block`](Self::subscribe_confirmed_block) to obtain a
54/// [`watch::Receiver<u64>`] that fires on every confirmed block. Use it to wait for the
55/// right parent before submitting a bundle:
56///
57/// ```no_run
58/// # async fn example(
59/// #     mut processor: tycho_simulation::evm::pending::PendingBlockProcessor,
60/// #     pending_block: &tycho_common::models::blockchain::PendingBlock,
61/// # ) {
62/// processor
63///     .subscribe_confirmed_block()
64///     .wait_for(|&n| n >= pending_block.block().number - 1)
65///     .await
66///     .expect("stream closed");
67/// let update = processor
68///     .generate_pending_update(pending_block, "bundle-1".to_string())
69///     .await
70///     .expect("pending update failed");
71/// # }
72/// ```
73///
74/// # Concurrency
75///
76/// `PendingBlockProcessor` is intentionally **not** wrapped in a `Mutex` at construction
77/// time. The confirmed stream forwards blocks via an unbounded channel — it never blocks
78/// waiting for the consumer. Multiple callers can each hold a watch receiver and
79/// independently decide when to acquire whatever external lock they use around
80/// `generate_pending_update`.
81pub struct PendingBlockProcessor {
82    indexers: HashMap<String, Box<dyn TxDeltaIndexer>>,
83    decoder: Arc<TychoStreamDecoder<BlockHeader>>,
84    chain: Chain,
85    /// Block number of the most recently confirmed block applied to `indexers`.
86    current_confirmed_block: u64,
87    /// Notified on every `advance_inner` call; drives `subscribe_confirmed_block`.
88    confirmed_block_tx: watch::Sender<u64>,
89    /// Confirmed blocks forwarded by the stream pipeline.
90    block_rx: UnboundedReceiver<FeedMessage<BlockHeader>>,
91}
92
93impl PendingBlockProcessor {
94    pub(crate) fn new(
95        indexers: HashMap<String, Box<dyn TxDeltaIndexer>>,
96        decoder: Arc<TychoStreamDecoder<BlockHeader>>,
97        chain: Chain,
98        block_rx: UnboundedReceiver<FeedMessage<BlockHeader>>,
99    ) -> Self {
100        let (confirmed_block_tx, _) = watch::channel(0u64);
101        Self { indexers, decoder, chain, current_confirmed_block: 0, confirmed_block_tx, block_rx }
102    }
103
104    /// Returns a receiver that is notified with the latest confirmed block number every time
105    /// a new block is applied.
106    ///
107    /// Typical usage: `.wait_for(|&n| n >= target_block - 1).await` before calling
108    /// [`generate_pending_update`](Self::generate_pending_update).
109    pub fn subscribe_confirmed_block(&self) -> watch::Receiver<u64> {
110        self.confirmed_block_tx.subscribe()
111    }
112
113    /// Returns the block number of the last confirmed block applied to the indexers.
114    pub fn current_confirmed_block(&self) -> u64 {
115        self.current_confirmed_block
116    }
117
118    /// Advances each registered indexer by applying one confirmed block.
119    ///
120    /// Only needed when using the processor standalone (without
121    /// [`ProtocolStreamBuilder::build_with_pending`](crate::evm::stream::ProtocolStreamBuilder::build_with_pending)).
122    /// When using `build_with_pending`, confirmed blocks are forwarded automatically.
123    pub fn advance(&mut self, msg: &FeedMessage<BlockHeader>) -> Result<(), PendingError> {
124        self.advance_inner(msg)
125    }
126
127    /// Simulates `pending` against the confirmed parent of `pending.block()`.
128    ///
129    /// Drains any confirmed blocks that have arrived since the last call, then immediately
130    /// checks whether `pending.block().number - 1` is available. If not, returns
131    /// [`PendingError::ParentNotYetConfirmed`] — **no blocking**. Use
132    /// [`subscribe_confirmed_block`](Self::subscribe_confirmed_block) to wait for the right
133    /// block before calling.
134    ///
135    /// Neither the indexers' internal state nor the decoder's confirmed pool states are
136    /// mutated. Calling this twice with the same arguments returns identical results.
137    ///
138    /// # Parameters
139    /// * `pending` — the in-flight block: the block being built, the candidate bundle in execution
140    ///   order (failed transactions are skipped), and post-execution account state for the accounts
141    ///   it touched. The returned deltas use this block's number and timestamp.
142    /// * `label` — opaque caller-supplied tag stamped onto the returned [`PendingUpdate`]. Use it
143    ///   to associate the result with a specific bundle or evaluation context.
144    pub async fn generate_pending_update(
145        &mut self,
146        pending: &PendingBlock,
147        label: String,
148    ) -> Result<PendingUpdate, PendingError> {
149        // Drain any confirmed blocks that have arrived since our last call.
150        while let Ok(msg) = self.block_rx.try_recv() {
151            self.advance_inner(&msg)?;
152        }
153
154        let target_block = pending.block();
155        let parent = target_block.number.saturating_sub(1);
156        if self.current_confirmed_block < parent {
157            return Err(PendingError::ParentNotYetConfirmed {
158                needed: parent,
159                current: self.current_confirmed_block,
160            });
161        }
162        let target_header = BlockHeader::from(target_block);
163
164        let mut pending_deltas: HashMap<String, BlockAggregatedChanges> = HashMap::new();
165        for (extractor, indexer) in &self.indexers {
166            let changes = indexer.generate_deltas(pending);
167            pending_deltas.insert(extractor.clone(), changes);
168        }
169
170        let update = self
171            .decoder
172            .apply_deltas_ephemeral(&pending_deltas, target_header)
173            .await?;
174        Ok(PendingUpdate { label, update })
175    }
176
177    fn advance_inner(&mut self, msg: &FeedMessage<BlockHeader>) -> Result<(), PendingError> {
178        let msg_block = msg
179            .state_msgs
180            .values()
181            .map(|s| s.header.number)
182            .max()
183            .unwrap_or(0);
184
185        for (extractor, state_msg) in &msg.state_msgs {
186            let Some(indexer) = self.indexers.get_mut(extractor) else {
187                continue;
188            };
189
190            if !state_msg.snapshots.states.is_empty() {
191                let block_changes = snapshot_to_block_changes(
192                    extractor,
193                    &state_msg.snapshots,
194                    &state_msg.header,
195                    self.chain,
196                );
197                indexer
198                    .apply_block(&block_changes)
199                    .map_err(|e| PendingError::Indexer {
200                        extractor: extractor.clone(),
201                        message: format!("{e:#}"),
202                    })?;
203            }
204
205            if let Some(deltas) = &state_msg.deltas {
206                indexer
207                    .apply_block(deltas)
208                    .map_err(|e| PendingError::Indexer {
209                        extractor: extractor.clone(),
210                        message: format!("{e:#}"),
211                    })?;
212            }
213        }
214
215        if msg_block > self.current_confirmed_block {
216            self.current_confirmed_block = msg_block;
217            // Receivers that have been dropped are silently ignored.
218            let _ = self.confirmed_block_tx.send(msg_block);
219        }
220        Ok(())
221    }
222}
223
224/// Converts a startup snapshot into a `BlockAggregatedChanges` suitable for
225/// [`TxDeltaIndexer::apply_block`].
226///
227/// Each component's traced entrypoints are folded into `dci_update`, so an indexer sees the
228/// contracts a component reads through DCI the same way it does on the delta path.
229fn snapshot_to_block_changes(
230    extractor: &str,
231    snapshot: &Snapshot,
232    header: &BlockHeader,
233    chain: Chain,
234) -> BlockAggregatedChanges {
235    let ts = chrono::DateTime::from_timestamp(header.timestamp as i64, 0)
236        .unwrap_or_default()
237        .naive_utc();
238    let block = Block {
239        number: header.number,
240        chain,
241        hash: header.hash.clone(),
242        parent_hash: header.parent_hash.clone(),
243        ts,
244    };
245
246    let mut new_protocol_components: HashMap<String, ProtocolComponent> = HashMap::new();
247    let mut state_deltas: HashMap<String, ProtocolComponentStateDelta> = HashMap::new();
248    let mut component_balances: HashMap<String, HashMap<Bytes, ComponentBalance>> = HashMap::new();
249    let mut dci_update = DCIUpdate::default();
250
251    for (id, comp_with_state) in &snapshot.states {
252        new_protocol_components.insert(id.clone(), comp_with_state.component.clone());
253
254        for (entrypoint, trace) in &comp_with_state.entrypoints {
255            let ep_id = entrypoint
256                .entry_point
257                .external_id
258                .clone();
259            dci_update
260                .new_entrypoints
261                .entry(id.clone())
262                .or_default()
263                .insert(entrypoint.entry_point.clone());
264            dci_update
265                .new_entrypoint_params
266                .entry(ep_id.clone())
267                .or_default()
268                .insert((entrypoint.params.clone(), id.clone()));
269            dci_update
270                .trace_results
271                .entry(ep_id)
272                .or_default()
273                .merge(trace.clone());
274        }
275
276        state_deltas.insert(
277            id.clone(),
278            ProtocolComponentStateDelta {
279                component_id: id.clone(),
280                updated_attributes: comp_with_state.state.attributes.clone(),
281                deleted_attributes: HashSet::new(),
282                created_attributes: HashSet::new(),
283            },
284        );
285
286        let token_balances: HashMap<Bytes, ComponentBalance> = comp_with_state
287            .state
288            .balances
289            .iter()
290            .map(|(token, balance)| {
291                (
292                    token.clone(),
293                    ComponentBalance {
294                        token: token.clone(),
295                        balance: balance.clone(),
296                        balance_float: 0.0,
297                        modify_tx: Bytes::default(),
298                        component_id: id.clone(),
299                    },
300                )
301            })
302            .collect();
303        component_balances.insert(id.clone(), token_balances);
304    }
305
306    BlockAggregatedChanges {
307        extractor: extractor.to_string(),
308        chain,
309        block,
310        finalized_block_height: header.number,
311        new_protocol_components,
312        state_deltas,
313        component_balances,
314        dci_update,
315        ..Default::default()
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use std::sync::Mutex;
322
323    use tycho_common::models::blockchain::{Block, PendingBlock};
324
325    use super::*;
326
327    /// Records the block it was handed, so a test can assert what the processor passed down.
328    struct RecordingIndexer {
329        seen: Arc<Mutex<Vec<Block>>>,
330    }
331
332    impl TxDeltaIndexer for RecordingIndexer {
333        fn apply_block(&mut self, _block: &BlockAggregatedChanges) -> anyhow::Result<()> {
334            Ok(())
335        }
336
337        fn generate_deltas(&self, pending: &PendingBlock) -> BlockAggregatedChanges {
338            self.seen
339                .lock()
340                .unwrap()
341                .push(pending.block().clone());
342            BlockAggregatedChanges::default()
343        }
344    }
345
346    fn target_block(number: u64, timestamp: i64) -> Block {
347        Block {
348            number,
349            chain: Chain::Ethereum,
350            hash: Bytes::from([1u8; 32]),
351            parent_hash: Bytes::from([2u8; 32]),
352            ts: chrono::DateTime::from_timestamp(timestamp, 0)
353                .unwrap()
354                .naive_utc(),
355        }
356    }
357
358    fn processor(seen: Arc<Mutex<Vec<Block>>>) -> PendingBlockProcessor {
359        let indexers: HashMap<String, Box<dyn TxDeltaIndexer>> =
360            HashMap::from([("fluid".to_string(), Box::new(RecordingIndexer { seen }) as _)]);
361        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
362        PendingBlockProcessor::new(
363            indexers,
364            Arc::new(TychoStreamDecoder::<BlockHeader>::new(Chain::Ethereum)),
365            Chain::Ethereum,
366            rx,
367        )
368    }
369
370    #[test]
371    fn test_snapshot_entrypoints_land_in_dci_update() {
372        use tycho_client::feed::synchronizer::ComponentWithState;
373        use tycho_common::models::{
374            blockchain::{
375                EntryPoint, EntryPointWithTracingParams, RPCTracerParams, TracingParams,
376                TracingResult,
377            },
378            protocol::ProtocolComponentState,
379        };
380
381        let component_id = "0xpool".to_string();
382        let entry_point = EntryPoint {
383            external_id: "0xpool:get_virtual_price()".to_string(),
384            target: Bytes::from([0xaa; 20]),
385            signature: "get_virtual_price()".to_string(),
386        };
387        let params =
388            TracingParams::RPCTracer(RPCTracerParams::new(None, Bytes::from([0x12, 0x34])));
389        let oracle = Bytes::from([0xbb; 20]);
390        let trace = TracingResult::new(
391            HashSet::new(),
392            HashMap::from([(oracle.clone(), HashSet::from([Bytes::from([0u8; 32])]))]),
393        );
394        let snapshot = Snapshot {
395            states: HashMap::from([(
396                component_id.clone(),
397                ComponentWithState {
398                    state: ProtocolComponentState::new(
399                        &component_id,
400                        HashMap::new(),
401                        HashMap::new(),
402                    ),
403                    component: ProtocolComponent::default(),
404                    component_tvl: None,
405                    entrypoints: vec![(
406                        EntryPointWithTracingParams::new(entry_point.clone(), params.clone()),
407                        trace,
408                    )],
409                },
410            )]),
411            vm_storage: HashMap::new(),
412        };
413        let header = BlockHeader { number: 7, ..Default::default() };
414
415        let changes = snapshot_to_block_changes("vm:curve", &snapshot, &header, Chain::Ethereum);
416
417        let dci = &changes.dci_update;
418        assert_eq!(dci.new_entrypoints[&component_id], HashSet::from([entry_point.clone()]));
419        assert_eq!(
420            dci.new_entrypoint_params[&entry_point.external_id],
421            HashSet::from([(params, component_id)])
422        );
423        assert!(dci.trace_results[&entry_point.external_id]
424            .accessed_slots
425            .contains_key(&oracle));
426    }
427
428    #[tokio::test]
429    async fn test_indexer_receives_the_callers_target_block() {
430        let seen = Arc::new(Mutex::new(Vec::new()));
431        let mut pending_processor = processor(seen.clone());
432        // Parent is block 0, which the processor considers confirmed from the start.
433        let block = target_block(1, 1_759_842_947);
434
435        pending_processor
436            .generate_pending_update(
437                &PendingBlock::new(block.clone(), vec![], HashMap::new()),
438                "bundle-1".to_string(),
439            )
440            .await
441            .expect("pending update failed");
442
443        let seen = seen.lock().unwrap();
444        assert_eq!(
445            seen.as_slice(),
446            [block],
447            "The indexer must be handed the caller's block, not one derived from the parent."
448        );
449    }
450
451    /// A confirmed block carrying no snapshots or deltas: it only advances the tip.
452    fn confirmed_at(number: u64) -> FeedMessage<BlockHeader> {
453        FeedMessage {
454            state_msgs: HashMap::from([(
455                "fluid".to_string(),
456                tycho_client::feed::synchronizer::StateSyncMessage {
457                    header: BlockHeader { number, ..Default::default() },
458                    ..Default::default()
459                },
460            )]),
461            sync_states: HashMap::new(),
462        }
463    }
464
465    /// The header stamped onto every delta must come from the pending block too, not from the
466    /// confirmed tip. Priced against a tip of 5, a target of 3 tells the two apart.
467    #[tokio::test]
468    async fn test_stamped_header_comes_from_the_pending_block() {
469        let seen = Arc::new(Mutex::new(Vec::new()));
470        let mut pending_processor = processor(seen);
471        pending_processor
472            .advance(&confirmed_at(5))
473            .expect("advance failed");
474
475        let update = pending_processor
476            .generate_pending_update(
477                &PendingBlock::new(target_block(3, 1_759_842_947), vec![], HashMap::new()),
478                "bundle-1".to_string(),
479            )
480            .await
481            .expect("pending update failed");
482
483        assert_eq!(
484            update.update.block_number_or_timestamp, 3,
485            "The update must be stamped with the pending block, not the confirmed tip."
486        );
487    }
488
489    #[tokio::test]
490    async fn test_parent_guard_reads_the_pending_blocks_number() {
491        let seen = Arc::new(Mutex::new(Vec::new()));
492        let mut pending_processor = processor(seen.clone());
493        let block = target_block(23_526_115, 1_759_842_947);
494
495        let result = pending_processor
496            .generate_pending_update(
497                &PendingBlock::new(block, vec![], HashMap::new()),
498                "bundle-1".to_string(),
499            )
500            .await;
501
502        match result {
503            Err(PendingError::ParentNotYetConfirmed { needed, current }) => {
504                assert_eq!(needed, 23_526_114);
505                assert_eq!(current, 0);
506            }
507            Err(other) => panic!("expected ParentNotYetConfirmed, got {other:?}"),
508            Ok(_) => panic!("expected ParentNotYetConfirmed, got a successful update"),
509        }
510        assert!(
511            seen.lock().unwrap().is_empty(),
512            "No indexer should run when the parent is not confirmed."
513        );
514    }
515}