It all started when I was going through Cyfrin's Solidity Smart Contract Development course (https://updraft.cyfrin.io/courses/solidity) and came across the `constant` keyword. I knew it was supposed to save gas, but I wanted to see exactly _how_, not just in theory, but at the bytecode level. So, I decided to dig deeper... and this article was born. I’ll break down what happens behind the scenes when you use `constant`, how it affects storage, gas costs, and why it matters for smart contract optimization. Here's our smart contract example(s): **NoConstant.sol** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract ConstantImmutable { uint256 public favoriteNumber = 5; } ``` **Constant.sol** ```solidity // SPDX-License-Identifier: MIT pragma solidity ^0.8.18; contract ConstantImmutable { uint256 public constant favoriteNumber = 5; } ``` ```NoConstant.sol``` declares a public variable **favoriteNumber** initialized to 5. When compiled, this variable is stored in the contract’s storage, and a getter function is automatically generated because it’s *public*. The modified contract ``Constant.sol`` adds the *constant* keyword. With *constant*, **favoriteNumber** becomes a compile-time constant embedded in the bytecode rather than a storage variable. This eliminates the need for storage access and changes how the getter function behaves. If we compile ```NoConstant.sol``` with ``` solc -o NoConstantDir --bin NoConstant.sol ``` we get this stream of bytes: ``` 608060405260055f553480156012575f5ffd5b5060ac80601e5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063471f7cdf14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b5f5481565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212204db89e73bbbb0573dc471b03586b22e69fc03409f4604fce921014f4d28aa1e464736f6c634300081c0033 ``` Let's do the same thing for ```Constant.sol``` with ``` solc -o ConstantDir --bin Constant.sol ```: ``` 6080604052348015600e575f5ffd5b5060ac80601a5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c8063471f7cdf14602a575b5f5ffd5b60306044565b604051603b9190605f565b60405180910390f35b600581565b5f819050919050565b6059816049565b82525050565b5f60208201905060705f8301846052565b9291505056fea26469706673582212205023b6e0afc1b00eed2d55f7dae23db61ec3d95bbc6be68efa7def3c7bcc85d464736f6c634300081c0033 ``` > The flag `-o` creates a directory with the contents inside. Right away we can see that the bytecode result (with *constant*) is smaller than the previous stream of bytes that we got from compiling ```NoConstant.sol```. ## Background: What’s Happening in the Contracts? I built a tool called **Soler** to help me understand this and we're going to use it right now. ![[Pasted image 20250305163814.png]] After inserting both bytecodes *(`NoConstant.sol` is on the left and ` Constant.sol ` is on the right)*, a CFG (Control-Flow Graph) is created for both (this is not perfect **AT ALL** but it does the job). The opcodes are also displayed and compared with each other. If they're different, they turn red as we can see in the image above. We can also go instruction by instruction clicking "Prev" or "Next". The opcode lists represent the disassembled bytecode for both contracts. They include the **creation code (constructor)** (which runs once during deployment) and the **runtime code** (which persists on-chain). The constructor’s job is to set up storage (if any) and deploy the runtime code. In most cases for Solidity-generated bytecode, the constructor starts at the first opcode and ends at the first **RETURN** opcode. For example, in ```NoConstant.sol```, the constructor ends at 0x001c with **RETURN**, and the runtime code begins at 0x001e. ``` (...) 0x0014: PUSH1 0xAC 0x0016: DUP1 0x0017: PUSH1 0x1E 0x0019: PUSH0 0x001a: CODECOPY 0x001b: PUSH0 0x001c: RETURN <------- End of the constructor 0x001d: INVALID 0x001e: PUSH1 0x80 <------- Runtime Start 0x0020: PUSH1 0x40 0x0022: MSTORE 0x0023: CALLVALUE ``` > The INVALID opcode at 0x001d is a **separator** between the constructor code and the runtime code in the compiled bytecode. It ensures that if execution somehow continues past the RETURN during deployment, it crashes immediately. This prevents the EVM from accidentally running into the runtime code as part of the constructor. ## Constructor: Storage Initialization vs. None ![[Untitled-2025-03-05-1654.png]] Now that we know what the constructors are and where they are, we can already see a couple of differences right at the start: - In **NoConstant.sol**: The constructor explicitly stores the value 5 in storage slot 0 (**PUSH1** 0x05). This makes **favoriteNumber** a persistent state variable that can be read later via the getter. - In **Constant.sol**: This section is absent. The constructor no longer includes **SSTORE** or any storage initialization for **favoriteNumber**. Since it’s a constant, the value 5 is written into the bytecode at compile time (shown in the section below) and doesn’t need to be written to storage during deployment. This reduces both bytecode size and deployment gas costs. ## Getter Function: Storage Read vs. Hardcoded Return > We're not going to use Soler here, since it would be too crowded with images. I can make a video showcasing this in the future. I said before that the *public* keyword generates a getter function. Let’s compare how it retrieves **favoriteNumber**: - **NoConstant.sol**: ``` 0x0048: JUMPDEST // Start of getter function 0x0049: PUSH1 0x30 0x004b: PUSH1 0x44 0x004d: JUMP // Jump to return logic ... 0x0062: JUMPDEST // Return value logic 0x0063: PUSH0 // Slot 0 0x0064: SLOAD // Load value from storage slot 0 0x0065: DUP2 0x0066: JUMP // Return to caller ``` Here, the getter jumps to a block that uses **SLOAD** to read **favoriteNumber** from storage slot 0. This requires an on-chain storage access (costing 2100 gas in EVM terms) every time the function is called. - **Constant.sol**: ``` 0x0044: JUMPDEST // Start of getter function 0x0045: PUSH1 0x30 0x0047: PUSH1 0x44 0x0049: JUMP // Jump to return logic ... 0x005e: JUMPDEST // Return value logic 0x005f: PUSH1 0x05 // Push the constant value 5 0x0061: DUP2 0x0062: JUMP // Return to caller ``` Here, instead of **SLOAD**, the getter simply pushes the hardcoded value 5 onto the stack with **PUSH1** 0x05 (costing 3 gas). No storage access is needed because **favoriteNumber** is embedded in the code itself. > You can get more information about an opcode at evm.codes. Check the difference between a "cold" or "warm" SLOAD access. > If you want to know how I got the getter function's location go to the end of the article. ## Why These Differences Matter - **Gas Efficiency**: - Deployment: ```NoConstant.sol``` uses more gas due to **SSTORE** (20,000 gas for the first write to a slot). - Execution: The getter in ```Constant.sol``` saves ~2097 gas per call by avoiding **SLOAD**. - **Security**: Constants can’t be modified, reducing attack surfaces (e.g., no risk of accidental overwrites in storage). - **Code Size**: Smaller bytecode in ```Constant.sol``` means lower deployment costs and a leaner contract. ## Conclusion Adding *constant* transforms **favoriteNumber** from a dynamic storage variable into a static, hardcoded value. This eliminates storage operations in both the constructor and getter, shrinking the bytecode and slashing gas costs. # ** --- ##### How did we get the getter function's location? In Solidity, when you mark a variable public (like ```uint256 public favoriteNumber```), the compiler auto-generates a getter function. This function has a unique identifier called a _function selector_ (4-byte hash derived from the function’s signature). For ```favoriteNumber()```, the signature is favoriteNumber(), and its selector is ```0x471f7cdf```, calculated as the first 4 bytes of ```keccak256("favoriteNumber()")```. The runtime bytecode includes a _dispatcher_ (a switch-like mechanism to route incoming calls to the right function based on this selector). Here’s how it works (using NoConstant.sol’s disassembled opcodes): ``` 0x002e: PUSH1 0x04 0x0030: CALLDATASIZE 0x0031: LT 0x0032: PUSH1 0x26 0x0034: JUMPI 0x0035: PUSH0 0x0036: CALLDATALOAD // Load first 32 bytes of calldata 0x0037: PUSH1 0xE0 0x0039: SHR // Shift right to get first 4 bytes (selector) 0x003a: DUP1 0x003b: PUSH4 0x471F7CDF // Getter selector 0x0040: EQ // Does it match? 0x0041: PUSH1 0x2A 0x0043: JUMPI // If yes, jump to 0x2a in the runtime code (0x001e + 0x2a = 0x0048) 0x0044: JUMPDEST // Fallback 0x0045: PUSH0 0x0046: PUSH0 0x0047: REVERT // No match, revert 0x0048: JUMPDEST // Getter starts here ``` When a call comes in, the *dispatcher* checks the first 4 bytes of the calldata against ```0x471f7cdf```. If it matches, it jumps to the getter’s starting point at 0x0048. This selector-based routing is how Solidity handles all public functions. [Optional: For more on selectors, click here](https://figtracer.com/Public/Resources/Web3/EVM/Function+Selectors) [Optional: For more on what all these opcodes mean, click here](https://www.evm.codes/)