Fuel for the Ritual: Gas Mechanics and Misfires in Web3

15 min read

March 30, 2025

Fuel for the Ritual: Gas Mechanics and Misfires in Web3

Table of contents

Let’s Talk Gas (No, Not That Kind)

If you’ve ever tried deploying a smart contract and thought, “Why is this thing costing me more than my last flight?” — welcome to the wonderful world of gas in Web3.

Gas isn’t just something that clogs your nose after too many beans. In Ethereum and other EVM-based blockchains, gas is the fuel that powers every interaction—from simple ETH transfers to complex DeFi spells. And just like in the real world, if you’re not careful, you can burn through a lot of it fast.

But here’s the twist: gas doesn’t just affect your wallet—it can break your contracts, open up attack vectors, and cause silent logic failures that are hard to detect and even harder to debug.

In this post, I'm going to explore what gas really is, how it can be abused, and how to make your contracts leaner, safer, and way less embarrassing on-chain. Bring your grimoire, and let’s get started.

What is Gas in Web3?

If you’ve ever interacted with Ethereum or any other EVM (Ethereum Virtual Machine)-based blockchain, you’ve probably come across the term "gas."

And let’s be honest, the first time you saw it, you probably thought:

What exactly is gas, and why do I have to pay for it?

Well, simply put, gas is the fuel that powers the blockchain. Every time you execute a transaction—whether it's sending ETH, minting an NFT, or interacting with a smart contract—you’re asking the network to perform computations on your behalf. And, of course, that work isn’t free.

Imagine the blockchain as a highway and transactions as cars. To drive on this highway, you need fuel (gas). If you want to get to your destination faster, you can pay more gas to move ahead. But if there’s a traffic jam (network congestion), gas prices spike because everyone is trying to move at the same time.

So in summary:

  • Gas = The computational energy required to execute blockchain transactions.
  • It’s paid in ETH (or the blockchain’s native token).
  • The more complex the transaction, the more gas it consumes.

How is Gas Calculated in Ethereum?

Gas is measured in gas units, and every Solidity operation has a specific gas cost. The basic formula is:

Total Gas Cost = Gas Units Used × Gas Price (in gwei)
  • Sending ETH from one address to another costs 21,000 gas.
  • Executing a function in a smart contract could cost 50,000 gas or more.
  • If the gas price is 30 gwei, sending ETH would cost:
21,000 gas × 30 gwei = 630,000 gwei = 0.00063 ETH

When signing a transaction, you can adjust the gas price:

  • If you pay more gas, your transaction gets confirmed faster.
  • If you pay less gas, it may take longer or even get stuck.

Understanding the Contract

Before diving into vulnerabilities or test cases, it’s important to understand how this contract works. Think of it as a digital grimoire—each function is a spell, each variable a channel of stored energy, and every interaction triggers a small piece of executable magic.

This setup includes two contracts: GrimoireOfEchoes, which acts as the main contract, and OracleOfWhispers, an external component used to record specific actions. Let’s walk through each part of the system.

Smart Contract Code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "forge-std/console.sol";

contract GrimoireOfEchoes {
    mapping(address => uint256) public manaReservoir;
    uint256 public corruptionIndex;
    uint256 public forbiddenTithe;
    address public oracle;
    event SpellBackfired(address target, bytes incantation);

    constructor(address _oracle) {
        oracle = _oracle;
        forbiddenTithe = 100;
    }

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

    function releaseEssence() public {
        uint256 essence = manaReservoir[msg.sender];
        require(essence > 0, "No essence bound");

        (bool success, ) = payable(msg.sender).call{gas: 2300, value: essence}("");
        require(success, "Ritual disrupted");

        manaReservoir[msg.sender] = 0;
    }

    function amplifySpirits(address[] calldata spirits) public {
        for (uint256 i = 0; i < spirits.length; i++) {
            manaReservoir[spirits[i]] = manaReservoir[spirits[i]] * 2;
        }
    }

    function invokeOracle() public {
        require(msg.sender != tx.origin, "Only summoned entities may invoke");

        corruptionIndex += 1;
        (bool success, ) = oracle.call(
            abi.encodeWithSignature("recordInvocation()")
        );

        if (success) {
            corruptionIndex -= 1;
        }
    }
}

