use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use soldb_core::TransactionTrace;
use soldb_ethdebug::{parse_word, word_hex, StorageLayout, StorageRef, Word};
use crate::stepping::StepMap;
use crate::{DebugValue, DebugValueStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateSource {
Trace,
Chain,
}
pub trait ChainStorage {
fn word(&self, address: &str, slot: &Word) -> Option<Word>;
fn label(&self) -> &str;
}
pub type ChainRead = Box<dyn Fn(&str, &Word) -> Option<Word>>;
pub struct CachedChain<F> {
read: F,
label: String,
words: RefCell<HashMap<(String, Word), Option<Word>>>,
}
impl<F> std::fmt::Debug for CachedChain<F> {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("CachedChain")
.field("label", &self.label)
.field("words", &self.words.borrow().len())
.finish()
}
}
impl<F> CachedChain<F>
where
F: Fn(&str, &Word) -> Option<Word>,
{
pub fn new(label: impl Into<String>, read: F) -> Self {
Self {
read,
label: label.into(),
words: RefCell::new(HashMap::new()),
}
}
}
impl<F> ChainStorage for CachedChain<F>
where
F: Fn(&str, &Word) -> Option<Word>,
{
fn word(&self, address: &str, slot: &Word) -> Option<Word> {
let key = (address.to_ascii_lowercase(), *slot);
if let Some(cached) = self.words.borrow().get(&key) {
return *cached;
}
let word = (self.read)(&key.0, slot);
self.words.borrow_mut().insert(key, word);
word
}
fn label(&self) -> &str {
&self.label
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StateVariable {
pub name: String,
pub ty: String,
pub slot: String,
pub offset: u64,
pub value: DebugValue,
pub source: StateSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StorageTape {
records: HashMap<(usize, Word), Vec<Record>>,
captured: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Record {
from: usize,
value: Option<Word>,
rollback: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Lookup {
Known(Word),
Untouched,
Reverted,
}
impl StorageTape {
#[must_use]
pub fn new(trace: &TransactionTrace, map: &StepMap) -> Self {
let mut records = HashMap::<(usize, Word), Vec<Record>>::new();
for (index, step) in trace.steps.iter().enumerate() {
let snapshot = step.snapshot_ref();
if snapshot.storage_diff.is_empty() {
continue;
}
let Some(context) = map.storage_context_index(index) else {
continue;
};
let from = if &*step.op == "SSTORE" {
index + 1
} else {
index
};
for (slot, change) in snapshot.storage_diff {
let Some(after) = change.after.as_deref() else {
continue;
};
let (Some(slot), Some(value)) = (parse_slot(slot), parse_slot(after)) else {
continue;
};
let entries = records.entry((context, slot)).or_default();
if entries.last().is_some_and(|last| last.value == Some(value)) {
continue;
}
entries.push(Record {
from,
value: Some(value),
rollback: false,
});
}
}
for (entry, last) in map.reverted_spans() {
for entries in records.values_mut() {
let touched = entries
.iter()
.any(|record| record.from > *entry && record.from <= last + 1);
if !touched {
continue;
}
let before = entries
.iter()
.filter(|record| record.from <= *entry)
.max_by_key(|record| (record.from, !record.rollback))
.and_then(|record| record.value);
entries.push(Record {
from: last + 1,
value: before,
rollback: true,
});
}
}
for entries in records.values_mut() {
entries.sort_by_key(|record| (record.from, !record.rollback));
}
Self {
records,
captured: trace.capabilities.storage,
}
}
#[must_use]
pub fn at(&self, step: usize, context: Option<usize>) -> StorageWords<'_> {
StorageWords {
tape: self,
step,
context,
address: None,
chain: None,
}
}
#[must_use]
pub fn at_step<'a>(&'a self, map: &'a StepMap, step: usize) -> StorageWords<'a> {
StorageWords {
tape: self,
step,
context: map.storage_context_index(step),
address: map.storage_address(step),
chain: None,
}
}
fn lookup(&self, context: usize, slot: &Word, step: usize) -> Lookup {
let Some(entries) = self.records.get(&(context, *slot)) else {
return Lookup::Untouched;
};
let count = entries.partition_point(|record| record.from <= step);
match count.checked_sub(1).map(|index| entries[index].value) {
Some(Some(value)) => Lookup::Known(value),
Some(None) => Lookup::Reverted,
None => Lookup::Untouched,
}
}
}
#[derive(Clone, Copy)]
pub struct StorageWords<'a> {
tape: &'a StorageTape,
step: usize,
context: Option<usize>,
address: Option<&'a str>,
chain: Option<&'a dyn ChainStorage>,
}
impl<'a> StorageWords<'a> {
#[must_use]
pub fn with_chain(mut self, chain: Option<&'a dyn ChainStorage>) -> Self {
self.chain = chain;
self
}
#[must_use]
pub fn get(&self, slot: &Word) -> Option<Word> {
match self.tape.lookup(self.context?, slot, self.step) {
Lookup::Known(value) => Some(value),
Lookup::Untouched | Lookup::Reverted => None,
}
}
#[must_use]
fn chain_word(&self, slot: &Word) -> Option<Word> {
self.chain?.word(self.address?, slot)
}
#[must_use]
pub fn chain_label(&self) -> Option<&str> {
Some(self.chain?.label())
}
#[must_use]
pub fn known(&self) -> Vec<(Word, Word)> {
let Some(context) = self.context else {
return Vec::new();
};
let mut known = self
.tape
.records
.keys()
.filter(|(candidate, _)| *candidate == context)
.filter_map(
|(_, slot)| match self.tape.lookup(context, slot, self.step) {
Lookup::Known(value) => Some((*slot, value)),
Lookup::Untouched | Lookup::Reverted => None,
},
)
.collect::<Vec<_>>();
known.sort_unstable();
known
}
#[must_use]
pub fn captured(&self) -> bool {
self.tape.captured
}
fn unavailable(&self, slot: &Word) -> String {
let unread = if self.chain.is_some() {
format!(
"<unknown: slot {} was not touched by this transaction and the chain could not be read>",
short_hex(slot)
)
} else {
format!(
"<unknown: slot {} has not been read or written yet>",
short_hex(slot)
)
};
if !self.tape.captured {
return "<unavailable: this backend recorded no storage>".to_owned();
}
let Some(context) = self.context else {
return "<unavailable: the storage of this frame belongs to no known account>"
.to_owned();
};
match self.tape.lookup(context, slot, self.step) {
Lookup::Reverted => format!(
"<unknown: the write to slot {} was reverted and the value before it was never read>",
short_hex(slot)
),
Lookup::Known(_) | Lookup::Untouched => unread,
}
}
}
#[must_use]
pub fn state_variables(layout: &StorageLayout, words: &StorageWords<'_>) -> Vec<StateVariable> {
layout
.variables
.iter()
.map(|variable| {
let reference = StorageRef {
path: variable.label.clone(),
slot: variable.slot,
offset: variable.offset,
type_id: variable.type_id.clone(),
};
read(layout, words, &reference)
})
.collect()
}
pub fn state_value(
layout: &StorageLayout,
words: &StorageWords<'_>,
path: &str,
) -> Result<StateVariable, String> {
let reference = layout.resolve(path).map_err(|error| error.to_string())?;
Ok(read(layout, words, &reference))
}
fn read(layout: &StorageLayout, words: &StorageWords<'_>, reference: &StorageRef) -> StateVariable {
let ty = layout
.type_of(&reference.type_id)
.map_or_else(|| reference.type_id.clone(), |ty| ty.label.clone());
let from_chain = Cell::new(false);
let decoded = layout.decode(reference, &|slot| {
if let Some(word) = words.get(slot) {
return Some(word);
}
let word = words.chain_word(slot)?;
from_chain.set(true);
Some(word)
});
let value = match decoded {
Ok(decoded) => DebugValue {
display: decoded.display,
raw: decoded.raw,
status: DebugValueStatus::Decoded,
},
Err(missing) => DebugValue {
display: words.unavailable(&missing),
raw: None,
status: DebugValueStatus::Unavailable,
},
};
StateVariable {
name: reference.path.clone(),
ty,
slot: short_hex(&reference.slot),
offset: reference.offset,
value,
source: if from_chain.get() {
StateSource::Chain
} else {
StateSource::Trace
},
}
}
fn parse_slot(text: &str) -> Option<Word> {
let digits = text
.strip_prefix("0x")
.or_else(|| text.strip_prefix("0X"))
.unwrap_or(text);
if digits.is_empty() {
return Some([0_u8; 32]);
}
parse_word(&format!("0x{digits}")).ok()
}
#[must_use]
pub fn short_hex(word: &Word) -> String {
let full = word_hex(word);
let trimmed = full[2..].trim_start_matches('0');
if trimmed.is_empty() {
"0x0".to_owned()
} else {
format!("0x{trimmed}")
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use serde_json::json;
use soldb_core::{StepSnapshot, StorageChange, TraceCapabilities, TraceStep, TransactionTrace};
use soldb_ethdebug::StorageLayout;
use super::{short_hex, state_value, state_variables, StorageTape, Word};
use crate::stepping::StepMap;
use crate::DebugValueStatus;
fn mapping_entry_slot() -> Word {
layout()
.resolve("balances[0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266]")
.expect("entry")
.slot
}
fn layout() -> StorageLayout {
StorageLayout::parse(&json!({
"storage": [
{"astId": 1, "contract": "T.sol:T", "label": "counter", "offset": 0, "slot": "0", "type": "t_uint256"},
{"astId": 2, "contract": "T.sol:T", "label": "balances", "offset": 0, "slot": "1", "type": "t_mapping(t_address,t_uint256)"}
],
"types": {
"t_uint256": {"encoding": "inplace", "label": "uint256", "numberOfBytes": "32"},
"t_address": {"encoding": "inplace", "label": "address", "numberOfBytes": "20"},
"t_mapping(t_address,t_uint256)": {"encoding": "mapping", "key": "t_address", "label": "mapping(address => uint256)", "numberOfBytes": "32", "value": "t_uint256"}
}
}))
.expect("layout")
}
fn step(op: &str, depth: u64, stack: &[&str], touched: &[(&str, &str)]) -> TraceStep {
let storage = touched
.iter()
.map(|(slot, value)| ((*slot).to_owned(), (*value).to_owned()))
.collect::<BTreeMap<_, _>>();
let storage_diff = touched
.iter()
.map(|(slot, value)| {
(
(*slot).to_owned(),
StorageChange {
before: None,
after: Some((*value).to_owned()),
},
)
})
.collect();
TraceStep {
pc: 0,
op: op.into(),
gas: 0,
gas_cost: 0,
depth,
stack: Vec::new(),
memory: None,
storage: None,
error: None,
snapshot: StepSnapshot::new(
stack
.iter()
.map(|word| soldb_core::Word::from(*word))
.collect(),
None,
storage,
storage_diff,
),
}
}
fn trace(steps: Vec<TraceStep>, captured: bool) -> TransactionTrace {
TransactionTrace {
tx_hash: None,
from_addr: "0x1".to_owned(),
to_addr: Some("0xAAaa000000000000000000000000000000000001".to_owned()),
value: "0x0".to_owned(),
input_data: "0x".to_owned(),
gas_used: 0,
output: "0x".to_owned(),
success: true,
error: None,
debug_trace_available: true,
contract_address: None,
backend: None,
capabilities: TraceCapabilities {
storage: captured,
..Default::default()
},
artifacts: Default::default(),
steps,
}
}
fn show(trace: &TransactionTrace, at: usize, path: &str) -> String {
let map = StepMap::new(trace, Vec::new());
let tape = StorageTape::new(trace, &map);
state_value(&layout(), &tape.at_step(&map, at), path)
.expect(path)
.value
.display
}
#[test]
fn a_slot_holds_its_last_recorded_word_until_the_next_write() {
let padded = format!("{:0>64}", "7");
let trace = trace(
vec![
step("PUSH1", 1, &[], &[]),
step("SLOAD", 1, &[], &[(&padded, &format!("{:0>64}", "3"))]),
step("SLOAD", 1, &[], &[("0x0", "0x2a")]),
step("ADD", 1, &[], &[]),
step("SSTORE", 1, &[], &[("0x0", "0x2b")]),
step("POP", 1, &[], &[]),
step("STOP", 1, &[], &[]),
],
true,
);
assert!(show(&trace, 0, "counter").contains("has not been read or written yet"));
assert!(show(&trace, 1, "counter").contains("has not been read or written yet"));
assert_eq!(show(&trace, 2, "counter"), "42");
assert_eq!(show(&trace, 3, "counter"), "42");
assert_eq!(show(&trace, 4, "counter"), "42");
assert_eq!(show(&trace, 5, "counter"), "43");
assert_eq!(show(&trace, 6, "counter"), "43");
let map = StepMap::new(&trace, Vec::new());
let tape = StorageTape::new(&trace, &map);
let words = tape.at_step(&map, 6);
let variables = state_variables(&layout(), &words);
assert_eq!(variables.len(), 2);
assert_eq!(
(
variables[0].name.as_str(),
variables[0].ty.as_str(),
variables[0].slot.as_str()
),
("counter", "uint256", "0x0")
);
assert_eq!(variables[0].value.status, DebugValueStatus::Decoded);
assert_eq!(
variables[0].value.raw.as_deref(),
Some(&format!("0x{:0>64}", "2b")[..])
);
assert_eq!(variables[1].value.display, "<mapping; index it with [key]>");
assert_eq!(variables[1].ty, "mapping(address => uint256)");
let known = words.known();
assert_eq!(known.len(), 2);
assert_eq!(short_hex(&known[0].1), "0x2b");
assert_eq!(short_hex(&known[1].0), "0x7");
let entry = state_value(
&layout(),
&words,
"balances[0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266]",
)
.expect("entry");
assert_eq!(entry.ty, "uint256");
assert_eq!(entry.value.status, DebugValueStatus::Unavailable);
let error = state_value(&layout(), &words, "nothing").expect_err("unknown variable");
assert!(
error.contains("no state variable named `nothing`"),
"{error}"
);
}
#[test]
fn a_reverted_call_keeps_none_of_its_writes() {
let callee = "0xbbbb000000000000000000000000000000000002";
let trace = trace(
vec![
step("SLOAD", 1, &[], &[("0x0", "0x5")]),
step(
"CALL",
1,
&["0x0", "0x0", "0x0", "0x0", "0x0", callee, "0x0"],
&[],
),
step("SSTORE", 2, &[], &[("0x0", "0x9")]),
step("POP", 2, &[], &[]),
step("REVERT", 2, &[], &[]),
step("POP", 1, &[], &[]),
step(
"DELEGATECALL",
1,
&["0x0", "0x0", "0x0", "0x0", callee, "0x0"],
&[],
),
step("SSTORE", 2, &[], &[("0x0", "0x6")]),
step("RETURN", 2, &[], &[]),
step("STOP", 1, &[], &[]),
],
true,
);
let map = StepMap::new(&trace, Vec::new());
let tape = StorageTape::new(&trace, &map);
let caller = map.storage_context_index(0).expect("root context");
let callee_context = map.storage_context_index(2).expect("callee context");
assert_ne!(caller, callee_context);
assert_eq!(map.reverted_spans(), [(2, 4)]);
assert_eq!(show(&trace, 3, "counter"), "9");
let after = tape.at(5, Some(callee_context));
assert!(after.get(&[0_u8; 32]).is_none());
assert!(state_value(&layout(), &after, "counter")
.expect("counter")
.value
.display
.contains("was reverted"));
assert_eq!(show(&trace, 5, "counter"), "5");
assert_eq!(map.storage_context_index(7), Some(caller));
assert_eq!(show(&trace, 7, "counter"), "5");
assert_eq!(show(&trace, 8, "counter"), "6");
assert_eq!(show(&trace, 9, "counter"), "6");
}
struct FakeChain {
words: BTreeMap<(String, Word), Word>,
label: String,
}
impl super::ChainStorage for FakeChain {
fn word(&self, address: &str, slot: &Word) -> Option<Word> {
self.words
.get(&(address.to_ascii_lowercase(), *slot))
.copied()
}
fn label(&self) -> &str {
&self.label
}
}
#[test]
fn a_slot_the_trace_never_touched_can_come_from_the_chain() {
let trace = trace(
vec![
step("SLOAD", 1, &[], &[]),
step("SSTORE", 1, &[], &[("0x0", "0x2a")]),
step("STOP", 1, &[], &[]),
],
true,
);
let map = StepMap::new(&trace, Vec::new());
let tape = StorageTape::new(&trace, &map);
let address = map.storage_address(0).expect("address").to_owned();
let entry_slot = mapping_entry_slot();
let chain = FakeChain {
words: BTreeMap::from([
((address.clone(), [0_u8; 32]), {
let mut word = [0_u8; 32];
word[31] = 9;
word
}),
((address.clone(), entry_slot), {
let mut word = [0_u8; 32];
word[31] = 4;
word
}),
]),
label: "the chain at block 7".to_owned(),
};
let layout = layout();
let words = tape.at_step(&map, 0).with_chain(Some(&chain));
assert_eq!(words.chain_label(), Some("the chain at block 7"));
let counter = state_value(&layout, &words, "counter").expect("counter");
assert_eq!(counter.value.display, "9");
assert_eq!(counter.source, super::StateSource::Chain);
assert_eq!(counter.value.status, DebugValueStatus::Decoded);
let entry = state_value(
&layout,
&words,
"balances[0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266]",
)
.expect("entry");
assert_eq!(entry.value.display, "4");
assert_eq!(entry.source, super::StateSource::Chain);
let words = tape.at_step(&map, 2).with_chain(Some(&chain));
let counter = state_value(&layout, &words, "counter").expect("counter");
assert_eq!(counter.value.display, "42");
assert_eq!(counter.source, super::StateSource::Trace);
let empty = FakeChain {
words: BTreeMap::new(),
label: "the chain at block 7".to_owned(),
};
let words = tape.at_step(&map, 0).with_chain(Some(&empty));
let counter = state_value(&layout, &words, "counter").expect("counter");
assert_eq!(counter.source, super::StateSource::Trace);
assert!(
counter
.value
.display
.contains("the chain could not be read"),
"{}",
counter.value.display
);
}
#[test]
fn a_cached_chain_reads_each_slot_once() {
use std::cell::Cell;
let reads = Cell::new(0_u32);
let chain =
super::CachedChain::new("the chain at block 7", |_address: &str, slot: &Word| {
reads.set(reads.get() + 1);
(slot[31] == 1).then(|| {
let mut word = [0_u8; 32];
word[31] = 9;
word
})
});
let mut slot = [0_u8; 32];
slot[31] = 1;
assert_eq!(super::ChainStorage::label(&chain), "the chain at block 7");
assert!(super::ChainStorage::word(&chain, "0xAA", &slot).is_some());
assert!(super::ChainStorage::word(&chain, "0xaa", &slot).is_some());
assert_eq!(reads.get(), 1);
let missing = [0_u8; 32];
assert!(super::ChainStorage::word(&chain, "0xaa", &missing).is_none());
assert!(super::ChainStorage::word(&chain, "0xaa", &missing).is_none());
assert_eq!(reads.get(), 2);
}
#[test]
fn says_when_the_backend_recorded_no_storage() {
let trace = trace(vec![step("STOP", 1, &[], &[])], false);
let map = StepMap::new(&trace, Vec::new());
let tape = StorageTape::new(&trace, &map);
let words = tape.at_step(&map, 0);
assert!(!words.captured());
assert!(words.known().is_empty());
let counter = state_value(&layout(), &words, "counter").expect("counter");
assert_eq!(
counter.value.display,
"<unavailable: this backend recorded no storage>"
);
}
#[test]
fn short_hex_trims_leading_zeros() {
let mut word = [0_u8; 32];
assert_eq!(short_hex(&word), "0x0");
word[31] = 0x1f;
assert_eq!(short_hex(&word), "0x1f");
word[0] = 0xab;
assert!(short_hex(&word).starts_with("0xab00"));
}
}