Case Study 12.1: The Constantinople Reentrancy — How a Gas Cost Change Almost Broke Contracts

Background

On January 15, 2019, the Ethereum community prepared for the Constantinople hard fork — a network upgrade bundling several EIPs that had been in development for months. The upgrade was scheduled for block 7,080,000. Clients were updated. Miners were ready. The countdown was underway.

Then, less than 24 hours before activation, everything stopped.

ChainSecurity, a smart contract auditing firm based in Zurich, published a report identifying a critical vulnerability. One of the EIPs in the Constantinople bundle — EIP-1283, which restructured SSTORE gas costs — would inadvertently enable reentrancy attacks on contracts that had previously been safe. The Ethereum core developers convened an emergency call and made the extraordinary decision to delay the entire hard fork to assess the risk.

This case study examines how a seemingly minor change to EVM gas costs nearly created a network-wide security crisis, why the vulnerability was so subtle, and what it reveals about the deep entanglement between gas economics and smart contract security.

The Technical Context: How .transfer() Provided Reentrancy Protection

To understand the vulnerability, we must first understand the security model that it would have broken.

Reentrancy: A Quick Recap

Reentrancy is a vulnerability where a contract makes an external call to an untrusted address, and the called contract calls back into the original contract before the first execution completes. The classic example is a withdrawal function:

// VULNERABLE TO REENTRANCY
function withdraw() public {
    uint256 amount = balances[msg.sender];
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
    balances[msg.sender] = 0;  // Updated AFTER the external call
}

If msg.sender is a malicious contract, it can re-enter withdraw() during the .call(), and because balances[msg.sender] has not yet been zeroed, it can withdraw again. And again. This is how The DAO was drained in 2016.

The .transfer() Gas Stipend

After The DAO hack, the Solidity community adopted a defensive pattern: use .transfer() or .send() instead of .call() for sending ETH. These functions forward exactly 2,300 gas to the recipient — a deliberately small amount called the "gas stipend."

Why 2,300? Because at the time, 2,300 gas was enough for the recipient to execute a simple receive() or fallback() function (log an event, update a simple counter) but not enough to execute an SSTORE, which cost 5,000 gas minimum. Without the ability to write to storage, a reentrant call could not meaningfully modify state. The gas stipend was, in effect, a reentrancy prevention mechanism enforced through gas economics:

// SAFE (before Constantinople) — only 2,300 gas forwarded
function withdraw() public {
    uint256 amount = balances[msg.sender];
    balances[msg.sender] = 0;
    payable(msg.sender).transfer(amount);
}

Thousands of contracts relied on this guarantee. The 2,300 gas stipend was enshrined in best practices, auditing checklists, and Solidity documentation as the standard defense against reentrancy for ETH transfers.

The EIP: EIP-1283 and Net Gas Metering

EIP-1283 proposed a restructuring of SSTORE gas costs using "net gas metering." The idea was economically sound: instead of charging separately for each SSTORE within a transaction, the EVM would track the net change to each storage slot and charge accordingly.

Under the existing (pre-Constantinople) rules: - Writing a non-zero value to a zero slot: 20,000 gas - Writing a non-zero value to a non-zero slot: 5,000 gas - These costs applied to every SSTORE, regardless of context

Under EIP-1283's net gas metering: - If a slot was being restored to its original value (e.g., set to X, then set back to X within the same transaction), the SSTORE would cost only 200 gas instead of 5,000. - The economic rationale was clear: if the net state change is zero, the actual cost to the network is minimal — no new data needs to be stored.

This was a good optimization for many use cases. Reentrancy guard patterns (setting a mutex to 1, executing logic, setting it back to 0) would become dramatically cheaper. Temporary storage patterns would be incentivized.

The Vulnerability: 200 Gas Is Less Than 2,300

Here is where the security assumption shattered.

Consider a contract with a reentrancy guard:

contract VulnerableVault {
    mapping(address => uint256) public balances;
    bool private locked;

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdraw() public {
        require(!locked, "No reentrancy");
        locked = true;

        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // Uses .transfer() — forwards only 2,300 gas
        payable(msg.sender).transfer(amount);

        balances[msg.sender] = 0;
        locked = false;
    }
}

Under pre-Constantinople rules, this contract was safe. The .transfer() forwarded only 2,300 gas. Even if the recipient tried to call back into withdraw(), any SSTORE in the re-entered call would cost at least 5,000 gas — far more than the 2,300 available. The reentrancy would fail from gas exhaustion.

Under EIP-1283's rules, the calculus changed. Consider this scenario within a single transaction:

  1. Attacker calls withdraw().
  2. locked is set from false (0) to true (1) — first SSTORE, costs 20,000 gas (new value). But this happens in the outer call, paid by the attacker's gas.
  3. .transfer() sends ETH to the attacker's contract with 2,300 gas.
  4. The attacker's receive() function calls back into withdraw().
  5. In the re-entered call, locked is read — it is true, so the require(!locked) check passes... wait, no, locked is true, so the require fails.

Actually, the vulnerability in the ChainSecurity report was more nuanced. It targeted contracts where the re-entered SSTORE was restoring a value. Consider a slightly different pattern:

contract SubtleVault {
    mapping(address => uint256) public balances;

    function withdraw() public {
        uint256 amount = balances[msg.sender];
        require(amount > 0);
        payable(msg.sender).transfer(amount);
        balances[msg.sender] = 0;
    }

    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }
}

Under EIP-1283, if an attacker had performed a deposit() earlier in the same transaction (setting their balance from 0 to some value), and then called withdraw(), the .transfer() re-enters and calls a function that modifies a storage slot that was already changed earlier in the transaction. Depending on the specific pattern, the net gas metering could make the SSTORE cheap enough (200 gas) to execute within the 2,300 gas stipend.