contract OracleOfWhispers {
    mapping(address => bool) public invoked;
    event InvocationRecorded(address caller);

    function recordInvocation() external {
        require(!invoked[msg.sender], "Already invoked");

        invoked[msg.sender] = true;
        emit InvocationRecorded(msg.sender);
    }
}

State Variables

  • mapping(address => uint256) public manaReservoir;
    This mapping tracks the "mana" (ETH) each user has deposited into the contract.
  • uint256 public forbiddenTithe = 100;
    A fixed fee stored in state, although it’s not currently used. Consider it a placeholder for a future arcane tax.
  • uint256 public corruptionIndex;
    A counter that tracks how many times someone has attempted to invoke the oracle. Think of it as a log of attempted invocations.
  • address public oracle;
    This holds the address of the external OracleOfWhispers contract, which is called during specific actions.
  • event SpellBackfired(address target, bytes incantation);
    An event meant to signal failed external calls, although it’s not emitted in the current implementation.

Constructor

constructor(address _oracle) {
    oracle = _oracle;
}

When the contract is deployed, it requires the address of the oracle contract. This establishes a fixed link between the two, allowing the main contract to call the oracle later on.

channelMana

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

This function allows users to send ETH to the contract, increasing their mana balance. It’s a simple deposit mechanism, storing value under their address.

releaseEssence()

function releaseEssence() public {
    uint256 essence = manaReservoir[msg.sender];
    require(essence > 0, "No essence bound");

    (bool success, ) = payable(msg.sender).call{gas: 2300, value: essence}("");
    require(success, "Ritual disrupted");

    manaReservoir[msg.sender] = 0;
}

This function lets users withdraw the ETH they’ve deposited. It attempts to send the exact amount back to the caller using a low-gas call, and if it succeeds, their balance is reset to zero. If the call fails, the entire transaction is reverted.

amplifySpirits

function amplifySpirits(address[] calldata spirits) public {
    for (uint256 i = 0; i < spirits.length; i++) {
        manaReservoir[spirits[i]] = manaReservoir[spirits[i]] * 2;
    }
}

This function doubles the mana of each address passed into it. It’s a kind of collective buff—a spell that amplifies the stored ETH of multiple users at once.

invokeOracle()

function invokeOracle() public {
    require(msg.sender != tx.origin, "Only summoned entities may invoke");

    corruptionIndex += 1;
    (bool success, ) = oracle.call(
        abi.encodeWithSignature("recordInvocation()")
    );

    if (success) {
        corruptionIndex -= 1;
    }
}

This function can only be called by other contracts (not externally owned accounts). When invoked, it increases the corruption counter and calls recordInvocation() on the external oracle. If the call is successful, it rolls back the counter. If not, the corruption remains—a trace of a failed ritual.

OracleOfWhispers – The Invocation Registry

This contract records whether an address has performed a certain action. It acts like a magical ledger that tracks who has summoned it.

State

  • mapping(address => bool) public invoked;
    This mapping marks whether an address has successfully recorded an invocation.

recordInvocation

function recordInvocation() external {
    require(!invoked[msg.sender], "Already invoked");

    invoked[msg.sender] = true;
    emit InvocationRecorded(msg.sender);
}

Only callable once per address, this function records that the caller has performed an invocation and emits an event. It’s a lightweight registry of who has interacted with the oracle.

Understanding Each Vulnerability Through Test Cases

Now that we understand how the GrimoireOfEchoes and the OracleOfWhispers work, it's time to examine the flaws in their logic. Each of the following vulnerabilities represents a common class of issues in smart contracts, particularly those related to gas usage. Alongside each explanation, we reference a test case that demonstrates how these flaws behave in practice.

Test Case
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import "forge-std/Test.sol";
import "../src/VulnerableContract.sol";

