Skip to main content

forest/state_manager/
message_search.rs

1// Copyright 2019-2026 ChainSafe Systems
2// SPDX-License-Identifier: Apache-2.0, MIT
3
4use super::*;
5use crate::blocks::TipsetKey;
6use crate::message::MessageRead as _;
7use ahash::HashSet;
8use parking_lot::RwLock;
9use std::sync::OnceLock;
10use std::time::Duration;
11use tokio_util::sync::CancellationToken;
12
13/// Maximum allowed message confidence.
14const MAX_MESSAGE_CONFIDENCE: ChainEpoch = crate::shim::policy::policy_constants::CHAIN_FINALITY;
15
16/// Checks whether `current` is at least `confidence` epochs past `candidate`.
17fn confidence_reached(current: ChainEpoch, candidate: ChainEpoch, confidence: i64) -> bool {
18    candidate >= 0 && current >= candidate && (current - candidate) >= confidence
19}
20
21impl StateManager {
22    /// Check if tipset had executed the message, by loading the receipt based
23    /// on the index of the message in the block.
24    fn tipset_executed_message(
25        &self,
26        tipset: &Tipset,
27        message: &ChainMessage,
28        allow_replaced: bool,
29    ) -> Result<Option<Receipt>, Error> {
30        if tipset.epoch() == 0 {
31            return Ok(None);
32        }
33        let message_from_address = message.from();
34        let message_sequence = message.sequence();
35        // Load parent state.
36        let pts = self
37            .chain_index()
38            .load_required_tipset(tipset.parents())
39            .map_err(|err| Error::Other(format!("Failed to load tipset: {err}")))?;
40        let messages = self
41            .cs
42            .messages_for_tipset(&pts)
43            .map_err(|err| Error::Other(format!("Failed to load messages for tipset: {err}")))?;
44        messages
45            .iter()
46            .enumerate()
47            // iterate in reverse because we going backwards through the chain
48            .rev()
49            .filter(|(_, s)| {
50                s.sequence() == message_sequence
51                    && s.from() == message_from_address
52                    && s.equal_call(message)
53            })
54            .map(|(index, m)| {
55                // A replacing message is a message with a different CID,
56                // any of Gas values, and different signature, but with all
57                // other parameters matching (source/destination, nonce, params, etc.)
58                if !allow_replaced && message.cid() != m.cid(){
59                    Err(Error::Other(format!(
60                        "found message with equal nonce and call params but different CID. wanted {}, found: {}, nonce: {}, from: {}",
61                        message.cid(),
62                        m.cid(),
63                        message.sequence(),
64                        message.from(),
65                    )))
66                } else {
67                    let block_header = tipset.block_headers().first();
68                    crate::chain::get_parent_receipt(
69                        self.db(),
70                        block_header,
71                        index,
72                    )
73                        .map_err(|err| Error::Other(format!("Failed to get parent receipt (message_receipts={}, index={index}, error={err})", block_header.message_receipts)))
74                }
75            })
76            .next()
77            .unwrap_or(Ok(None))
78    }
79
80    fn check_search_blocking(
81        &self,
82        mut current: Tipset,
83        message: &ChainMessage,
84        lookback_max_epoch: ChainEpoch,
85        allow_replaced: bool,
86        cancellation_token: &CancellationToken,
87    ) -> Result<Option<(Tipset, Receipt)>, Error> {
88        let message_from_address = message.from();
89        let message_sequence = message.sequence();
90        let current_actor_state = self
91            .get_required_actor(&message_from_address, *current.parent_state())
92            .map_err(Error::state)?;
93        // The sender's nonce only grows, so once it is at or below the message
94        // nonce the message cannot have been executed yet. Walking back would
95        // only end at the sender's creation or at pruned state.
96        if current_actor_state.sequence <= message_sequence {
97            return Ok(None);
98        }
99        let message_from_id = self.lookup_required_id(&message_from_address, &current)?;
100
101        while !cancellation_token.is_cancelled() && current.epoch() >= lookback_max_epoch {
102            let parent_tipset = self
103                .chain_index()
104                .load_required_tipset(current.parents())
105                .map_err(|err| {
106                    Error::Other(format!(
107                        "failed to load tipset during msg wait searchback: {err:}"
108                    ))
109                })?;
110
111            let parent_actor_state = self
112                .get_actor(&message_from_id, *parent_tipset.parent_state())
113                .map_err(|e| Error::State(e.to_string()))?;
114
115            match parent_actor_state {
116                // The nonce is still above the message nonce at the parent, so
117                // the message executed strictly earlier; keep walking back.
118                Some(state) if state.sequence > message_sequence => current = parent_tipset,
119                // The nonce crossed the message nonce between the parent and
120                // `current` (or the sender did not exist yet), so only `current`
121                // can have executed the message. No receipt there means a
122                // replacing message was executed instead.
123                _ => {
124                    return Ok(self
125                        .tipset_executed_message(&current, message, allow_replaced)?
126                        .map(|receipt| (current, receipt)));
127                }
128            }
129        }
130
131        Ok(None)
132    }
133
134    /// Searches backwards through the chain for a message receipt.
135    fn search_back_for_message_blocking(
136        &self,
137        current: Tipset,
138        message: &ChainMessage,
139        look_back_limit: Option<ChainEpoch>,
140        allow_replaced: Option<bool>,
141        cancellation_token: &CancellationToken,
142    ) -> Result<Option<(Tipset, Receipt)>, Error> {
143        let current_epoch = current.epoch();
144        let allow_replaced = allow_replaced.unwrap_or(true);
145
146        let Some(max_lookback_epoch_inclusive) =
147            Self::max_lookback_epoch_inclusive(current_epoch, look_back_limit)
148        else {
149            return Ok(None);
150        };
151
152        self.check_search_blocking(
153            current,
154            message,
155            max_lookback_epoch_inclusive,
156            allow_replaced,
157            cancellation_token,
158        )
159    }
160
161    //. Calculates the max lookback epoch (inclusive lower bound) for the search.
162    pub fn max_lookback_epoch_inclusive(
163        current_epoch: ChainEpoch,
164        look_back_limit: Option<ChainEpoch>,
165    ) -> Option<ChainEpoch> {
166        match look_back_limit {
167            // No search: limit = 0 means search 0 epochs
168            Some(0) => None,
169            // Limited search: calculate the inclusive lower bound, clamped to genesis
170            // Example: limit=5 at epoch=1000 → min_epoch=996, searches [996,1000] = 5 epochs
171            // Example: limit=2000 at epoch=1000 → min_epoch=0, searches [0,1000] = 1001 epochs (all available)
172            Some(limit) if limit > 0 => Some((current_epoch - limit + 1).max(0)),
173            // Search all the way to genesis (epoch 0)
174            _ => Some(0),
175        }
176    }
177
178    /// Returns a message receipt from a given tipset and message CID.
179    pub fn get_receipt_blocking(
180        &self,
181        tipset: Tipset,
182        msg: Cid,
183        cancellation_token: &CancellationToken,
184    ) -> Result<Receipt, Error> {
185        let m = crate::chain::get_chain_message(self.db(), &msg)
186            .map_err(|e| Error::Other(e.to_string()))?;
187        let message_receipt = self.tipset_executed_message(&tipset, &m, true)?;
188        if let Some(receipt) = message_receipt {
189            return Ok(receipt);
190        }
191
192        let maybe_tuple =
193            self.search_back_for_message_blocking(tipset, &m, None, None, cancellation_token)?;
194        let message_receipt = maybe_tuple
195            .ok_or_else(|| {
196                Error::Other("Could not get receipt from search back message".to_string())
197            })?
198            .1;
199        Ok(message_receipt)
200    }
201
202    pub async fn wait_for_message_with_timeout(
203        &self,
204        msg_cid: Cid,
205        confidence: i64,
206        look_back_limit: Option<ChainEpoch>,
207        allow_replaced: Option<bool>,
208        timeout: Duration,
209    ) -> Result<(Tipset, Receipt), Error> {
210        let cancellation_token = CancellationToken::new();
211        let _cancellation_token_drop_guard = cancellation_token.drop_guard_ref();
212        tokio::time::timeout(
213            timeout,
214            self.wait_for_message(
215                msg_cid,
216                confidence,
217                look_back_limit,
218                allow_replaced,
219                &cancellation_token,
220            ),
221        )
222        .await
223        .map_err(|_| {
224            Error::other(format!(
225                "wait_for_message timed out after {}",
226                humantime::format_duration(timeout)
227            ))
228        })?
229    }
230
231    /// `WaitForMessage` blocks until a message appears on chain. It looks
232    /// backwards in the chain to see if this has already happened. It
233    /// guarantees that the message has been on chain for at least
234    /// confidence epochs without being reverted before returning.
235    /// Returns an error when cancelled.
236    pub async fn wait_for_message(
237        &self,
238        msg_cid: Cid,
239        confidence: i64,
240        look_back_limit: Option<ChainEpoch>,
241        allow_replaced: Option<bool>,
242        cancellation_token: &CancellationToken,
243    ) -> Result<(Tipset, Receipt), Error> {
244        if confidence > MAX_MESSAGE_CONFIDENCE {
245            return Err(Error::other(format!(
246                "message confidence exceeds maximum: {confidence} > {MAX_MESSAGE_CONFIDENCE}"
247            )));
248        }
249        let message = Arc::new(
250            crate::chain::get_chain_message(self.db(), &msg_cid)
251                .map_err(|err| Error::Other(format!("failed to load message {err:}")))?,
252        );
253        // Subscribe to head changes before sampling the head so that a reorg
254        // between sampling and subscribing cannot be missed. Otherwise a revert
255        // of the sampled head could go unseen and a reverted receipt could be
256        // released after `confidence` epochs.
257        let head_changes_rx = self.cs.subscribe_head_changes();
258        let current_ts = self.heaviest_tipset();
259        let maybe_message_receipt =
260            self.tipset_executed_message(&current_ts, &message, allow_replaced.unwrap_or(true))?;
261        // If the message already executed at the current head, return right
262        // away only when no confidence is required; otherwise seed it as a
263        // candidate for the head-change loop to release after `confidence` epochs.
264        let initial_candidate = match maybe_message_receipt {
265            Some(receipt) if confidence == 0 => return Ok((current_ts, receipt)),
266            Some(receipt) => Some((current_ts.shallow_clone(), receipt)),
267            None => None,
268        };
269
270        // For immediate search back response
271        let (search_back_tx, search_back_rx) = flume::bounded(1);
272        let search_back_candidate: Arc<OnceLock<(Tipset, Receipt)>> = Default::default();
273        let reverted: Arc<RwLock<HashSet<TipsetKey>>> = Arc::new(RwLock::new(HashSet::default()));
274        // Search back task
275        tokio::task::spawn_blocking({
276            let sm = self.shallow_clone();
277            let message = message.shallow_clone();
278            // Cloning tx to avoid all senders being dropped to make `search_back_rx.recv_async()` wait
279            let search_back_tx = search_back_tx.clone();
280            let search_back_candidate = search_back_candidate.shallow_clone();
281            let reverted = reverted.shallow_clone();
282            let cancellation_token = cancellation_token.clone();
283            move || {
284                if let Ok(Some((ts, receipt))) = sm
285                    .search_back_for_message_blocking(
286                        current_ts,
287                        &message,
288                        look_back_limit,
289                        allow_replaced,
290                        &cancellation_token,
291                    )
292                    .inspect_err(|e| {
293                        tracing::warn!("failed to search back for message: {e}");
294                    })
295                    && !reverted.read().contains(ts.key())
296                {
297                    if confidence_reached(sm.heaviest_tipset().epoch(), ts.epoch(), confidence) {
298                        _ = search_back_tx.send((ts, receipt)).inspect_err(|e| {
299                            tracing::warn!("failed to send to search_back_tx: {e}");
300                        });
301                    } else {
302                        _ = search_back_candidate.set((ts, receipt)).inspect_err(|_| {
303                            tracing::warn!("failed to send to set search_back_candidate");
304                        });
305                    }
306                }
307            }
308        });
309
310        // Wait for message to be included in head change.
311        let subscriber_poll = tokio::task::spawn({
312            let cancellation_token = cancellation_token.clone();
313            let search_back_candidate = search_back_candidate.shallow_clone();
314            let reverted = reverted.shallow_clone();
315            let sm = self.shallow_clone();
316            async move {
317                let mut candidate: Option<(Tipset, Receipt)> = initial_candidate;
318                while !cancellation_token.is_cancelled() {
319                    let Ok(head_changes) = head_changes_rx.recv_async().await else {
320                        break;
321                    };
322                    for reverted_ts in head_changes.reverts {
323                        reverted.write().insert(reverted_ts.key().clone());
324
325                        if candidate
326                            .as_ref()
327                            .is_some_and(|(ts, _)| ts.key() == reverted_ts.key())
328                        {
329                            candidate = None;
330                        }
331                    }
332                    for applied_ts in head_changes.applies {
333                        reverted.write().remove(applied_ts.key());
334
335                        // Return if `search_back_candidate` meets confidence requirement
336                        if let Some((candidate_ts, candidate_receipt)) = search_back_candidate.get()
337                            && confidence_reached(
338                                applied_ts.epoch(),
339                                candidate_ts.epoch(),
340                                confidence,
341                            )
342                            && !reverted.read().contains(candidate_ts.key())
343                        {
344                            return Ok((candidate_ts.shallow_clone(), candidate_receipt.clone()));
345                        }
346
347                        // Return if the candidate meets confidence requirement
348                        if let Some((candidate_ts, _)) = &candidate
349                            && confidence_reached(
350                                applied_ts.epoch(),
351                                candidate_ts.epoch(),
352                                confidence,
353                            )
354                            && let Some(candidate) = candidate
355                        {
356                            return Ok(candidate);
357                        }
358
359                        let maybe_receipt = sm.tipset_executed_message(
360                            &applied_ts,
361                            &message,
362                            allow_replaced.unwrap_or(true),
363                        )?;
364                        if let Some(receipt) = maybe_receipt {
365                            if confidence == 0 {
366                                // Return if there's no confidence requirement
367                                return Ok((applied_ts, receipt));
368                            } else {
369                                // Otherwise set it as candidate
370                                candidate = Some((applied_ts, receipt));
371                            }
372                        }
373                    }
374                }
375                Err(Error::other("cancelled"))
376            }
377        });
378
379        // Await on first future to finish.
380        tokio::select! {
381            res = subscriber_poll => {
382                res.context("tokio join error")?
383            }
384            res = search_back_rx.recv_async()  => {
385                Ok(res.context("channel receive error")?)
386            }
387            _ = cancellation_token.cancelled() => {
388                Err(Error::other("cancelled"))
389            }
390        }
391    }
392
393    pub async fn search_for_message(
394        &self,
395        from: Option<Tipset>,
396        msg_cid: Cid,
397        look_back_limit: Option<i64>,
398        allow_replaced: Option<bool>,
399        cancellation_token: &CancellationToken,
400    ) -> Result<Option<(Tipset, Receipt)>, Error> {
401        let from = from.unwrap_or_else(|| self.heaviest_tipset());
402        let message = crate::chain::get_chain_message(self.db(), &msg_cid)
403            .map_err(|err| Error::Other(format!("failed to load message {err}")))?;
404        let maybe_message_receipt =
405            self.tipset_executed_message(&from, &message, allow_replaced.unwrap_or(true))?;
406        if let Some(r) = maybe_message_receipt {
407            Ok(Some((from, r)))
408        } else {
409            tokio::task::spawn_blocking({
410                let this = self.shallow_clone();
411                let cancellation_token = cancellation_token.clone();
412                move || {
413                    this.search_back_for_message_blocking(
414                        from,
415                        &message,
416                        look_back_limit,
417                        allow_replaced,
418                        &cancellation_token,
419                    )
420                }
421            })
422            .await?
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::blocks::{
431        CachingBlockHeader, Chain4U, HeaderBuilder, RawBlockHeader, TxMeta, chain4u,
432    };
433    use crate::chain::ChainStore;
434    use crate::db::MemoryDB;
435    use crate::networks::ChainConfig;
436    use crate::shim::address::Address;
437    use crate::shim::econ::TokenAmount;
438    use crate::shim::message::Message;
439    use crate::shim::state_tree::{ActorState, StateTree, StateTreeVersion};
440    use crate::utils::db::CborStoreExt as _;
441    use fil_actors_shared::fvm_ipld_amt::Amtv0;
442    use fvm_ipld_blockstore::Blockstore;
443    use quickcheck_macros::quickcheck;
444    use rstest::rstest;
445
446    const SENDER: Address = Address::new_id(100);
447
448    #[rstest]
449    #[case(1000, Some(0), None)]
450    #[case(1000, Some(5), Some(996))]
451    #[case(1000, Some(2000), Some(0))]
452    #[case(1000, Some(i64::MAX), Some(0))]
453    #[case(1000, Some(-1), Some(0))]
454    #[case(1000, None, Some(0))]
455    fn max_lookback_epoch_inclusive_examples(
456        #[case] current_epoch: ChainEpoch,
457        #[case] look_back_limit: Option<ChainEpoch>,
458        #[case] expected: Option<ChainEpoch>,
459    ) {
460        assert_eq!(
461            StateManager::max_lookback_epoch_inclusive(current_epoch, look_back_limit),
462            expected
463        );
464    }
465
466    #[quickcheck]
467    fn max_lookback_epoch_inclusive_no_panic(
468        current_epoch: ChainEpoch,
469        look_back_limit: Option<ChainEpoch>,
470    ) -> bool {
471        let current_epoch = current_epoch.max(0);
472        match StateManager::max_lookback_epoch_inclusive(current_epoch, look_back_limit) {
473            Some(min_epoch) => min_epoch >= 0 && min_epoch <= current_epoch.max(0),
474            None => look_back_limit == Some(0),
475        }
476    }
477
478    fn state_root_with_sender_nonce(db: &Arc<MemoryDB>, sequence: u64) -> Cid {
479        let mut state_tree = StateTree::new(db, StateTreeVersion::V5).unwrap();
480        state_tree
481            .set_actor(
482                &SENDER,
483                ActorState::new(
484                    Cid::default(),
485                    Cid::default(),
486                    TokenAmount::default(),
487                    sequence,
488                    None,
489                ),
490            )
491            .unwrap();
492        state_tree.flush().unwrap()
493    }
494
495    fn tx_meta(db: &impl Blockstore, message: Cid) -> Cid {
496        let bls_message_root = Amtv0::new_from_iter(db, [message]).unwrap();
497        let secp_message_root = Amtv0::new_from_iter(db, std::iter::empty::<Cid>()).unwrap();
498        db.put_cbor_default(&TxMeta {
499            bls_message_root,
500            secp_message_root,
501        })
502        .unwrap()
503    }
504
505    fn receipts_root(db: &impl Blockstore) -> Cid {
506        let receipt = fvm_shared4::receipt::Receipt {
507            exit_code: fvm_shared4::error::ExitCode::OK,
508            return_data: Default::default(),
509            gas_used: 0,
510            events_root: None,
511        };
512        Amtv0::new_from_iter(db, [receipt]).unwrap()
513    }
514
515    fn message_with_nonce(sequence: u64) -> Message {
516        Message {
517            from: SENDER,
518            to: Address::new_id(101),
519            sequence,
520            ..Default::default()
521        }
522    }
523
524    fn state_manager_with_replaced_message_at_head(db: &Arc<MemoryDB>) -> (StateManager, Cid) {
525        let message = message_with_nonce(5);
526        let msg_cid = db.put_cbor_default(&message).unwrap();
527        let replacement = Message {
528            gas_limit: 1,
529            ..message
530        };
531        let replacement_cid = db.put_cbor_default(&replacement).unwrap();
532
533        let root_before = state_root_with_sender_nonce(db, 5);
534        let root_after = state_root_with_sender_nonce(db, 6);
535        let messages = tx_meta(db, replacement_cid);
536        let receipts = receipts_root(db);
537        let c4u = Chain4U::with_blockstore(db.clone());
538        chain4u! {
539            in c4u;
540            [genesis = HeaderBuilder::new().with_timestamp(7777)]
541            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
542            -> [_e2 = HeaderBuilder::new()
543                    .with_state_root(root_before)
544                    .with_messages(messages)]
545            -> head @ [_e3 = HeaderBuilder::new()
546                    .with_state_root(root_after)
547                    .with_message_receipts(receipts)]
548        };
549        (state_manager_with_head(db.clone(), genesis, head), msg_cid)
550    }
551
552    fn state_manager_with_head(
553        db: Arc<MemoryDB>,
554        genesis: &RawBlockHeader,
555        head: &Tipset,
556    ) -> StateManager {
557        let chain_store = ChainStore::new(
558            db,
559            Arc::new(ChainConfig::default()),
560            CachingBlockHeader::new(genesis.clone()),
561        )
562        .unwrap();
563        chain_store.set_heaviest_tipset(head.clone()).unwrap();
564        StateManager::new(chain_store).unwrap()
565    }
566
567    /// Chain where the sender's nonce is `actor_nonce` at every epoch and the
568    /// genesis state is unavailable, like state pruned by GC. Searching must
569    /// not walk into the missing state.
570    async fn search_pending(
571        actor_nonce: u64,
572        message_nonce: u64,
573    ) -> Result<Option<(Tipset, Receipt)>, Error> {
574        let db = Arc::new(MemoryDB::default());
575        let root = state_root_with_sender_nonce(&db, actor_nonce);
576        let c4u = Chain4U::with_blockstore(db.clone());
577        chain4u! {
578            in c4u;
579            [genesis = HeaderBuilder::new().with_timestamp(7777)]
580            -> [_e1 = HeaderBuilder::new().with_state_root(root)]
581            -> [_e2 = HeaderBuilder::new().with_state_root(root)]
582            -> head @ [_e3 = HeaderBuilder::new().with_state_root(root)]
583        };
584        let state_manager = state_manager_with_head(db.clone(), genesis, head);
585
586        let message = message_with_nonce(message_nonce);
587        let msg_cid = db.put_cbor_default(&message).unwrap();
588
589        state_manager
590            .search_for_message(None, msg_cid, None, Some(true), &CancellationToken::new())
591            .await
592    }
593
594    #[tokio::test]
595    async fn search_returns_none_for_message_with_future_nonce() {
596        let result = search_pending(5, 10).await.unwrap();
597        assert!(result.is_none());
598    }
599
600    #[tokio::test]
601    async fn search_returns_none_for_pending_message_at_current_nonce() {
602        let result = search_pending(5, 5).await.unwrap();
603        assert!(result.is_none());
604    }
605
606    #[tokio::test]
607    async fn search_returns_none_for_pending_message_from_fresh_sender() {
608        let result = search_pending(0, 0).await.unwrap();
609        assert!(result.is_none());
610    }
611
612    /// The sender's nonce crossed the message nonce, but a replacing message
613    /// with a different call executed instead: the searched message was never
614    /// executed, so the result is `None`, not an error.
615    #[tokio::test]
616    async fn search_returns_none_for_replaced_message() {
617        let db = Arc::new(MemoryDB::default());
618        let message = message_with_nonce(5);
619        let msg_cid = db.put_cbor_default(&message).unwrap();
620
621        let root_before = state_root_with_sender_nonce(&db, 5);
622        let root_after = state_root_with_sender_nonce(&db, 6);
623        let c4u = Chain4U::with_blockstore(db.clone());
624        chain4u! {
625            in c4u;
626            [genesis = HeaderBuilder::new().with_timestamp(7777)]
627            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
628            -> [_e2 = HeaderBuilder::new().with_state_root(root_before)]
629            -> [_e3 = HeaderBuilder::new().with_state_root(root_after)]
630            -> head @ [_e4 = HeaderBuilder::new().with_state_root(root_after)]
631        };
632        let state_manager = state_manager_with_head(db.clone(), genesis, head);
633
634        let result = state_manager
635            .search_for_message(None, msg_cid, None, Some(true), &CancellationToken::new())
636            .await
637            .unwrap();
638        assert!(result.is_none());
639    }
640
641    #[tokio::test]
642    async fn search_finds_executed_message() {
643        let db = Arc::new(MemoryDB::default());
644        let message = message_with_nonce(5);
645        let msg_cid = db.put_cbor_default(&message).unwrap();
646
647        let root_before = state_root_with_sender_nonce(&db, 5);
648        let root_after = state_root_with_sender_nonce(&db, 6);
649        let messages = tx_meta(&db, msg_cid);
650        let receipts = receipts_root(&db);
651        let c4u = Chain4U::with_blockstore(db.clone());
652        chain4u! {
653            in c4u;
654            [genesis = HeaderBuilder::new().with_timestamp(7777)]
655            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
656            -> [_e2 = HeaderBuilder::new()
657                    .with_state_root(root_before)
658                    .with_messages(messages)]
659            -> [_e3 = HeaderBuilder::new()
660                    .with_state_root(root_after)
661                    .with_message_receipts(receipts)]
662            -> head @ [_e4 = HeaderBuilder::new().with_state_root(root_after)]
663        };
664        let state_manager = state_manager_with_head(db.clone(), genesis, head);
665
666        let (tipset, receipt) = state_manager
667            .search_for_message(None, msg_cid, None, Some(true), &CancellationToken::new())
668            .await
669            .unwrap()
670            .expect("executed message should be found");
671        assert_eq!(tipset.epoch(), 3);
672        assert!(receipt.exit_code().is_success());
673    }
674
675    /// Searching from an explicit tipset only covers executions at or below
676    /// it, like in Lotus, even when the message executed later in the chain.
677    #[tokio::test]
678    async fn search_from_older_tipset_ignores_later_execution() {
679        let db = Arc::new(MemoryDB::default());
680        let message = message_with_nonce(5);
681        let msg_cid = db.put_cbor_default(&message).unwrap();
682
683        let root_before = state_root_with_sender_nonce(&db, 5);
684        let root_after = state_root_with_sender_nonce(&db, 6);
685        let messages = tx_meta(&db, msg_cid);
686        let receipts = receipts_root(&db);
687        let c4u = Chain4U::with_blockstore(db.clone());
688        chain4u! {
689            in c4u;
690            [genesis = HeaderBuilder::new().with_timestamp(7777)]
691            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
692            -> from @ [_e2 = HeaderBuilder::new()
693                    .with_state_root(root_before)
694                    .with_messages(messages)]
695            -> [_e3 = HeaderBuilder::new()
696                    .with_state_root(root_after)
697                    .with_message_receipts(receipts)]
698            -> head @ [_e4 = HeaderBuilder::new().with_state_root(root_after)]
699        };
700        let state_manager = state_manager_with_head(db.clone(), genesis, head);
701
702        let result = state_manager
703            .search_for_message(
704                Some(from.clone()),
705                msg_cid,
706                None,
707                Some(true),
708                &CancellationToken::new(),
709            )
710            .await
711            .unwrap();
712        assert!(result.is_none());
713    }
714
715    /// A replacing message executed on chain. When replacements are
716    /// disallowed, `wait_for_message` must forward `allow_replaced = false`
717    /// and surface an error instead of silently returning the replacement's
718    /// receipt.
719    #[tokio::test]
720    async fn wait_for_message_rejects_replaced_when_disallowed() {
721        let db = Arc::new(MemoryDB::default());
722        let (state_manager, msg_cid) = state_manager_with_replaced_message_at_head(&db);
723
724        let result = state_manager
725            .wait_for_message(msg_cid, 0, None, Some(false), &CancellationToken::new())
726            .await;
727        let err = result.expect_err("replaced message should be rejected");
728        assert!(err.to_string().contains("different CID"), "{err}");
729    }
730
731    /// The same replacing message is accepted when replacements are allowed,
732    /// returning the receipt at the head tipset.
733    #[tokio::test]
734    async fn wait_for_message_accepts_replaced_when_allowed() {
735        let db = Arc::new(MemoryDB::default());
736        let (state_manager, msg_cid) = state_manager_with_replaced_message_at_head(&db);
737
738        let (tipset, receipt) = state_manager
739            .wait_for_message(msg_cid, 0, None, Some(true), &CancellationToken::new())
740            .await
741            .expect("replaced message should be accepted");
742        assert_eq!(tipset.epoch(), 3);
743        assert!(receipt.exit_code().is_success());
744    }
745
746    /// The message executed as of the head tipset and no confidence is
747    /// required, so `wait_for_message` returns the head hit immediately.
748    #[tokio::test]
749    async fn wait_for_message_head_hit_returns_immediately_with_zero_confidence() {
750        let db = Arc::new(MemoryDB::default());
751        let message = message_with_nonce(5);
752        let msg_cid = db.put_cbor_default(&message).unwrap();
753
754        let root_before = state_root_with_sender_nonce(&db, 5);
755        let root_after = state_root_with_sender_nonce(&db, 6);
756        let messages = tx_meta(&db, msg_cid);
757        let receipts = receipts_root(&db);
758        let c4u = Chain4U::with_blockstore(db.clone());
759        chain4u! {
760            in c4u;
761            [genesis = HeaderBuilder::new().with_timestamp(7777)]
762            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
763            -> [_e2 = HeaderBuilder::new()
764                    .with_state_root(root_before)
765                    .with_messages(messages)]
766            -> head @ [_e3 = HeaderBuilder::new()
767                    .with_state_root(root_after)
768                    .with_message_receipts(receipts)]
769        };
770        let state_manager = state_manager_with_head(db.clone(), genesis, head);
771
772        let (tipset, receipt) = state_manager
773            .wait_for_message(msg_cid, 0, None, Some(true), &CancellationToken::new())
774            .await
775            .expect("head hit should return immediately with zero confidence");
776        assert_eq!(tipset.epoch(), 3);
777        assert!(receipt.exit_code().is_success());
778    }
779
780    /// The message executed as of the head tipset, but a positive confidence is
781    /// requested and the head never advances, so the head hit must not be
782    /// returned early; the call times out instead.
783    #[tokio::test]
784    async fn wait_for_message_head_hit_waits_for_confidence() {
785        let db = Arc::new(MemoryDB::default());
786        let message = message_with_nonce(5);
787        let msg_cid = db.put_cbor_default(&message).unwrap();
788
789        let root_before = state_root_with_sender_nonce(&db, 5);
790        let root_after = state_root_with_sender_nonce(&db, 6);
791        let messages = tx_meta(&db, msg_cid);
792        let receipts = receipts_root(&db);
793        let c4u = Chain4U::with_blockstore(db.clone());
794        chain4u! {
795            in c4u;
796            [genesis = HeaderBuilder::new().with_timestamp(7777)]
797            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
798            -> [_e2 = HeaderBuilder::new()
799                    .with_state_root(root_before)
800                    .with_messages(messages)]
801            -> head @ [_e3 = HeaderBuilder::new()
802                    .with_state_root(root_after)
803                    .with_message_receipts(receipts)]
804        };
805        let state_manager = state_manager_with_head(db.clone(), genesis, head);
806
807        let result = state_manager
808            .wait_for_message_with_timeout(msg_cid, 2, None, Some(true), Duration::from_millis(300))
809            .await;
810        assert!(result.is_err());
811    }
812
813    /// The message executed as of the head tipset with a positive confidence.
814    /// Once the head advances by `confidence` epochs without a revert, the head
815    /// hit is returned.
816    #[tokio::test]
817    async fn wait_for_message_head_hit_returns_after_confidence_reached() {
818        let db = Arc::new(MemoryDB::default());
819        let message = message_with_nonce(5);
820        let msg_cid = db.put_cbor_default(&message).unwrap();
821
822        let root_before = state_root_with_sender_nonce(&db, 5);
823        let root_after = state_root_with_sender_nonce(&db, 6);
824        let messages = tx_meta(&db, msg_cid);
825        let receipts = receipts_root(&db);
826        let c4u = Chain4U::with_blockstore(db.clone());
827        chain4u! {
828            in c4u;
829            [genesis = HeaderBuilder::new().with_timestamp(7777)]
830            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
831            -> [_e2 = HeaderBuilder::new()
832                    .with_state_root(root_before)
833                    .with_messages(messages)]
834            -> exec @ [_e3 = HeaderBuilder::new()
835                    .with_state_root(root_after)
836                    .with_message_receipts(receipts)]
837            -> next4 @ [_e4 = HeaderBuilder::new().with_state_root(root_after)]
838            -> next5 @ [_e5 = HeaderBuilder::new().with_state_root(root_after)]
839        };
840        let state_manager = state_manager_with_head(db.clone(), genesis, exec);
841
842        let token = CancellationToken::new();
843        let mut wait =
844            Box::pin(state_manager.wait_for_message(msg_cid, 2, None, Some(true), &token));
845
846        // Poll while the head is at epoch 3 to seed the candidate and subscribe,
847        // then advance to epoch 4. Confidence 2 is unmet in both cases, so the
848        // future must stay pending.
849        for advance_to in [None, Some(next4)] {
850            if let Some(ts) = advance_to {
851                state_manager
852                    .chain_store()
853                    .set_heaviest_tipset(ts.clone())
854                    .unwrap();
855            }
856            assert!(
857                tokio::time::timeout(Duration::from_millis(100), &mut wait)
858                    .await
859                    .is_err(),
860                "confidence 2 must not be reached before epoch 5"
861            );
862        }
863
864        // Epoch 5 reaches confidence 2; the future resolves.
865        state_manager
866            .chain_store()
867            .set_heaviest_tipset(next5.clone())
868            .unwrap();
869        let (tipset, receipt) = tokio::time::timeout(Duration::from_secs(5), &mut wait)
870            .await
871            .expect("should resolve once confidence is reached")
872            .expect("head hit should be returned after confidence reached");
873        assert_eq!(tipset.epoch(), 3);
874        assert!(receipt.exit_code().is_success());
875    }
876
877    /// A candidate seeded at the head must not be returned once a reorg reverts that tipset,
878    /// even after the new chain advances past the confidence window.
879    #[tokio::test]
880    async fn wait_for_message_reverted_candidate_is_not_returned() {
881        let db = Arc::new(MemoryDB::default());
882        let message = message_with_nonce(5);
883        let msg_cid = db.put_cbor_default(&message).unwrap();
884
885        let root_before = state_root_with_sender_nonce(&db, 5);
886        let root_after = state_root_with_sender_nonce(&db, 6);
887        let messages = tx_meta(&db, msg_cid);
888        let receipts = receipts_root(&db);
889        let c4u = Chain4U::with_blockstore(db.clone());
890        chain4u! {
891            in c4u;
892            [genesis = HeaderBuilder::new().with_timestamp(7777)]
893            -> [_e1 = HeaderBuilder::new().with_state_root(root_before)]
894            -> [_e2 = HeaderBuilder::new()
895                    .with_state_root(root_before)
896                    .with_messages(messages)]
897            -> exec @ [_e3 = HeaderBuilder::new()
898                    .with_state_root(root_after)
899                    .with_message_receipts(receipts)]
900        };
901        // A competing fork from epoch 1 that never includes the message, advancing to epoch 5.
902        chain4u! {
903            from [_e1] in c4u;
904            [_f2 = HeaderBuilder::new().with_state_root(root_before)]
905            -> [_f3 = HeaderBuilder::new().with_state_root(root_before)]
906            -> [_f4 = HeaderBuilder::new().with_state_root(root_before)]
907            -> fork_head @ [_f5 = HeaderBuilder::new().with_state_root(root_before)]
908        };
909        let state_manager = state_manager_with_head(db.clone(), genesis, exec);
910
911        let token = CancellationToken::new();
912        let mut wait =
913            Box::pin(state_manager.wait_for_message(msg_cid, 2, None, Some(true), &token));
914
915        // Seed the candidate at the head (epoch 3) and subscribe; confidence 2 is unmet.
916        assert!(
917            tokio::time::timeout(Duration::from_millis(100), &mut wait)
918                .await
919                .is_err()
920        );
921
922        // Reorg: exec is reverted onto a fork (epoch 5) that never executed the message.
923        state_manager
924            .chain_store()
925            .set_heaviest_tipset(fork_head.clone())
926            .unwrap();
927
928        // The reverted candidate must not be released even though the fork is past confidence.
929        assert!(
930            tokio::time::timeout(Duration::from_millis(300), &mut wait)
931                .await
932                .is_err(),
933            "a reverted candidate must not be returned"
934        );
935    }
936
937    #[rstest]
938    #[case::zero_confidence(10, 10, 0, true)]
939    #[case::exact_confidence(15, 10, 5, true)]
940    #[case::negative_candidate(0, -1, 1, false)]
941    #[case::insufficient_confidence(14, 10, 5, false)]
942    #[case::candidate_above_current(9, 10, 0, false)]
943    fn confidence_reached_cases(
944        #[case] current: ChainEpoch,
945        #[case] candidate: ChainEpoch,
946        #[case] confidence: i64,
947        #[case] expected: bool,
948    ) {
949        assert_eq!(confidence_reached(current, candidate, confidence), expected);
950    }
951
952    /// `wait_for_message` rejects a confidence above chain finality.
953    #[tokio::test]
954    async fn wait_for_message_rejects_confidence_above_maximum() {
955        let db = Arc::new(MemoryDB::default());
956        let (state_manager, msg_cid) = state_manager_with_replaced_message_at_head(&db);
957
958        let result = state_manager
959            .wait_for_message(
960                msg_cid,
961                MAX_MESSAGE_CONFIDENCE + 1,
962                None,
963                Some(true),
964                &CancellationToken::new(),
965            )
966            .await;
967        let err = result.expect_err("confidence above maximum should be rejected");
968        assert!(
969            err.to_string()
970                .contains("message confidence exceeds maximum"),
971            "{err}"
972        );
973    }
974}