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