Skip to main content

soldb_ethdebug/
source_map.rs

1//! The legacy `srcmap` debug format.
2//!
3//! Solidity compilers can emit source mappings as a compact `s:l:f:j:m` string alongside
4//! the bytecode, with fields inherited from the previous entry when omitted. This module
5//! parses that format and builds the program-counter index for it, which requires walking
6//! the bytecode so `PUSH` immediates are not mistaken for opcodes.
7//!
8//! This is a fallback path. ETHDebug is the primary source of debug information and
9//! carries strictly more of it; prefer it whenever the compiler can produce it.
10
11use std::borrow::Cow;
12use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18use soldb_core::{SoldbError, SoldbResult};
19
20use crate::opcodes;
21use crate::storage_layout::StorageLayout;
22use crate::{EthdebugInfo, Instruction, SourceLocation, SourceMapContext, SourceMapJump};
23
24/// The bytecode environment described by a legacy source map.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum SourceMapEnvironment {
27    Creation,
28    Runtime,
29}
30
31impl SourceMapEnvironment {
32    fn fields(self) -> (&'static str, &'static str, &'static str) {
33        match self {
34            Self::Creation => ("srcmap", "bin", "create"),
35            Self::Runtime => ("srcmap-runtime", "bin-runtime", "call"),
36        }
37    }
38}
39
40/// A legacy source-map program adapted to the common debug-info model.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SourceMapProgram {
43    pub info: EthdebugInfo,
44    pub resources: Value,
45    pub source_contents: BTreeMap<u64, String>,
46    /// The file each source was read from, which may be above the artifact directory.
47    pub source_files: BTreeMap<u64, PathBuf>,
48    /// The contract's `storage-layout` entry, when the artifact was compiled with it.
49    pub storage_layout: Option<StorageLayout>,
50    /// The contract's `transient-storage-layout` entry, when compiled with it.
51    pub transient_storage_layout: Option<StorageLayout>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct SourceMapEntry {
56    pub offset: i64,
57    pub length: i64,
58    pub file_index: i64,
59    pub jump_type: String,
60    pub modifier_depth: i64,
61}
62
63impl SourceMapEntry {
64    #[must_use]
65    pub fn is_valid(&self) -> bool {
66        self.file_index >= 0 && self.offset >= 0
67    }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct SourceMapInfo {
72    pub contract_name: String,
73    pub sources: Vec<String>,
74    pub bytecode: Vec<u8>,
75    pub srcmap_entries: Vec<SourceMapEntry>,
76    pub pc_to_instruction_index: BTreeMap<usize, usize>,
77    pub compiler_version: Option<String>,
78}
79
80impl SourceMapInfo {
81    #[must_use]
82    pub fn source_entry_at_pc(&self, pc: usize) -> Option<&SourceMapEntry> {
83        let instruction_index = self.pc_to_instruction_index.get(&pc)?;
84        self.srcmap_entries.get(*instruction_index)
85    }
86
87    #[must_use]
88    pub fn source_info(&self, pc: usize) -> Option<(&str, i64, i64)> {
89        let entry = self.source_entry_at_pc(pc)?;
90        if !entry.is_valid() {
91            return None;
92        }
93
94        let source = self.sources.get(usize::try_from(entry.file_index).ok()?)?;
95        Some((source, entry.offset, entry.length))
96    }
97}
98
99pub fn build_pc_to_instruction_map(bytecode: &[u8]) -> BTreeMap<usize, usize> {
100    let mut pc_to_index = BTreeMap::new();
101    let mut pc = 0;
102    let mut instruction_index = 0;
103
104    while pc < bytecode.len() {
105        pc_to_index.insert(pc, instruction_index);
106        pc += 1 + opcodes::immediate_size(bytecode[pc]);
107        instruction_index += 1;
108    }
109
110    pc_to_index
111}
112
113pub fn parse_srcmap(srcmap: &str) -> SoldbResult<Vec<SourceMapEntry>> {
114    if srcmap.is_empty() {
115        return Ok(Vec::new());
116    }
117
118    let mut entries = Vec::new();
119    let mut previous = SourceMapEntry {
120        offset: 0,
121        length: 0,
122        file_index: -1,
123        jump_type: "-".to_owned(),
124        modifier_depth: 0,
125    };
126
127    for part in srcmap.split(';') {
128        if part.is_empty() {
129            entries.push(previous.clone());
130            continue;
131        }
132
133        let fields = part.split(':').collect::<Vec<_>>();
134        let entry = SourceMapEntry {
135            offset: parse_inherited_i64(fields.first().copied(), previous.offset, "offset")?,
136            length: parse_inherited_i64(fields.get(1).copied(), previous.length, "length")?,
137            file_index: parse_inherited_i64(
138                fields.get(2).copied(),
139                previous.file_index,
140                "file index",
141            )?,
142            jump_type: parse_inherited_string(fields.get(3).copied(), &previous.jump_type),
143            modifier_depth: parse_inherited_i64(
144                fields.get(4).copied(),
145                previous.modifier_depth,
146                "modifier depth",
147            )?,
148        };
149
150        previous = entry.clone();
151        entries.push(entry);
152    }
153
154    Ok(entries)
155}
156
157/// Loads one program from `combined.json` when the requested source map exists.
158///
159/// `Ok(None)` means the directory has no legacy artifact, or the selected contract
160/// does not contain that creation/runtime map. A present but malformed artifact is
161/// always an error so callers cannot silently lose source attribution.
162pub fn load_source_map_program(
163    root: &Path,
164    contract_name: &str,
165    environment: SourceMapEnvironment,
166) -> SoldbResult<Option<SourceMapProgram>> {
167    load_source_map_program_with_sources(root, &[], contract_name, environment)
168}
169
170/// The same, also looking for the sources it names in `source_roots`.
171pub fn load_source_map_program_with_sources(
172    root: &Path,
173    source_roots: &[PathBuf],
174    contract_name: &str,
175    environment: SourceMapEnvironment,
176) -> SoldbResult<Option<SourceMapProgram>> {
177    let path = root.join("combined.json");
178    if !path.exists() {
179        return Ok(None);
180    }
181
182    let input = fs::read_to_string(&path).map_err(|error| {
183        SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
184    })?;
185    let combined = serde_json::from_str::<Value>(&input).map_err(|error| {
186        SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
187    })?;
188    source_map_program_from_combined(
189        root,
190        source_roots,
191        &path,
192        &combined,
193        contract_name,
194        environment,
195    )
196}
197
198fn source_map_program_from_combined(
199    root: &Path,
200    source_roots: &[PathBuf],
201    artifact_path: &Path,
202    combined: &Value,
203    contract_name: &str,
204    environment: SourceMapEnvironment,
205) -> SoldbResult<Option<SourceMapProgram>> {
206    let contracts = combined
207        .get("contracts")
208        .and_then(Value::as_object)
209        .ok_or_else(|| {
210            SoldbError::Message(format!(
211                "legacy artifact `{}` does not contain a contracts object",
212                artifact_path.display()
213            ))
214        })?;
215    let Some((contract_key, contract)) = find_contract(contracts, contract_name)? else {
216        return Ok(None);
217    };
218    let (source_map_field, bytecode_field, environment_name) = environment.fields();
219    let Some(source_map) = contract.get(source_map_field).and_then(Value::as_str) else {
220        return Ok(None);
221    };
222    let bytecode = contract
223        .get(bytecode_field)
224        .and_then(Value::as_str)
225        .ok_or_else(|| {
226            SoldbError::Message(format!(
227                "legacy artifact `{}` has `{source_map_field}` for `{contract_key}` but no \
228                 `{bytecode_field}`; recompile with bytecode output enabled",
229                artifact_path.display()
230            ))
231        })?;
232    let bytecode = decode_bytecode(bytecode, artifact_path, contract_key, bytecode_field)?;
233    let source_map = parse_srcmap(source_map)?;
234    let source_list = combined
235        .get("sourceList")
236        .and_then(Value::as_array)
237        .ok_or_else(|| {
238            SoldbError::Message(format!(
239                "legacy artifact `{}` does not contain a sourceList array",
240                artifact_path.display()
241            ))
242        })?;
243
244    let mut sources = BTreeMap::new();
245    let mut source_contents = BTreeMap::new();
246    let mut source_files = BTreeMap::new();
247    let mut compilation_sources = Vec::with_capacity(source_list.len());
248    for (source_id, source) in source_list.iter().enumerate() {
249        let source_path = source.as_str().ok_or_else(|| {
250            SoldbError::Message(format!(
251                "legacy artifact `{}` has a non-string sourceList entry at index {source_id}",
252                artifact_path.display()
253            ))
254        })?;
255        let source_id = u64::try_from(source_id)
256            .map_err(|_| SoldbError::Message("source index does not fit in `u64`".to_owned()))?;
257        sources.insert(source_id, source_path.to_owned());
258
259        let mut source = json!({
260            "id": source_id,
261            "path": source_path,
262            "language": "Solidity",
263        });
264        if let Some((file, contents)) = read_source(root, source_roots, source_path)? {
265            source_contents.insert(source_id, contents.clone());
266            source_files.insert(source_id, file);
267            source["contents"] = Value::String(contents);
268        }
269        compilation_sources.push(source);
270    }
271
272    let pc_to_instruction_index = build_pc_to_instruction_map(&bytecode);
273    if source_map.len() > pc_to_instruction_index.len() {
274        return Err(SoldbError::Message(format!(
275            "legacy source map for `{contract_key}` has {} entries but `{bytecode_field}` has \
276             only {} instructions",
277            source_map.len(),
278            pc_to_instruction_index.len()
279        )));
280    }
281
282    let mut instructions = Vec::with_capacity(source_map.len());
283    for (pc, instruction_index) in pc_to_instruction_index {
284        let Some(entry) = source_map.get(instruction_index) else {
285            break;
286        };
287        instructions.push(Instruction::from_source_map(
288            u64::try_from(pc).map_err(|_| {
289                SoldbError::Message("program counter does not fit in `u64`".to_owned())
290            })?,
291            opcode_mnemonic(bytecode[pc]),
292            source_context(
293                entry,
294                sources.len(),
295                &source_contents,
296                instruction_index,
297                contract_key,
298            )?,
299        ));
300    }
301
302    let compiler_version = combined
303        .get("version")
304        .and_then(Value::as_str)
305        .unwrap_or("unknown");
306    let compilation = json!({
307        "id": format!("legacy-{compiler_version}"),
308        "compiler": {
309            "name": "Solidity compiler",
310            "version": compiler_version,
311        },
312        "sources": compilation_sources,
313    });
314    let resources = json!({
315        "compilation": compilation,
316        "types": {},
317        "pointers": {},
318    });
319    let resolved_contract_name = contract_key
320        .rsplit_once(':')
321        .map_or(contract_key, |(_, name)| name);
322    let info = EthdebugInfo {
323        compilation,
324        contract_name: resolved_contract_name.to_owned(),
325        environment: environment_name.to_owned(),
326        instructions,
327        sources,
328        variable_locations: BTreeMap::new(),
329    };
330
331    let storage_layout = combined_layout(contract, "storage-layout", artifact_path, contract_key)?;
332    let transient_storage_layout = combined_layout(
333        contract,
334        "transient-storage-layout",
335        artifact_path,
336        contract_key,
337    )?;
338
339    Ok(Some(SourceMapProgram {
340        info,
341        resources,
342        source_contents,
343        source_files,
344        storage_layout,
345        transient_storage_layout,
346    }))
347}
348
349/// A contract's layout entry `key` in a `combined.json`, when it was compiled with it.
350fn combined_layout(
351    contract: &Value,
352    key: &str,
353    artifact_path: &Path,
354    contract_key: &str,
355) -> SoldbResult<Option<StorageLayout>> {
356    contract
357        .get(key)
358        .map(StorageLayout::parse)
359        .transpose()
360        .map_err(|error| {
361            SoldbError::Message(format!(
362                "legacy artifact `{}` has an invalid `{key}` for `{contract_key}`: {error}",
363                artifact_path.display()
364            ))
365        })
366}
367
368fn find_contract<'a>(
369    contracts: &'a serde_json::Map<String, Value>,
370    contract_name: &str,
371) -> SoldbResult<Option<(&'a str, &'a Value)>> {
372    if contract_name.is_empty() {
373        let mut contracts = contracts.iter();
374        let Some((name, contract)) = contracts.next() else {
375            return Ok(None);
376        };
377        if contracts.next().is_some() {
378            return Err(SoldbError::Message(
379                "legacy combined JSON contains multiple contracts; provide `contractName`"
380                    .to_owned(),
381            ));
382        }
383        return Ok(Some((name, contract)));
384    }
385    if let Some((name, contract)) = contracts.get_key_value(contract_name) {
386        return Ok(Some((name, contract)));
387    }
388
389    let suffix = format!(":{contract_name}");
390    let mut matches = contracts.iter().filter(|(name, _)| name.ends_with(&suffix));
391    let Some((name, contract)) = matches.next() else {
392        return Ok(None);
393    };
394    if matches.next().is_some() {
395        return Err(SoldbError::Message(format!(
396            "contract name `{contract_name}` is ambiguous in legacy combined JSON"
397        )));
398    }
399    Ok(Some((name, contract)))
400}
401
402fn decode_bytecode(
403    bytecode: &str,
404    artifact_path: &Path,
405    contract_name: &str,
406    bytecode_field: &str,
407) -> SoldbResult<Vec<u8>> {
408    let bytecode = bytecode.strip_prefix("0x").unwrap_or(bytecode);
409    let (byte_pairs, remainder) = bytecode.as_bytes().as_chunks::<2>();
410    if !remainder.is_empty() {
411        return Err(SoldbError::Message(format!(
412            "`{bytecode_field}` for `{contract_name}` in `{}` has an odd number of hex digits",
413            artifact_path.display()
414        )));
415    }
416
417    byte_pairs
418        .iter()
419        .map(|digits| {
420            let high = decode_hex_digit(digits[0]);
421            let low = decode_hex_digit(digits[1]);
422            match (high, low) {
423                (Some(high), Some(low)) => Ok(high << 4 | low),
424                _ => Err(SoldbError::Message(format!(
425                    "`{bytecode_field}` for `{contract_name}` in `{}` is not valid hex",
426                    artifact_path.display()
427                ))),
428            }
429        })
430        .collect()
431}
432
433fn decode_hex_digit(digit: u8) -> Option<u8> {
434    match digit {
435        b'0'..=b'9' => Some(digit - b'0'),
436        b'a'..=b'f' => Some(digit - b'a' + 10),
437        b'A'..=b'F' => Some(digit - b'A' + 10),
438        _ => None,
439    }
440}
441
442fn opcode_mnemonic(opcode: u8) -> Cow<'static, str> {
443    opcodes::mnemonic(opcode).map_or_else(
444        || Cow::Owned(format!("UNKNOWN(0x{opcode:02x})")),
445        Cow::Borrowed,
446    )
447}
448
449/// The context of one legacy entry: its source range, and the jump markers ETHDebug
450/// defines (`invoke` for a jump into a function, `return` for a jump out of one) when the
451/// map's jump type carries them. Generated code has no range but may still jump.
452fn source_context(
453    entry: &SourceMapEntry,
454    source_count: usize,
455    source_contents: &BTreeMap<u64, String>,
456    instruction_index: usize,
457    contract_name: &str,
458) -> SoldbResult<SourceMapContext> {
459    Ok(SourceMapContext {
460        code: source_code(
461            entry,
462            source_count,
463            source_contents,
464            instruction_index,
465            contract_name,
466        )?,
467        jump: match entry.jump_type.as_str() {
468            "i" => SourceMapJump::Into,
469            "o" => SourceMapJump::Out,
470            _ => SourceMapJump::Regular,
471        },
472        modifier_depth: entry.modifier_depth,
473    })
474}
475
476/// The `code` part of an entry's context: the source range it maps to, if any.
477fn source_code(
478    entry: &SourceMapEntry,
479    source_count: usize,
480    source_contents: &BTreeMap<u64, String>,
481    instruction_index: usize,
482    contract_name: &str,
483) -> SoldbResult<Option<SourceLocation>> {
484    if entry.file_index < 0 {
485        return Ok(None);
486    }
487    if entry.offset < 0 || entry.length < 0 {
488        return Err(SoldbError::Message(format!(
489            "legacy source-map entry {instruction_index} for `{contract_name}` has a negative \
490             source range"
491        )));
492    }
493    let source_id = u64::try_from(entry.file_index).map_err(|_| {
494        SoldbError::Message(format!(
495            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
496             source index"
497        ))
498    })?;
499    // solc attributes the Yul it generates (ABI decoding, checked arithmetic, panics) to a
500    // utility source whose index is one past the last entry in `sourceList`, and that
501    // source is never in the artifact. Such an instruction has no user source, the same
502    // as an entry with index -1; treating it as an error would reject every map a modern
503    // legacy-format compiler emits.
504    if usize::try_from(source_id).map_or(true, |source_id| source_id >= source_count) {
505        return Ok(None);
506    }
507    let offset = u64::try_from(entry.offset).map_err(|_| {
508        SoldbError::Message(format!(
509            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
510             offset"
511        ))
512    })?;
513    let length = u64::try_from(entry.length).map_err(|_| {
514        SoldbError::Message(format!(
515            "legacy source-map entry {instruction_index} for `{contract_name}` has an invalid \
516             length"
517        ))
518    })?;
519    let end = offset.checked_add(length).ok_or_else(|| {
520        SoldbError::Message(format!(
521            "legacy source-map entry {instruction_index} for `{contract_name}` overflows its \
522             source range"
523        ))
524    })?;
525    if let Some(source) = source_contents.get(&source_id) {
526        let source_len = u64::try_from(source.len())
527            .map_err(|_| SoldbError::Message("source length does not fit in `u64`".to_owned()))?;
528        if end > source_len {
529            return Err(SoldbError::Message(format!(
530                "legacy source-map entry {instruction_index} for `{contract_name}` ends at byte \
531                 {end}, beyond source {source_id} length {source_len}"
532            )));
533        }
534    }
535
536    Ok(Some(SourceLocation {
537        source_id,
538        offset,
539        length,
540    }))
541}
542
543fn read_source(
544    root: &Path,
545    source_roots: &[PathBuf],
546    source_path: &str,
547) -> SoldbResult<Option<(PathBuf, String)>> {
548    let candidates = source_roots
549        .iter()
550        .flat_map(|extra| crate::artifacts::source_candidates(extra, source_path))
551        .chain(crate::artifacts::source_candidates(root, source_path));
552    for candidate in candidates {
553        if !candidate.exists() {
554            continue;
555        }
556        let contents = fs::read_to_string(&candidate).map_err(|error| {
557            SoldbError::Message(format!(
558                "failed to read source `{}`: {error}",
559                candidate.display()
560            ))
561        })?;
562        return Ok(Some((candidate, contents)));
563    }
564    Ok(None)
565}
566
567#[must_use]
568pub fn is_legacy_compiler(version: &str) -> bool {
569    if version.is_empty() {
570        return true;
571    }
572
573    let version_core = version.split('+').next().unwrap_or(version);
574    let parts = version_core.split('.').collect::<Vec<_>>();
575    if parts.len() < 3 {
576        return true;
577    }
578
579    let Ok(major) = parts[0].parse::<u64>() else {
580        return true;
581    };
582    let Ok(minor) = parts[1].parse::<u64>() else {
583        return true;
584    };
585    let Ok(patch) = parts[2].parse::<u64>() else {
586        return true;
587    };
588
589    major == 0 && (minor < 8 || (minor == 8 && patch < 29))
590}
591
592fn parse_inherited_i64(field: Option<&str>, previous: i64, label: &str) -> SoldbResult<i64> {
593    let Some(field) = field else {
594        return Ok(previous);
595    };
596
597    let trimmed = field.trim();
598    if trimmed.is_empty() {
599        return Ok(previous);
600    }
601
602    trimmed.parse::<i64>().map_err(|error| {
603        SoldbError::Message(format!("Invalid source map {label} '{trimmed}': {error}"))
604    })
605}
606
607fn parse_inherited_string(field: Option<&str>, previous: &str) -> String {
608    field
609        .filter(|value| !value.trim().is_empty())
610        .unwrap_or(previous)
611        .to_owned()
612}
613
614#[cfg(test)]
615mod tests {
616    use crate::metadata::FunctionExit;
617    use std::fs;
618    use std::path::PathBuf;
619    use std::time::{SystemTime, UNIX_EPOCH};
620
621    use serde_json::json;
622
623    use crate::Instruction;
624
625    use super::{
626        build_pc_to_instruction_map, is_legacy_compiler, load_source_map_program, opcode_mnemonic,
627        parse_srcmap, SourceMapEnvironment, SourceMapInfo,
628    };
629
630    fn temp_dir(label: &str) -> PathBuf {
631        let unique = SystemTime::now()
632            .duration_since(UNIX_EPOCH)
633            .expect("system time")
634            .as_nanos();
635        let dir = std::env::temp_dir().join(format!("soldb-source-map-{label}-{unique}"));
636        fs::create_dir_all(&dir).expect("create temp dir");
637        dir
638    }
639
640    #[test]
641    fn names_instructions_and_marks_invalid_bytes() {
642        // The names are what a trace records for the same steps, so a `JUMPDEST` in an
643        // artifact is a `JUMPDEST` in a trace; a byte that is no instruction says so.
644        assert_eq!(opcode_mnemonic(0x5b), "JUMPDEST");
645        assert_eq!(opcode_mnemonic(0x60), "PUSH1");
646        assert_eq!(opcode_mnemonic(0xf1), "CALL");
647        assert_eq!(opcode_mnemonic(0x0c), "UNKNOWN(0x0c)");
648    }
649
650    #[test]
651    fn maps_program_counters_around_push_data() {
652        let bytecode = [0x60, 0xff, 0x61, 0x01, 0x02, 0x00];
653        let pc_map = build_pc_to_instruction_map(&bytecode);
654
655        assert_eq!(pc_map.get(&0), Some(&0));
656        assert_eq!(pc_map.get(&2), Some(&1));
657        assert_eq!(pc_map.get(&5), Some(&2));
658        assert!(!pc_map.contains_key(&1));
659        assert!(!pc_map.contains_key(&3));
660    }
661
662    #[test]
663    fn parses_source_maps_with_inherited_fields() {
664        let entries = parse_srcmap("0:10:0:-:0;:5::i:;20::1").expect("source map");
665
666        assert_eq!(entries[0].offset, 0);
667        assert_eq!(entries[1].offset, 0);
668        assert_eq!(entries[1].length, 5);
669        assert_eq!(entries[1].file_index, 0);
670        assert_eq!(entries[1].jump_type, "i");
671        assert_eq!(entries[2].offset, 20);
672        assert_eq!(entries[2].length, 5);
673        assert_eq!(entries[2].file_index, 1);
674    }
675
676    #[test]
677    fn rejects_invalid_source_map_numbers() {
678        let error = parse_srcmap("abc:10:0").expect_err("invalid number");
679        assert!(error.to_string().contains("offset"));
680    }
681
682    #[test]
683    fn reports_source_info_for_valid_pcs() {
684        let bytecode = vec![0x60, 0xff, 0x00];
685        let srcmap_entries = parse_srcmap("0:4:0;10:2:1").expect("source map");
686        let info = SourceMapInfo {
687            contract_name: "Counter".to_owned(),
688            sources: vec!["A.sol".to_owned(), "B.sol".to_owned()],
689            bytecode: bytecode.clone(),
690            srcmap_entries,
691            pc_to_instruction_index: build_pc_to_instruction_map(&bytecode),
692            compiler_version: Some("0.8.16".to_owned()),
693        };
694
695        assert_eq!(info.source_info(0), Some(("A.sol", 0, 4)));
696        assert_eq!(info.source_info(2), Some(("B.sol", 10, 2)));
697        assert_eq!(info.source_info(1), None);
698    }
699
700    #[test]
701    fn detects_legacy_compiler_versions() {
702        assert!(is_legacy_compiler(""));
703        assert!(is_legacy_compiler("0.8.16"));
704        assert!(is_legacy_compiler("0.8.28+commit.deadbeef"));
705        assert!(!is_legacy_compiler("0.8.29"));
706        assert!(!is_legacy_compiler("0.9.0"));
707    }
708
709    #[test]
710    fn loads_runtime_combined_json_as_debug_info() {
711        let dir = temp_dir("runtime");
712        let source = "contract Counter { function add() external {} }";
713        fs::write(dir.join("Counter.sol"), source).expect("write source");
714        fs::write(
715            dir.join("combined.json"),
716            json!({
717                "version": "0.8.36+commit.test",
718                "sourceList": ["Counter.sol"],
719                "contracts": {
720                    "Counter.sol:Counter": {
721                        "bin-runtime": "600100",
722                        "srcmap-runtime": "0:8:0:-:0;9:3:0",
723                        "storage-layout": {
724                            "storage": [{
725                                "astId": 1,
726                                "contract": "Counter.sol:Counter",
727                                "label": "value",
728                                "offset": 0,
729                                "slot": "0",
730                                "type": "t_uint256"
731                            }],
732                            "types": {
733                                "t_uint256": {
734                                    "encoding": "inplace",
735                                    "label": "uint256",
736                                    "numberOfBytes": "32"
737                                }
738                            }
739                        },
740                        "transient-storage-layout": {
741                            "storage": [{
742                                "astId": 2,
743                                "contract": "Counter.sol:Counter",
744                                "label": "locked",
745                                "offset": 0,
746                                "slot": "0",
747                                "type": "t_bool"
748                            }],
749                            "types": {
750                                "t_bool": {
751                                    "encoding": "inplace",
752                                    "label": "bool",
753                                    "numberOfBytes": "1"
754                                }
755                            }
756                        }
757                    }
758                }
759            })
760            .to_string(),
761        )
762        .expect("write combined JSON");
763
764        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
765            .expect("load source map")
766            .expect("runtime program");
767
768        // `solc --combined-json ...,storage-layout` carries the layout per contract, so a
769        // pre-ETHDebug compiler reads state variables by name just as a modern one does.
770        let layout = program.storage_layout.as_ref().expect("storage layout");
771        assert_eq!(layout.variables.len(), 1);
772        assert_eq!(layout.variable("value").expect("value").slot, [0_u8; 32]);
773        assert_eq!(
774            layout.resolve("value").expect("resolve").type_id,
775            "t_uint256"
776        );
777        // `transient-storage-layout` is the same shape, for the `transient` variables.
778        let transient = program
779            .transient_storage_layout
780            .as_ref()
781            .expect("transient layout");
782        assert!(transient.variable("locked").is_some());
783        assert!(transient.variable("value").is_none());
784
785        assert_eq!(program.info.contract_name, "Counter");
786        assert_eq!(program.info.environment, "call");
787        assert_eq!(program.info.source_info(0), Some(("Counter.sol", 0, 8)));
788        assert_eq!(program.info.source_info(2), Some(("Counter.sol", 9, 3)));
789        assert_eq!(
790            program
791                .info
792                .instruction_at_pc(0)
793                .and_then(Instruction::mnemonic),
794            Some("PUSH1")
795        );
796        assert_eq!(
797            program
798                .info
799                .instruction_at_pc(2)
800                .and_then(Instruction::mnemonic),
801            Some("STOP")
802        );
803        assert_eq!(
804            program.source_contents.get(&0).map(String::as_str),
805            Some(source)
806        );
807        assert_eq!(
808            program.resources["compilation"]["compiler"]["version"],
809            "0.8.36+commit.test"
810        );
811    }
812
813    #[test]
814    fn jump_types_become_call_and_return_markers() {
815        let dir = temp_dir("jump-types");
816        let source = "contract Counter { function add() external {} }";
817        fs::write(dir.join("Counter.sol"), source).expect("write source");
818        fs::write(
819            dir.join("combined.json"),
820            json!({
821                "version": "0.8.16+commit.test",
822                "sourceList": ["Counter.sol"],
823                "contracts": {
824                    "Counter.sol:Counter": {
825                        "bin-runtime": "6001600100",
826                        "srcmap-runtime": "0:8:0:i;9:3:0:o;0:0:-1:-"
827                    }
828                }
829            })
830            .to_string(),
831        )
832        .expect("write combined JSON");
833
834        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
835            .expect("load source map")
836            .expect("runtime program");
837        let at = |pc: u64| program.info.instruction_at_pc(pc).expect("instruction");
838        assert_eq!(at(0).function_invocations().len(), 1);
839        assert_eq!(at(0).modifier_depth(), Some(0));
840        assert_eq!(at(0).function_exit(), None);
841        assert!(at(2).function_invocations().is_empty());
842        assert_eq!(at(2).function_exit(), Some(FunctionExit::Return));
843        // Generated code without a range still retains its modifier depth.
844        assert!(at(4).source_location().is_none());
845        assert!(at(4).function_invocations().is_empty());
846    }
847
848    #[test]
849    fn generated_code_past_the_source_list_has_no_source() {
850        // solc 0.8.x maps its generated Yul to a source index one past `sourceList`;
851        // those instructions are unmapped rather than a reason to reject the map.
852        let dir = temp_dir("utility-source");
853        let source = "contract Counter { function add() external {} }";
854        fs::write(dir.join("Counter.sol"), source).expect("write source");
855        fs::write(
856            dir.join("combined.json"),
857            json!({
858                "version": "0.8.16+commit.test",
859                "sourceList": ["Counter.sol"],
860                "contracts": {
861                    "Counter.sol:Counter": {
862                        "bin-runtime": "600100",
863                        "srcmap-runtime": "0:8:0:-:0;0:5:1"
864                    }
865                }
866            })
867            .to_string(),
868        )
869        .expect("write combined JSON");
870
871        let program = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
872            .expect("load source map")
873            .expect("runtime program");
874        assert_eq!(program.info.source_info(0), Some(("Counter.sol", 0, 8)));
875        assert_eq!(program.info.source_info(2), None);
876        assert!(program.info.instruction_at_pc(2).is_some());
877    }
878
879    #[test]
880    fn loads_creation_map_and_infers_single_contract() {
881        let dir = temp_dir("creation");
882        fs::write(
883            dir.join("combined.json"),
884            json!({
885                "sourceList": ["Missing.sol"],
886                "contracts": {
887                    "Missing.sol:Created": {
888                        "bin": "00",
889                        "srcmap": "0:1:0"
890                    }
891                }
892            })
893            .to_string(),
894        )
895        .expect("write combined JSON");
896
897        let program = load_source_map_program(&dir, "", SourceMapEnvironment::Creation)
898            .expect("load source map")
899            .expect("creation program");
900
901        assert_eq!(program.info.contract_name, "Created");
902        assert_eq!(program.info.environment, "create");
903        assert!(program.source_contents.is_empty());
904    }
905
906    #[test]
907    fn requires_bytecode_for_program_counter_mapping() {
908        let dir = temp_dir("missing-bytecode");
909        fs::write(
910            dir.join("combined.json"),
911            json!({
912                "sourceList": ["Counter.sol"],
913                "contracts": {
914                    "Counter.sol:Counter": {"srcmap-runtime": "0:1:0"}
915                }
916            })
917            .to_string(),
918        )
919        .expect("write combined JSON");
920
921        let error = load_source_map_program(&dir, "Counter", SourceMapEnvironment::Runtime)
922            .expect_err("missing bytecode");
923        assert!(error.to_string().contains("`bin-runtime`"));
924        assert!(error.to_string().contains("bytecode output"));
925    }
926}