Skip to main content

soldb_debugger/
lib.rs

1//! Source-level debugging over an execution trace.
2//!
3//! Given a [`soldb_core::TransactionTrace`] and the ETHDebug metadata for the contract
4//! it executed, this crate answers the questions a source-level debugger asks: which
5//! source span a step is at, which function contains a program counter, and which
6//! variables are live there with what values.
7//!
8//! It is frontend-agnostic on purpose. [`variables_for_step`] is what backs both the CLI
9//! REPL's `vars`/`print` commands and the DAP server's variables view, so the terminal
10//! and the editor decode identically.
11//!
12//! Variable values are only as good as the debug info: a location the backend did not
13//! capture decodes to [`DebugValueStatus::Unavailable`], and a value whose declared type
14//! is not decodable is reported as [`DebugValueStatus::Raw`] rather than guessed at.
15
16use std::collections::BTreeMap;
17
18use serde::{Deserialize, Serialize};
19use soldb_core::{StepSnapshot, TraceStep, TransactionTrace};
20use soldb_ethdebug::{EthdebugInfo, VariableLocation};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct DebugSession {
24    pub trace: TransactionTrace,
25    pub ethdebug: Option<EthdebugInfo>,
26    #[serde(default)]
27    pub source_contents: BTreeMap<u64, String>,
28    #[serde(default)]
29    pub functions: Vec<SourceFunction>,
30}
31
32impl DebugSession {
33    #[must_use]
34    pub fn new(trace: TransactionTrace) -> Self {
35        Self {
36            trace,
37            ethdebug: None,
38            source_contents: BTreeMap::new(),
39            functions: Vec::new(),
40        }
41    }
42
43    #[must_use]
44    pub fn with_ethdebug(
45        trace: TransactionTrace,
46        ethdebug: EthdebugInfo,
47        source_contents: BTreeMap<u64, String>,
48    ) -> Self {
49        let functions = source_contents
50            .iter()
51            .flat_map(|(source_id, source)| parse_source_functions(*source_id, source))
52            .collect();
53        Self {
54            trace,
55            ethdebug: Some(ethdebug),
56            source_contents,
57            functions,
58        }
59    }
60
61    #[must_use]
62    pub fn step(&self, step_index: usize) -> Option<DebugStep> {
63        let step = self.trace.steps.get(step_index)?;
64        Some(DebugStep {
65            index: step_index,
66            pc: step.pc,
67            op: step.op.clone(),
68            gas: step.gas,
69            gas_cost: step.gas_cost,
70            depth: step.depth,
71            source: self.source_span(step.pc),
72            function: self.function_at_pc(step.pc).cloned(),
73            snapshot: step.normalized_snapshot(),
74            variables: self.variables_at_step(step),
75        })
76    }
77
78    #[must_use]
79    pub fn steps(&self) -> Vec<DebugStep> {
80        (0..self.trace.steps.len())
81            .filter_map(|step_index| self.step(step_index))
82            .collect()
83    }
84
85    #[must_use]
86    pub fn source_span(&self, pc: u64) -> Option<SourceSpan> {
87        let ethdebug = self.ethdebug.as_ref()?;
88        let instruction = ethdebug.instruction_at_pc(pc)?;
89        let location = instruction.source_location()?;
90        let path = ethdebug.sources.get(&location.source_id)?.clone();
91        let position = self
92            .source_contents
93            .get(&location.source_id)
94            .map(|source| line_column_for_offset(source, location.offset as usize));
95        Some(SourceSpan {
96            source_id: location.source_id,
97            path,
98            offset: location.offset,
99            length: location.length,
100            line: position.map_or(0, |position| position.line),
101            column: position.map_or(0, |position| position.column),
102        })
103    }
104
105    #[must_use]
106    pub fn function_at_pc(&self, pc: u64) -> Option<&SourceFunction> {
107        let location = self
108            .ethdebug
109            .as_ref()?
110            .instruction_at_pc(pc)?
111            .source_location()?;
112        self.functions
113            .iter()
114            .filter(|function| {
115                function.source_id == location.source_id
116                    && function.declaration_start <= location.offset
117                    && location.offset <= function.body_end
118            })
119            .min_by_key(|function| function.body_end.saturating_sub(function.declaration_start))
120    }
121
122    #[must_use]
123    pub fn variables_at_step(&self, step: &TraceStep) -> Vec<DebugVariable> {
124        let Some(ethdebug) = &self.ethdebug else {
125            return Vec::new();
126        };
127        variables_for_step(&self.trace, ethdebug, step)
128    }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub struct DebugStep {
133    pub index: usize,
134    pub pc: u64,
135    pub op: String,
136    pub gas: u64,
137    pub gas_cost: u64,
138    pub depth: u64,
139    pub source: Option<SourceSpan>,
140    pub function: Option<SourceFunction>,
141    pub snapshot: StepSnapshot,
142    pub variables: Vec<DebugVariable>,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct SourceSpan {
147    pub source_id: u64,
148    pub path: String,
149    pub offset: u64,
150    pub length: u64,
151    pub line: u64,
152    pub column: u64,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct SourceFunction {
157    pub source_id: u64,
158    pub name: String,
159    pub params: Vec<SourceParam>,
160    pub declaration_start: u64,
161    pub body_end: u64,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub struct SourceParam {
166    pub name: String,
167    pub ty: String,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct DebugVariable {
172    pub name: String,
173    pub ty: String,
174    pub location: DebugLocation,
175    pub value: DebugValue,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct DebugLocation {
180    pub kind: String,
181    pub offset: u64,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
185pub struct DebugValue {
186    pub display: String,
187    pub raw: Option<String>,
188    pub status: DebugValueStatus,
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "snake_case")]
193pub enum DebugValueStatus {
194    Decoded,
195    Raw,
196    Unavailable,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200struct SourcePosition {
201    line: u64,
202    column: u64,
203}
204
205#[must_use]
206pub fn variables_for_step(
207    trace: &TransactionTrace,
208    ethdebug: &EthdebugInfo,
209    step: &TraceStep,
210) -> Vec<DebugVariable> {
211    ethdebug
212        .variables_at_pc(step.pc)
213        .into_iter()
214        .map(|variable| decode_variable(trace, step, variable))
215        .collect()
216}
217
218fn decode_variable(
219    trace: &TransactionTrace,
220    step: &TraceStep,
221    variable: &VariableLocation,
222) -> DebugVariable {
223    let raw = raw_value_for_location(trace, step, variable);
224    let value = raw.map_or_else(
225        || DebugValue {
226            display: "<unavailable>".to_owned(),
227            raw: None,
228            status: DebugValueStatus::Unavailable,
229        },
230        |raw| decode_debug_value(&raw, &variable.ty),
231    );
232    DebugVariable {
233        name: variable.name.clone(),
234        ty: variable.ty.clone(),
235        location: DebugLocation {
236            kind: variable.location_type.clone(),
237            offset: variable.offset,
238        },
239        value,
240    }
241}
242
243fn raw_value_for_location(
244    trace: &TransactionTrace,
245    step: &TraceStep,
246    variable: &VariableLocation,
247) -> Option<String> {
248    let snapshot = step.snapshot_ref();
249    match variable.location_type.as_str() {
250        "stack" => snapshot
251            .stack
252            .get(variable.offset as usize)
253            .map(|value| normalize_hex(value)),
254        "memory" => word_from_hex_bytes(snapshot.memory?, variable.offset as usize),
255        "calldata" => word_from_hex_bytes(&trace.input_data, variable.offset as usize),
256        "storage" => storage_value(snapshot.storage, variable.offset),
257        _ => None,
258    }
259}
260
261fn decode_debug_value(raw: &str, ty: &str) -> DebugValue {
262    let ty = ty.trim();
263    let normalized = normalize_hex(raw);
264    let word = normalized.trim_start_matches("0x");
265    let decoded = decode_static_word(word, ty);
266    match decoded {
267        Some(display) => DebugValue {
268            display,
269            raw: Some(normalized),
270            status: DebugValueStatus::Decoded,
271        },
272        None => DebugValue {
273            display: normalized.clone(),
274            raw: Some(normalized),
275            status: DebugValueStatus::Raw,
276        },
277    }
278}
279
280fn decode_static_word(word: &str, ty: &str) -> Option<String> {
281    if !word.bytes().all(|byte| byte.is_ascii_hexdigit()) {
282        return None;
283    }
284    if ty.starts_with("uint") {
285        return Some(format_uint_word(word));
286    }
287    if ty == "address" {
288        let padded = left_pad_word(word);
289        return Some(
290            format!("0x{}", &padded[padded.len().saturating_sub(40)..]).to_ascii_lowercase(),
291        );
292    }
293    if ty == "bool" {
294        let trimmed = word.trim_start_matches('0');
295        return match trimmed {
296            "" | "0" => Some("false".to_owned()),
297            "1" => Some("true".to_owned()),
298            _ => None,
299        };
300    }
301    if ty == "bytes32" {
302        return Some(normalize_hex(word));
303    }
304    if let Some(size) = fixed_bytes_size(ty) {
305        let padded = right_pad_word(word);
306        return Some(format!("0x{}", &padded[..size * 2]).to_ascii_lowercase());
307    }
308    None
309}
310
311fn fixed_bytes_size(ty: &str) -> Option<usize> {
312    let size = ty.strip_prefix("bytes")?.parse::<usize>().ok()?;
313    (1..=32).contains(&size).then_some(size)
314}
315
316fn format_uint_word(word: &str) -> String {
317    let trimmed = word.trim_start_matches('0');
318    if trimmed.is_empty() {
319        return "0".to_owned();
320    }
321    if trimmed.len() <= 32 {
322        return u128::from_str_radix(trimmed, 16)
323            .map(|value| value.to_string())
324            .unwrap_or_else(|_| format!("0x{}", trimmed.to_ascii_lowercase()));
325    }
326    format!("0x{}", trimmed.to_ascii_lowercase())
327}
328
329fn storage_value(storage: &BTreeMap<String, String>, slot: u64) -> Option<String> {
330    storage
331        .get(&format!("0x{slot:x}"))
332        .or_else(|| storage.get(&format!("{slot:x}")))
333        .or_else(|| storage.get(&format!("0x{slot:064x}")))
334        .or_else(|| storage.get(&format!("{slot:064x}")))
335        .map(|value| normalize_hex(value))
336}
337
338fn word_from_hex_bytes(input: &str, byte_offset: usize) -> Option<String> {
339    let hex = input.trim_start_matches("0x");
340    let start = byte_offset.checked_mul(2)?;
341    let end = start.checked_add(64)?;
342    let word = hex.get(start..end)?;
343    word.bytes()
344        .all(|byte| byte.is_ascii_hexdigit())
345        .then(|| format!("0x{}", word.to_ascii_lowercase()))
346}
347
348fn normalize_hex(value: &str) -> String {
349    let value = value.trim();
350    let hex = value.strip_prefix("0x").unwrap_or(value);
351    format!("0x{}", hex.to_ascii_lowercase())
352}
353
354fn left_pad_word(word: &str) -> String {
355    let word = word.trim_start_matches("0x").to_ascii_lowercase();
356    if word.len() >= 64 {
357        return word;
358    }
359    format!("{:0>64}", word)
360}
361
362fn right_pad_word(word: &str) -> String {
363    let word = word.trim_start_matches("0x").to_ascii_lowercase();
364    if word.len() >= 64 {
365        return word;
366    }
367    format!("{:0<64}", word)
368}
369
370fn line_column_for_offset(source: &str, offset: usize) -> SourcePosition {
371    let mut line = 1;
372    let mut column = 1;
373    for byte in source.bytes().take(offset.min(source.len())) {
374        if byte == b'\n' {
375            line += 1;
376            column = 1;
377        } else {
378            column += 1;
379        }
380    }
381    SourcePosition { line, column }
382}
383
384#[must_use]
385pub fn parse_source_functions(source_id: u64, source: &str) -> Vec<SourceFunction> {
386    let mut functions = Vec::new();
387    let mut cursor = 0;
388    while let Some(keyword_start) = find_solidity_keyword(source, "function", cursor) {
389        let mut index = keyword_start + "function".len();
390        index = skip_ascii_whitespace(source, index);
391        let Some((name, name_end)) = parse_identifier(source, index) else {
392            cursor = index;
393            continue;
394        };
395        index = skip_ascii_whitespace(source, name_end);
396        if source.as_bytes().get(index) != Some(&b'(') {
397            cursor = index;
398            continue;
399        }
400        let Some(params_end) = find_matching_delimiter(source, index, b'(', b')') else {
401            cursor = index + 1;
402            continue;
403        };
404        let params = parse_source_params(&source[index + 1..params_end]);
405        let Some(body_start) = find_next_byte(source, params_end + 1, b'{') else {
406            cursor = params_end + 1;
407            continue;
408        };
409        let Some(body_end) = find_matching_delimiter(source, body_start, b'{', b'}') else {
410            cursor = body_start + 1;
411            continue;
412        };
413
414        functions.push(SourceFunction {
415            source_id,
416            name: name.to_owned(),
417            params,
418            declaration_start: keyword_start as u64,
419            body_end: body_end as u64,
420        });
421        cursor = body_end + 1;
422    }
423    functions
424}
425
426fn parse_source_params(params: &str) -> Vec<SourceParam> {
427    split_top_level_commas(params)
428        .into_iter()
429        .enumerate()
430        .filter_map(|(index, param)| {
431            let param = param.trim();
432            if param.is_empty() {
433                return None;
434            }
435            let mut tokens = param.split_whitespace().collect::<Vec<_>>();
436            let name = tokens
437                .last()
438                .copied()
439                .filter(|token| is_identifier(token))
440                .map_or_else(|| format!("arg{index}"), str::to_owned);
441            if tokens.last().copied() == Some(name.as_str()) && tokens.len() > 1 {
442                tokens.pop();
443            }
444            let ty = tokens
445                .into_iter()
446                .filter(|token| !matches!(*token, "memory" | "calldata" | "storage" | "payable"))
447                .collect::<Vec<_>>()
448                .join(" ");
449            (!ty.is_empty()).then_some(SourceParam { name, ty })
450        })
451        .collect()
452}
453
454fn split_top_level_commas(input: &str) -> Vec<&str> {
455    let mut parts = Vec::new();
456    let mut start = 0;
457    let mut depth = 0_i32;
458    for (index, byte) in input.bytes().enumerate() {
459        match byte {
460            b'(' | b'[' => depth += 1,
461            b')' | b']' => depth -= 1,
462            b',' if depth == 0 => {
463                parts.push(&input[start..index]);
464                start = index + 1;
465            }
466            _ => {}
467        }
468    }
469    parts.push(&input[start..]);
470    parts
471}
472
473fn find_solidity_keyword(source: &str, keyword: &str, start: usize) -> Option<usize> {
474    let mut cursor = start;
475    while let Some(relative) = source[cursor..].find(keyword) {
476        let absolute = cursor + relative;
477        let before = absolute
478            .checked_sub(1)
479            .and_then(|index| source.as_bytes().get(index))
480            .copied();
481        let after = source.as_bytes().get(absolute + keyword.len()).copied();
482        if before.is_none_or(|byte| !is_identifier_byte(byte))
483            && after.is_none_or(|byte| !is_identifier_byte(byte))
484        {
485            return Some(absolute);
486        }
487        cursor = absolute + keyword.len();
488    }
489    None
490}
491
492fn parse_identifier(source: &str, start: usize) -> Option<(&str, usize)> {
493    let bytes = source.as_bytes();
494    let first = *bytes.get(start)?;
495    if !is_identifier_start_byte(first) {
496        return None;
497    }
498    let mut end = start + 1;
499    while bytes.get(end).is_some_and(|byte| is_identifier_byte(*byte)) {
500        end += 1;
501    }
502    Some((&source[start..end], end))
503}
504
505fn is_identifier(input: &str) -> bool {
506    let mut bytes = input.bytes();
507    let Some(first) = bytes.next() else {
508        return false;
509    };
510    is_identifier_start_byte(first) && bytes.all(is_identifier_byte)
511}
512
513fn is_identifier_start_byte(byte: u8) -> bool {
514    byte == b'_' || byte.is_ascii_alphabetic()
515}
516
517fn is_identifier_byte(byte: u8) -> bool {
518    is_identifier_start_byte(byte) || byte.is_ascii_digit()
519}
520
521fn skip_ascii_whitespace(source: &str, mut index: usize) -> usize {
522    while source
523        .as_bytes()
524        .get(index)
525        .is_some_and(u8::is_ascii_whitespace)
526    {
527        index += 1;
528    }
529    index
530}
531
532fn find_next_byte(source: &str, start: usize, needle: u8) -> Option<usize> {
533    source
534        .as_bytes()
535        .iter()
536        .enumerate()
537        .skip(start)
538        .find_map(|(index, byte)| (*byte == needle).then_some(index))
539}
540
541fn find_matching_delimiter(source: &str, open_index: usize, open: u8, close: u8) -> Option<usize> {
542    let mut depth = 0_i32;
543    for (index, byte) in source.bytes().enumerate().skip(open_index) {
544        if byte == open {
545            depth += 1;
546        } else if byte == close {
547            depth -= 1;
548            if depth == 0 {
549                return Some(index);
550            }
551        }
552    }
553    None
554}
555
556#[cfg(test)]
557mod tests {
558    use std::collections::BTreeMap;
559
560    use serde_json::json;
561    use soldb_core::{TraceStep, TransactionTrace};
562    use soldb_ethdebug::{EthdebugInfo, Instruction, VariableLocation};
563
564    use super::{parse_source_functions, DebugSession, DebugValueStatus};
565
566    #[test]
567    fn builds_source_steps_and_decodes_active_variables() {
568        let source =
569            "contract Counter {\n  function set(uint256 x) public {\n    value = x;\n  }\n}\n";
570        let offset = source.find("value = x").expect("source offset") as u64;
571        let trace = sample_trace();
572        let mut sources = BTreeMap::new();
573        sources.insert(0, "Counter.sol".to_owned());
574        let mut source_contents = BTreeMap::new();
575        source_contents.insert(0, source.to_owned());
576        let mut variable_locations = BTreeMap::new();
577        variable_locations.insert(
578            0,
579            vec![
580                VariableLocation {
581                    name: "x".to_owned(),
582                    ty: "uint256".to_owned(),
583                    location_type: "stack".to_owned(),
584                    offset: 0,
585                    pc_range: (0, 1),
586                },
587                VariableLocation {
588                    name: "sender".to_owned(),
589                    ty: "address".to_owned(),
590                    location_type: "calldata".to_owned(),
591                    offset: 4,
592                    pc_range: (0, 1),
593                },
594            ],
595        );
596        let info = EthdebugInfo {
597            compilation: json!({}),
598            contract_name: "Counter".to_owned(),
599            environment: "runtime".to_owned(),
600            instructions: vec![Instruction {
601                offset: 0,
602                operation: json!({"mnemonic": "PUSH1"}),
603                context: Some(json!({
604                    "code": {
605                        "source": {"id": 0},
606                        "range": {"offset": offset, "length": 9}
607                    }
608                })),
609            }],
610            sources,
611            variable_locations,
612        };
613
614        let session = DebugSession::with_ethdebug(trace, info, source_contents);
615        let step = session.step(0).expect("debug step");
616        assert_eq!(step.source.as_ref().expect("source").line, 3);
617        assert_eq!(step.function.as_ref().expect("function").name, "set");
618        assert_eq!(step.snapshot.stack, ["0x2a"]);
619        assert_eq!(step.variables[0].name, "x");
620        assert_eq!(step.variables[0].value.display, "42");
621        assert_eq!(step.variables[0].value.status, DebugValueStatus::Decoded);
622        assert_eq!(
623            step.variables[1].value.display,
624            "0x1111111111111111111111111111111111111111"
625        );
626    }
627
628    #[test]
629    fn keeps_raw_values_when_type_is_dynamic_or_location_is_unknown() {
630        let trace = sample_trace();
631        let mut variable_locations = BTreeMap::new();
632        variable_locations.insert(
633            0,
634            vec![
635                VariableLocation {
636                    name: "name".to_owned(),
637                    ty: "string".to_owned(),
638                    location_type: "stack".to_owned(),
639                    offset: 0,
640                    pc_range: (0, 0),
641                },
642                VariableLocation {
643                    name: "missing".to_owned(),
644                    ty: "uint256".to_owned(),
645                    location_type: "stack".to_owned(),
646                    offset: 99,
647                    pc_range: (0, 0),
648                },
649            ],
650        );
651        let info = EthdebugInfo {
652            compilation: json!({}),
653            contract_name: "Counter".to_owned(),
654            environment: "runtime".to_owned(),
655            instructions: Vec::new(),
656            sources: BTreeMap::new(),
657            variable_locations,
658        };
659
660        let session = DebugSession::with_ethdebug(trace, info, BTreeMap::new());
661        let step = session.step(0).expect("debug step");
662        assert_eq!(step.variables[0].value.status, DebugValueStatus::Raw);
663        assert_eq!(step.variables[0].value.display, "0x2a");
664        assert_eq!(
665            step.variables[1].value.status,
666            DebugValueStatus::Unavailable
667        );
668    }
669
670    #[test]
671    fn parses_source_functions_with_tuple_and_array_params() {
672        let functions = parse_source_functions(
673            7,
674            "contract C { function submit(Person memory p, uint256[2] memory xs) public {} }",
675        );
676        assert_eq!(functions[0].source_id, 7);
677        assert_eq!(functions[0].name, "submit");
678        assert_eq!(functions[0].params[0].name, "p");
679        assert_eq!(functions[0].params[0].ty, "Person");
680        assert_eq!(functions[0].params[1].name, "xs");
681        assert_eq!(functions[0].params[1].ty, "uint256[2]");
682    }
683
684    fn sample_trace() -> TransactionTrace {
685        TransactionTrace {
686            tx_hash: Some("0xabc".to_owned()),
687            from_addr: "0x1".to_owned(),
688            to_addr: Some("0x2".to_owned()),
689            value: "0".to_owned(),
690            input_data: format!(
691                "0x12345678{}",
692                "0000000000000000000000001111111111111111111111111111111111111111"
693            ),
694            gas_used: 1,
695            output: "0x".to_owned(),
696            success: true,
697            error: None,
698            debug_trace_available: true,
699            contract_address: None,
700            backend: Some("debug-rpc".to_owned()),
701            capabilities: Default::default(),
702            artifacts: Default::default(),
703            steps: vec![TraceStep {
704                pc: 0,
705                op: "PUSH1".to_owned(),
706                gas: 1,
707                gas_cost: 0,
708                depth: 0,
709                stack: vec!["0x2a".to_owned()],
710                memory: Some(format!("{}{}", "00".repeat(32), "2a".repeat(32))),
711                storage: Some(BTreeMap::from([("0x0".to_owned(), "0x2a".to_owned())])),
712                error: None,
713                snapshot: Default::default(),
714            }],
715        }
716    }
717}