contract GrimoireTest is Test {
    GrimoireOfEchoes grimoire;
    OracleOfWhispers oracle;
    ArcaneInvoker attacker;

    address user = address(0x123);
    address[] spirits = new address[](100);
    address payable essenceBearer = payable(address(0x456));

    function setUp() public {
        oracle = new OracleOfWhispers();
        grimoire = new GrimoireOfEchoes(address(oracle));
        attacker = new ArcaneInvoker();
    }

    function testEssenceReleaseFailsForContractReceiver() public {
        vm.deal(address(attacker), 10 ether);
        vm.deal(address(grimoire), 10 ether);

        attacker.channelManaToGrimoire{value: 1 ether}(payable(address(grimoire)));

        vm.expectRevert();
        attacker.releaseEssenceFromGrimoire(address(grimoire));
    }

    function testAmplifyFailsDueToBlockGasLimit() public {
        vm.deal(address(grimoire), 10 ether);
        for (uint256 i = 0; i < spirits.length; i++) {
            spirits[i] = address(uint160(i + 1));
        }

        vm.expectRevert();
        grimoire.amplifySpirits(spirits);
    }

    function testInsufficientGasInvocation() public {
        vm.deal(address(grimoire), 10 ether);

        attacker.performGriefingInvocation(address(grimoire));

        uint256 index = grimoire.corruptionIndex();
        console.log("Corruption index after failed invocation:", index);
        assertEq(index, 1);
    }
}
contract ArcaneInvoker {
    receive() external payable {
        uint256 entropy;
        for (uint256 i = 0; i < 10000; i++) {
            entropy += i;
        }
        console.log("Remaining mana (gas): %s", gasleft());
    }

    function channelManaToGrimoire(address payable grimoireAddress) public payable {
        GrimoireOfEchoes(grimoireAddress).channelMana{value: msg.value}();
    }

    function releaseEssenceFromGrimoire(address grimoireAddress) public {
        GrimoireOfEchoes(grimoireAddress).releaseEssence();
    }

    function performGriefingInvocation(address grimoireAddress) public {
        bytes memory incantation = abi.encodeWithSignature("invokeOracle()");
        grimoireAddress.call{gas: 50000}(incantation);
    }
}

Low-Gas Transfer to Contracts

Vulnerability: Using a fixed gas stipend (2300 gas) to send ETH prevents contract receivers from executing code in their fallback or receive functions, potentially causing the transfer to fail.

In releaseEssence(), the Grimoire attempts to send ETH back to the caller using:

(bool success, ) = payable(msg.sender).call{gas: 2300, value: essence}("");

This pattern was historically recommended to prevent reentrancy attacks, as 2300 gas is only enough to emit an event or write to storage. However, if the recipient is a contract with any logic in its receive() function—even a small loop—it will require more than 2300 gas, and the call will fail.

In testEssenceReleaseFailsForContractReceiver(), the attacker deposits ETH into the Grimoire, then tries to withdraw it. However, the attacker's contract includes a receive() function with a gas-consuming loop, which causes the withdrawal to fail due to the insufficient gas provided by the fixed 2300 stipend.

function testEssenceReleaseFailsForContractReceiver() public {
        vm.deal(address(attacker), 10 ether);
        vm.deal(address(grimoire), 10 ether);

        attacker.channelManaToGrimoire{value: 1 ether}(payable(address(grimoire)));

        vm.expectRevert();
        attacker.releaseEssenceFromGrimoire(address(grimoire));
    }
receive() external payable {
        uint256 entropy;
        for (uint256 i = 0; i < 10000; i++) {
            entropy += i;
        }
        console.log("Remaining mana (gas): %s", gasleft());
    }
Revert - out of gas

As shown in the trace output in the image above, the test confirms the revert due to an OutOfGas error in the receive() function, and the revert message matches the expected failure path in releaseEssence().

Denial of Service via Block Gas Limit

Vulnerability: Functions that perform unbounded loops over user-controlled data risk exceeding the block gas limit, rendering them unusable and opening the door to denial-of-service attacks.

The function amplifySpirits() doubles the mana of each address in the input array:

for (uint256 i = 0; i < spirits.length; i++) {
    manaReservoir[spirits[i]] *= 2;
}

