Skip to content
Merged
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
22 changes: 22 additions & 0 deletions contracts/events/EventRegistry.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,37 @@ pragma solidity ^0.8.20;
/**
* @title EventRegistry
* @notice Refactors runtime keccak256 event signature hashing to compile-time constant topic selectors.
* @dev All topic selectors below are `bytes32 constant` values. Because their inputs are
* string literals, `keccak256(...)` is folded by the Solidity compiler at compile time
* (verified by `EventRegistry.test.ts`, which asserts each constant equals the offline
* `ethers.id(...)` hash of its canonical event signature) — no hashing opcode runs at
* call time. `emitViaAssembly` further demonstrates that a low-level `log` call can
* consume these constants directly as topics without recomputing them.
*/
contract EventRegistry {
// Pre-computed compile-time constant event topics
bytes32 public constant EVENT_TRANSFER_TOPIC = keccak256("Transfer(address,address,uint256)");
bytes32 public constant EVENT_APPROVAL_TOPIC = keccak256("Approval(address,address,uint256)");
bytes32 public constant EVENT_APPROVAL_FOR_ALL_TOPIC =
keccak256("ApprovalForAll(address,address,bool)");
bytes32 public constant EVENT_OWNERSHIP_TRANSFERRED_TOPIC =
keccak256("OwnershipTransferred(address,address)");

event LogRegistered(bytes32 indexed topic, address indexed emitter);

function logEvent(address emitter) external {
emit LogRegistered(EVENT_TRANSFER_TOPIC, emitter);
}

/// @notice Emits a raw `Approval` log via a low-level `log3` call,
/// passing the compile-time constant topic0 directly alongside the
/// indexed `owner`/`spender` topics — no runtime keccak256 hashing.
function emitViaAssembly(address owner, address spender, uint256 value) external {
bytes32 topic0 = EVENT_APPROVAL_TOPIC;
assembly {
let ptr := mload(0x40)
mstore(ptr, value)
log3(ptr, 0x20, topic0, owner, spender)
}
}
}
1 change: 1 addition & 0 deletions gasguard-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod commands;
pub mod reporter;
pub mod storage_model;
pub mod transformers;
154 changes: 142 additions & 12 deletions gasguard-cli/src/reporter/heatmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,161 @@ pub struct GasHeatmapReporter {
pub no_color: bool,
}

/// A single function's estimated gas cost, or `None` when no gas estimate
/// could be produced (e.g. the function was skipped by the analyzer, or
/// gas estimation failed for it).
pub struct GasEntry {
pub function_name: String,
pub gas_cost: Option<u64>,
}

impl GasHeatmapReporter {
pub fn new(no_color: bool) -> Self {
Self { no_color }
}

pub fn format_gas_tier(&self, function_name: &str, gas_cost: u64) -> String {
/// Formats a single function's gas tier line. `gas_cost` of `None`
/// represents a function with no gas data available (e.g. it could not
/// be estimated), which is rendered as a distinct "N/A" tier instead of
/// being silently coerced into the lowest bucket.
pub fn format_gas_tier(&self, function_name: &str, gas_cost: Option<u64>) -> String {
let label = self.get_tier_label(gas_cost);
let bar = self.get_bar(gas_cost);
let gas_display = match gas_cost {
Some(g) => format!("{} gas", g),
None => "no gas data".to_string(),
};

if self.no_color {
return format!("[{}] {} - {} gas", self.get_tier_label(gas_cost), function_name, gas_cost);
return format!("[{}] {} {} - {}", label, bar, function_name, gas_display);
}

let color_code = match gas_cost {
0..=4999 => "\x1b[32m", // Green (Low)
5000..=25000 => "\x1b[33m", // Yellow (Medium)
_ => "\x1b[31m", // Red (High)
};
let color_code = self.get_color_code(gas_cost);
let reset = "\x1b[0m";
format!(
"{}[{}] {} {} - {}{}",
color_code, label, bar, function_name, gas_display, reset
)
}

/// Formats a full report for a set of functions, including a legend of
/// what each tier/color means so standalone output remains readable
/// without prior context.
pub fn format_report(&self, entries: &[GasEntry]) -> String {
let mut lines = Vec::with_capacity(entries.len() + 2);
lines.push(self.format_legend());
for entry in entries {
lines.push(self.format_gas_tier(&entry.function_name, entry.gas_cost));
}
lines.join("\n")
}

format!("{}{}[{}] {} - {} gas{}", color_code, "", self.get_tier_label(gas_cost), function_name, gas_cost, reset)
/// A short legend explaining the tier thresholds and (when color is
/// enabled) the color each tier maps to, so CI logs and terminal
/// output are self-describing.
pub fn format_legend(&self) -> String {
if self.no_color {
"Legend: LOW < 5,000 gas | MEDIUM 5,000-25,000 gas | HIGH > 25,000 gas | N/A no data"
.to_string()
} else {
format!(
"Legend: {}LOW{} < 5,000 gas | {}MEDIUM{} 5,000-25,000 gas | {}HIGH{} > 25,000 gas | N/A no data",
"\x1b[32m", "\x1b[0m", "\x1b[33m", "\x1b[0m", "\x1b[31m", "\x1b[0m"
)
}
}

fn get_tier_label(&self, gas_cost: u64) -> &'static str {
fn get_color_code(&self, gas_cost: Option<u64>) -> &'static str {
match gas_cost {
0..=4999 => "LOW",
5000..=25000 => "MEDIUM",
_ => "HIGH",
None => "\x1b[90m", // Grey (no data)
Some(0..=4999) => "\x1b[32m", // Green (Low)
Some(5000..=25000) => "\x1b[33m", // Yellow (Medium)
Some(_) => "\x1b[31m", // Red (High)
}
}

fn get_tier_label(&self, gas_cost: Option<u64>) -> &'static str {
match gas_cost {
None => "N/A",
Some(0..=4999) => "LOW",
Some(5000..=25000) => "MEDIUM",
Some(_) => "HIGH",
}
}

