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, 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`].
226fn snapshot_to_block_changes(
227    extractor: &str,
228    snapshot: &Snapshot,
229    header: &BlockHeader,
230    chain: Chain,
231) -> BlockAggregatedChanges {
232    let ts = chrono::DateTime::from_timestamp(header.timestamp as i64, 0)
233        .unwrap_or_default()
234        .naive_utc();
235    let block = Block {
236        number: header.number,
237        chain,
238        hash: header.hash.clone(),
239        parent_hash: header.parent_hash.clone(),
240        ts,
241    };
242
243    let mut new_protocol_components: HashMap<String, ProtocolComponent> = HashMap::new();
244    let mut state_deltas: HashMap<String, ProtocolComponentStateDelta> = HashMap::new();
245    let mut component_balances: HashMap<String, HashMap<Bytes, ComponentBalance>> = HashMap::new();
246
247    for (id, comp_with_state) in &snapshot.states {
248        new_protocol_components.insert(id.clone(), comp_with_state.component.clone());
249
250        state_deltas.insert(
251            id.clone(),
252            ProtocolComponentStateDelta {
253                component_id: id.clone(),
254                updated_attributes: comp_with_state.state.attributes.clone(),
255                deleted_attributes: HashSet::new(),
256                created_attributes: HashSet::new(),
257            },
258        );
259
260        let token_balances: HashMap<Bytes, ComponentBalance> = comp_with_state
261            .state
262            .balances
263            .iter()
264            .map(|(token, balance)| {
265                (
266                    token.clone(),
267                    ComponentBalance {
268                        token: token.clone(),
269                        balance: balance.clone(),
270                        balance_float: 0.0,
271                        modify_tx: Bytes::default(),
272                        component_id: id.clone(),
273                    },
274                )
275            })
276            .collect();
277        component_balances.insert(id.clone(), token_balances);
278    }
279
280    BlockAggregatedChanges {
281        extractor: extractor.to_string(),
282        chain,
283        block,
284        finalized_block_height: header.number,
285        new_protocol_components,
286        state_deltas,
287        component_balances,
288        ..Default::default()
289    }
290}
291
292#[cfg(test)]
293mod tests {
294    use std::sync::Mutex;
295
296    use tycho_common::models::blockchain::{Block, PendingBlock};
297
298    use super::*;
299
300    /// Records the block it was handed, so a test can assert what the processor passed down.
301    struct RecordingIndexer {
302        seen: Arc<Mutex<Vec<Block>>>,
303    }
304
305    impl TxDeltaIndexer for RecordingIndexer {
306        fn apply_block(&mut self, _block: &BlockAggregatedChanges) -> anyhow::Result<()> {
307            Ok(())
308        }
309
310        fn generate_deltas(&self, pending: &PendingBlock) -> BlockAggregatedChanges {
311            self.seen
312                .lock()
313                .unwrap()
314                .push(pending.block().clone());
315            BlockAggregatedChanges::default()
316        }
317    }
318
319    fn target_block(number: u64, timestamp: i64) -> Block {
320        Block {
321            number,
322            chain: Chain::Ethereum,
323            hash: Bytes::from([1u8; 32]),
324            parent_hash: Bytes::from([2u8; 32]),
325            ts: chrono::DateTime::from_timestamp(timestamp, 0)
326                .unwrap()
327                .naive_utc(),
328        }
329    }
330
331    fn processor(seen: Arc<Mutex<Vec<Block>>>) -> PendingBlockProcessor {
332        let indexers: HashMap<String, Box<dyn TxDeltaIndexer>> =
333            HashMap::from([("fluid".to_string(), Box::new(RecordingIndexer { seen }) as _)]);
334        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
335        PendingBlockProcessor::new(
336            indexers,
337            Arc::new(TychoStreamDecoder::<BlockHeader>::new()),
338            Chain::Ethereum,
339            rx,
340        )
341    }
342
343    #[tokio::test]
344    async fn test_indexer_receives_the_callers_target_block() {
345        let seen = Arc::new(Mutex::new(Vec::new()));
346        let mut pending_processor = processor(seen.clone());
347        // Parent is block 0, which the processor considers confirmed from the start.
348        let block = target_block(1, 1_759_842_947);
349
350        pending_processor
351            .generate_pending_update(
352                &PendingBlock::new(block.clone(), vec![], HashMap::new()),
353                "bundle-1".to_string(),
354            )
355            .await
356            .expect("pending update failed");
357
358        let seen = seen.lock().unwrap();
359        assert_eq!(
360            seen.as_slice(),
361            [block],
362            "The indexer must be handed the caller's block, not one derived from the parent."
363        );
364    }
365
366    /// A confirmed block carrying no snapshots or deltas: it only advances the tip.
367    fn confirmed_at(number: u64) -> FeedMessage<BlockHeader> {
368        FeedMessage {
369            state_msgs: HashMap::from([(
370                "fluid".to_string(),
371                tycho_client::feed::synchronizer::StateSyncMessage {
372                    header: BlockHeader { number, ..Default::default() },
373                    ..Default::default()
374                },
375            )]),
376            sync_states: HashMap::new(),
377        }
378    }
379
380    /// The header stamped onto every delta must come from the pending block too, not from the
381    /// confirmed tip. Priced against a tip of 5, a target of 3 tells the two apart.
382    #[tokio::test]
383    async fn test_stamped_header_comes_from_the_pending_block() {
384        let seen = Arc::new(Mutex::new(Vec::new()));
385        let mut pending_processor = processor(seen);
386        pending_processor
387            .advance(&confirmed_at(5))
388            .expect("advance failed");
389
390        let update = pending_processor
391            .generate_pending_update(
392                &PendingBlock::new(target_block(3, 1_759_842_947), vec![], HashMap::new()),
393                "bundle-1".to_string(),
394            )
395            .await
396            .expect("pending update failed");
397
398        assert_eq!(
399            update.update.block_number_or_timestamp, 3,
400            "The update must be stamped with the pending block, not the confirmed tip."
401        );
402    }
403
404    #[tokio::test]
405    async fn test_parent_guard_reads_the_pending_blocks_number() {
406        let seen = Arc::new(Mutex::new(Vec::new()));
407        let mut pending_processor = processor(seen.clone());
408        let block = target_block(23_526_115, 1_759_842_947);
409
410        let result = pending_processor
411            .generate_pending_update(
412                &PendingBlock::new(block, vec![], HashMap::new()),
413                "bundle-1".to_string(),
414            )
415            .await;
416
417        match result {
418            Err(PendingError::ParentNotYetConfirmed { needed, current }) => {
419                assert_eq!(needed, 23_526_114);
420                assert_eq!(current, 0);
421            }
422            Err(other) => panic!("expected ParentNotYetConfirmed, got {other:?}"),
423            Ok(_) => panic!("expected ParentNotYetConfirmed, got a successful update"),
424        }
425        assert!(
426            seen.lock().unwrap().is_empty(),
427            "No indexer should run when the parent is not confirmed."
428        );
429    }
430}