There is no input validation or limit on the array length. If the array is too large, the gas cost of the loop will exceed the block gas limit, and the transaction will revert.

In testAmplifyFailsDueToBlockGasLimit(), we attempt to simulate this by generating a large array of addresses and passing it to amplifySpirits(). However, in practice, the failure occurs before the function is even called—specifically during this line:

spirits[i] = address(uint160(i + 1));
function testAmplifyFailsDueToBlockGasLimit() public {
        vm.deal(address(grimoire), 10 ether);
        for (uint256 i = 0; i < spirits.length; i++) {
            spirits[i] = address(uint160(i + 1));
        }

        vm.expectRevert();
        grimoire.amplifySpirits(spirits);
    }
Out of gas because spirit's map

The act of populating the array with 100,000 addresses consumes so much gas that the test reverts before reaching the contract call. Determining the exact value of i that would push it over the edge is non-trivial, as it depends on block limits, memory usage, and environment configuration.

Still, the point stands: functions that rely on unbounded loops over user input are fragile and can easily break under realistic conditions. Whether the gas runs out during input preparation or contract execution, the root issue is the same—a lack of bounds or batching in logic that scales with user data.

As seen in the gas report image below, the testAmplifyFailsDueToBlockGasLimit() appears to pass, but this is only because the test was modified to do so. Specifically, the vm.expectRevert() was removed, and the number of addresses in the input array was reduced to 100. This allows the test to complete successfully, making it possible to extract a meaningful gas estimate using forge test --gas-report.

Gas report

While this version no longer triggers an actual OutOfGas error, it still serves an important purpose: it shows how expensive the function is even with relatively few entries. The gas report reveals that amplifySpirits() consumes over 326,000 gas in this small-scale scenario—making it clear how quickly the function becomes unsustainable as the dataset grows.

Insufficient Gas Griefing

Vulnerability: Failing to check the result of a low-level call allows an attacker to provide insufficient gas to an external call, causing it to fail silently while the contract continues execution under the false assumption that everything succeeded.

The invokeOracle() function includes the following logic:

(bool success, ) = oracle.call(
    abi.encodeWithSignature("recordInvocation()")
);

if (success) {
    corruptionIndex -= 1;
}

If the call to the oracle fails (for example, due to insufficient gas), success will be false. However, the function does not revert or perform any other validation—it simply skips the rollback of the corruptionIndex, which was incremented earlier in the function.

In testInsufficientGasInvocation(), the attacker calls invokeOracle() with only 50,000 gas—enough to reach the external call, but not enough for recordInvocation() to complete. The oracle’s function includes a state write, which fails due to low gas. The call fails silently, but the corruptionIndex remains incremented.

function testInsufficientGasInvocation() public {
        vm.deal(address(grimoire), 10 ether);

        attacker.performGriefingInvocation(address(grimoire));

        uint256 index = grimoire.corruptionIndex();
        console.log("Corruption index after failed invocation:", index);
        assertEq(index, 1);
    }
function performGriefingInvocation(address grimoireAddress) public {
        bytes memory incantation = abi.encodeWithSignature("invokeOracle()");
        grimoireAddress.call{gas: 50000}(incantation);
    }
Test trace

In this test, corruptionIndex is used as a simple counter to demonstrate the issue. But in real-world systems, this could easily be something more critical: a nonce for a replay-protected signature, a counter in a DAO voting module, or a usage flag in a multisig wallet. Any of these could be permanently desynchronized by the same technique.

This is my favorite gas-related vulnerability in Web3 because it exposes how gas is not just a cost—it's a constraint. If developers don’t treat gas failures as first-class failures, attackers will use that gap to corrupt contract logic.

Practical Tips for Writing Gas-Efficient Smart Contracts

As pentesters, our job goes beyond finding vulnerabilities—we’re also expected to provide actionable insights that help teams harden and optimize their contracts. Gas usage is one of the most overlooked areas in early-stage smart contract development, and inefficient code can quickly become a bottleneck in production.

