Last Man Standing

First Flight #45
Beginner FriendlyFoundrySolidity
100 EXP
View results
Submission Details
Impact: low
Likelihood: high
Invalid

Inefficient Storage Packing & Over-Allocated Integer Types in `./src/Game.sol::Game` Lead to Increased Gas Costs

Root Cause + Impact

The Game contract in ./src/Game.sol suffers from inefficient storage layout and over-allocated data types, both of which increase gas usage and waste precious storage slots on-chain. Specifically:

Description

  • Data or Storage Packing must be optimal and take less storage space and less execution cost.

  • Variables such as currentKing, gameEnded, and _locked are not packed optimally. Several variables like lastClaimTime, gracePeriod, and feeIncreasePercentage use unnecessarily large types like uint256, even though their actual range of values can easily be represented using smaller types like uint64, uint32, or uint8. These inefficiencies not only increase deployment costs and runtime transaction gas, but also violate Solidity best practices for state variable layout and packing.

Solidity stores state variables in 32-byte storage slots and packs multiple smaller variables only if they are defined consecutively and fit within a single slot.

Suboptimal Packing:

  • currentKing (address, 20 bytes) is placed alone in slot 1.

  • gameEnded and _locked (bools, 1 byte each) are isolated in slots 6 and 16, wasting 62 bytes of storage.

Over-Allocated Types:

Variable Purpose Current Type Optimal Type Notes
lastClaimTime Timestamp uint256 uint64 Unix timestamps fit in 64 bits
gracePeriod Time duration in seconds uint256 uint32/64 Timeouts rarely exceed 2^32
initialGracePeriod Same uint256 uint32/64
feeIncreasePercentage Percent value (0–100) uint256 uint8 Max 100%
platformFeePercentage Same uint256 uint8

Emoticon Storage Diagram

Current Layout

[📦 Slot 0] _owner (address)
[📦 Slot 1] currentKing (address) ❌ wasted 12 bytes
[📦 Slot 2] lastClaimTime (uint256) ❌ needs only 64 bits
[📦 Slot 3] gracePeriod (uint256) ❌ only few bytes needed
[📦 Slot 4] pot (uint256)
[📦 Slot 5] claimFee (uint256)
[📦 Slot 6] gameEnded (bool) ❌ wastes 31 bytes
[📦 Slot 710] other uint256
[📦 Slot 16] _locked (bool) ❌ even worse, 31 slots apart!

Optimized Layout

[📦 Slot 0] _owner (address)
[📦 Slot 1] currentKing (address), gameEnded (bool), _locked (bool) ✅ packed
[📦 Slot 2] lastClaimTime (uint64), gracePeriod (uint64), initialGracePeriod (uint64),
feeIncreasePercentage (uint8), platformFeePercentage (uint8) ✅ all packed
[📦 Slot 3+] uint256 vars + mappings

Risk

Likelihood: It's currently inevitable therefore, The likelihood is High.

Updating variables like gameEnded, _locked, or currentKing individually results in separate slot writes, each costing up to 20,000 gas. When packed, these writes can share slots, reducing gas to ~5,000–8,000 or even less.

Impact: Low

  • High Gas Costs:

    • More storage slots touched → more SSTORE ops → higher gas fees.

  • Wasted Storage Space:

    • Misaligned and over-allocated types lead to bloated memory footprint.

  • Reduced Scalability:

    • Larger contracts hit block gas limits sooner.

  • Bad Engineering Practice:

    • Makes audits harder and reflects lack of low-level optimization awareness.

Proof of Concept

