Skip to main content
Gas Optimization Pitfalls

Revert Bombs & Refund Traps: Gas Griefing in the Wild

You've optimized your contract to the bone. Storage packed, loops unrolled, calls batched. Then someone sends a transaction that reverts at the last second—and your users eat the bill. That's gas griefing, and it's uglier than it sounds. Let's look at how it works, why it survives even careful optimization, and what you can actually do about it. Why Gas Griefing Matters Right Now The rise of MEV and adversarial mempools Gas griefing stopped being a lab curiosity around the time MEV bots started bidding aggressively for every revertible transaction in sight. The mempool is no longer a passive queue—it's a battlefield where searchers simulate your pending transaction, detect a revert path, and front-run you into it. A failed call that overheads you 0.01 ETH in gas now spend you that *plus* whatever the bot extracts from the reordering.

图片

You've optimized your contract to the bone. Storage packed, loops unrolled, calls batched. Then someone sends a transaction that reverts at the last second—and your users eat the bill. That's gas griefing, and it's uglier than it sounds. Let's look at how it works, why it survives even careful optimization, and what you can actually do about it.

Why Gas Griefing Matters Right Now

The rise of MEV and adversarial mempools

Gas griefing stopped being a lab curiosity around the time MEV bots started bidding aggressively for every revertible transaction in sight. The mempool is no longer a passive queue—it's a battlefield where searchers simulate your pending transaction, detect a revert path, and front-run you into it. A failed call that overheads you 0.01 ETH in gas now spend you that *plus* whatever the bot extracts from the reordering. The attacker's profit isn't the victim's loss directly; the victim just eats the wasted gas while the bot moves on. That asymmetry makes griefing cheap for the aggressor and brutally expensive for the target.

Wrong order. That's the whole game.

What changed is that reverts are now *observable* and *manipulable* in real time. Flashbots and private relays gave searchers a clean window into pending txns, and the same infrastructure that powers arbitrage also powers griefing. I have watched a simple NFT mint fail repeatedly since a bot kept pushing the gas price just high enough to ensure the victim's transaction reverted at the end of the block. The victim seldom saw it coming—their wallet just showed "out of gas" or "execution reverted" and they retried, paying again. That retry loop is the attack.

User experience fallout from failed txns

The casual user doesn't understand why their transaction failed. They see a red error, lose the gas fee, and blame the dApp. Support tickets flood in, Discord mods repeat "try increasing slippage" a hundred times a day, and the team burns hours debugging a problem that's fundamentally adversarial. The real cost surfaces later: users churn, trust erodes, and the project's reputation takes a hit that no audit report can repair. Gas griefing is not just an economic drain—it's a UX killer that makes your product feel broken even when your code is correct.

That hurts. And it compounds.

The catch is that EIP-1559 changed the economics of this in a subtle way. earlier than, a failed transaction burned the full gas price you set, and miners collected the rest. Now, the base fee is burned entirely, and the priority fee goes to the block builder. Reverts still consume the full gas limit you specified, not just the portion used until the failure point. So a griefing attack that forces a revert at the last possible opcode can burn an enormous base fee—money that disappears from circulation, not into anyone's pocket. The attacker doesn't profit directly, but they don't care. Their goal is to make your transaction fail, and the protocol's fee burn just adds insult to injury.

How EIP-1559 changed the cost of reverts

Most teams miss this: the priority fee is a bribe, and the base fee is a tax. When a txn reverts, you pay both. But the block builder who includes your failed txn still earns the priority fee, so they have zero incentive to exclude it. In busy blocks, builders fill leftover space with griefing txns that revert—they get paid for the attempt, and the victim gets nothing but a receipt. The base fee burn makes the victim's loss permanent, and the builder's profit makes the attack self-sustaining.

The odd part is that many developers still assume reverts are "free" given Solidity returns unused gas. That's true only for *internal* gas refunds in certain opcodes, not for the transaction-level gas metering. The EVM charges for every step of execution until the revert, and the entire gas limit is reserved for the duration of the call. So a griefing txn that does a few cheap operations then hits a revert can still reserve a massive gas limit, displacing legitimate transactions and inflating the effective gas price for everyone else on the network.

"A revert is not a refund. It's a bill for work rarely delivered."

