diff --git a/contracts/events/EventRegistry.sol b/contracts/events/EventRegistry.sol index 395b916..b21464f 100644 --- a/contracts/events/EventRegistry.sol +++ b/contracts/events/EventRegistry.sol @@ -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) + } + } } diff --git a/gasguard-cli/src/lib.rs b/gasguard-cli/src/lib.rs index 39ce51d..0bcdb3b 100644 --- a/gasguard-cli/src/lib.rs +++ b/gasguard-cli/src/lib.rs @@ -1,3 +1,4 @@ pub mod commands; +pub mod reporter; pub mod storage_model; pub mod transformers; \ No newline at end of file diff --git a/gasguard-cli/src/reporter/heatmap.rs b/gasguard-cli/src/reporter/heatmap.rs index 21eee8f..80c56ce 100644 --- a/gasguard-cli/src/reporter/heatmap.rs +++ b/gasguard-cli/src/reporter/heatmap.rs @@ -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, +} + 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) -> 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) -> &'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) -> &'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) -> &'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]")); + } } diff --git a/rules/g016_redundant_casts.rs b/rules/g016_redundant_casts.rs index ec48cb9..8271c13 100644 --- a/rules/g016_redundant_casts.rs +++ b/rules/g016_redundant_casts.rs @@ -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; @@ -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 { 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()); + } +} diff --git a/rules/g017_enum_iteration.rs b/rules/g017_enum_iteration.rs index 0121244..5d23b3a 100644 --- a/rules/g017_enum_iteration.rs +++ b/rules/g017_enum_iteration.rs @@ -1,4 +1,9 @@ //! Rule G017: Flag Unbounded Iteration Over Enum Types in Solidity loops. +//! +//! Detects `for` and `while` loops whose body casts the loop index (or a +//! variable derived from it) into an `enum` type on every iteration. This +//! pattern re-validates enum bounds and performs a type conversion on each +//! pass instead of using a precomputed bitmask or lookup table. pub struct RuleG017EnumIteration; @@ -7,13 +12,213 @@ impl RuleG017EnumIteration { "G017_enum_iteration" } + /// Extracts the declared names of every `enum` type defined in the source. + fn enum_names(source_code: &str) -> Vec { + let mut names = Vec::new(); + for line in source_code.lines() { + let trimmed = line.trim_start(); + if let Some(rest) = trimmed.strip_prefix("enum ") { + if let Some(name) = rest.split(|c: char| c == '{' || c.is_whitespace()).next() { + if !name.is_empty() { + names.push(name.to_string()); + } + } + } + } + names + } + + /// Finds every `for (...) { ... }` / `while (...) { ... }` loop body in + /// the source, returning the raw body text for each match. This is a + /// lightweight brace-matching scan rather than a full parser, but it is + /// sufficient to isolate loop bodies (including nested loops, which are + /// each returned as their own entry) for pattern inspection. + fn loop_bodies(source_code: &str) -> Vec { + let mut bodies = Vec::new(); + let bytes = source_code.as_bytes(); + let mut i = 0; + while i < source_code.len() { + let is_for = source_code[i..].starts_with("for (") || source_code[i..].starts_with("for("); + let is_while = + source_code[i..].starts_with("while (") || source_code[i..].starts_with("while("); + if is_for || is_while { + // Find the opening brace of the loop body, skipping the + // condition/header parens first. + if let Some(rel_paren) = source_code[i..].find('(') { + let mut depth = 0i32; + let mut j = i + rel_paren; + let mut header_end = None; + while j < bytes.len() { + match bytes[j] { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + header_end = Some(j + 1); + break; + } + } + _ => {} + } + j += 1; + } + if let Some(mut k) = header_end { + while k < bytes.len() && (bytes[k] as char).is_whitespace() { + k += 1; + } + if k < bytes.len() && bytes[k] == b'{' { + let mut brace_depth = 0i32; + let start = k; + let mut end = k; + while k < bytes.len() { + match bytes[k] { + b'{' => brace_depth += 1, + b'}' => { + brace_depth -= 1; + if brace_depth == 0 { + end = k + 1; + break; + } + } + _ => {} + } + k += 1; + } + if end > start { + bodies.push(source_code[start..end].to_string()); + } + } + } + } + } + i += 1; + } + bodies + } + + /// Returns true if `body` contains a cast of the form `EnumName(expr)` + /// for one of the known enum type names. + fn casts_to_enum(body: &str, enum_names: &[String]) -> bool { + enum_names.iter().any(|name| { + let pattern = format!("{}(", name); + body.contains(&pattern) + }) + } + pub fn check(source_code: &str) -> Vec { let mut warnings = Vec::new(); - if source_code.contains("for (") || source_code.contains("while (") { - if source_code.contains("enum ") || source_code.contains("Enum") { - warnings.push("Warning: Unbounded iteration over enum type detected".to_string()); + let enums = Self::enum_names(source_code); + if enums.is_empty() { + return warnings; + } + + for body in Self::loop_bodies(source_code) { + if Self::casts_to_enum(&body, &enums) { + warnings.push( + "Warning: Unbounded iteration over enum type detected via sequential \ + integer-to-enum casting; consider a bitmask or lookup mapping instead" + .to_string(), + ); } } + warnings } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flags_for_loop_casting_index_to_enum() { + let code = r#" + enum ActionState { PENDING, ACTIVE, COMPLETED, CANCELLED } + + function iterateEnum() external pure { + for (uint8 i = 0; i < 4; i++) { + ActionState state = ActionState(i); + } + } + "#; + let warnings = RuleG017EnumIteration::check(code); + assert_eq!(warnings.len(), 1); + } + + #[test] + fn flags_while_loop_casting_index_to_enum() { + let code = r#" + enum Phase { INIT, RUNNING, DONE } + + function walk() external pure { + uint8 i = 0; + while (i < 3) { + Phase p = Phase(i); + i++; + } + } + "#; + let warnings = RuleG017EnumIteration::check(code); + assert_eq!(warnings.len(), 1); + } + + #[test] + fn flags_each_loop_independently_in_nested_loops() { + let code = r#" + enum Color { RED, GREEN, BLUE } + + function nested() external pure { + for (uint8 i = 0; i < 2; i++) { + for (uint8 j = 0; j < 3; j++) { + Color c = Color(j); + } + } + } + "#; + let warnings = RuleG017EnumIteration::check(code); + // The outer loop body (which contains the inner loop, which itself + // casts to Color) and the inner loop body both match. + assert_eq!(warnings.len(), 2); + } + + #[test] + fn does_not_flag_standard_integer_index_loop() { + let code = r#" + enum ActionState { PENDING, ACTIVE, COMPLETED, CANCELLED } + + function sum(uint256[] memory arr) external pure returns (uint256 total) { + for (uint256 i = 0; i < arr.length; i++) { + total += arr[i]; + } + } + "#; + let warnings = RuleG017EnumIteration::check(code); + assert!(warnings.is_empty()); + } + + #[test] + fn does_not_flag_contract_with_no_enums() { + let code = r#" + function sum(uint256[] memory arr) external pure returns (uint256 total) { + for (uint256 i = 0; i < arr.length; i++) { + total += arr[i]; + } + } + "#; + let warnings = RuleG017EnumIteration::check(code); + assert!(warnings.is_empty()); + } + + #[test] + fn does_not_flag_enum_cast_outside_a_loop() { + let code = r#" + enum ActionState { PENDING, ACTIVE, COMPLETED, CANCELLED } + + function single(uint8 i) external pure returns (ActionState) { + return ActionState(i); + } + "#; + let warnings = RuleG017EnumIteration::check(code); + assert!(warnings.is_empty()); + } +} diff --git a/test/events/EventRegistry.test.ts b/test/events/EventRegistry.test.ts index 5b26872..9260613 100644 --- a/test/events/EventRegistry.test.ts +++ b/test/events/EventRegistry.test.ts @@ -1,7 +1,54 @@ import { expect } from "chai"; +import { ethers } from "hardhat"; +import type { Contract } from "ethers"; describe("EventRegistry", () => { - it("should inline constant event topic selectors", async () => { - expect(true).to.be.true; + let registry: Contract; + + beforeEach(async function () { + const Factory = await ethers.getContractFactory("EventRegistry"); + registry = await Factory.deploy(); + await registry.waitForDeployment(); + }); + + it("should inline constant event topic selectors matching their canonical ABI signatures", async () => { + expect(await registry.EVENT_TRANSFER_TOPIC()).to.equal( + ethers.id("Transfer(address,address,uint256)") + ); + expect(await registry.EVENT_APPROVAL_TOPIC()).to.equal( + ethers.id("Approval(address,address,uint256)") + ); + expect(await registry.EVENT_APPROVAL_FOR_ALL_TOPIC()).to.equal( + ethers.id("ApprovalForAll(address,address,bool)") + ); + expect(await registry.EVENT_OWNERSHIP_TRANSFERRED_TOPIC()).to.equal( + ethers.id("OwnershipTransferred(address,address)") + ); + }); + + it("should emit LogRegistered with the pre-computed transfer topic", async () => { + const [, emitter] = await ethers.getSigners(); + const transferTopic = await registry.EVENT_TRANSFER_TOPIC(); + + await expect(registry.logEvent(emitter.address)) + .to.emit(registry, "LogRegistered") + .withArgs(transferTopic, emitter.address); + }); + + it("should emit a raw Approval-shaped log via assembly using the constant topic0", async () => { + const [owner, spender] = await ethers.getSigners(); + const approvalTopic = await registry.EVENT_APPROVAL_TOPIC(); + + const tx = await registry.emitViaAssembly(owner.address, spender.address, 42n); + const receipt = await tx.wait(); + + const log = receipt!.logs.find( + (l: { address: string }) => l.address === (await registry.getAddress()) + ); + expect(log).to.not.be.undefined; + expect(log!.topics[0]).to.equal(approvalTopic); + expect(ethers.getAddress("0x" + log!.topics[1].slice(26))).to.equal(owner.address); + expect(ethers.getAddress("0x" + log!.topics[2].slice(26))).to.equal(spender.address); + expect(BigInt(log!.data)).to.equal(42n); }); }); diff --git a/test/fixtures/g016_samples.sol b/test/fixtures/g016_samples.sol index 8295228..8d9479a 100644 --- a/test/fixtures/g016_samples.sol +++ b/test/fixtures/g016_samples.sol @@ -2,7 +2,23 @@ pragma solidity ^0.8.20; contract G016Sample { + // Flagged: redundant payable double-cast. function redundantCast(address addr) external pure returns (address) { return address(payable(addr)); } + + // Flagged: redundant uint160 round-trip. + function redundantUint160(uint160 x) external pure returns (address) { + return address(uint160(x)); + } + + // Flagged: redundant triple-cast chain. + function redundantTripleCast(uint256 x) external pure returns (address) { + return address(uint160(uint256(x))); + } + + // Not flagged: necessary bytes32 -> address narrowing. + function necessaryConversion(bytes32 b) external pure returns (address) { + return address(uint256(b)); + } } diff --git a/test/fixtures/g017_samples.sol b/test/fixtures/g017_samples.sol index 1f55a0b..91b34e9 100644 --- a/test/fixtures/g017_samples.sol +++ b/test/fixtures/g017_samples.sol @@ -3,10 +3,42 @@ pragma solidity ^0.8.20; contract G017Sample { enum ActionState { PENDING, ACTIVE, COMPLETED, CANCELLED } + enum Phase { INIT, RUNNING, DONE } + // Flagged: for-loop casts its index into an enum on every iteration. function iterateEnum() external pure { for (uint8 i = 0; i < 4; i++) { ActionState state = ActionState(i); } } + + // Flagged: while-loop casts its index into an enum on every iteration. + function iterateEnumWhile() external pure { + uint8 i = 0; + while (i < 3) { + Phase p = Phase(i); + i++; + } + } + + // Flagged: nested loops, inner loop casts into an enum. + function iterateEnumNested() external pure { + for (uint8 i = 0; i < 2; i++) { + for (uint8 j = 0; j < 4; j++) { + ActionState state = ActionState(j); + } + } + } + + // Not flagged: plain integer-index loop with no enum casting. + function sum(uint256[] memory arr) external pure returns (uint256 total) { + for (uint256 i = 0; i < arr.length; i++) { + total += arr[i]; + } + } + + // Not flagged: enum cast happens once, outside of any loop. + function single(uint8 i) external pure returns (ActionState) { + return ActionState(i); + } }