Skip to main content

tycho_executor/phase/
bounce.rs

1use anyhow::Result;
2use tycho_types::cell::{Cell, CellBuilder, CellFamily, Lazy, Store};
3use tycho_types::models::{
4    BouncePhase, BounceReason, ExecutedBouncePhase, MsgInfo, NewBounceBody,
5    NewBounceComputePhaseInfo, NewBounceOriginalInfo, NoFundsBouncePhase, StorageUsedShort,
6};
7use tycho_types::num::Tokens;
8
9use crate::ExecutorState;
10use crate::phase::receive::ReceivedMessage;
11use crate::util::{
12    ExtStorageStat, StorageStatLimits, check_rewrite_dst_addr, new_varuint56_truncate,
13};
14
15/// Bounce phase input context.
16pub struct BouncePhaseContext<'a> {
17    /// Gas fees from the compute phase (if any).
18    pub gas_fees: Tokens,
19    /// Action fine from the action phase (if any).
20    pub action_fine: Tokens,
21    /// Received message (internal only).
22    pub received_message: &'a ReceivedMessage,
23    /// Bounce reason.
24    pub reason: BounceReason,
25    /// Brief compute phase info (if any).
26    pub compute_phase_info: Option<NewBounceComputePhaseInfo>,
27}
28
29impl ExecutorState<'_> {
30    /// Bounce phase of ordinary transactions.
31    ///
32    /// - Tries to send an inbound message back to the sender;
33    /// - Defined only for internal inbound messages;
34    /// - Remaining message balance is substracted from the account balance;
35    /// - Fees are paid using the remaining inbound message balance;
36    ///
37    /// Returns an executed [`BouncePhase`].
38    ///
39    /// Fails if the origin workchain of the message doesn't exist or
40    /// disabled. Can also fail on [`total_fees`] overflow, but this should
41    /// not happen on networks with valid value flow.
42    ///
43    /// [`total_fees`]: Self::total_fees
44    pub fn bounce_phase(&mut self, ctx: BouncePhaseContext<'_>) -> Result<BouncePhase> {
45        let mut info = ctx.received_message.root.parse::<MsgInfo>()?;
46        let MsgInfo::Int(int_msg_info) = &mut info else {
47            anyhow::bail!("bounce phase is defined only for internal messages");
48        };
49
50        // Reverse message direction.
51        std::mem::swap(&mut int_msg_info.src, &mut int_msg_info.dst);
52        if !check_rewrite_dst_addr(&self.config.workchains, &mut int_msg_info.dst) {
53            // FIXME: Just ignore this phase in that case? What if we disable
54            // the message origin workchain and this message bounces? However,
55            // for that we should at least have other workchains .
56            anyhow::bail!("invalid destination address in a bounced message");
57        }
58
59        // Compute bounce message body.
60        let mut body = CellBuilder::new();
61        if int_msg_info.extra_flags.is_new_bounce_format() && !self.params.full_body_in_bounced {
62            // Store an extended info into the body.
63            let (bounced_by_phase, exit_code) = ctx.reason.flatten();
64            let (range, cell) = &ctx.received_message.body;
65
66            NewBounceBody {
67                original_body: if int_msg_info.extra_flags.is_full_body_in_bounced() {
68                    if range.is_full(cell) {
69                        cell.clone()
70                    } else {
71                        CellBuilder::build_from(range.apply_allow_exotic(cell))?
72                    }
73                } else {
74                    CellBuilder::build_from(range.apply_allow_exotic(cell).without_references())?
75                },
76                original_info: Lazy::new(&NewBounceOriginalInfo {
77                    // NOTE: We preserve the original value here. Should
78                    // we remove authority marks from it as well?
79                    value: ctx.received_message.balance_remaining.clone(),
80                    created_lt: int_msg_info.created_lt,
81                    created_at: int_msg_info.created_at,
82                })?,
83                bounced_by_phase,
84                exit_code,
85                compute_phase: ctx.compute_phase_info,
86            }
87            .store_into(&mut body, Cell::empty_context())?;
88        } else {
89            const ROOT_BODY_BITS: u16 = 256;
90
91            // Fallback to the old format when `full_body_in_bounced` capability is set,
92            // or the message did not specify the new format.
93            let (range, cell) = &ctx.received_message.body;
94
95            body.store_u32(u32::MAX)?;
96            body.store_slice(range.apply_allow_exotic(cell).get_prefix(ROOT_BODY_BITS, 0))?;
97            if self.params.full_body_in_bounced {
98                body.store_reference(if range.is_full(cell) {
99                    cell.clone()
100                } else {
101                    CellBuilder::build_from(range.apply_allow_exotic(cell))?
102                })?;
103            }
104        };
105
106        // Overwrite msg balance.
107        let mut msg_value = ctx.received_message.balance_remaining.clone();
108
109        // Authority marks cannot be bounced back.
110        if self.params.authority_marks_enabled
111            && let Some(marks) = &self.config.authority_marks
112        {
113            marks.remove_authority_marks_in(&mut msg_value)?;
114        }
115
116        // Compute message storage stats.
117        let stats = 'stats: {
118            let mut stats = ExtStorageStat::with_limits(StorageStatLimits {
119                bit_count: self.config.size_limits.max_msg_bits,
120                cell_count: self.config.size_limits.max_msg_cells,
121            });
122
123            // Root cell is free, but all children must be accounted.
124            'valid: {
125                // Msg value can contain some cells.
126                if let Some(extra_currencies) = msg_value.other.as_dict().root()
127                    && !stats.add_cell(extra_currencies.as_ref())
128                {
129                    break 'valid;
130                }
131                // We must also include all message body cells.
132                for cell in body.references() {
133                    if !stats.add_cell(cell.as_ref()) {
134                        break 'valid;
135                    }
136                }
137                // Exit this block with a valid storage stats info.
138                break 'stats stats.stats();
139            }
140
141            // Fallback to NoFunds if the returned message cannot fit into the limits.
142            // We require an "infinite" amount of tokens here if storage overflows.
143            let stats = stats.stats();
144            return Ok(BouncePhase::NoFunds(NoFundsBouncePhase {
145                msg_size: StorageUsedShort {
146                    bits: new_varuint56_truncate(stats.bit_count),
147                    cells: new_varuint56_truncate(stats.cell_count),
148                },
149                req_fwd_fees: Tokens::MAX,
150            }));
151        };
152
153        // Compute forwarding fee.
154        let use_mc_prices = self.address.is_masterchain() || int_msg_info.dst.is_masterchain();
155        let prices = self.config.fwd_prices(use_mc_prices);
156
157        let mut fwd_fees = prices.compute_fwd_fee(stats);
158        let msg_size = StorageUsedShort {
159            cells: new_varuint56_truncate(stats.cell_count),
160            bits: new_varuint56_truncate(stats.bit_count),
161        };
162
163        // Try to substract all fees from the remaining message balance.
164        msg_value.tokens = match msg_value
165            .tokens
166            .checked_sub(ctx.gas_fees)
167            .and_then(|t| t.checked_sub(ctx.action_fine))
168        {
169            Some(msg_balance) if msg_balance >= fwd_fees => msg_balance,
170            msg_balance => {
171                return Ok(BouncePhase::NoFunds(NoFundsBouncePhase {
172                    msg_size,
173                    req_fwd_fees: fwd_fees - msg_balance.unwrap_or_default(),
174                }));
175            }
176        };
177
178        // Take message balance back from the account balance.
179        self.balance.try_sub_assign(&msg_value)?;
180
181        // Take forwarding fee from the message balance.
182        msg_value.tokens -= fwd_fees;
183
184        // Split forwarding fee.
185        let msg_fees = prices.get_first_part(fwd_fees);
186        fwd_fees -= msg_fees;
187        self.total_fees.try_add_assign(msg_fees)?;
188
189        // Finalize message.
190        int_msg_info.ihr_disabled = true;
191        int_msg_info.bounce = false;
192        int_msg_info.bounced = true;
193        int_msg_info.value = msg_value;
194        int_msg_info.fwd_fee = fwd_fees;
195        int_msg_info.created_lt = self.end_lt;
196        int_msg_info.created_at = self.params.block_unixtime;
197        // NOTE: `int_msg_info.extra_flags` stays the same.
198
199        let msg = {
200            let c = Cell::empty_context();
201            let mut b = CellBuilder::new();
202            info.store_into(&mut b, c)?;
203            b.store_bit_zero()?; // init:(Maybe ...) -> nothing$0
204
205            if b.has_capacity(1 + body.size_bits(), body.size_refs()) {
206                b.store_bit_zero()?; // body:(Either X ^X) -> left$0 X
207                b.store_builder(&body)?;
208            } else {
209                b.store_bit_one()?; // body:(Either X ^X) -> right$1 ^X
210                b.store_reference(body.build()?)?
211            }
212
213            // SAFETY: `b` is an ordinary cell.
214            unsafe { Lazy::from_raw_unchecked(b.build()?) }
215        };
216
217        // Add message to output.
218        self.out_msgs.push(msg);
219        self.end_lt += 1;
220
221        // Done
222        Ok(BouncePhase::Executed(ExecutedBouncePhase {
223            msg_size,
224            msg_fees,
225            fwd_fees,
226        }))
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use std::collections::BTreeMap;
233
234    use tycho_types::cell::CellTreeStats;
235    use tycho_types::models::{
236        AuthorityMarksConfig, ComputePhaseSkipReason, CurrencyCollection, IntMsgInfo,
237        MessageExtraFlags, StdAddr,
238    };
239    use tycho_types::num::VarUint248;
240    use tycho_types::prelude::*;
241
242    use super::*;
243    use crate::ExecutorParams;
244    use crate::tests::{
245        make_custom_config, make_default_config, make_default_params, make_message,
246    };
247
248    #[test]
249    fn bounce_with_enough_funds() {
250        let mut params = make_default_params();
251        params.full_body_in_bounced = false;
252
253        let config = make_default_config();
254
255        let src_addr = StdAddr::new(0, HashBytes([0; 32]));
256        let dst_addr = StdAddr::new(0, HashBytes([1; 32]));
257
258        let gas_fees = Tokens::new(100);
259        let action_fine = Tokens::new(200);
260
261        let mut state =
262            ExecutorState::new_uninit(&params, &config, &dst_addr, Tokens::new(1_000_000_000));
263        state.balance.tokens -= gas_fees;
264        state.balance.tokens -= action_fine;
265        let prev_balance = state.balance.clone();
266        let prev_total_fees = state.total_fees;
267        let prev_start_lt = state.start_lt;
268
269        let int_msg_info = IntMsgInfo {
270            src: src_addr.clone().into(),
271            dst: dst_addr.clone().into(),
272            value: Tokens::new(1_000_000_000).into(),
273            bounce: true,
274            extra_flags: MessageExtraFlags::NEW_BOUNCE_FORMAT,
275            created_lt: prev_start_lt + 1000,
276            ..Default::default()
277        };
278
279        let received_msg = state
280            .receive_in_msg(make_message(int_msg_info.clone(), None, None))
281            .unwrap();
282
283        assert_eq!(state.start_lt, prev_start_lt + 1000 + 1);
284        assert_eq!(state.end_lt, prev_start_lt + 1000 + 2);
285
286        let bounce_phase = state
287            .bounce_phase(BouncePhaseContext {
288                gas_fees,
289                action_fine,
290                received_message: &received_msg,
291                reason: BounceReason::ComputePhaseSkipped(ComputePhaseSkipReason::NoState),
292                compute_phase_info: None,
293            })
294            .unwrap();
295
296        let BouncePhase::Executed(bounce_phase) = bounce_phase else {
297            panic!("expected bounce phase to execute")
298        };
299
300        // There were no extra currencies in the inbound message.
301        assert_eq!(state.out_msgs.len(), 1);
302        let bounced_msg = state.out_msgs.last().unwrap().load().unwrap();
303        assert!(bounced_msg.init.is_none());
304
305        // Only msg fees are collected during the transaction.
306        let expected_fwd_fees = config.fwd_prices.compute_fwd_fee(CellTreeStats {
307            // Original body + NewBounceOriginalInfo ...
308            bit_count: int_msg_info.value.bit_len() as u64 + 64 + 32,
309            // ... that are stored in separate cells.
310            cell_count: 2,
311        });
312
313        let collected_fees = config.fwd_prices.get_first_part(expected_fwd_fees);
314        assert_eq!(state.total_fees, prev_total_fees + collected_fees);
315        assert_eq!(state.total_fees, prev_total_fees + bounce_phase.msg_fees);
316        assert_eq!(bounce_phase.fwd_fees, expected_fwd_fees - collected_fees);
317
318        let mut body_cs = CellSlice::apply(&bounced_msg.body).unwrap();
319        let parsed_body = NewBounceBody::load_from(&mut body_cs).unwrap();
320        assert!(body_cs.is_empty());
321
322        assert_eq!(parsed_body, NewBounceBody {
323            original_body: Cell::empty_cell(),
324            original_info: Lazy::new(&NewBounceOriginalInfo {
325                value: int_msg_info.value,
326                created_lt: int_msg_info.created_lt,
327                created_at: int_msg_info.created_at
328            })
329            .unwrap(),
330            bounced_by_phase: 0,
331            exit_code: -1,
332            compute_phase: None
333        });
334
335        let MsgInfo::Int(bounced_msg_info) = bounced_msg.info else {
336            panic!("expected bounced internal message");
337        };
338        assert_eq!(state.balance.other, prev_balance.other);
339        assert!(bounced_msg_info.value.other.is_empty());
340        assert_eq!(
341            state.balance.tokens,
342            prev_balance.tokens - (received_msg.balance_remaining.tokens - gas_fees - action_fine)
343        );
344        assert_eq!(
345            bounced_msg_info.value.tokens,
346            received_msg.balance_remaining.tokens - gas_fees - action_fine - expected_fwd_fees
347        );
348        assert!(bounced_msg_info.ihr_disabled);
349        assert!(!bounced_msg_info.bounce);
350        assert!(bounced_msg_info.bounced);
351        assert_eq!(bounced_msg_info.src, dst_addr.clone().into());
352        assert_eq!(bounced_msg_info.dst, src_addr.clone().into());
353        assert_eq!(bounced_msg_info.extra_flags, int_msg_info.extra_flags);
354        assert_eq!(bounced_msg_info.fwd_fee, bounce_phase.fwd_fees);
355        assert_eq!(bounced_msg_info.created_at, params.block_unixtime);
356        assert_eq!(bounced_msg_info.created_lt, prev_start_lt + 1000 + 2);
357
358        // End LT must increase.
359        assert_eq!(state.end_lt, prev_start_lt + 1000 + 3);
360    }
361
362    #[test]
363    fn should_bounce_full_body_in_new_format() {
364        let params = make_default_params();
365
366        let config = make_default_config();
367
368        let src_addr = StdAddr::new(0, HashBytes([0; 32]));
369        let dst_addr = StdAddr::new(0, HashBytes([1; 32]));
370
371        let gas_fees = Tokens::new(100);
372        let action_fine = Tokens::new(200);
373
374        let mut state =
375            ExecutorState::new_uninit(&params, &config, &dst_addr, Tokens::new(1_000_000_000));
376        state.balance.tokens -= gas_fees;
377        state.balance.tokens -= action_fine;
378
379        let msg_balance = CurrencyCollection {
380            tokens: Tokens::new(1_000_000_000),
381            other: Default::default(),
382        };
383
384        let extra_flags =
385            MessageExtraFlags::NEW_BOUNCE_FORMAT | MessageExtraFlags::FULL_BODY_IN_BOUNCED;
386
387        let mut cb = CellBuilder::new();
388        cb.store_reference(CellBuilder::new().build().unwrap())
389            .unwrap();
390        cb.store_u32(322).unwrap();
391
392        let received_msg = state
393            .receive_in_msg(make_message(
394                IntMsgInfo {
395                    src: src_addr.clone().into(),
396                    dst: dst_addr.clone().into(),
397                    value: msg_balance.clone(),
398                    bounce: true,
399                    extra_flags,
400                    created_lt: state.start_lt + 1000,
401                    ..Default::default()
402                },
403                None,
404                Some(cb.clone()),
405            ))
406            .unwrap();
407
408        let bounce_phase = state
409            .bounce_phase(BouncePhaseContext {
410                gas_fees,
411                action_fine,
412                received_message: &received_msg,
413                reason: BounceReason::ComputePhaseSkipped(ComputePhaseSkipReason::NoState),
414                compute_phase_info: None,
415            })
416            .unwrap();
417
418        let BouncePhase::Executed(_) = bounce_phase else {
419            panic!("expected bounce phase to execute")
420        };
421        assert_eq!(state.out_msgs.len(), 1);
422        let msg = state.out_msgs.first().unwrap().load().unwrap();
423
424        let mut slice = CellSlice::apply(&msg.body).unwrap();
425        let body = NewBounceBody::load_from(&mut slice).unwrap();
426        assert!(slice.is_empty());
427        assert_eq!(body.original_body, cb.build().unwrap());
428        assert_eq!(body.compute_phase, None);
429        assert_eq!(body.bounced_by_phase, 0);
430        assert_eq!(body.exit_code, -1);
431    }
432
433    #[test]
434    fn bounce_does_not_return_marks() {
435        let params = ExecutorParams {
436            authority_marks_enabled: true,
437            ..make_default_params()
438        };
439        let config = make_custom_config(|config| {
440            config.set_authority_marks_config(&AuthorityMarksConfig {
441                authority_addresses: BTreeMap::from_iter([(HashBytes::ZERO, ())]).try_into()?,
442                black_mark_id: 100,
443                white_mark_id: 101,
444            })?;
445            Ok(())
446        });
447
448        let src_addr = StdAddr::new(0, HashBytes([123; 32]));
449        let dst_addr = StdAddr::new(-1, HashBytes::ZERO);
450
451        let gas_fees = Tokens::new(100);
452        let action_fine = Tokens::new(200);
453
454        let mut state =
455            ExecutorState::new_uninit(&params, &config, &dst_addr, CurrencyCollection {
456                tokens: Tokens::new(1_000_000_000),
457                other: BTreeMap::from_iter([
458                    (100u32, VarUint248::new(1000)), // more black barks than white
459                    (101u32, VarUint248::new(100)),
460                ])
461                .try_into()
462                .unwrap(),
463            });
464        state.balance.tokens -= gas_fees;
465        state.balance.tokens -= action_fine;
466        let prev_balance = state.balance.clone();
467        let prev_total_fees = state.total_fees;
468        let prev_start_lt = state.start_lt;
469
470        let msg_balance = CurrencyCollection {
471            tokens: Tokens::new(1_000_000_000),
472            other: BTreeMap::from_iter([(100u32, VarUint248::new(1))])
473                .try_into()
474                .unwrap(),
475        };
476
477        let received_msg = state
478            .receive_in_msg(make_message(
479                IntMsgInfo {
480                    src: src_addr.clone().into(),
481                    dst: dst_addr.clone().into(),
482                    value: msg_balance.clone(),
483                    bounce: true,
484                    created_lt: prev_start_lt + 1000,
485                    ..Default::default()
486                },
487                None,
488                None,
489            ))
490            .unwrap();
491        assert_eq!(state.start_lt, prev_start_lt + 1000 + 1);
492        assert_eq!(state.end_lt, prev_start_lt + 1000 + 2);
493
494        let credit_phase = state.credit_phase(&received_msg).unwrap();
495        assert_eq!(credit_phase.credit, msg_balance);
496
497        let bounce_phase = state
498            .bounce_phase(BouncePhaseContext {
499                gas_fees,
500                action_fine,
501                received_message: &received_msg,
502                reason: BounceReason::ComputePhaseSkipped(ComputePhaseSkipReason::NoState),
503                compute_phase_info: None,
504            })
505            .unwrap();
506
507        let BouncePhase::Executed(bounce_phase) = bounce_phase else {
508            panic!("expected bounce phase to execute")
509        };
510
511        // Only msg fees are collected during the transaction.
512        let full_fwd_fee = Tokens::new(config.mc_fwd_prices.lump_price as _);
513        let collected_fees = config.mc_fwd_prices.get_first_part(full_fwd_fee);
514        assert_eq!(state.total_fees, prev_total_fees + collected_fees);
515        assert_eq!(state.total_fees, prev_total_fees + bounce_phase.msg_fees);
516        assert_eq!(bounce_phase.fwd_fees, full_fwd_fee - collected_fees);
517
518        // There were no extra currencies in the inbound message.
519        assert_eq!(state.out_msgs.len(), 1);
520        let message = state.out_msgs.last().unwrap();
521        let bounced_msg = message.load().unwrap();
522
523        assert!(bounced_msg.init.is_none());
524        assert_eq!(bounced_msg.body.0.size_bits(), 32);
525        assert_eq!(
526            CellSlice::apply(&bounced_msg.body)
527                .unwrap()
528                .load_u32()
529                .unwrap(),
530            u32::MAX
531        );
532
533        let MsgInfo::Int(bounced_msg_info) = bounced_msg.info else {
534            panic!("expected bounced internal message");
535        };
536        assert_eq!(
537            state.balance.other,
538            prev_balance.other.checked_add(&msg_balance.other).unwrap()
539        );
540        assert!(bounced_msg_info.value.other.is_empty());
541        assert_eq!(
542            state.balance.tokens,
543            prev_balance.tokens + gas_fees + action_fine
544        );
545        assert_eq!(
546            bounced_msg_info.value.tokens,
547            received_msg.balance_remaining.tokens - gas_fees - action_fine - full_fwd_fee
548        );
549        assert!(bounced_msg_info.ihr_disabled);
550        assert!(!bounced_msg_info.bounce);
551        assert!(bounced_msg_info.bounced);
552        assert_eq!(bounced_msg_info.src, dst_addr.clone().into());
553        assert_eq!(bounced_msg_info.dst, src_addr.clone().into());
554        assert_eq!(bounced_msg_info.extra_flags, MessageExtraFlags::empty());
555        assert_eq!(bounced_msg_info.fwd_fee, bounce_phase.fwd_fees);
556        assert_eq!(bounced_msg_info.created_at, params.block_unixtime);
557        assert_eq!(bounced_msg_info.created_lt, prev_start_lt + 1000 + 2);
558
559        // Root cell is free and the bounced message has no child cells.
560        assert_eq!(bounce_phase.msg_size, StorageUsedShort {
561            bits: Default::default(),
562            cells: Default::default()
563        });
564
565        // End LT must increase.
566        assert_eq!(state.end_lt, prev_start_lt + 1000 + 3);
567    }
568
569    #[test]
570    fn bounce_with_no_funds() {
571        let mut params = make_default_params();
572        params.full_body_in_bounced = false;
573
574        let config = make_default_config();
575
576        let src_addr = StdAddr::new(0, HashBytes([0; 32]));
577        let dst_addr = StdAddr::new(0, HashBytes([1; 32]));
578
579        let mut state =
580            ExecutorState::new_uninit(&params, &config, &dst_addr, Tokens::new(1_000_000_001));
581        let prev_balance = state.balance.clone();
582        let prev_total_fees = state.total_fees;
583        let prev_start_lt = state.start_lt;
584
585        let received_msg = state
586            .receive_in_msg(make_message(
587                IntMsgInfo {
588                    src: src_addr.clone().into(),
589                    dst: dst_addr.clone().into(),
590                    value: Tokens::new(1).into(),
591                    bounce: true,
592                    created_lt: prev_start_lt + 1000,
593                    extra_flags: MessageExtraFlags::empty(),
594                    ..Default::default()
595                },
596                None,
597                None,
598            ))
599            .unwrap();
600        assert_eq!(state.start_lt, prev_start_lt + 1000 + 1);
601        assert_eq!(state.end_lt, prev_start_lt + 1000 + 2);
602
603        let bounce_phase = state
604            .bounce_phase(BouncePhaseContext {
605                gas_fees: Tokens::ZERO,
606                action_fine: Tokens::ZERO,
607                received_message: &received_msg,
608                reason: BounceReason::ComputePhaseSkipped(ComputePhaseSkipReason::NoState),
609                compute_phase_info: None,
610            })
611            .unwrap();
612
613        let BouncePhase::NoFunds(bounce_phase) = bounce_phase else {
614            panic!("expected bounce phase to execute")
615        };
616
617        // Balance must not change.
618        assert_eq!(state.balance.other, prev_balance.other);
619        assert_eq!(state.balance.tokens, prev_balance.tokens);
620        assert_eq!(state.total_fees, prev_total_fees);
621
622        // Required fees must be computed correctly.
623        let full_fwd_fee = Tokens::new(config.fwd_prices.lump_price as _);
624        assert_eq!(
625            bounce_phase.req_fwd_fees,
626            full_fwd_fee - received_msg.balance_remaining.tokens
627        );
628
629        // Root cell is free and the bounced message has no child cells.
630        assert_eq!(bounce_phase.msg_size, StorageUsedShort {
631            bits: Default::default(),
632            cells: Default::default()
633        });
634
635        // No messages must be produced.
636        assert_eq!(state.out_msgs.len(), 0);
637
638        // End LT must not change.
639        assert_eq!(state.end_lt, prev_start_lt + 1000 + 2);
640    }
641}