From d4143b06d9beed885b53ad31e009d573a18658ff Mon Sep 17 00:00:00 2001 From: Arya Lanjewar <102943033+AryaLanjewar3005@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:27:46 +0530 Subject: [PATCH] fix(rpc): report base fee as the gas price of derived txs --- CHANGELOG.md | 6 ++ rpc/backend/comet_to_eth.go | 11 ++- rpc/backend/derived_gas_price_test.go | 108 ++++++++++++++++++++++++++ rpc/types/utils.go | 61 +++++++++++---- rpc/types/utils_test.go | 71 +++++++++++++++++ 5 files changed, 242 insertions(+), 15 deletions(-) create mode 100644 rpc/backend/derived_gas_price_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 56954aef9..65519cf7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ Follow the [migration document](docs/migrations/v0.5.x_to_v0.6.0.md) for upgrade ### BUG FIXES +- Report the block base fee (instead of `0`) as the `gasPrice` of derived EVM transactions in + `eth_getTransactionByHash` / `eth_getBlockByNumber`, and as their receipt `effectiveGasPrice`. + Derived txs carry zero fee caps, so consumers that model burn as `base_fee * gas_used` — such as + Blockscout's block-reward formula — read blocks whose only content is derived txs as burning more + than they collected, and render a negative block reward. + ## v0.5.1 ### DEPENDENCIES diff --git a/rpc/backend/comet_to_eth.go b/rpc/backend/comet_to_eth.go index 750dc99bb..3c2bc1fd9 100644 --- a/rpc/backend/comet_to_eth.go +++ b/rpc/backend/comet_to_eth.go @@ -424,9 +424,16 @@ func (b *Backend) ReceiptsFromCometBlock( cumulatedGasUsed += txResult.GasUsed var effectiveGasPrice *big.Int - if baseFee != nil { + switch { + case additional != nil: + // Derived tx: reconstructed with zero fee caps, so the generic EIP-1559 + // formula would report 0 here and make fee-accounting consumers read the + // block as burning more than it collected. Report the base fee, matching + // the `gasPrice` served for the same tx by eth_getTransactionByHash. + effectiveGasPrice = rpctypes.DerivedTxGasPrice(ethMsg.Raw.Transaction, baseFee) + case baseFee != nil: effectiveGasPrice = rpctypes.EffectiveGasPrice(ethMsg.Raw.Transaction, baseFee) - } else { + default: effectiveGasPrice = ethMsg.Raw.GasFeeCap() } diff --git a/rpc/backend/derived_gas_price_test.go b/rpc/backend/derived_gas_price_test.go new file mode 100644 index 000000000..3607f12c8 --- /dev/null +++ b/rpc/backend/derived_gas_price_test.go @@ -0,0 +1,108 @@ +package backend + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + abcitypes "github.com/cometbft/cometbft/abci/types" + tmrpctypes "github.com/cometbft/cometbft/rpc/core/types" + tmtypes "github.com/cometbft/cometbft/types" + + "github.com/cosmos/evm/rpc/backend/mocks" + rpctypes "github.com/cosmos/evm/rpc/types" + servertypes "github.com/cosmos/evm/server/types" + evmtypes "github.com/cosmos/evm/x/vm/types" + + sdkmath "cosmossdk.io/math" +) + +// TestDerivedTxReceiptEffectiveGasPrice is a regression test for the negative block +// rewards reported by Blockscout for blocks whose only content is derived txs. +// +// Derived txs are reconstructed from events with zero fee caps, so the generic EIP-1559 +// effective-price formula resolves them to 0. A receipt reporting gasUsed > 0 at +// effectiveGasPrice == 0 makes any consumer that models burn as base_fee * gas_used read +// the block as burning more than it collected. The receipt must report the base fee, and +// must agree with the `gasPrice` served by eth_getTransactionByHash for the same tx. +func TestDerivedTxReceiptEffectiveGasPrice(t *testing.T) { + const ( + height = int64(100) + gasUsed = uint64(98_212) + gasLimit = uint64(50_000_000) + ) + baseFee := big.NewInt(1_000_000_000) // 1 gwei, as on donut + + backend := setupMockBackend(t) + mockEVMQueryClient := backend.QueryClient.QueryClient.(*mocks.EVMQueryClient) + mockEVMQueryClient.On("BaseFee", mock.Anything, mock.Anything). + Return(&evmtypes.QueryBaseFeeResponse{BaseFee: ptrInt(sdkmath.NewIntFromBigInt(baseFee))}, nil) + + limit := gasLimit + additional := &rpctypes.TxResultAdditionalFields{ + Hash: common.BigToHash(big.NewInt(0xdeadbeef)), + Type: evmtypes.DerivedTxType, + Recipient: common.HexToAddress("0x7e5ac993907bc433046316948fa23b0c9c702664"), + Sender: common.HexToAddress("0x5826874ddef35d5f802634e212fbab949cb34f6a"), + Value: big.NewInt(0), + GasUsed: gasUsed, + GasLimit: &limit, + Nonce: 1, + } + ethMsg := backend.parseDerivedTxFromAdditionalFields(additional) + require.NotNil(t, ethMsg) + + backend.Indexer = &MockIndexer{ + txResults: map[common.Hash]*servertypes.TxResult{ + additional.Hash: { + Height: height, + TxIndex: 0, + EthTxIndex: 0, + MsgIndex: 0, + GasUsed: gasUsed, + }, + }, + } + + resBlock := &tmrpctypes.ResultBlock{ + BlockID: tmtypes.BlockID{Hash: common.BigToHash(big.NewInt(0xb10c)).Bytes()}, + Block: &tmtypes.Block{Header: tmtypes.Header{Height: height}}, + } + blockRes := &tmrpctypes.ResultBlockResults{ + Height: height, + TxsResults: []*abcitypes.ExecTxResult{{Code: 0}}, + } + + receipts, err := backend.ReceiptsFromCometBlock( + resBlock, + blockRes, + []*evmtypes.MsgEthereumTx{ethMsg}, + []*rpctypes.TxResultAdditionalFields{additional}, + ) + require.NoError(t, err) + require.Len(t, receipts, 1) + + require.Equal(t, baseFee, receipts[0].EffectiveGasPrice, + "derived tx receipt must report the base fee, not 0") + require.Equal(t, gasUsed, receipts[0].GasUsed) + + // The tx object served for the same derived tx must agree, so that consumers reading + // either field compute the same (zero) net fee. + rpcTx, err := rpctypes.NewRPCTransactionFromIncompleteMsg( + ethMsg, + common.BytesToHash(resBlock.BlockID.Hash), + uint64(height), + 0, + baseFee, + backend.EvmChainID, + additional.Hash, + ) + require.NoError(t, err) + require.Equal(t, receipts[0].EffectiveGasPrice, rpcTx.GasPrice.ToInt(), + "eth_getTransactionByHash gasPrice must match the receipt effectiveGasPrice") +} + +func ptrInt(i sdkmath.Int) *sdkmath.Int { return &i } diff --git a/rpc/types/utils.go b/rpc/types/utils.go index 9a27cb5b4..a81db719f 100644 --- a/rpc/types/utils.go +++ b/rpc/types/utils.go @@ -304,28 +304,63 @@ func NewRPCTransactionFromIncompleteMsg( from := msg.GetSender() v, r, s := tx.RawSignatureValues() result := &RPCTransaction{ - Type: hexutil.Uint64(tx.Type()), - From: from, - Gas: hexutil.Uint64(tx.Gas()), - GasPrice: (*hexutil.Big)(tx.GasPrice()), - Hash: txHash, - Input: hexutil.Bytes(tx.Data()), - Nonce: hexutil.Uint64(tx.Nonce()), - To: tx.To(), - Value: (*hexutil.Big)(tx.Value()), - V: (*hexutil.Big)(v), - R: (*hexutil.Big)(r), - S: (*hexutil.Big)(s), - ChainID: (*hexutil.Big)(chainID), + Type: hexutil.Uint64(tx.Type()), + From: from, + Gas: hexutil.Uint64(tx.Gas()), + Hash: txHash, + Input: hexutil.Bytes(tx.Data()), + Nonce: hexutil.Uint64(tx.Nonce()), + To: tx.To(), + Value: (*hexutil.Big)(tx.Value()), + V: (*hexutil.Big)(v), + R: (*hexutil.Big)(r), + S: (*hexutil.Big)(s), + ChainID: (*hexutil.Big)(chainID), } if blockHash != (common.Hash{}) { result.BlockHash = &blockHash result.BlockNumber = (*hexutil.Big)(new(big.Int).SetUint64(blockNumber)) result.TransactionIndex = (*hexutil.Uint64)(&index) + result.GasPrice = (*hexutil.Big)(DerivedTxGasPrice(tx, baseFee)) + } else { + // Not mined: there is no block base fee to report against, so fall back to + // the reconstructed tx's own price. + result.GasPrice = (*hexutil.Big)(tx.GasPrice()) } return result, nil } +// DerivedTxGasPrice returns the gas price to report over JSON-RPC for a derived +// (protocol-internal, non-user-signed) EVM transaction mined in a block with the +// given base fee. +// +// Derived txs are constructed with zero fee caps — they are not signed by a user and +// no fee is charged for them — so the generic EIP-1559 formulas resolve their price to +// 0. Reporting 0 breaks consumers that model every transaction as burning +// base_fee * gas_used. Blockscout computes a block's reward as +// +// Σ(gas_used * gas_price) − base_fee_per_gas * Σ(gas_used) +// +// which goes negative for blocks whose only content is derived txs, even though such a +// block moves no value at all: nothing is paid to the proposer and nothing is burnt. +// +// Reporting exactly the base fee — the effective price of a transaction that adds no +// priority tip — makes that arithmetic net to zero, which matches the chain's actual +// economics. Both the transaction object's `gasPrice` and the receipt's +// `effectiveGasPrice` must use this so the two agree. +// +// When the base fee is unavailable (pruned node), the reconstructed tx's own price is +// returned rather than inventing one. +func DerivedTxGasPrice(tx *ethtypes.Transaction, baseFee *big.Int) *big.Int { + if baseFee == nil { + if tx == nil { + return big.NewInt(0) + } + return tx.GasPrice() + } + return new(big.Int).Set(baseFee) +} + // effectiveGasPrice computes the transaction gas fee, based on the given basefee value. // // price = min(gasTipCap + baseFee, gasFeeCap) diff --git a/rpc/types/utils_test.go b/rpc/types/utils_test.go index badeddd71..2e5430de5 100644 --- a/rpc/types/utils_test.go +++ b/rpc/types/utils_test.go @@ -47,3 +47,74 @@ func TestNewRPCTransactionFromIncompleteMsgGas(t *testing.T) { require.Equal(t, txHash, rpcTx.Hash, "hash must be the supplied derived tx hash") require.Equal(t, sender, rpcTx.From) } + +// TestNewRPCTransactionFromIncompleteMsgGasPrice is a regression test for the negative +// block rewards reported by Blockscout for blocks whose only content is derived txs. +// +// Derived txs are reconstructed with zero fee caps, so reporting their raw price yields +// gasPrice == 0 while the receipt still reports gasUsed > 0. Blockscout derives a block's +// reward as Σ(gas_used * gas_price) − base_fee_per_gas * Σ(gas_used), which goes negative +// for such blocks even though they move no value. A mined derived tx must therefore report +// exactly the block base fee — the price of a tx that adds no priority tip — so that +// arithmetic nets to zero. +func TestNewRPCTransactionFromIncompleteMsgGasPrice(t *testing.T) { + to := common.HexToAddress("0x000000000000000000000000000000000000dEaD") + txHash := common.BigToHash(big.NewInt(1)) + blockHash := common.BigToHash(big.NewInt(2)) + baseFee := big.NewInt(1_000_000_000) // 1 gwei + + // Derived txs are reconstructed as EIP-1559 txs with zero fee caps. + newMsg := func() *evmtypes.MsgEthereumTx { + inner := ethtypes.NewTx(ðtypes.DynamicFeeTx{ + ChainID: big.NewInt(1), + Nonce: 0, + GasFeeCap: big.NewInt(0), + GasTipCap: big.NewInt(0), + Gas: 60000, + To: &to, + Value: big.NewInt(0), + }) + msg := &evmtypes.MsgEthereumTx{} + msg.FromEthereumTx(inner) + msg.From = common.BytesToAddress([]byte("sender")).Bytes() + return msg + } + + t.Run("mined tx reports the block base fee", func(t *testing.T) { + rpcTx, err := NewRPCTransactionFromIncompleteMsg( + newMsg(), blockHash, 7, 0, baseFee, big.NewInt(1), txHash, + ) + require.NoError(t, err) + require.NotNil(t, rpcTx.GasPrice) + require.Equal(t, baseFee, rpcTx.GasPrice.ToInt(), + "gasPrice must be the base fee so tx fees and burnt fees cancel out") + }) + + t.Run("mined tx with unknown base fee falls back to the tx price", func(t *testing.T) { + rpcTx, err := NewRPCTransactionFromIncompleteMsg( + newMsg(), blockHash, 7, 0, nil, big.NewInt(1), txHash, + ) + require.NoError(t, err) + require.NotNil(t, rpcTx.GasPrice) + require.Equal(t, big.NewInt(0), rpcTx.GasPrice.ToInt()) + }) + + t.Run("unmined tx falls back to the tx price", func(t *testing.T) { + rpcTx, err := NewRPCTransactionFromIncompleteMsg( + newMsg(), common.Hash{}, 0, 0, baseFee, big.NewInt(1), txHash, + ) + require.NoError(t, err) + require.NotNil(t, rpcTx.GasPrice) + require.Equal(t, big.NewInt(0), rpcTx.GasPrice.ToInt()) + }) + + t.Run("supplied base fee is not aliased", func(t *testing.T) { + bf := big.NewInt(1_000_000_000) + rpcTx, err := NewRPCTransactionFromIncompleteMsg( + newMsg(), blockHash, 7, 0, bf, big.NewInt(1), txHash, + ) + require.NoError(t, err) + rpcTx.GasPrice.ToInt().SetInt64(42) + require.Equal(t, big.NewInt(1_000_000_000), bf, "caller's baseFee must not be mutated") + }) +}