diff --git a/core/chains/evm/gas/mocks/evm_fee_estimator.go b/core/chains/evm/gas/mocks/evm_fee_estimator.go index 20e6c940f7e..dbca58dcdd5 100644 --- a/core/chains/evm/gas/mocks/evm_fee_estimator.go +++ b/core/chains/evm/gas/mocks/evm_fee_estimator.go @@ -15,6 +15,8 @@ import ( mock "github.com/stretchr/testify/mock" + rollups "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups" + types "github.com/smartcontractkit/chainlink/v2/common/fee/types" ) @@ -155,6 +157,22 @@ func (_m *EvmFeeEstimator) HealthReport() map[string]error { return r0 } +// L1Oracle provides a mock function with given fields: +func (_m *EvmFeeEstimator) L1Oracle() rollups.L1Oracle { + ret := _m.Called() + + var r0 rollups.L1Oracle + if rf, ok := ret.Get(0).(func() rollups.L1Oracle); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(rollups.L1Oracle) + } + } + + return r0 +} + // Name provides a mock function with given fields: func (_m *EvmFeeEstimator) Name() string { ret := _m.Called() diff --git a/core/chains/evm/gas/models.go b/core/chains/evm/gas/models.go index 8b6580685b5..c6f8edbf04b 100644 --- a/core/chains/evm/gas/models.go +++ b/core/chains/evm/gas/models.go @@ -8,6 +8,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/pkg/errors" + "golang.org/x/exp/maps" commonfee "github.com/smartcontractkit/chainlink/v2/common/fee" feetypes "github.com/smartcontractkit/chainlink/v2/common/fee/types" @@ -15,11 +16,13 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/assets" evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" evmconfig "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/label" evmtypes "github.com/smartcontractkit/chainlink/v2/core/chains/evm/types" "github.com/smartcontractkit/chainlink/v2/core/config" "github.com/smartcontractkit/chainlink/v2/core/logger" "github.com/smartcontractkit/chainlink/v2/core/services" + "github.com/smartcontractkit/chainlink/v2/core/utils" bigmath "github.com/smartcontractkit/chainlink/v2/core/utils/big_math" ) @@ -30,6 +33,8 @@ type EvmFeeEstimator interface { services.ServiceCtx commontypes.HeadTrackable[*evmtypes.Head, common.Hash] + // L1Oracle returns the L1 gas price oracle only if the chain has one, e.g. OP stack L2s and Arbitrum. + L1Oracle() rollups.L1Oracle GetFee(ctx context.Context, calldata []byte, feeLimit uint32, maxFeePrice *assets.Wei, opts ...feetypes.Opt) (fee EvmFee, chainSpecificFeeLimit uint32, err error) BumpFee(ctx context.Context, originalFee EvmFee, feeLimit uint32, maxFeePrice *assets.Wei, attempts []EvmPriorAttempt) (bumpedFee EvmFee, chainSpecificFeeLimit uint32, err error) @@ -61,18 +66,24 @@ func NewEstimator(lggr logger.Logger, ethClient evmclient.Client, cfg Config, ge "priceMin", geCfg.PriceMin(), ) df := geCfg.EIP1559DynamicFees() + + // create l1Oracle only if it is supported for the chain + var l1Oracle rollups.L1Oracle + if rollups.IsRollupWithL1Support(cfg.ChainType()) { + l1Oracle = rollups.NewL1GasPriceOracle(lggr, ethClient, cfg.ChainType()) + } switch s { case "Arbitrum": - return NewWrappedEvmEstimator(NewArbitrumEstimator(lggr, geCfg, ethClient, ethClient), df) + return NewWrappedEvmEstimator(NewArbitrumEstimator(lggr, geCfg, ethClient, ethClient), df, l1Oracle) case "BlockHistory": - return NewWrappedEvmEstimator(NewBlockHistoryEstimator(lggr, ethClient, cfg, geCfg, bh, *ethClient.ConfiguredChainID()), df) + return NewWrappedEvmEstimator(NewBlockHistoryEstimator(lggr, ethClient, cfg, geCfg, bh, *ethClient.ConfiguredChainID()), df, l1Oracle) case "FixedPrice": - return NewWrappedEvmEstimator(NewFixedPriceEstimator(geCfg, bh, lggr), df) + return NewWrappedEvmEstimator(NewFixedPriceEstimator(geCfg, bh, lggr), df, l1Oracle) case "Optimism2", "L2Suggested": - return NewWrappedEvmEstimator(NewL2SuggestedPriceEstimator(lggr, ethClient), df) + return NewWrappedEvmEstimator(NewL2SuggestedPriceEstimator(lggr, ethClient), df, l1Oracle) default: lggr.Warnf("GasEstimator: unrecognised mode '%s', falling back to FixedPriceEstimator", s) - return NewWrappedEvmEstimator(NewFixedPriceEstimator(geCfg, bh, lggr), df) + return NewWrappedEvmEstimator(NewFixedPriceEstimator(geCfg, bh, lggr), df, l1Oracle) } } @@ -141,18 +152,82 @@ func (fee EvmFee) ValidDynamic() bool { type WrappedEvmEstimator struct { EvmEstimator EIP1559Enabled bool + l1Oracle rollups.L1Oracle + utils.StartStopOnce } var _ EvmFeeEstimator = (*WrappedEvmEstimator)(nil) -func NewWrappedEvmEstimator(e EvmEstimator, eip1559Enabled bool) EvmFeeEstimator { +func NewWrappedEvmEstimator(e EvmEstimator, eip1559Enabled bool, l1Oracle rollups.L1Oracle) EvmFeeEstimator { return &WrappedEvmEstimator{ EvmEstimator: e, EIP1559Enabled: eip1559Enabled, + l1Oracle: l1Oracle, } } -func (e WrappedEvmEstimator) GetFee(ctx context.Context, calldata []byte, feeLimit uint32, maxFeePrice *assets.Wei, opts ...feetypes.Opt) (fee EvmFee, chainSpecificFeeLimit uint32, err error) { +func (e *WrappedEvmEstimator) Name() string { + return fmt.Sprintf("WrappedEvmEstimator(%s)", e.EvmEstimator.Name()) +} + +func (e *WrappedEvmEstimator) Start(ctx context.Context) error { + return e.StartOnce(e.Name(), func() error { + if err := e.EvmEstimator.Start(ctx); err != nil { + return errors.Wrap(err, "failed to start EVMEstimator") + } + if e.l1Oracle != nil { + if err := e.l1Oracle.Start(ctx); err != nil { + return errors.Wrap(err, "failed to start L1Oracle") + } + } + return nil + }) +} +func (e *WrappedEvmEstimator) Close() error { + return e.StopOnce(e.Name(), func() error { + var errEVM, errOracle error + + errEVM = errors.Wrap(e.EvmEstimator.Close(), "failed to stop EVMEstimator") + if e.l1Oracle != nil { + errOracle = errors.Wrap(e.l1Oracle.Close(), "failed to stop L1Oracle") + } + + if errEVM != nil { + return errEVM + } + return errOracle + }) +} + +func (e *WrappedEvmEstimator) Ready() error { + var errEVM, errOracle error + + errEVM = e.EvmEstimator.Ready() + if e.l1Oracle != nil { + errOracle = e.l1Oracle.Ready() + } + + if errEVM != nil { + return errEVM + } + return errOracle +} + +func (e *WrappedEvmEstimator) HealthReport() map[string]error { + report := map[string]error{e.Name(): e.StartStopOnce.Healthy()} + maps.Copy(report, e.EvmEstimator.HealthReport()) + if e.l1Oracle != nil { + maps.Copy(report, e.l1Oracle.HealthReport()) + } + + return report +} + +func (e *WrappedEvmEstimator) L1Oracle() rollups.L1Oracle { + return e.l1Oracle +} + +func (e *WrappedEvmEstimator) GetFee(ctx context.Context, calldata []byte, feeLimit uint32, maxFeePrice *assets.Wei, opts ...feetypes.Opt) (fee EvmFee, chainSpecificFeeLimit uint32, err error) { // get dynamic fee if e.EIP1559Enabled { var dynamicFee DynamicFee @@ -167,7 +242,7 @@ func (e WrappedEvmEstimator) GetFee(ctx context.Context, calldata []byte, feeLim return } -func (e WrappedEvmEstimator) GetMaxCost(ctx context.Context, amount assets.Eth, calldata []byte, feeLimit uint32, maxFeePrice *assets.Wei, opts ...feetypes.Opt) (*big.Int, error) { +func (e *WrappedEvmEstimator) GetMaxCost(ctx context.Context, amount assets.Eth, calldata []byte, feeLimit uint32, maxFeePrice *assets.Wei, opts ...feetypes.Opt) (*big.Int, error) { fees, gasLimit, err := e.GetFee(ctx, calldata, feeLimit, maxFeePrice, opts...) if err != nil { return nil, err @@ -185,7 +260,7 @@ func (e WrappedEvmEstimator) GetMaxCost(ctx context.Context, amount assets.Eth, return amountWithFees, nil } -func (e WrappedEvmEstimator) BumpFee(ctx context.Context, originalFee EvmFee, feeLimit uint32, maxFeePrice *assets.Wei, attempts []EvmPriorAttempt) (bumpedFee EvmFee, chainSpecificFeeLimit uint32, err error) { +func (e *WrappedEvmEstimator) BumpFee(ctx context.Context, originalFee EvmFee, feeLimit uint32, maxFeePrice *assets.Wei, attempts []EvmPriorAttempt) (bumpedFee EvmFee, chainSpecificFeeLimit uint32, err error) { // validate only 1 fee type is present if (!originalFee.ValidDynamic() && originalFee.Legacy == nil) || (originalFee.ValidDynamic() && originalFee.Legacy != nil) { err = errors.New("only one dynamic or legacy fee can be defined") diff --git a/core/chains/evm/gas/models_test.go b/core/chains/evm/gas/models_test.go index 048646a980c..c1dd9e44ffc 100644 --- a/core/chains/evm/gas/models_test.go +++ b/core/chains/evm/gas/models_test.go @@ -5,6 +5,7 @@ import ( "math/big" "testing" + "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -12,6 +13,7 @@ import ( "github.com/smartcontractkit/chainlink/v2/core/assets" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/mocks" + rollupMocks "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" ) func TestWrappedEvmEstimator(t *testing.T) { @@ -36,11 +38,28 @@ func TestWrappedEvmEstimator(t *testing.T) { e.On("BumpLegacyGas", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). Return(legacyFee, gasLimit, nil).Once() + mockEvmEstimatorName := "MockEstimator" + mockEstimatorName := "WrappedEvmEstimator(MockEstimator)" + + // L1Oracle returns the correct L1Oracle interface + t.Run("L1Oracle", func(t *testing.T) { + // expect nil + estimator := gas.NewWrappedEvmEstimator(e, false, nil) + l1Oracle := estimator.L1Oracle() + assert.Nil(t, l1Oracle) + + // expect l1Oracle + oracle := rollupMocks.NewL1Oracle(t) + estimator = gas.NewWrappedEvmEstimator(e, false, oracle) + l1Oracle = estimator.L1Oracle() + assert.Equal(t, oracle, l1Oracle) + }) + // GetFee returns gas estimation based on configuration value t.Run("GetFee", func(t *testing.T) { // expect legacy fee data dynamicFees := false - estimator := gas.NewWrappedEvmEstimator(e, dynamicFees) + estimator := gas.NewWrappedEvmEstimator(e, dynamicFees, nil) fee, max, err := estimator.GetFee(ctx, nil, 0, nil) require.NoError(t, err) assert.Equal(t, gasLimit, max) @@ -50,7 +69,7 @@ func TestWrappedEvmEstimator(t *testing.T) { // expect dynamic fee data dynamicFees = true - estimator = gas.NewWrappedEvmEstimator(e, dynamicFees) + estimator = gas.NewWrappedEvmEstimator(e, dynamicFees, nil) fee, max, err = estimator.GetFee(ctx, nil, 0, nil) require.NoError(t, err) assert.Equal(t, gasLimit, max) @@ -62,7 +81,7 @@ func TestWrappedEvmEstimator(t *testing.T) { // BumpFee returns bumped fee type based on original fee calculation t.Run("BumpFee", func(t *testing.T) { dynamicFees := false - estimator := gas.NewWrappedEvmEstimator(e, dynamicFees) + estimator := gas.NewWrappedEvmEstimator(e, dynamicFees, nil) // expect legacy fee data fee, max, err := estimator.BumpFee(ctx, gas.EvmFee{Legacy: assets.NewWeiI(0)}, 0, nil, nil) @@ -99,7 +118,7 @@ func TestWrappedEvmEstimator(t *testing.T) { // expect legacy fee data dynamicFees := false - estimator := gas.NewWrappedEvmEstimator(e, dynamicFees) + estimator := gas.NewWrappedEvmEstimator(e, dynamicFees, nil) total, err := estimator.GetMaxCost(ctx, val, nil, gasLimit, nil) require.NoError(t, err) fee := new(big.Int).Mul(legacyFee.ToInt(), big.NewInt(int64(gasLimit))) @@ -107,10 +126,86 @@ func TestWrappedEvmEstimator(t *testing.T) { // expect dynamic fee data dynamicFees = true - estimator = gas.NewWrappedEvmEstimator(e, dynamicFees) + estimator = gas.NewWrappedEvmEstimator(e, dynamicFees, nil) total, err = estimator.GetMaxCost(ctx, val, nil, gasLimit, nil) require.NoError(t, err) fee = new(big.Int).Mul(dynamicFee.FeeCap.ToInt(), big.NewInt(int64(gasLimit))) assert.Equal(t, new(big.Int).Add(val.ToInt(), fee), total) }) + + t.Run("Name", func(t *testing.T) { + evmEstimator := mocks.NewEvmEstimator(t) + oracle := rollupMocks.NewL1Oracle(t) + + evmEstimator.On("Name").Return(mockEvmEstimatorName, nil).Once() + + estimator := gas.NewWrappedEvmEstimator(evmEstimator, false, oracle) + name := estimator.Name() + require.Equal(t, mockEstimatorName, name) + }) + + t.Run("Start and stop calls both EVM estimator and L1Oracle", func(t *testing.T) { + evmEstimator := mocks.NewEvmEstimator(t) + oracle := rollupMocks.NewL1Oracle(t) + + evmEstimator.On("Name").Return(mockEvmEstimatorName, nil).Times(4) + evmEstimator.On("Start", mock.Anything).Return(nil).Twice() + evmEstimator.On("Close").Return(nil).Twice() + oracle.On("Start", mock.Anything).Return(nil).Once() + oracle.On("Close").Return(nil).Once() + + estimator := gas.NewWrappedEvmEstimator(evmEstimator, false, nil) + err := estimator.Start(ctx) + require.NoError(t, err) + err = estimator.Close() + require.NoError(t, err) + + estimator = gas.NewWrappedEvmEstimator(evmEstimator, false, oracle) + err = estimator.Start(ctx) + require.NoError(t, err) + err = estimator.Close() + require.NoError(t, err) + }) + + t.Run("Read calls both EVM estimator and L1Oracle", func(t *testing.T) { + evmEstimator := mocks.NewEvmEstimator(t) + oracle := rollupMocks.NewL1Oracle(t) + + evmEstimator.On("Ready").Return(nil).Twice() + oracle.On("Ready").Return(nil).Once() + + estimator := gas.NewWrappedEvmEstimator(evmEstimator, false, nil) + err := estimator.Ready() + require.NoError(t, err) + + estimator = gas.NewWrappedEvmEstimator(evmEstimator, false, oracle) + err = estimator.Ready() + require.NoError(t, err) + }) + + t.Run("HealthReport merges report from EVM estimator and L1Oracle", func(t *testing.T) { + evmEstimator := mocks.NewEvmEstimator(t) + oracle := rollupMocks.NewL1Oracle(t) + + evmEstimatorKey := "evm" + evmEstimatorError := errors.New("evm error") + oracleKey := "oracle" + oracleError := errors.New("oracle error") + + evmEstimator.On("Name").Return(mockEvmEstimatorName, nil).Twice() + evmEstimator.On("HealthReport").Return(map[string]error{evmEstimatorKey: evmEstimatorError}).Twice() + oracle.On("HealthReport").Return(map[string]error{oracleKey: oracleError}).Once() + + estimator := gas.NewWrappedEvmEstimator(evmEstimator, false, nil) + report := estimator.HealthReport() + require.True(t, errors.Is(report[evmEstimatorKey], evmEstimatorError)) + require.Nil(t, report[oracleKey]) + require.NotNil(t, report[mockEstimatorName]) + + estimator = gas.NewWrappedEvmEstimator(evmEstimator, false, oracle) + report = estimator.HealthReport() + require.True(t, errors.Is(report[evmEstimatorKey], evmEstimatorError)) + require.True(t, errors.Is(report[oracleKey], oracleError)) + require.NotNil(t, report[mockEstimatorName]) + }) } diff --git a/core/chains/evm/gas/rollups/l1_gas_price_oracle.go b/core/chains/evm/gas/rollups/l1_gas_price_oracle.go new file mode 100644 index 00000000000..13ec5e29dd8 --- /dev/null +++ b/core/chains/evm/gas/rollups/l1_gas_price_oracle.go @@ -0,0 +1,176 @@ +package rollups + +import ( + "context" + "errors" + "fmt" + "math/big" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "golang.org/x/exp/slices" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/logger" + "github.com/smartcontractkit/chainlink/v2/core/utils" +) + +//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) +} + +// Reads L2-specific precompiles and caches the l1GasPrice set by the L2. +type l1GasPriceOracle struct { + client ethClient + pollPeriod time.Duration + logger logger.Logger + address string + callArgs string + + l1GasPriceMu sync.RWMutex + l1GasPrice *assets.Wei + + chInitialised chan struct{} + chStop utils.StopChan + chDone chan struct{} + utils.StartStopOnce +} + +const ( + // ArbGasInfoAddress is the address of the "Precompiled contract that exists in every Arbitrum chain." + // https://github.com/OffchainLabs/nitro/blob/f7645453cfc77bf3e3644ea1ac031eff629df325/contracts/src/precompiles/ArbGasInfo.sol + ArbGasInfoAddress = "0x000000000000000000000000000000000000006C" + // ArbGasInfo_getL1BaseFeeEstimate is the a hex encoded call to: + // `function getL1BaseFeeEstimate() external view returns (uint256);` + ArbGasInfo_getL1BaseFeeEstimate = "f5d6ded7" + + // GasOracleAddress is the address of the precompiled contract that exists on OP stack chain. + // This is the case for Optimism and Base. + OPGasOracleAddress = "0x420000000000000000000000000000000000000F" + // GasOracle_l1BaseFee is the a hex encoded call to: + // `function l1BaseFee() external view returns (uint256);` + OPGasOracle_l1BaseFee = "519b4bd3" + + // Interval at which to poll for L1BaseFee. A good starting point is the L1 block time. + PollPeriod = 12 * time.Second +) + +var supportedChainTypes = []config.ChainType{config.ChainArbitrum, config.ChainOptimismBedrock} + +func IsRollupWithL1Support(chainType config.ChainType) bool { + return slices.Contains(supportedChainTypes, chainType) +} + +func NewL1GasPriceOracle(lggr logger.Logger, ethClient ethClient, chainType config.ChainType) L1Oracle { + var address, callArgs string + switch chainType { + case config.ChainArbitrum: + address = ArbGasInfoAddress + callArgs = ArbGasInfo_getL1BaseFeeEstimate + case config.ChainOptimismBedrock: + address = OPGasOracleAddress + callArgs = OPGasOracle_l1BaseFee + default: + panic(fmt.Sprintf("Received unspported chaintype %s", chainType)) + } + + return &l1GasPriceOracle{ + client: ethClient, + pollPeriod: PollPeriod, + logger: lggr.Named(fmt.Sprintf("L1GasPriceOracle(%s)", chainType)), + address: address, + callArgs: callArgs, + chInitialised: make(chan struct{}), + chStop: make(chan struct{}), + chDone: make(chan struct{}), + } +} + +func (o *l1GasPriceOracle) Name() string { + return o.logger.Name() +} + +func (o *l1GasPriceOracle) Start(ctx context.Context) error { + return o.StartOnce(o.Name(), func() error { + go o.run() + <-o.chInitialised + return nil + }) +} +func (o *l1GasPriceOracle) Close() error { + return o.StopOnce(o.Name(), func() error { + close(o.chStop) + <-o.chDone + return nil + }) +} + +func (o *l1GasPriceOracle) Ready() error { return o.StartStopOnce.Ready() } + +func (o *l1GasPriceOracle) HealthReport() map[string]error { + return map[string]error{o.Name(): o.StartStopOnce.Healthy()} +} + +func (o *l1GasPriceOracle) run() { + defer close(o.chDone) + + t := o.refresh() + close(o.chInitialised) + + for { + select { + case <-o.chStop: + return + case <-t.C: + t = o.refresh() + } + } +} + +func (o *l1GasPriceOracle) refresh() (t *time.Timer) { + t = time.NewTimer(utils.WithJitter(o.pollPeriod)) + + ctx, cancel := o.chStop.CtxCancel(evmclient.ContextWithDefaultTimeout()) + defer cancel() + + precompile := common.HexToAddress(o.address) + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &precompile, + Data: common.Hex2Bytes(o.callArgs), + }, nil) + if err != nil { + o.logger.Errorf("gas oracle contract call failed: %v", err) + return + } + + if len(b) != 32 { // returns uint256; + o.logger.Criticalf("return data length (%d) different than expected (%d)", len(b), 32) + return + } + price := new(big.Int).SetBytes(b) + + o.l1GasPriceMu.Lock() + defer o.l1GasPriceMu.Unlock() + o.l1GasPrice = assets.NewWei(price) + return +} + +func (o *l1GasPriceOracle) GasPrice(_ context.Context) (l1GasPrice *assets.Wei, err error) { + ok := o.IfStarted(func() { + o.l1GasPriceMu.RLock() + l1GasPrice = o.l1GasPrice + o.l1GasPriceMu.RUnlock() + }) + if !ok { + return l1GasPrice, errors.New("L1GasPriceOracle is not started; cannot estimate gas") + } + if l1GasPrice == nil { + return l1GasPrice, errors.New("failed to get l1 gas price; gas price not set") + } + return +} diff --git a/core/chains/evm/gas/rollups/l1_gas_price_oracle_test.go b/core/chains/evm/gas/rollups/l1_gas_price_oracle_test.go new file mode 100644 index 00000000000..9fd2a66201c --- /dev/null +++ b/core/chains/evm/gas/rollups/l1_gas_price_oracle_test.go @@ -0,0 +1,83 @@ +package rollups + +import ( + "fmt" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/config" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" + "github.com/smartcontractkit/chainlink/v2/core/logger" +) + +func TestL1GasPriceOracle(t *testing.T) { + t.Parallel() + + t.Run("Unsupported ChainType returns nil", func(t *testing.T) { + ethClient := mocks.NewETHClient(t) + + assert.Panicsf(t, func() { NewL1GasPriceOracle(logger.TestLogger(t), ethClient, config.ChainCelo) }, "Received unspported chaintype %s", config.ChainCelo) + }) + + t.Run("Calling L1GasPrice on unstarted L1Oracle returns error", func(t *testing.T) { + ethClient := mocks.NewETHClient(t) + + oracle := NewL1GasPriceOracle(logger.TestLogger(t), ethClient, config.ChainOptimismBedrock) + + _, err := oracle.GasPrice(testutils.Context(t)) + assert.EqualError(t, err, "L1GasPriceOracle is not started; cannot estimate gas") + }) + + t.Run("Calling GasPrice on started Arbitrum L1Oracle returns Arbitrum l1GasPrice", func(t *testing.T) { + l1BaseFee := big.NewInt(100) + + 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) + assert.Equal(t, ArbGasInfoAddress, callMsg.To.String()) + assert.Equal(t, ArbGasInfo_getL1BaseFeeEstimate, fmt.Sprintf("%x", callMsg.Data)) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) + + oracle := NewL1GasPriceOracle(logger.TestLogger(t), ethClient, config.ChainArbitrum) + require.NoError(t, oracle.Start(testutils.Context(t))) + t.Cleanup(func() { assert.NoError(t, oracle.Close()) }) + + gasPrice, err := oracle.GasPrice(testutils.Context(t)) + require.NoError(t, err) + + assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) + }) + + t.Run("Calling GasPrice on started OPStack L1Oracle returns OPStack l1GasPrice", func(t *testing.T) { + l1BaseFee := big.NewInt(200) + + 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) + assert.Equal(t, OPGasOracleAddress, callMsg.To.String()) + assert.Equal(t, OPGasOracle_l1BaseFee, fmt.Sprintf("%x", callMsg.Data)) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) + + oracle := NewL1GasPriceOracle(logger.TestLogger(t), ethClient, config.ChainOptimismBedrock) + require.NoError(t, oracle.Start(testutils.Context(t))) + t.Cleanup(func() { assert.NoError(t, oracle.Close()) }) + + gasPrice, err := oracle.GasPrice(testutils.Context(t)) + require.NoError(t, err) + + assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) + }) +} diff --git a/core/chains/evm/gas/rollups/mocks/eth_client.go b/core/chains/evm/gas/rollups/mocks/eth_client.go new file mode 100644 index 00000000000..5389661bc56 --- /dev/null +++ b/core/chains/evm/gas/rollups/mocks/eth_client.go @@ -0,0 +1,58 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + big "math/big" + + ethereum "github.com/ethereum/go-ethereum" + + mock "github.com/stretchr/testify/mock" +) + +// ETHClient is an autogenerated mock type for the ethClient type +type ETHClient struct { + mock.Mock +} + +// 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) + + var r0 []byte + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg, *big.Int) ([]byte, error)); ok { + return rf(ctx, msg, blockNumber) + } + if rf, ok := ret.Get(0).(func(context.Context, ethereum.CallMsg, *big.Int) []byte); ok { + r0 = rf(ctx, msg, blockNumber) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]byte) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, ethereum.CallMsg, *big.Int) error); ok { + r1 = rf(ctx, msg, blockNumber) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewETHClient interface { + mock.TestingT + Cleanup(func()) +} + +// NewETHClient creates a new instance of ETHClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewETHClient(t mockConstructorTestingTNewETHClient) *ETHClient { + mock := ÐClient{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/gas/rollups/mocks/l1_oracle.go b/core/chains/evm/gas/rollups/mocks/l1_oracle.go new file mode 100644 index 00000000000..e148c0e9ac4 --- /dev/null +++ b/core/chains/evm/gas/rollups/mocks/l1_oracle.go @@ -0,0 +1,129 @@ +// Code generated by mockery v2.28.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + assets "github.com/smartcontractkit/chainlink/v2/core/assets" + + mock "github.com/stretchr/testify/mock" +) + +// L1Oracle is an autogenerated mock type for the L1Oracle type +type L1Oracle struct { + mock.Mock +} + +// Close provides a mock function with given fields: +func (_m *L1Oracle) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GasPrice provides a mock function with given fields: ctx +func (_m *L1Oracle) GasPrice(ctx context.Context) (*assets.Wei, error) { + ret := _m.Called(ctx) + + var r0 *assets.Wei + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*assets.Wei, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *assets.Wei); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*assets.Wei) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// HealthReport provides a mock function with given fields: +func (_m *L1Oracle) HealthReport() map[string]error { + ret := _m.Called() + + var r0 map[string]error + if rf, ok := ret.Get(0).(func() map[string]error); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]error) + } + } + + return r0 +} + +// Name provides a mock function with given fields: +func (_m *L1Oracle) Name() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// Ready provides a mock function with given fields: +func (_m *L1Oracle) Ready() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Start provides a mock function with given fields: _a0 +func (_m *L1Oracle) Start(_a0 context.Context) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewL1Oracle interface { + mock.TestingT + Cleanup(func()) +} + +// NewL1Oracle creates a new instance of L1Oracle. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewL1Oracle(t mockConstructorTestingTNewL1Oracle) *L1Oracle { + mock := &L1Oracle{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/core/chains/evm/gas/rollups/models.go b/core/chains/evm/gas/rollups/models.go new file mode 100644 index 00000000000..83ae29f4ea9 --- /dev/null +++ b/core/chains/evm/gas/rollups/models.go @@ -0,0 +1,18 @@ +package rollups + +import ( + "context" + + "github.com/smartcontractkit/chainlink/v2/core/assets" + "github.com/smartcontractkit/chainlink/v2/core/services" +) + +// L1Oracle provides interface for fetching L1-specific fee components if the chain is an L2. +// For example, on Optimistic Rollups, this oracle can return rollup-specific l1BaseFee +// +//go:generate mockery --quiet --name L1Oracle --output ./mocks/ --case=underscore +type L1Oracle interface { + services.ServiceCtx + + GasPrice(ctx context.Context) (*assets.Wei, error) +} diff --git a/core/chains/evm/txmgr/broadcaster_test.go b/core/chains/evm/txmgr/broadcaster_test.go index aa0368ecc09..e1133e8ef21 100644 --- a/core/chains/evm/txmgr/broadcaster_test.go +++ b/core/chains/evm/txmgr/broadcaster_test.go @@ -67,7 +67,7 @@ func NewTestEthBroadcaster( lggr := logger.TestLogger(t) ge := config.EVM().GasEstimator() - estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(config.EVM().GasEstimator(), ge.BlockHistory(), lggr), ge.EIP1559DynamicFees()) + estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(config.EVM().GasEstimator(), ge.BlockHistory(), lggr), ge.EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), ge, keyStore, estimator) txNonceSyncer := txmgr.NewNonceSyncer(txStore, lggr, ethClient, keyStore) ethBroadcaster := txmgr.NewEvmBroadcaster(txStore, txmgr.NewEvmTxmClient(ethClient), txmgr.NewEvmTxmConfig(config.EVM()), txmgr.NewEvmTxmFeeConfig(config.EVM().GasEstimator()), config.EVM().Transactions(), config.Database().Listener(), keyStore, eb, txBuilder, txNonceSyncer, lggr, checkerFactory, nonceAutoSync) @@ -1123,7 +1123,7 @@ func TestEthBroadcaster_ProcessUnstartedEthTxs_Errors(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { assert.NoError(t, eventBroadcaster.Close()) }) lggr := logger.TestLogger(t) - estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(evmcfg.EVM().GasEstimator(), evmcfg.EVM().GasEstimator().BlockHistory(), lggr), evmcfg.EVM().GasEstimator().EIP1559DynamicFees()) + estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(evmcfg.EVM().GasEstimator(), evmcfg.EVM().GasEstimator().BlockHistory(), lggr), evmcfg.EVM().GasEstimator().EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), evmcfg.EVM().GasEstimator(), ethKeyStore, estimator) eb = txmgr.NewEvmBroadcaster(txStore, txmgr.NewEvmTxmClient(ethClient), txmgr.NewEvmTxmConfig(evmcfg.EVM()), txmgr.NewEvmTxmFeeConfig(evmcfg.EVM().GasEstimator()), evmcfg.EVM().Transactions(), evmcfg.Database().Listener(), ethKeyStore, eventBroadcaster, txBuilder, nil, lggr, &testCheckerFactory{}, false) { @@ -1771,7 +1771,7 @@ func TestEthBroadcaster_SyncNonce(t *testing.T) { sub.On("Events").Return(make(<-chan pg.Event)) sub.On("Close") eventBroadcaster.On("Subscribe", "evm.insert_on_txes", "").Return(sub, nil) - estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(evmcfg.EVM().GasEstimator(), evmcfg.EVM().GasEstimator().BlockHistory(), lggr), evmcfg.EVM().GasEstimator().EIP1559DynamicFees()) + estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(evmcfg.EVM().GasEstimator(), evmcfg.EVM().GasEstimator().BlockHistory(), lggr), evmcfg.EVM().GasEstimator().EIP1559DynamicFees(), nil) checkerFactory := &testCheckerFactory{} ge := evmcfg.EVM().GasEstimator() diff --git a/core/chains/evm/txmgr/confirmer_test.go b/core/chains/evm/txmgr/confirmer_test.go index 795725cdb2d..555ea09ff3a 100644 --- a/core/chains/evm/txmgr/confirmer_test.go +++ b/core/chains/evm/txmgr/confirmer_test.go @@ -122,7 +122,7 @@ func TestEthConfirmer_Lifecycle(t *testing.T) { estimator := gasmocks.NewEvmEstimator(t) lggr := logger.TestLogger(t) ge := config.EVM().GasEstimator() - feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees()) + feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), ge, ethKeyStore, feeEstimator) ec := txmgr.NewEvmConfirmer(txStore, txmgr.NewEvmTxmClient(ethClient), txmgr.NewEvmTxmConfig(config.EVM()), txmgr.NewEvmTxmFeeConfig(ge), config.EVM().Transactions(), config.Database(), ethKeyStore, txBuilder, lggr) ctx := testutils.Context(t) @@ -1645,7 +1645,7 @@ func TestEthConfirmer_RebroadcastWhereNecessary_WithConnectivityCheck(t *testing estimator := gasmocks.NewEvmEstimator(t) estimator.On("BumpLegacyGas", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(nil, uint32(0), pkgerrors.Wrapf(commonfee.ErrConnectivity, "transaction...")) ge := ccfg.EVM().GasEstimator() - feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees()) + feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), ge, kst, feeEstimator) addresses := []gethCommon.Address{fromAddress} kst.On("EnabledAddressesForChain", &cltest.FixtureChainID).Return(addresses, nil).Maybe() @@ -1690,7 +1690,7 @@ func TestEthConfirmer_RebroadcastWhereNecessary_WithConnectivityCheck(t *testing estimator.On("BumpDynamicFee", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(gas.DynamicFee{}, uint32(0), pkgerrors.Wrapf(commonfee.ErrConnectivity, "transaction...")) // Create confirmer with necessary state ge := ccfg.EVM().GasEstimator() - feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees()) + feeEstimator := gas.NewWrappedEvmEstimator(estimator, ge.EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), ge, kst, feeEstimator) addresses := []gethCommon.Address{fromAddress} kst.On("EnabledAddressesForChain", &cltest.FixtureChainID).Return(addresses, nil).Maybe() diff --git a/core/internal/cltest/cltest.go b/core/internal/cltest/cltest.go index 8799ff42776..139d66b590f 100644 --- a/core/internal/cltest/cltest.go +++ b/core/internal/cltest/cltest.go @@ -213,7 +213,7 @@ func NewEthConfirmer(t testing.TB, txStore txmgr.EvmTxStore, ethClient evmclient t.Helper() lggr := logger.TestLogger(t) ge := config.EVM().GasEstimator() - estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(ge, ge.BlockHistory(), lggr), ge.EIP1559DynamicFees()) + estimator := gas.NewWrappedEvmEstimator(gas.NewFixedPriceEstimator(ge, ge.BlockHistory(), lggr), ge.EIP1559DynamicFees(), nil) txBuilder := txmgr.NewEvmTxAttemptBuilder(*ethClient.ConfiguredChainID(), ge, ks, estimator) ec := txmgr.NewEvmConfirmer(txStore, txmgr.NewEvmTxmClient(ethClient), txmgr.NewEvmTxmConfig(config.EVM()), txmgr.NewEvmTxmFeeConfig(ge), config.EVM().Transactions(), config.Database(), ks, txBuilder, lggr) ec.SetResumeCallback(fn)