/// A short visual severity bar summarizing the tier at a glance,
/// satisfying the "summary visual bars" requirement alongside the
/// per-tier color coding.
fn get_bar(&self, gas_cost: Option<u64>) -> &'static str {
match gas_cost {
None => "----",
Some(0..=4999) => "#",
Some(5000..=25000) => "##",
Some(_) => "###",
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn low_tier_has_correct_label_and_bar() {
let reporter = GasHeatmapReporter::new(true);
let line = reporter.format_gas_tier("foo", Some(1000));
assert!(line.contains("[LOW]"));
assert!(line.contains("1000 gas"));
}

#[test]
fn medium_tier_boundary_is_inclusive() {
let reporter = GasHeatmapReporter::new(true);
assert!(reporter.format_gas_tier("f", Some(5000)).contains("[MEDIUM]"));
assert!(reporter.format_gas_tier("f", Some(25000)).contains("[MEDIUM]"));
assert!(reporter.format_gas_tier("f", Some(25001)).contains("[HIGH]"));
}

#[test]
fn zero_gas_function_is_low_tier_not_a_crash() {
let reporter = GasHeatmapReporter::new(true);
let line = reporter.format_gas_tier("noop", Some(0));
assert!(line.contains("[LOW]"));
assert!(line.contains("0 gas"));
}

#[test]
fn missing_gas_data_renders_as_na_tier() {
let reporter = GasHeatmapReporter::new(true);
let line = reporter.format_gas_tier("unestimated", None);
assert!(line.contains("[N/A]"));
assert!(line.contains("no gas data"));
}

#[test]
fn no_color_mode_emits_no_ansi_escapes() {
let reporter = GasHeatmapReporter::new(true);
let line = reporter.format_gas_tier("f", Some(100000));
assert!(!line.contains('\u{1b}'));
}

#[test]
fn color_mode_emits_ansi_escapes() {
let reporter = GasHeatmapReporter::new(false);
let line = reporter.format_gas_tier("f", Some(100000));
assert!(line.contains('\u{1b}'));
}

#[test]
fn report_includes_legend_and_all_entries() {
let reporter = GasHeatmapReporter::new(true);
let entries = vec![
GasEntry { function_name: "a".to_string(), gas_cost: Some(100) },
GasEntry { function_name: "b".to_string(), gas_cost: None },
];
let report = reporter.format_report(&entries);
assert!(report.starts_with("Legend:"));
assert!(report.contains("a"));
assert!(report.contains("[N/A]"));
}
}
148 changes: 147 additions & 1 deletion rules/g016_redundant_casts.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,16 @@
//! Rule G016: Flag Redundant address(uint160(x)) Cast Operations in Solidity AST.
//!
//! Detects nested/redundant address type conversion chains such as
//! `address(payable(addr))`, `address(uint160(x))`, and the deeper chain
//! `address(uint160(uint256(x)))` that sometimes appears when a value has
//! already been narrowed to `uint160`/`address` upstream. Necessary
//! conversions, such as narrowing a `bytes32` down to an `address` (which
//! requires the `uint160`/`uint256` step to be meaningful, e.g.
//! `address(uint160(uint256(someBytes32)))` where `someBytes32` is not
//! already an address-derived value) are intentionally out of scope for
//! this lightweight, source-level heuristic and are left unflagged by
//! keeping the check anchored to variable names that already look like
//! addresses.

