use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use soldb_core::{StepSnapshot, TraceStep, TransactionTrace, Word as StackWord};
use soldb_ethdebug::{decode_value, parse_word, EthdebugInfo, VariableLocation, Word};
use crate::decode::ValueReader;
use crate::roots::root_calls;
use crate::stepping::calldata_for_step;
pub mod condition;
pub mod debug_diff;
mod decode;
mod locals;
mod roots;
pub mod state;
pub mod stepping;
mod types;
pub use condition::{Condition, ConditionContext, Evaluation};
pub use debug_diff::{
capture_debug_trace, compare_debug_traces, DebugCheckpoint, DebugDiffMode, DebugDiffReport,
DebugDifference, DebugDifferenceKind, DebugExecution, DebugTrace, DebugTraceEvent,
DebugTraceSummary,
};
pub use roots::check_root_calls;
pub use soldb_ethdebug::{CodeGenerator, StorageLayout};
pub use state::{
short_hex, state_value, state_variables, CachedChain, ChainRead, ChainStorage, StateSource,
StateVariable, StorageTape, StorageWords,
};
pub use stepping::{
address_from_word, call_target, normalize_address, source_path_matches, ContractDebugInfo,
Frame, FunctionId, InferredVariable, JumpMarker, LineKey, LocalsStatus, ResolvedFunction,
ResolvedLine, SourceListing, StepLocation, StepMap, VariableKind, INFERRED_LOCALS_NOTE,
INFERRED_LOCALS_WARNING,
};
pub use types::{SourceTypes, StructMember};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugSession {
pub trace: TransactionTrace,
pub ethdebug: Option<EthdebugInfo>,
#[serde(default)]
pub source_contents: BTreeMap<u64, String>,
#[serde(default)]
pub functions: Vec<SourceFunction>,
}
impl DebugSession {
#[must_use]
pub fn new(trace: TransactionTrace) -> Self {
Self {
trace,
ethdebug: None,
source_contents: BTreeMap::new(),
functions: Vec::new(),
}
}
#[must_use]
pub fn with_ethdebug(
trace: TransactionTrace,
ethdebug: EthdebugInfo,
source_contents: BTreeMap<u64, String>,
) -> Self {
let mut session = Self::new(trace);
session.attach_ethdebug(ethdebug, source_contents);
session
}
pub fn attach_ethdebug(
&mut self,
ethdebug: EthdebugInfo,
source_contents: BTreeMap<u64, String>,
) {
self.functions = source_contents
.iter()
.flat_map(|(source_id, source)| parse_source_functions(*source_id, source))
.collect();
self.ethdebug = Some(ethdebug);
self.source_contents = source_contents;
}
#[must_use]
pub fn step(&self, step_index: usize) -> Option<DebugStep> {
let step = self.trace.steps.get(step_index)?;
Some(DebugStep {
index: step_index,
pc: step.pc,
op: step.op.to_string(),
gas: step.gas,
gas_cost: step.gas_cost,
depth: step.depth,
source: self.source_span(step.pc),
function: self.function_at_pc(step.pc).cloned(),
snapshot: step.normalized_snapshot(),
variables: self.variables_at_step(step_index),
})
}
#[must_use]
pub fn steps(&self) -> Vec<DebugStep> {
(0..self.trace.steps.len())
.filter_map(|step_index| self.step(step_index))
.collect()
}
#[must_use]
pub fn source_span(&self, pc: u64) -> Option<SourceSpan> {
let ethdebug = self.ethdebug.as_ref()?;
let instruction = ethdebug.instruction_at_pc(pc)?;
let location = instruction.source_location()?;
let path = ethdebug.sources.get(&location.source_id)?.clone();
let position = self
.source_contents
.get(&location.source_id)
.map(|source| line_column_for_offset(source, location.offset as usize));
Some(SourceSpan {
source_id: location.source_id,
path,
offset: location.offset,
length: location.length,
line: position.map_or(0, |position| position.line),
column: position.map_or(0, |position| position.column),
})
}
#[must_use]
pub fn function_at_pc(&self, pc: u64) -> Option<&SourceFunction> {
let location = self
.ethdebug
.as_ref()?
.instruction_at_pc(pc)?
.source_location()?;
self.functions
.iter()
.filter(|function| {
function.source_id == location.source_id
&& function.declaration_start <= location.offset
&& location.offset <= function.body_end
})
.min_by_key(|function| function.body_end.saturating_sub(function.declaration_start))
}
#[must_use]
pub fn variables_at_step(&self, step_index: usize) -> Vec<DebugVariable> {
let Some(ethdebug) = &self.ethdebug else {
return Vec::new();
};
variables_for_step(&self.trace, ethdebug, step_index)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugStep {
pub index: usize,
pub pc: u64,
pub op: String,
pub gas: u64,
pub gas_cost: u64,
pub depth: u64,
pub source: Option<SourceSpan>,
pub function: Option<SourceFunction>,
pub snapshot: StepSnapshot,
pub variables: Vec<DebugVariable>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceSpan {
pub source_id: u64,
pub path: String,
pub offset: u64,
pub length: u64,
pub line: u64,
pub column: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceFunction {
pub source_id: u64,
pub name: String,
pub params: Vec<SourceParam>,
pub declaration_start: u64,
#[serde(default)]
pub declaration_line: u64,
#[serde(default)]
pub body_start: u64,
pub body_end: u64,
#[serde(default)]
pub returns: Vec<SourceLocal>,
#[serde(default)]
pub locals: Vec<SourceLocal>,
#[serde(default)]
pub has_modifiers: bool,
#[serde(default)]
pub modifiers: Vec<String>,
#[serde(default)]
pub placeholder: Option<u64>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ByteRange {
pub start: u64,
pub end: u64,
}
impl ByteRange {
#[must_use]
pub const fn contains(self, offset: u64) -> bool {
self.start <= offset && offset < self.end
}
#[must_use]
pub const fn covers(self, offset: u64, length: u64) -> bool {
self.start <= offset && offset.saturating_add(length) <= self.end
}
#[must_use]
pub const fn encloses(self, other: Self) -> bool {
self.start <= other.start && other.end <= self.end
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceLocal {
pub name: String,
pub ty: String,
#[serde(default)]
pub location: Option<String>,
pub declaration: ByteRange,
pub statement: ByteRange,
pub position: usize,
pub scope: ByteRange,
}
impl SourceLocal {
#[must_use]
pub fn declared_type(&self) -> String {
match &self.location {
Some(location) => format!("{} {location}", self.ty),
None => self.ty.clone(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceParam {
pub name: String,
pub ty: String,
#[serde(default)]
pub location: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FrameArgument {
pub name: String,
pub ty: String,
pub value: DebugValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugVariable {
pub name: String,
pub ty: String,
pub location: DebugLocation,
pub value: DebugValue,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugLocation {
pub kind: String,
pub offset: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DebugValue {
pub display: String,
pub raw: Option<String>,
pub status: DebugValueStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DebugValueStatus {
Decoded,
Raw,
Unavailable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SourcePosition {
line: u64,
column: u64,
}
#[must_use]
pub fn variables_for_step(
trace: &TransactionTrace,
ethdebug: &EthdebugInfo,
step_index: usize,
) -> Vec<DebugVariable> {
let Some(step) = trace.steps.get(step_index) else {
return Vec::new();
};
let variables = ethdebug.variables_at_pc(step.pc);
if variables.is_empty() {
return Vec::new();
}
let roots = root_calls(trace);
let calldata = calldata_for_step(trace, &roots, step_index);
variables
.into_iter()
.map(|variable| decode_variable(step, calldata, variable))
.collect()
}
fn decode_variable(step: &TraceStep, calldata: &str, variable: &VariableLocation) -> DebugVariable {
let raw = raw_value_for_location(step, calldata, variable);
let value = raw.map_or_else(
|| DebugValue {
display: "<unavailable>".to_owned(),
raw: None,
status: DebugValueStatus::Unavailable,
},
|raw| decode_debug_value(&raw, &variable.ty),
);
DebugVariable {
name: variable.name.clone(),
ty: variable.ty.clone(),
location: DebugLocation {
kind: variable.location_type.clone(),
offset: variable.offset,
},
value,
}
}
fn raw_value_for_location(
step: &TraceStep,
calldata: &str,
variable: &VariableLocation,
) -> Option<String> {
let snapshot = step.snapshot_ref();
match variable.location_type.as_str() {
"stack" => snapshot
.stack
.get(variable.offset as usize)
.map(|value| normalize_hex(value)),
"memory" => word_from_hex_bytes(snapshot.memory?, variable.offset as usize),
"calldata" => word_from_hex_bytes(calldata, variable.offset as usize),
"storage" => storage_value(snapshot.storage, variable.offset),
_ => None,
}
}
pub(crate) fn decode_debug_value(raw: &str, ty: &str) -> DebugValue {
let ty = ty.trim();
let normalized = normalize_hex(raw);
let word = normalized.trim_start_matches("0x");
let decoded = decode_static_word(word, ty);
match decoded {
Some(display) => DebugValue {
display,
raw: Some(normalized),
status: DebugValueStatus::Decoded,
},
None => DebugValue {
display: normalized.clone(),
raw: Some(normalized),
status: DebugValueStatus::Raw,
},
}
}
pub(crate) fn decode_static_word(word: &str, ty: &str) -> Option<String> {
if !word.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
if ty.starts_with("uint") {
return Some(format_uint_word(word));
}
if ty.starts_with("int") {
return Some(format_int_word(word));
}
if ty == "address" {
let padded = left_pad_word(word);
return Some(
format!("0x{}", &padded[padded.len().saturating_sub(40)..]).to_ascii_lowercase(),
);
}
if ty == "bool" {
let trimmed = word.trim_start_matches('0');
return match trimmed {
"" | "0" => Some("false".to_owned()),
"1" => Some("true".to_owned()),
_ => None,
};
}
if ty == "bytes32" {
return Some(normalize_hex(word));
}
if let Some(size) = fixed_bytes_size(ty) {
let padded = right_pad_word(word);
return Some(format!("0x{}", &padded[..size * 2]).to_ascii_lowercase());
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArgumentOrder {
FirstOnTop,
LastOnTop,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArgumentLayout {
Ordered(ArgumentOrder),
TopWords,
}
impl ArgumentOrder {
#[must_use]
fn word_index(self, index: usize, count: usize, stack_len: usize) -> usize {
match self {
Self::FirstOnTop => stack_len - 1 - index,
Self::LastOnTop => stack_len - count + index,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct FrameState<'a> {
pub stack: &'a [StackWord],
pub memory: Option<&'a str>,
}
#[must_use]
pub fn decode_arguments(
params: &[SourceParam],
state: FrameState<'_>,
layout: ArgumentLayout,
types: &SourceTypes,
scope: Option<&str>,
) -> Vec<FrameArgument> {
let stack = state.stack;
if params.is_empty() || stack.len() < params.len() {
return Vec::new();
}
if !params.iter().all(readable_parameter) {
return Vec::new();
}
let order = match layout {
ArgumentLayout::Ordered(order) => order,
ArgumentLayout::TopWords if params.len() == 1 => ArgumentOrder::FirstOnTop,
ArgumentLayout::TopWords => return Vec::new(),
};
params
.iter()
.enumerate()
.map(|(index, param)| {
let word = &stack[order.word_index(index, params.len(), stack.len())];
FrameArgument {
name: param.name.clone(),
ty: param.ty.clone(),
value: argument_value(param, word, state.memory, types, scope),
}
})
.collect()
}
fn argument_value(
param: &SourceParam,
word: &str,
memory: Option<&str>,
types: &SourceTypes,
scope: Option<&str>,
) -> DebugValue {
let Ok(parsed) = parse_word(&format!("0x{}", word.trim_start_matches("0x"))) else {
return DebugValue {
display: "<unreadable stack word>".to_owned(),
raw: Some(word.to_owned()),
status: DebugValueStatus::Unavailable,
};
};
let reader = ValueReader {
memory,
calldata: "",
storage: None,
layout: None,
types,
scope,
};
let raw = Some(short_hex(&parsed));
match param.location.as_deref() {
Some("memory") => reader.variable(&format!("{} memory", param.ty), &[word]),
Some(location) => DebugValue {
display: format!("<{} in {location} at {}>", param.ty, short_hex(&parsed)),
raw,
status: DebugValueStatus::Raw,
},
None => DebugValue {
display: decode_value(value_bytes(&parsed, ¶m.ty), ¶m.ty),
raw,
status: DebugValueStatus::Decoded,
},
}
}
pub(crate) fn readable_parameter(param: &SourceParam) -> bool {
match param.location.as_deref() {
Some("calldata") => !is_dynamic_type(¶m.ty),
Some(_) => true,
None => is_value_type(¶m.ty),
}
}
fn is_dynamic_type(ty: &str) -> bool {
ty == "string" || ty == "bytes" || ty.ends_with("[]")
}
pub(crate) fn array_shape(ty: &str) -> Option<(&str, Option<usize>)> {
let inner = ty.strip_suffix(']')?;
let open = inner.rfind('[')?;
let element = inner[..open].trim();
let length = &inner[open + 1..];
if length.is_empty() {
return Some((element, None));
}
Some((element, Some(length.parse::<usize>().ok()?)))
}
pub(crate) fn memory_word(memory: &str, offset: usize) -> Option<Word> {
let bytes = memory_bytes(memory, offset, 32)?;
let mut word = [0_u8; 32];
word.copy_from_slice(&bytes);
Some(word)
}
pub(crate) fn memory_bytes(memory: &str, offset: usize, length: usize) -> Option<Vec<u8>> {
let start = offset.checked_mul(2)?;
let end = start.checked_add(length.checked_mul(2)?)?;
let digits = memory.get(start..end)?;
if !digits.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
Some(
digits
.as_bytes()
.chunks(2)
.map(|pair| {
u8::from_str_radix(std::str::from_utf8(pair).expect("ascii"), 16).expect("hex")
})
.collect(),
)
}
pub(crate) fn word_as_usize(word: &Word) -> Option<usize> {
if word[..24].iter().any(|byte| *byte != 0) {
return None;
}
usize::try_from(u64::from_be_bytes(word[24..].try_into().expect("8 bytes"))).ok()
}
pub(crate) fn hex_of(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
pub(crate) fn value_bytes<'a>(word: &'a Word, ty: &str) -> &'a [u8] {
if let Some(bits) = integer_bits(ty, "int") {
return &word[32 - bits / 8..];
}
if let Some(size) = fixed_bytes_size(ty) {
return &word[..size];
}
word
}
#[must_use]
pub fn is_value_type(ty: &str) -> bool {
matches!(ty, "bool" | "address" | "address payable")
|| fixed_bytes_size(ty).is_some()
|| integer_bits(ty, "uint").is_some()
|| integer_bits(ty, "int").is_some()
}
fn integer_bits(ty: &str, prefix: &str) -> Option<usize> {
let rest = ty.strip_prefix(prefix)?;
if rest.is_empty() {
return Some(256);
}
let bits = rest.parse::<usize>().ok()?;
(bits % 8 == 0 && (8..=256).contains(&bits)).then_some(bits)
}
fn fixed_bytes_size(ty: &str) -> Option<usize> {
let size = ty.strip_prefix("bytes")?.parse::<usize>().ok()?;
(1..=32).contains(&size).then_some(size)
}
fn format_uint_word(word: &str) -> String {
let trimmed = word.trim_start_matches('0');
if trimmed.is_empty() {
return "0".to_owned();
}
if trimmed.len() <= 32 {
return u128::from_str_radix(trimmed, 16)
.map(|value| value.to_string())
.unwrap_or_else(|_| format!("0x{}", trimmed.to_ascii_lowercase()));
}
format!("0x{}", trimmed.to_ascii_lowercase())
}
fn format_int_word(word: &str) -> String {
let padded = left_pad_word(word);
let Ok(bytes) = (0..32)
.map(|index| u8::from_str_radix(&padded[index * 2..index * 2 + 2], 16))
.collect::<Result<Vec<_>, _>>()
else {
return format!("0x{}", word.to_ascii_lowercase());
};
if bytes[0] & 0x80 == 0 {
return format_uint_word(word);
}
let mut magnitude = bytes.iter().map(|byte| !byte).collect::<Vec<_>>();
for byte in magnitude.iter_mut().rev() {
let (sum, carry) = byte.overflowing_add(1);
*byte = sum;
if !carry {
break;
}
}
let hex = magnitude
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let trimmed = hex.trim_start_matches('0');
if trimmed.len() <= 32 {
if let Ok(value) = u128::from_str_radix(trimmed, 16) {
return format!("-{value}");
}
}
format!("-0x{trimmed}")
}
fn storage_value(storage: &BTreeMap<String, String>, slot: u64) -> Option<String> {
storage
.get(&format!("0x{slot:x}"))
.or_else(|| storage.get(&format!("{slot:x}")))
.or_else(|| storage.get(&format!("0x{slot:064x}")))
.or_else(|| storage.get(&format!("{slot:064x}")))
.map(|value| normalize_hex(value))
}
fn word_from_hex_bytes(input: &str, byte_offset: usize) -> Option<String> {
let hex = input.trim_start_matches("0x");
let start = byte_offset.checked_mul(2)?;
let end = start.checked_add(64)?;
let word = hex.get(start..end)?;
word.bytes()
.all(|byte| byte.is_ascii_hexdigit())
.then(|| format!("0x{}", word.to_ascii_lowercase()))
}
fn normalize_hex(value: &str) -> String {
let value = value.trim();
let hex = value.strip_prefix("0x").unwrap_or(value);
format!("0x{}", hex.to_ascii_lowercase())
}
fn left_pad_word(word: &str) -> String {
let word = word.trim_start_matches("0x").to_ascii_lowercase();
if word.len() >= 64 {
return word;
}
format!("{:0>64}", word)
}
fn right_pad_word(word: &str) -> String {
let word = word.trim_start_matches("0x").to_ascii_lowercase();
if word.len() >= 64 {
return word;
}
format!("{:0<64}", word)
}
fn line_column_for_offset(source: &str, offset: usize) -> SourcePosition {
let mut line = 1;
let mut column = 1;
for byte in source.bytes().take(offset.min(source.len())) {
if byte == b'\n' {
line += 1;
column = 1;
} else {
column += 1;
}
}
SourcePosition { line, column }
}
const DECLARATION_KEYWORDS: [&str; 5] =
["function", "modifier", "constructor", "fallback", "receive"];
#[must_use]
pub fn parse_source_functions(source_id: u64, source: &str) -> Vec<SourceFunction> {
let mut functions = Vec::new();
for keyword in DECLARATION_KEYWORDS {
collect_declarations(source_id, source, keyword, &mut functions);
}
functions.sort_by_key(|function| function.declaration_start);
functions
}
fn collect_declarations(
source_id: u64,
source: &str,
keyword: &str,
functions: &mut Vec<SourceFunction>,
) {
let named = matches!(keyword, "function" | "modifier");
let mut cursor = 0;
while let Some(keyword_start) = find_solidity_keyword(source, keyword, cursor) {
let mut index = skip_ascii_whitespace(source, keyword_start + keyword.len());
let name = if named {
let Some((name, name_end)) = parse_identifier(source, index) else {
cursor = index;
continue;
};
index = skip_ascii_whitespace(source, name_end);
name
} else {
keyword
};
if source.as_bytes().get(index) != Some(&b'(') {
cursor = index;
continue;
}
let Some(params_end) = find_matching_delimiter(source, index, b'(', b')') else {
cursor = index + 1;
continue;
};
let params = parse_source_params(&source[index + 1..params_end]);
let Some(body_start) = find_next_byte(source, params_end + 1, b'{') else {
cursor = params_end + 1;
continue;
};
if source[params_end + 1..body_start].contains(';') {
cursor = params_end + 1;
continue;
}
let Some(body_end) = find_matching_delimiter(source, body_start, b'{', b'}') else {
cursor = body_start + 1;
continue;
};
let (returns, modifiers) = parse_header(source, params_end + 1, body_start);
let modifiers = if keyword == "modifier" {
Vec::new()
} else {
modifiers
};
let returns = returns
.into_iter()
.map(|variable| SourceLocal {
name: variable.name,
ty: variable.ty,
location: variable.location,
declaration: variable.span,
statement: variable.span,
position: 0,
scope: ByteRange {
start: body_start as u64,
end: body_end as u64,
},
})
.collect();
functions.push(SourceFunction {
source_id,
name: name.to_owned(),
params,
declaration_start: keyword_start as u64,
declaration_line: source[..keyword_start]
.bytes()
.filter(|byte| *byte == b'\n')
.count() as u64
+ 1,
body_start: body_start as u64,
body_end: body_end as u64,
returns,
locals: locals::scan_locals(source, body_start, body_end),
has_modifiers: !modifiers.is_empty(),
modifiers,
placeholder: (keyword == "modifier")
.then(|| locals::find_placeholder(source, body_start, body_end))
.flatten(),
});
cursor = body_end + 1;
}
}
const HEADER_KEYWORDS: [&str; 11] = [
"public", "private", "internal", "external", "pure", "view", "payable", "virtual", "override",
"returns", "constant",
];
fn parse_header(source: &str, start: usize, end: usize) -> (Vec<locals::Declared>, Vec<String>) {
let bytes = source.as_bytes();
let mut returns = Vec::new();
let mut modifiers = Vec::new();
let mut index = start;
while index < end {
index = locals::skip_trivia(source, index);
if index >= end {
break;
}
let Some((word, word_end)) = parse_identifier(source, index) else {
index += 1;
continue;
};
let after = locals::skip_trivia(source, word_end);
let arguments = (bytes.get(after) == Some(&b'('))
.then(|| find_matching_delimiter(source, after, b'(', b')'))
.flatten()
.filter(|close| *close < end);
if word == "returns" {
if let Some(close) = arguments {
returns = parse_return_list(source, after, close);
}
} else if !HEADER_KEYWORDS.contains(&word) {
modifiers.push(word.to_owned());
}
index = arguments.map_or(word_end, |close| close + 1);
}
(returns, modifiers)
}
fn parse_return_list(source: &str, open: usize, close: usize) -> Vec<locals::Declared> {
let mut returns = Vec::new();
let mut start = open + 1;
let mut depth = 0_i32;
for index in open + 1..=close {
match source.as_bytes()[index] {
b'(' | b'[' => depth += 1,
b')' | b']' if depth > 0 => depth -= 1,
b',' | b')' => {
let component = source[start..index].trim();
if !component.is_empty() {
let component_start = start
+ (source[start..index].len() - source[start..index].trim_start().len());
let component_start = locals::skip_trivia(source, component_start);
let component_end = component_start + component.len();
if let Some(declared) =
locals::parse_return(source, component_start, component_end, returns.len())
{
returns.push(declared);
}
}
start = index + 1;
}
_ => {}
}
}
returns
}
const PARAM_KEYWORDS: [&str; 4] = ["memory", "calldata", "storage", "payable"];
fn parse_source_params(params: &str) -> Vec<SourceParam> {
let params = strip_param_comments(params);
split_top_level_commas(¶ms)
.into_iter()
.enumerate()
.filter_map(|(index, param)| {
let param = param.trim();
if param.is_empty() {
return None;
}
let mut tokens = param.split_whitespace().collect::<Vec<_>>();
let trailing = tokens.last().copied();
let name = match trailing {
Some(token)
if tokens.len() > 1
&& is_identifier(token)
&& !PARAM_KEYWORDS.contains(&token) =>
{
tokens.pop();
token.to_owned()
}
_ => format!("arg{index}"),
};
let location = tokens
.iter()
.find(|token| matches!(**token, "memory" | "calldata" | "storage"))
.map(|token| (*token).to_owned());
let ty = tokens
.into_iter()
.filter(|token| !PARAM_KEYWORDS.contains(token))
.collect::<Vec<_>>()
.join(" ");
(!ty.is_empty()).then_some(SourceParam { name, ty, location })
})
.collect()
}
fn strip_param_comments(params: &str) -> String {
let mut output = String::with_capacity(params.len());
let mut chars = params.chars().peekable();
while let Some(character) = chars.next() {
if character == '/' {
match chars.peek() {
Some('/') => {
for next in chars.by_ref() {
if next == '\n' {
output.push('\n');
break;
}
}
continue;
}
Some('*') => {
chars.next();
let mut previous = None;
for next in chars.by_ref() {
if previous == Some('*') && next == '/' {
break;
}
previous = Some(next);
}
output.push(' ');
continue;
}
_ => {}
}
}
output.push(character);
}
output
}
fn split_top_level_commas(input: &str) -> Vec<&str> {
let mut parts = Vec::new();
let mut start = 0;
let mut depth = 0_i32;
for (index, byte) in input.bytes().enumerate() {
match byte {
b'(' | b'[' => depth += 1,
b')' | b']' => depth -= 1,
b',' if depth == 0 => {
parts.push(&input[start..index]);
start = index + 1;
}
_ => {}
}
}
parts.push(&input[start..]);
parts
}
pub(crate) fn find_solidity_keyword(source: &str, keyword: &str, start: usize) -> Option<usize> {
let mut cursor = start;
while let Some(relative) = source[cursor..].find(keyword) {
let absolute = cursor + relative;
let before = absolute
.checked_sub(1)
.and_then(|index| source.as_bytes().get(index))
.copied();
let after = source.as_bytes().get(absolute + keyword.len()).copied();
if before.is_none_or(|byte| !is_identifier_byte(byte))
&& after.is_none_or(|byte| !is_identifier_byte(byte))
{
return Some(absolute);
}
cursor = absolute + keyword.len();
}
None
}
pub(crate) fn parse_identifier(source: &str, start: usize) -> Option<(&str, usize)> {
let bytes = source.as_bytes();
let first = *bytes.get(start)?;
if !is_identifier_start_byte(first) {
return None;
}
let mut end = start + 1;
while bytes.get(end).is_some_and(|byte| is_identifier_byte(*byte)) {
end += 1;
}
Some((&source[start..end], end))
}
pub(crate) fn is_identifier(input: &str) -> bool {
let mut bytes = input.bytes();
let Some(first) = bytes.next() else {
return false;
};
is_identifier_start_byte(first) && bytes.all(is_identifier_byte)
}
fn is_identifier_start_byte(byte: u8) -> bool {
byte == b'_' || byte.is_ascii_alphabetic()
}
pub(crate) fn is_identifier_byte(byte: u8) -> bool {
is_identifier_start_byte(byte) || byte.is_ascii_digit()
}
pub(crate) fn skip_ascii_whitespace(source: &str, mut index: usize) -> usize {
while source
.as_bytes()
.get(index)
.is_some_and(u8::is_ascii_whitespace)
{
index += 1;
}
index
}
fn find_next_byte(source: &str, start: usize, needle: u8) -> Option<usize> {
source
.as_bytes()
.iter()
.enumerate()
.skip(start)
.find_map(|(index, byte)| (*byte == needle).then_some(index))
}
pub(crate) fn find_matching_delimiter(
source: &str,
open_index: usize,
open: u8,
close: u8,
) -> Option<usize> {
let mut depth = 0_i32;
for (index, byte) in source.bytes().enumerate().skip(open_index) {
if byte == open {
depth += 1;
} else if byte == close {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
}
None
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use soldb_core::{ExecutionCall, TraceStep, TransactionTrace};
use soldb_ethdebug::{EthdebugInfo, Instruction, VariableLocation};
use super::{parse_source_functions, DebugSession, DebugValueStatus};
#[test]
fn builds_source_steps_and_decodes_active_variables() {
let source =
"contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
let offset = source.find("value = x").expect("source offset") as u64;
let trace = sample_trace();
let mut sources = BTreeMap::new();
sources.insert(0, "Counter.sol".to_owned());
let mut source_contents = BTreeMap::new();
source_contents.insert(0, source.to_owned());
let mut variable_locations = BTreeMap::new();
variable_locations.insert(
0,
vec![
VariableLocation {
name: "x".to_owned(),
ty: "uint256".to_owned(),
location_type: "stack".to_owned(),
offset: 0,
pc_range: (0, 1),
},
VariableLocation {
name: "sender".to_owned(),
ty: "address".to_owned(),
location_type: "calldata".to_owned(),
offset: 4,
pc_range: (0, 1),
},
],
);
let info = EthdebugInfo {
compilation: json!({}),
contract_name: "Counter".to_owned(),
environment: "runtime".to_owned(),
instructions: vec![Instruction {
offset: 0,
operation: json!({"mnemonic": "PUSH1"}),
context: Some(json!({
"code": {
"source": {"id": 0},
"range": {"offset": offset, "length": 9}
}
})),
}],
sources,
variable_locations,
};
let session = DebugSession::with_ethdebug(trace, info, source_contents);
let step = session.step(0).expect("debug step");
assert_eq!(step.source.as_ref().expect("source").line, 3);
assert_eq!(step.function.as_ref().expect("function").name, "set");
assert_eq!(
step.snapshot
.stack
.iter()
.map(|word| &**word)
.collect::<Vec<_>>(),
["0x2a"]
);
assert_eq!(step.variables[0].name, "x");
assert_eq!(step.variables[0].value.display, "42");
assert_eq!(step.variables[0].value.status, DebugValueStatus::Decoded);
assert_eq!(
step.variables[1].value.display,
"0x1111111111111111111111111111111111111111"
);
}
#[test]
fn calldata_variables_read_the_input_of_their_top_level_call() {
let root = |id: usize, entry: usize, input: String| ExecutionCall {
id,
parent_id: None,
depth: 1,
entry_step: Some(entry),
exit_step: Some(entry + 1),
call_type: "CALL".to_owned(),
from: "0x1".to_owned(),
to: "0x2".to_owned(),
bytecode_address: "0x2".to_owned(),
value: "0x0".to_owned(),
input,
gas_limit: 0,
gas_used: None,
output: None,
success: Some(true),
error: None,
};
let mut trace = sample_trace();
trace.steps.push(trace.steps[0].clone());
let second_input = format!(
"0x12345678{:0>64}",
"2222222222222222222222222222222222222222"
);
trace.artifacts.calls = vec![
root(0, 0, trace.input_data.clone()),
root(1, 1, second_input),
];
let info = EthdebugInfo {
compilation: json!({}),
contract_name: "Counter".to_owned(),
environment: "runtime".to_owned(),
instructions: Vec::new(),
sources: BTreeMap::new(),
variable_locations: BTreeMap::from([(
0,
vec![VariableLocation {
name: "sender".to_owned(),
ty: "address".to_owned(),
location_type: "calldata".to_owned(),
offset: 4,
pc_range: (0, 1),
}],
)]),
};
let session = DebugSession::with_ethdebug(trace, info, BTreeMap::new());
let sender = |step: usize| {
session.step(step).expect("debug step").variables[0]
.value
.display
.clone()
};
assert_eq!(sender(0), "0x1111111111111111111111111111111111111111");
assert_eq!(sender(1), "0x2222222222222222222222222222222222222222");
}
#[test]
fn keeps_raw_values_when_type_is_dynamic_or_location_is_unknown() {
let trace = sample_trace();
let mut variable_locations = BTreeMap::new();
variable_locations.insert(
0,
vec![
VariableLocation {
name: "name".to_owned(),
ty: "string".to_owned(),
location_type: "stack".to_owned(),
offset: 0,
pc_range: (0, 0),
},
VariableLocation {
name: "missing".to_owned(),
ty: "uint256".to_owned(),
location_type: "stack".to_owned(),
offset: 99,
pc_range: (0, 0),
},
],
);
let info = EthdebugInfo {
compilation: json!({}),
contract_name: "Counter".to_owned(),
environment: "runtime".to_owned(),
instructions: Vec::new(),
sources: BTreeMap::new(),
variable_locations,
};
let session = DebugSession::with_ethdebug(trace, info, BTreeMap::new());
let step = session.step(0).expect("debug step");
assert_eq!(step.variables[0].value.status, DebugValueStatus::Raw);
assert_eq!(step.variables[0].value.display, "0x2a");
assert_eq!(
step.variables[1].value.status,
DebugValueStatus::Unavailable
);
}
#[test]
fn attaching_ethdebug_matches_constructing_with_it_and_replaces_prior_info() {
let source =
"contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
let offset = source.find("value = x").expect("source offset") as u64;
let info = EthdebugInfo {
compilation: json!({}),
contract_name: "Counter".to_owned(),
environment: "runtime".to_owned(),
instructions: vec![Instruction {
offset: 0,
operation: json!({"mnemonic": "PUSH1"}),
context: Some(json!({
"code": {"source": {"id": 0}, "range": {"offset": offset, "length": 9}}
})),
}],
sources: BTreeMap::from([(0, "Counter.sol".to_owned())]),
variable_locations: BTreeMap::new(),
};
let source_contents = BTreeMap::from([(0, source.to_owned())]);
let constructed =
DebugSession::with_ethdebug(sample_trace(), info.clone(), source_contents.clone());
let mut attached = DebugSession::new(sample_trace());
assert!(attached.ethdebug.is_none());
assert!(attached.functions.is_empty());
attached.attach_ethdebug(info, source_contents);
assert_eq!(attached, constructed);
assert_eq!(attached.functions.len(), 1);
assert_eq!(attached.functions[0].name, "set");
assert_eq!(
attached
.step(0)
.and_then(|step| step.source)
.map(|span| span.line),
Some(3)
);
let other = EthdebugInfo {
compilation: json!({}),
contract_name: "Other".to_owned(),
environment: "runtime".to_owned(),
instructions: Vec::new(),
sources: BTreeMap::new(),
variable_locations: BTreeMap::new(),
};
attached.attach_ethdebug(other, BTreeMap::new());
assert_eq!(
attached
.ethdebug
.as_ref()
.map(|info| info.contract_name.as_str()),
Some("Other")
);
assert!(attached.functions.is_empty());
assert!(attached.source_contents.is_empty());
assert_eq!(attached.step(0).and_then(|step| step.source), None);
assert_eq!(attached.trace, sample_trace());
}
#[test]
fn parses_source_functions_with_tuple_and_array_params() {
let functions = parse_source_functions(
7,
"contract C { function submit(Person memory p, uint256[2] memory xs) public {} }",
);
assert_eq!(functions[0].source_id, 7);
assert_eq!(functions[0].name, "submit");
assert_eq!(functions[0].params[0].name, "p");
assert_eq!(functions[0].params[0].ty, "Person");
assert_eq!(functions[0].params[1].name, "xs");
assert_eq!(functions[0].params[1].ty, "uint256[2]");
}
#[test]
fn unnamed_params_documented_by_comments_do_not_become_parameters() {
let functions = parse_source_functions(
0,
"contract C {\n function burnFrom_withCaller(\n address caller,\n address, //from\n uint256, //amount\n bytes32 //h\n ) external view returns (bool) {}\n}",
);
let params = &functions[0].params;
assert_eq!(functions[0].name, "burnFrom_withCaller");
assert_eq!(params.len(), 4);
assert_eq!(
(params[0].ty.as_str(), params[0].name.as_str()),
("address", "caller")
);
assert_eq!(
(params[1].ty.as_str(), params[1].name.as_str()),
("address", "arg1")
);
assert_eq!(
(params[2].ty.as_str(), params[2].name.as_str()),
("uint256", "arg2")
);
assert_eq!(
(params[3].ty.as_str(), params[3].name.as_str()),
("bytes32", "arg3")
);
}
#[test]
fn unnamed_params_take_positional_names_not_their_type() {
let functions = parse_source_functions(
0,
"contract C { function burn(address from, uint256 amount, bytes32, bytes memory signature) public {} }",
);
let params = &functions[0].params;
assert_eq!(
(params[1].ty.as_str(), params[1].name.as_str()),
("uint256", "amount")
);
assert_eq!(
(params[2].ty.as_str(), params[2].name.as_str()),
("bytes32", "arg2")
);
assert_eq!(
(params[3].ty.as_str(), params[3].name.as_str()),
("bytes", "signature")
);
assert_eq!(params[3].location.as_deref(), Some("memory"));
}
#[test]
fn a_trailing_declaration_keyword_does_not_name_a_param() {
let functions = parse_source_functions(
0,
"contract C { function f(string memory, address payable, bytes calldata data) public {} }",
);
let params = &functions[0].params;
assert_eq!(
(params[0].ty.as_str(), params[0].name.as_str()),
("string", "arg0")
);
assert_eq!(params[0].location.as_deref(), Some("memory"));
assert_eq!(
(params[1].ty.as_str(), params[1].name.as_str()),
("address", "arg1")
);
assert_eq!(
(params[2].ty.as_str(), params[2].name.as_str()),
("bytes", "data")
);
assert_eq!(params[2].location.as_deref(), Some("calldata"));
}
#[test]
fn block_comments_in_a_param_list_separate_rather_than_join() {
let functions = parse_source_functions(
0,
"contract C { function f(uint256 /* wei */ amount, address /*to*/) public {} }",
);
let params = &functions[0].params;
assert_eq!(
(params[0].ty.as_str(), params[0].name.as_str()),
("uint256", "amount")
);
assert_eq!(
(params[1].ty.as_str(), params[1].name.as_str()),
("address", "arg1")
);
}
#[test]
fn stripping_comments_leaves_declaration_offsets_alone() {
let source = "contract C {\n function f(address, //from\n uint256 amount\n ) public {}\n}";
let functions = parse_source_functions(0, source);
let start = functions[0].declaration_start as usize;
assert!(source[start..].starts_with("function f("));
assert_eq!(functions[0].declaration_line, 2);
assert_eq!(source.as_bytes()[functions[0].body_end as usize], b'}');
}
fn sample_trace() -> TransactionTrace {
TransactionTrace {
tx_hash: Some("0xabc".to_owned()),
from_addr: "0x1".to_owned(),
to_addr: Some("0x2".to_owned()),
value: "0".to_owned(),
input_data: format!(
"0x12345678{}",
"0000000000000000000000001111111111111111111111111111111111111111"
),
gas_used: 1,
output: "0x".to_owned(),
success: true,
error: None,
debug_trace_available: true,
contract_address: None,
backend: Some("debug-rpc".to_owned()),
capabilities: Default::default(),
artifacts: Default::default(),
steps: vec![TraceStep {
pc: 0,
op: "PUSH1".into(),
gas: 1,
gas_cost: 0,
depth: 0,
stack: vec!["0x2a".into()],
memory: Some(format!("{}{}", "00".repeat(32), "2a".repeat(32))),
storage: Some(BTreeMap::from([("0x0".to_owned(), "0x2a".to_owned())])),
error: None,
snapshot: Default::default(),
}],
}
}
fn memory_image(words: &[&str]) -> String {
words
.iter()
.map(|word| format!("{:0>64}", word.trim_start_matches("0x")))
.collect()
}
#[test]
fn reads_arguments_that_live_in_memory() {
let memory = memory_image(&[
"5",
"68656c6c6f000000000000000000000000000000000000000000000000000000",
"3",
"7",
"8",
"9",
]);
let types = super::SourceTypes::default();
let reader = super::ValueReader {
memory: Some(&memory),
calldata: "",
storage: None,
layout: None,
types: &types,
scope: None,
};
let read = |offset: usize, ty: &str| reader.read_memory(offset, ty, 0);
assert_eq!(read(0, "string").as_deref(), Some("\"hello\""));
assert_eq!(read(0, "bytes").as_deref(), Some("0x68656c6c6f"));
assert_eq!(read(64, "uint256[]").as_deref(), Some("[7, 8, 9]"));
assert_eq!(read(96, "uint256[2]").as_deref(), Some("[7, 8]"));
assert_eq!(read(4096, "string"), None);
assert_eq!(
read(64, "string[]").as_deref(),
Some("[<string at 0x7>, <string at 0x8>, <string at 0x9>]")
);
}
#[test]
fn a_parameter_is_readable_only_when_its_width_is_known() {
let param = |ty: &str, location: Option<&str>| super::SourceParam {
name: "x".to_owned(),
ty: ty.to_owned(),
location: location.map(str::to_owned),
};
assert!(super::readable_parameter(¶m("uint256", None)));
assert!(super::readable_parameter(¶m("string", Some("memory"))));
assert!(super::readable_parameter(¶m("Config", Some("storage"))));
assert!(super::readable_parameter(¶m(
"uint256[2]",
Some("calldata")
)));
assert!(!super::readable_parameter(¶m(
"bytes",
Some("calldata")
)));
assert!(!super::readable_parameter(¶m(
"uint256[]",
Some("calldata")
)));
assert!(!super::readable_parameter(¶m("Config", None)));
}
}