From c7cacd0710f5040a46532e6dae7eac1b9eafe645 Mon Sep 17 00:00:00 2001 From: Chunkai Yang Date: Tue, 26 Mar 2024 17:32:03 -0400 Subject: [PATCH] Ecotone gas price (#12584) * define OP stack da price reader * fix existing l1 oracle test * fetch v1 gas price test * add isEcotone test case * address lint * add changeset * go imports * fix test name * fix OP oracle address * fix lint --- .changeset/rude-paws-cross.md | 5 + core/chains/evm/gas/rollups/l1_oracle.go | 56 ++++- core/chains/evm/gas/rollups/l1_oracle_abi.go | 4 + core/chains/evm/gas/rollups/l1_oracle_test.go | 32 +-- .../evm/gas/rollups/mocks/da_price_reader.go | 59 +++++ .../evm/gas/rollups/mocks/eth_client.go | 20 ++ .../chains/evm/gas/rollups/op_price_reader.go | 228 ++++++++++++++++++ .../evm/gas/rollups/op_price_reader_test.go | 200 +++++++++++++++ 8 files changed, 569 insertions(+), 35 deletions(-) create mode 100644 .changeset/rude-paws-cross.md create mode 100644 core/chains/evm/gas/rollups/mocks/da_price_reader.go create mode 100644 core/chains/evm/gas/rollups/op_price_reader.go create mode 100644 core/chains/evm/gas/rollups/op_price_reader_test.go diff --git a/.changeset/rude-paws-cross.md b/.changeset/rude-paws-cross.md new file mode 100644 index 00000000000..395a6d76244 --- /dev/null +++ b/.changeset/rude-paws-cross.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +L1Oracle handles OP Stack Ecotone encoded l1 gas price diff --git a/core/chains/evm/gas/rollups/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go index e9cdc6b73b1..ae46071cf0d 100644 --- a/core/chains/evm/gas/rollups/l1_oracle.go +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/rpc" "github.com/smartcontractkit/chainlink-common/pkg/logger" "github.com/smartcontractkit/chainlink-common/pkg/services" @@ -28,6 +29,12 @@ import ( //go:generate mockery --quiet --name ethClient --output ./mocks/ --case=underscore --structname ETHClient type ethClient interface { CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) + BatchCallContext(ctx context.Context, b []rpc.BatchElem) error +} + +//go:generate mockery --quiet --name daPriceReader --output ./mocks/ --case=underscore --structname DAPriceReader +type daPriceReader interface { + GetDAGasPrice(ctx context.Context) (*big.Int, error) } type priceEntry struct { @@ -53,6 +60,8 @@ type l1Oracle struct { gasCostMethod string l1GasCostMethodAbi abi.ABI + priceReader daPriceReader + chInitialised chan struct{} chStop services.StopChan chDone chan struct{} @@ -109,9 +118,23 @@ func IsRollupWithL1Support(chainType config.ChainType) bool { } func NewL1GasOracle(lggr logger.Logger, ethClient ethClient, chainType config.ChainType) L1Oracle { + var priceReader daPriceReader + switch chainType { + case config.ChainOptimismBedrock: + priceReader = newOPPriceReader(lggr, ethClient, chainType, OPGasOracleAddress) + case config.ChainKroma: + priceReader = newOPPriceReader(lggr, ethClient, chainType, KromaGasOracleAddress) + default: + priceReader = nil + } + return newL1GasOracle(lggr, ethClient, chainType, priceReader) +} + +func newL1GasOracle(lggr logger.Logger, ethClient ethClient, chainType config.ChainType, priceReader daPriceReader) L1Oracle { var l1GasPriceAddress, gasPriceMethod, l1GasCostAddress, gasCostMethod string var l1GasPriceMethodAbi, l1GasCostMethodAbi abi.ABI var gasPriceErr, gasCostErr error + switch chainType { case config.ChainArbitrum: l1GasPriceAddress = ArbGasInfoAddress @@ -164,6 +187,8 @@ func NewL1GasOracle(lggr logger.Logger, ethClient ethClient, chainType config.Ch gasCostMethod: gasCostMethod, l1GasCostMethodAbi: l1GasCostMethodAbi, + priceReader: priceReader, + chInitialised: make(chan struct{}), chStop: make(chan struct{}), chDone: make(chan struct{}), @@ -222,13 +247,30 @@ func (o *l1Oracle) refreshWithError() (t *time.Timer, err error) { ctx, cancel := o.chStop.CtxCancel(evmclient.ContextWithDefaultTimeout()) defer cancel() + price, err := o.fetchL1GasPrice(ctx) + if err != nil { + return t, err + } + + o.l1GasPriceMu.Lock() + defer o.l1GasPriceMu.Unlock() + o.l1GasPrice = priceEntry{price: assets.NewWei(price), timestamp: time.Now()} + return +} + +func (o *l1Oracle) fetchL1GasPrice(ctx context.Context) (price *big.Int, err error) { + // if dedicated priceReader exists, use the reader + if o.priceReader != nil { + return o.priceReader.GetDAGasPrice(ctx) + } + var callData, b []byte precompile := common.HexToAddress(o.l1GasPriceAddress) callData, err = o.l1GasPriceMethodAbi.Pack(o.gasPriceMethod) if err != nil { errMsg := fmt.Sprintf("failed to pack calldata for %s L1 gas price method", o.chainType) o.logger.Errorf(errMsg) - return t, fmt.Errorf("%s: %w", errMsg, err) + return nil, fmt.Errorf("%s: %w", errMsg, err) } b, err = o.client.CallContract(ctx, ethereum.CallMsg{ To: &precompile, @@ -237,20 +279,16 @@ func (o *l1Oracle) refreshWithError() (t *time.Timer, err error) { if err != nil { errMsg := "gas oracle contract call failed" o.logger.Errorf(errMsg) - return t, fmt.Errorf("%s: %w", errMsg, err) + return nil, fmt.Errorf("%s: %w", errMsg, err) } if len(b) != 32 { // returns uint256; errMsg := fmt.Sprintf("return data length (%d) different than expected (%d)", len(b), 32) o.logger.Criticalf(errMsg) - return t, fmt.Errorf(errMsg) + return nil, fmt.Errorf(errMsg) } - price := new(big.Int).SetBytes(b) - - o.l1GasPriceMu.Lock() - defer o.l1GasPriceMu.Unlock() - o.l1GasPrice = priceEntry{price: assets.NewWei(price), timestamp: time.Now()} - return + price = new(big.Int).SetBytes(b) + return price, nil } func (o *l1Oracle) GasPrice(_ context.Context) (l1GasPrice *assets.Wei, err error) { diff --git a/core/chains/evm/gas/rollups/l1_oracle_abi.go b/core/chains/evm/gas/rollups/l1_oracle_abi.go index 77ef4d49f3c..dc18e43c98e 100644 --- a/core/chains/evm/gas/rollups/l1_oracle_abi.go +++ b/core/chains/evm/gas/rollups/l1_oracle_abi.go @@ -11,3 +11,7 @@ const GasEstimateL1ComponentAbiString = `[{"inputs":[{"internalType":"address"," // All ABIs found at https://optimistic.etherscan.io/address/0xc0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d3c0d3000f#code const L1BaseFeeAbiString = `[{"inputs":[],"name":"l1BaseFee","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]` const GetL1FeeAbiString = `[{"inputs":[{"internalType":"bytes","name":"_data","type":"bytes"}],"name":"getL1Fee","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]` + +// ABIs for OP Stack Ecotone GasPriceOracle methods needed to calculated encoded gas price +const OPIsEcotoneAbiString = `[{"inputs":[],"name":"isEcotone","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"}]` +const OPGetL1GasUsedAbiString = `[{"inputs":[{"internalType":"bytes","name":"_data","type":"bytes"}],"name":"getL1GasUsed","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]` diff --git a/core/chains/evm/gas/rollups/l1_oracle_test.go b/core/chains/evm/gas/rollups/l1_oracle_test.go index 8415e4d7805..4f3b67e2ecf 100644 --- a/core/chains/evm/gas/rollups/l1_oracle_test.go +++ b/core/chains/evm/gas/rollups/l1_oracle_test.go @@ -72,21 +72,11 @@ func TestL1Oracle_GasPrice(t *testing.T) { t.Run("Calling GasPrice on started Kroma L1Oracle returns Kroma l1GasPrice", func(t *testing.T) { l1BaseFee := big.NewInt(100) - l1GasPriceMethodAbi, err := abi.JSON(strings.NewReader(L1BaseFeeAbiString)) - require.NoError(t, err) - ethClient := mocks.NewETHClient(t) - ethClient.On("CallContract", mock.Anything, mock.IsType(ethereum.CallMsg{}), mock.IsType(&big.Int{})).Run(func(args mock.Arguments) { - callMsg := args.Get(1).(ethereum.CallMsg) - blockNumber := args.Get(2).(*big.Int) - var payload []byte - payload, err = l1GasPriceMethodAbi.Pack("l1BaseFee") - require.NoError(t, err) - require.Equal(t, payload, callMsg.Data) - assert.Nil(t, blockNumber) - }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) + priceReader := mocks.NewDAPriceReader(t) + priceReader.On("GetDAGasPrice", mock.Anything).Return(l1BaseFee, nil) - oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainKroma) + oracle := newL1GasOracle(logger.Test(t), nil, config.ChainKroma, priceReader) servicetest.RunHealthy(t, oracle) gasPrice, err := oracle.GasPrice(testutils.Context(t)) @@ -97,21 +87,11 @@ func TestL1Oracle_GasPrice(t *testing.T) { t.Run("Calling GasPrice on started OPStack L1Oracle returns OPStack l1GasPrice", func(t *testing.T) { l1BaseFee := big.NewInt(100) - l1GasPriceMethodAbi, err := abi.JSON(strings.NewReader(L1BaseFeeAbiString)) - require.NoError(t, err) - ethClient := mocks.NewETHClient(t) - ethClient.On("CallContract", mock.Anything, mock.IsType(ethereum.CallMsg{}), mock.IsType(&big.Int{})).Run(func(args mock.Arguments) { - callMsg := args.Get(1).(ethereum.CallMsg) - blockNumber := args.Get(2).(*big.Int) - var payload []byte - payload, err = l1GasPriceMethodAbi.Pack("l1BaseFee") - require.NoError(t, err) - require.Equal(t, payload, callMsg.Data) - assert.Nil(t, blockNumber) - }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) + priceReader := mocks.NewDAPriceReader(t) + priceReader.On("GetDAGasPrice", mock.Anything).Return(l1BaseFee, nil) - oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainOptimismBedrock) + oracle := newL1GasOracle(logger.Test(t), nil, config.ChainOptimismBedrock, priceReader) servicetest.RunHealthy(t, oracle) gasPrice, err := oracle.GasPrice(testutils.Context(t)) diff --git a/core/chains/evm/gas/rollups/mocks/da_price_reader.go b/core/chains/evm/gas/rollups/mocks/da_price_reader.go new file mode 100644 index 00000000000..7758f53e436 --- /dev/null +++ b/core/chains/evm/gas/rollups/mocks/da_price_reader.go @@ -0,0 +1,59 @@ +// Code generated by mockery v2.38.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + big "math/big" + + mock "github.com/stretchr/testify/mock" +) + +// DAPriceReader is an autogenerated mock type for the daPriceReader type +type DAPriceReader struct { + mock.Mock +} + +// GetDAGasPrice provides a mock function with given fields: ctx +func (_m *DAPriceReader) GetDAGasPrice(ctx context.Context) (*big.Int, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetDAGasPrice") + } + + var r0 *big.Int + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*big.Int, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *big.Int); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*big.Int) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewDAPriceReader creates a new instance of DAPriceReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewDAPriceReader(t interface { + mock.TestingT + Cleanup(func()) +}) *DAPriceReader { + mock := &DAPriceReader{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/gas/rollups/mocks/eth_client.go b/core/chains/evm/gas/rollups/mocks/eth_client.go index bb0784f8515..e5a28f715ad 100644 --- a/core/chains/evm/gas/rollups/mocks/eth_client.go +++ b/core/chains/evm/gas/rollups/mocks/eth_client.go @@ -9,6 +9,8 @@ import ( ethereum "github.com/ethereum/go-ethereum" mock "github.com/stretchr/testify/mock" + + rpc "github.com/ethereum/go-ethereum/rpc" ) // ETHClient is an autogenerated mock type for the ethClient type @@ -16,6 +18,24 @@ type ETHClient struct { mock.Mock } +// BatchCallContext provides a mock function with given fields: ctx, b +func (_m *ETHClient) BatchCallContext(ctx context.Context, b []rpc.BatchElem) error { + ret := _m.Called(ctx, b) + + if len(ret) == 0 { + panic("no return value specified for BatchCallContext") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []rpc.BatchElem) error); ok { + r0 = rf(ctx, b) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // CallContract provides a mock function with given fields: ctx, msg, blockNumber func (_m *ETHClient) CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { ret := _m.Called(ctx, msg, blockNumber) diff --git a/core/chains/evm/gas/rollups/op_price_reader.go b/core/chains/evm/gas/rollups/op_price_reader.go new file mode 100644 index 00000000000..2d3d668ad8b --- /dev/null +++ b/core/chains/evm/gas/rollups/op_price_reader.go @@ -0,0 +1,228 @@ +package rollups + +import ( + "context" + "fmt" + "math/big" + "strings" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/common/config" +) + +const ( + // OPStackGasOracle_l1BaseFee fetches the l1 base fee set in the OP Stack GasPriceOracle contract + OPStackGasOracle_l1BaseFee = "l1BaseFee" + + // OPStackGasOracle_isEcotone fetches if the OP Stack GasPriceOracle contract has upgraded to Ecotone + OPStackGasOracle_isEcotone = "isEcotone" + + // OPStackGasOracle_getL1GasUsed fetches the l1 gas used for given tx bytes + OPStackGasOracle_getL1GasUsed = "getL1GasUsed" + + // OPStackGasOracle_getL1Fee fetches the l1 fee for given tx bytes + OPStackGasOracle_getL1Fee = "getL1Fee" + + // OPStackGasOracle_isEcotonePollingPeriod is the interval to poll if chain has upgraded to Ecotone + // Set to poll every 4 hours + OPStackGasOracle_isEcotonePollingPeriod = 14400 +) + +type opStackGasPriceReader struct { + client ethClient + logger logger.SugaredLogger + + oracleAddress common.Address + isEcotoneMethodAbi abi.ABI + + l1BaseFeeCalldata []byte + isEcotoneCalldata []byte + getL1GasUsedCalldata []byte + getL1FeeCalldata []byte + + isEcotone bool + isEcotoneCheckTs int64 +} + +func newOPPriceReader(lggr logger.Logger, ethClient ethClient, chainType config.ChainType, oracleAddress string) daPriceReader { + // encode calldata for each method; these calldata will remain the same for each call, we can encode them just once + l1BaseFeeMethodAbi, err := abi.JSON(strings.NewReader(L1BaseFeeAbiString)) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() method ABI for chain: %s; %w", OPStackGasOracle_l1BaseFee, chainType, err)) + } + l1BaseFeeCalldata, err := l1BaseFeeMethodAbi.Pack(OPStackGasOracle_l1BaseFee) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() calldata for chain: %s; %w", OPStackGasOracle_l1BaseFee, chainType, err)) + } + + isEcotoneMethodAbi, err := abi.JSON(strings.NewReader(OPIsEcotoneAbiString)) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() method ABI for chain: %s; %w", OPStackGasOracle_isEcotone, chainType, err)) + } + isEcotoneCalldata, err := isEcotoneMethodAbi.Pack(OPStackGasOracle_isEcotone) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() calldata for chain: %s; %w", OPStackGasOracle_isEcotone, chainType, err)) + } + + getL1GasUsedMethodAbi, err := abi.JSON(strings.NewReader(OPGetL1GasUsedAbiString)) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() method ABI for chain: %s; %w", OPStackGasOracle_getL1GasUsed, chainType, err)) + } + getL1GasUsedCalldata, err := getL1GasUsedMethodAbi.Pack(OPStackGasOracle_getL1GasUsed, []byte{0x1}) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() calldata for chain: %s; %w", OPStackGasOracle_getL1GasUsed, chainType, err)) + } + + getL1FeeMethodAbi, err := abi.JSON(strings.NewReader(GetL1FeeAbiString)) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() method ABI for chain: %s; %w", OPStackGasOracle_getL1Fee, chainType, err)) + } + getL1FeeCalldata, err := getL1FeeMethodAbi.Pack(OPStackGasOracle_getL1Fee, []byte{0x1}) + if err != nil { + panic(fmt.Errorf("failed to parse GasPriceOracle %s() calldata for chain: %s; %w", OPStackGasOracle_getL1Fee, chainType, err)) + } + + return &opStackGasPriceReader{ + client: ethClient, + logger: logger.Sugared(logger.Named(lggr, fmt.Sprintf("OPStackGasOracle(%s)", chainType))), + + oracleAddress: common.HexToAddress(oracleAddress), + isEcotoneMethodAbi: isEcotoneMethodAbi, + + l1BaseFeeCalldata: l1BaseFeeCalldata, + isEcotoneCalldata: isEcotoneCalldata, + getL1GasUsedCalldata: getL1GasUsedCalldata, + getL1FeeCalldata: getL1FeeCalldata, + + isEcotone: false, + isEcotoneCheckTs: 0, + } +} + +func (o *opStackGasPriceReader) GetDAGasPrice(ctx context.Context) (*big.Int, error) { + isEcotone, err := o.checkIsEcotone(ctx) + if err != nil { + return nil, err + } + + o.logger.Infof("Chain isEcotone result: %t", isEcotone) + + if isEcotone { + return o.getEcotoneGasPrice(ctx) + } + + return o.getV1GasPrice(ctx) +} + +func (o *opStackGasPriceReader) checkIsEcotone(ctx context.Context) (bool, error) { + // if chain is already Ecotone, NOOP + if o.isEcotone { + return true, nil + } + // if time since last check has not exceeded polling period, NOOP + if time.Now().Unix()-o.isEcotoneCheckTs < OPStackGasOracle_isEcotonePollingPeriod { + return false, nil + } + o.isEcotoneCheckTs = time.Now().Unix() + + // confirmed with OP team that isEcotone() is the canonical way to check if the chain has upgraded + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &o.oracleAddress, + Data: o.isEcotoneCalldata, + }, nil) + + // if the chain has not upgraded to Ecotone, the isEcotone call will revert, this would be expected + if err != nil { + o.logger.Infof("isEcotone() call failed, this can happen if chain has not upgraded: %w", err) + return false, nil + } + + res, err := o.isEcotoneMethodAbi.Unpack(OPStackGasOracle_isEcotone, b) + if err != nil { + return false, fmt.Errorf("failed to unpack isEcotone() return data: %w", err) + } + o.isEcotone = res[0].(bool) + return o.isEcotone, nil +} + +func (o *opStackGasPriceReader) getV1GasPrice(ctx context.Context) (*big.Int, error) { + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &o.oracleAddress, + Data: o.l1BaseFeeCalldata, + }, nil) + if err != nil { + return nil, fmt.Errorf("l1BaseFee() call failed: %w", err) + } + + if len(b) != 32 { + return nil, fmt.Errorf("l1BaseFee() return data length (%d) different than expected (%d)", len(b), 32) + } + return new(big.Int).SetBytes(b), nil +} + +func (o *opStackGasPriceReader) getEcotoneGasPrice(ctx context.Context) (*big.Int, error) { + rpcBatchCalls := []rpc.BatchElem{ + { + Method: "eth_call", + Args: []any{ + map[string]interface{}{ + "from": common.Address{}, + "to": o.oracleAddress, + "data": hexutil.Bytes(o.getL1GasUsedCalldata), + }, + "latest", + }, + Result: new(string), + }, + { + Method: "eth_call", + Args: []any{ + map[string]interface{}{ + "from": common.Address{}, + "to": o.oracleAddress, + "data": hexutil.Bytes(o.getL1FeeCalldata), + }, + "latest", + }, + Result: new(string), + }, + } + + err := o.client.BatchCallContext(ctx, rpcBatchCalls) + if err != nil { + return nil, fmt.Errorf("getEcotoneGasPrice batch call failed: %w", err) + } + if rpcBatchCalls[0].Error != nil { + return nil, fmt.Errorf("%s call failed in a batch: %w", OPStackGasOracle_getL1GasUsed, err) + } + if rpcBatchCalls[1].Error != nil { + return nil, fmt.Errorf("%s call failed in a batch: %w", OPStackGasOracle_getL1Fee, err) + } + + l1GasUsedResult := *(rpcBatchCalls[0].Result.(*string)) + l1FeeResult := *(rpcBatchCalls[1].Result.(*string)) + + l1GasUsedBytes, err := hexutil.Decode(l1GasUsedResult) + if err != nil { + return nil, fmt.Errorf("failed to decode %s rpc result: %w", OPStackGasOracle_getL1GasUsed, err) + } + l1FeeBytes, err := hexutil.Decode(l1FeeResult) + if err != nil { + return nil, fmt.Errorf("failed to decode %s rpc result: %w", OPStackGasOracle_getL1Fee, err) + } + + l1GasUsed := new(big.Int).SetBytes(l1GasUsedBytes) + l1Fee := new(big.Int).SetBytes(l1FeeBytes) + + // for the same tx byte, l1Fee / l1GasUsed will give the l1 gas price + // note this price is per l1 gas, not l1 data byte + return new(big.Int).Div(l1Fee, l1GasUsed), nil +} diff --git a/core/chains/evm/gas/rollups/op_price_reader_test.go b/core/chains/evm/gas/rollups/op_price_reader_test.go new file mode 100644 index 00000000000..dad12a16366 --- /dev/null +++ b/core/chains/evm/gas/rollups/op_price_reader_test.go @@ -0,0 +1,200 @@ +package rollups + +import ( + "fmt" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + + "github.com/smartcontractkit/chainlink/v2/common/config" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" +) + +func TestDAPriceReader_ReadV1GasPrice(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + isEcotoneError bool + returnBadData bool + }{ + { + name: "calling isEcotone returns false, fetches l1BaseFee", + isEcotoneError: false, + }, + { + name: "calling isEcotone when chain has not made Ecotone upgrade, fetches l1BaseFee", + isEcotoneError: true, + }, + { + name: "calling isEcotone returns bad data, returns error", + isEcotoneError: false, + returnBadData: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + l1BaseFee := big.NewInt(100) + oracleAddress := common.HexToAddress("0x1234").String() + + l1BaseFeeMethodAbi, err := abi.JSON(strings.NewReader(L1BaseFeeAbiString)) + require.NoError(t, err) + l1BaseFeeCalldata, err := l1BaseFeeMethodAbi.Pack(OPStackGasOracle_l1BaseFee) + require.NoError(t, err) + + isEcotoneMethodAbi, err := abi.JSON(strings.NewReader(OPIsEcotoneAbiString)) + require.NoError(t, err) + isEcotoneCalldata, err := isEcotoneMethodAbi.Pack(OPStackGasOracle_isEcotone) + require.NoError(t, err) + + ethClient := mocks.NewETHClient(t) + call := ethClient.On("CallContract", mock.Anything, mock.IsType(ethereum.CallMsg{}), mock.IsType(&big.Int{})).Run(func(args mock.Arguments) { + callMsg := args.Get(1).(ethereum.CallMsg) + blockNumber := args.Get(2).(*big.Int) + require.Equal(t, isEcotoneCalldata, callMsg.Data) + require.Equal(t, oracleAddress, callMsg.To.String()) + assert.Nil(t, blockNumber) + }) + + if tc.returnBadData { + call.Return([]byte{0x2, 0x2}, nil).Once() + } else if tc.isEcotoneError { + call.Return(nil, fmt.Errorf("test error")).Once() + } else { + call.Return(isEcotoneMethodAbi.Methods["isEcotone"].Outputs.Pack(false)).Once() + } + + if !tc.returnBadData { + ethClient.On("CallContract", mock.Anything, mock.IsType(ethereum.CallMsg{}), mock.IsType(&big.Int{})).Run(func(args mock.Arguments) { + callMsg := args.Get(1).(ethereum.CallMsg) + blockNumber := args.Get(2).(*big.Int) + require.Equal(t, l1BaseFeeCalldata, callMsg.Data) + require.Equal(t, oracleAddress, callMsg.To.String()) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(l1BaseFee).Bytes(), nil).Once() + } + + oracle := newOPPriceReader(logger.Test(t), ethClient, config.ChainOptimismBedrock, oracleAddress) + gasPrice, err := oracle.GetDAGasPrice(testutils.Context(t)) + + if tc.returnBadData { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, l1BaseFee, gasPrice) + } + }) + } +} + +func setupIsEcotone(t *testing.T, oracleAddress string) *mocks.ETHClient { + isEcotoneMethodAbi, err := abi.JSON(strings.NewReader(OPIsEcotoneAbiString)) + require.NoError(t, err) + isEcotoneCalldata, err := isEcotoneMethodAbi.Pack(OPStackGasOracle_isEcotone) + require.NoError(t, err) + + ethClient := mocks.NewETHClient(t) + ethClient.On("CallContract", mock.Anything, mock.IsType(ethereum.CallMsg{}), mock.IsType(&big.Int{})).Run(func(args mock.Arguments) { + callMsg := args.Get(1).(ethereum.CallMsg) + blockNumber := args.Get(2).(*big.Int) + require.Equal(t, isEcotoneCalldata, callMsg.Data) + require.Equal(t, oracleAddress, callMsg.To.String()) + assert.Nil(t, blockNumber) + }).Return(isEcotoneMethodAbi.Methods["isEcotone"].Outputs.Pack(true)).Once() + + return ethClient +} + +func TestDAPriceReader_ReadEcotoneGasPrice(t *testing.T) { + l1BaseFee := big.NewInt(100) + oracleAddress := common.HexToAddress("0x1234").String() + + t.Parallel() + + t.Run("correctly fetches weighted gas price if chain has upgraded to Ecotone", func(t *testing.T) { + ethClient := setupIsEcotone(t, oracleAddress) + getL1GasUsedMethodAbi, err := abi.JSON(strings.NewReader(OPGetL1GasUsedAbiString)) + require.NoError(t, err) + getL1GasUsedCalldata, err := getL1GasUsedMethodAbi.Pack(OPStackGasOracle_getL1GasUsed, []byte{0x1}) + require.NoError(t, err) + + getL1FeeMethodAbi, err := abi.JSON(strings.NewReader(GetL1FeeAbiString)) + require.NoError(t, err) + getL1FeeCalldata, err := getL1FeeMethodAbi.Pack(OPStackGasOracle_getL1Fee, []byte{0x1}) + require.NoError(t, err) + + ethClient.On("BatchCallContext", mock.Anything, mock.IsType([]rpc.BatchElem{})).Run(func(args mock.Arguments) { + rpcElements := args.Get(1).([]rpc.BatchElem) + require.Equal(t, 2, len(rpcElements)) + + for _, rE := range rpcElements { + require.Equal(t, "eth_call", rE.Method) + require.Equal(t, oracleAddress, rE.Args[0].(map[string]interface{})["to"].(common.Address).String()) + require.Equal(t, "latest", rE.Args[1]) + } + + require.Equal(t, hexutil.Bytes(getL1GasUsedCalldata), rpcElements[0].Args[0].(map[string]interface{})["data"]) + require.Equal(t, hexutil.Bytes(getL1FeeCalldata), rpcElements[1].Args[0].(map[string]interface{})["data"]) + + res1 := common.BigToHash(big.NewInt(1)).Hex() + res2 := common.BigToHash(l1BaseFee).Hex() + rpcElements[0].Result = &res1 + rpcElements[1].Result = &res2 + }).Return(nil).Once() + + oracle := newOPPriceReader(logger.Test(t), ethClient, config.ChainOptimismBedrock, oracleAddress) + gasPrice, err := oracle.GetDAGasPrice(testutils.Context(t)) + require.NoError(t, err) + assert.Equal(t, l1BaseFee, gasPrice) + }) + + t.Run("fetching Ecotone price but rpc returns bad data", func(t *testing.T) { + ethClient := setupIsEcotone(t, oracleAddress) + ethClient.On("BatchCallContext", mock.Anything, mock.IsType([]rpc.BatchElem{})).Run(func(args mock.Arguments) { + rpcElements := args.Get(1).([]rpc.BatchElem) + var badData = "zzz" + rpcElements[0].Result = &badData + rpcElements[1].Result = &badData + }).Return(nil).Once() + + oracle := newOPPriceReader(logger.Test(t), ethClient, config.ChainOptimismBedrock, oracleAddress) + _, err := oracle.GetDAGasPrice(testutils.Context(t)) + assert.Error(t, err) + }) + + t.Run("fetching Ecotone price but rpc parent call errors", func(t *testing.T) { + ethClient := setupIsEcotone(t, oracleAddress) + ethClient.On("BatchCallContext", mock.Anything, mock.IsType([]rpc.BatchElem{})).Return(fmt.Errorf("revert")).Once() + + oracle := newOPPriceReader(logger.Test(t), ethClient, config.ChainOptimismBedrock, oracleAddress) + _, err := oracle.GetDAGasPrice(testutils.Context(t)) + assert.Error(t, err) + }) + + t.Run("fetching Ecotone price but one of the sub rpc call errors", func(t *testing.T) { + ethClient := setupIsEcotone(t, oracleAddress) + ethClient.On("BatchCallContext", mock.Anything, mock.IsType([]rpc.BatchElem{})).Run(func(args mock.Arguments) { + rpcElements := args.Get(1).([]rpc.BatchElem) + res := common.BigToHash(l1BaseFee).Hex() + rpcElements[0].Result = &res + rpcElements[1].Error = fmt.Errorf("revert") + }).Return(nil).Once() + + oracle := newOPPriceReader(logger.Test(t), ethClient, config.ChainOptimismBedrock, oracleAddress) + _, err := oracle.GetDAGasPrice(testutils.Context(t)) + assert.Error(t, err) + }) +}