Skip to main content

revmc_codegen/bytecode/passes/
dedup.rs

1//! Block deduplication pass.
2//!
3//! Identifies structurally identical non-fallthrough blocks (same opcode + immediate sequence)
4//! and eliminates duplicates by marking them as dead code and redirecting predecessors to a
5//! single canonical copy.
6
7use super::block_analysis::{Block, BlockData, Snapshots};
8use crate::bytecode::{Bytecode, Inst, InstData, InstFlags};
9use alloy_primitives::map::HashMap;
10use oxc_index::IndexVec;
11use revm_bytecode::opcode as op;
12use smallvec::SmallVec;
13
14/// Dedup key: instruction-level structural fingerprint, CFG successor blocks, and terminator
15/// jump kind.
16///
17/// The fingerprint encodes each instruction's opcode plus its (interned) immediate so that
18/// PUSH operations with the same value, and DUPN/SWAPN/EXCHANGE with the same operand, hash
19/// equal across blocks. Raw bytes are intentionally not consulted — all immediate data is
20/// pre-interned at parse time on `InstData`.
21///
22/// Structural equality alone is insufficient for JUMP-terminated blocks because block
23/// analysis may resolve identical copies to different static targets depending on incoming
24/// stack context (e.g. different return-address values), so successors are part of the key.
25///
26/// The `is_multi_jump` discriminator prevents MULTI_JUMP dispatcher blocks from colliding
27/// with STATIC_JUMP blocks that happen to share the same fingerprint and successor set.
28#[derive(Clone, PartialEq, Eq, Hash)]
29struct DedupKey {
30    /// Instruction fingerprint: per-instruction `(opcode, immediate)` tuples encoded as bytes.
31    fingerprint: SmallVec<[u8; 32]>,
32    /// CFG successors for this block. Two structurally identical blocks are only merged when
33    /// their successor sets also match.
34    succs: SmallVec<[Block; 4]>,
35    /// Whether the block's terminator is a MULTI_JUMP.
36    is_multi_jump: bool,
37}
38
39impl<'a> Bytecode<'a> {
40    /// Deduplicate structurally identical non-fallthrough blocks.
41    ///
42    /// Eligible blocks are those whose terminator cannot fall through (diverging instructions
43    /// like REVERT/STOP/RETURN, or unconditional JUMP). For every group of byte-identical
44    /// blocks, keeps one canonical copy while marking them as dead code.
45    /// A redirect entry is stored in [`Bytecode::redirects`] so the translator can map
46    /// dead instructions to the canonical block's IR block. The CFG is rebuilt after each
47    /// iteration so successor/predecessor lists stay consistent.
48    ///
49    /// `local_snapshots` are the block-local snapshots computed by `block_analysis_local`
50    /// (before the global fixpoint). After merging, the canonical block's snapshots are
51    /// restored to these local values because the global snapshots may be context-specific
52    /// and stale once multiple predecessors are merged.
53    #[instrument(name = "dedup", level = "debug", skip_all)]
54    pub(crate) fn dedup_blocks(&mut self, local_snapshots: &Snapshots) {
55        let mut key_to_blocks = HashMap::<DedupKey, SmallVec<[Block; 4]>>::default();
56        let mut total_deduped = 0usize;
57        loop {
58            key_to_blocks.clear();
59            let deduped = self.dedup_blocks_once(&mut key_to_blocks, local_snapshots);
60            total_deduped += deduped;
61            if deduped == 0 {
62                break;
63            }
64            self.refresh_cfg();
65        }
66        debug!(deduped = total_deduped, "finished");
67    }
68
69    /// Single dedup iteration. Returns the number of blocks deduped.
70    fn dedup_blocks_once(
71        &mut self,
72        key_to_blocks: &mut HashMap<DedupKey, SmallVec<[Block; 4]>>,
73        local_snapshots: &Snapshots,
74    ) -> usize {
75        for bid in self.cfg.blocks.indices() {
76            let block = &self.cfg.blocks[bid];
77            let term = &self.insts[block.terminator()];
78
79            // Only dedup blocks whose terminator cannot fall through (diverging or
80            // unconditional JUMP). JUMPI blocks fall through to position-dependent targets.
81            if term.can_fall_through() {
82                continue;
83            }
84
85            // Reachable JUMPDESTs with dynamic jumps cannot be deduped because any
86            // JUMPDEST could be reached from an unresolved dynamic JUMP with an
87            // arbitrary stack context.
88            let first = &self.insts[block.insts.start];
89            if self.has_dynamic_jumps && first.is_reachable_jumpdest(true) {
90                continue;
91            }
92
93            // Skip blocks containing PC — it returns the current program counter,
94            // so identical bytecode at different positions produces different results.
95            if block.insts().any(|i| self.insts[i].opcode == op::PC) {
96                continue;
97            }
98
99            // Skip unresolved dynamic jumps — any target is possible at runtime.
100            if term.is_jump() && !term.flags.contains(InstFlags::STATIC_JUMP) {
101                continue;
102            }
103
104            let fingerprint = block_fingerprint(&self.insts, block);
105            if fingerprint.is_empty() {
106                continue;
107            }
108
109            let is_multi_jump = term.flags.contains(InstFlags::MULTI_JUMP);
110            key_to_blocks
111                .entry(DedupKey { fingerprint, succs: block.succs.clone(), is_multi_jump })
112                .or_default()
113                .push(bid);
114        }
115
116        let mut deduped = 0usize;
117        for group in key_to_blocks.values() {
118            if group.len() < 2 {
119                continue;
120            }
121            let (&canonical, dups) = group.split_first().unwrap();
122            let canonical_first_inst = self.cfg.blocks[canonical].insts.start;
123
124            // Restore block-local snapshots for the canonical block. The global
125            // fixpoint may have recorded context-specific constants that become stale
126            // once the canonical block serves multiple predecessors after merging.
127            // Block-local constants (computed without incoming stack state) remain valid.
128            self.snapshots.restore_from(self.cfg.blocks[canonical].insts(), local_snapshots);
129
130            for &dup in dups {
131                deduped += 1;
132                trace!("deduped: {from} -> {to}", from = dup, to = canonical);
133
134                // Mark all instructions in the duplicate block as dead.
135                let dup_block = &self.cfg.blocks[dup];
136                for i in dup_block.insts() {
137                    self.insts[i].flags |= InstFlags::DEAD_CODE;
138                }
139
140                let dup_first_inst = dup_block.insts.start;
141                self.redirects.insert(dup_first_inst, canonical_first_inst);
142
143                // If the duplicate was a reachable JUMPDEST, the canonical must be too
144                // so that sections/gas-checks treat it as a jump target entry point.
145                if self.insts[dup_first_inst].is_jumpdest()
146                    && self.insts[dup_first_inst].is_jumpdest_reachable()
147                    && self.insts[canonical_first_inst].is_jumpdest()
148                {
149                    self.insts[canonical_first_inst].set_jumpdest_reachable();
150                }
151
152                // Merge multi-jump targets from the duplicate into the canonical block.
153                // Without this, valid case PCs unique to the duplicate dispatcher would
154                // be lost and fall into the default InvalidJump path at runtime.
155                let canonical_term = self.cfg.blocks[canonical].terminator();
156                let dup_term = dup_block.terminator();
157                if self.insts[dup_term].flags.contains(InstFlags::MULTI_JUMP)
158                    && let Some(dup_targets) = self.multi_jump_targets.remove(&dup_term)
159                {
160                    let canonical_targets =
161                        self.multi_jump_targets.get_mut(&canonical_term).unwrap();
162                    for t in dup_targets {
163                        if !canonical_targets.contains(&t) {
164                            canonical_targets.push(t);
165                        }
166                    }
167                }
168
169                // Redirect predecessors that reach the duplicate via static jumps.
170                for &pred in &dup_block.preds {
171                    let term_inst = self.cfg.blocks[pred].terminator();
172                    let term = &self.insts[term_inst];
173                    let is_static = term.is_static_jump()
174                        && !term.flags.contains(InstFlags::INVALID_JUMP)
175                        && !term.flags.contains(InstFlags::MULTI_JUMP)
176                        && term.static_jump_target() == dup_first_inst;
177                    if is_static {
178                        self.insts[term_inst].set_static_jump_target(canonical_first_inst);
179                    }
180                    // Multi-jump target PCs are not rewritten here: each carries a
181                    // distinct PC that callers push as a return address. The targets
182                    // were already merged into the canonical block above, and
183                    // Translator callsites resolve redirects to map dead
184                    // instruction targets to the canonical IR entry.
185                }
186            }
187        }
188
189        deduped
190    }
191}
192
193/// Builds a structural fingerprint of a block from instruction data, without consulting the
194/// raw bytecode.
195///
196/// For each non-dead instruction in the block, encodes the opcode followed by the immediate
197/// payload (interned U256Idx for `PUSH*`, immediate byte for `DUPN`/`SWAPN`/`EXCHANGE`).
198/// JUMP/JUMPI carry no immediate and so contribute only their opcode; jump targets are
199/// already factored into the surrounding `DedupKey` via the block's CFG successors.
200fn block_fingerprint(insts: &IndexVec<Inst, InstData>, block: &BlockData) -> SmallVec<[u8; 32]> {
201    let mut buf: SmallVec<[u8; 32]> = SmallVec::new();
202    for i in block.insts() {
203        let data = &insts[i];
204        buf.push(data.opcode);
205        if data.imm_len() > 0 {
206            let mut imm = &data.data.to_ne_bytes()[..];
207            while let [0, rest @ ..] = imm {
208                imm = rest;
209            }
210            buf.extend_from_slice(imm);
211        }
212    }
213    buf
214}
215
216#[cfg(test)]
217mod tests {
218    use crate::bytecode::{
219        AnalysisConfig, InstFlags, passes::block_analysis::tests::analyze_asm_with,
220    };
221    use revm_bytecode::opcode as op;
222
223    #[test]
224    fn dedup_identical_revert_blocks() {
225        // Two JUMPI branches each fall through to an identical `PUSH1 0x00 / DUP1 / REVERT`.
226        let bytecode = analyze_asm_with(
227            "
228            PUSH0
229            CALLDATALOAD
230            PUSH %bb2
231            JUMPI
232            ; revert A
233            PUSH1 0x00
234            DUP1
235            REVERT
236        bb2:
237            JUMPDEST
238            PUSH0
239            CALLDATALOAD
240            PUSH %bb4
241            JUMPI
242            ; revert B (identical to A)
243            PUSH1 0x00
244            DUP1
245            REVERT
246        bb4:
247            JUMPDEST
248            STOP
249        ",
250            AnalysisConfig::DEDUP,
251        );
252
253        // One of the two `PUSH1 0x00 / DUP1 / REVERT` blocks should be redirected.
254        assert_eq!(
255            bytecode.redirects.len(),
256            1,
257            "expected exactly 1 redirect for 2 identical revert blocks",
258        );
259    }
260
261    #[test]
262    fn dedup_preserves_unique_blocks() {
263        // Two different diverging blocks — they should NOT be deduplicated.
264        let bytecode = analyze_asm_with(
265            "
266            PUSH0
267            CALLDATALOAD
268            PUSH %target
269            JUMPI
270            ; revert with 0
271            PUSH1 0x00
272            DUP1
273            REVERT
274        target:
275            JUMPDEST
276            STOP
277        ",
278            AnalysisConfig::DEDUP,
279        );
280
281        assert!(bytecode.redirects.is_empty(), "should not dedup different blocks");
282    }
283
284    #[test]
285    fn dedup_three_identical_blocks() {
286        // Three identical `PUSH1 0x00 / DUP1 / REVERT` blocks.
287        let bytecode = analyze_asm_with(
288            "
289            PUSH0
290            CALLDATALOAD
291            PUSH %bb2
292            JUMPI
293            ; revert A
294            PUSH1 0x00
295            DUP1
296            REVERT
297        bb2:
298            JUMPDEST
299            PUSH0
300            CALLDATALOAD
301            PUSH %bb4
302            JUMPI
303            ; revert B
304            PUSH1 0x00
305            DUP1
306            REVERT
307        bb4:
308            JUMPDEST
309            PUSH0
310            CALLDATALOAD
311            PUSH %bb6
312            JUMPI
313            ; revert C
314            PUSH1 0x00
315            DUP1
316            REVERT
317        bb6:
318            JUMPDEST
319            STOP
320        ",
321            AnalysisConfig::DEDUP,
322        );
323
324        let redirect_count = bytecode.redirects.len();
325        assert_eq!(redirect_count, 2, "expected 2 redirects, got {redirect_count}");
326    }
327
328    #[test]
329    fn dedup_skips_pc_opcode() {
330        // Two identical `PC / PUSH1 0x00 / REVERT` blocks — PC is position-dependent,
331        // so they must NOT be deduplicated.
332        let bytecode = analyze_asm_with(
333            "
334            PUSH0
335            CALLDATALOAD
336            PUSH %bb2
337            JUMPI
338            ; PC + revert A
339            PC
340            PUSH1 0x00
341            REVERT
342        bb2:
343            JUMPDEST
344            PUSH0
345            CALLDATALOAD
346            PUSH %bb4
347            JUMPI
348            ; PC + revert B (same bytes, different PC value)
349            PC
350            PUSH1 0x00
351            REVERT
352        bb4:
353            JUMPDEST
354            STOP
355        ",
356            AnalysisConfig::DEDUP,
357        );
358
359        assert!(bytecode.redirects.is_empty(), "should not dedup blocks containing PC");
360    }
361
362    #[test]
363    fn dedup_jump_same_target() {
364        // Two byte-identical fallthrough JUMP tails resolved to the same target.
365        let bytecode = analyze_asm_with(
366            "
367            CALLDATASIZE
368            PUSH %path1
369            JUMPI
370            PUSH0
371            PUSH1 0xFF
372            JUMPI
373            PUSH %target
374            JUMP
375        path1:
376            JUMPDEST
377            PUSH0
378            PUSH1 0xFF
379            JUMPI
380            PUSH %target
381            JUMP
382        target:
383            JUMPDEST
384            STOP
385        ",
386            AnalysisConfig::DEDUP,
387        );
388        assert_eq!(bytecode.redirects.len(), 1, "same-target JUMP tails should be deduped");
389    }
390
391    #[test]
392    fn dedup_jump_different_targets() {
393        // Two byte-identical non-JUMPDEST JUMP tails with different resolved static targets.
394        // Must NOT be merged — the JUMP target is context-sensitive.
395        // Regression test for:
396        // revmc-dedup-non-jumpdest-fallthrough-jump-tail-static-target-confusion
397        let bytecode = analyze_asm_with(
398            "
399            CALLDATASIZE
400            PUSH %path1
401            JUMPI
402
403            PUSH %ret0
404            PUSH0
405            PUSH1 0xFF
406            JUMPI
407            PUSH1 0x42
408            SWAP1
409            JUMP
410
411        path1:
412            JUMPDEST
413            PUSH %ret1
414            PUSH0
415            PUSH1 0xFF
416            JUMPI
417            PUSH1 0x42
418            SWAP1
419            JUMP
420
421        ret0:
422            JUMPDEST
423            POP
424            PUSH1 0xAA
425            STOP
426
427        ret1:
428            JUMPDEST
429            POP
430            PUSH1 0xBB
431            STOP
432        ",
433            AnalysisConfig::DEDUP,
434        );
435
436        assert!(
437            bytecode.redirects.is_empty(),
438            "different-target JUMP tails must not be deduped, got {} redirect(s)",
439            bytecode.redirects.len(),
440        );
441    }
442
443    #[test]
444    fn dedup_weth() {
445        let code = fixture_entry_code(include_str!("../../../../../data/weth.json"));
446        let mut bytecode = crate::Bytecode::test(code);
447        bytecode.config = AnalysisConfig::DEDUP;
448        bytecode.analyze().unwrap();
449
450        assert_eq!(bytecode.redirects.len(), 13);
451    }
452
453    fn fixture_entry_code(json: &str) -> Vec<u8> {
454        let v: serde_json::Value = serde_json::from_str(json).unwrap();
455        let case = v.as_object().unwrap().values().next().unwrap();
456        let to = case["transaction"][0]["to"].as_str().unwrap();
457        let code = case["pre"][to]["code"].as_str().unwrap().trim_start_matches("0x");
458        revm_primitives::hex::decode(code).unwrap()
459    }
460
461    #[test]
462    fn dedup_skips_dynamic_jump_sstore_tail() {
463        // Two non-JUMPDEST byte-identical `SSTORE ; JUMP` tails with unresolved dynamic
464        // JUMP terminators must NOT be deduped.
465        let bytecode = analyze_asm_with(
466            "
467            PUSH0
468            CALLDATALOAD
469            PUSH1 0x20
470            CALLDATALOAD
471            PUSH %path_b
472            JUMPI
473
474            ; path A
475            PUSH1 0x11
476            PUSH1 0x01
477            PUSH0
478            PUSH %not_taken_a
479            JUMPI
480            SSTORE
481            JUMP
482
483        not_taken_a:
484            JUMPDEST
485            INVALID
486
487        path_b:
488            JUMPDEST
489            PUSH1 0x22
490            PUSH1 0x02
491            PUSH0
492            PUSH %not_taken_b
493            JUMPI
494            SSTORE
495            JUMP
496
497        not_taken_b:
498            JUMPDEST
499            INVALID
500
501        done:
502            JUMPDEST
503            STOP
504        ",
505            AnalysisConfig::DEDUP,
506        );
507
508        assert!(
509            bytecode.redirects.is_empty(),
510            "should not dedup blocks ending in unresolved dynamic JUMP",
511        );
512    }
513
514    #[test]
515    fn dedup_skips_const_true_dynamic_jumpi_tail() {
516        // Two non-JUMPDEST byte-identical tails ending in const-true `JUMPI` with
517        // unresolved dynamic targets must NOT be deduped. The constant condition
518        // makes the terminator non-fallthrough, but the target is still arbitrary.
519        let bytecode = analyze_asm_with(
520            "
521            CALLDATASIZE
522            PUSH %path_b
523            JUMPI
524
525            ; tail A
526            PUSH1 0x01
527            PUSH0
528            CALLDATALOAD
529            JUMPI
530            STOP
531
532        path_b:
533            JUMPDEST
534            PUSH0
535            PUSH %after_b
536            JUMPI
537
538            ; tail B
539            PUSH1 0x01
540            PUSH0
541            CALLDATALOAD
542            JUMPI
543            STOP
544
545        after_b:
546            JUMPDEST
547            INVALID
548        ",
549            AnalysisConfig::DEDUP,
550        );
551
552        let dynamic_jumpis = bytecode
553            .iter_insts()
554            .filter(|(_, data)| {
555                data.opcode == op::JUMPI
556                    && data.has_const_jumpi_condition()
557                    && !data.flags.contains(InstFlags::STATIC_JUMP)
558            })
559            .count();
560        assert_eq!(dynamic_jumpis, 2, "expected two const-true dynamic JUMPI tails");
561        assert!(
562            bytecode.redirects.is_empty(),
563            "should not dedup blocks ending in unresolved dynamic JUMPI",
564        );
565    }
566
567    #[test]
568    fn dedup_allows_const_true_static_jumpi_tail() {
569        // The dynamic-target guard should not block const-true JUMPI tails whose
570        // target is known and whose CFG successors match.
571        let bytecode = analyze_asm_with(
572            "
573            CALLDATASIZE
574            PUSH %path_b
575            JUMPI
576
577            ; tail A
578            PUSH1 0x01
579            PUSH %target
580            JUMPI
581            STOP
582
583        path_b:
584            JUMPDEST
585            PUSH0
586            PUSH %after_b
587            JUMPI
588
589            ; tail B
590            PUSH1 0x01
591            PUSH %target
592            JUMPI
593            STOP
594
595        after_b:
596            JUMPDEST
597            INVALID
598
599        target:
600            JUMPDEST
601            STOP
602        ",
603            AnalysisConfig::DEDUP,
604        );
605
606        let static_jumpis = bytecode
607            .iter_insts()
608            .filter(|(_, data)| {
609                data.opcode == op::JUMPI
610                    && data.has_const_jumpi_condition()
611                    && data.flags.contains(InstFlags::STATIC_JUMP)
612            })
613            .count();
614        assert!(
615            static_jumpis >= 2,
616            "expected at least two const-condition static JUMPI instructions",
617        );
618        assert_eq!(
619            bytecode.redirects.len(),
620            1,
621            "same-target const-true static JUMPI tails should be deduped",
622        );
623    }
624
625    #[test]
626    fn dedup_multi_jump_source_merges_targets() {
627        // Two byte-identical MULTI_JUMP dispatcher blocks (dispatcher_a, dispatcher_b)
628        // each route to two identical JUMPDEST+STOP return blocks. After the return
629        // blocks are deduped, the two dispatchers become identical in (bytes, succs).
630        // Dedup must merge their multi_jump_targets so the canonical switch emits cases
631        // for ALL valid return PCs.
632        let bytecode = analyze_asm_with(
633            "
634            ; Branch on first calldata word.
635            PUSH0
636            CALLDATALOAD
637            PUSH %path_b
638            JUMPI
639
640            ; Branch on second calldata word.
641            PUSH1 0x20
642            CALLDATALOAD
643            PUSH %path_a1
644            JUMPI
645            PUSH %ret0
646            PUSH %dispatcher_a
647            JUMP
648        path_a1:
649            JUMPDEST
650            PUSH %ret1
651            PUSH %dispatcher_a
652            JUMP
653
654        path_b:
655            JUMPDEST
656            PUSH1 0x20
657            CALLDATALOAD
658            PUSH %path_b1
659            JUMPI
660            PUSH %ret2
661            PUSH %dispatcher_b
662            JUMP
663        path_b1:
664            JUMPDEST
665            PUSH %ret3
666            PUSH %dispatcher_b
667            JUMP
668
669        ret0:
670            JUMPDEST
671            STOP
672        ret1:
673            JUMPDEST
674            STOP
675        ret2:
676            JUMPDEST
677            STOP
678        ret3:
679            JUMPDEST
680            STOP
681
682        dispatcher_a:
683            JUMPDEST
684            JUMP
685        dispatcher_b:
686            JUMPDEST
687            JUMP
688        ",
689            AnalysisConfig::DEDUP,
690        );
691
692        // The four STOP blocks are deduped (3 redirects), and one dispatcher is deduped
693        // (1 redirect) = 4 total.
694        assert_eq!(bytecode.redirects.len(), 4);
695
696        // Find the canonical dispatcher's multi_jump_targets — it must contain all 4
697        // original return PCs (possibly redirected to the canonical STOP inst).
698        let canonical_dispatcher_term = bytecode
699            .multi_jump_targets
700            .keys()
701            .find(|&&inst| !bytecode.insts[inst].is_dead_code())
702            .expect("should have a live MULTI_JUMP dispatcher");
703        let targets = &bytecode.multi_jump_targets[canonical_dispatcher_term];
704        assert_eq!(
705            targets.len(),
706            4,
707            "canonical dispatcher should have 4 multi-jump targets (merged), got {targets:?}",
708        );
709    }
710
711    #[test]
712    fn dedup_clears_stale_noop_flags() {
713        // Regression test for stale NOOP flags surviving dedup.
714        //
715        // Two byte-identical RETURN tails reached via different paths:
716        // - Path 0 pushes known constants (0x11, 0x22) → DSE can mark ADD as NOOP because the
717        //   output is a known constant (0x33).
718        // - Path 1 pushes CALLDATASIZE twice → ADD output is dynamic.
719        //
720        // After dedup merges the tails, the canonical block's snapshots are restored
721        // to local (which lack the incoming constants), but DSE re-runs and must NOT
722        // mark ADD as NOOP since its output is no longer a known constant.
723        let bytecode = analyze_asm_with(
724            "
725            CALLDATASIZE
726            PUSH %path1
727            JUMPI
728
729            ; Path 0: known constants.
730            PUSH1 0x11
731            PUSH1 0x22
732            PUSH %tail0
733            JUMP
734
735        path1:
736            JUMPDEST
737            ; Path 1: dynamic values.
738            CALLDATASIZE
739            CALLDATASIZE
740            PUSH %tail1
741            JUMP
742
743        tail0:
744            JUMPDEST
745            ADD
746            PUSH0
747            MSTORE
748            PUSH1 0x20
749            PUSH0
750            RETURN
751
752        tail1:
753            JUMPDEST
754            ADD
755            PUSH0
756            MSTORE
757            PUSH1 0x20
758            PUSH0
759            RETURN
760        ",
761            AnalysisConfig::DEDUP,
762        );
763
764        // Dedup should have merged the two tails.
765        assert_eq!(bytecode.redirects.len(), 1);
766
767        // The canonical block's ADD must NOT be marked NOOP — its output is dynamic
768        // under the merged (local) snapshots.
769        let canonical_start = *bytecode.redirects.values().next().unwrap();
770        let add_inst = canonical_start + 1; // JUMPDEST, ADD
771        assert!(
772            !bytecode.insts[add_inst].flags.contains(InstFlags::NOOP),
773            "canonical ADD must not be NOOP after dedup (stale flag from context-specific DSE)",
774        );
775    }
776
777    #[test]
778    fn dedup_invalidates_stale_const_snapshots() {
779        // Two byte-identical RETURN tails reached with different incoming constants.
780        // After dedup the canonical block must NOT retain stale const snapshots.
781        let bytecode = analyze_asm_with(
782            "
783            CALLDATASIZE
784            PUSH %path1
785            JUMPI
786
787            PUSH1 0xAA
788            PUSH %tail0
789            JUMP
790
791        path1:
792            JUMPDEST
793            PUSH1 0xBB
794            PUSH %tail1
795            JUMP
796
797        tail0:
798            JUMPDEST
799            PUSH1 0x01
800            ADD
801            PUSH0
802            MSTORE
803            PUSH1 0x20
804            PUSH0
805            RETURN
806
807        tail1:
808            JUMPDEST
809            PUSH1 0x01
810            ADD
811            PUSH0
812            MSTORE
813            PUSH1 0x20
814            PUSH0
815            RETURN
816        ",
817            AnalysisConfig::DEDUP,
818        );
819
820        // Dedup should have merged the two tails.
821        assert_eq!(bytecode.redirects.len(), 1);
822
823        // The canonical block's ADD must not carry a stale const_output
824        // (it would be 0xAB from the first context, but the second expects 0xBC).
825        let canonical_start = *bytecode.redirects.values().next().unwrap();
826        let add_inst = canonical_start + 2; // JUMPDEST, PUSH1, ADD
827        assert!(
828            bytecode.const_output(add_inst).is_none(),
829            "canonical ADD should have no const_output after dedup (was stale)",
830        );
831    }
832}