Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/doc/src/parser/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,65 @@ impl<'a> CommentsRef<'a> {
})
}

pub fn storage_location_pairs(&self) -> Vec<(String, String)> {
self.iter()
.filter_map(|c| match &c.tag {
CommentTag::Custom(name) if name == "storage-location" => Some(c.value.as_str()),
_ => None,
})
.flat_map(Self::split_erc_storage_pairs)
.collect()
}

fn split_erc_storage_pairs(line: &str) -> Vec<(String, String)> {
// Lowercase copy for case-insensitive matching of "erc"
let lower = line.to_ascii_lowercase();

// Find every starting index of the substring "erc7201"
let mut starts: Vec<usize> = lower.match_indices("erc7201").map(|(i, _)| i).collect();
if starts.is_empty() {
return Vec::new();
}
// Add sentinel to mark the end of the final slice
starts.push(line.len());

let mut out = Vec::new();

for window in starts.windows(2) {
let (a, b) = (window[0], window[1]);
let slice = line[a..b].trim().trim_matches(|c: char| c.is_whitespace() || c == ',');
if slice.is_empty() {
continue;
}

// Attempt to split once at ':' or first whitespace
let (left, right) = if let Some((l, r)) = slice.split_once(':') {
(l.trim(), r.trim())
} else if let Some((l, r)) =
slice.split_once(char::is_whitespace).map(|(l, r)| (l.trim(), r.trim()))
{
(l, r)
} else {
continue;
};

// Basic sanity check: left must start with "erc" (case-insensitive)
let left_lc = left.to_ascii_lowercase();
if !left_lc.starts_with("erc") {
continue;
}

// Require at least one digit after "erc"
if left_lc.chars().skip(3).next().map(|c| c.is_ascii_digit()).unwrap_or(false)
&& !right.is_empty()
{
out.push((left.to_string(), right.to_string()));
}
}

out
}

/// Find an [CommentTag::Inheritdoc] comment and extract the base.
fn find_inheritdoc_base(&self) -> Option<&'a str> {
self.iter()
Expand Down
160 changes: 158 additions & 2 deletions crates/forge/src/cmd/inspect.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
use alloy_json_abi::{EventParam, InternalType, JsonAbi, Param};
use alloy_primitives::{hex, keccak256};
use alloy_primitives::{U256, hex, keccak256};
use clap::Parser;
use comfy_table::{Cell, Table, modifiers::UTF8_ROUND_CORNERS, presets::ASCII_MARKDOWN};
use eyre::{Result, eyre};
use forge_doc::{Comment, CommentTag, Comments, CommentsRef};
use foundry_cli::opts::{BuildOpts, CompilerOpts};
use foundry_common::{
compile::{PathOrContractInfo, ProjectCompiler},
Expand Down Expand Up @@ -108,7 +109,9 @@ impl InspectArgs {
print_json(&artifact.gas_estimates)?;
}
ContractArtifactField::StorageLayout => {
print_storage_layout(artifact.storage_layout.as_ref(), wrap)?;
let namespaced_rows =
parse_storage_locations(artifact.raw_metadata.as_ref()).unwrap_or_default();
print_storage_layout(artifact.storage_layout.as_ref(), namespaced_rows, wrap)?;
}
ContractArtifactField::DevDoc => {
print_json(&artifact.devdoc)?;
Expand Down Expand Up @@ -302,6 +305,7 @@ fn internal_ty(ty: &InternalType) -> String {

pub fn print_storage_layout(
storage_layout: Option<&StorageLayout>,
namespaced_rows: Vec<(String, String, String)>,
should_wrap: bool,
) -> Result<()> {
let Some(storage_layout) = storage_layout else {
Expand Down Expand Up @@ -335,6 +339,16 @@ pub fn print_storage_layout(
&slot.contract,
]);
}
for (_, ns, slot_hex) in &namespaced_rows {
table.add_row([
"",
ns.as_str(),
slot_hex.as_str(),
"0",
"32",
ns.split('.').last().unwrap_or(ns.as_str()),
]);
}
},
should_wrap,
)
Expand Down Expand Up @@ -639,6 +653,94 @@ fn missing_error(field: &str) -> eyre::Error {
)
}

#[inline]
fn compute_erc7201_slot_hex(ns: &str) -> String {
// Step 1: keccak256(bytes(id))
let ns_hash = keccak256(ns.as_bytes()); // 32 bytes

// Step 2: (uint256(keccak256(id)) - 1) as 32-byte big-endian
let mut u = U256::from_be_slice(ns_hash.as_slice());
u = u.wrapping_sub(U256::from(1u8));
let enc = u.to_be_bytes::<32>();

// Step 3: keccak256(abi.encode(uint256(...)))
let slot_hash = keccak256(enc);

// Step 4: & ~0xff (zero out the lowest byte)
let mut slot_u = U256::from_be_slice(slot_hash.as_slice());
slot_u &= !U256::from(0xffu8);

// 0x-prefixed 32-byte hex, optionally shorten with your helper
let full = hex::encode_prefixed(slot_u.to_be_bytes::<32>());
short_hex(&full)
}

// Simple “formula registry” so future EIPs can be added without touching the parser.
fn derive_slot_hex(formula: &str, ns: &str) -> Option<String> {
match formula.to_ascii_lowercase().as_str() {
"erc7201" => Some(compute_erc7201_slot_hex(ns)),
// For future EIPs: add "erc1234" => Some(compute_erc1234_slot_hex(ns))
_ => None,
}
}

fn strings_from_json(val: &serde_json::Value) -> Vec<String> {
match val {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => {
arr.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()
}
_ => vec![],
}
}

