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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Dependencies
node_modules
/target
gasguard-cli/target

# Foundry / Hardhat build output
/out
Expand Down
60 changes: 60 additions & 0 deletions contracts/proxy/YulProxyForwarder.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title YulProxyForwarder
/// @notice Minimal, zero-overhead `delegatecall` forwarder implemented
/// entirely in Yul assembly. Forwards all incoming calldata to a fixed
/// implementation address and relays the return data (or revert reason)
/// transparently, without any high-level Solidity stack manipulation.
/// @dev Intended to be deployed as the runtime code behind a proxy pointer
/// (e.g. an EIP-1967 storage slot) or used directly as a fallback-only
/// forwarding contract when the implementation address is immutable.
contract YulProxyForwarder {
/// @notice The address all calls are delegated to.
address public immutable implementation;

constructor(address implementation_) {
implementation = implementation_;
}

/// @dev Catches all calls (including those with no matching function
/// selector and plain ETH transfers) and forwards them via
/// `delegatecall` to `implementation`.
fallback() external payable {
_forward(implementation);
}

receive() external payable {
_forward(implementation);
}

/// @notice Forwards the current call's calldata to `impl` via
/// `delegatecall` and returns/reverts with the callee's return data.
/// @dev Safety: this function never returns to Solidity control flow —
/// it always terminates the call via `return` or `revert` inside the
/// assembly block. No storage is written directly by this function;
/// all state changes happen in `impl`'s execution context via
/// delegatecall. Gas: copies calldata/returndata once each
/// (`calldatacopy`/`returndatacopy`), forwards all remaining gas.
function _forward(address impl) internal {
assembly {
// Copy incoming calldata to memory location 0x00.
calldatacopy(0, 0, calldatasize())

// Forward the call as a delegatecall, preserving msg.sender
// and msg.value semantics of the original caller.
let result := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)

// Copy the returned data (success or revert reason) to memory.
returndatacopy(0, 0, returndatasize())

switch result
case 0 {
revert(0, returndatasize())
}
default {
return(0, returndatasize())
}
}
}
}
71 changes: 71 additions & 0 deletions contracts/utils/BatchBalanceChecker.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title BatchBalanceChecker
/// @notice Gas-optimized view helper that queries `balanceOf(address)` for
/// multiple ERC-20 tokens across multiple accounts in a single call, using
/// low-level `staticcall`s instead of per-token high-level interface calls.
/// @dev Non-contract addresses or tokens that revert on `balanceOf` yield a
/// `0` entry for that (token, account) pair instead of reverting the whole
/// batch, so a single misbehaving token can't block the rest of the query.
/// Deployed once and queried directly (no state, no constructor args), akin
/// to a lightweight on-chain multicall helper.
contract BatchBalanceChecker {
/// @notice Queries `balanceOf(account)` for every (token, account) pair
/// in the Cartesian product of `tokens` x `accounts`.
/// @param tokens The ERC-20 token addresses to query.
/// @param accounts The accounts to query balances for.
/// @return balances A flat array of length `tokens.length * accounts.length`,
/// laid out as `balances[i * accounts.length + j]` = balance of
/// `accounts[j]` in `tokens[i]`. Entries default to `0` for calls that
/// revert or target a non-contract address.
function batchBalances(address[] memory tokens, address[] memory accounts)
external
view
returns (uint256[] memory balances)
{
uint256 tokenCount = tokens.length;
uint256 accountCount = accounts.length;
balances = new uint256[](tokenCount * accountCount);

// keccak256("balanceOf(address)") selector, built once in memory
// scratch space and reused for every staticcall.
// Safety: writes only to scratch memory (0x00-0x44); makes only
// `staticcall`s (no state mutation possible); loop bound is fixed
// by input array lengths, no unbounded external growth.
// Gas: builds the 4-byte selector + address calldata once outside
// the loop instead of re-encoding via `abi.encodeWithSelector` on
// every iteration.
assembly {
// Store selector for balanceOf(address) = 0x70a08231, left-padded
// to 4 bytes at memory offset 0x00.
mstore(0x00, 0x70a0823100000000000000000000000000000000000000000000000000000000)

let tokensData := add(tokens, 0x20)
let accountsData := add(accounts, 0x20)
let balancesData := add(balances, 0x20)

for { let i := 0 } lt(i, tokenCount) { i := add(i, 1) } {
let token := mload(add(tokensData, mul(i, 0x20)))
let rowOffset := mul(i, accountCount)

for { let j := 0 } lt(j, accountCount) { j := add(j, 1) } {
let account := mload(add(accountsData, mul(j, 0x20)))

// Encode the account argument right after the selector.
mstore(0x04, account)

// staticcall(gas, target, argsOffset, argsSize, retOffset, retSize)
let success := staticcall(gas(), token, 0x00, 0x24, 0x24, 0x20)

let value := 0
if and(success, gt(returndatasize(), 0x1f)) {
value := mload(0x24)
}

mstore(add(balancesData, mul(add(rowOffset, j), 0x20)), value)
}
}
}
}
}
86 changes: 86 additions & 0 deletions contracts/utils/YulBitSlice.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

