From cc91c0a494ca416324f543bac4ebba1aed73be45 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Tue, 11 Aug 2026 22:38:48 +0700 Subject: [PATCH 1/7] execution/types, execution/protocol/aa: reject AA gas limits that overflow uint64 The four RIP-7560 gas limits arrive unvalidated and were summed with no overflow check in four places. A wrapped total made the balance precharge in chargeGas small enough to pass the insufficient-funds check, and made refundGas compute a refund from a preCharge below the actual cost. TotalGasLimit reports the overflow so both can reject. GetGasLimit cannot return an error, so it saturates and lets downstream gas checks fail closed. --- execution/protocol/aa/aa_exec.go | 6 +++++- execution/protocol/aa/aa_gas.go | 10 ++++++++-- execution/types/aa_transaction.go | 21 ++++++++++++++++++++- execution/types/transaction_test.go | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/execution/protocol/aa/aa_exec.go b/execution/protocol/aa/aa_exec.go index bd3c2381d22..a94a7b30d1d 100644 --- a/execution/protocol/aa/aa_exec.go +++ b/execution/protocol/aa/aa_exec.go @@ -317,7 +317,11 @@ func ExecuteAATransaction( return 0, 0, err } - gasPool.AddGas(params.TxAAGas + tx.ValidationGasLimit + tx.PaymasterValidationGasLimit + tx.GasLimit + tx.PostOpGasLimit - gasUsed) + totalGasLimit, ok := tx.TotalGasLimit(params.TxAAGas) + if !ok { + return 0, 0, fmt.Errorf("%w: RIP-7560 gas limits sum overflows uint64", protocol.ErrGasLimitReached) + } + gasPool.AddGas(totalGasLimit - gasUsed) return executionStatus, gasUsed, nil } diff --git a/execution/protocol/aa/aa_gas.go b/execution/protocol/aa/aa_gas.go index 6f053abc1e4..fed3ae9fd1a 100644 --- a/execution/protocol/aa/aa_gas.go +++ b/execution/protocol/aa/aa_gas.go @@ -26,7 +26,10 @@ func chargeGas( effectiveGasTip := tx.GetEffectiveGasTip(baseFee) effectiveGasPrice := new(uint256.Int).Add(baseFee, &effectiveGasTip) - totalGasLimit := preTxCost + tx.ValidationGasLimit + tx.PaymasterValidationGasLimit + tx.GasLimit + tx.PostOpGasLimit + totalGasLimit, ok := tx.TotalGasLimit(preTxCost) + if !ok { + return fmt.Errorf("%w: RIP-7560 gas limits sum overflows uint64", protocol.ErrGasLimitReached) + } preCharge := new(uint256.Int).SetUint64(totalGasLimit) preCharge = preCharge.Mul(preCharge, effectiveGasPrice) @@ -62,7 +65,10 @@ func refundGas( effectiveGasPrice := new(uint256.Int).Add(baseFee, &effectiveGasTip) actualGasCost := new(uint256.Int).Mul(effectiveGasPrice, new(uint256.Int).SetUint64(gasUsed)) - totalGasLimit := params.TxAAGas + tx.ValidationGasLimit + tx.PaymasterValidationGasLimit + tx.GasLimit + tx.PostOpGasLimit + totalGasLimit, ok := tx.TotalGasLimit(params.TxAAGas) + if !ok { + return fmt.Errorf("%w: RIP-7560 gas limits sum overflows uint64", protocol.ErrGasLimitReached) + } preCharge := new(uint256.Int).SetUint64(totalGasLimit) preCharge = preCharge.Mul(preCharge, effectiveGasPrice) diff --git a/execution/types/aa_transaction.go b/execution/types/aa_transaction.go index d0c51ec8cda..b809c10f635 100644 --- a/execution/types/aa_transaction.go +++ b/execution/types/aa_transaction.go @@ -9,6 +9,7 @@ import ( "github.com/holiman/uint256" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/math" "github.com/erigontech/erigon/execution/abi" "github.com/erigontech/erigon/execution/chain" "github.com/erigontech/erigon/execution/protocol/mdgas" @@ -114,8 +115,26 @@ func (tx *AccountAbstractionTransaction) GetFeeCap() *uint256.Int { return tx.FeeCap } +// TotalGasLimit reports whether base plus the declared gas limits fits in a uint64. +func (tx *AccountAbstractionTransaction) TotalGasLimit(base uint64) (uint64, bool) { + total := base + for _, gas := range [...]uint64{tx.ValidationGasLimit, tx.PaymasterValidationGasLimit, tx.GasLimit, tx.PostOpGasLimit} { + sum, overflow := math.SafeAdd(total, gas) + if overflow { + return 0, false + } + total = sum + } + return total, true +} + func (tx *AccountAbstractionTransaction) GetGasLimit() uint64 { - return params.TxAAGas + tx.ValidationGasLimit + tx.PaymasterValidationGasLimit + tx.GasLimit + tx.PostOpGasLimit + // saturate: the interface cannot report overflow, and a wrapped-small total would pass gas checks + total, ok := tx.TotalGasLimit(params.TxAAGas) + if !ok { + return math.MaxUint64 + } + return total } func (tx *AccountAbstractionTransaction) GetTipCap() *uint256.Int { diff --git a/execution/types/transaction_test.go b/execution/types/transaction_test.go index 4375677f41c..2e0b3bf8952 100644 --- a/execution/types/transaction_test.go +++ b/execution/types/transaction_test.go @@ -876,3 +876,18 @@ func TestTypedTxEmptyToErrorMessage(t *testing.T) { }) } } + +func TestAATotalGasLimitOverflow(t *testing.T) { + t.Parallel() + + tx := &AccountAbstractionTransaction{ValidationGasLimit: 1, PaymasterValidationGasLimit: 2, PostOpGasLimit: 4} + tx.GasLimit = 8 + total, ok := tx.TotalGasLimit(16) + assert.True(t, ok) + assert.Equal(t, uint64(31), total) + + overflowing := &AccountAbstractionTransaction{ValidationGasLimit: ^uint64(0), PaymasterValidationGasLimit: 1} + _, ok = overflowing.TotalGasLimit(params.TxAAGas) + assert.False(t, ok) + assert.Equal(t, ^uint64(0), overflowing.GetGasLimit()) +} From 2d9714d0d14301fb946ed855d808fa4231bd204e Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Tue, 11 Aug 2026 22:38:48 +0700 Subject: [PATCH 2/7] cl/merkle_tree: handle a bitlist with no sentinel bit A bitlist encoding always ends in a sentinel bit, so the last byte is never zero. Reading msb from a zero byte underflowed to 255 and inflated the length mixed into the hash tree root; an empty buffer indexed out of range. --- cl/merkle_tree/list.go | 4 ++++ cl/merkle_tree/merkle_root_test.go | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/cl/merkle_tree/list.go b/cl/merkle_tree/list.go index d953b968458..c3a78f90a4d 100644 --- a/cl/merkle_tree/list.go +++ b/cl/merkle_tree/list.go @@ -110,6 +110,10 @@ func packBitsInto(dst [][32]byte, bytes []byte) [][32]byte { } func parseBitlist(dst, buf []byte) ([]byte, uint64) { + // a bitlist without its sentinel bit has no recoverable length + if len(buf) == 0 || buf[len(buf)-1] == 0 { + return dst, 0 + } msb := uint8(bits.Len8(buf[len(buf)-1])) - 1 size := uint64(8*(len(buf)-1) + int(msb)) diff --git a/cl/merkle_tree/merkle_root_test.go b/cl/merkle_tree/merkle_root_test.go index 5ac9d4ce840..82eea0ff644 100644 --- a/cl/merkle_tree/merkle_root_test.go +++ b/cl/merkle_tree/merkle_root_test.go @@ -79,3 +79,20 @@ func TestProgressiveContainerProofRejectsOversizedSchema(t *testing.T) { _, err := merkle_tree.ProgressiveContainerProofAll(0, schema...) require.Error(t, err) } + +func TestBitlistRootWithLimitNoSentinel(t *testing.T) { + const limit = 2048 + + empty, err := merkle_tree.BitlistRootWithLimit([]byte{}, limit) + require.NoError(t, err) + + for _, malformed := range [][]byte{{0x00}, {0xff, 0x00}} { + root, err := merkle_tree.BitlistRootWithLimit(malformed, limit) + require.NoError(t, err) + require.Equal(t, empty, root) + } + + wellFormed, err := merkle_tree.BitlistRootWithLimit([]byte{0x03}, limit) + require.NoError(t, err) + require.NotEqual(t, empty, wellFormed) +} From 07eeeb946ff444e1ec66d392e91442576f663f61 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 12 Aug 2026 09:30:57 +0700 Subject: [PATCH 3/7] common/math: add SafeSub, use it for the wrap-then-detect sites geth carries SafeAdd, SafeSub and SafeMul; erigon's copy dropped SafeSub. Subtraction is the most common unchecked case, so restore it. Two call sites computed the result first and detected the wrap afterwards. That is correct Go but relies on the wraparound it is trying to reject, and arithmetic instrumentation flags it as an overflow. --- cl/phase1/network/backward_beacon_downloader.go | 6 +++--- common/math/integer.go | 6 ++++++ common/math/integer_test.go | 17 +++++++++++++++++ execution/protocol/txn_executor.go | 3 ++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cl/phase1/network/backward_beacon_downloader.go b/cl/phase1/network/backward_beacon_downloader.go index ef7cfad7315..f30a9f12b0e 100644 --- a/cl/phase1/network/backward_beacon_downloader.go +++ b/cl/phase1/network/backward_beacon_downloader.go @@ -21,7 +21,6 @@ import ( "errors" "fmt" "io" - "math" "net/http" "slices" "strings" @@ -38,6 +37,7 @@ import ( "github.com/erigontech/erigon/cl/sentinel/peers" "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/math" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/snapshotsync/freezeblocks" ) @@ -199,8 +199,8 @@ func (b *BackwardBeaconDownloader) RequestMore(ctx context.Context) error { // Falls back to the beacon API when P2P is unavailable and an HTTP URL is configured. func (b *BackwardBeaconDownloader) fetchBlockRange(ctx context.Context) ([]*cltypes.SignedBeaconBlock, error) { const count = uint64(64) - start := b.slotToDownload.Load() - count + 1 - if start > b.slotToDownload.Load() { // overflow check + start, underflow := math.SafeSub(b.slotToDownload.Load(), count-1) + if underflow { start = 0 } diff --git a/common/math/integer.go b/common/math/integer.go index 201f7f3f8f1..95ca56f359d 100644 --- a/common/math/integer.go +++ b/common/math/integer.go @@ -125,6 +125,12 @@ func SafeAdd(x, y uint64) (uint64, bool) { return sum, carryOut != 0 } +// SafeSub returns x-y and checks for underflow. +func SafeSub(x, y uint64) (uint64, bool) { + diff, borrowOut := bits.Sub64(x, y, 0) + return diff, borrowOut != 0 +} + // NextPowerOfTwo returns the least power of two at or above n, and 1 for // n == 0; n above 1<<63 wraps to 0. func NextPowerOfTwo(n uint64) uint64 { diff --git a/common/math/integer_test.go b/common/math/integer_test.go index ba4f9831e06..523e91bebb3 100644 --- a/common/math/integer_test.go +++ b/common/math/integer_test.go @@ -99,3 +99,20 @@ func TestNextPowerOfTwo(t *testing.T) { assert.Equal(t, tc.want, NextPowerOfTwo(tc.in), "n=%d", tc.in) } } + +func TestSafeSub(t *testing.T) { + for _, tc := range []struct { + x, y, want uint64 + underflow bool + }{ + {9, 4, 5, false}, + {4, 4, 0, false}, + {0, 1, ^uint64(0), true}, + {4, 9, ^uint64(0) - 4, true}, + {^uint64(0), ^uint64(0), 0, false}, + } { + got, underflow := SafeSub(tc.x, tc.y) + assert.Equal(t, tc.want, got, "%d-%d", tc.x, tc.y) + assert.Equal(t, tc.underflow, underflow, "%d-%d", tc.x, tc.y) + } +} diff --git a/execution/protocol/txn_executor.go b/execution/protocol/txn_executor.go index b2d485ee680..2a8168107aa 100644 --- a/execution/protocol/txn_executor.go +++ b/execution/protocol/txn_executor.go @@ -29,6 +29,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/math" "github.com/erigontech/erigon/common/u256" "github.com/erigontech/erigon/execution/protocol/mdgas" "github.com/erigontech/erigon/execution/protocol/params" @@ -304,7 +305,7 @@ func (st *TxnExecutor) preCheck(gasBailout bool, intrinsicGasResult mdgas.Intrin return upfrontTxnFees{}, &nonceError{err: ErrNonceTooHigh, from: from, txNonce: msgNonce, stateNonce: stNonce} } else if stNonce > msgNonce { return upfrontTxnFees{}, &nonceError{err: ErrNonceTooLow, from: from, txNonce: msgNonce, stateNonce: stNonce} - } else if stNonce+1 < stNonce { + } else if _, overflow := math.SafeAdd(stNonce, 1); overflow { return upfrontTxnFees{}, fmt.Errorf("%w: address %v, nonce: %d", ErrNonceMax, from, stNonce) } From 10b1314542d7274a0bc3069a8aff235356d0c801 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 12 Aug 2026 09:44:58 +0700 Subject: [PATCH 4/7] cl/merkle_tree, execution/types: punctuate the two new inline comments --- cl/merkle_tree/list.go | 2 +- execution/types/aa_transaction.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cl/merkle_tree/list.go b/cl/merkle_tree/list.go index c3a78f90a4d..9522a6123fc 100644 --- a/cl/merkle_tree/list.go +++ b/cl/merkle_tree/list.go @@ -110,7 +110,7 @@ func packBitsInto(dst [][32]byte, bytes []byte) [][32]byte { } func parseBitlist(dst, buf []byte) ([]byte, uint64) { - // a bitlist without its sentinel bit has no recoverable length + // A bitlist without its sentinel bit has no recoverable length. if len(buf) == 0 || buf[len(buf)-1] == 0 { return dst, 0 } diff --git a/execution/types/aa_transaction.go b/execution/types/aa_transaction.go index b809c10f635..73dc945c873 100644 --- a/execution/types/aa_transaction.go +++ b/execution/types/aa_transaction.go @@ -129,7 +129,7 @@ func (tx *AccountAbstractionTransaction) TotalGasLimit(base uint64) (uint64, boo } func (tx *AccountAbstractionTransaction) GetGasLimit() uint64 { - // saturate: the interface cannot report overflow, and a wrapped-small total would pass gas checks + // Saturate: the interface cannot report overflow, and a wrapped-small total would pass gas checks. total, ok := tx.TotalGasLimit(params.TxAAGas) if !ok { return math.MaxUint64 From a1cae9d32334d94a6822cb864881f85c87b554d5 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 12 Aug 2026 12:05:54 +0700 Subject: [PATCH 5/7] execution/execmodule: guard the notify-range span against underflow finishProgressAfter can trail finishProgressBefore. The subtraction then wrapped, min clamped it to 1024, and notifyFrom was computed from a block span that never ran. --- execution/execmodule/notification_dispatcher.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/execution/execmodule/notification_dispatcher.go b/execution/execmodule/notification_dispatcher.go index 03fe89e3ad4..9f751a79b8f 100644 --- a/execution/execmodule/notification_dispatcher.go +++ b/execution/execmodule/notification_dispatcher.go @@ -21,6 +21,7 @@ import ( "github.com/erigontech/erigon/common" "github.com/erigontech/erigon/common/log/v3" + "github.com/erigontech/erigon/common/math" "github.com/erigontech/erigon/db/kv" "github.com/erigontech/erigon/db/rawdb" "github.com/erigontech/erigon/db/state/execctx" @@ -109,9 +110,13 @@ func (d *Dispatcher) Dispatch( // Genesis (block 0): notify from block 0. notifyFrom = 0 default: - heightSpan := min(finishProgressAfter-finishProgressBefore, 1024) - notifyFrom = finishProgressAfter - heightSpan - notifyFrom++ + // finishProgressAfter can trail finishProgressBefore, and a wrapped + // span would clamp to 1024 and notify over a range that never ran. + span, underflow := math.SafeSub(finishProgressAfter, finishProgressBefore) + if underflow { + span = 0 + } + notifyFrom = finishProgressAfter - min(span, 1024) + 1 } notifyTo := finishProgressAfter + 1 //[from, to) From 0cba9e4ef5af03c3471ebfe61465ad3e65e5f6f6 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 12 Aug 2026 13:57:32 +0700 Subject: [PATCH 6/7] execution/protocol/aa: precharge on the spec base, not preTxCost RIP-7560 maxPossibleGasCost is AA_BASE_GAS_COST plus the four declared limits. preTxCost also carries the dynamic calldata, access-list and authorization charges, which the validation frame already deducts from ValidationGasLimit. Charging it here precharged more than refundGas and the gas-pool restoration ever returned, so a payer holding exactly the spec maximum was rejected and any excess was never refunded. --- execution/protocol/aa/aa_exec.go | 2 +- execution/protocol/aa/aa_gas.go | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/execution/protocol/aa/aa_exec.go b/execution/protocol/aa/aa_exec.go index a94a7b30d1d..e9f9c92ca92 100644 --- a/execution/protocol/aa/aa_exec.go +++ b/execution/protocol/aa/aa_exec.go @@ -64,7 +64,7 @@ func ValidateAATransaction( } validationGasUsed = preTxCost - if err := chargeGas(header, tx, gasPool, ibs, preTxCost); err != nil { + if err := chargeGas(header, tx, gasPool, ibs); err != nil { return nil, 0, err } diff --git a/execution/protocol/aa/aa_gas.go b/execution/protocol/aa/aa_gas.go index fed3ae9fd1a..890eb20619c 100644 --- a/execution/protocol/aa/aa_gas.go +++ b/execution/protocol/aa/aa_gas.go @@ -20,13 +20,16 @@ func chargeGas( tx *types.AccountAbstractionTransaction, gasPool *protocol.GasPool, ibs *state.IntraBlockState, - preTxCost uint64, ) error { baseFee := header.BaseFee effectiveGasTip := tx.GetEffectiveGasTip(baseFee) effectiveGasPrice := new(uint256.Int).Add(baseFee, &effectiveGasTip) - totalGasLimit, ok := tx.TotalGasLimit(preTxCost) + // RIP-7560 maxPossibleGasCost is AA_BASE_GAS_COST plus the four declared + // limits. preTxCost carries the dynamic calldata charges too, which the + // validation frame already takes out of ValidationGasLimit — charging it + // here would precharge more than refundGas and the gas pool ever return. + totalGasLimit, ok := tx.TotalGasLimit(params.TxAAGas) if !ok { return fmt.Errorf("%w: RIP-7560 gas limits sum overflows uint64", protocol.ErrGasLimitReached) } From 1362b2af76ff2c25b0ffe6c96447aac211c611d3 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Wed, 12 Aug 2026 20:16:23 +0700 Subject: [PATCH 7/7] node/privateapi: reject AA gas limits that overflow uint64 AAValidation is a gRPC endpoint, so the four declared limits reach this sum straight off the wire. A wrapped total sized the validation gas pool from a number the transaction never asked for. --- node/privateapi/ethbackend.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/node/privateapi/ethbackend.go b/node/privateapi/ethbackend.go index 7a80043a8d3..b5e6a38cdd6 100644 --- a/node/privateapi/ethbackend.go +++ b/node/privateapi/ethbackend.go @@ -538,7 +538,11 @@ func (s *EthBackendServer) AAValidation(ctx context.Context, req *remoteproto.AA return nil, err } - totalGasLimit := preTxCost + aaTxn.ValidationGasLimit + aaTxn.PaymasterValidationGasLimit + aaTxn.GasLimit + aaTxn.PostOpGasLimit + totalGasLimit, ok := aaTxn.TotalGasLimit(preTxCost) + if !ok { + log.Info("RIP-7560 validation err", "err", "gas limits sum overflows uint64") + return &remoteproto.AAValidationReply{Valid: false}, nil + } _, _, err = aa.ValidateAATransaction(aaTxn, ibs, new(protocol.GasPool).AddGas(totalGasLimit), header, evm, s.chainConfig) if err != nil { log.Info("RIP-7560 validation err", "err", err.Error())