🌱 Beginner ⏱️ 30 min

Mappings & Structs

Mappings and structs are the backbone of Solidity data modeling. Every ERC20 token balance, every NFT owner record, every DeFi position — they all live in mappings and structs. Getting them wrong produces some of the most subtle and dangerous bugs in smart contract development: default-value confusion, storage pointer misuse, and unbounded iteration DoS. This lesson covers both features thoroughly with a security lens throughout.

📖 Mappings are Not Databases

A Solidity mapping looks like a dictionary or hash map, but it works differently. There is no "list of keys", no "size", and no iteration. Instead, every possible key in the universe is implicitly initialized to the zero value of the value type. This fundamental property is the source of many subtle bugs where "not set" is indistinguishable from "set to zero".

Mapping Fundamentals

🗺️ Mapping Declaration, Access, and Update
contract MappingBasics { // Declaration: mapping(KeyType => ValueType) mapping(address => uint256) public balances; mapping(uint256 => address) public tokenOwner; mapping(bytes32 => bool) public usedHashes; // Reading: always returns a value (zero if key was never set) function getBalance(address user) external view returns (uint256) { return balances[user]; // returns 0 for unknown addresses } // Writing: just assign to the key function deposit() external payable { balances[msg.sender] += msg.value; } // Deleting: resets value to zero (NOT the same as removing a key) function clearBalance(address user) external { delete balances[user]; // sets balances[user] = 0, refunds gas } // Key types allowed: value types, bytes, string // Key types NOT allowed: arrays, mappings, structs }

How Mapping Storage Slots Are Computed

Mappings don't use contiguous storage like arrays. Instead, each value lives at a slot computed by hashing the key with the mapping's base slot. This is important for understanding storage layout and using vm.store in tests:

🔑 Mapping Storage Slot Computation
// For a mapping at storage slot N: // Value for key K lives at: keccak256(abi.encode(K, N)) // Example: balances is declared first, so it's at slot 0 mapping(address => uint256) public balances; // slot 0 // balances[0xAbCd...1234] lives at: // keccak256(abi.encode(0xAbCd...1234, uint256(0))) // In Foundry tests — directly manipulate mapping values: bytes32 slot = keccak256(abi.encode(userAddress, uint256(0))); vm.store(address(token), slot, bytes32(uint256(1000e18))); // Now token.balances[userAddress] == 1000e18 // This is why mappings have NO size/length — the slot is computed, // not stored. The EVM doesn't track which keys exist.

Nested Mappings: The Allowance Pattern

🔗 Nested Mapping — ERC20 Allowances
contract ERC20Allowance { // owner => spender => amount mapping(address => mapping(address => uint256)) public allowance; function approve(address spender, uint256 amount) external { allowance[msg.sender][spender] = amount; emit Approval(msg.sender, spender, amount); } function transferFrom(address from, address to, uint256 amount) external { require(allowance[from][msg.sender] >= amount, "not approved"); allowance[from][msg.sender] -= amount; balances[from] -= amount; balances[to] += amount; } // SECURITY: The ERC20 approve + transferFrom race condition // If owner calls approve(spender, 100) then approve(spender, 50), // the spender can front-run and transfer 100 + 50 = 150 // Mitigation: use increaseAllowance / decreaseAllowance pattern // or ERC20Permit (EIP-2612) for permit-based approvals }

Structs: Grouping Related Data

📋 Struct Declaration and Usage
contract StructDemo { struct Position { address owner; // 20 bytes uint96 collateral; // 12 bytes — fits with address in one slot! uint128 debt; // 16 bytes uint128 timestamp; // 16 bytes — fits with debt in one slot! bool isActive; // 1 byte } mapping(uint256 => Position) public positions; function openPosition(uint256 id) external payable { // Direct struct initialization (creates in storage) positions[id] = Position({ owner: msg.sender, collateral: uint96(msg.value), debt: 0, timestamp: uint128(block.timestamp), isActive: true }); } function updateDebt(uint256 id, uint128 newDebt) external { // Storage reference — modifies the struct in place (efficient) Position storage pos = positions[id]; pos.debt = newDebt; // one SSTORE instead of rewriting whole struct } }

Struct Packing: Saving Gas with Storage Layout

The EVM stores data in 32-byte (256-bit) slots. Multiple small variables can be packed into a single slot, dramatically reducing SLOAD/SSTORE costs. Variable declaration order determines packing:

Struct LayoutSlots UsedGas Cost (3 SLOADs)Verdict
uint256, uint128, uint1282 slots~4,200 gasEfficient — 128s share slot
uint256, uint256, uint1283 slots~6,300 gasInefficient — 128 gets own slot
uint128, uint256, uint1283 slots~6,300 gasInefficient — uint256 breaks packing
address (20B), uint96 (12B)1 slot~2,100 gasPerfect pack — 20+12=32 bytes
address (20B), uint2562 slots~4,200 gasuint256 can't fit with address

Security Issues with Mappings and Structs

🚨 Default Value Confusion

An uninitialized mapping key returns the zero value — 0 for uints, false for bools, address(0) for addresses. A common bug is checking if (roles[user] == ADMIN_ROLE) where ADMIN_ROLE is bytes32(0) — every address that was never assigned a role would pass this check. Always define your constants as non-zero values or explicitly track "unset" state.

💣 Storage Pointer Bug — storage vs memory
contract StoragePointerBug { struct User { uint256 balance; bool active; } mapping(address => User) public users; // BUG: 'memory' copy — changes don't persist function badActivate(address user) external { User memory u = users[user]; // COPY to memory u.active = true; // modifies memory copy only // storage unchanged! users[user].active is still false } // CORRECT: 'storage' reference — writes through to storage function goodActivate(address user) external { User storage u = users[user]; // REFERENCE to storage u.active = true; // writes to storage } // When to use memory: read-only or when you need to modify // a struct for computation but NOT persist the changes }
💡 Gas Tip: Pack Struct Fields for Storage Efficiency

When defining structs that will be stored, order fields from largest to smallest type, grouping types that add up to 32 bytes together. For example, pair address (20 bytes) with uint96 (12 bytes) — they fit in one 32-byte slot. Each additional slot consumed by a struct adds 2,100 gas (cold SLOAD) to every read. In a protocol with thousands of positions, slot packing can cut gas costs by 30-50%.

⚠️ Keeping Track of Mapping Keys

Since mappings have no built-in enumeration, developers often maintain a parallel array of keys. This pattern works but introduces an unbounded-loop DoS risk: if anyone can add to the key array, iterating it eventually hits the block gas limit. Use this pattern only when key growth is bounded, or move enumeration entirely off-chain.

Enums: State Machines Done Right

✏️ Try It — Enum State Machine
contract Escrow { enum State { Pending, Active, Released, Refunded } struct Deal { address buyer; address seller; uint256 amount; State state; } mapping(uint256 => Deal) public deals; function release(uint256 id) external { Deal storage deal = deals[id]; // Explicit state check prevents invalid transitions require(deal.state == State.Active, "not active"); require(msg.sender == deal.buyer, "not buyer"); deal.state = State.Released; payable(deal.seller).transfer(deal.amount); } // BUG example: casting arbitrary uint to enum // State s = State(99); // 99 is out of range — undefined behavior in 0.7 // In Solidity 0.8: reverts with Panic(0x21) — out of range enum cast }

Key Takeaways

  • Mappings return zero for any key never set — never treat a zero return as "entry does not exist" without explicit tracking of which keys are set.
  • Nested mappings implement the allowance pattern; the ERC20 approve race condition is a classic example of mapping-based vulnerability.
  • Struct variable ordering controls storage packing — place smaller types together to fit multiple variables into one 32-byte slot and reduce SLOAD/SSTORE counts.
  • Use storage references to update struct fields in place; a memory copy will not persist changes to the blockchain.
  • Maintaining a key array for mapping enumeration is a common DoS risk — bound the array size or move enumeration off-chain.
  • Enums should be used for explicit state machines; always validate state transitions with require to prevent jumping to invalid states.