fn get_custom_tag_lines(devdoc: &serde_json::Value, key: &str) -> Vec<String> {
if let Some(v) = devdoc.get(key) {
let xs = strings_from_json(v);
if !xs.is_empty() {
return xs;
}
}
devdoc
.get("methods")
.and_then(|m| m.get("constructor"))
.and_then(|c| c.as_object())
.and_then(|obj| obj.get(key))
.map(strings_from_json)
.unwrap_or_default()
}

pub fn parse_storage_locations(
raw_metadata: Option<&String>,
) -> Option<Vec<(String, String, String)>> {
let raw = raw_metadata?;
let v: serde_json::Value = serde_json::from_str(raw).ok()?;
let devdoc = v.get("output")?.get("devdoc")?;
let loc_lines = get_custom_tag_lines(devdoc, "custom:storage-location");
if loc_lines.is_empty() {
return None;
}
let mut comments = Comments::default();
for s in loc_lines {
comments.push(Comment::new(CommentTag::Custom("storage-location".to_owned()), s));
}
let cref = CommentsRef::from(&comments);
let out: Vec<(String, String, String)> = cref
.storage_location_pairs()
.into_iter()
.filter_map(|(formula, ns)| {
derive_slot_hex(&formula, &ns)
.map(|slot_hex| (formula.to_ascii_lowercase(), ns, slot_hex))
})
.collect();
if out.is_empty() { None } else { Some(out) }
}

fn short_hex(h: &str) -> String {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we should short this, is that a big issue if we display it entirely?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, it just distorts the table render.

This is the difference in outputs

tc@TCs-MacBook-Pro frxAccount-EIP7702 % /Users/tc/Documents/GitHub/foundry/target/debug/forge inspect src/FrxCommerce.sol:FrxCommerceAccount storageLayout

╭----------------+----------------------+---------------+--------+-------+---------------╮
| Name           | Type                 | Slot          | Offset | Bytes | Contract      |
+========================================================================================+
| storage-bucket | struct EIP712Storage | 0xa16a46…d100 | 0      | 32    | EIP712Storage |
|----------------+----------------------+---------------+--------+-------+---------------|
| storage-bucket | struct NoncesStorage | 0x5ab42c…bb00 | 0      | 32    | NoncesStorage |
╰----------------+----------------------+---------------+--------+-------+---------------╯

tc@TCs-MacBook-Pro frxAccount-EIP7702 % /Users/tc/Documents/GitHub/foundry/target/debug/forge inspect src/FrxCommerce.sol:FrxCommerceAccount storageLayout

╭----------------+----------------------+--------------------------------------------------------------------+--------+-------+---------------╮
| Name           | Type                 | Slot                                                               | Offset | Bytes | Contract      |
+=============================================================================================================================================+
| storage-bucket | struct EIP712Storage | 0xa16a46d94261c7517cc8ff89f61c0ce93598e3c849801011dee649a6a557d100 | 0      | 32    | EIP712Storage |
|----------------+----------------------+--------------------------------------------------------------------+--------+-------+---------------|
| storage-bucket | struct NoncesStorage | 0x5ab42ced628888259c08ac98db1eb0cf702fc1501344311d8b100cd1bfe4bb00 | 0      | 32    | NoncesStorage |
╰----------------+----------------------+--------------------------------------------------------------------+--------+-------+---------------╯

Personally prefer the former but can change if you feel strongly

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, then maybe we could reuse

fn trimmed_hex(s: &[u8]) -> String {

@DaniPopes @zerosnacks wdyt?

let s = h.strip_prefix("0x").unwrap_or(h);
if s.len() > 12 { format!("0x{}…{}", &s[..6], &s[s.len() - 4..]) } else { format!("0x{s}") }
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -675,4 +777,58 @@ mod tests {
}
}
}

#[test]
fn parses_eip7201_storage_buckets_from_metadata() {
let raw_wrapped = r#"
{
"metadata": {
"compiler": { "version": "0.8.30+commit.73712a01" },
"language": "Solidity",
"output": {
"abi": [],
"devdoc": {
"kind": "dev",
"methods": {
"constructor": {
"custom:storage-location": "erc7201:openzeppelin.storage.ERC20erc7201:openzeppelin.storage.AccessControlDefaultAdminRules"
}
},
"version": 1
},
"userdoc": { "kind": "user", "methods": {}, "version": 1 }
},
"settings": { "optimizer": { "enabled": false, "runs": 200 } },
"sources": {},
"version": 1
}
}"#;

let v: serde_json::Value = serde_json::from_str(raw_wrapped).unwrap();
let inner_meta_str = v.get("metadata").unwrap().to_string();

let rows = parse_storage_locations(Some(&inner_meta_str)).expect("parser returned None");
assert_eq!(rows.len(), 2, "expected two EIP-7201 buckets");

assert_eq!(rows[0].1, "openzeppelin.storage.ERC20");
assert_eq!(rows[1].1, "openzeppelin.storage.AccessControlDefaultAdminRules");

let expect_short = |h: &str| {
let hex_str = h.trim_start_matches("0x");
let slot = U256::from_str_radix(hex_str, 16).unwrap();
let full = alloy_primitives::hex::encode_prefixed(slot.to_be_bytes::<32>());
short_hex(&full)
};

let eip712_slot_hex =
expect_short("0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00");
let nonces_slot_hex =
expect_short("0xeef3dac4538c82c8ace4063ab0acd2d15cdb5883aa1dff7c2673abb3d8698400");

assert_eq!(rows[0].2, eip712_slot_hex);
assert_eq!(rows[1].2, nonces_slot_hex);

assert!(rows[0].2.starts_with("0x") && rows[0].2.contains('…'));
assert!(rows[1].2.starts_with("0x") && rows[1].2.contains('…'));
}
}