/// @title YulBitSlice
/// @notice Gas-optimized Yul assembly utility for extracting arbitrary
/// bit-length slices from unpadded `calldata`/`bytes` buffers without
/// allocating intermediate memory copies of the full buffer.
/// @dev Bit offsets are counted from the most-significant bit of the first
/// byte (big-endian, matching how `bytes` data is laid out on-chain).
library YulBitSlice {
error BitLengthTooLarge(uint256 bitLength);
error OutOfBounds(uint256 bitOffset, uint256 bitLength, uint256 dataLength);

/// @notice Extracts `bitLength` bits starting at `bitOffset` (in bits)
/// from `data`, returning them right-aligned in the low-order bits of
/// the returned `uint256`.
/// @param data The source calldata byte buffer.
/// @param bitOffset The starting bit position (0-indexed from the MSB of byte 0).
/// @param bitLength The number of bits to extract. Must be <= 256.
/// @return result The extracted bits, right-aligned and zero-padded.
function extractBits(bytes calldata data, uint256 bitOffset, uint256 bitLength)
internal
pure
returns (uint256 result)
{
if (bitLength == 0 || bitLength > 256) revert BitLengthTooLarge(bitLength);
uint256 dataBits = data.length * 8;
if (bitOffset + bitLength > dataBits) {
revert OutOfBounds(bitOffset, bitLength, data.length);
}

// Safety: reads only from calldata within [data.offset, data.offset + data.length);
// no storage access, no external calls, no memory writes beyond scratch space.
// Gas: two `calldataload`s (worst case, when the slice straddles a 32-byte
// calldata word boundary) plus shifts/masks — no loops, no memory expansion.
assembly {
let byteOffset := shr(3, bitOffset)
let bitShiftInByte := and(bitOffset, 7)

// Load up to 32 bytes starting at the byte containing bitOffset.
// calldataload reads 32 bytes past data.offset + byteOffset; calldata
// past the actual buffer end reads as zero, which is safe here because
// we've already bounds-checked bitOffset + bitLength above.
let word := calldataload(add(data.offset, byteOffset))

// Total bits available from bitShiftInByte to the end of `word`.
// We want the `bitLength` bits starting at `bitShiftInByte` bits
// into `word` (from the MSB side).
let shiftFromRight := sub(256, add(bitShiftInByte, bitLength))

switch slt(shiftFromRight, 0)
case 0 {
// The requested slice fits entirely within `word` from the MSB side.
result := and(shr(shiftFromRight, word), sub(shl(bitLength, 1), 1))
}
default {
// The slice straddles into the next calldata word; pull in the
// next word and merge the two halves.
let bitsFromFirstWord := sub(256, bitShiftInByte)
let remainingBits := sub(bitLength, bitsFromFirstWord)

let highPart := and(word, sub(shl(bitsFromFirstWord, 1), 1))

let nextWord := calldataload(add(data.offset, add(byteOffset, 32)))
let lowPart := shr(sub(256, remainingBits), nextWord)

result := or(shl(remainingBits, highPart), lowPart)
}
}
}

/// @notice Extracts a byte-aligned sub-slice `[byteOffset, byteOffset + length)`
/// from `data` and returns it as a new `bytes` value.
/// @dev Thin convenience wrapper around `extractBits` for the common
/// byte-aligned case; still avoids copying the *entire* source buffer.
function sliceBytes(bytes calldata data, uint256 byteOffset, uint256 length)
internal
pure
returns (bytes memory result)
{
if (byteOffset + length > data.length) {
revert OutOfBounds(byteOffset * 8, length * 8, data.length);
}
result = data[byteOffset:byteOffset + length];
}
}
2 changes: 2 additions & 0 deletions gasguard-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[workspace]

[package]
name = "gasguard-cli"
version = "0.1.0"
Expand Down
3 changes: 2 additions & 1 deletion gasguard-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub mod optimize_storage;
pub mod optimize_storage;
pub mod reorder_storage;
51 changes: 51 additions & 0 deletions gasguard-cli/src/commands/reorder_storage.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use std::fs;
use std::path::Path;

use crate::transformers::storage_packer::transform_storage_layout;

/// Runs the G015 storage-slot re-ordering transform on a Solidity contract
/// file, writing the transformed source back to disk (or to `output_path`
/// if provided) and printing a summary of the savings achieved.
pub fn run_reorder_storage(contract_path: &str, output_path: Option<&str>) -> Result<(), String> {
let path = Path::new(contract_path);
if !path.exists() {
return Err(format!("Contract file not found: {}", contract_path));
}

let source = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;

let result = transform_storage_layout(&source)?;

println!("\n GasGuard Reorder Storage (Rule G015)");
println!(" ──────────────────────────────────────────────────────────");
println!(" Contract: {}", path.display());

if result.already_optimal {
println!(" ✓ Storage layout is already optimal. No changes made.");
return Ok(());
}

println!(
" ✓ Reordered {} variable(s), saving {} storage slot(s).",
result.variables_reordered, result.savings_slots
);

let destination = output_path.unwrap_or(contract_path);
fs::write(destination, &result.transformed_source)
.map_err(|e| format!("Failed to write output: {}", e))?;

println!(" Written to: {}", destination);

Ok(())
}

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

#[test]
fn run_reorder_storage_file_not_found() {
let result = run_reorder_storage("/nonexistent/Contract.sol", None);
assert!(result.is_err());
}
}
3 changes: 2 additions & 1 deletion gasguard-cli/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
pub mod commands;
pub mod storage_model;
pub mod storage_model;
pub mod transformers;
1 change: 1 addition & 0 deletions gasguard-cli/src/transformers/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pub mod storage_packer;
Loading
Loading