The critical insight: EIP-1283 created scenarios where an SSTORE that previously cost 5,000 gas would cost only 200 gas, and 200 is well under the 2,300 gas forwarded by .transfer(). The gas stipend, which had served as a de facto reentrancy wall, suddenly had a hole in it.

The Discovery

ChainSecurity's Hubert Ritzdorf and team identified the vulnerability through systematic analysis. They did not find a specific exploitable contract — they found a class of exploitable patterns. Any contract that:

  1. Sent ETH via .transfer() or .send(), AND
  2. Had storage operations whose costs could be reduced below 2,300 gas by EIP-1283's net metering, AND
  3. Had a state modification after the ETH transfer

...was potentially vulnerable.

The report was published on January 15, 2019. The Constantinople fork was scheduled for January 16. The window was astonishingly narrow.

The Response

The Ethereum core developers responded with remarkable speed:

  1. January 15, 2019: ChainSecurity publishes the vulnerability report.
  2. January 15, 2019 (hours later): Emergency all-core-devs call. Decision made to postpone Constantinople.
  3. January 15, 2019: Client teams release updated versions without the Constantinople activation.
  4. January 16, 2019: The original activation block passes without the fork activating on the updated nodes.

The postponement was not without cost. Node operators who had updated their clients for Constantinople now had to update again. Miners and exchanges had to adjust. The delay lasted approximately one month.

The Resolution: EIP-1706 and EIP-2200

The Ethereum community developed two solutions:

EIP-1706 proposed that SSTORE should always fail (revert) if less than 2,300 gas remained. This would preserve the gas stipend's reentrancy protection regardless of SSTORE's base cost. However, this approach was considered too restrictive.

EIP-2200 (adopted in the Istanbul hard fork, December 2019) combined EIP-1283's net gas metering with a safety check: if the remaining gas is 2,300 or less, SSTORE reverts with an out-of-gas error. This preserved both the gas optimization and the safety guarantee.

The revised rule: SSTORE is forbidden when gas_remaining <= 2300. This means that .transfer(), which forwards exactly 2,300 gas, can never trigger an SSTORE in the recipient — regardless of whether the SSTORE would be cheap due to net metering.

Lessons for EVM Architecture

1. Gas costs are a security mechanism, not just an economic one

The .transfer() gas stipend was never formally designed as a security feature. It was a side effect of gas pricing. But thousands of contracts came to rely on it as a security guarantee. When gas costs changed, the security guarantee evaporated.

This is a recurring pattern in the EVM: implicit security assumptions based on gas costs are fragile. Any time a contract's security depends on "operation X costs too much to fit in Y gas," a future gas repricing can break it.

2. Changing the EVM affects all existing contracts

Unlike conventional software, where you can update a program to fix a bug, deployed smart contracts are immutable. EVM changes must be backward-compatible with every contract ever deployed. A gas cost change that is beneficial for new contracts can be catastrophic for existing ones.

This "ossification pressure" is one of the EVM's most significant long-term challenges. Every proposed change must be evaluated not just for its direct effects but for its interactions with the millions of contracts already deployed.

3. The .transfer() pattern is now considered harmful

After the Constantinople incident, the Solidity community revised its guidance. The current best practice is:

  • Do NOT rely on .transfer() or .send() for reentrancy protection. Use the checks-effects-interactions pattern and/or reentrancy guards (like OpenZeppelin's ReentrancyGuard).
  • Use .call() with explicit reentrancy protection instead of relying on gas stipends.

This shift was painful for the ecosystem. Thousands of contracts deployed with .transfer() cannot be updated, and future gas changes could still affect them.

4. Last-minute security review saves millions

ChainSecurity's discovery, made barely 24 hours before activation, prevented what could have been a catastrophic series of exploits. The incident underscored the importance of:

  • Independent security review of EIPs
  • Testnets that model real-world contract patterns
  • The willingness of the community to delay upgrades when risks are identified

Discussion Questions

  1. Should EVM opcodes have "stability guarantees" — promises that their gas costs will not change? What are the trade-offs?

  2. The gas stipend (2,300 gas forwarded by .transfer()) was an informal security mechanism that became critical infrastructure. Can you identify other informal assumptions in the EVM that might be similarly fragile?

  3. If you were designing a new virtual machine for a blockchain, would you include a fixed gas stipend for ETH transfers? Why or why not?

  4. EIP-2200's solution (revert SSTORE if gas <= 2,300) hardcodes the gas stipend value into the SSTORE logic. Is this good design, or does it create a new form of coupling between opcodes?

  5. The Constantinople delay cost the ecosystem time, coordination effort, and credibility. Was the decision to delay correct? Under what circumstances would it be acceptable to proceed with a known risk?

Timeline

Date Event
August 2018 EIP-1283 proposed for net gas metering
October 2018 Constantinople upgrade scheduled, including EIP-1283
January 15, 2019 ChainSecurity publishes vulnerability report
January 15, 2019 Emergency developer call; fork postponed
February 28, 2019 Constantinople + Petersburg activate (EIP-1283 removed)
June 2019 EIP-2200 proposed (net metering with safety check)
December 8, 2019 Istanbul hard fork activates with EIP-2200

Sources and Further Reading

  • ChainSecurity, "Constantinople enables new Reentrancy Attack" (January 2019)
  • EIP-1283: Net gas metering for SSTORE without dirty maps
  • EIP-2200: Structured Definitions for Net Gas Metering
  • EIP-1706: Disable SSTORE with gasleft lower than call stipend
  • Ethereum All Core Developers Call #53 (emergency Constantinople call)
  • OpenZeppelin, "Constantinople Reentrancy Attack" post-mortem