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}