Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions rpc/backend/comet_to_eth.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
// with their event-assigned hashes, not the reconstructed LegacyTx hash.
block := resBlock.Block
blockHash := common.BytesToHash(block.Hash())
blockHeight := uint64(block.Height) //nolint:gosec // G115

Check failure on line 63 in rpc/backend/comet_to_eth.go

View workflow job for this annotation

GitHub Actions / Run golangci-lint

File is not properly formatted (gci)
blockTime := uint64(block.Time.Unix()) //nolint:gosec // G115
baseFee, _ := b.BaseFee(blockRes)

Expand Down Expand Up @@ -424,9 +424,16 @@
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()
}

Expand Down
108 changes: 108 additions & 0 deletions rpc/backend/derived_gas_price_test.go
Original file line number Diff line number Diff line change
@@ -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 }
61 changes: 48 additions & 13 deletions rpc/types/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
71 changes: 71 additions & 0 deletions rpc/types/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(&ethtypes.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")
})
}
Loading