During an audit, it's worth paying attention to patterns or decisions that increase gas costs unnecessarily. Recommending improvements in gas efficiency not only helps your client reduce user costs—it can also prevent logic failures under heavy load, and improve the scalability and long-term maintainability of the protocol.

Below is a collection of things you should look for when reviewing contracts, along with best practices that you can suggest to clients during or after the audit. Many of them apply directly to the GrimoireOfEchoes example we've seen.

Minimize Storage Writes

Storage operations are among the most expensive things you can do on-chain. Every time you update a mapping, assign a new value to a uint, or change the state of a contract variable, you’re writing to persistent storage—which costs significantly more gas than reading from it.
Avoid unnecessary writes. For instance, don’t overwrite a value if it hasn’t changed, and don’t reset a variable unless you absolutely need to.

Instead of blindly writing:

manaReservoir[msg.sender] = 0;

Check if it’s already zero, or skip the write under certain conditions. These savings accumulate fast in high-traffic contracts.

Use constant and immutable Whenever Possible

If a variable doesn’t change after deployment, mark it as constant or immutable. This tells the compiler that the value is fixed, allowing it to pack and optimize the bytecode accordingly.

  • constant is for values known at compile time (e.g., a fee rate).
  • immutable is for values set once in the constructor (e.g., an oracle address).


Reading from constant or immutable variables is cheaper than accessing standard storage variables.

uint256 public constant forbiddenTithe = 100;

Much better than a normal state variable when the value never changes.

Avoid Unbounded Loops

We covered this earlier in the context of vulnerabilities, but it’s worth repeating as a general best practice. Unbounded for loops over user-controlled arrays are dangerous—not just for DoS potential, but also because they consume a lot of gas quickly.

What to do instead:

  • Enforce a maximum input length.
  • Process data in batches or over multiple transactions.
  • Use events for logging rather than updating storage inside the loop, when possible.

Use view and pure Modifiers Appropriately

Functions that don’t modify state should be explicitly marked with view (reads state) or pure (reads neither). This isn’t just about semantics—it affects how the function is used.

Calling a view or pure function from off-chain (e.g., a frontend or script) doesn’t cost gas. But if the function isn’t marked, it might appear to require a transaction when it doesn’t.

function checkMana(address user) external view returns (uint256) {
    return manaReservoir[user];
}

Pack Storage Variables When Possible

Solidity stores data in 32-byte slots. If you declare multiple variables of smaller types (e.g., uint8, bool, address) together, they can often fit in a single slot, reducing gas costs.
Proper packing reduces the number of storage slots used, which means cheaper writes and cheaper deployments.

Favor uint256 for Math Over Smaller Types

This one’s a bit counterintuitive: while uint8 or uint16 may seem smaller and more efficient, using uint256 in most cases is better—especially in the EVM, which is natively 256-bit. Smaller types often require extra conversion logic (type casting) or padding, which increases gas costs in many operations.

Don’t Overuse Events for Internal State Tracking

Events are great for off-chain indexing and logging, but they don’t modify on-chain state. If you’re using events as a way to track something critical (e.g., who has invoked a spell), that logic should live on-chain, not only in emitted logs.

Relying solely on events for logic validation can lead to inconsistencies, especially if a function fails after the event is emitted.

Use events for transparency and analytics—not as a source of truth.

Consider Using unchecked for Safe Math in Trusted Contexts

Since Solidity 0.8.0, arithmetic operations include overflow checks by default. While this improves safety, it adds gas overhead. In trusted internal logic (e.g., for loop counters), you can use unchecked blocks to save gas safely.

for (uint256 i = 0; i < n; ) {
    // do stuff

    unchecked {
        i++;
    }
}

It avoids unnecessary checks in contexts where overflow is not a concern (e.g., incrementing a loop index up to a known bound).

Final Refactored Contract

Below you can see how the previously vulnerable contract looks once optimized to reduce gas consumption and eliminate the issues discussed earlier.

Refractored Contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.17;

