From 6cd2d5139d8ee680c342a8ad2ff8c2f2dbda9d7f Mon Sep 17 00:00:00 2001 From: mattkii <142337545+mattkii@users.noreply.github.com> Date: Sun, 4 Oct 2026 17:59:28 -0700 Subject: [PATCH 1/5] feat: refund unused EVM gas in the paid denom --- .../integration/x/vm/test_state_transition.go | 1 + x/vm/keeper/gas.go | 32 ++++++++++++++++++- x/vm/keeper/state_transition.go | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/integration/x/vm/test_state_transition.go b/tests/integration/x/vm/test_state_transition.go index d6ff6623..9b9f2bf6 100644 --- a/tests/integration/x/vm/test_state_transition.go +++ b/tests/integration/x/vm/test_state_transition.go @@ -521,6 +521,7 @@ func (s *KeeperTestSuite) TestRefundGas() { unitNetwork.GetContext(), *coreMsg, refund, + gasUsed, unitNetwork.GetBaseDenom(), ) diff --git a/x/vm/keeper/gas.go b/x/vm/keeper/gas.go index c885fd12..c6b8820e 100644 --- a/x/vm/keeper/gas.go +++ b/x/vm/keeper/gas.go @@ -19,6 +19,10 @@ import ( authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" ) +// ContextPaidFeesKey stores the coins actually charged for an EVM tx. +// Fee abstraction sets this so the refund uses that denom, capped by the amount paid. +type ContextPaidFeesKey struct{} + // GetEthIntrinsicGas returns the intrinsic gas cost for the transaction func (k *Keeper) GetEthIntrinsicGas(ctx sdk.Context, msg core.Message, cfg *params.ChainConfig, isContractCreation bool, @@ -40,10 +44,15 @@ func (k *Keeper) GetEthIntrinsicGas(ctx sdk.Context, msg core.Message, cfg *para // consumed in the transaction. Additionally, the function sets the total gas consumed to the value // returned by the EVM execution, thus ignoring the previous intrinsic gas consumed during in the // AnteHandler. -func (k *Keeper) RefundGas(ctx sdk.Context, msg core.Message, leftoverGas uint64, denom string) (err error) { +func (k *Keeper) RefundGas(ctx sdk.Context, msg core.Message, leftoverGas, gasUsed uint64, denom string) (err error) { ctx, span := ctx.StartSpan(tracer, "RefundGas", trace.WithAttributes(attribute.Int64("leftover_gas", int64(leftoverGas)))) //nolint:gosec // G115 defer func() { evmtrace.EndSpanErr(span, err) }() + // No gas was consumed, so there is nothing to refund against. + if gasUsed == 0 { + return nil + } + // Return EVM tokens for remaining gas, exchanged at the original rate. remaining := new(big.Int).Mul(new(big.Int).SetUint64(leftoverGas), msg.GasPrice) @@ -52,6 +61,27 @@ func (k *Keeper) RefundGas(ctx sdk.Context, msg core.Message, leftoverGas uint64 // negative refund errors return errorsmod.Wrapf(types.ErrInvalidRefund, "refunded amount value cannot be negative %d", remaining.Int64()) case 1: + // Fee abstraction records the coin that was actually charged. + // Refund that coin, scaled by leftover gas over the tx gas limit, + // so the sender never receives more than was paid. + if val := ctx.Value(ContextPaidFeesKey{}); val != nil { + if paidCoins, ok := val.(sdk.Coins); ok && !paidCoins.IsZero() { + if len(paidCoins) != 1 { + return errorsmod.Wrapf(types.ErrInvalidRefund, "expected a single coin for EVM refunds, got %d", len(paidCoins)) + } + paidCoin := paidCoins[0] + denom = paidCoin.Denom + remaining = new(big.Int).Div( + new(big.Int).Mul(paidCoin.Amount.BigInt(), new(big.Int).SetUint64(leftoverGas)), + new(big.Int).SetUint64(msg.GasLimit), + ) + } + } + + if remaining.Sign() <= 0 { + return nil + } + // positive amount refund refundedCoins := sdk.Coins{sdk.NewCoin(denom, sdkmath.NewIntFromBigInt(remaining))} diff --git a/x/vm/keeper/state_transition.go b/x/vm/keeper/state_transition.go index 0c160b43..dab023b2 100644 --- a/x/vm/keeper/state_transition.go +++ b/x/vm/keeper/state_transition.go @@ -321,7 +321,7 @@ func (k *Keeper) ApplyTransaction(ctx sdk.Context, tx *ethtypes.Transaction) (_ if msg.GasLimit > res.GasUsed { remainingGas = msg.GasLimit - res.GasUsed } - if err = k.RefundGas(ctx, *msg, remainingGas, types.GetEVMCoinDenom()); err != nil { + if err = k.RefundGas(ctx, *msg, remainingGas, res.GasUsed, types.GetEVMCoinDenom()); err != nil { return nil, errorsmod.Wrapf(err, "failed to refund leftover gas to sender %s", msg.From) } From 685a608218ca1d123ac2134df847d3f4b7248d36 Mon Sep 17 00:00:00 2001 From: mattkii <142337545+mattkii@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:03:23 -0700 Subject: [PATCH 2/5] feat: reject contract creation on non-base accounts --- mempool/interface.go | 1 + mempool/mocks/VMKeeperI.go | 18 ++++++++++ x/erc20/types/interfaces.go | 1 + x/erc20/types/mocks/EVMKeeper.go | 18 ++++++++++ x/ibc/callbacks/types/expected_keepers.go | 1 + x/vm/keeper/statedb.go | 12 +++++++ x/vm/statedb/commit_atomicity_test.go | 4 +++ x/vm/statedb/interfaces.go | 4 +++ x/vm/statedb/mock_test.go | 4 +++ x/vm/statedb/statedb.go | 3 ++ x/vm/statedb/statedb_test.go | 40 +++++++++++++++++++++++ x/vm/types/mocks/EVMKeeper.go | 24 ++++++++++++-- 12 files changed, 128 insertions(+), 2 deletions(-) diff --git a/mempool/interface.go b/mempool/interface.go index c255d8b5..a2edb13a 100644 --- a/mempool/interface.go +++ b/mempool/interface.go @@ -17,6 +17,7 @@ type VMKeeperI interface { GetParams(ctx sdk.Context) (params vmtypes.Params) GetEvmCoinInfo(ctx sdk.Context) (coinInfo vmtypes.EvmCoinInfo) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/mempool/mocks/VMKeeperI.go b/mempool/mocks/VMKeeperI.go index 47fa07b6..835942a9 100644 --- a/mempool/mocks/VMKeeperI.go +++ b/mempool/mocks/VMKeeperI.go @@ -76,6 +76,24 @@ func (_m *VMKeeperI) GetAccount(ctx types.Context, addr common.Address) *statedb return r0 } +// IsBaseAccountOrEmpty provides a mock function with given fields: ctx, addr +func (_m *VMKeeperI) IsBaseAccountOrEmpty(ctx types.Context, addr common.Address) bool { + ret := _m.Called(ctx, addr) + + if len(ret) == 0 { + panic("no return value specified for IsBaseAccountOrEmpty") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(types.Context, common.Address) bool); ok { + r0 = rf(ctx, addr) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // GetBaseFee provides a mock function with given fields: ctx func (_m *VMKeeperI) GetBaseFee(ctx types.Context) *big.Int { ret := _m.Called(ctx) diff --git a/x/erc20/types/interfaces.go b/x/erc20/types/interfaces.go index 446a5801..2daba1dc 100644 --- a/x/erc20/types/interfaces.go +++ b/x/erc20/types/interfaces.go @@ -51,6 +51,7 @@ type EVMKeeper interface { SetCode(ctx sdk.Context, hash []byte, bytecode []byte) SetAccount(ctx sdk.Context, address common.Address, account statedb.Account) error GetAccount(ctx sdk.Context, address common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, address common.Address) bool IsContract(ctx sdk.Context, address common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/x/erc20/types/mocks/EVMKeeper.go b/x/erc20/types/mocks/EVMKeeper.go index f2936784..ff97d3a5 100644 --- a/x/erc20/types/mocks/EVMKeeper.go +++ b/x/erc20/types/mocks/EVMKeeper.go @@ -207,6 +207,24 @@ func (_m *EVMKeeper) GetAccount(ctx types.Context, address common.Address) *stat return r0 } +// IsBaseAccountOrEmpty provides a mock function with given fields: ctx, address +func (_m *EVMKeeper) IsBaseAccountOrEmpty(ctx types.Context, address common.Address) bool { + ret := _m.Called(ctx, address) + + if len(ret) == 0 { + panic("no return value specified for IsBaseAccountOrEmpty") + } + + var r0 bool + if rf, ok := ret.Get(0).(func(types.Context, common.Address) bool); ok { + r0 = rf(ctx, address) + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + // GetAccountWithoutBalance provides a mock function with given fields: ctx, addr func (_m *EVMKeeper) GetAccountWithoutBalance(ctx types.Context, addr common.Address) *statedb.Account { ret := _m.Called(ctx, addr) diff --git a/x/ibc/callbacks/types/expected_keepers.go b/x/ibc/callbacks/types/expected_keepers.go index 749adbe3..4692c96d 100644 --- a/x/ibc/callbacks/types/expected_keepers.go +++ b/x/ibc/callbacks/types/expected_keepers.go @@ -28,6 +28,7 @@ type EVMKeeper interface { CallEVMWithData(ctx sdk.Context, stateDB *statedb.StateDB, from common.Address, contract *common.Address, data []byte, commit bool, callFromPrecompile bool, gasCap *big.Int) (*evmtypes.MsgEthereumTxResponse, error) GetAccountOrEmpty(ctx sdk.Context, addr common.Address) statedb.Account GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool IsContract(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte diff --git a/x/vm/keeper/statedb.go b/x/vm/keeper/statedb.go index 4b6ddaf5..e6dce21d 100644 --- a/x/vm/keeper/statedb.go +++ b/x/vm/keeper/statedb.go @@ -47,6 +47,18 @@ func (k *Keeper) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Accou ) } +// IsBaseAccountOrEmpty reports whether addr has no Cosmos account yet, or +// carries exactly the plain BaseAccount type. CreateAccount uses it to reject +// EVM contract deployment onto vesting, module, or other non-base accounts. +func (k *Keeper) IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool { + acct := k.accountKeeper.GetAccount(ctx, addr.Bytes()) + if acct == nil { + return true + } + _, ok := acct.(*authtypes.BaseAccount) + return ok +} + // GetState loads contract state from database. func (k *Keeper) GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash { ctx, span := ctx.StartSpan(tracer, "GetState", trace.WithAttributes( diff --git a/x/vm/statedb/commit_atomicity_test.go b/x/vm/statedb/commit_atomicity_test.go index eb5c12b3..2f8d122b 100644 --- a/x/vm/statedb/commit_atomicity_test.go +++ b/x/vm/statedb/commit_atomicity_test.go @@ -29,6 +29,10 @@ var _ statedb.Keeper = &atomicTestKeeper{} func (k *atomicTestKeeper) store(ctx sdk.Context) storetypes.KVStore { return ctx.KVStore(k.key) } +func (k *atomicTestKeeper) IsBaseAccountOrEmpty(_ sdk.Context, _ common.Address) bool { + return true +} + func (k *atomicTestKeeper) GetAccount(ctx sdk.Context, addr common.Address) *statedb.Account { bz := k.store(ctx).Get(addr.Bytes()) if bz == nil { diff --git a/x/vm/statedb/interfaces.go b/x/vm/statedb/interfaces.go index 1f9cb086..d5aca0f4 100644 --- a/x/vm/statedb/interfaces.go +++ b/x/vm/statedb/interfaces.go @@ -22,6 +22,10 @@ type ExtStateDB interface { type Keeper interface { // Read methods GetAccount(ctx sdk.Context, addr common.Address) *Account + // IsBaseAccountOrEmpty reports whether addr has no Cosmos account yet, or + // carries exactly the plain BaseAccount type. EVM contract code may only + // attach to such an address. + IsBaseAccountOrEmpty(ctx sdk.Context, addr common.Address) bool GetState(ctx sdk.Context, addr common.Address, key common.Hash) common.Hash GetCode(ctx sdk.Context, codeHash common.Hash) []byte GetCodeHash(ctx sdk.Context, addr common.Address) common.Hash diff --git a/x/vm/statedb/mock_test.go b/x/vm/statedb/mock_test.go index b39d93bb..6bf6ea73 100644 --- a/x/vm/statedb/mock_test.go +++ b/x/vm/statedb/mock_test.go @@ -29,6 +29,10 @@ type MockKeeper struct { codes map[common.Hash][]byte } +func (k MockKeeper) IsBaseAccountOrEmpty(_ sdk.Context, _ common.Address) bool { + return true +} + func (k MockKeeper) GetCodeHash(_ sdk.Context, addr common.Address) common.Hash { return common.HexToHash(addr.Hex()) } diff --git a/x/vm/statedb/statedb.go b/x/vm/statedb/statedb.go index 42b69f51..0ac0e935 100644 --- a/x/vm/statedb/statedb.go +++ b/x/vm/statedb/statedb.go @@ -426,6 +426,9 @@ func (s *StateDB) createObject(addr common.Address) *stateObject { // pre-funded-then-deployed flow is preserved without any balance carry-over // here. See go-ethereum PR #29520. func (s *StateDB) CreateAccount(addr common.Address) { + if !s.keeper.IsBaseAccountOrEmpty(s.ctx, addr) { + panic(fmt.Sprintf("cannot deploy EVM contract on top of non-base account %s", addr.Hex())) + } s.createObject(addr) } diff --git a/x/vm/statedb/statedb_test.go b/x/vm/statedb/statedb_test.go index d0330193..61c90c2a 100644 --- a/x/vm/statedb/statedb_test.go +++ b/x/vm/statedb/statedb_test.go @@ -196,6 +196,46 @@ func (suite *StateDBTestSuite) TestSubBalanceUnderflowPanics() { }) } +func (suite *StateDBTestSuite) TestCreateAccountBlocksNonBaseAccountTypes() { + testCases := []struct { + name string + malleate func(*mocks.EVMKeeper, common.Address) + expectPanic bool + }{ + {"no account", func(*mocks.EVMKeeper, common.Address) {}, false}, + {"empty BaseAccount", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(0), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + }, false}, + {"funded BaseAccount", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(100), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + }, false}, + {"non-BaseAccount type present", func(k *mocks.EVMKeeper, addr common.Address) { + err := k.SetAccount(sdk.Context{}, addr, statedb.Account{Balance: uint256.NewInt(2), CodeHash: mocks.EmptyCodeHash}) + suite.Require().NoError(err) + k.SetBlockedAccountType(addr, true) + }, true}, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + keeper := mocks.NewEVMKeeper() + addr := common.BigToAddress(big.NewInt(200)) + tc.malleate(keeper, addr) + + db := statedb.New(newTestCtx(), keeper, emptyTxConfig) + createAccount := func() { db.CreateAccount(addr) } + + if tc.expectPanic { + suite.Require().Panics(createAccount) + } else { + suite.Require().NotPanics(createAccount) + } + }) + } +} + func (suite *StateDBTestSuite) TestState() { key1 := common.BigToHash(big.NewInt(1)) value1 := common.BigToHash(big.NewInt(1)) diff --git a/x/vm/types/mocks/EVMKeeper.go b/x/vm/types/mocks/EVMKeeper.go index 9f174a4d..50196169 100644 --- a/x/vm/types/mocks/EVMKeeper.go +++ b/x/vm/types/mocks/EVMKeeper.go @@ -22,8 +22,9 @@ var ( ) type Account struct { - account statedb.Account - states statedb.Storage + account statedb.Account + states statedb.Storage + blockedAccountType bool } type EVMKeeper struct { @@ -48,6 +49,25 @@ func (k EVMKeeper) GetAccount(_ sdk.Context, addr common.Address) *statedb.Accou return &acct.account } +// IsBaseAccountOrEmpty reports true unless the address was marked with SetBlockedAccountType. +func (k EVMKeeper) IsBaseAccountOrEmpty(_ sdk.Context, addr common.Address) bool { + acct, ok := k.accounts[addr] + if !ok { + return true + } + return !acct.blockedAccountType +} + +// SetBlockedAccountType marks addr as a non-base Cosmos account for CreateAccount tests. +func (k EVMKeeper) SetBlockedAccountType(addr common.Address, blocked bool) { + acct, exists := k.accounts[addr] + if !exists { + acct = Account{states: make(statedb.Storage)} + } + acct.blockedAccountType = blocked + k.accounts[addr] = acct +} + func (k EVMKeeper) GetState(_ sdk.Context, addr common.Address, key common.Hash) common.Hash { return k.accounts[addr].states[key] } From 634014f29c152c86e53ae3fed76e070ee8f696ae Mon Sep 17 00:00:00 2001 From: mattkii <142337545+mattkii@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:03:27 -0700 Subject: [PATCH 3/5] fix: skip non-20-byte accounts when mirroring balances --- precompiles/common/balance_handler.go | 12 ++++++++---- precompiles/distribution/tx.go | 8 ++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/precompiles/common/balance_handler.go b/precompiles/common/balance_handler.go index a90580a1..396e5fb6 100644 --- a/precompiles/common/balance_handler.go +++ b/precompiles/common/balance_handler.go @@ -76,8 +76,8 @@ func (bh *BalanceHandler) AfterBalanceChange(ctx sdk.Context, stateDB *statedb.S if err != nil { return fmt.Errorf("failed to parse spender address from event %q: %w", banktypes.EventTypeCoinSpent, err) } - if bh.bankKeeper.BlockedAddr(spenderAddr) { - // Bypass blocked addresses + if bh.bankKeeper.BlockedAddr(spenderAddr) || !isMirrorableEVMAddress(spenderAddr) { + // Bypass blocked addresses and accounts that are not 20 bytes. continue } @@ -93,8 +93,8 @@ func (bh *BalanceHandler) AfterBalanceChange(ctx sdk.Context, stateDB *statedb.S if err != nil { return fmt.Errorf("failed to parse receiver address from event %q: %w", banktypes.EventTypeCoinReceived, err) } - if bh.bankKeeper.BlockedAddr(receiverAddr) { - // Bypass blocked addresses + if bh.bankKeeper.BlockedAddr(receiverAddr) || !isMirrorableEVMAddress(receiverAddr) { + // Bypass blocked addresses and accounts that are not 20 bytes. continue } @@ -113,3 +113,7 @@ func (bh *BalanceHandler) AfterBalanceChange(ctx sdk.Context, stateDB *statedb.S return nil } + +func isMirrorableEVMAddress(addr sdk.AccAddress) bool { + return len(addr) == common.AddressLength +} diff --git a/precompiles/distribution/tx.go b/precompiles/distribution/tx.go index 3dea64f0..bc1e9a04 100644 --- a/precompiles/distribution/tx.go +++ b/precompiles/distribution/tx.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/vm" cmn "github.com/cosmos/evm/precompiles/common" @@ -102,6 +103,13 @@ func (p Precompile) SetWithdrawAddress( return nil, fmt.Errorf(cmn.ErrRequesterIsNotMsgSender, msgSender.String(), delegatorHexAddr.String()) } + if withdrawAddr, decErr := p.addrCdc.StringToBytes(msg.WithdrawAddress); decErr == nil && len(withdrawAddr) != common.AddressLength { + return nil, fmt.Errorf( + "withdraw address must be exactly %d bytes to be EVM-compatible; got %d bytes: %s", + common.AddressLength, len(withdrawAddr), msg.WithdrawAddress, + ) + } + if _, err = p.distributionMsgServer.SetWithdrawAddress(ctx, msg); err != nil { return nil, err } From a4309511ac447fc0f0165c5a64360b1dd248959f Mon Sep 17 00:00:00 2001 From: mattkii <142337545+mattkii@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:03:27 -0700 Subject: [PATCH 4/5] fix: use the sign-doc chain id for EIP-712 --- ethereum/eip712/encoding.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/ethereum/eip712/encoding.go b/ethereum/eip712/encoding.go index 659c7239..b052fc98 100644 --- a/ethereum/eip712/encoding.go +++ b/ethereum/eip712/encoding.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" apitypes "github.com/ethereum/go-ethereum/signer/core/apitypes" @@ -109,7 +110,7 @@ func decodeAminoSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { } typedData, err := WrapTxToTypedData( - eip155ChainID, + chainIDFromSignDoc(aminoDoc.ChainID), signDocBytes, ) if err != nil { @@ -203,7 +204,7 @@ func decodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { } typedData, err := WrapTxToTypedData( - eip155ChainID, + chainIDFromSignDoc(signDoc.ChainId), signBytes, ) if err != nil { @@ -213,6 +214,19 @@ func decodeProtobufSignDoc(signDocBytes []byte) (apitypes.TypedData, error) { return typedData, nil } +// chainIDFromSignDoc uses the chain id on the sign doc when it parses as a uint64. +// Otherwise it falls back to the configured EVM chain id. +func chainIDFromSignDoc(chainIDStr string) uint64 { + if chainIDStr == "" { + return eip155ChainID + } + parsed, err := strconv.ParseUint(chainIDStr, 10, 64) + if err != nil { + return eip155ChainID + } + return parsed +} + // validateCodecInit ensures that both Amino and Protobuf encoding codecs have been set on app init, // so the module does not panic if either codec is not found. func validateCodecInit() error { From 700c82972cdae23e55f394c97be72900bff8efd0 Mon Sep 17 00:00:00 2001 From: mattkii <142337545+mattkii@users.noreply.github.com> Date: Sun, 4 Oct 2026 18:03:27 -0700 Subject: [PATCH 5/5] fix: treat already-known EVM txs as success --- mempool/mempool_handlers.go | 5 ++++- rpc/backend/call_tx.go | 8 ++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/mempool/mempool_handlers.go b/mempool/mempool_handlers.go index 2484d048..eee781e8 100644 --- a/mempool/mempool_handlers.go +++ b/mempool/mempool_handlers.go @@ -11,6 +11,8 @@ import ( errorsmod "cosmossdk.io/errors" sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/cosmos/evm/mempool/txpool" ) const ( @@ -88,7 +90,8 @@ func (m *Mempool) NewCheckTxHandler(txDecoder sdk.TxDecoder, timeout time.Durati // ErrAsCheckTxResponse converts an error to a ResponseCheckTx object, respecting error wrapping. func ErrAsCheckTxResponse(err error) *abci.ResponseCheckTx { - if err == nil { + if err == nil || errors.Is(err, txpool.ErrAlreadyKnown) { + // Already-known is success so a promoted nonce-gap tx can be re-gossiped. return &abci.ResponseCheckTx{Code: abci.CodeTypeOK} } diff --git a/rpc/backend/call_tx.go b/rpc/backend/call_tx.go index a1c59cf8..071ecbc9 100644 --- a/rpc/backend/call_tx.go +++ b/rpc/backend/call_tx.go @@ -17,6 +17,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "github.com/cosmos/evm/mempool/txpool" rpctypes "github.com/cosmos/evm/rpc/types" evmtrace "github.com/cosmos/evm/trace" evmtypes "github.com/cosmos/evm/x/vm/types" @@ -147,6 +148,13 @@ func (b *Backend) SendRawTransaction(ctx context.Context, data hexutil.Bytes) (r txHash := tx.Hash() + // User-submitted RPC txs should get "already known" instead of a second insert. + if b.Mempool != nil { + if pool := b.Mempool.GetTxPool(); pool != nil && pool.Has(txHash) { + return txHash, txpool.ErrAlreadyKnown + } + } + // publish tx directly to app-side mempool, avoiding broadcasting to // consensus layer. // we are directly calling into the mempool rather than the ABCI