Smart Contracts Deep Dive: Storage Packing, Payment Flows, and msg Object Explained

In my last article, "Inside a Smart Contract: Storage, Execution, and Blockchain Behavior", we explored how smart contracts store data, execute logic, and interact with the blockchain. Today, we are diving deeper into three fundamental aspects: How Solidity optimizes storage with Storage Packing How smart contracts handle Receiving and Sending Payments Understanding the msg object (msg.sender, msg.value, etc.) We'll also build a complete smart contract example that ties all these concepts together. Solidity Storage Packing: Saving Gas by Using Less Space In the Ethereum Virtual Machine (EVM), storage is divided into slots of 32 bytes (256 bits). Storage Packing happens when Solidity fits multiple small variables into a single storage slot to save space and gas. Example: // Good packing: fits into 1 slot uint128 a; // 16 bytes uint128 b; // 16 bytes // Bad packing: takes 2 slots uint128 a; uint256 b; Key Rules: Variables are packed in order of declaration. Types must fit without overflowing the 32-byte limit. Why it matters: Storage on Ethereum is very expensive. Fewer slots = lower gas fees = cheaper transactions. Real Storage Layout Example Suppose we store: a = 0xAAAA (uint128) b = 0xBBBB (uint128) Good Packing Storage (Single Slot): Slot 0: 0x000...BBBB...AAAA Higher 16 bytes: BBBB Lower 16 bytes: AAAA Bad Packing Storage (Two Slots): Slot 0: 0x000...0000...AAAA Slot 1: 0x000...0000...BBBB Notice how the badly packed version wastes a whole extra slot! Payment Flows: Receiving and Sending ETH/Tokens Smart contracts can receive and send ETH and tokens. Handling these flows properly is critical. Receiving ETH Use the receive() or fallback() function, and they must be marked payable: receive() external payable { // Handle ETH received } fallback() external payable { // Handle unexpected calls } Sending ETH Using transfer function basicTransferETH(address payable recipient, uint256 amount) external { require(address(this).balance >= amount, "Insufficient balance"); recipient.transfer(amount); } For developers with an OOP background, recipient.transfer(amount) might look like a method call on the recipient object. However, in Solidity, it actually means "send ETH from this contract to the recipient" — it is still this contract that is initiating the transfer using its own balance. Transfers ETH and reverts on failure. Sends exactly 2300 gas, enough only to emit an event. The transfer automatically reverts if the recipient's fallback function consumes more than 2300 gas. Therefore, while transfer looks simple, it can be surprisingly fragile in real-world scenarios. Using send function basicSendETH(address payable recipient, uint256 amount) external { require(address(this).balance >= amount, "Insufficient balance"); bool success = recipient.send(amount); require(success, "Failed to send Ether"); } send() returns a boolean indicating success or failure; it does not automatically revert like transfer(). It also only forwards 2300 gas. You must manually handle failure by checking the return value. Using call safely function safeTransferETH(address payable recipient, uint256 amount) external payable { require(address(this).balance >= amount, "Insufficient balance"); (bool sent, ) = recipient.call{value: amount}(""); require(sent, "Failed to send Ether"); } This way: You ensure there is enough balance. You check the result of call to confirm success. You avoid gas limitation issues that might arise from transfer or send. Sending ERC20 Tokens In modern Solidity (0.8.x), transfer returns a boolean. Always capture and check the return value to ensure the transfer succeeded, like: bool success = IERC20(tokenAddress).transfer(to, amount); require(success, "Token transfer failed"); This avoids mistakenly believing a transfer occurred when it failed. The msg Object: Your Contract's Context The msg object contains important metadata about the current transaction: msg.sender — who initiated the call (an EOA or another contract) msg.value — how much ETH (in wei) was sent with the transaction msg.data — the full calldata msg.sig — the 4-byte function selector function pay() public payable { require(msg.value > 0, "No ETH sent"); address payer = msg.sender; } Important: If Contract A calls Contract B, then inside B, msg.sender is A, not the original user. Be cautious using tx.origin; it can expose your contract to phishing attacks. Full Smart Contract Example Here is a full Solidity contract that showcases all these concepts: // SPDX-License-Identifier: MIT pragma solidity ^0.8.20; interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); } contract DeepDiveContract { // ❌ Bad storage packing uint128 bigN

Apr 27, 2025 - 16:27
 0
Smart Contracts Deep Dive: Storage Packing, Payment Flows, and msg Object Explained

In my last article, "Inside a Smart Contract: Storage, Execution, and Blockchain Behavior", we explored how smart contracts store data, execute logic, and interact with the blockchain.

Today, we are diving deeper into three fundamental aspects:

  • How Solidity optimizes storage with Storage Packing
  • How smart contracts handle Receiving and Sending Payments
  • Understanding the msg object (msg.sender, msg.value, etc.)

We'll also build a complete smart contract example that ties all these concepts together.

Solidity Storage Packing: Saving Gas by Using Less Space

In the Ethereum Virtual Machine (EVM), storage is divided into slots of 32 bytes (256 bits).

Storage Packing happens when Solidity fits multiple small variables into a single storage slot to save space and gas.

Example:

// Good packing: fits into 1 slot
uint128 a; // 16 bytes
uint128 b; // 16 bytes

// Bad packing: takes 2 slots
uint128 a;
uint256 b;

Key Rules:

  • Variables are packed in order of declaration.
  • Types must fit without overflowing the 32-byte limit.

Why it matters:

  • Storage on Ethereum is very expensive.
  • Fewer slots = lower gas fees = cheaper transactions.

