From 78ae94b6260e5951eb4b0c26855daf2026126517 Mon Sep 17 00:00:00 2001 From: Matt Beuttenmuller Date: Fri, 24 Jul 2026 19:08:46 -0400 Subject: [PATCH] Fix OOG reversion from sending to cold account --- app/vibenet/demos/account/AccountDemo.tsx | 7 +++++- app/vibenet/demos/account/library/calls.ts | 22 +++++++++++++++++++ app/vibenet/demos/account/library/chains.ts | 24 ++++++++++++++++++++- 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/app/vibenet/demos/account/AccountDemo.tsx b/app/vibenet/demos/account/AccountDemo.tsx index 5d38c9f..607029d 100644 --- a/app/vibenet/demos/account/AccountDemo.tsx +++ b/app/vibenet/demos/account/AccountDemo.tsx @@ -99,6 +99,7 @@ import { safeGasLimit, tryDecodeUsdvTransfer, USDV_DECIMALS, + valueBearingCallCount, } from './library/calls'; import { type AccountType, @@ -775,8 +776,9 @@ export function AccountDemo() { deploy: !acct.deployed, calls: calls.length, keyChanges: keyChangeCount, + valueCalls: valueBearingCallCount(calls), }); - }, [acct, chain.mode, calls.length, keyChangeCount]); + }, [acct, chain.mode, calls, keyChangeCount]); // Re-sync owner draft + stale results when the active account changes. useEffect(() => { @@ -1421,6 +1423,9 @@ export function AccountDemo() { calls: plainCallCount, keyChanges: accountChanges.filter((c) => c.type === 'config').length, policyCalls: heavyCallCount, + // Value-bearing user calls carry the stipend + cold-account cost the node + // estimate misses (policy-wrapped sends still forward the ETH value). + valueCalls: valueBearingCallCount(rows), fallback, }); let gasLimit: bigint; diff --git a/app/vibenet/demos/account/library/calls.ts b/app/vibenet/demos/account/library/calls.ts index 1b2d766..5875bef 100644 --- a/app/vibenet/demos/account/library/calls.ts +++ b/app/vibenet/demos/account/library/calls.ts @@ -64,6 +64,28 @@ export function buildCalls(rows: CallRow[], fallback: Address) { return rows.map((r) => rowToCall(r, fallback)); } +/** + * Count call rows that transfer a non-zero native ETH value. Each such inner + * CALL needs the ~9k value-transfer surcharge (G_callvalue) and, for a + * not-yet-existent recipient, the ~25k new-account creation cost (G_newaccount) + * — gas the node's EIP-8130 `eth_estimateGas` + * omits (a phase whose inner CALL OOGs is still a valid inclusion, so the + * estimator converges below what the transfer actually needs to SUCCEED). The + * structural floor budgets these via `estimateTxGas`'s `valueCalls`. Rows whose + * value is empty, zero, or unparseable count as non-value-bearing. + */ +export function valueBearingCallCount(rows: CallRow[]): number { + return rows.reduce((n, r) => { + const v = r.value.trim(); + if (!v) return n; + try { + return parseEther(v) > 0n ? n + 1 : n; + } catch { + return n; + } + }, 0); +} + // --- USDV (ERC-20 transfer) helpers ----------------------------------------- export const ERC20_TRANSFER_SELECTOR = '0xa9059cbb'; diff --git a/app/vibenet/demos/account/library/chains.ts b/app/vibenet/demos/account/library/chains.ts index b4a8774..fcb60d9 100644 --- a/app/vibenet/demos/account/library/chains.ts +++ b/app/vibenet/demos/account/library/chains.ts @@ -217,6 +217,13 @@ export function estimateTxGas(params: { // adds the PolicyManager.execute frame, policy validation, spend-tracking // SSTOREs, and the callback into executeBatch on top of the inner call. policyCalls?: number; + // Number of calls that transfer a non-zero native ETH value. Each needs the + // ~9k value stipend plus (for a not-yet-existent recipient) the ~25k + // new-account creation cost. The node's `eth_estimateGas` misses this — it + // converges to the tx-envelope cost, ignoring that the inner value transfer + // must actually succeed — so the floor must carry it or a send to a cold + // recipient OOG-reverts on-chain (unused gas is refunded, an OOG is not). + valueCalls?: number; // When true, this value is the SOLE gas source (the node's `eth_estimateGas` // is unavailable, reverted, or the user chose "Send anyway"), so it is scaled // up by {@link FALLBACK_SAFETY} to over-provision on purpose — unused gas is @@ -226,7 +233,15 @@ export function estimateTxGas(params: { // node's accurate estimate and permanently over-price every transaction. fallback?: boolean; }): number { - const { mode, deploy, calls, keyChanges, policyCalls = 0, fallback = false } = params; + const { + mode, + deploy, + calls, + keyChanges, + policyCalls = 0, + valueCalls = 0, + fallback = false, + } = params; // ERC-4337: EntryPoint overhead (unchanged — different execution path). // EIP-8130 native: base covers intrinsic gas for authenticator dispatch, // AccountConfiguration lookup, and RLP calldata. @@ -247,6 +262,12 @@ export function estimateTxGas(params: { // node's estimate can be low when the simulated inner call reverts (an 8130 // reverting phase is still a valid inclusion), so the floor is the backstop. const perPolicyCall = 55_000; + // Headroom for a value-bearing inner CALL the node estimate can't see: the 9k + // value-transfer surcharge (G_callvalue, which itself includes the 2,300 callee + // stipend) + 25k creation of a cold recipient account (G_newaccount). Additive + // on top of `perCall` (a value call is still a call). Both native and 4337 pay + // it — the EVM cost is the same regardless of execution path. + const perValueCall = 34_000; // 2x safety multiplier, applied ONLY in fallback mode (see `fallback` above): // when the node's `eth_estimateGas` is unavailable or reverts (staged config + // policy bundles, cold-account creation on a value transfer to a fresh @@ -263,6 +284,7 @@ export function estimateTxGas(params: { calls * perCall + keyChanges * perKeyChange + policyCalls * perPolicyCall + + valueCalls * perValueCall + wrap) ); }