use colored::Colorize;
use error_stack::{Report, Result, ResultExt};
use crate::batbelt::evm::metadata::bat_metadata::{ContractMetadata, EvmBatMetadata};
use crate::batbelt::evm::miro::auto_deploy::{beside_cluster, below_everything, REFERENCE_FONT};
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::{ArrowEnd, ConnectorStroke, ConnectorStyle, MiroClient};
use crate::batbelt::path::BatFolder;
use crate::batbelt::silicon;
const COLUMN_GAP: f64 = 600.0;
const MARGIN: f64 = 400.0;
pub struct StorageOptions {
pub contract: String,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
struct Slot {
slot: String,
offset: u64,
label: String,
type_label: String,
bytes: u64,
declared_by: String,
declared_at: Option<(String, usize)>,
}
#[derive(Debug, Clone)]
struct Unstored {
name: String,
type_name: String,
kind: &'static str,
line: usize,
}
#[derive(Debug, Clone)]
struct Writer {
slot_label: String,
function: String,
path: String,
line: usize,
lvalue: String,
}
pub async fn run(options: StorageOptions) -> Result<(), EvmMiroError> {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let contract = pick_contract(&metadata, &options.contract)?;
let mut slots = forge_layout(&contract.name)?;
attribute(&metadata, contract, &mut slots);
if slots.is_empty() {
println!(
" {} {} occupies no storage slot at all",
"note:".yellow(),
contract.name
);
}
let unstored = unstored_variables(&metadata, contract);
let writers = writers_of(&metadata, contract, &slots);
let (table, slot_rows) = slot_table(&contract.name, &slots, &writers);
let sidebar = sidebar(&contract.name, &unstored);
for line in table.iter().chain(sidebar.iter()) {
println!("{line}");
}
if options.dry_run {
return Ok(());
}
deploy(contract, &slots, &slot_rows, &table, &unstored).await
}
fn pick_contract<'a>(
metadata: &'a EvmBatMetadata,
wanted: &str,
) -> Result<&'a ContractMetadata, EvmMiroError> {
let matches: Vec<&ContractMetadata> = metadata
.contracts
.iter()
.filter(|contract| contract.name == wanted && !contract.vendored)
.collect();
match matches.len() {
1 => Ok(matches[0]),
0 => Err(Report::new(EvmMiroError).attach_printable(format!(
"no in-scope contract called `{wanted}`; run `bat-cli sonar` if the scan is stale"
))),
_ => Err(Report::new(EvmMiroError).attach_printable(format!(
"`{wanted}` names {} contracts in scope:\n {}",
matches.len(),
matches
.iter()
.map(|contract| contract.file_path.clone())
.collect::<Vec<_>>()
.join("\n ")
))),
}
}
fn forge_layout(contract: &str) -> Result<Vec<Slot>, EvmMiroError> {
let output = std::process::Command::new("forge")
.args(["inspect", contract, "storage-layout", "--json"])
.output()
.map_err(|error| {
Report::new(EvmMiroError).attach_printable(format!(
"could not run `forge inspect {contract} storage-layout --json`: {error}"
))
})?;
if !output.status.success() {
return Err(Report::new(EvmMiroError)
.attach_printable(format!(
"`forge inspect {contract} storage-layout --json` failed:\n{}",
String::from_utf8_lossy(&output.stderr).trim()
))
.attach(crate::Suggestion(
"run it by hand from the Foundry root — the layout comes from the compiler, \
so the contract has to compile"
.to_string(),
)));
}
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| {
Report::new(EvmMiroError)
.attach_printable(format!("forge printed something that is not JSON: {error}"))
})?;
let types = &value["types"];
let mut slots = Vec::new();
for entry in value["storage"].as_array().into_iter().flatten() {
let type_key = entry["type"].as_str().unwrap_or_default();
let described = &types[type_key];
slots.push(Slot {
slot: entry["slot"].as_str().unwrap_or_default().to_string(),
offset: entry["offset"].as_u64().unwrap_or_default(),
label: entry["label"].as_str().unwrap_or_default().to_string(),
type_label: described["label"]
.as_str()
.unwrap_or(type_key)
.to_string(),
bytes: described["numberOfBytes"]
.as_str()
.and_then(|n| n.parse().ok())
.or_else(|| described["numberOfBytes"].as_u64())
.unwrap_or_default(),
declared_by: String::new(),
declared_at: None,
});
}
Ok(slots)
}
fn attribute(metadata: &EvmBatMetadata, contract: &ContractMetadata, slots: &mut [Slot]) {
let chain = chain_of(metadata, contract);
for slot in slots.iter_mut() {
let found = chain.iter().find_map(|source| {
source
.state_variables
.iter()
.find(|variable| variable.name == slot.label)
.map(|variable| (source, variable))
});
match found {
Some((source, variable)) => {
slot.declared_by = source.name.clone();
slot.declared_at = Some((source.file_path.clone(), variable.line));
}
None => slot.declared_by = "?".to_string(),
}
}
}
fn unstored_variables(metadata: &EvmBatMetadata, contract: &ContractMetadata) -> Vec<Unstored> {
let mut out = Vec::new();
for source in chain_of(metadata, contract) {
for variable in &source.state_variables {
let kind = match (variable.is_constant, variable.is_immutable) {
(true, _) => "constant",
(_, true) => "immutable",
_ => continue,
};
out.push(Unstored {
name: variable.name.clone(),
type_name: variable.type_name.clone(),
kind,
line: variable.line,
});
}
}
out
}
fn chain_of<'a>(
metadata: &'a EvmBatMetadata,
contract: &'a ContractMetadata,
) -> Vec<&'a ContractMetadata> {
let mut out = vec![contract];
let mut pending: Vec<String> = contract.base_contracts.clone();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
seen.insert(contract.name.clone());
while let Some(name) = pending.pop() {
if !seen.insert(name.clone()) {
continue;
}
let Some(base) = metadata
.contract_in_scope(&name, &contract.file_path)
.or_else(|| metadata.contracts.iter().find(|c| c.name == name))
else {
continue;
};
pending.extend(base.base_contracts.clone());
out.push(base);
}
out
}
fn writers_of(
metadata: &EvmBatMetadata,
contract: &ContractMetadata,
slots: &[Slot],
) -> Vec<Writer> {
let labels: std::collections::HashSet<&str> =
slots.iter().map(|slot| slot.label.as_str()).collect();
let mut out = Vec::new();
for source in chain_of(metadata, contract) {
for function in &source.functions {
for site in &function.storage_write_sites {
let root = site
.name
.split(['.', '['])
.next()
.unwrap_or(&site.name)
.to_string();
if !labels.contains(root.as_str()) {
continue;
}
out.push(Writer {
slot_label: root,
function: format!("{}.{}", source.name, function.name),
path: prettify(&source.file_path),
line: site.line,
lvalue: site.name.clone(),
});
}
}
}
out.sort_by(|a, b| {
(&a.slot_label, &a.function, a.line).cmp(&(&b.slot_label, &b.function, b.line))
});
out.dedup_by(|a, b| a.slot_label == b.slot_label && a.line == b.line && a.path == b.path);
out
}
fn slot_table(contract: &str, slots: &[Slot], writers: &[Writer]) -> (Vec<String>, Vec<usize>) {
let mut lines = vec![
format!("{contract} — storage layout ({} slot entries)", slots.len()),
"from `forge inspect`; a repeated slot number is packing".to_string(),
"beside it: a purple name is a constant, a red one an immutable — neither holds a slot"
.to_string(),
String::new(),
];
let mut rows = Vec::new();
if slots.is_empty() {
lines.push(" this contract occupies no storage".to_string());
return (lines, rows);
}
for slot in slots {
let mine: Vec<&Writer> = writers
.iter()
.filter(|writer| writer.slot_label == slot.label)
.collect();
let indexed = mine.iter().any(|writer| writer.lvalue.contains('['));
let inherited = if slot.declared_by != contract && !slot.declared_by.is_empty() {
format!(" ({})", slot.declared_by)
} else {
String::new()
};
if !rows.is_empty() {
lines.push(String::new());
}
rows.push(lines.len());
lines.push(format!(
" slot {:>3}+{} {} {}{}",
slot.slot,
slot.offset,
slot.type_label,
format!("{}{}", slot.label, if indexed { "[]" } else { "" }),
inherited,
));
let mut by_function: Vec<(&str, Vec<&Writer>)> = Vec::new();
for writer in &mine {
match by_function.last_mut() {
Some((name, group)) if *name == writer.function => group.push(writer),
_ => by_function.push((&writer.function, vec![writer])),
}
}
let widest = mine.iter().map(|writer| writer.lvalue.len()).max().unwrap_or(0);
let last = by_function.len().saturating_sub(1);
for (index, (function, written)) in by_function.iter().enumerate() {
let elbow = if index == last { "└─" } else { "├─" };
lines.push(format!(" {elbow} {function}"));
let stem = if index == last { " " } else { "│ " };
for writer in written {
lines.push(format!(
" {stem} {:<widest$} {}:{}",
writer.lvalue, writer.path, writer.line
));
}
}
}
(lines, rows)
}
fn sidebar(contract: &str, unstored: &[Unstored]) -> Vec<String> {
let mut lines = vec![
format!("{contract} — in the bytecode, not in storage ({})", unstored.len()),
"declared among the state variables; occupies no slot".to_string(),
String::new(),
];
if unstored.is_empty() {
lines.push(" none".to_string());
}
for variable in unstored {
lines.push(format!(
" {:<10} {:<28} {:<24} L{}",
variable.kind, variable.name, variable.type_name, variable.line
));
}
lines
}
fn prettify(path: &str) -> String {
crate::batbelt::path::prettify_source_code_path(path).unwrap_or_else(|_| path.to_string())
}
async fn deploy(
contract: &ContractMetadata,
slots: &[Slot],
slot_rows: &[usize],
table: &[String],
unstored: &[Unstored],
) -> Result<(), EvmMiroError> {
BatFolder::Figures.create_folder().change_context(EvmMiroError)?;
let destination = BatFolder::Figures.get_path(true).change_context(EvmMiroError)?;
let source = std::fs::read_to_string(&contract.file_path)
.change_context(EvmMiroError)
.attach_printable_lazy(|| format!("cannot read {}", contract.file_path))?;
let all: Vec<String> = source.lines().map(|line| line.to_string()).collect();
let Some((first, last)) = declaration_span(contract, all.len()) else {
println!(
" {} {} declares no state variable of its own",
"note:".yellow(),
contract.name
);
return Ok(());
};
let declarations = &all[first - 1..last];
let table_png = render(table, &destination, &format!("storage_{}_table", contract.name), 0, &[])?;
let code_png = render(
declarations,
&destination,
&format!("storage_{}_code", contract.name),
first,
&unstored_marks(unstored),
)?;
let columns = [&code_png, &table_png];
let width: f64 = columns.iter().map(|c| c.width).sum::<f64>()
+ COLUMN_GAP * (columns.len() as f64 - 1.0);
let height: f64 = columns.iter().fold(0.0_f64, |tallest, c| tallest.max(c.height));
let frame_width = width + MARGIN * 2.0;
let frame_height = height + MARGIN * 2.0;
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let board_frames = client.list_frames().await.change_context(EvmMiroError)?;
let (frame_x, frame_y) = beside_cluster(
&metadata,
&contract.name,
&board_frames,
frame_width,
frame_height,
)
.unwrap_or_else(|| {
let (left, top) = below_everything(&board_frames);
(left + frame_width / 2.0, top + frame_height / 2.0)
});
let title = format!("storage: {}", contract.name);
for previous in board_frames.iter().filter(|frame| frame.title == title) {
client
.delete_item(&previous.id)
.await
.change_context(EvmMiroError)?;
println!(" {} replaced the previous {title} frame", "↻".blue());
}
let frame_id = client
.create_frame(
&title,
frame_x,
frame_y,
frame_width,
frame_height,
None,
)
.await
.change_context(EvmMiroError)?;
let mut cursor = MARGIN;
let mut placed: Vec<(String, f64)> = Vec::new();
for column in columns {
let (x, y) = (cursor + column.width / 2.0, MARGIN + column.height / 2.0);
let item = client
.create_image_in_frame(&column.png_path, &frame_id, &contract.name, x, y, column.width)
.await
.change_context(EvmMiroError)?;
placed.push((item, cursor));
cursor += column.width + COLUMN_GAP;
let _ = std::fs::remove_file(&column.png_path);
}
println!(" {} {} column(s) uploaded", "✓".green(), columns.len());
let code_left = placed[0].1;
let table_left = placed[1].1;
let code_right = code_left + code_png.width;
let geometry = silicon::line_geometry(Some(REFERENCE_FONT));
let aimed: Vec<(usize, &Slot, usize)> = slots
.iter()
.enumerate()
.filter_map(|(index, slot)| {
let declared = contract
.state_variables
.iter()
.find(|variable| variable.name == slot.label)?;
Some((index, slot, declared.line))
})
.collect();
let mut aimed: Vec<(usize, usize)> = aimed
.iter()
.filter_map(|(index, _, declared_line)| Some((*slot_rows.get(*index)?, *declared_line)))
.collect();
aimed.sort_by(|a, b| a.1.cmp(&b.1));
let gutter = (table_left - code_right).max(1.0);
let margin = 100.0_f64.min(gutter / 4.0);
let usable = (gutter - 2.0 * margin).max(0.0);
let pitch = if aimed.len() > 1 {
usable / (aimed.len() - 1) as f64
} else {
0.0
};
let mut drawn = 0usize;
for (order, (row, declared_line)) in aimed.iter().enumerate() {
let row_y = MARGIN
+ table_png.height * geometry.line_center_fraction(*row, table_png.height as u32);
let code_y = MARGIN
+ code_png.height
* geometry.line_center_fraction(declared_line - first, code_png.height as u32);
let row_start = table_left
+ silicon::line_end_x(Some(REFERENCE_FONT), false, table.len(), 0, "") as f64;
let declaration_end = code_left
+ silicon::line_end_x(
Some(REFERENCE_FONT),
true,
declarations.len(),
first,
&declarations[declared_line - first],
) as f64;
let lane_x = code_right + margin + order as f64 * pitch;
crate::batbelt::evm::miro::auto_deploy::draw_lane_arrow(
&client,
&frame_id,
crate::batbelt::evm::miro::auto_deploy::LaneEnd::Point { x: row_start, y: row_y },
crate::batbelt::evm::miro::auto_deploy::LaneEnd::Point {
x: declaration_end,
y: code_y,
},
lane_x,
ConnectorStyle {
stroke_color: silicon::BAT_PALETTE[order % silicon::BAT_PALETTE.len()].to_string(),
stroke_width: "8".to_string(),
stroke: ConnectorStroke::Solid,
caption: None,
arrow: ArrowEnd::End,
},
)
.await
.change_context(EvmMiroError)?;
drawn += 1;
}
println!(" {} {drawn} slot(s) pointed at their declaration", "✓".green());
println!(" {}", client.frame_url(&frame_id).blue());
Ok(())
}
struct Rendered {
png_path: String,
width: f64,
height: f64,
}
fn declaration_span(contract: &ContractMetadata, file_lines: usize) -> Option<(usize, usize)> {
let first = contract
.state_variables
.iter()
.map(|variable| variable.line)
.min()?;
let last = contract
.state_variables
.iter()
.map(|variable| variable.line)
.max()?;
(first <= file_lines).then(|| (first.max(1), last.min(file_lines)))
}
fn unstored_marks(unstored: &[Unstored]) -> Vec<silicon::TracedName> {
use silicon::{TraceKind, TracedName};
const IMMUTABLE: usize = 0;
const CONSTANT: usize = 2;
unstored
.iter()
.map(|variable| TracedName {
name: variable.name.clone(),
kind: TraceKind::Parameter,
color: if variable.kind == "constant" { CONSTANT } else { IMMUTABLE },
dotted: false,
})
.collect()
}
fn render(
lines: &[String],
destination: &str,
name: &str,
line_offset: usize,
traced: &[silicon::TracedName],
) -> Result<Rendered, EvmMiroError> {
let png_path = silicon::create_figure_tracing(
&lines.join("\n"),
destination,
&format!("{name}.{}", if line_offset > 0 { "sol" } else { "txt" }),
line_offset,
Some(REFERENCE_FONT),
line_offset > 0,
traced,
);
let (width, height) = image::image_dimensions(&png_path).change_context(EvmMiroError)?;
Ok(Rendered {
png_path,
width: width as f64,
height: height as f64,
})
}
#[cfg(test)]
mod storage_test {
use super::*;
fn slot(label: &str, slot: &str, offset: u64, declared_by: &str) -> Slot {
Slot {
slot: slot.to_string(),
offset,
label: label.to_string(),
type_label: "address".to_string(),
bytes: 20,
declared_by: declared_by.to_string(),
declared_at: Some(("src/C.sol".to_string(), 7)),
}
}
#[test]
fn packing_is_two_rows_with_one_slot_number() {
let slots = vec![slot("a", "0", 0, "C"), slot("b", "0", 20, "C")];
let (table, rows) = slot_table("C", &slots, &[]);
assert_eq!(rows.len(), 2, "one row per entry, both on slot 0");
assert!(table.iter().any(|line| line.contains("slot 0+0")));
assert!(table.iter().any(|line| line.contains("slot 0+20")));
}
#[test]
fn a_slot_with_no_writer_says_so() {
let (table, _) = slot_table("C", &[slot("a", "0", 0, "C")], &[]);
assert_eq!(table.iter().filter(|line| line.contains("├─") || line.contains("└─")).count(), 0);
}
}