// GameOptimized.sol (Fixed Layout)
contract GameOptimized {
// address _owner; // inherited from the Ownable Contract/Library
// ✅ Packing: address (20) + bool (1) + bool (1) = packed in 1 slot
address public currentKing;
bool public gameEnded;
bool private _locked;
//...
uint64 public lastClaimTime;
uint64 public gracePeriod;
uint64 public initialGracePeriod;
uint8 public feeIncreasePercentage;
uint8 public platformFeePercentage;
//...
// rest of the variables
//...
}
// GameUnoptimized.sol (Original Layout)
contract GameUnoptimized {
// address _owner; // inherited from the Ownable Contract/Library
address public currentKing;
uint256 public lastClaimTime;
uint256 public gracePeriod;
uint256 public pot;
uint256 public claimFee;
bool public gameEnded;
uint256 public initialClaimFee;
uint256 public feeIncreasePercentage;
uint256 public platformFeePercentage;
uint256 public initialGracePeriod;
mapping(address => uint256) public pendingWinnings;
uint256 public platformFeesBalance;
uint256 public gameRound;
uint256 public totalClaims;
mapping(address => uint256) public playerClaimCount;
bool private _locked;
//...
}
// `Gas.t.sol`
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.20;
import {Test, console2} from "forge-std/Test.sol";
import {Game} from "../src/Game.sol";
import {GameOptimized} from "../src/GameOptimized.sol";
import {GameUnoptimized} from "../src/GameUnoptimized.sol";
contract GasTest is Test {
GameOptimized optimized;
GameUnoptimized unoptimized;
function setUp() public {
optimized = new GameOptimized(INITIAL_CLAIM_FEE, GRACE_PERIOD, FEE_INCREASE_PERCENTAGE, PLATFORM_FEE_PERCENTAGE);
unoptimized = new GameUnoptimized(INITIAL_CLAIM_FEE, GRACE_PERIOD, FEE_INCREASE_PERCENTAGE, PLATFORM_FEE_PERCENTAGE);
}
function testGasOptimized() public {
// play game, run test, and then see the gas report...
}
function testGasUnoptimized() public {
// play game, run test, and then see the gas report...
}
}

Run Gas Report

forge test --gas-report

Expected result: GameOptimized consumes 1,500–15,000 less gas per write, especially on warm slots or grouped writes.

Tools Used

  • Foundry Test Suite

  • Chat-GPT AI Assistance (Report Grammar Check & Improvements)

  • Foundry's Black box utility commands i.e., forge inspect

  • Manual Review

Refs and Resources

Recommendations

// 1. **Reorder for Packing**
// Group variables by size:
// 20 + 1 + 1 = 22 bytes -> Slot 1
+ address public currentKing;
+ bool public gameEnded;
+ bool public _locked;

Use Proper Integer Sizes

Variable Type
lastClaimTime uint64
gracePeriod uint64
initialGracePeriod uint64
feeIncreasePercentage uint8
platformFeePercentage uint8

Pack them together like so:

uint64 public lastClaimTime;
uint64 public gracePeriod;
uint64 public initialGracePeriod;
uint8 public feeIncreasePercentage;
uint8 public platformFeePercentage;

3. Group Bool and Small Ints

Pack booleans together or with small integers (e.g., uint8).

4. Use forge inspect to Check Layout

Confirm slot usage using:

forge inspect ./src/Game.sol::Game storageLayout

5. Final Optimized Layout (Proposed)

address private _owner;
// Slot 1 — tightly packed
address public currentKing;
bool public gameEnded;
bool private _locked;
// Slot 2 — timestamps + percents packed
uint64 public lastClaimTime;
uint64 public gracePeriod;
uint64 public initialGracePeriod;
uint8 public feeIncreasePercentage;
uint8 public platformFeePercentage;
// Remaining uint256 variables
uint256 public pot;
uint256 public claimFee;
uint256 public initialClaimFee;
uint256 public platformFeesBalance;
uint256 public gameRound;
uint256 public totalClaims;
// Mappings (always take full slots)
mapping(address => uint256) public pendingWinnings;
mapping(address => uint256) public playerClaimCount;
//...
Updates

Appeal created

inallhonesty Lead Judge about 2 months ago
Submission Judgement Published
Invalidated
Reason: Non-acceptable severity

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.