Skip to main content

soldb_ethdebug/
metadata.rs

1//! ETHDebug artifact model: instructions, source spans, and variable locations.
2//!
3//! An ETHDebug runtime artifact is a list of instructions, each carrying its program
4//! counter and a `context` describing the source range it was generated from. Mapping a
5//! program counter back to Solidity means finding its instruction and reading that span.
6//!
7//! Spans nest: a compiler attaches a whole-contract range to dispatcher and preamble
8//! instructions, so a span containing a given line is not the same thing as a span
9//! generated for it. Callers that resolve a source line to a program counter must rank
10//! candidates accordingly rather than taking the first intersecting span.
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use soldb_core::{SoldbError, SoldbResult};
17
18#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
19pub struct SourceLocation {
20    pub source_id: u64,
21    pub offset: u64,
22    pub length: u64,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub struct FunctionIdentity {
27    pub identifier: Option<String>,
28    pub declaration: Option<SourceLocation>,
29    pub activation: Option<String>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
33#[serde(rename_all = "lowercase")]
34pub enum FunctionExit {
35    Return,
36    Revert,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct Instruction {
41    pub offset: u64,
42    pub operation: Value,
43    #[serde(default)]
44    pub context: Option<Value>,
45}
46
47impl Instruction {
48    #[must_use]
49    pub fn mnemonic(&self) -> Option<&str> {
50        self.operation
51            .get("mnemonic")
52            .and_then(serde_json::Value::as_str)
53    }
54
55    #[must_use]
56    pub fn arguments(&self) -> Vec<&str> {
57        self.operation
58            .get("arguments")
59            .and_then(serde_json::Value::as_array)
60            .map(|arguments| {
61                arguments
62                    .iter()
63                    .filter_map(serde_json::Value::as_str)
64                    .collect()
65            })
66            .unwrap_or_default()
67    }
68
69    #[must_use]
70    pub fn source_location(&self) -> Option<SourceLocation> {
71        let mut locations = self.source_locations();
72        (locations.len() == 1).then(|| locations.remove(0))
73    }
74
75    /// Returns every source alternative attached to this instruction.
76    #[must_use]
77    pub fn source_locations(&self) -> Vec<SourceLocation> {
78        let mut locations = Vec::new();
79        if let Some(context) = &self.context {
80            collect_source_locations(context, &mut locations);
81        }
82        locations
83    }
84
85    /// Returns every possible function invocation attached to this instruction.
86    #[must_use]
87    pub fn function_invocations(&self) -> Vec<FunctionIdentity> {
88        let mut functions = Vec::new();
89        if let Some(context) = &self.context {
90            collect_function_invocations(context, &mut functions);
91        }
92        functions
93    }
94
95    /// Returns the function exit attached to this instruction.
96    #[must_use]
97    pub fn function_exit(&self) -> Option<FunctionExit> {
98        let context = self.context.as_ref()?;
99        if context.get("return").is_some() {
100            Some(FunctionExit::Return)
101        } else if context.get("revert").is_some() {
102            Some(FunctionExit::Revert)
103        } else {
104            None
105        }
106    }
107}
108
109fn collect_source_locations(context: &Value, locations: &mut Vec<SourceLocation>) {
110    if let Some(location) = context.get("code").and_then(parse_source_location) {
111        if !locations.contains(&location) {
112            locations.push(location);
113        }
114    }
115    if let Some(pick) = context.get("pick").and_then(Value::as_array) {
116        for alternative in pick {
117            collect_source_locations(alternative, locations);
118        }
119    }
120}
121
122fn collect_function_invocations(context: &Value, functions: &mut Vec<FunctionIdentity>) {
123    if let Some(invoke) = context.get("invoke") {
124        let function = FunctionIdentity {
125            identifier: invoke
126                .get("identifier")
127                .and_then(Value::as_str)
128                .map(str::to_owned),
129            declaration: invoke.get("declaration").and_then(parse_source_location),
130            activation: invoke
131                .get("activation")
132                .and_then(Value::as_str)
133                .map(str::to_owned),
134        };
135        if !functions.contains(&function) {
136            functions.push(function);
137        }
138    }
139    if let Some(pick) = context.get("pick").and_then(Value::as_array) {
140        for alternative in pick {
141            collect_function_invocations(alternative, functions);
142        }
143    }
144}
145
146fn parse_source_location(value: &Value) -> Option<SourceLocation> {
147    let source_id = value.get("source")?.get("id")?.as_u64()?;
148    let range = value.get("range")?;
149    Some(SourceLocation {
150        source_id,
151        offset: range.get("offset")?.as_u64()?,
152        length: range.get("length")?.as_u64()?,
153    })
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct VariableLocation {
158    pub name: String,
159    pub ty: String,
160    pub location_type: String,
161    pub offset: u64,
162    pub pc_range: (u64, u64),
163}
164
165impl VariableLocation {
166    #[must_use]
167    pub fn is_active_at_pc(&self, pc: u64) -> bool {
168        self.pc_range.0 <= pc && pc <= self.pc_range.1
169    }
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct EthdebugInfo {
174    pub compilation: Value,
175    pub contract_name: String,
176    pub environment: String,
177    pub instructions: Vec<Instruction>,
178    pub sources: BTreeMap<u64, String>,
179    #[serde(default)]
180    pub variable_locations: BTreeMap<u64, Vec<VariableLocation>>,
181}
182
183impl EthdebugInfo {
184    #[must_use]
185    pub fn instruction_at_pc(&self, pc: u64) -> Option<&Instruction> {
186        self.instructions
187            .iter()
188            .find(|instruction| instruction.offset == pc)
189    }
190
191    #[must_use]
192    pub fn source_info(&self, pc: u64) -> Option<(&str, u64, u64)> {
193        let instruction = self.instruction_at_pc(pc)?;
194        let source_location = instruction.source_location()?;
195        let source_path = self.sources.get(&source_location.source_id)?;
196        Some((source_path, source_location.offset, source_location.length))
197    }
198
199    #[must_use]
200    pub fn variables_at_pc(&self, pc: u64) -> Vec<&VariableLocation> {
201        if let Some(exact) = self.variable_locations.get(&pc) {
202            return exact.iter().collect();
203        }
204
205        self.variable_locations
206            .values()
207            .flat_map(|variables| variables.iter())
208            .filter(|variable| variable.is_active_at_pc(pc))
209            .collect()
210    }
211}
212
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct EthdebugSpec {
215    pub address: Option<String>,
216    pub name: Option<String>,
217    pub path: String,
218}
219
220pub fn parse_ethdebug_spec(input: &str) -> EthdebugSpec {
221    if let Ok(spec) = parse_single_contract_spec(input) {
222        return spec;
223    }
224
225    parse_multi_contract_spec(input).unwrap_or_else(|_| EthdebugSpec {
226        address: None,
227        name: None,
228        path: input.to_owned(),
229    })
230}
231
232pub fn parse_single_contract_spec(input: &str) -> SoldbResult<EthdebugSpec> {
233    if !input.starts_with("0x") {
234        return Err(SoldbError::Message(format!(
235            "Must use format 'address:name:path' (got: {input})"
236        )));
237    }
238
239    let parts = input.splitn(3, ':').collect::<Vec<_>>();
240    if parts.len() != 3 || parts[1].is_empty() || parts[2].is_empty() {
241        return Err(SoldbError::Message(format!(
242            "Must use format 'address:name:path' (got: {input})"
243        )));
244    }
245
246    Ok(EthdebugSpec {
247        address: Some(parts[0].to_owned()),
248        name: Some(parts[1].to_owned()),
249        path: parts[2].to_owned(),
250    })
251}
252
253pub fn parse_multi_contract_spec(input: &str) -> SoldbResult<EthdebugSpec> {
254    if input.is_empty() {
255        return Err(SoldbError::Message("Path cannot be empty".to_owned()));
256    }
257
258    if input.starts_with("0x") && input.contains(':') {
259        let parts = input.splitn(2, ':').collect::<Vec<_>>();
260        if parts.len() != 2 || parts[1].is_empty() {
261            return Err(SoldbError::Message(format!(
262                "Must use format 'address:path' (got: {input})"
263            )));
264        }
265
266        return Ok(EthdebugSpec {
267            address: Some(parts[0].to_owned()),
268            name: None,
269            path: parts[1].to_owned(),
270        });
271    }
272
273    Ok(EthdebugSpec {
274        address: None,
275        name: None,
276        path: input.to_owned(),
277    })
278}
279
280pub fn parse_variable_locations(
281    contract_data: &Value,
282) -> SoldbResult<BTreeMap<u64, Vec<VariableLocation>>> {
283    let mut variable_locations = BTreeMap::<u64, Vec<VariableLocation>>::new();
284
285    if let Some(instructions) = contract_data
286        .get("instructions")
287        .and_then(serde_json::Value::as_array)
288    {
289        for instruction in instructions {
290            let pc = instruction
291                .get("offset")
292                .and_then(serde_json::Value::as_u64)
293                .ok_or_else(|| SoldbError::Message("Instruction missing offset".to_owned()))?;
294
295            let Some(variables) = instruction
296                .get("context")
297                .and_then(|context| context.get("variables"))
298                .and_then(serde_json::Value::as_array)
299            else {
300                continue;
301            };
302
303            for variable in variables {
304                variable_locations
305                    .entry(pc)
306                    .or_default()
307                    .push(parse_context_variable(variable, pc));
308            }
309        }
310    }
311
312    if let Some(variables) = contract_data
313        .get("variables")
314        .and_then(serde_json::Value::as_array)
315    {
316        for variable in variables {
317            let variable_location = parse_top_level_variable(variable);
318            for pc in variable_location.pc_range.0..=variable_location.pc_range.1 {
319                variable_locations
320                    .entry(pc)
321                    .or_default()
322                    .push(variable_location.clone());
323            }
324        }
325    }
326
327    Ok(variable_locations)
328}
329
330fn parse_context_variable(variable: &Value, pc: u64) -> VariableLocation {
331    let location = variable.get("location");
332    let scope = variable.get("scope");
333
334    VariableLocation {
335        name: get_string(variable, "name", "unknown"),
336        ty: get_string(variable, "type", "unknown"),
337        location_type: location
338            .and_then(|value| value.get("type"))
339            .and_then(serde_json::Value::as_str)
340            .unwrap_or("stack")
341            .to_owned(),
342        offset: location
343            .and_then(|value| value.get("offset"))
344            .and_then(serde_json::Value::as_u64)
345            .unwrap_or(0),
346        pc_range: (
347            scope
348                .and_then(|value| value.get("start"))
349                .and_then(serde_json::Value::as_u64)
350                .unwrap_or(pc),
351            scope
352                .and_then(|value| value.get("end"))
353                .and_then(serde_json::Value::as_u64)
354                .unwrap_or(pc),
355        ),
356    }
357}
358
359fn parse_top_level_variable(variable: &Value) -> VariableLocation {
360    VariableLocation {
361        name: get_string(variable, "name", "unknown"),
362        ty: get_string(variable, "type", "unknown"),
363        location_type: get_string(variable, "location_type", "stack"),
364        offset: variable
365            .get("offset")
366            .and_then(serde_json::Value::as_u64)
367            .unwrap_or(0),
368        pc_range: (
369            variable
370                .get("pc_start")
371                .and_then(serde_json::Value::as_u64)
372                .unwrap_or(0),
373            variable
374                .get("pc_end")
375                .and_then(serde_json::Value::as_u64)
376                .unwrap_or(0),
377        ),
378    }
379}
380
381fn get_string(value: &Value, key: &str, default: &str) -> String {
382    value
383        .get(key)
384        .and_then(serde_json::Value::as_str)
385        .unwrap_or(default)
386        .to_owned()
387}
388
389#[cfg(test)]
390mod tests {
391    use std::collections::BTreeMap;
392
393    use super::{
394        parse_ethdebug_spec, parse_multi_contract_spec, parse_single_contract_spec,
395        parse_variable_locations, EthdebugInfo, Instruction,
396    };
397    use serde_json::json;
398
399    #[test]
400    fn parses_single_and_multi_contract_specs() {
401        let spec = parse_single_contract_spec("0xabc:Token:out").expect("single spec");
402        assert_eq!(spec.address.as_deref(), Some("0xabc"));
403        assert_eq!(spec.name.as_deref(), Some("Token"));
404        assert_eq!(spec.path, "out");
405
406        let multi = parse_multi_contract_spec("0xabc:out").expect("multi spec");
407        assert_eq!(multi.address.as_deref(), Some("0xabc"));
408        assert_eq!(multi.name, None);
409        assert_eq!(multi.path, "out");
410
411        let plain = parse_ethdebug_spec("out");
412        assert_eq!(plain.address, None);
413        assert_eq!(plain.path, "out");
414
415        assert!(parse_single_contract_spec("out").is_err());
416        assert!(parse_multi_contract_spec("").is_err());
417    }
418
419    #[test]
420    fn extracts_instruction_source_locations() {
421        let instruction: Instruction = serde_json::from_value(json!({
422            "offset": 12,
423            "operation": {"mnemonic": "PUSH1", "arguments": ["0x2a"]},
424            "context": {
425                "code": {
426                    "source": {"id": 7},
427                    "range": {"offset": 20, "length": 4}
428                }
429            }
430        }))
431        .expect("instruction");
432
433        let source_location = instruction.source_location().expect("source location");
434        assert_eq!(instruction.mnemonic(), Some("PUSH1"));
435        assert_eq!(instruction.arguments(), ["0x2a"]);
436        assert_eq!(source_location.source_id, 7);
437        assert_eq!(source_location.offset, 20);
438        assert_eq!(source_location.length, 4);
439    }
440
441    #[test]
442    fn extracts_source_picks_and_function_events() {
443        let invoke: Instruction = serde_json::from_value(json!({
444            "offset": 12,
445            "operation": {"mnemonic": "JUMPDEST"},
446            "context": {
447                "invoke": {
448                    "identifier": "calculate",
449                    "declaration": {
450                        "source": {"id": 7},
451                        "range": {"offset": 100, "length": 80}
452                    },
453                    "jump": true
454                }
455            }
456        }))
457        .expect("invoke instruction");
458        let shared: Instruction = serde_json::from_value(json!({
459            "offset": 13,
460            "operation": {"mnemonic": "SSTORE"},
461            "context": {
462                "pick": [
463                    {"code": {
464                        "source": {"id": 7},
465                        "range": {"offset": 20, "length": 4}
466                    }},
467                    {"code": {
468                        "source": {"id": 7},
469                        "range": {"offset": 140, "length": 4}
470                    }}
471                ]
472            }
473        }))
474        .expect("shared instruction");
475        let returned: Instruction = serde_json::from_value(json!({
476            "offset": 14,
477            "operation": {"mnemonic": "STOP"},
478            "context": {"return": {}}
479        }))
480        .expect("return instruction");
481
482        let function = invoke.function_invocations().remove(0);
483        assert_eq!(function.identifier.as_deref(), Some("calculate"));
484        assert_eq!(function.declaration.map(|range| range.offset), Some(100));
485        assert_eq!(shared.source_location(), None);
486        assert_eq!(
487            shared
488                .source_locations()
489                .into_iter()
490                .map(|location| location.offset)
491                .collect::<Vec<_>>(),
492            [20, 140]
493        );
494        assert_eq!(returned.function_exit(), Some(super::FunctionExit::Return));
495    }
496
497    #[test]
498    fn reports_source_and_variable_info_at_pc() {
499        let instruction: Instruction = serde_json::from_value(json!({
500            "offset": 3,
501            "operation": {"mnemonic": "SLOAD"},
502            "context": {
503                "code": {
504                    "source": {"id": 0},
505                    "range": {"offset": 9, "length": 5}
506                }
507            }
508        }))
509        .expect("instruction");
510
511        let variable = super::VariableLocation {
512            name: "stored".to_owned(),
513            ty: "uint256".to_owned(),
514            location_type: "storage".to_owned(),
515            offset: 0,
516            pc_range: (2, 5),
517        };
518        let mut sources = BTreeMap::new();
519        sources.insert(0, "Counter.sol".to_owned());
520        let mut variable_locations = BTreeMap::new();
521        variable_locations.insert(2, vec![variable]);
522        let info = EthdebugInfo {
523            compilation: json!({}),
524            contract_name: "Counter".to_owned(),
525            environment: "runtime".to_owned(),
526            instructions: vec![instruction],
527            sources,
528            variable_locations,
529        };
530
531        assert_eq!(info.source_info(3), Some(("Counter.sol", 9, 5)));
532        assert_eq!(info.variables_at_pc(3)[0].name, "stored");
533        assert!(info.variables_at_pc(9).is_empty());
534    }
535
536    #[test]
537    fn parses_variable_locations_from_contract_data() {
538        let variables = parse_variable_locations(&json!({
539            "instructions": [
540                {
541                    "offset": 10,
542                    "context": {
543                        "variables": [
544                            {
545                                "name": "amount",
546                                "type": "uint256",
547                                "location": {"type": "stack", "offset": 1},
548                                "scope": {"start": 10, "end": 12}
549                            }
550                        ]
551                    }
552                }
553            ],
554            "variables": [
555                {
556                    "name": "stored",
557                    "type": "uint256",
558                    "location_type": "storage",
559                    "offset": 0,
560                    "pc_start": 20,
561                    "pc_end": 21
562                }
563            ]
564        }))
565        .expect("variables");
566
567        assert_eq!(variables.get(&10).expect("pc 10")[0].name, "amount");
568        assert_eq!(
569            variables.get(&20).expect("pc 20")[0].location_type,
570            "storage"
571        );
572        assert_eq!(variables.get(&21).expect("pc 21")[0].name, "stored");
573    }
574}