Real Storage Layout Example

Suppose we store:

a = 0xAAAA (uint128)
b = 0xBBBB (uint128)

Good Packing Storage (Single Slot):

Slot 0: 0x000...BBBB...AAAA
Higher 16 bytes: BBBB
Lower 16 bytes: AAAA

Bad Packing Storage (Two Slots):

Slot 0: 0x000...0000...AAAA
Slot 1: 0x000...0000...BBBB

Notice how the badly packed version wastes a whole extra slot!

Payment Flows: Receiving and Sending ETH/Tokens

Smart contracts can receive and send ETH and tokens. Handling these flows properly is critical.

Receiving ETH

Use the receive() or fallback() function, and they must be marked payable:

receive() external payable {
    // Handle ETH received
}

fallback() external payable {
    // Handle unexpected calls
}

Sending ETH

Using transfer

function basicTransferETH(address payable recipient, uint256 amount) external {
    require(address(this).balance >= amount, "Insufficient balance");
    recipient.transfer(amount);
}

For developers with an OOP background, recipient.transfer(amount) might look like a method call on the recipient object. However, in Solidity, it actually means "send ETH from this contract to the recipient" — it is still this contract that is initiating the transfer using its own balance.

Transfers ETH and reverts on failure. Sends exactly 2300 gas, enough only to emit an event. The transfer automatically reverts if the recipient's fallback function consumes more than 2300 gas.

Therefore, while transfer looks simple, it can be surprisingly fragile in real-world scenarios.

Using send

function basicSendETH(address payable recipient, uint256 amount) external {
    require(address(this).balance >= amount, "Insufficient balance");
    bool success = recipient.send(amount);
    require(success, "Failed to send Ether");
}

send() returns a boolean indicating success or failure; it does not automatically revert like transfer().
It also only forwards 2300 gas.
You must manually handle failure by checking the return value.

Using call safely

function safeTransferETH(address payable recipient, uint256 amount) external payable {
    require(address(this).balance >= amount, "Insufficient balance");
    (bool sent, ) = recipient.call{value: amount}("");
    require(sent, "Failed to send Ether");
}

This way:

  • You ensure there is enough balance.
  • You check the result of call to confirm success.
  • You avoid gas limitation issues that might arise from transfer or send.

Sending ERC20 Tokens

In modern Solidity (0.8.x), transfer returns a boolean. Always capture and check the return value to ensure the transfer succeeded, like:

bool success = IERC20(tokenAddress).transfer(to, amount);
require(success, "Token transfer failed");

This avoids mistakenly believing a transfer occurred when it failed.

The msg Object: Your Contract's Context

The msg object contains important metadata about the current transaction:

  • msg.sender — who initiated the call (an EOA or another contract)
  • msg.value — how much ETH (in wei) was sent with the transaction
  • msg.data — the full calldata
  • msg.sig — the 4-byte function selector
function pay() public payable {
    require(msg.value > 0, "No ETH sent");
    address payer = msg.sender;
}

Important:

  • If Contract A calls Contract B, then inside B, msg.sender is A, not the original user.
  • Be cautious using tx.origin; it can expose your contract to phishing attacks.

Full Smart Contract Example

Here is a full Solidity contract that showcases all these concepts:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
}

contract DeepDiveContract {
    // ❌ Bad storage packing
    uint128 bigNumber;
    uint256 veryBigNumber;

    // ✅ Good storage packing
    uint128 smallA;
    uint128 smallB;

    // Events
    event Received(address sender, uint256 amount, bytes data, bytes4 sig);
    event PaymentSent(address recipient, uint256 amount);
    event TokenSent(address recipient, uint256 amount, address tokenAddress);

    // Receiving ETH
    receive() external payable {
        emit Received(msg.sender, msg.value, msg.data, msg.sig);
    }

    fallback() external payable {
        emit Received(msg.sender, msg.value, msg.data, msg.sig);
    }

    // Sending ETH
    function sendEther(address payable _to) external payable {
        require(msg.value > 0, "Send some ETH to forward");
        (bool sent, ) = _to.call{value: msg.value}("");
        require(sent, "Failed to send Ether");
        emit PaymentSent(_to, msg.value);
    }

    // Sending ERC20 Tokens
    function sendToken(address tokenAddress, address to, uint256 amount) external {
        bool success = IERC20(tokenAddress).transfer(to, amount);
        require(success, "Token transfer failed");
        emit TokenSent(to, amount, tokenAddress);
    }

    // Write to bad storage
    function writeBadStorage(uint128 _bigNumber, uint256 _veryBigNumber) external {
        bigNumber = _bigNumber;
        veryBigNumber = _veryBigNumber;
    }

    // Write to good storage
    function writeGoodStorage(uint128 _smallA, uint128 _smallB) external {
        smallA = _smallA;
        smallB = _smallB;
    }

    // View bad storage
    function viewBadStorage() external view returns (uint128, uint256) {
        return (bigNumber, veryBigNumber);
    }

    // View good storage
    function viewGoodStorage() external view returns (uint128, uint128) {
        return (smallA, smallB);
    }
}

Conclusion

Storage optimization saves gas.
Correctly managing ETH and token flows prevents losing funds.
Understanding msg.sender and related metadata is critical for secure smart contract programming.

Mastering these fundamentals prepares you for designing secure, efficient, and upgradeable smart contracts.

Stay tuned for the next article, where we'll explore upgradable contracts, proxy patterns, and common attack vectors!