When the Inner Contract Dies, Does the Outer One Survive?
The withdrawal went through. Ether moved. The top-level call shows a clean success on the block explorer, the emitted event fired exactly as expected, and yet the side-effect you were counting on, a token transfer buried three frames deep in the call stack, simply never happened. No revert surfaced to the user. No error in the logs. Just a transaction that looks fine and isn't.
This isn't exotic. It's one of the most consequential mechanics in the Ethereum execution layer, and it bites developers who haven't thought carefully about what "out of gas" actually means inside a nested call.
The EVM Runs Calls Inside Calls Inside Calls
Every Ethereum transaction starts with a top-level call carrying a gas limit. As execution proceeds, a contract can invoke another contract using one of several opcodes: `CALL`, `STATICCALL`, `DELEGATECALL`, or `CALLCODE`. The EVM spins up a fresh execution context for each one, a sub-call, and allocates it some portion of the remaining gas.
That allocation is not automatic. The calling contract specifies how much gas to forward. Solidity's high-level syntax handles this by default, forwarding nearly all remaining gas minus a small retention for the caller's own cleanup. The sub-call runs with its own gas counter, separate from the parent's.
Think of it like nested budget envelopes. The outer envelope holds 100,000 gas units. It tears off 60,000 and slides them to the inner envelope. The inner envelope runs its logic. If it hits zero before finishing, something very specific happens: execution halts, the EVM raises an out-of-gas exception, and every state change made inside that sub-call is reverted.
The outer envelope gets back control. With a return value of zero (failure). And still holding its remaining gas.
What happens next depends entirely on whether the outer contract checks that return value.
The Mechanic That Actually Decides Fate
When a sub-call fails due to out-of-gas (or any other exception), the EVM pops back to the parent execution frame and places a `0` on the stack where the success boolean should be. The parent's gas counter is not drained. The parent's state changes up to that point are still pending.
So the parent contract can do one of two things.
Option A: It checks the return value, sees the failure, and deliberately reverts itself. The entire transaction unwinds. Nothing persists.
Option B: It doesn't check, or it checks and decides to continue regardless. The parent's state changes commit. The sub-call's changes do not. The transaction succeeds at the top level.
Option B is where silent failure lives. And silent failure is, in my view, a worse outcome than a clean revert: it's a transaction that lies.
The worked version makes this concrete. Suppose a contract called `Vault` (address `0xAAA`) calls a contract called `Notifier` (address `0xBBB`) as part of a withdrawal. `Vault` forwards 5,000 gas to `Notifier` for what it assumes is a cheap logging operation. `Notifier` was upgraded recently and now does considerably more work. It runs out of gas at unit 4,997.
`Vault` receives a failure flag. If the Solidity developer wrote `notifier.notify{gas: 5000}(user, amount)` without checking the return value or wrapping it in a `require`, the withdrawal completes regardless. Ether moves. `Notifier` records nothing. Nobody is told.
Two developers auditing this contract will spot it immediately if they're looking at the low-level call pattern. Most reviewers scanning for logic bugs will miss it entirely.
The 63/64 Rule Adds a Wrinkle You Might Not Expect
EIP-150, introduced after the 2016 Shanghai denial-of-service attacks, changed how gas is forwarded in sub-calls. The rule: a calling contract can forward at most 63/64 of its remaining gas. That last 1/64 is always retained by the caller.
This prevents a recursive call chain from consuming the entire transaction gas in one shot, which was the attack vector EIP-150 closed. It also means that even if a Solidity developer writes `externalContract.call{gas: gasleft()}(data)`, the EVM silently caps the forwarded amount at 63/64 of `gasleft()`. The contract thinks it forwarded everything. It didn't.
Run the arithmetic on a deep call stack and the retained fractions compound like interest on a loan you forgot you took out. At five levels deep, a transaction that started with 1,000,000 gas units has retained roughly 75,000 units across the chain of callers, even if every contract tried to forward the maximum. That's not dust. That's enough to execute meaningful logic, or to ensure top-level cleanup code doesn't also run dry.
The rule is a safety valve, not a tax. Worth knowing it exists.
The Actual Revert Scope: Precise and Surgical
The common mental model says "if anything fails, everything fails." That holds for a top-level revert. It does not hold for a sub-call failure that the parent absorbs without reverting.
The EVM's state transition model is frame-based. Each `CALL` creates a snapshot of state at the moment of that call. If the sub-call fails, the EVM restores from that snapshot for that frame only. The parent frame's state, accumulated before the sub-call was made, is untouched.
Storage writes, balance changes, and nonce increments that happened inside the failed sub-call are gone. Everything the parent wrote before making that call stays intact.
This is precise and intentional. It's also why using low-level `.call()` in Solidity to send Ether to untrusted addresses, rather than `transfer()` or `send()`, is now standard practice in reentrancy-hardened code: the developer decides what a failure means, rather than having the compiler impose an automatic revert.
The tradeoff is responsibility. Lower-level control means the contract must handle failure explicitly.
So here's the question worth sitting with: if you're reviewing a contract and you see an external call followed immediately by state changes, with no success check in between, do you treat that as a deliberate design choice or an accidental omission? Not every such instance is dangerous. Some are intentional. But every single one deserves a conscious decision from the developer, written down somewhere, not a shrug.
The EVM doesn't punish ambiguity. It just executes whatever you told it to do, including the parts you forgot to think about.