From d30ae29ebabb0510e6f23a05b2c372ef3f4544b6 Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Tue, 1 Sep 2026 10:31:56 -0600 Subject: [PATCH 1/2] fix(mempool): track cumulative committed cost per account (CON-375) Two queued nonces for the same EVM account were each checked against the same static balance snapshot instead of a running total, letting the mempool mark both ready even when their combined cost exceeded the account's real balance. The second tx would then fail ante's real balance check at inclusion, landing on-chain as a status=0/gasUsed=0 stub receipt instead of staying pending. - Add a readyCost accumulator to evmAccount and use it in the readiness-advance loop, same-nonce replacement check, and shouldReject, so all three agree on what's actually affordable. - Fix testEnv.readyTxs()'s oracle in tx_test.go, which had the identical bug, and add two targeted regression tests. - Add an EIP-7702 differential test (CON-411): a delegate/drain/ undelegate sequence produces the same stub receipt via a completely different mechanism (SELFBALANCE-triggered execution-time balance loss, invisible to any admission-time cost estimate). Confirms Giga has no independent code path for this failure class -- it unconditionally falls back to V2 for any fee/nonce/balance validation failure -- and that the fallback produces byte-identical results to V2. --- app/eip7702_selfbalance_drain_test.go | 174 ++++++++++++++++++ giga/tests/eip7702_selfbalance_drain_test.go | 182 +++++++++++++++++++ sei-tendermint/internal/mempool/tx.go | 50 ++++- sei-tendermint/internal/mempool/tx_test.go | 91 +++++++++- 4 files changed, 491 insertions(+), 6 deletions(-) create mode 100644 app/eip7702_selfbalance_drain_test.go create mode 100644 giga/tests/eip7702_selfbalance_drain_test.go diff --git a/app/eip7702_selfbalance_drain_test.go b/app/eip7702_selfbalance_drain_test.go new file mode 100644 index 0000000000..ffe3fcfa68 --- /dev/null +++ b/app/eip7702_selfbalance_drain_test.go @@ -0,0 +1,174 @@ +package app_test + +import ( + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/holiman/uint256" + "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/sei-cosmos/crypto/keys/secp256k1" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" + "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + evmtypes "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/sei-protocol/sei-chain/x/evm/types/ethtx" + "github.com/stretchr/testify/require" +) + +// drainContractCreationCode is the compiled bytecode of: +// +// contract Drain { +// function drain() external { +// (bool ok, ) = payable(0x…dEaD).call{value: address(this).balance}(""); +// require(ok, "drain failed"); +// } +// } +// +// See CON-411 for the source file and the original docker-cluster reproduction. +const drainContractCreationCode = "6080604052348015600e575f5ffd5b5060f58061001b5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c80639890220b14602a575b5f5ffd5b60306032565b005b60405161dead905f90829047908381818185875af1925050503d805f81146073576040519150601f19603f3d011682016040523d82523d5f602084013e6078565b606091505b505090508060bb5760405162461bcd60e51b815260206004820152600c60248201526b191c985a5b8819985a5b195960a21b604482015260640160405180910390fd5b505056fea26469706673582212204a35f48107480beffc2123e3b6502a44bbae2ce9df637b35d8e1c97dd3ca7acf64736f6c63430008240033" + +// drainSelector is the 4-byte selector for drain(). +const drainSelector = "9890220b" + +// signAndEncodeEVMTx converts a signed Ethereum transaction of any supported +// type into a Sei tx envelope ready for ProcessBlock. +func signAndEncodeEVMTx(t *testing.T, a *app.App, signedTx *ethtypes.Transaction) []byte { + t.Helper() + typedTx, err := ethtx.NewTxDataFromTx(signedTx) + require.NoError(t, err) + msg, err := evmtypes.NewMsgEVMTransaction(typedTx) + require.NoError(t, err) + txBuilder := a.GetTxConfig().NewTxBuilder() + require.NoError(t, txBuilder.SetMsgs(msg)) + bz, err := a.GetTxConfig().TxEncoder()(txBuilder.GetTx()) + require.NoError(t, err) + return bz +} + +// This is a regression test to ensure that accounts that lack the upfront balance to execute the transaction are +// executed the same across giga and v2. Specifically, the transaction increments the nonce and generates a +// receipt with status=0, and gasUsed=0. The specific mechanism is a 7702 contract. +func TestEIP7702SelfBalanceDrainProducesStubReceipt(t *testing.T) { + tm := time.Now().UTC() + valPub := secp256k1.GenPrivKey().PubKey() + testWrapper := app.NewTestWrapper(t, tm, valPub, false) + a := testWrapper.App + ctx := testWrapper.Ctx.WithBlockHeight(1) + chainID := a.EvmKeeper.ChainID(ctx) + signer := ethtypes.NewPragueSigner(chainID) + + sponsorKey, err := crypto.GenerateKey() + require.NoError(t, err) + sponsorAddr := crypto.PubkeyToAddress(sponsorKey.PublicKey) + + authorityKey, err := crypto.GenerateKey() + require.NoError(t, err) + authorityAddr := crypto.PubkeyToAddress(authorityKey.PublicKey) + + fund := func(addr common.Address, usei int64) { + coins := sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(usei))) + require.NoError(t, a.BankKeeper.MintCoins(ctx, "evm", coins)) + require.NoError(t, a.BankKeeper.SendCoinsFromModuleToAccount(ctx, "evm", sdk.AccAddress(addr[:]), coins)) + } + fund(sponsorAddr, 10_000_000) // gas money only + fund(authorityAddr, 1_000_000) // the balance drain() will empty + + gasPrice := big.NewInt(100_000_000_000) // 100 gwei, matches other app tests + dead := common.HexToAddress("0x000000000000000000000000000000000000dEaD") + + // tx0 (sponsor nonce 0): deploy Drain. + deployTx, err := ethtypes.SignTx(ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: gasPrice, + Gas: 500_000, + Data: common.FromHex(drainContractCreationCode), + }), signer, sponsorKey) + require.NoError(t, err) + drainAddr := crypto.CreateAddress(sponsorAddr, 0) + + // tx1 (sponsor nonce 1): sponsored authorization delegating authority -> Drain. + // Applying it bumps the authority's own nonce 0 -> 1. + delegateAuth, err := ethtypes.SignSetCode(authorityKey, ethtypes.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(chainID), + Address: drainAddr, + Nonce: 0, + }) + require.NoError(t, err) + delegateTx, err := ethtypes.SignNewTx(sponsorKey, signer, ðtypes.SetCodeTx{ + ChainID: uint256.MustFromBig(chainID), + Nonce: 1, + GasTipCap: uint256.MustFromBig(gasPrice), + GasFeeCap: uint256.MustFromBig(gasPrice), + Gas: 200_000, + To: dead, + AuthList: []ethtypes.SetCodeAuthorization{delegateAuth}, + }) + require.NoError(t, err) + + // tx2 (sponsor nonce 2): call authority, running Drain.drain() in its + // context. Empties authority's balance via SELFBALANCE + CALL. + drainCallTx, err := ethtypes.SignTx(ethtypes.NewTx(ðtypes.LegacyTx{ + Nonce: 2, + GasPrice: gasPrice, + Gas: 200_000, + To: &authorityAddr, + Data: common.FromHex(drainSelector), + }), signer, sponsorKey) + require.NoError(t, err) + + // tx3 (authority nonce 1, self-sponsored): undelegate. Self-sponsored + // auth nonce = tx nonce + 1. Balance is now empty, so BuyGas fails. + undelegateAuth, err := ethtypes.SignSetCode(authorityKey, ethtypes.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(chainID), + Address: common.Address{}, // zero address == undelegate + Nonce: 2, + }) + require.NoError(t, err) + undelegateTx, err := ethtypes.SignNewTx(authorityKey, signer, ðtypes.SetCodeTx{ + ChainID: uint256.MustFromBig(chainID), + Nonce: 1, + GasTipCap: uint256.MustFromBig(gasPrice), + GasFeeCap: uint256.MustFromBig(gasPrice), + Gas: 200_000, + To: dead, + AuthList: []ethtypes.SetCodeAuthorization{undelegateAuth}, + }) + require.NoError(t, err) + + txs := [][]byte{ + signAndEncodeEVMTx(t, a, deployTx), + signAndEncodeEVMTx(t, a, delegateTx), + signAndEncodeEVMTx(t, a, drainCallTx), + signAndEncodeEVMTx(t, a, undelegateTx), + } + + req := &abci.RequestFinalizeBlock{Header: &types.Header{ChainID: "sei-test", Height: 1}} + _, txResults, _, err := a.ProcessBlock(ctx, txs, finalizeToBlockProcessReq(req), req.DecidedLastCommit, false, nil) + require.NoError(t, err) + require.Len(t, txResults, 4) + + require.Equal(t, uint32(0), txResults[0].Code, "deploy should succeed") + require.Equal(t, uint32(0), txResults[1].Code, "delegate should succeed") + require.Equal(t, uint32(0), txResults[2].Code, "drain call should succeed") + + // Ethereum-facing receipt (not the raw Cosmos ExecTxResult) carries the + // stub signature: status=0, gasUsed=0. + require.NotEqual(t, uint32(0), txResults[3].Code, "undelegate should fail ante's real BuyGas check") + receipt, err := a.EvmKeeper.GetTransientReceipt(ctx, undelegateTx.Hash(), 3) + require.NoError(t, err) + require.Equal(t, uint32(0), receipt.Status, "the CON-375/411 stub-receipt signature") + require.Equal(t, uint64(0), receipt.GasUsed, "the CON-375/411 stub-receipt signature") + + require.Equal(t, uint64(2), a.EvmKeeper.GetNonce(ctx, authorityAddr), + "the authority's nonce still advances past the failed undelegate") + + // Ante failed before the auth list was processed, so the undelegate + // never applied. + wantCode := append([]byte{0xef, 0x01, 0x00}, drainAddr.Bytes()...) + require.Equal(t, wantCode, a.EvmKeeper.GetCode(ctx, authorityAddr), + "the authority remains delegated to Drain since the undelegate never applied") +} diff --git a/giga/tests/eip7702_selfbalance_drain_test.go b/giga/tests/eip7702_selfbalance_drain_test.go new file mode 100644 index 0000000000..dd170cd080 --- /dev/null +++ b/giga/tests/eip7702_selfbalance_drain_test.go @@ -0,0 +1,182 @@ +package giga_test + +import ( + "crypto/ecdsa" + "math/big" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/holiman/uint256" + "github.com/sei-protocol/sei-chain/app" + "github.com/sei-protocol/sei-chain/occ_tests/utils" + "github.com/sei-protocol/sei-chain/x/evm/config" + "github.com/sei-protocol/sei-chain/x/evm/types" + "github.com/sei-protocol/sei-chain/x/evm/types/ethtx" + "github.com/stretchr/testify/require" +) + +// drainContractCreationCode is the compiled bytecode of: +// +// contract Drain { +// function drain() external { +// (bool ok, ) = payable(0x…dEaD).call{value: address(this).balance}(""); +// require(ok, "drain failed"); +// } +// } +// +// See CON-411 for the source file and the original docker-cluster +// reproduction, and app/eip7702_selfbalance_drain_test.go for the V2-only +// regression test this mirrors. +const drainContractCreationCode = "6080604052348015600e575f5ffd5b5060f58061001b5f395ff3fe6080604052348015600e575f5ffd5b50600436106026575f3560e01c80639890220b14602a575b5f5ffd5b60306032565b005b60405161dead905f90829047908381818185875af1925050503d805f81146073576040519150601f19603f3d011682016040523d82523d5f602084013e6078565b606091505b505090508060bb5760405162461bcd60e51b815260206004820152600c60248201526b191c985a5b8819985a5b195960a21b604482015260640160405180910390fd5b505056fea26469706673582212204a35f48107480beffc2123e3b6502a44bbae2ce9df637b35d8e1c97dd3ca7acf64736f6c63430008240033" + +// drainSelector is the 4-byte selector for drain(). +const drainSelector = "9890220b" + +// encodeSignedEVMTx converts a signed Ethereum transaction of any supported +// type into a Sei tx envelope ready for RunBlock. +func encodeSignedEVMTx(t *testing.T, signedTx *ethtypes.Transaction) []byte { + t.Helper() + txData, err := ethtx.NewTxDataFromTx(signedTx) + require.NoError(t, err) + msg, err := types.NewMsgEVMTransaction(txData) + require.NoError(t, err) + tc := app.MakeEncodingConfig().TxConfig + txBuilder := tc.NewTxBuilder() + require.NoError(t, txBuilder.SetMsgs(msg)) + bz, err := tc.TxEncoder()(txBuilder.GetTx()) + require.NoError(t, err) + return bz +} + +// buildEIP7702SelfBalanceDrainTxs builds the delegate/drain/undelegate +// sequence, associating both accounts first so Giga uses its native EIP-7702 +// path instead of deferring an unassociated authority to V2. +func buildEIP7702SelfBalanceDrainTxs(t *testing.T, tCtx *GigaTestContext, sponsor, authority utils.TestAcct) (txs [][]byte, drainAddr common.Address) { + t.Helper() + // Three sponsor txs at up to 500,000 gas * 100 gwei each need up to 4.5e16 + // wei; fund generously above that. + fundAccount(t, tCtx, sponsor.AccountAddress, big.NewInt(1_000_000_000_000_000_000)) // gas money only + fundAccount(t, tCtx, authority.AccountAddress, big.NewInt(1_000_000_000_000_000)) // the balance drain() will empty + tCtx.TestApp.EvmKeeper.SetAddressMapping(tCtx.Ctx, sponsor.AccountAddress, sponsor.EvmAddress) + tCtx.TestApp.EvmKeeper.SetAddressMapping(tCtx.Ctx, authority.AccountAddress, authority.EvmAddress) + tCtx.TestApp.GigaEvmKeeper.SetAddressMapping(tCtx.Ctx, sponsor.AccountAddress, sponsor.EvmAddress) + tCtx.TestApp.GigaEvmKeeper.SetAddressMapping(tCtx.Ctx, authority.AccountAddress, authority.EvmAddress) + + chainID := big.NewInt(config.DefaultChainID) + signer := ethtypes.NewPragueSigner(chainID) + gasPrice := big.NewInt(100_000_000_000) // 100 gwei + dead := common.HexToAddress("0x000000000000000000000000000000000000dEaD") + + signLegacy := func(key *ecdsa.PrivateKey, txdata *ethtypes.LegacyTx) *ethtypes.Transaction { + signedTx, err := ethtypes.SignTx(ethtypes.NewTx(txdata), signer, key) + require.NoError(t, err) + return signedTx + } + signSetCode := func(key *ecdsa.PrivateKey, txdata *ethtypes.SetCodeTx) *ethtypes.Transaction { + signedTx, err := ethtypes.SignNewTx(key, signer, txdata) + require.NoError(t, err) + return signedTx + } + + // tx0 (sponsor nonce 0): deploy Drain. + deployTx := signLegacy(sponsor.EvmPrivateKey, ðtypes.LegacyTx{ + Nonce: 0, + GasPrice: gasPrice, + Gas: 500_000, + Data: common.FromHex(drainContractCreationCode), + }) + drainAddr = crypto.CreateAddress(sponsor.EvmAddress, 0) + + // tx1 (sponsor nonce 1): sponsored authorization delegating authority -> Drain. + // Applying it bumps the authority's own nonce 0 -> 1. + delegateAuth, err := ethtypes.SignSetCode(authority.EvmPrivateKey, ethtypes.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(chainID), + Address: drainAddr, + Nonce: 0, + }) + require.NoError(t, err) + delegateTx := signSetCode(sponsor.EvmPrivateKey, ðtypes.SetCodeTx{ + ChainID: uint256.MustFromBig(chainID), + Nonce: 1, + GasTipCap: uint256.MustFromBig(gasPrice), + GasFeeCap: uint256.MustFromBig(gasPrice), + Gas: 200_000, + To: dead, + AuthList: []ethtypes.SetCodeAuthorization{delegateAuth}, + }) + + // tx2 (sponsor nonce 2): call authority, running Drain.drain() in its + // context. Empties authority's balance via SELFBALANCE + CALL. + drainCallTx := signLegacy(sponsor.EvmPrivateKey, ðtypes.LegacyTx{ + Nonce: 2, + GasPrice: gasPrice, + Gas: 200_000, + To: &authority.EvmAddress, + Data: common.FromHex(drainSelector), + }) + + // tx3 (authority nonce 1, self-sponsored): undelegate. Self-sponsored + // auth nonce = tx nonce + 1. Balance is now empty, so the fee check fails. + undelegateAuth, err := ethtypes.SignSetCode(authority.EvmPrivateKey, ethtypes.SetCodeAuthorization{ + ChainID: *uint256.MustFromBig(chainID), + Address: common.Address{}, // zero address == undelegate + Nonce: 2, + }) + require.NoError(t, err) + undelegateTx := signSetCode(authority.EvmPrivateKey, ðtypes.SetCodeTx{ + ChainID: uint256.MustFromBig(chainID), + Nonce: 1, + GasTipCap: uint256.MustFromBig(gasPrice), + GasFeeCap: uint256.MustFromBig(gasPrice), + Gas: 200_000, + To: dead, + AuthList: []ethtypes.SetCodeAuthorization{undelegateAuth}, + }) + + return [][]byte{ + encodeSignedEVMTx(t, deployTx), + encodeSignedEVMTx(t, delegateTx), + encodeSignedEVMTx(t, drainCallTx), + encodeSignedEVMTx(t, undelegateTx), + }, drainAddr +} + +// TestGigaValidation_EIP7702SelfBalanceDrain_LastResultsHash is CON-411's +// Giga-differential test. Any fee/nonce/balance validation failure makes +// Giga abort the whole batch and re-run it through V2 (app/app.go, +// executeEVMTxWithGigaExecutor), so this checks that hand-off is lossless +// rather than testing independent Giga logic -- there is none for this +// failure class. +func TestGigaValidation_EIP7702SelfBalanceDrain_LastResultsHash(t *testing.T) { + runCase := func(t *testing.T, gigaMode ExecutorMode) { + blockTime := time.Now() + accts := utils.NewTestAccounts(3) + sponsor := utils.NewSigner() + authority := utils.NewSigner() + + v2Ctx := NewGigaTestContext(t, accts, blockTime, 1, ModeV2Sequential) + v2Txs, _ := buildEIP7702SelfBalanceDrainTxs(t, v2Ctx, sponsor, authority) + _, v2Results, err := RunBlock(t, v2Ctx, v2Txs) + require.NoError(t, err) + require.Len(t, v2Results, 4) + require.Equal(t, uint32(0), v2Results[0].Code, "V2: deploy should succeed") + require.Equal(t, uint32(0), v2Results[1].Code, "V2: delegate should succeed") + require.Equal(t, uint32(0), v2Results[2].Code, "V2: drain call should succeed") + require.NotEqual(t, uint32(0), v2Results[3].Code, "V2: undelegate should fail its real balance check") + + gigaCtx := NewGigaTestContext(t, accts, blockTime, 1, gigaMode) + gigaTxs, _ := buildEIP7702SelfBalanceDrainTxs(t, gigaCtx, sponsor, authority) + _, gigaResults, err := RunBlock(t, gigaCtx, gigaTxs) + require.NoError(t, err) + require.Len(t, gigaResults, 4) + + CompareDeterministicFields(t, "EIP7702SelfBalanceDrain/"+gigaMode.String(), v2Results, gigaResults) + CompareLastResultsHash(t, "EIP7702SelfBalanceDrain/"+gigaMode.String(), v2Results, gigaResults) + } + for _, mode := range []ExecutorMode{ModeGigaSequential, ModeGigaOCC} { + t.Run(mode.String(), func(t *testing.T) { runCase(t, mode) }) + } +} diff --git a/sei-tendermint/internal/mempool/tx.go b/sei-tendermint/internal/mempool/tx.go index 7b53d436ba..2229dde7ba 100644 --- a/sei-tendermint/internal/mempool/tx.go +++ b/sei-tendermint/internal/mempool/tx.go @@ -91,6 +91,45 @@ type evmAccount struct { balance uint256.Int firstNonce uint64 nextNonce uint64 + // readyCost is the sum of requiredBalance over all nonces in + // [firstNonce, nextNonce) currently marked ready for this account. + readyCost uint256.Int +} + +// hasSufficientBalance reports whether committing cost on top of everything +// already ready for this account still fits within its balance. +func (a *evmAccount) hasSufficientBalance(cost *uint256.Int) bool { + sum, overflow := new(uint256.Int).AddOverflow(&a.readyCost, cost) + return !overflow && a.balance.Cmp(sum) >= 0 +} + +// canReplace reports whether swapping a ready nonce's committed cost from +// oldCost to newCost keeps the account within balance. oldCost must already +// be included in readyCost, i.e. the tx being replaced must itself be ready. +func (a *evmAccount) canReplace(oldCost, newCost *uint256.Int) bool { + remaining, underflow := new(uint256.Int).SubOverflow(&a.readyCost, oldCost) + if underflow { + return false + } + sum, overflow := new(uint256.Int).AddOverflow(remaining, newCost) + return !overflow && a.balance.Cmp(sum) >= 0 +} + +// commitReady records that cost is now committed against a newly-ready nonce. +func (a *evmAccount) commitReady(cost *uint256.Int) { + a.readyCost.Add(&a.readyCost, cost) +} + +// replaceReady swaps the committed cost of an already-ready nonce. Callers +// must have already validated the swap via canReplace with the same costs. +func (a *evmAccount) replaceReady(oldCost, newCost *uint256.Int) { + if _, underflow := new(uint256.Int).SubOverflow(&a.readyCost, oldCost); underflow { + // canReplace should have rejected this swap already; treat as a + // no-op rather than corrupting readyCost for the account's lifetime. + return + } + a.readyCost.Sub(&a.readyCost, oldCost) + a.readyCost.Add(&a.readyCost, newCost) } type txCounter struct { @@ -342,7 +381,7 @@ func (inner *txStoreInner) shouldReject(txHash types.TxHash) bool { oldEvm := old.evm.OrPanic("non-evm tx") oldReady := oldEvm.nonce < account.nextNonce // If the old tx is ready but the new tx is not, then reject the new tx. - if oldReady && account.balance.Cmp(&evm.requiredBalance) < 0 { + if oldReady && !account.canReplace(&oldEvm.requiredBalance, &evm.requiredBalance) { return true } // If the old tx has >= priority, then reject new tx. @@ -365,7 +404,7 @@ func (s *txStore) insert(inner *txStoreInner, wtx *WrappedTx, recordAdded bool) // TODO(gprusak): consider whether we should move these queries out of the mutex. b := s.app.EvmBalance(evm.address, evm.seiAddress) n := s.app.EvmNonce(evm.address) - account = &evmAccount{b, n, n} + account = &evmAccount{balance: b, firstNonce: n, nextNonce: n} inner.accounts[evm.address] = account } // Reject transactions with old nonces. @@ -391,7 +430,7 @@ func (s *txStore) insert(inner *txStoreInner, wtx *WrappedTx, recordAdded bool) oldEvm := old.evm.OrPanic("non-evm tx") oldReady := oldEvm.nonce < account.nextNonce // If the old tx is ready but the new tx is not, then reject the new tx. - if oldReady && account.balance.Cmp(&evm.requiredBalance) < 0 { + if oldReady && !account.canReplace(&oldEvm.requiredBalance, &evm.requiredBalance) { return errSameNonce } // If the old tx has >= priority, then reject new tx. @@ -407,6 +446,7 @@ func (s *txStore) insert(inner *txStoreInner, wtx *WrappedTx, recordAdded bool) s.readyTxs.Remove(el) } if oldReady { + account.replaceReady(&oldEvm.requiredBalance, &evm.requiredBalance) state.ready.Dec(old.Size()) state.ready.Inc(wtx.Size()) s.priorityReservoir.Add(wtx.priority) @@ -424,9 +464,10 @@ func (s *txStore) insert(inner *txStoreInner, wtx *WrappedTx, recordAdded bool) break } requiredBalance := wtx.evm.OrPanic("non-evm tx").requiredBalance - if account.balance.Cmp(&requiredBalance) < 0 { + if !account.hasSufficientBalance(&requiredBalance) { break } + account.commitReady(&requiredBalance) account.nextNonce += 1 state.ready.Inc(wtx.Size()) if !wtx.readyEl.IsPresent() { @@ -541,6 +582,7 @@ func (s *txStore) compact(inner *txStoreInner, clearAccounts bool) { } for _, account := range inner.accounts { account.nextNonce = account.firstNonce + account.readyCost = uint256.Int{} } for _, wtx := range wtxs { total := inner.state.Load().total diff --git a/sei-tendermint/internal/mempool/tx_test.go b/sei-tendermint/internal/mempool/tx_test.go index 79d7c87bd6..6cdf14760e 100644 --- a/sei-tendermint/internal/mempool/tx_test.go +++ b/sei-tendermint/internal/mempool/tx_test.go @@ -122,16 +122,19 @@ func (e *testEnv) readyTxs() []*WrappedTx { for _, account := range e.accounts { byNonce := e.byNonce(account) currentNonce := e.app.EvmNonce(account.address) - balance := e.app.balanceOf(account.address) + balance := uint256.NewInt(uint64(e.app.balanceOf(account.address))) + committed := uint256.NewInt(0) for nonce := currentNonce; ; nonce++ { wtx, ok := byNonce[nonce] if !ok { break } requiredBalance := wtx.evm.OrPanic("").requiredBalance - if requiredBalance.CmpUint64(uint64(balance)) > 0 { + next := new(uint256.Int).Add(committed, &requiredBalance) + if next.Cmp(balance) > 0 { break } + committed = next ready = append(ready, wtx) } } @@ -663,6 +666,90 @@ func TestTxStore_ReplacesReadyTxByHigherPriority(t *testing.T) { require.False(t, ok) } +func TestTxStore_ReadyAdvanceRespectsCumulativeBalance(t *testing.T) { + rng := utils.TestRng() + app := newEVMNonceApp() + txStore := NewTxStore(TestConfig(), proxy.New(app)) + env := newTestEnv(rng, txStore, app, 1) + address := env.accounts[0].address + env.app.setNonce(address, 7) + env.app.setBalance(address, 100) + + // Each tx is individually affordable (60 <= 100) but their combined cost + // (120) exceeds the account's balance, so only the first should become + // ready. + first := makeEvmTxForTest(rng, address, 7, 10, 60) + second := makeEvmTxForTest(rng, address, 8, 10, 60) + require.NoError(t, env.txStore.Insert(first)) + require.NoError(t, env.txStore.Insert(second)) + env.byHash = map[types.TxHash]*WrappedTx{first.Hash(): first, second.Hash(): second} + env.markReadyTxs() + env.assertState(t) + require.Equal(t, uint64(8), env.txStore.NextNonce(address)) + + // Once the account's real balance grows enough to cover both, a compact + // (triggered here via Update) re-derives readiness from scratch and the + // second nonce becomes ready too. + env.app.setBalance(address, 200) + env.txStore.Update(updateSpec{ + Now: time.Now(), + Height: 1, + TxResults: map[types.TxHash]bool{}, + Constraints: NopTxConstraints(), + NewPriorities: map[types.TxHash]int64{}, + }) + env.markReadyTxs() + env.assertState(t) + require.Equal(t, uint64(9), env.txStore.NextNonce(address)) +} + +func TestTxStore_ReplacesReadyTxRespectsCumulativeBalance(t *testing.T) { + rng := utils.TestRng() + app := newEVMNonceApp() + txStore := NewTxStore(TestConfig(), proxy.New(app)) + env := newTestEnv(rng, txStore, app, 1) + address := env.accounts[0].address + env.app.setNonce(address, 7) + env.app.setBalance(address, 100) + + // nonce 7 (cost 30) and nonce 8 (cost 50) are both ready; combined cost + // (80) already accounts for most of the balance. + first := makeEvmTxForTest(rng, address, 7, 10, 30) + second := makeEvmTxForTest(rng, address, 8, 10, 50) + require.NoError(t, env.txStore.Insert(first)) + require.NoError(t, env.txStore.Insert(second)) + env.byHash = map[types.TxHash]*WrappedTx{first.Hash(): first, second.Hash(): second} + env.markReadyTxs() + env.assertState(t) + + // A higher-priority replacement for nonce 7 costing 80 would push the + // combined cost (80+50=130) over balance, even though 80 alone fits. + // The replacement must be rejected and the store left unchanged. + tooExpensive := makeEvmTxForTest(rng, address, 7, 20, 80) + require.ErrorIs(t, env.txStore.Insert(tooExpensive), errSameNonce) + env.assertState(t) + + // A higher-priority replacement costing 40 fits (40+50=90 <= 100) and + // must succeed, updating the account's committed cost accordingly. + replacement := makeEvmTxForTest(rng, address, 7, 20, 40) + require.NoError(t, env.txStore.Insert(replacement)) + delete(env.byHash, first.Hash()) + env.byHash[replacement.Hash()] = replacement + env.markReadyTxs() + env.assertState(t) + + // If the swap had left readyCost stale (e.g. still counting the + // replaced tx's cost), a new nonce 9 costing 10 would wrongly be + // rejected from readiness (90+10=100 exactly fits; a stale 100+10 would + // not). + third := makeEvmTxForTest(rng, address, 9, 10, 10) + require.NoError(t, env.txStore.Insert(third)) + env.byHash[third.Hash()] = third + env.markReadyTxs() + env.assertState(t) + require.Equal(t, uint64(10), env.txStore.NextNonce(address)) +} + func TestTxStore_RejectsDuplicateEvmHash(t *testing.T) { rng := utils.TestRng() app := newEVMNonceApp() From b02ed0c1e9005ab104b6ecee40e7262c6137ab8e Mon Sep 17 00:00:00 2001 From: Danno Ferrin Date: Wed, 2 Sep 2026 15:02:57 -0600 Subject: [PATCH 2/2] increase balance for 7702 delegate --- giga/tests/eip7702_selfbalance_drain_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/giga/tests/eip7702_selfbalance_drain_test.go b/giga/tests/eip7702_selfbalance_drain_test.go index dd170cd080..a60450f21e 100644 --- a/giga/tests/eip7702_selfbalance_drain_test.go +++ b/giga/tests/eip7702_selfbalance_drain_test.go @@ -58,8 +58,8 @@ func buildEIP7702SelfBalanceDrainTxs(t *testing.T, tCtx *GigaTestContext, sponso t.Helper() // Three sponsor txs at up to 500,000 gas * 100 gwei each need up to 4.5e16 // wei; fund generously above that. - fundAccount(t, tCtx, sponsor.AccountAddress, big.NewInt(1_000_000_000_000_000_000)) // gas money only - fundAccount(t, tCtx, authority.AccountAddress, big.NewInt(1_000_000_000_000_000)) // the balance drain() will empty + fundAccount(t, tCtx, sponsor.AccountAddress, big.NewInt(1_000_000_000_000_000_000)) // gas money only + fundAccount(t, tCtx, authority.AccountAddress, big.NewInt(1_000_000_000_000_000_000)) // the balance drain() will empty tCtx.TestApp.EvmKeeper.SetAddressMapping(tCtx.Ctx, sponsor.AccountAddress, sponsor.EvmAddress) tCtx.TestApp.EvmKeeper.SetAddressMapping(tCtx.Ctx, authority.AccountAddress, authority.EvmAddress) tCtx.TestApp.GigaEvmKeeper.SetAddressMapping(tCtx.Ctx, sponsor.AccountAddress, sponsor.EvmAddress)