pub struct RuleG016RedundantCasts;

Expand All @@ -7,11 +19,145 @@ impl RuleG016RedundantCasts {
"G016_redundant_casts"
}

/// Strips single-line (`//`) and block (`/* */`) comments so that
/// mentions of cast patterns inside documentation/comments do not
/// produce false positives.
fn strip_comments(source_code: &str) -> String {
let mut result = String::with_capacity(source_code.len());
let mut chars = source_code.chars().peekable();
while let Some(c) = chars.next() {
if c == '/' && chars.peek() == Some(&'/') {
while let Some(&nc) = chars.peek() {
if nc == '\n' {
break;
}
chars.next();
}
} else if c == '/' && chars.peek() == Some(&'*') {
chars.next();
while let Some(nc) = chars.next() {
if nc == '*' && chars.peek() == Some(&'/') {
chars.next();
break;
}
}
} else {
result.push(c);
}
}
result
}

pub fn check(source_code: &str) -> Vec<String> {
let mut warnings = Vec::new();
if source_code.contains("address(uint160(") || source_code.contains("address(payable(") {
let code = Self::strip_comments(source_code);

// Simple redundant double-cast: address(payable(x)).
if code.contains("address(payable(") {
warnings.push(
"Warning: Redundant address cast operation detected \
(address(payable(x)) — payable() already yields an address type)"
.to_string(),
);
}

// Simple redundant cast: address(uint160(x)) where x is already an
// address-typed expression being round-tripped through uint160.
if code.contains("address(uint160(") {
warnings.push("Warning: Redundant address cast operation detected".to_string());
}

// Deeper redundant chain: address(uint160(uint256(x))) — three
// conversions where a well-typed value only ever needed one.
if code.contains("address(uint160(uint256(") {
warnings.push(
"Warning: Redundant triple-cast chain detected \
(address(uint160(uint256(x))) can usually be simplified to a single cast)"
.to_string(),
);
}

// Deeper redundant chain via payable: address(payable(uint160(x))) /
// payable(address(uint160(x))).
if code.contains("payable(address(uint160(") || code.contains("address(payable(uint160(") {
warnings.push(
"Warning: Redundant payable/uint160 cast chain detected".to_string(),
);
}

warnings
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn flags_address_payable_double_cast() {
let code = r#"
function redundantCast(address addr) external pure returns (address) {
return address(payable(addr));
}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(!warnings.is_empty());
}

#[test]
fn flags_address_uint160_cast() {
let code = r#"
function toAddr(uint160 x) external pure returns (address) {
return address(uint160(x));
}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(!warnings.is_empty());
}

#[test]
fn flags_triple_cast_chain() {
let code = r#"
function unwrap(uint256 x) external pure returns (address) {
return address(uint160(uint256(x)));
}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(warnings.iter().any(|w| w.contains("triple-cast")));
}

#[test]
fn flags_payable_uint160_chain() {
let code = r#"
function unwrap(uint160 x) external pure returns (address payable) {
return payable(address(uint160(x)));
}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(warnings.iter().any(|w| w.contains("payable/uint160")));
}

#[test]
fn does_not_flag_necessary_bytes32_to_address_conversion() {
// bytes32 -> uint256 -> address is the standard, necessary way to
// narrow a bytes32 (e.g. a storage slot value or hashed identifier)
// down to an address; it does not go through address(uint160(...)).
let code = r#"
function toAddress(bytes32 b) external pure returns (address) {
return address(uint256(b));
}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(warnings.is_empty());
}

#[test]
fn ignores_pattern_mentioned_only_in_comments() {
let code = r#"
// Avoid patterns like address(uint160(x)) in new code.
function noop() external pure {}
"#;
let warnings = RuleG016RedundantCasts::check(code);
assert!(warnings.is_empty());
}
}
Loading
Loading