DEV Community

Cover image for I claimed my gas refunds were exact. An audit found the 21,000 I was crediting twice.
Edy Cu
Edy Cu

Posted on

I claimed my gas refunds were exact. An audit found the 21,000 I was crediting twice.

My README said the gas refund was exact. An audit round the next day showed it wasn't: a contract that batched K orders in one transaction was over-refunded 21,000 × (K − 1) gas. Nobody had exploited it, it was bounded by each order's reserve, and it was still not "exact". This post is the mechanism, the mistake, the fix, and the receipts that stayed in the repo.

Why recurring payments need a keeper network (everywhere else)

A standing order on-chain needs someone to call it when it's due. Whoever does pays gas. To make that worth their while you have to repay them — and on any chain where gas is ETH and the app's money is an ERC-20, repaying gas in the payment asset needs a price oracle, and finding executors needs a keeper network. That is the whole reason automation protocols exist.

On Arc, gas is USDC, with 18-decimal msg.value. The payment and the fee are the same dollar. So the contract can meter the gas it used, multiply by tx.gasprice (already USDC), and pay it back from the order's own deposit — in the same transaction, with no oracle and no keeper network. Anyone can run a due order and nets exactly the tip.

Legwork is that contract, live on Arc mainnet at 0x8E2F8AFC29e9dc127103CD6AD5BCfBe661141ccb.

How the meter works

execute(id) reads gasleft() on its first statement, does the work, and reads it again at a fixed measurement point after which nothing of variable cost runs. Everything outside that window — intrinsic gas, calldata, the payout call, the event, one tstore — is a constant, OVERHEAD, calibrated on mainnet and baked in as an immutable:

function execute(uint256 id) external {
    uint256 g0 = gasleft();                                                     // 1  first statement
    if (_lock != 0) revert Reentrant();                                         // 2  guard (transient)
    _lock = 1;
    Order storage o = orders[id];                                               // 3  load + checks
    if (o.payer == address(0)) revert NoOrder();
    if (o.paused) revert IsPaused();
    uint48 nextDue = o.nextDue;
    if (block.timestamp < nextDue) revert NotDue(nextDue);
    if (_txSeen == 0) { _txSeen = 1; g0 += INTRINSIC_GAS; }                     //    intrinsic credited to the first execute of a transaction only
    uint256 price = tx.gasprice;                                                // 4  price = min(gasprice, 2·basefee, maxGasPrice)
    if (price > block.basefee << 1) price = block.basefee << 1;
    if (price > o.maxGasPrice) price = o.maxGasPrice;
    // … pre-check, effects, pay the payee (a refused payment pauses the order instead of reverting) …
    uint256 metered = g0 - gasleft() + (OVERHEAD - INTRINSIC_GAS);              // 9  measurement point
    if (metered > REFUND_CEIL_GAS) metered = REFUND_CEIL_GAS;
    uint256 refund = metered * price;                                           // 10 bounded by the deposit
    if (refund > deposit) refund = deposit;
    o.deposit = uint128(deposit - refund);                                      // 11
    (bool ok,) = msg.sender.call{value: refund + tip, gas: EXECUTOR_GAS}("");   // 12 repay + tip, one call
    if (!ok) revert PayoutFailed();
    emit Executed(id, msg.sender, metered, price, refund, tip, nextDue, paid); // 13 fixed width on both branches
    _lock = 0;                                                                  // 14
}
Enter fullscreen mode Exit fullscreen mode

The price is capped at min(tx.gasprice, 2 × basefee, maxGasPrice) so an executor cannot drain a deposit by choosing an absurd gas price, and the refund is capped at REFUND_CEIL_GAS and at the deposit itself.

Then you check it against reality. The transaction receipt has gasUsed and effectiveGasPrice, neither of which the contract can see. If metered == gasUsed and price == effectiveGasPrice, the refund equals the real fee to the wei. That difference — gasUsed − metered — is what I call drift.

The line if (_txSeen == 0) { … } is the fix. It wasn't there in v1.

What v1 got wrong

The transaction's intrinsic 21,000 gas is paid once per transaction. v1 credited it once per call. For a wallet calling execute directly that's the same thing, and every test and every mainnet run said drift 0. But a contract that batches several orders in one transaction pays the intrinsic once and would have been refunded it K times.