— field observation from a DeFi incident post-mortem, 2024

So why does this matter *right now*? since the current DeFi landscape is full of maximally extractable value, and adversarial mempools are the norm, not the exception. Every escrow, every Dutch auction, every batch settlement contract that has a revert path is a target. The builders and searchers have already weaponized this. The question is whether your contract is the weapon or the casualty.

The Core Idea in Plain Language

What counts as a grief?

A grief is any action that forces someone else to spend more gas than they planned — without necessarily stealing their funds. Think of it like someone standing in front of a vending machine, pressing every button, then walking away. The machine works, but you waited, and now you're late. On-chain, that "waiting" expenses real Ether. The attacker doesn't get your tokens. They just make your transaction more expensive than it should be.

Most teams miss this as they focus on fund theft. Wrong instinct. The cheaper attack is often the annoying one.

Gas griefing sits in a weird middle ground: it's not a hack that empties wallets, and it's not a vulnerability that bricks the contract. It's a tax on participation. The attacker pays a little to make you pay a lot. Sometimes the math works in their favor — especially when the victim is a protocol that processes thousands of transactions per day.

Reverts don't refund all gas

Here's the part that surprises most developers: when a transaction reverts, the network refunds unused gas, but not the gas already consumed. That consumed portion is gone — paid to miners, burned, rarely coming back. So if I can force your transaction to run through expensive logic and then revert at the last step, you've just paid for my prank.

The catch is that reverts are supposed to be cheap. A simple require(false) expenses a few thousand gas. But if the revert happens after loops, storage writes, or external calls, the bill climbs fast.

That sounds fine until someone builds a contract with a refund pattern. Refunds are the worst offender — they encourage the contract to hold Ether for users, then return it on withdrawal. An attacker can deposit, trigger a withdrawal, and revert mid-refund. The victim contract still owes the attacker, and the attacker can repeat the cycle until the victim's gas budget bleeds out. Wrong order — that's the trick. The revert doesn't undo the state change, but it does drain the gas from the caller.

Gas griefing is the art of making someone else's transaction fail — after they've already paid for the privilege.

— paraphrased from a security review I read last year

The difference between wasted and stolen gas

Stolen gas means the attacker profits directly — they trick you into paying their fees, or they front-run your transaction and extract value. Wasted gas is different. Nobody gains from it except the miner who pockets the fee. The attacker loses a little, you lose a lot, and the chain just gets heavier.

That distinction matters since it changes how you defend. Against theft, you add access controls and reentrancy guards. Against wasting, you need gas limits, careful ordering, and a hard look at any function that calls external contracts without a cap.

Most teams skip this step. They audit for value extraction, not for annoyance. The result: contracts that are technically "secure" but cost users twice as much as they should when an adversary pokes at the edges.

Flag this for smart: shortcuts cost a day.

I have seen this in the wild — a token vesting contract where a griefer looped through the claim function, reverting each time, as the contract stored the user's balance in a nested mapping that required two extra SLOADs on every claim. The fix was trivial: cache the mapping value, or check the revert condition earlier than the expensive read. But the contract shipped that way for six months.

So here's the plain-language takeaway: if you're writing functions that handle refunds, claims, or withdrawals, assume someone will try to make them fail. Then ask yourself — what do I lose when their failure succeeds?

Under the Hood: How Reverts Drain Gas

EVM Gas Refund Mechanism

Every revert in the EVM does something counterintuitive: it burns the gas you already spent, then hands back a tiny consolation prize. The refund mechanism was designed to reward cleanup—delete a storage slot, get 4,800 gas back—but it only pays out at the end of a transaction. Attackers weaponize this by forcing victims to write storage, then triggering a revert earlier than cleanup ever happens.

The math stings. A cold storage write expenses 20,000 gas. The refund for clearing it? 4,800. That's a 15,200 gas hole per slot, and the attacker didn't spend a dime of their own.

What usually breaks first is the assumption that reverts "undo" everything. They don't. The EVM unwinds state changes, but gas consumption stays on the bill.

EIP-150 and the 63/64 Rule

