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

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!