Variables & Data Types
Solidity's type system is the first place most auditors look for bugs. Integer overflow, precision loss, timestamp manipulation, and storage collision vulnerabilities all originate in how data types behave. This lesson covers every important type with security implications for each.
Value Types — The Complete Reference
Value types are stored directly (not as references). When assigned to a new variable, they are copied.
| Type | Size | Range / Values | Default | Security Note |
|---|---|---|---|---|
| bool | 1 bit (stored as 1 byte) | true / false | false | Can be accidentally reset by bad initialization |
| uint8 | 8 bits | 0 to 255 | 0 | Overflows at 255 in unchecked blocks |
| uint16 | 16 bits | 0 to 65,535 | 0 | Common in Uniswap tick math |
| uint32 | 32 bits | 0 to 4,294,967,295 | 0 | Timestamps fit in uint32 until year 2106 |
| uint64 | 64 bits | 0 to 1.8 * 10^19 | 0 | Often used for timestamps, amounts |
| uint128 | 128 bits | 0 to 3.4 * 10^38 | 0 | Uniswap v3 uses for liquidity |
| uint256 | 256 bits | 0 to 1.15 * 10^77 | 0 | Default uint — use this unless you have reason not to |
| int256 | 256 bits | -5.8*10^76 to 5.8*10^76 | 0 | type(int256).min negation can overflow! |
| address | 20 bytes | Ethereum address | address(0) | address(0) check is critical for access control |
| bytes32 | 32 bytes | Fixed byte array | 0x00...00 | Commonly used for hashes, roles |
| enum | uint8 internally | Named values | First value | Casting invalid uint to enum = undefined behavior |
Integers in Depth — Sizes, Overflow, and Safety
// All integer types and their boundaries
uint8 maxU8 = 255; // 2^8 - 1
uint16 maxU16 = 65535; // 2^16 - 1
uint256 maxU256 = type(uint256).max; // 2^256 - 1 = 115792089...71615
int256 maxI256 = type(int256).max; // 2^255 - 1
int256 minI256 = type(int256).min; // -2^255
// DANGER: type(int256).min negation overflows!
int256 danger = -type(int256).min; // REVERTS in 0.8.x! Still = min in unchecked
// Retrieving min/max at runtime:
function getMaxUint() external pure returns (uint256) {
return type(uint256).max;
}
// Wrapping arithmetic (useful when you want overflow behavior):
unchecked {
uint256 wrapped = type(uint256).max + 1; // = 0 (wraps around)
}Reference Types — Arrays, Structs, Mappings
contract ReferenceTypes {
// FIXED-SIZE ARRAY: size known at compile time
uint256[5] public fixedArray;
// DYNAMIC ARRAY: size can change
address[] public users;
// STRUCT: group related data
struct User {
address wallet;
uint256 balance;
uint64 lastActive;
bool isActive;
}
mapping(address => User) public userProfiles;
// MAPPING: key-value store (cannot iterate!)
mapping(address => uint256) public balances;
// NESTED MAPPING: e.g. allowances[owner][spender] = amount
mapping(address => mapping(address => uint256)) public allowances;
function arrayOps() external {
users.push(msg.sender); // add to end
users.pop(); // remove last element
uint256 len = users.length; // get length
delete users[0]; // zeroes element, does NOT shift others
}
}Mappings do not store their keys. You cannot loop over all entries in a mapping, and you cannot return a mapping from a function. If you need to iterate, maintain a separate array of keys alongside the mapping. This is a common source of confusion for beginners.
Storage Locations: storage, memory, calldata
| Location | Where | Lifetime | Mutable? | Cost | When to Use |
|---|---|---|---|---|---|
storage | On-chain, contract state | Forever | Yes | Very expensive (20K write) | State variables, persistent data |
memory | In-memory, temporary | Function call duration | Yes | Cheap (3 gas/word) | Local variables, return values, complex objects |
calldata | Input data buffer | Function call duration | No (read-only) | Cheapest | External function params (saves gas vs memory) |
contract StorageLocations {
uint256[] public myArray; // storage (state variable)
// calldata: cheapest for external function inputs (read-only)
function processData(uint256[] calldata input) external pure
returns (uint256)
{
// input is calldata — cannot modify input[0] = 5 (compile error)
return input[0];
}
// memory: mutable temporary copy
function processDataMutable(uint256[] memory input) external pure
returns (uint256)
{
input[0] = 42; // OK — memory is mutable
return input[0];
}
// CRITICAL: Storage pointer vs memory copy
function storageRef() external {
// storage reference — modifies the actual state variable
uint256[] storage ref = myArray;
ref.push(99); // myArray is now [99]
// memory copy — does NOT modify state
uint256[] memory copy = myArray;
copy[0] = 123; // myArray[0] is STILL 99
}
}Type Casting — Safe and Unsafe Conversions
// Implicit conversion (safe — widening)
uint8 small = 100;
uint256 big = small; // OK: 100 fits in uint256
// Explicit conversion — DANGEROUS if truncating
uint256 large = 300;
uint8 truncated = uint8(large); // 300 % 256 = 44 — silent data loss!
// In Solidity 0.8.x: explicit truncation is allowed but silent
// You MUST check bounds yourself before downcasting
function safeCast(uint256 value) internal pure returns (uint8) {
require(value <= type(uint8).max, "Value too large");
return uint8(value);
}
// Or use OpenZeppelin SafeCast library:
import "@openzeppelin/contracts/utils/math/SafeCast.sol";
uint8 safe = SafeCast.toUint8(large); // Reverts if > 255
// address conversions:
address addr = address(0x1234);
address payable payableAddr = payable(addr); // Required to send ETH
uint256 asUint = uint256(uint160(addr)); // address → uint: must go via uint160Integer Overflow/Underflow — History and the 0.8.x Fix
Before Solidity 0.8.0 (released December 2020), integer arithmetic silently wrapped around on overflow/underflow. This caused some of the biggest hacks in early DeFi history. The BeautyChain token lost hundreds of millions because a multiplication overflow allowed minting unlimited tokens.
// VULNERABLE (Solidity < 0.8.0)
contract VulnerableToken {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
// UNDERFLOW: if balances[msg.sender] = 0 and amount = 1:
// 0 - 1 = 2^256 - 1 (wraps to max uint256!)
// The require passes because 2^256-1 >= 1
require(balances[msg.sender] - amount >= 0); // BUG: always true for uint!
balances[msg.sender] -= amount; // Attacker now has massive balance
balances[to] += amount;
}
}
// CORRECT fix for < 0.8.0: Use SafeMath (OpenZeppelin)
using SafeMath for uint256;
balances[msg.sender] = balances[msg.sender].sub(amount); // Reverts on underflow
// Solidity 0.8.0+: Arithmetic is CHECKED BY DEFAULT
pragma solidity ^0.8.0;
// Now: 0 - 1 reverts with Panic(0x11)
// SafeMath is no longer needed for simple arithmetic
// When you WANT wrap-around behavior (e.g., in gas-optimized loops):
unchecked {
i++; // Saves gas by skipping overflow check — safe if you know i < max
}Special Global Variables — msg, block, tx
// MSG — Information about the current call
msg.sender; // address: who called this function (immediate caller)
msg.value; // uint256: ETH sent (in wei) with this call
msg.data; // bytes: full calldata (ABI-encoded function call)
msg.sig; // bytes4: function selector (first 4 bytes of msg.data)
// BLOCK — Information about the current block
block.timestamp; // uint256: current block time (Unix seconds) — CAN BE MANIPULATED
block.number; // uint256: current block height
block.basefee; // uint256: current block's base fee (EIP-1559)
block.difficulty; // uint256: PoW difficulty (now PREVRANDAO in PoS)
block.chainid; // uint256: chain ID (1 = mainnet)
block.coinbase; // address payable: current block's validator address
block.gaslimit; // uint256: block gas limit
// TX — Information about the originating transaction
tx.origin; // address: EOA that initiated the entire tx chain (NEVER use for auth!)
tx.gasprice; // uint256: gas price of the transaction
// OTHERS
gasleft(); // uint256: remaining gas in current execution
address(this).balance; // uint256: ETH balance of this contract
blockhash(n); // bytes32: hash of block n (only last 256 blocks)Constants and Immutables — Gas Savings and Security
contract GasOptimized {
// CONSTANT: value set at compile time, inlined in bytecode
// No SLOAD needed — free to read!
// Must be a literal value or constant expression
uint256 public constant MAX_SUPPLY = 1_000_000 * 10**18;
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN_ROLE");
// IMMUTABLE: set once in constructor, then inlined in bytecode
// No SLOAD needed — free to read!
// Value determined at deploy time (not compile time)
address public immutable owner;
uint256 public immutable deploymentTime;
constructor() {
owner = msg.sender; // Set once in constructor
deploymentTime = block.timestamp;
}
// REGULAR STATE VARIABLE: stored in contract storage slot
// Costs 2,100 gas (cold) or 100 gas (warm) to read
uint256 public mutableValue; // expensive to read
}| Feature | constant | immutable | state variable |
|---|---|---|---|
| When value set | Compile time | Constructor | Anytime |
| Can be changed after deploy? | No | No | Yes |
| Gas to read | Free (inlined) | Free (inlined) | 100-2,100 gas (SLOAD) |
| Can use runtime values? | No | Yes (constructor args) | Yes |
Common Vulnerabilities — Real Examples
1. Precision Loss from Integer Division
// Solidity uses INTEGER division — always rounds DOWN
uint256 a = 1;
uint256 b = 3;
uint256 result = a / b; // = 0, not 0.333...
// Real vulnerability: fee calculation rounding
function calculateFee(uint256 amount) public pure returns (uint256) {
// BUG: If amount < 100, fee = 0 (rounding to zero)
// Attacker can make many small transactions with zero fee
return amount / 100; // 1% fee — but 50 / 100 = 0
}
// FIX: Multiply before dividing to preserve precision
// Use fixed-point math with a scaling factor (e.g., 1e18 = WAD)
uint256 constant WAD = 1e18;
function calculateFeeFixed(uint256 amount) public pure returns (uint256) {
// Multiply first, scale down last
return (amount * 1 * WAD / 100) / WAD;
// Or simply: require amount has enough precision
}2. Timestamp Dependence Vulnerability
Validators can adjust block.timestamp by up to ~15 seconds from the true time. While small, this is enough to manipulate any smart contract that uses timestamps for logic like "is this auction over?", "can this reward be claimed?", or — most dangerously — as a source of randomness.
// VULNERABLE: Using timestamp as pseudo-random number
contract BadLottery {
function pickWinner() external {
// NEVER do this: timestamp is not random
// A validator proposing this block CONTROLS the timestamp
// They can choose a timestamp where they win!
uint256 winner = block.timestamp % participants.length;
participants[winner].transfer(address(this).balance);
}
}
// Also dangerous: block.difficulty (now PREVRANDAO in PoS)
// PREVRANDAO is better than timestamp but still manipulable by validators
// SECURE: Use Chainlink VRF for true randomness
import "@chainlink/contracts/src/v0.8/VRFConsumerBaseV2.sol";
// Timestamp IS acceptable for:
// - Timelocks (15-second manipulation is fine for 24h locks)
// - Cooldown periods where precision doesn't matter
// - Display purposes
// Timestamp is NOT acceptable for:
// - Randomness (any amount of control is exploitable)
// - High-value financial cutoffs (exact-second matters)// Deploy this in Remix IDE to see overflow behavior
pragma solidity ^0.8.20;
contract OverflowDemo {
// This will REVERT in 0.8.x (checked arithmetic)
function checkedOverflow() external pure returns (uint8) {
uint8 x = 255;
return x + 1; // REVERTS with Panic(0x11)
}
// This will WRAP AROUND (unchecked block)
function uncheckedOverflow() external pure returns (uint8) {
uint8 x = 255;
unchecked {
return x + 1; // Returns 0 (wraps around)
}
}
// Precision loss demonstration
function precisionLoss() external pure returns (uint256 bad, uint256 good) {
uint256 amount = 7;
bad = amount / 3 * 3; // 7/3=2, 2*3=6 — WRONG (lost 1)
good = amount * 3 / 3; // 7*3=21, 21/3=7 — CORRECT
}
}Common Mistakes Section
Always multiply before dividing to avoid precision loss. a / b * c loses precision; a * c / b is much more accurate. This is the basis of Solidity's fixed-point math conventions (WAD = 1e18, RAY = 1e27).
Counterintuitively, using uint8 or uint16 in state variables does NOT save gas over uint256 for isolated storage slots. The EVM operates in 256-bit words. Small types only save gas when packed together in a struct. Outside a struct, use uint256.
Never use block.timestamp, block.number, or blockhash() as sources of randomness. All of these are visible before block inclusion and some are directly controlled by validators. Use Chainlink VRF for secure randomness.
Summary / Key Takeaways
| Type / Concept | Key Fact | Security Relevance |
|---|---|---|
| uint overflow (pre-0.8) | Silently wrapped around | Balance → max uint (theft) |
| Solidity 0.8.x | Checked arithmetic by default | Overflow reverts (safe) |
| unchecked {} | Skips overflow checks | Only use when mathematically provable safe |
| Integer division | Always rounds down (floors) | Fee rounding = zero fee exploit |
| Storage location | storage / memory / calldata | Wrong location = data not persisted |
| block.timestamp | Validator-adjustable by ~15s | Never use for randomness |
| constant / immutable | Inlined in bytecode | Free to read, saves 2,100 gas |
| Type downcasting | Silently truncates | SafeCast needed for safety |