EIP-150 introduced a nasty guardrail: any subcall can only use 63/64 of the remaining gas. This was meant to prevent reentrancy griefing from spiraling into full-chain DoS, but it creates a honeypot for carefully crafted attacks. The victim's contract calls a malicious function, the subcall eats 1/64 of the gas, and when it reverts—the entire remaining gas vanishes with it.

That's the trap. An attacker doesn't need to spend 50% of your gas upfront. They just need to force a subcall that reverts at the right moment.

The catch: the 63/64 rule applies to each CALL, DELEGATECALL, or STATICCALL. Nested subcalls compound the drain. I have seen contracts lose 90% of their gas through a single depth-2 call chain, with the actual revert happening in a leaf function that did nothing but throw.

Why Storage Writes Are a Honeypot

Storage writes are the meat of the refund mechanic, and they're also the easiest way to bleed a victim dry. Here's the pattern: the victim thinks they're performing a legitimate state update, so they write to a slot they've written ahead of. Warm storage access spend 100 gas, but changing a zero to non-zero expenses 20,000. The attacker engineers the revert after that write, and the refund almost rarely materializes since the transaction died early.

Refunds are paid only at transaction end, and a revert skips that payment entirely. Cleanup is not a safety net—it's a reward for finishing.

— Solidity auditor, anonymous

The odd part is — most developers still treat storage cleanup as a free operation. They assume the refund makes reverts painless. That assumption fails the moment an attacker controls call ordering.

We fixed this in a lending protocol by moving all state writes to the very end of a function, after every external call resolves. The failure mode was brutal: a liquidator's transaction would revert mid-way, and the protocol's accounting would be intact, but the user's gas—all 200,000 of it—was gone.

Mitigations exist, but they all cost something. Stricter gas limits on subcalls shrink the attack surface but cripple legitimate integrations. Redundant state checks add overhead. The only real defense is designing functions so that reverts happen earlier than any expensive storage write, not after. That means reordering logic and accepting that some operations will revert more often.

Smart contracts aren't forgiving. Reverts don't refund the journey—just the destination.

A Worked Example: Attacking a Mock Escrow

Setup: A Naive Escrow with a Refund Path

Picture a mock escrow that holds ETH until a buyer confirms receipt. The refund function is the obvious griefing target — it calls msg.sender.call{value: amount}("") prior setting a state flag to paid. Wrong order. A malicious seller can trigger the refund, watch the call fail, and leave the contract stuck with a zeroed balance and an unset flag. The contract still thinks it owes money, but there is nothing left to send.

Here is the simplified version I tested:

contract MockEscrow { mapping(address => uint) public deposits; bool public paid; function refund(address to, uint amount) external { require(deposits[to] >= amount, "no deposit"); deposits[to] -= amount; (bool ok, ) = to.call{value: amount}(""); require(ok, "refund failed"); paid = true; } }

That require(ok) is the bomb. A normal user’s refund succeeds, so the flag flips and life goes on. But griefers don't play normal. They deploy a tiny contract with a receive() that reverts unconditionally, then deposit ETH into the escrow and call refund. The state change to deposits already happened — but the whole transaction rolls back, including the flag flip. The griefing contract is left with a full deposit, and the escrow is stuck.

The Attack Transaction Step by Step

Step one: attacker deploys GriefBox with a self-reverting fallback. Step two: they send 1 ETH to the escrow under their address. Step three: they call refund(GriefBox, 1 ether). The escrow subtracts the deposit, then fires the external call. The call hits receive(), which executes revert(). The entire transaction unwinds — gas is consumed, the deposit is untouched, and the escrow’s paid flag never flips. Repeat forever.

The griefed party is not just the escrow deployer. Any legitimate buyer waiting for that refund loses access to their ETH, as the contract’s logic now expects a flag that will never change. I have seen this exact pattern in a token vesting contract — the griefing contract didn’t even need a custom fallback, just a require(false) in its receive function. That hurts.

Simulating the Grief with Foundry

Foundry makes the measurement brutal and clear. I wrote a test that runs the refund call against a normal EOA and then against the GriefBox. The EOA case: 42,918 gas, flag flips, done. The GriefBox case: 58,204 gas, transaction reverts, flag stays false. The extra ~15,000 gas is the revert bomb — the caller pays for the entire state rollback, including the cost of the failed inner call.

