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.
- Live: https://legwork.edycu.dev/ (reviewer path:
/#/judge) - Repo (MIT): https://github.com/edycutjong/legwork
- 2:17 video of a real create → execute on Arc mainnet: https://youtu.be/YPUbDlJc1lY
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
}
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");
}
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
-
gasUsedp50 = p95 = 58,415 (the payee-as-executor rows are 55,915 — the payee istx.originand already warm, and the meter caught that too: drift 0) -
tx.gasprice == effectiveGasPriceon 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 buildinstead. - 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
- Contract on Arc mainnet (chain 5042):
0x8E2F8AFC29e9dc127103CD6AD5BCfBe661141ccb·OVERHEAD() = 32503 - The run in the video: https://explorer.arc.io/tx/0x2b33e38f8404b12be1c07706c196991a05da1824ab08ea1b95f833c6cd6ad255 — 58,415 gas × 20 Gwei refunded, drift 0
- 42 Foundry tests (34 unit · 2 fuzz × 512 · 6 invariants), 36 vitest incl. 20,000 fast-check property cases, Playwright end-to-end with live mainnet reads
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)