Skip to main content

soldb_debugger/
debug_diff.rs

1//! Differential testing for the source-level debugging experience.
2//!
3//! Compilers need not emit the same bytecode for the same source, so comparing raw
4//! source maps or program counters says little about what a user sees. This module maps
5//! each execution through [`StepMap::for_debug_diff`], keeping single-instruction
6//! source stops that interactive stepping can smooth over, and compares the traces.
7//! The result is suitable for a test harness: it is
8//! deterministic, serializable, and separates execution differences from debug-info
9//! differences.
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde::{Deserialize, Serialize};
14use soldb_core::{SoldbError, SoldbResult, TransactionTrace};
15use soldb_ethdebug::{keccak256, parse_word, word_hex};
16
17use crate::{ContractDebugInfo, StepMap};
18
19/// How strictly two source-level traces are compared.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "kebab-case")]
22pub enum DebugDiffMode {
23    /// Compare ordered source lines, functions, frame depth, and frame entries.
24    Steps,
25    /// Compare steps and their exact source ranges and generated-code attribution.
26    Spans,
27    /// Compare the set of source lines and functions reached, ignoring order and depth.
28    Coverage,
29}
30
31/// The execution result whose debug information was captured.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct DebugExecution {
35    pub creation: bool,
36    pub input: String,
37    pub value: String,
38    pub success: bool,
39    pub output: String,
40    /// ABI-encoded arguments supplied separately from compiler-specific initcode.
41    #[serde(default)]
42    pub constructor_args: Option<String>,
43}
44
45/// Aggregate mapping quality for one side of a comparison.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "camelCase")]
48pub struct DebugTraceSummary {
49    pub instructions: usize,
50    pub mapped_instructions: usize,
51    pub source_steps: usize,
52    pub frame_entries: usize,
53}
54
55/// One compiler-mapped source stop in an execution.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct DebugTraceEvent {
59    /// Instruction index in the original execution trace, for diagnostics only.
60    pub instruction: usize,
61    /// Program counter in that execution, for diagnostics only.
62    pub pc: u64,
63    pub contract: String,
64    pub source: String,
65    /// Keccak-256 of the exact source bytes, independent of checkout location.
66    #[serde(default)]
67    pub source_hash: Option<[u8; 32]>,
68    pub line: u64,
69    pub column: u64,
70    pub offset: u64,
71    pub length: u64,
72    pub function: Option<String>,
73    pub frame_depth: u32,
74    pub frame_entry: bool,
75    pub generated: bool,
76    /// Legacy source-map modifier depth, when the artifact carries it.
77    pub modifier_depth: Option<i64>,
78}
79
80/// A compiler-independent view of one execution's debugging experience.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct DebugTrace {
84    pub execution: DebugExecution,
85    pub summary: DebugTraceSummary,
86    pub events: Vec<DebugTraceEvent>,
87    /// Artifact or trace inconsistencies that make a comparison inconclusive.
88    #[serde(default)]
89    pub diagnostics: Vec<String>,
90}
91
92/// A source stop required by a test, independent of compiler-generated entry steps.
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct DebugCheckpoint {
95    pub source: String,
96    /// One-based source line.
97    pub line: u64,
98}
99
100impl DebugTrace {
101    /// Restricts comparison to explicit test checkpoints and diagnoses unreached ones.
102    ///
103    /// This is a test expectation, not source recovery: only existing mapped stops can
104    /// satisfy a checkpoint. Each side must reach every checkpoint at least once.
105    pub fn retain_checkpoints(&mut self, checkpoints: &[DebugCheckpoint]) {
106        let mut by_line = BTreeMap::<u64, Vec<(usize, String)>>::new();
107        for (index, checkpoint) in checkpoints.iter().enumerate() {
108            by_line
109                .entry(checkpoint.line)
110                .or_default()
111                .push((index, normalize_source_path(&checkpoint.source)));
112        }
113        let mut reached = BTreeSet::new();
114        self.events.retain(|event| {
115            let mut matched = false;
116            for (index, source) in by_line.get(&event.line).into_iter().flatten() {
117                if source_paths_match(&event.source, source) {
118                    reached.insert(*index);
119                    matched = true;
120                }
121            }
122            matched
123        });
124        for (index, checkpoint) in checkpoints.iter().enumerate() {
125            if !reached.contains(&index) {
126                self.diagnostics.push(format!(
127                    "checkpoint {}:{} was not reached",
128                    checkpoint.source, checkpoint.line
129                ));
130            }
131        }
132        self.summary.source_steps = self.events.len();
133    }
134
135    /// Supplies encoded constructor arguments when comparing creation executions.
136    ///
137    /// Their boundary cannot be recovered from source maps: initcode contains embedded
138    /// runtime code and data too. The caller must supply the known ABI payload.
139    pub fn set_constructor_args(&mut self, arguments: &str) -> SoldbResult<()> {
140        let arguments = normalize_hex_data(arguments);
141        let hex = arguments.strip_prefix("0x").unwrap_or(&arguments);
142        if !self.execution.creation
143            || !hex.len().is_multiple_of(2)
144            || !hex.bytes().all(|byte| byte.is_ascii_hexdigit())
145            || !self.execution.input.ends_with(hex)
146        {
147            return Err(SoldbError::Message(
148                "constructor arguments must be hex matching the end of creation input".to_owned(),
149            ));
150        }
151        self.execution.constructor_args = Some(arguments);
152        Ok(())
153    }
154}
155
156/// The kind of one sampled difference.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "kebab-case")]
159pub enum DebugDifferenceKind {
160    Changed,
161    Missing,
162    Unexpected,
163}
164
165/// One difference between the reference and candidate source traces.
166#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct DebugDifference {
169    pub kind: DebugDifferenceKind,
170    pub index: usize,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub reference: Option<DebugTraceEvent>,
173    #[serde(skip_serializing_if = "Option::is_none")]
174    pub candidate: Option<DebugTraceEvent>,
175}
176
177/// Serializable result of a source-level differential.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179#[serde(rename_all = "camelCase")]
180pub struct DebugDiffReport {
181    pub schema_version: u32,
182    pub mode: DebugDiffMode,
183    pub equivalent: bool,
184    pub comparable: bool,
185    pub diagnostics: Vec<String>,
186    pub execution_equivalent: bool,
187    pub execution_differences: Vec<String>,
188    pub reference: DebugTraceSummary,
189    pub candidate: DebugTraceSummary,
190    pub difference_count: usize,
191    pub differences: Vec<DebugDifference>,
192    pub differences_truncated: bool,
193}
194
195/// Maps one execution to source stops without interactive line smoothing.
196#[must_use]
197pub fn capture_debug_trace(
198    trace: &TransactionTrace,
199    contracts: Vec<ContractDebugInfo>,
200) -> DebugTrace {
201    let mut diagnostics = BTreeSet::new();
202    let source_hashes = contracts
203        .iter()
204        .enumerate()
205        .flat_map(|(index, contract)| {
206            contract
207                .source_contents
208                .iter()
209                .map(move |(source, content)| ((index, *source), keccak256(content.as_bytes())))
210        })
211        .collect::<BTreeMap<_, _>>();
212    for contract in &contracts {
213        let mut pcs = BTreeSet::new();
214        for instruction in &contract.info.instructions {
215            if !pcs.insert(instruction.offset) {
216                diagnostics.insert(format!(
217                    "duplicate instruction at PC {} in `{}`",
218                    instruction.offset, contract.name
219                ));
220            }
221            for source in instruction.source_locations() {
222                let valid = contract
223                    .source_contents
224                    .get(&source.source_id)
225                    .is_some_and(|text| {
226                        source
227                            .offset
228                            .checked_add(source.length)
229                            .is_some_and(|end| end <= text.len() as u64)
230                    });
231                if !valid {
232                    diagnostics.insert(format!(
233                        "missing source or invalid source range at PC {} in `{}`",
234                        instruction.offset, contract.name
235                    ));
236                }
237            }
238        }
239    }
240    let map = StepMap::for_debug_diff(trace, contracts);
241    let mut mapped_instructions = 0;
242    let mut frame_entries = 0;
243    let mut events = Vec::new();
244
245    for instruction in 0..map.step_count() {
246        let step = trace.steps.step(instruction);
247        if let Some(contract) = map.contract_at_step(instruction) {
248            let opcode = contract
249                .instruction_at_pc(step.pc())
250                .and_then(|inst| inst.mnemonic());
251            if !opcode.is_some_and(|opcode| opcodes_match(opcode, step.mnemonic())) {
252                diagnostics.insert(format!(
253                    "trace opcode `{}` at PC {} disagrees with artifact `{}` in `{}`",
254                    step.mnemonic(),
255                    step.pc(),
256                    opcode.unwrap_or("<missing>"),
257                    contract.name
258                ));
259            }
260        } else {
261            diagnostics.insert("trace has steps without a matching contract artifact".to_owned());
262        }
263        if map.line_key(instruction).is_some() {
264            mapped_instructions += 1;
265        }
266        if map.is_frame_entry(instruction) {
267            frame_entries += 1;
268        }
269        if !map.is_line_start(instruction) {
270            continue;
271        }
272
273        let Some(location) = map.location(instruction) else {
274            continue;
275        };
276        let modifier_depth = map
277            .contract_at_step(instruction)
278            .and_then(|contract| contract.modifier_depth_at_pc(step.pc()));
279        events.push(DebugTraceEvent {
280            instruction,
281            pc: step.pc(),
282            contract: location.contract_name,
283            source: normalize_source_path(&location.path),
284            source_hash: source_hashes
285                .get(&(location.key.contract, location.key.source_id))
286                .copied(),
287            line: location.line,
288            column: location.column,
289            offset: location.offset,
290            length: location.length,
291            function: location.function_name,
292            frame_depth: map.frame_depth(instruction).unwrap_or(0),
293            frame_entry: map.is_frame_entry(instruction),
294            generated: location.generated,
295            modifier_depth,
296        });
297    }
298
299    DebugTrace {
300        execution: DebugExecution {
301            creation: trace.to_addr.is_none(),
302            input: normalize_hex_data(&trace.input_data),
303            value: match parse_word(&trace.value) {
304                Ok(value) => word_hex(&value),
305                Err(error) => {
306                    diagnostics.insert(format!("invalid call value: {error}"));
307                    trace.value.clone()
308                }
309            },
310            success: trace.success,
311            output: normalize_hex_data(&trace.output),
312            constructor_args: None,
313        },
314        summary: DebugTraceSummary {
315            instructions: map.step_count(),
316            mapped_instructions,
317            source_steps: events.len(),
318            frame_entries,
319        },
320        events,
321        diagnostics: diagnostics.into_iter().collect(),
322    }
323}
324
325/// Compares two captured debugging experiences and samples at most `difference_limit`
326/// differences for display.
327#[must_use]
328pub fn compare_debug_traces(
329    reference: &DebugTrace,
330    candidate: &DebugTrace,
331    mode: DebugDiffMode,
332    difference_limit: usize,
333) -> DebugDiffReport {
334    let mut diagnostics = Vec::new();
335    for (side, trace) in [("reference", reference), ("candidate", candidate)] {
336        diagnostics.extend(
337            trace
338                .diagnostics
339                .iter()
340                .map(|message| format!("{side}: {message}")),
341        );
342        if trace.events.iter().any(|event| event.source_hash.is_none()) {
343            diagnostics.push(format!("{side}: source identity is unavailable"));
344        }
345    }
346    if reference.events.is_empty() {
347        diagnostics.push("reference trace has no source steps".to_owned());
348    }
349    if candidate.events.is_empty() {
350        diagnostics.push("candidate trace has no source steps".to_owned());
351    }
352    let execution_differences = compare_execution(&reference.execution, &candidate.execution);
353    let (difference_count, differences) = match mode {
354        DebugDiffMode::Steps | DebugDiffMode::Spans => {
355            compare_ordered_events(&reference.events, &candidate.events, mode, difference_limit)
356        }
357        DebugDiffMode::Coverage => {
358            compare_coverage(&reference.events, &candidate.events, difference_limit)
359        }
360    };
361    let comparable = diagnostics.is_empty();
362    let execution_equivalent = execution_differences.is_empty();
363
364    DebugDiffReport {
365        schema_version: 1,
366        mode,
367        equivalent: comparable && execution_equivalent && difference_count == 0,
368        comparable,
369        diagnostics,
370        execution_equivalent,
371        execution_differences,
372        reference: reference.summary.clone(),
373        candidate: candidate.summary.clone(),
374        difference_count,
375        differences_truncated: difference_count > differences.len(),
376        differences,
377    }
378}
379
380fn compare_execution(reference: &DebugExecution, candidate: &DebugExecution) -> Vec<String> {
381    let mut differences = Vec::new();
382    if reference.creation != candidate.creation {
383        differences.push("execution kind differs".to_owned());
384    }
385    if reference.creation && candidate.creation {
386        match (&reference.constructor_args, &candidate.constructor_args) {
387            (Some(left), Some(right)) if left != right => {
388                differences.push("constructor arguments differ".to_owned());
389            }
390            (Some(_), Some(_)) => {}
391            _ => differences.push("encoded constructor arguments were not supplied".to_owned()),
392        }
393    } else if reference.input != candidate.input {
394        differences.push("calldata differs".to_owned());
395    }
396    if reference.value != candidate.value {
397        differences.push("call value differs".to_owned());
398    }
399    if reference.success != candidate.success {
400        differences.push("success status differs".to_owned());
401    }
402    // Creation returns compiler-specific runtime bytecode, so differing output is not a
403    // behavioral mismatch between the constructors.
404    let deployed =
405        reference.creation && candidate.creation && reference.success && candidate.success;
406    if !deployed && reference.output != candidate.output {
407        differences.push("return data differs".to_owned());
408    }
409    differences
410}
411
412fn compare_ordered_events(
413    reference: &[DebugTraceEvent],
414    candidate: &[DebugTraceEvent],
415    mode: DebugDiffMode,
416    limit: usize,
417) -> (usize, Vec<DebugDifference>) {
418    let mut difference_count = 0;
419    let mut differences = Vec::new();
420    for index in 0..reference.len().max(candidate.len()) {
421        let left = reference.get(index);
422        let right = candidate.get(index);
423        if matches!((left, right), (Some(left), Some(right)) if events_match(left, right, mode)) {
424            continue;
425        }
426
427        difference_count += 1;
428        if differences.len() == limit {
429            continue;
430        }
431        differences.push(DebugDifference {
432            kind: match (left, right) {
433                (Some(_), Some(_)) => DebugDifferenceKind::Changed,
434                (Some(_), None) => DebugDifferenceKind::Missing,
435                (None, Some(_)) => DebugDifferenceKind::Unexpected,
436                (None, None) => continue,
437            },
438            index,
439            reference: left.cloned(),
440            candidate: right.cloned(),
441        });
442    }
443    (difference_count, differences)
444}
445
446fn compare_coverage(
447    reference: &[DebugTraceEvent],
448    candidate: &[DebugTraceEvent],
449    limit: usize,
450) -> (usize, Vec<DebugDifference>) {
451    let reference = unique_coverage(reference);
452    let candidate = unique_coverage(candidate);
453    let mut candidate_by_core = BTreeMap::<CoverageCore<'_>, Vec<usize>>::new();
454    for (index, event) in candidate.iter().enumerate() {
455        candidate_by_core
456            .entry(CoverageCore::new(event))
457            .or_default()
458            .push(index);
459    }
460    let mut matched_candidate = BTreeSet::new();
461    let mut differences = Vec::new();
462    let mut difference_count = 0;
463
464    for (index, left) in reference.iter().enumerate() {
465        let right = candidate_by_core
466            .get(&CoverageCore::new(left))
467            .into_iter()
468            .flatten()
469            .find_map(|candidate_index| {
470                let right = candidate[*candidate_index];
471                (!matched_candidate.contains(candidate_index)
472                    && source_paths_match(&left.source, &right.source)
473                    && left.source_hash == right.source_hash)
474                    .then_some((*candidate_index, right))
475            });
476        if let Some((candidate_index, _)) = right {
477            matched_candidate.insert(candidate_index);
478            continue;
479        }
480        difference_count += 1;
481        if differences.len() < limit {
482            differences.push(DebugDifference {
483                kind: DebugDifferenceKind::Missing,
484                index,
485                reference: Some((*left).clone()),
486                candidate: None,
487            });
488        }
489    }
490
491    for (index, right) in candidate.iter().enumerate() {
492        if matched_candidate.contains(&index) {
493            continue;
494        }
495        difference_count += 1;
496        if differences.len() < limit {
497            differences.push(DebugDifference {
498                kind: DebugDifferenceKind::Unexpected,
499                index,
500                reference: None,
501                candidate: Some((*right).clone()),
502            });
503        }
504    }
505
506    (difference_count, differences)
507}
508
509fn unique_coverage(events: &[DebugTraceEvent]) -> Vec<&DebugTraceEvent> {
510    let mut keys = BTreeSet::new();
511    let mut unique = Vec::new();
512    for event in events {
513        if keys.insert((
514            &event.contract,
515            &event.source,
516            event.source_hash,
517            event.line,
518            &event.function,
519        )) {
520            unique.push(event);
521        }
522    }
523    unique
524}
525
526#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
527struct CoverageCore<'a> {
528    contract: &'a str,
529    line: u64,
530    function: Option<&'a str>,
531}
532
533impl<'a> CoverageCore<'a> {
534    fn new(event: &'a DebugTraceEvent) -> Self {
535        Self {
536            contract: &event.contract,
537            line: event.line,
538            function: event.function.as_deref(),
539        }
540    }
541}
542
543fn events_match(
544    reference: &DebugTraceEvent,
545    candidate: &DebugTraceEvent,
546    mode: DebugDiffMode,
547) -> bool {
548    let source_step = reference.contract == candidate.contract
549        && source_paths_match(&reference.source, &candidate.source)
550        && reference.source_hash == candidate.source_hash
551        && reference.line == candidate.line
552        && reference.function == candidate.function;
553    if mode == DebugDiffMode::Coverage {
554        return source_step;
555    }
556
557    source_step
558        && reference.frame_depth == candidate.frame_depth
559        && reference.frame_entry == candidate.frame_entry
560        && (mode != DebugDiffMode::Spans
561            || (reference.column == candidate.column
562                && reference.offset == candidate.offset
563                && reference.length == candidate.length
564                && reference.generated == candidate.generated
565                && reference.modifier_depth == candidate.modifier_depth))
566}
567
568fn normalize_source_path(path: &str) -> String {
569    let path = path.replace('\\', "/");
570    path.strip_prefix("./").unwrap_or(&path).to_owned()
571}
572
573fn normalize_hex_data(value: &str) -> String {
574    let value = value
575        .strip_prefix("0x")
576        .or_else(|| value.strip_prefix("0X"))
577        .unwrap_or(value);
578    format!("0x{}", value.to_ascii_lowercase())
579}
580
581fn source_paths_match(left: &str, right: &str) -> bool {
582    let absolute = |path: &str| path.starts_with('/') || path.as_bytes().get(1) == Some(&b':');
583    let suffix = |long: &str, short: &str| {
584        long.strip_suffix(short)
585            .is_some_and(|prefix| prefix.ends_with('/'))
586    };
587    left == right
588        || (absolute(left) != absolute(right) && (suffix(left, right) || suffix(right, left)))
589}
590
591fn opcodes_match(left: &str, right: &str) -> bool {
592    let canonical = |opcode| match opcode {
593        "DIFFICULTY" => "PREVRANDAO",
594        "SHA3" => "KECCAK256",
595        other => other,
596    };
597    canonical(left).eq_ignore_ascii_case(canonical(right))
598}
599
600#[cfg(test)]
601mod tests {
602    use std::collections::BTreeMap;
603
604    use serde_json::json;
605    use soldb_core::{TraceStep, TransactionTrace};
606    use soldb_ethdebug::{source_range, EthdebugInfo, Instruction};
607
608    use super::{
609        capture_debug_trace, compare_debug_traces, DebugCheckpoint, DebugDiffMode,
610        DebugDifferenceKind,
611    };
612    use crate::ContractDebugInfo;
613
614    const SOURCE: &str = "contract C {\n    function f() external {\n        uint256 x = 1;\n        x++;\n    }\n}\n";
615
616    fn trace(pcs: &[u64], output: &str) -> TransactionTrace {
617        TransactionTrace {
618            tx_hash: None,
619            from_addr: "0x1".to_owned(),
620            to_addr: Some("0x2".to_owned()),
621            value: "0x0".to_owned(),
622            input_data: "0x26121ff0".to_owned(),
623            gas_used: 0,
624            output: output.to_owned(),
625            success: true,
626            error: None,
627            debug_trace_available: true,
628            contract_address: None,
629            backend: None,
630            capabilities: Default::default(),
631            artifacts: Default::default(),
632            steps: pcs
633                .iter()
634                .map(|pc| TraceStep {
635                    pc: *pc,
636                    op: "JUMPDEST".into(),
637                    gas: 0,
638                    gas_cost: 0,
639                    depth: 0,
640                    stack: Vec::new(),
641                    memory: None,
642                    storage: None,
643                    error: None,
644                    snapshot: Default::default(),
645                })
646                .collect(),
647        }
648    }
649
650    fn contract(pcs: &[u64], offsets: &[usize], path: &str) -> ContractDebugInfo {
651        let instructions = pcs
652            .iter()
653            .zip(offsets)
654            .map(|(pc, offset)| {
655                let code = source_range(0, *offset as u64, 1);
656                Instruction::new(*pc, "JUMPDEST", Some(json!({"code": code})))
657            })
658            .collect();
659        let info = EthdebugInfo {
660            compilation: json!({}),
661            contract_name: "C".to_owned(),
662            environment: "call".to_owned(),
663            instructions,
664            sources: BTreeMap::from([(0, path.to_owned())]),
665            variable_locations: BTreeMap::new(),
666        };
667        ContractDebugInfo::new(None, "C", info, BTreeMap::from([(0, SOURCE.to_owned())]))
668    }
669
670    #[test]
671    fn different_bytecode_with_the_same_source_steps_matches() {
672        let first = SOURCE.find("uint256").expect("statement");
673        let second = SOURCE.find("x++").expect("statement");
674        let reference = capture_debug_trace(
675            &trace(&[0, 1], "0x01"),
676            vec![contract(&[0, 1], &[first, second], "src/C.sol")],
677        );
678        let candidate = capture_debug_trace(
679            &trace(&[20, 24], "0x01"),
680            vec![contract(&[20, 24], &[first, second], "/checkout/src/C.sol")],
681        );
682
683        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8);
684
685        assert!(report.equivalent);
686        assert_eq!(report.difference_count, 0);
687    }
688
689    #[test]
690    fn changed_steps_and_execution_are_reported_separately() {
691        let first = SOURCE.find("uint256").expect("statement");
692        let second = SOURCE.find("x++").expect("statement");
693        let reference = capture_debug_trace(
694            &trace(&[0, 1], "0x01"),
695            vec![contract(&[0, 1], &[first, second], "C.sol")],
696        );
697        let candidate = capture_debug_trace(
698            &trace(&[20, 24], "0x02"),
699            vec![contract(&[20, 24], &[second, first], "C.sol")],
700        );
701
702        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 1);
703
704        assert!(!report.equivalent);
705        assert!(!report.execution_equivalent);
706        assert_eq!(report.execution_differences, ["return data differs"]);
707        assert_eq!(report.difference_count, 2);
708        assert_eq!(report.differences.len(), 1);
709        assert!(report.differences_truncated);
710        assert_eq!(report.differences[0].kind, DebugDifferenceKind::Changed);
711    }
712
713    #[test]
714    fn coverage_ignores_order_and_repetition() {
715        let first = SOURCE.find("uint256").expect("statement");
716        let second = SOURCE.find("x++").expect("statement");
717        let reference = capture_debug_trace(
718            &trace(&[0, 0, 1, 1], "0x"),
719            vec![contract(&[0, 1], &[first, second], "C.sol")],
720        );
721        let candidate = capture_debug_trace(
722            &trace(&[20, 20, 24, 24], "0x"),
723            vec![contract(&[20, 24], &[second, first], "C.sol")],
724        );
725
726        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Coverage, 8);
727
728        assert!(report.equivalent);
729    }
730
731    #[test]
732    fn empty_source_traces_are_not_comparable() {
733        let reference = capture_debug_trace(&trace(&[99], "0x"), Vec::new());
734        let candidate = capture_debug_trace(&trace(&[100], "0x"), Vec::new());
735
736        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8);
737
738        assert!(!report.equivalent);
739        assert!(!report.comparable);
740        assert!(report
741            .diagnostics
742            .iter()
743            .any(|message| message == "reference trace has no source steps"));
744    }
745
746    #[test]
747    fn span_mode_checks_exact_ranges_and_modifier_depth() {
748        let offset = SOURCE.find("uint256").expect("statement");
749        let reference =
750            capture_debug_trace(&trace(&[0], "0x"), vec![contract(&[0], &[offset], "C.sol")]);
751        let mut candidate = reference.clone();
752        candidate.events[0].length += 1;
753        candidate.events[0].modifier_depth = Some(1);
754
755        assert!(compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8).equivalent);
756        assert!(!compare_debug_traces(&reference, &candidate, DebugDiffMode::Spans, 8).equivalent);
757    }
758
759    #[test]
760    fn execution_comparison_normalizes_hex_spelling() {
761        let offset = SOURCE.find("uint256").expect("statement");
762        let reference = capture_debug_trace(
763            &trace(&[0], "0xAB"),
764            vec![contract(&[0], &[offset], "C.sol")],
765        );
766        let mut candidate_trace = trace(&[20], "0Xab");
767        candidate_trace.value = "0x00".to_owned();
768        let candidate =
769            capture_debug_trace(&candidate_trace, vec![contract(&[20], &[offset], "C.sol")]);
770
771        assert!(compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8).equivalent);
772    }
773
774    #[test]
775    fn mismatched_opcodes_and_missing_programs_cannot_pass() {
776        let offset = SOURCE.find("uint256").expect("statement");
777        let reference =
778            capture_debug_trace(&trace(&[0], "0x"), vec![contract(&[0], &[offset], "C.sol")]);
779        let mut wrong = trace(&[0], "0x");
780        let mut steps = wrong.steps.to_steps();
781        steps[0].op = "INVALID".into();
782        wrong.steps = steps.into();
783        let candidate = capture_debug_trace(&wrong, vec![contract(&[0], &[offset], "C.sol")]);
784        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8);
785        assert!(!report.equivalent);
786        assert!(!report.comparable);
787        assert_eq!(report.difference_count, 0);
788        assert!(report.diagnostics[0].contains("trace opcode `INVALID`"));
789    }
790
791    #[test]
792    fn source_paths_and_contents_both_participate_in_identity() {
793        let offset = SOURCE.find("uint256").expect("statement");
794        let reference = capture_debug_trace(
795            &trace(&[0], "0x"),
796            vec![contract(&[0], &[offset], "a/C.sol")],
797        );
798        let candidate = capture_debug_trace(
799            &trace(&[0], "0x"),
800            vec![contract(&[0], &[offset], "b/C.sol")],
801        );
802        for mode in [DebugDiffMode::Steps, DebugDiffMode::Coverage] {
803            assert!(!compare_debug_traces(&reference, &candidate, mode, 8).equivalent);
804        }
805        let mut changed = contract(&[0], &[offset], "a/C.sol");
806        changed
807            .source_contents
808            .insert(0, SOURCE.replace("x = 1", "x = 2"));
809        let candidate = capture_debug_trace(&trace(&[0], "0x"), vec![changed]);
810        assert!(!compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8).equivalent);
811    }
812
813    #[test]
814    fn constructors_compare_arguments_and_revert_data_not_initcode() {
815        let offset = SOURCE.find("uint256").expect("statement");
816        let mut left = trace(&[0], "0x6000");
817        left.to_addr = None;
818        left.input_data = "0x60000042".to_owned();
819        let mut right = left.clone();
820        right.input_data = "0x60010042".to_owned();
821        right.output = "0x6001".to_owned();
822        let mut reference = capture_debug_trace(&left, vec![contract(&[0], &[offset], "C.sol")]);
823        let mut candidate = capture_debug_trace(&right, vec![contract(&[0], &[offset], "C.sol")]);
824        assert!(!compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8).equivalent);
825        assert!(candidate.set_constructor_args("0x99").is_err());
826        reference.set_constructor_args("0x0042").expect("arguments");
827        candidate.set_constructor_args("0x0042").expect("arguments");
828        assert!(compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8).equivalent);
829        reference.execution.success = false;
830        candidate.execution.success = false;
831        assert!(
832            !compare_debug_traces(&reference, &candidate, DebugDiffMode::Steps, 8)
833                .execution_equivalent
834        );
835    }
836
837    #[test]
838    fn checkpoints_cannot_hide_an_unreached_statement() {
839        let first = SOURCE.find("uint256").expect("statement");
840        let second = SOURCE.find("x++").expect("statement");
841        let mut reference = capture_debug_trace(
842            &trace(&[0, 1], "0x"),
843            vec![contract(&[0, 1], &[first, second], "C.sol")],
844        );
845        let mut candidate =
846            capture_debug_trace(&trace(&[0], "0x"), vec![contract(&[0], &[first], "C.sol")]);
847        let checkpoints = [DebugCheckpoint {
848            source: "C.sol".to_owned(),
849            line: 4,
850        }];
851        reference.retain_checkpoints(&checkpoints);
852        candidate.retain_checkpoints(&checkpoints);
853        let report = compare_debug_traces(&reference, &candidate, DebugDiffMode::Coverage, 8);
854        assert!(!report.equivalent);
855        assert!(!report.comparable);
856        assert_eq!(reference.events.len(), 1);
857        assert_eq!(candidate.events.len(), 0);
858        assert!(report
859            .diagnostics
860            .iter()
861            .any(|message| message == "candidate: checkpoint C.sol:4 was not reached"));
862    }
863
864    #[test]
865    fn partial_artifacts_and_invalid_ranges_cannot_pass() {
866        let offset = SOURCE.find("uint256").expect("statement");
867        let reference =
868            capture_debug_trace(&trace(&[0], "0x"), vec![contract(&[0], &[offset], "C.sol")]);
869        let partial = capture_debug_trace(
870            &trace(&[0, 99], "0x"),
871            vec![contract(&[0], &[offset], "C.sol")],
872        );
873        assert!(!compare_debug_traces(&reference, &partial, DebugDiffMode::Steps, 8).comparable);
874        let invalid = capture_debug_trace(
875            &trace(&[0], "0x"),
876            vec![contract(&[0], &[SOURCE.len() + 1], "C.sol")],
877        );
878        assert!(!compare_debug_traces(&reference, &invalid, DebugDiffMode::Steps, 8).comparable);
879    }
880
881    #[test]
882    fn a_single_instruction_body_is_not_smoothed_out() {
883        let declaration = SOURCE.find("function").expect("declaration");
884        let statement = SOURCE.find("uint256").expect("statement");
885        let mut captured = capture_debug_trace(
886            &trace(&[0, 1, 2], "0x"),
887            vec![contract(
888                &[0, 1, 2],
889                &[declaration, statement, declaration],
890                "C.sol",
891            )],
892        );
893        captured.retain_checkpoints(&[DebugCheckpoint {
894            source: "C.sol".to_owned(),
895            line: 3,
896        }]);
897        assert!(captured.diagnostics.is_empty());
898        assert_eq!(captured.events.len(), 1);
899        assert_eq!(captured.events[0].pc, 1);
900    }
901}