function testGrief() public { GriefBox g = new GriefBox(); escrow.deposit{value: 1 ether}(address(g)); vm.expectRevert(); escrow.refund(address(g), 1 ether); assertEq(escrow.paid(), false); // stuck state }

The odd part is that the fix is boring: use withdraw patterns with pull-over-push, or set the state flag ahead of the external call and revert manually if the call fails. But the boring fix has its own trap — if you flip the flag first, a reentrant call can double-spend ahead of the flag is read. That's the edge case the next section will chew on.

Flag this for smart: shortcuts cost a day.

A revert is not a refund — it's a denial of service dressed up as a failed transaction.

— deployer debugging a stuck vesting contract

Run the test yourself with forge test --gas-report. Watch the gas numbers climb when you swap the target from an EOA to a hostile contract. Then ask yourself: how many of your own refund paths would survive a caller that simply refuses to accept money?

Edge Cases and Exceptions

Gas tokens and refund manipulation

Gas tokens break the griefing math. The old ones — GST2, CHI — let an attacker mint cheap during low congestion, then burn them inside a victim’s transaction to force a refund. That refund lands in the caller’s pocket, not the contract’s. So an attacker can grief you and get paid for it. The seam blows out when the refund exceeds the cost of minting. I have seen escrow contracts lose 0.02 ETH per failed withdrawal to this trick. Not catastrophic. But repeat it a thousand times.

The catch is most of those tokens died with the London hard fork. Post-EIP-1559, base fees get burned, so the refund mechanic that made gas tokens viable no longer works. Newer chains with different fee models? Different story. On chains where priority fees behave like pre-London tips, a variant can still squeeze value from your revert path. The weirdest part is the attacker doesn’t even need to succeed. They just need the call to fail after consuming gas.

Your mitigation is boring: validate everything earlier than state changes, minimize the post-validation gas footprint. That sounds fine until a nested call inside your try/catch does unexpected work. What usually breaks first is the external library call.

“The refund doesn’t care about your intent. It only cares about the unused gas you handed over.”

— anonymous MEV searcher, private discord log

EIP-2930 access lists as a shield

Access lists flip the dynamic. Pre-declaring storage slots and addresses drops gas expenses for cold reads. An attacker can pay to warm your storage prior their griefing attempt — then their revert overheads them less, making the attack cheaper to spam. The defensive angle is different though. If you build your own access list for the transaction that calls your contract, you can cap the variability of execution expenses. That makes griefing less profitable per unit of attacker effort.

The trade-off is real: access lists are static, contracts are dynamic. A list that covers your happy path misses the storage slot you touch only in the failure branch. Wrong order and your revert overheads more than expected. I fixed an escrow once where adding an access list for the token transfer path accidentally exposed the refund path to higher gas — the attacker spotted it within a day. Use access lists as a shield, but test the full decision tree, not just the sunny branch.

The special case of off-chain griefing

Off-chain protection dodges the EVM entirely. A relayer or API server can sim the transaction, check the gas outcome, and refuse to forward reverts with suspicious cost profiles. That works until the relayer is the target. Many teams run a centralized relayer with a gas cap. An attacker just sends a batch that looks fine individually but blows the relayer’s total budget across many parallel calls. The relayer eats the loss, users get blocked, and your “off-chain protection” becomes a denial-of-service vector.

Another gap: partial-fill griefing. The off-chain check approves a transaction with a max gas value. The attacker splits a large revert bomb into smaller chunks that each pass your gas filter but together drain the relayer’s pool. Not one big spike, just a slow bleed. The fix is per-sender budgets, not per-transaction limits. That said, per-sender budgets get wrecked by Sybil accounts.

Most teams skip this layer. They assume off-chain validation is immune to gas games since it doesn’t touch the chain. It does touch the chain eventually. Treat your relayer as a contract with finite resources, given attackers already do. The practical next step: set a hard cap on aggregate gas per block per sender, then monitor for patterns that slide just under that cap. Then lower the cap by 20%. That hurts, but it hurts less than a drained relayer.

Limits of Mitigation Strategies

Why try/catch doesn't save you

Most teams assume Solidity’s try/catch is their shield. It's not. The pattern only works when you wrap an external call inside a function that can itself revert and propagate cleanly. But the gas griefing attack doesn’t need to revert the wrapping function — it just needs to burn through your available gas. The revert happens, the catch block runs, and you’re still stuck paying for the failed call’s execution. What usually breaks first is the assumption that “catching” means “cheap.” It doesn’t. You catch the revert, sure — but you also catch the bill.

That hurts.

The deeper issue is that try/catch only helps when the griefing comes from a single external call. Attackers don’t play that way. They nest calls, they use loops, they trigger selfdestruct patterns that force gas refunds to be recalculated mid-execution. Your try/catch block sees one revert, but the damage is already done across three different subcalls. I have seen contracts where a single try/catch wrapper added 40% overhead to every legitimate operation — since the EVM still steps through the entire call stack ahead of deciding what to revert.

Gas limits you can't control

You can set your own gas limits on external calls. That gives you a ceiling. But the floor is the problem. When you call an untrusted contract, you have no way to know how much gas its fallback function will chew through prior it decides to revert. A malicious contract can deliberately use require(false) after looping 10,000 times — the revert still happens, but you’ve paid for all 10,000 iterations.

The classic fix — specifying a low gas limit on the call — has a nasty trade-off. Set it too low, and legitimate users with complex calldata get their transactions reverted. Set it too high, and you’re back to square one. Gas griefing doesn’t care about your limits; it cares about the gap between your limit and the minimum an honest call needs. That gap is the attack surface.

The EVM’s gas refund mechanism makes things worse. Refunds for storage clearing are capped at 20% of total gas spent, but griefers can force you into refund-heavy paths that eat your remaining gas without you noticing until it’s too late. You can't control refunds at the call level — only at the transaction level, which is out of your hands.

The trade-off between efficiency and safety

Every mitigation strategy has a cost. The most robust approach — splitting your logic into smaller, isolated calls — blows up your transaction count. A single escrow settlement becomes three separate transactions, each with its own failure mode. The efficiency loss is real: users pay more in base fees, your contract gets more complicated, and the attack surface expands to include reentrancy vectors that never existed in the monolith.

The alternative — external gas metering where you simulate the call first — is theoretically sound but practically fragile. Simulation results drift from on-chain reality since state changes between your test and the actual execution. I’ve debugged two incidents where simulated gas estimates were off by 30% given a storage slot got warmed by an earlier call in the same transaction. No simulation tool catches that reliably.

The honest answer is that no mitigation removes residual risk — it only shifts it. You can stop the cheap attacks, but a determined griefer with a fat wallet can always make your contract uneconomical to use. The real defense is economic, not technical: make griefing cost more than the attacker gains. That means accepting some inefficiency in your normal path, logging anomalies, and having an admin override that kicks in when gas usage spikes beyond the 95th percentile.

“Gas griefing is a tax on trust. You pay it whether you want to or not — the only question is how much.”

— field note from a security audit, 2024

Reality check: name the contracts owner or stop.

We fixed one escrow contract by adding a circuit breaker that pauses withdrawals when average gas per settlement climbs past a threshold. It didn’t stop the attack — it just made it not worth the attacker’s time. That’s the realistic ceiling. If you need harder guarantees, reconsider whether your contract should be calling untrusted contracts at all, or whether you can enforce a whitelist with known gas profiles. Wrong order? No — right order, but with the caveat that whitelists centralize your system.

Test your worst-case gas scenarios weekly. Track them against mainnet stats. And when you find a griefing vector you can’t close, publish it — someone else has the same problem and might have a workaround you haven’t seen yet.

Frequently Asked Questions

Can I get my gas back?

Short answer: no, and you should stop expecting otherwise. Gas is spent the moment a transaction is included in a block — reverts don't refund the execution cost, they only undo state changes. The network still ran every opcode, verified every signature, and stored your transaction hash forever. That said, some applications return a portion of the fee via custom logic, like refunding excess ETH sent with a call. But those are voluntary transfers, not protocol-level rebates, and they carry their own griefing surface — a malicious contract can promise refunds and then renege.

That hurts more when you realize the attacker pays nothing extra.

Does using a relayer help?

Relayers shift the fee burden, but they don't eliminate griefing — they just move the target. A meta-transaction relayer pays gas upfront and gets reimbursed by the user, which sounds clean. The catch is that relayers are now the ones absorbing revert overheads when a user's bundled call fails. I have seen relayers blacklist addresses after a single failed batch, effectively kicking out honest users who made one typo in their calldata. Some relayers mitigate this by simulating the entire bundle off-chain earlier than submitting, but that only catches deterministic failures — not state-dependent reverts that happen mid-execution.

Most teams skip this: the relayer itself becomes a central point of trust. If it decides to censor you, your transaction never lands, and you have no recourse on-chain.

What about Layer 2s?

L2s change the math, but not the principle. On optimistic rollups, gas is cheaper, so the absolute loss per griefing attack drops — but the frequency can spike since cheap calls invite more testing. ZK-rollups batch transactions, meaning a revert inside a batch can invalidate the whole batch in some designs, amplifying one griefer's damage across hundreds of innocent users. Arbitrum's nitro has a different fee model where L1 data expenses dominate, so a revert still burns real money, just unevenly distributed.

Gas griefing isn't a bug you fix; it's a tax you price into your architecture.

— independent auditor, private correspondence

Legal recourse? Practically nonexistent for small amounts. You can sue in some jurisdictions if the griefer is identifiable and the damage clears a monetary threshold, but the cost of discovery, expert witnesses, and court fees will dwarf the lost gas. Law enforcement doesn't care about a $40 burned transaction. The realistic play is technical: simulate prior you send, set gas limits conservatively, and never assume a revert means the end of the interaction — check the receipt log for partial state changes.

The honest answer is that you absorb the loss, learn the pattern, and move on.

Practical Takeaways for Developers

Checklist for gas-safe contracts

Start with the obvious: every external call you make is a handshake with a stranger. ahead of you write `call`, `delegatecall`, or `transfer`, ask what happens if that stranger burns all the gas you gave them. The rule I keep coming back to is simple — cap the gas on any call that can fail, and never assume the recipient will play nice.

Your checklist needs teeth, not vibes. Enforce a gas limit on low-level calls. Use `try/catch` sparingly, because it still consumes the full stipend on revert. Prefer pull payments over push; let users withdraw their own funds instead of forcing you to deliver. And for the love of audits, avoid loops that iterate over arrays of unknown length — one malicious entrypoint can turn your refund function into a furnace.

The catch is that gas griefing hides in plain sight. I have seen contracts where the developer added a `require` after a transfer, thinking they were safe. That revert burned everything. Wrong order. So test the failure paths, not just the happy ones.

  • Set explicit gas limits on untrusted calls
  • Reorder state changes before external interactions
  • Track refund balances, never compute them on the fly
  • Document every place where griefing could stall a user

Testing with adversarial calldata

Standard unit tests won't save you. They pass clean values and assume the world is reasonable. What usually breaks first is the edge case you never wrote — a user sending 0 ether, a contract that selfdestructs mid-call, data that looks valid but decodes into nonsense. You need fuzzing, and you need it early.

We fixed a griefing vector in our mock escrow by feeding it random calldata until something snapped. The test suite ran 10,000 cases and found one where a refund call consumed 300,000 gas more than expected. That hurts. The fix was a gas limit on the withdraw function, but we never would have caught it without adversarial input.

Think about what an attacker controls: the order of operations, the size of the payload, the timing. Most teams skip this and pay for it later. A revert bomb doesn't care about your intentions; it cares about your gas stipend.

“Gas griefing is not a bug in your logic — it's a bug in your assumptions about who you're talking to.”

— anonymous auditor, private conversation, 2024

When to prioritize safety over optimization

Here is the hard truth: gas optimization and griefing resistance sometimes pull in opposite directions. You can save 2,000 gas by skipping a check, but that check might be the only thing between your users and a refund trap. The trade-off is real, and you have to choose which failure mode you can live with.

That said, most contracts are not hurting for gas. They're hurting for trust. I would rather ship a function that expenses 50,000 gas and works under attack than one that costs 20,000 and breaks when someone sends a malicious callback. Priorities, not perfection.

One more thing: document your reasoning. When a future developer sees a gas limit you left, they should know why it's there. A comment like “prevents griefing from reentrant refunds” beats a silent constant every time. That's the kind of safety that compounds.

Share this article:

Comments (0)

No comments yet. Be the first to comment!