1use 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}