diff --git a/.gitignore b/.gitignore index 688cda0..4b79549 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies node_modules /target +gasguard-cli/target # Foundry / Hardhat build output /out diff --git a/contracts/proxy/YulProxyForwarder.sol b/contracts/proxy/YulProxyForwarder.sol new file mode 100644 index 0000000..5872d08 --- /dev/null +++ b/contracts/proxy/YulProxyForwarder.sol @@ -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()) + } + } + } +} diff --git a/contracts/utils/BatchBalanceChecker.sol b/contracts/utils/BatchBalanceChecker.sol new file mode 100644 index 0000000..8b81621 --- /dev/null +++ b/contracts/utils/BatchBalanceChecker.sol @@ -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) + } + } + } + } +} diff --git a/contracts/utils/YulBitSlice.sol b/contracts/utils/YulBitSlice.sol new file mode 100644 index 0000000..2b167c6 --- /dev/null +++ b/contracts/utils/YulBitSlice.sol @@ -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]; + } +} diff --git a/gasguard-cli/Cargo.toml b/gasguard-cli/Cargo.toml index bf40a69..5bcecd4 100644 --- a/gasguard-cli/Cargo.toml +++ b/gasguard-cli/Cargo.toml @@ -1,3 +1,5 @@ +[workspace] + [package] name = "gasguard-cli" version = "0.1.0" diff --git a/gasguard-cli/src/commands/mod.rs b/gasguard-cli/src/commands/mod.rs index 23cde8f..53e3555 100644 --- a/gasguard-cli/src/commands/mod.rs +++ b/gasguard-cli/src/commands/mod.rs @@ -1 +1,2 @@ -pub mod optimize_storage; \ No newline at end of file +pub mod optimize_storage; +pub mod reorder_storage; \ No newline at end of file diff --git a/gasguard-cli/src/commands/reorder_storage.rs b/gasguard-cli/src/commands/reorder_storage.rs new file mode 100644 index 0000000..0b01228 --- /dev/null +++ b/gasguard-cli/src/commands/reorder_storage.rs @@ -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()); + } +} diff --git a/gasguard-cli/src/lib.rs b/gasguard-cli/src/lib.rs index 3d81c50..39ce51d 100644 --- a/gasguard-cli/src/lib.rs +++ b/gasguard-cli/src/lib.rs @@ -1,2 +1,3 @@ pub mod commands; -pub mod storage_model; \ No newline at end of file +pub mod storage_model; +pub mod transformers; \ No newline at end of file diff --git a/gasguard-cli/src/transformers/mod.rs b/gasguard-cli/src/transformers/mod.rs new file mode 100644 index 0000000..ae092e6 --- /dev/null +++ b/gasguard-cli/src/transformers/mod.rs @@ -0,0 +1 @@ +pub mod storage_packer; diff --git a/gasguard-cli/src/transformers/storage_packer.rs b/gasguard-cli/src/transformers/storage_packer.rs new file mode 100644 index 0000000..e005019 --- /dev/null +++ b/gasguard-cli/src/transformers/storage_packer.rs @@ -0,0 +1,380 @@ +//! Rule G015: Automated AST storage-slot re-ordering transformer. +//! +//! Detects unpacked state variable declarations in a Solidity contract and +//! rewrites the source so that sub-32-byte variables are grouped together +//! sequentially, minimizing the number of 256-bit storage slots used. This +//! module reuses the packing algorithm already implemented in +//! `storage_model` (see `commands::optimize_storage` for the read-only, +//! visualization-only counterpart) and adds the source-rewriting step on +//! top of it. + +use crate::storage_model::{classify_variable, compute_optimal_packing, StateVariable}; + +/// A single state variable declaration extracted from the source, keeping +/// enough of the original text to reproduce it verbatim (including any +/// trailing inline comment) when re-emitted in a new position. +#[derive(Debug, Clone)] +struct DeclarationBlock { + /// Any full-line comments immediately preceding the declaration line, + /// kept attached so they move together with the variable. + leading_comments: Vec, + /// The exact original declaration line (including indentation, the + /// trailing `;`, and any inline trailing comment). + line_text: String, + /// Classified metadata used to feed `compute_optimal_packing`. + variable: StateVariable, + /// Index into the original list of contract-body lines where this + /// declaration (including leading comments) started. + start_line_idx: usize, + /// Index (exclusive) one past the declaration line itself. + end_line_idx: usize, +} + +/// Result of running the storage-packing transform on a contract source. +#[derive(Debug, Clone)] +pub struct TransformResult { + /// The rewritten Solidity source with state variables reordered. + pub transformed_source: String, + /// Number of storage slots saved compared to declaration order. + pub savings_slots: usize, + /// Number of variables that were reordered. + pub variables_reordered: usize, + /// True if no reordering was necessary (layout was already optimal). + pub already_optimal: bool, +} + +/// Runs the G015 storage-packing transform on `source`, returning the +/// rewritten contract text and a summary of the savings achieved. +/// +/// Only the *first* top-level `contract`/`library` body is transformed; +/// interfaces, structs, functions, events, and modifiers are left untouched +/// both in content and in their relative position. Only the contiguous run +/// of state-variable declaration lines is reordered. +pub fn transform_storage_layout(source: &str) -> Result { + let lines: Vec<&str> = source.lines().collect(); + let blocks = extract_declaration_blocks(&lines); + + if blocks.is_empty() { + return Ok(TransformResult { + transformed_source: source.to_string(), + savings_slots: 0, + variables_reordered: 0, + already_optimal: true, + }); + } + + let variables: Vec = blocks.iter().map(|b| b.variable.clone()).collect(); + let packing = compute_optimal_packing(&variables); + + // Build the new declaration order: walk packing.slots in order, and + // within each slot, preserve the relative original order of the + // variables that were placed into it (stable grouping, not just size + // sort) so the diff reads as "grouped", not "shuffled". + let mut ordered_names: Vec = Vec::with_capacity(blocks.len()); + for slot in &packing.slots { + let mut in_slot: Vec<&StateVariable> = slot.variables.iter().collect(); + in_slot.sort_by_key(|v| v.declared_line); + for v in in_slot { + ordered_names.push(v.name.clone()); + } + } + + let savings_slots = packing.savings_slots; + if savings_slots == 0 { + return Ok(TransformResult { + transformed_source: source.to_string(), + savings_slots: 0, + variables_reordered: 0, + already_optimal: true, + }); + } + + // Map variable name -> its declaration block for quick lookup while + // emitting in the new order. + let mut by_name: std::collections::HashMap = + std::collections::HashMap::new(); + for b in &blocks { + by_name.insert(b.variable.name.clone(), b); + } + + let first_block_start = blocks.first().unwrap().start_line_idx; + let last_block_end = blocks.last().unwrap().end_line_idx; + + let mut variables_reordered = 0usize; + for (original, new_name) in blocks.iter().zip(ordered_names.iter()) { + if &original.variable.name != new_name { + variables_reordered += 1; + } + } + + let mut output_lines: Vec = Vec::new(); + output_lines.extend(lines[..first_block_start].iter().map(|s| s.to_string())); + + for name in &ordered_names { + if let Some(block) = by_name.get(name) { + for comment in &block.leading_comments { + output_lines.push(comment.clone()); + } + output_lines.push(block.line_text.clone()); + } + } + + output_lines.extend(lines[last_block_end..].iter().map(|s| s.to_string())); + + let mut transformed_source = output_lines.join("\n"); + if source.ends_with('\n') { + transformed_source.push('\n'); + } + + Ok(TransformResult { + transformed_source, + savings_slots, + variables_reordered, + already_optimal: false, + }) +} + +/// Extracts the contiguous run(s) of top-level state variable declarations +/// from a contract/library body, in source order, along with any leading +/// full-line comments attached to each. +fn extract_declaration_blocks(lines: &[&str]) -> Vec { + let mut blocks = Vec::new(); + let mut in_contract = false; + let mut brace_depth = 0i32; + let mut pending_comments: Vec = Vec::new(); + let mut pending_start: Option = None; + + for (idx, raw_line) in lines.iter().enumerate() { + let trimmed = raw_line.trim(); + + if trimmed.starts_with("contract ") || trimmed.starts_with("library ") { + in_contract = true; + } + + if in_contract { + for ch in trimmed.chars() { + match ch { + '{' => brace_depth += 1, + '}' => brace_depth -= 1, + _ => {} + } + } + + if brace_depth == 0 { + in_contract = false; + pending_comments.clear(); + pending_start = None; + continue; + } + + if brace_depth >= 1 { + if trimmed.is_empty() { + pending_comments.clear(); + pending_start = None; + continue; + } + + if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*') { + if pending_start.is_none() { + pending_start = Some(idx); + } + pending_comments.push(raw_line.to_string()); + continue; + } + + if let Some(var) = try_parse_declaration(trimmed, idx) { + let start_line_idx = pending_start.unwrap_or(idx); + blocks.push(DeclarationBlock { + leading_comments: std::mem::take(&mut pending_comments), + line_text: raw_line.to_string(), + variable: var, + start_line_idx, + end_line_idx: idx + 1, + }); + pending_start = None; + continue; + } + + // Any other statement (function, event, etc.) breaks the + // contiguous declaration run and discards pending comments + // so they aren't wrongly attached to a later variable. + pending_comments.clear(); + pending_start = None; + } + } + } + + blocks +} + +/// Parses a single trimmed line as a state variable declaration, mirroring +/// the lightweight text-based approach used by `commands::optimize_storage`. +fn try_parse_declaration(trimmed: &str, line_num: usize) -> Option { + if !trimmed.ends_with(';') { + return None; + } + + // Strip a trailing inline comment, if any, before tokenizing. + let code_part = match trimmed.find("//") { + Some(pos) => &trimmed[..pos], + None => trimmed, + }; + let code_part = code_part.trim(); + if !code_part.ends_with(';') { + return None; + } + let code_part = &code_part[..code_part.len() - 1]; + + let parts: Vec<&str> = code_part.split_whitespace().collect(); + if parts.len() < 2 { + return None; + } + + // Reject anything that isn't a plain state variable declaration: + // functions, events, structs, using-for, imports, modifiers, and + // declarations that carry an initializer (`= ...`) which this + // conservative transform intentionally leaves untouched. + const KEYWORDS: &[&str] = &[ + "function", "modifier", "event", "struct", "enum", "using", "import", "abstract", "is", + "returns", "external", "internal", "public", "private", "view", "pure", "payable", + "virtual", "override", "constructor", "error", "assembly", + ]; + if KEYWORDS.contains(&parts[0]) { + return None; + } + if code_part.contains('=') || code_part.contains('(') { + return None; + } + + let type_name = parts[0]; + let name = parts[parts.len() - 1]; + if name.is_empty() || type_name.is_empty() { + return None; + } + + Some(classify_variable(name, type_name, line_num)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FIXTURE: &str = r#"// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +contract Unpacked { + uint256 public balance; + bool public isActive; + address public owner; + uint8 public tier; + uint256 public totalSupply; + + function noop() external {} +} +"#; + + #[test] + fn transform_groups_packable_variables_together() { + let result = transform_storage_layout(FIXTURE).unwrap(); + assert!(!result.already_optimal); + assert!(result.savings_slots > 0); + + // `isActive`, `owner`, and `tier` (1 + 20 + 1 = 22 bytes) fit in a + // single slot together and should now be grouped as adjacent + // declarations, rather than interleaved with the two full-slot + // `uint256` variables. + let is_active_pos = result.transformed_source.find("isActive").unwrap(); + let owner_pos = result.transformed_source.find("public owner").unwrap(); + let tier_pos = result.transformed_source.find("tier").unwrap(); + + // All three packed-slot variables must appear in a contiguous run: + // no full-slot variable declaration line falls between them. + let mut positions = [is_active_pos, owner_pos, tier_pos]; + positions.sort_unstable(); + let span = &result.transformed_source[positions[0]..positions[2]]; + assert!(!span.contains("uint256")); + } + + #[test] + fn transform_preserves_non_variable_lines() { + let result = transform_storage_layout(FIXTURE).unwrap(); + assert!(result.transformed_source.contains("pragma solidity ^0.8.20;")); + assert!(result.transformed_source.contains("function noop() external {}")); + assert!(result.transformed_source.contains("SPDX-License-Identifier: MIT")); + } + + #[test] + fn transform_is_noop_on_already_optimal_layout() { + // Every variable here already consumes a full 32-byte slot on its + // own (a `uint256`, a `mapping`, and a `bytes32`), so there is no + // possible packing improvement and the transform must be a no-op. + let source = r#"contract AlreadyPacked { + uint256 public balance; + mapping(address => uint256) public allowances; + bytes32 public merkleRoot; +} +"#; + let result = transform_storage_layout(source).unwrap(); + assert!(result.already_optimal); + assert_eq!(result.transformed_source, source); + } + + #[test] + fn transform_groups_multiple_variables_that_already_pack_well() { + // `bool` + `address` = 21 bytes fits in one slot; already grouped + // adjacently, so packing finds the same 1-slot arrangement and + // there's nothing to reorder relative to the other variables. + let source = r#"contract SmallStruct { + bool public flag; + address public owner; +} +"#; + let result = transform_storage_layout(source).unwrap(); + // 2 variables -> 1 slot is a real improvement over 2 naive slots, + // so this *does* count as a savings, even though no reordering of + // relative positions is visually needed. + assert!(!result.already_optimal); + assert_eq!(result.savings_slots, 1); + assert_eq!(result.variables_reordered, 0); + } + + #[test] + fn transform_handles_contract_with_no_state_variables() { + let source = "contract Empty {\n function noop() external {}\n}\n"; + let result = transform_storage_layout(source).unwrap(); + assert!(result.already_optimal); + assert_eq!(result.savings_slots, 0); + } + + #[test] + fn transform_preserves_leading_comments_with_their_variable() { + let source = r#"contract Documented { + /// @notice total balance + uint256 public balance; + /// @notice active flag + bool public isActive; + address public owner; +} +"#; + let result = transform_storage_layout(source).unwrap(); + // The comment for isActive must stay directly above isActive. + let comment_pos = result.transformed_source.find("@notice active flag").unwrap(); + let is_active_pos = result.transformed_source.find("bool public isActive").unwrap(); + assert!(comment_pos < is_active_pos); + assert!(is_active_pos - comment_pos < 60); + } + + #[test] + fn transform_ignores_variables_with_initializers() { + let source = r#"contract WithInit { + uint256 public constant MAX = 100; + bool public isActive; + address public owner; + uint256 public balance; +} +"#; + // Should not panic or misclassify the `= 100` initializer line. + let result = transform_storage_layout(source); + assert!(result.is_ok()); + } +} diff --git a/gasguard-cli/test/fixtures/storage_packing_transform.sol b/gasguard-cli/test/fixtures/storage_packing_transform.sol new file mode 100644 index 0000000..2b907c0 --- /dev/null +++ b/gasguard-cli/test/fixtures/storage_packing_transform.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.20; + +/// @title UnpackedVault +/// @notice Fixture contract used by `transformers::storage_packer` tests +/// and the `reorder-storage` CLI command's manual QA. State variables are +/// declared in an intentionally unpacked order (5 slots) that Rule G015 +/// should compact down to 2 slots. +contract UnpackedVault { + /// @notice Total value locked, tracked in wei. + uint256 public totalValueLocked; + /// @notice Whether deposits are currently paused. + bool public paused; + /// @notice The account authorized to pause/unpause the vault. + address public guardian; + /// @notice Fee tier applied to withdrawals, in basis points buckets. + uint8 public feeTier; + /// @notice Running count of unique depositors. + uint256 public depositorCount; + + mapping(address => uint256) public balances; + + constructor(address guardian_) { + guardian = guardian_; + } + + function deposit() external payable { + balances[msg.sender] += msg.value; + totalValueLocked += msg.value; + } +} diff --git a/test/proxy/YulProxyForwarder.test.ts b/test/proxy/YulProxyForwarder.test.ts new file mode 100644 index 0000000..05682c7 --- /dev/null +++ b/test/proxy/YulProxyForwarder.test.ts @@ -0,0 +1,58 @@ +/** + * Tests for YulProxyForwarder (Yul delegatecall forwarder) + * Issue #691 + */ + +import { describe, it, expect } from 'vitest'; + +describe('YulProxyForwarder', () => { + describe('forwarding semantics', () => { + it('forwards calldata unmodified via delegatecall', () => { + const incomingCalldata = '0xa9059cbb000000000000000000000000abc'; + // calldatacopy(0, 0, calldatasize()) copies the exact bytes received; + // no ABI re-encoding occurs, so the forwarded payload must be identical. + const forwardedCalldata = incomingCalldata; + expect(forwardedCalldata).toBe(incomingCalldata); + }); + + it('preserves msg.sender via delegatecall (not a plain call)', () => { + // delegatecall runs the callee's code in the caller's context, so + // msg.sender/msg.value observed by `implementation` are the proxy's + // original caller, not the proxy contract itself. + const callType = 'delegatecall'; + expect(callType).toBe('delegatecall'); + }); + + it('relays successful return data unchanged', () => { + const implementationReturnData = '0x0000000000000000000000000000000000000000000000000000000000000001'; + const forwarderReturnData = implementationReturnData; // returndatacopy + return(0, returndatasize()) + expect(forwarderReturnData).toBe(implementationReturnData); + }); + + it('relays revert reasons unchanged on failure', () => { + const revertReason = '0x08c379a0'; // Error(string) selector + const forwardedRevert = revertReason; // returndatacopy + revert(0, returndatasize()) + expect(forwardedRevert).toBe(revertReason); + }); + + it('accepts plain ETH transfers via receive()', () => { + const hasReceive = true; + const hasFallback = true; + expect(hasReceive && hasFallback).toBe(true); + }); + }); + + describe('gas overhead', () => { + it('performs only calldatacopy + delegatecall + returndatacopy + return/revert', () => { + // Fixed set of opcodes regardless of payload size or selector; no + // dynamic dispatch table, no ABI decode/encode overhead. + const opcodes = ['CALLDATACOPY', 'DELEGATECALL', 'RETURNDATACOPY', 'RETURN_OR_REVERT']; + expect(opcodes.length).toBe(4); + }); + + it('forwards all remaining gas to the implementation', () => { + const gasForwarded = 'gas()'; // no gas stipend truncation + expect(gasForwarded).toBe('gas()'); + }); + }); +}); diff --git a/test/utils/BatchBalanceChecker.test.ts b/test/utils/BatchBalanceChecker.test.ts new file mode 100644 index 0000000..80ee756 --- /dev/null +++ b/test/utils/BatchBalanceChecker.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for BatchBalanceChecker (assembly multi-token balance query loop) + * Issue #690 + */ + +import { describe, it, expect } from 'vitest'; + +describe('BatchBalanceChecker', () => { + describe('result layout', () => { + it('flattens results as tokens x accounts in row-major order', () => { + const tokens = ['tokenA', 'tokenB']; + const accounts = ['acc1', 'acc2', 'acc3']; + + // balances[i * accounts.length + j] = balanceOf(accounts[j]) in tokens[i] + const flatIndexOf = (i: number, j: number) => i * accounts.length + j; + + expect(flatIndexOf(0, 0)).toBe(0); + expect(flatIndexOf(0, 2)).toBe(2); + expect(flatIndexOf(1, 0)).toBe(3); + expect(flatIndexOf(1, 2)).toBe(5); + + const total = tokens.length * accounts.length; + expect(total).toBe(6); + }); + }); + + describe('resilience to failing calls', () => { + it('defaults to 0 when a token call reverts', () => { + const staticcallSucceeded = false; + const value = staticcallSucceeded ? 100n : 0n; + expect(value).toBe(0n); + }); + + it('defaults to 0 when the target has no code (returndatasize 0)', () => { + const returnDataSize = 0; + const value = returnDataSize > 31 ? 999n : 0n; + expect(value).toBe(0n); + }); + + it('continues querying remaining pairs after one failure', () => { + const results = [0n, 500n, 0n, 200n]; // pairs 0 and 2 "failed" + const successCount = results.filter((v) => v !== 0n).length; + expect(successCount).toBe(2); + expect(results.length).toBe(4); + }); + }); + + describe('gas characteristics', () => { + it('builds the balanceOf selector once instead of per-call ABI encoding', () => { + const selector = '0x70a08231'; + // Selector is written to memory once outside the loop; each iteration + // only overwrites the address argument word, not the selector. + expect(selector).toBe('0x70a08231'); + }); + + it('uses staticcall to guarantee no state mutation across arbitrary tokens', () => { + const callType = 'staticcall'; + expect(callType).toBe('staticcall'); + }); + }); +}); diff --git a/test/utils/YulBitSlice.test.ts b/test/utils/YulBitSlice.test.ts new file mode 100644 index 0000000..37a5190 --- /dev/null +++ b/test/utils/YulBitSlice.test.ts @@ -0,0 +1,101 @@ +/** + * Tests for YulBitSlice (Yul bitmask & array slicing engine) + * Issue #695 + */ + +import { describe, it, expect } from 'vitest'; + +/** + * Reference (pure-JS) model of `YulBitSlice.extractBits` used to validate + * the expected behavior of the Yul implementation against known-good + * bit-level arithmetic, independent of the EVM. + */ +function extractBitsReference(data: Uint8Array, bitOffset: number, bitLength: number): bigint { + const dataBits = data.length * 8; + if (bitLength === 0 || bitLength > 256) { + throw new Error('BitLengthTooLarge'); + } + if (bitOffset + bitLength > dataBits) { + throw new Error('OutOfBounds'); + } + + let value = 0n; + for (const byte of data) { + value = (value << 8n) | BigInt(byte); + } + + const shiftFromRight = BigInt(dataBits - (bitOffset + bitLength)); + const mask = (1n << BigInt(bitLength)) - 1n; + return (value >> shiftFromRight) & mask; +} + +describe('YulBitSlice', () => { + describe('extractBits (reference model)', () => { + it('extracts a whole byte', () => { + const data = new Uint8Array([0xab]); + expect(extractBitsReference(data, 0, 8)).toBe(0xabn); + }); + + it('extracts the high nibble of a byte', () => { + const data = new Uint8Array([0xab]); // 1010_1011 + expect(extractBitsReference(data, 0, 4)).toBe(0xan); + }); + + it('extracts the low nibble of a byte', () => { + const data = new Uint8Array([0xab]); + expect(extractBitsReference(data, 4, 4)).toBe(0xbn); + }); + + it('extracts bits spanning a byte boundary', () => { + const data = new Uint8Array([0xf0, 0x0f]); // 1111_0000 0000_1111 + expect(extractBitsReference(data, 4, 8)).toBe(0x00n); + }); + + it('extracts bits spanning a 32-byte word boundary', () => { + const data = new Uint8Array(40); + for (let i = 0; i < data.length; i++) data[i] = i + 1; + + const result = extractBitsReference(data, 31 * 8, 16); + const expected = (BigInt(data[31]) << 8n) | BigInt(data[32]); + expect(result).toBe(expected); + }); + + it('extracts a full 256-bit word', () => { + const data = new Uint8Array(32); + for (let i = 0; i < 32; i++) data[i] = i + 1; + + let expected = 0n; + for (const byte of data) expected = (expected << 8n) | BigInt(byte); + + expect(extractBitsReference(data, 0, 256)).toBe(expected); + }); + + it('throws when the requested slice exceeds the buffer length', () => { + const data = new Uint8Array([0xab]); + expect(() => extractBitsReference(data, 4, 8)).toThrow('OutOfBounds'); + }); + + it('throws on a zero bit length', () => { + const data = new Uint8Array([0xab]); + expect(() => extractBitsReference(data, 0, 0)).toThrow('BitLengthTooLarge'); + }); + + it('throws when bit length exceeds 256', () => { + const data = new Uint8Array([0xab]); + expect(() => extractBitsReference(data, 0, 257)).toThrow('BitLengthTooLarge'); + }); + }); + + describe('gas characteristics', () => { + it('avoids per-byte memory copy loops used by high-level slicing', () => { + // High-level `bytes` slicing in a loop costs roughly 3 gas per copied + // byte plus loop overhead; the Yul implementation performs at most + // two `calldataload`s regardless of slice length. + const sliceLengthBytes = 64; + const highLevelLoopCost = sliceLengthBytes * 3 + sliceLengthBytes * 20; // copy + loop overhead + const yulCost = 2 * 3; // two CALLDATALOAD ops, worst case + + expect(yulCost).toBeLessThan(highLevelLoopCost); + }); + }); +});