Bounded? Yes — a refund can never exceed the order's reserve. Exploited? No — the only executors were my own two wallets. Exact? No. And "exact" was the word in the README's first paragraph.

v2 tracks it in transient storage: the first execute in a transaction claims the intrinsic, later ones in the same transaction don't. The regression test runs two orders through a batching contract and checks the second call's metered figure is the first's minus 21,000 (plus the one tstore the first call paid):

function test_execute_batchedExecutorIsChargedTheIntrinsicOnce() public {
    // two orders to two existing, cold payees; a batcher runs both in one transaction
    address p1 = makeAddr("p1"); address p2 = makeAddr("p2");
    vm.deal(p1, 1); vm.deal(p2, 1);
    uint256 a = _create(p1, 0.01 ether, 60, 0.01 ether, 100 gwei, 0.05 ether);
    uint256 b = _create(p2, 0.01 ether, 60, 0.01 ether, 100 gwei, 0.05 ether);
    Batcher bt = new Batcher(lw);
    uint256[] memory ids = new uint256[](2); ids[0] = a; ids[1] = b;
    vm.recordLogs();
    bt.go(ids);
    Vm.Log[] memory logs = vm.getRecordedLogs();
    (uint256 m1,,,,,) = abi.decode(logs[0].data, (uint256, uint256, uint256, uint256, uint48, bool));
    (uint256 m2,,,,,) = abi.decode(logs[1].data, (uint256, uint256, uint256, uint256, uint48, bool));
    // the second call's metered figure is the first's minus the 21,000 intrinsic (and minus the one tstore the first paid)
    assertGe(m1 - m2, 21_000 + 100, "intrinsic credited once per transaction (plus the one tstore the first call paid)");
    assertLe(m1 - m2, 21_000 + 300, "nothing else differs between the two calls");
}
Enter fullscreen mode Exit fullscreen mode

The change is inside the measured window, so OVERHEAD didn't move. v1 (0x68a92aF2Be2e6A640a19508a0fe44cbc8B2C62E2) had its demo orders cancelled and holds nothing — but its 36 execute receipts stay in proof/receipts/ and are still re-checked by npm run recheck, next to the 36 from v2. Deleting them would have made the repo look cleaner and the claim weaker.

It was not the only correction that day. OVERHEAD was estimated at 31,400 before the first deploy; three calibration runs on mainnet measured 32,503 — under by exactly 1,103 on all three, spread 0. The wrong number stays visible in the deploy record. And the paused branch (payee refuses the payment) meters 6 gas over, so on that path the executor is over-refunded by 6 × 20 Gwei — about a hundred-millionth of a dollar, and it's documented rather than hidden.

The numbers, after the fix

30 consecutive mainnet executes on the production contract (25 by the payer's wallet, 5 by the payee collecting its own payment):

  • refund ÷ real fee = 1.000000 on all 30 rows (the pre-stated invariant was 1.00 ± 0.02)
  • drift = 0 gas on every row
  • gasUsed p50 = p95 = 58,415 (the payee-as-executor rows are 55,915 — the payee is tx.origin and already warm, and the meter caught that too: drift 0)
  • tx.gasprice == effectiveGasPrice on every row, 20 Gwei

There is no randomness to seed and no warm-up to discard: each run is its own transaction. The 107 committed receipts are recomputed from raw chain data by npm run recheck, which exits non-zero on any drift outside the documented −6…0 window.

Why the retraction is the point

The engineering interest here is small: one transient flag. The reason I'm writing it up is that "exact" is a claim you can only make after you've gone looking for the case where it isn't — and the case was a contract executor I hadn't built, running a batch I hadn't imagined, against a README I had already written. If your refund logic has never been called from a contract, it hasn't been tested for the intrinsic.

Honest limitations

  • Executors so far are my own two wallets. Nobody else has run an order yet.
  • Every bench row sits at Arc's 20 Gwei base fee; the 2× cap is exercised by tests and one capped receipt, not by the bench.
  • Explorer source verification wasn't possible (the API is behind a challenge page); the on-chain runtime bytecode is checked byte-for-byte against forge build instead.
  • This is a modifier for one contract's payouts, not an automation network. It doesn't schedule anything for you; it makes it worth someone's while to call you.

Proof

Live page: https://legwork.edycu.dev/ · repo: https://github.com/edycutjong/legwork

If you've shipped gas-refund logic on any EVM and metered it differently, I'd like to compare notes.

Top comments (0)