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