contract GrimoireOfEchoes {
    mapping(address => uint256) public manaReservoir;
    uint256 public corruptionIndex;
    address public immutable oracle;
    uint256 public constant FORBIDDEN_TITHE = 100;

    error NoEssenceBound();
    error RitualDisrupted();
    error OnlyContractsMayInvoke();

    event SpellBackfired(address target, bytes incantation);

    constructor(address _oracle) {
        oracle = _oracle;
    }

    function channelMana() external payable {
        manaReservoir[msg.sender] += msg.value;
    }

    function withdrawEssence() external {
        uint256 essence = manaReservoir[msg.sender];
        if (essence == 0) revert NoEssenceBound();

        manaReservoir[msg.sender] = 0;

        (bool success, ) = payable(msg.sender).call{value: essence}("");
        if (!success) revert RitualDisrupted();
    }

    function amplifySpirits(address[] calldata spirits) external {
        uint256 len = spirits.length;
        for (uint256 i = 0; i < len;) {
            manaReservoir[spirits[i]] = manaReservoir[spirits[i]] * 2;
            unchecked { i++; }
        }
    }

    function invokeOracle() external {
        if (msg.sender == tx.origin) revert OnlyContractsMayInvoke();

        (bool success, ) = oracle.call(
            abi.encodeWithSignature("recordInvocation()")
        );

        if (success) {
            unchecked { corruptionIndex += 1; }
        } else {
            emit SpellBackfired(oracle, abi.encodeWithSignature("recordInvocation()"));
        }
    }
}

contract OracleOfWhispers {
    mapping(address => bool) public invoked;
    event InvocationRecorded(address caller);

    error AlreadyInvoked();

    function recordInvocation() external {
        if (invoked[msg.sender]) revert AlreadyInvoked();

        invoked[msg.sender] = true;
        emit InvocationRecorded(msg.sender);
    }
}
  • Switched to pull-based ETH withdrawal (withdrawEssence)
    The original function used a 2300 gas call, which is risky for contract receivers. We now use a pull pattern: users call withdrawEssence() themselves, and gas forwarding is not artificially restricted.
  • Replaced state variables with constant and immutableFORBIDDEN_TITHE is now constant, reducing storage costs.oracle is now immutable, since it’s only set once in the constructor.
  • Introduced custom errors for cheaper and cleaner reverts
    Instead of using string-based require(), we defined custom errors. This lowers gas usage and improves clarity when reading the code.
  • Deferred state change after external call (invokeOracle)
    We moved the increment of corruptionIndex to only occur after verifying that the call to the oracle succeeded—preventing silent state corruption.
  • Loop optimizations in amplifySpirits()Cached the array length in a local variable to save gas.Used unchecked for the loop index increment, since overflow is not possible within a bounded loop.
  • Function naming made more expressive (withdrawEssence)
    The previous name releaseEssence implied a push-based model. withdrawEssence better reflects the new pull-based pattern.
  • Failure logging (SpellBackfired event)
    If the oracle call fails, we emit an event to make it observable off-chain, which aids in debugging and monitoring.

Conclusions

Gas is more than just a fee—it’s a constraint that directly impacts how contracts behave, scale, and fail.

Throughout this article, we’ve looked at how poor gas management can lead to silent logic corruption, blocked withdrawals, and denial-of-service risks. But we’ve also seen how thoughtful design choices—like switching to pull-based transfers, bounding loops, and using efficient patterns—can prevent these issues entirely.

When auditing smart contracts, spotting vulnerabilities is only half the job. The real value comes from understanding why they happen and how to reshape the code to make it safer and more efficient.

Use gas reports to validate your assumptions, study how code behaves under pressure, and don't just look for exploits—look for opportunities to improve.

Smart contracts are, in the end, systems running under strict limits. And a well-audited system isn’t just secure—it’s built to endure.

Chapters

Botón Anterior
Strengthening Smart Contracts: Unit Testing, Fuzzing, and Invariant Testing with Foundry

Previous chapter

Slither: Your First Line of Defense in Smart Contract Security

Next chapter

Enjoyed the article?

Subscribe to the newsletter and get technical insights, cybersecurity tips, and development content straight to your inbox. Or support my work with a coffee ☕ if you found it useful!

📫 Subscribe now ☕ Buy me a coffee