diff --git a/core/chains/evm/gas/models.go b/core/chains/evm/gas/models.go index 9f0e8ab0ba6..0b3ebdd0ee3 100644 --- a/core/chains/evm/gas/models.go +++ b/core/chains/evm/gas/models.go @@ -69,7 +69,7 @@ func NewEstimator(lggr logger.Logger, ethClient evmclient.Client, cfg Config, ge // 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()) + l1Oracle = rollups.NewL1GasOracle(lggr, ethClient, cfg.ChainType()) } var newEstimator func(logger.Logger) EvmEstimator switch s { diff --git a/core/chains/evm/gas/rollups/l1_gas_price_oracle.go b/core/chains/evm/gas/rollups/l1_gas_price_oracle.go deleted file mode 100644 index a006ea89923..00000000000 --- a/core/chains/evm/gas/rollups/l1_gas_price_oracle.go +++ /dev/null @@ -1,196 +0,0 @@ -package rollups - -import ( - "context" - "errors" - "fmt" - "math/big" - "slices" - "sync" - "time" - - "github.com/ethereum/go-ethereum" - "github.com/ethereum/go-ethereum/common" - - "github.com/smartcontractkit/chainlink-common/pkg/logger" - "github.com/smartcontractkit/chainlink-common/pkg/services" - "github.com/smartcontractkit/chainlink-common/pkg/utils" - - "github.com/smartcontractkit/chainlink/v2/common/config" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" - evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" -) - -//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 { - services.StateMachine - client ethClient - pollPeriod time.Duration - logger logger.SugaredLogger - address string - callArgs string - - l1GasPriceMu sync.RWMutex - l1GasPrice *assets.Wei - - chInitialised chan struct{} - chStop services.StopChan - chDone chan struct{} -} - -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" - - // GasOracleAddress is the address of the precompiled contract that exists on Kroma chain. - // This is the case for Kroma. - KromaGasOracleAddress = "0x4200000000000000000000000000000000000005" - // GasOracle_l1BaseFee is the a hex encoded call to: - // `function l1BaseFee() external view returns (uint256);` - KromaGasOracle_l1BaseFee = "519b4bd3" - - // GasOracleAddress is the address of the precompiled contract that exists on scroll chain. - // This is the case for Scroll. - ScrollGasOracleAddress = "0x5300000000000000000000000000000000000002" - // GasOracle_l1BaseFee is the a hex encoded call to: - // `function l1BaseFee() external view returns (uint256);` - ScrollGasOracle_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, config.ChainKroma, config.ChainScroll} - -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 - case config.ChainKroma: - address = KromaGasOracleAddress - callArgs = KromaGasOracle_l1BaseFee - case config.ChainScroll: - address = ScrollGasOracleAddress - callArgs = ScrollGasOracle_l1BaseFee - default: - panic(fmt.Sprintf("Received unspported chaintype %s", chainType)) - } - - return &l1GasPriceOracle{ - client: ethClient, - pollPeriod: PollPeriod, - logger: logger.Sugared(logger.Named(lggr, 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) HealthReport() map[string]error { - return map[string]error{o.Name(): o.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 deleted file mode 100644 index 188376bf832..00000000000 --- a/core/chains/evm/gas/rollups/l1_gas_price_oracle_test.go +++ /dev/null @@ -1,125 +0,0 @@ -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-common/pkg/logger" - "github.com/smartcontractkit/chainlink-common/pkg/services/servicetest" - - "github.com/smartcontractkit/chainlink/v2/common/config" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" - "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" - "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" -) - -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.Test(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.Test(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.Test(t), ethClient, config.ChainArbitrum) - servicetest.RunHealthy(t, oracle) - - gasPrice, err := oracle.GasPrice(testutils.Context(t)) - require.NoError(t, err) - - assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) - }) - - t.Run("Calling GasPrice on started Kroma L1Oracle returns Kroma 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, KromaGasOracleAddress, callMsg.To.String()) - assert.Equal(t, KromaGasOracle_l1BaseFee, fmt.Sprintf("%x", callMsg.Data)) - assert.Nil(t, blockNumber) - }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) - - oracle := NewL1GasPriceOracle(logger.Test(t), ethClient, config.ChainKroma) - servicetest.RunHealthy(t, oracle) - - 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.Test(t), ethClient, config.ChainOptimismBedrock) - servicetest.RunHealthy(t, oracle) - - gasPrice, err := oracle.GasPrice(testutils.Context(t)) - require.NoError(t, err) - - assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) - }) - - t.Run("Calling GasPrice on started Scroll L1Oracle returns Scroll 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, ScrollGasOracleAddress, callMsg.To.String()) - assert.Equal(t, ScrollGasOracle_l1BaseFee, fmt.Sprintf("%x", callMsg.Data)) - assert.Nil(t, blockNumber) - }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) - - oracle := NewL1GasPriceOracle(logger.Test(t), ethClient, config.ChainScroll) - 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/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go new file mode 100644 index 00000000000..e9cdc6b73b1 --- /dev/null +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -0,0 +1,331 @@ +package rollups + +import ( + "context" + "fmt" + "math/big" + "slices" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + "github.com/smartcontractkit/chainlink-common/pkg/utils" + + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/smartcontractkit/chainlink/v2/common/client" + "github.com/smartcontractkit/chainlink/v2/common/config" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client" +) + +//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) +} + +type priceEntry struct { + price *assets.Wei + timestamp time.Time +} + +// Reads L2-specific precompiles and caches the l1GasPrice set by the L2. +type l1Oracle struct { + services.StateMachine + client ethClient + pollPeriod time.Duration + logger logger.SugaredLogger + chainType config.ChainType + + l1GasPriceAddress string + gasPriceMethod string + l1GasPriceMethodAbi abi.ABI + l1GasPriceMu sync.RWMutex + l1GasPrice priceEntry + + l1GasCostAddress string + gasCostMethod string + l1GasCostMethodAbi abi.ABI + + chInitialised chan struct{} + chStop services.StopChan + chDone chan struct{} +} + +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 = "getL1BaseFeeEstimate" + // NodeInterfaceAddress is the address of the precompiled contract that is only available through RPC + // https://github.com/OffchainLabs/nitro/blob/e815395d2e91fb17f4634cad72198f6de79c6e61/nodeInterface/NodeInterface.go#L37 + ArbNodeInterfaceAddress = "0x00000000000000000000000000000000000000C8" + // ArbGasInfo_getPricesInArbGas is the a hex encoded call to: + // `function gasEstimateL1Component(address to, bool contractCreation, bytes calldata data) external payable returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate);` + ArbNodeInterface_gasEstimateL1Component = "gasEstimateL1Component" + + // OPGasOracleAddress is the address of the precompiled contract that exists on OP stack chain. + // This is the case for Optimism and Base. + OPGasOracleAddress = "0x420000000000000000000000000000000000000F" + // OPGasOracle_l1BaseFee is a hex encoded call to: + // `function l1BaseFee() external view returns (uint256);` + OPGasOracle_l1BaseFee = "l1BaseFee" + // OPGasOracle_getL1Fee is a hex encoded call to: + // `function getL1Fee(bytes) external view returns (uint256);` + OPGasOracle_getL1Fee = "getL1Fee" + + // ScrollGasOracleAddress is the address of the precompiled contract that exists on Scroll chain. + ScrollGasOracleAddress = "0x5300000000000000000000000000000000000002" + // ScrollGasOracle_l1BaseFee is a hex encoded call to: + // `function l1BaseFee() external view returns (uint256);` + ScrollGasOracle_l1BaseFee = "l1BaseFee" + // ScrollGasOracle_getL1Fee is a hex encoded call to: + // `function getL1Fee(bytes) external view returns (uint256);` + ScrollGasOracle_getL1Fee = "getL1Fee" + + // GasOracleAddress is the address of the precompiled contract that exists on Kroma chain. + // This is the case for Kroma. + KromaGasOracleAddress = "0x4200000000000000000000000000000000000005" + // GasOracle_l1BaseFee is the a hex encoded call to: + // `function l1BaseFee() external view returns (uint256);` + KromaGasOracle_l1BaseFee = "l1BaseFee" + + // Interval at which to poll for L1BaseFee. A good starting point is the L1 block time. + PollPeriod = 6 * time.Second +) + +var supportedChainTypes = []config.ChainType{config.ChainArbitrum, config.ChainOptimismBedrock, config.ChainKroma, config.ChainScroll} + +func IsRollupWithL1Support(chainType config.ChainType) bool { + return slices.Contains(supportedChainTypes, chainType) +} + +func NewL1GasOracle(lggr logger.Logger, ethClient ethClient, chainType config.ChainType) L1Oracle { + var l1GasPriceAddress, gasPriceMethod, l1GasCostAddress, gasCostMethod string + var l1GasPriceMethodAbi, l1GasCostMethodAbi abi.ABI + var gasPriceErr, gasCostErr error + switch chainType { + case config.ChainArbitrum: + l1GasPriceAddress = ArbGasInfoAddress + gasPriceMethod = ArbGasInfo_getL1BaseFeeEstimate + l1GasPriceMethodAbi, gasPriceErr = abi.JSON(strings.NewReader(GetL1BaseFeeEstimateAbiString)) + l1GasCostAddress = ArbNodeInterfaceAddress + gasCostMethod = ArbNodeInterface_gasEstimateL1Component + l1GasCostMethodAbi, gasCostErr = abi.JSON(strings.NewReader(GasEstimateL1ComponentAbiString)) + case config.ChainOptimismBedrock: + l1GasPriceAddress = OPGasOracleAddress + gasPriceMethod = OPGasOracle_l1BaseFee + l1GasPriceMethodAbi, gasPriceErr = abi.JSON(strings.NewReader(L1BaseFeeAbiString)) + l1GasCostAddress = OPGasOracleAddress + gasCostMethod = OPGasOracle_getL1Fee + l1GasCostMethodAbi, gasCostErr = abi.JSON(strings.NewReader(GetL1FeeAbiString)) + case config.ChainKroma: + l1GasPriceAddress = KromaGasOracleAddress + gasPriceMethod = KromaGasOracle_l1BaseFee + l1GasPriceMethodAbi, gasPriceErr = abi.JSON(strings.NewReader(L1BaseFeeAbiString)) + l1GasCostAddress = "" + gasCostMethod = "" + case config.ChainScroll: + l1GasPriceAddress = ScrollGasOracleAddress + gasPriceMethod = ScrollGasOracle_l1BaseFee + l1GasPriceMethodAbi, gasPriceErr = abi.JSON(strings.NewReader(L1BaseFeeAbiString)) + l1GasCostAddress = ScrollGasOracleAddress + gasCostMethod = ScrollGasOracle_getL1Fee + l1GasCostMethodAbi, gasCostErr = abi.JSON(strings.NewReader(GetL1FeeAbiString)) + default: + panic(fmt.Sprintf("Received unspported chaintype %s", chainType)) + } + + if gasPriceErr != nil { + panic(fmt.Sprintf("Failed to parse L1 gas price method ABI for chain: %s", chainType)) + } + if gasCostErr != nil { + panic(fmt.Sprintf("Failed to parse L1 gas cost method ABI for chain: %s", chainType)) + } + + return &l1Oracle{ + client: ethClient, + pollPeriod: PollPeriod, + logger: logger.Sugared(logger.Named(lggr, fmt.Sprintf("L1GasOracle(%s)", chainType))), + chainType: chainType, + + l1GasPriceAddress: l1GasPriceAddress, + gasPriceMethod: gasPriceMethod, + l1GasPriceMethodAbi: l1GasPriceMethodAbi, + l1GasCostAddress: l1GasCostAddress, + gasCostMethod: gasCostMethod, + l1GasCostMethodAbi: l1GasCostMethodAbi, + + chInitialised: make(chan struct{}), + chStop: make(chan struct{}), + chDone: make(chan struct{}), + } +} + +func (o *l1Oracle) Name() string { + return o.logger.Name() +} + +func (o *l1Oracle) Start(ctx context.Context) error { + return o.StartOnce(o.Name(), func() error { + go o.run() + <-o.chInitialised + return nil + }) +} +func (o *l1Oracle) Close() error { + return o.StopOnce(o.Name(), func() error { + close(o.chStop) + <-o.chDone + return nil + }) +} + +func (o *l1Oracle) HealthReport() map[string]error { + return map[string]error{o.Name(): o.Healthy()} +} + +func (o *l1Oracle) 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 *l1Oracle) refresh() (t *time.Timer) { + t, err := o.refreshWithError() + if err != nil { + o.SvcErrBuffer.Append(err) + } + return +} + +func (o *l1Oracle) refreshWithError() (t *time.Timer, err error) { + t = time.NewTimer(utils.WithJitter(o.pollPeriod)) + + ctx, cancel := o.chStop.CtxCancel(evmclient.ContextWithDefaultTimeout()) + defer cancel() + + 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) + } + b, err = o.client.CallContract(ctx, ethereum.CallMsg{ + To: &precompile, + Data: callData, + }, nil) + if err != nil { + errMsg := "gas oracle contract call failed" + o.logger.Errorf(errMsg) + return t, 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) + } + price := new(big.Int).SetBytes(b) + + o.l1GasPriceMu.Lock() + defer o.l1GasPriceMu.Unlock() + o.l1GasPrice = priceEntry{price: assets.NewWei(price), timestamp: time.Now()} + return +} + +func (o *l1Oracle) GasPrice(_ context.Context) (l1GasPrice *assets.Wei, err error) { + var timestamp time.Time + ok := o.IfStarted(func() { + o.l1GasPriceMu.RLock() + l1GasPrice = o.l1GasPrice.price + timestamp = o.l1GasPrice.timestamp + o.l1GasPriceMu.RUnlock() + }) + if !ok { + return l1GasPrice, fmt.Errorf("L1GasOracle is not started; cannot estimate gas") + } + if l1GasPrice == nil { + return l1GasPrice, fmt.Errorf("failed to get l1 gas price; gas price not set") + } + // Validate the price has been updated within the pollPeriod * 2 + // Allowing double the poll period before declaring the price stale to give ample time for the refresh to process + if time.Since(timestamp) > o.pollPeriod*2 { + return l1GasPrice, fmt.Errorf("gas price is stale") + } + return +} + +// Gets the L1 gas cost for the provided transaction at the specified block num +// If block num is not provided, the value on the latest block num is used +func (o *l1Oracle) GetGasCost(ctx context.Context, tx *gethtypes.Transaction, blockNum *big.Int) (*assets.Wei, error) { + ctx, cancel := context.WithTimeout(ctx, client.QueryTimeout) + defer cancel() + var callData, b []byte + var err error + if o.chainType == config.ChainOptimismBedrock || o.chainType == config.ChainScroll { + // Append rlp-encoded tx + var encodedtx []byte + if encodedtx, err = tx.MarshalBinary(); err != nil { + return nil, fmt.Errorf("failed to marshal tx for gas cost estimation: %w", err) + } + if callData, err = o.l1GasCostMethodAbi.Pack(o.gasCostMethod, encodedtx); err != nil { + return nil, fmt.Errorf("failed to pack calldata for %s L1 gas cost estimation method: %w", o.chainType, err) + } + } else if o.chainType == config.ChainArbitrum { + if callData, err = o.l1GasCostMethodAbi.Pack(o.gasCostMethod, tx.To(), false, tx.Data()); err != nil { + return nil, fmt.Errorf("failed to pack calldata for %s L1 gas cost estimation method: %w", o.chainType, err) + } + } else { + return nil, fmt.Errorf("L1 gas cost not supported for this chain: %s", o.chainType) + } + + precompile := common.HexToAddress(o.l1GasCostAddress) + b, err = o.client.CallContract(ctx, ethereum.CallMsg{ + To: &precompile, + Data: callData, + }, blockNum) + if err != nil { + errorMsg := fmt.Sprintf("gas oracle contract call failed: %v", err) + o.logger.Errorf(errorMsg) + return nil, fmt.Errorf(errorMsg) + } + + var l1GasCost *big.Int + if o.chainType == config.ChainOptimismBedrock || o.chainType == config.ChainScroll { + if len(b) != 32 { // returns uint256; + errorMsg := fmt.Sprintf("return data length (%d) different than expected (%d)", len(b), 32) + o.logger.Critical(errorMsg) + return nil, fmt.Errorf(errorMsg) + } + l1GasCost = new(big.Int).SetBytes(b) + } else if o.chainType == config.ChainArbitrum { + if len(b) != 8+2*32 { // returns (uint64 gasEstimateForL1, uint256 baseFee, uint256 l1BaseFeeEstimate); + errorMsg := fmt.Sprintf("return data length (%d) different than expected (%d)", len(b), 8+2*32) + o.logger.Critical(errorMsg) + return nil, fmt.Errorf(errorMsg) + } + l1GasCost = new(big.Int).SetBytes(b[:8]) + } + + return assets.NewWei(l1GasCost), nil +} diff --git a/core/chains/evm/gas/rollups/l1_oracle_abi.go b/core/chains/evm/gas/rollups/l1_oracle_abi.go new file mode 100644 index 00000000000..77ef4d49f3c --- /dev/null +++ b/core/chains/evm/gas/rollups/l1_oracle_abi.go @@ -0,0 +1,13 @@ +package rollups + +/* ABIs for Arbitrum Gas Info and Node Interface precompile contract methods needed for the L1 oracle */ +// ABI found at https://arbiscan.io/address/0x000000000000000000000000000000000000006C#code +const GetL1BaseFeeEstimateAbiString = `[{"inputs":[],"name":"getL1BaseFeeEstimate","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}]` + +// ABI found at https://arbiscan.io/address/0x00000000000000000000000000000000000000C8#code +const GasEstimateL1ComponentAbiString = `[{"inputs":[{"internalType":"address","name":"to","type":"address"},{"internalType":"bool","name":"contractCreation","type":"bool"},{"internalType":"bytes","name":"data","type":"bytes"}],"name":"gasEstimateL1Component","outputs":[{"internalType":"uint64","name":"gasEstimateForL1","type":"uint64"},{"internalType":"uint256","name":"baseFee","type":"uint256"},{"internalType":"uint256","name":"l1BaseFeeEstimate","type":"uint256"}],"stateMutability":"payable","type":"function"}]` + +/* ABIs for Optimism, Scroll, and Kroma precompile contract methods needed for the L1 oracle */ +// 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"}]` diff --git a/core/chains/evm/gas/rollups/l1_oracle_test.go b/core/chains/evm/gas/rollups/l1_oracle_test.go new file mode 100644 index 00000000000..8415e4d7805 --- /dev/null +++ b/core/chains/evm/gas/rollups/l1_oracle_test.go @@ -0,0 +1,270 @@ +package rollups + +import ( + "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/core/types" + "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-common/pkg/services/servicetest" + + "github.com/smartcontractkit/chainlink/v2/common/config" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils" + "github.com/smartcontractkit/chainlink/v2/core/internal/testutils" +) + +func TestL1Oracle(t *testing.T) { + t.Parallel() + + t.Run("Unsupported ChainType returns nil", func(t *testing.T) { + ethClient := mocks.NewETHClient(t) + + assert.Panicsf(t, func() { NewL1GasOracle(logger.Test(t), ethClient, config.ChainCelo) }, "Received unspported chaintype %s", config.ChainCelo) + }) +} + +func TestL1Oracle_GasPrice(t *testing.T) { + t.Parallel() + + t.Run("Calling GasPrice on unstarted L1Oracle returns error", func(t *testing.T) { + ethClient := mocks.NewETHClient(t) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainOptimismBedrock) + + _, err := oracle.GasPrice(testutils.Context(t)) + assert.EqualError(t, err, "L1GasOracle 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) + l1GasPriceMethodAbi, err := abi.JSON(strings.NewReader(GetL1BaseFeeEstimateAbiString)) + 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("getL1BaseFeeEstimate") + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(l1BaseFee).Bytes(), nil) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainArbitrum) + servicetest.RunHealthy(t, oracle) + + gasPrice, err := oracle.GasPrice(testutils.Context(t)) + require.NoError(t, err) + + assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) + }) + + 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) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainKroma) + servicetest.RunHealthy(t, oracle) + + 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(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) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainOptimismBedrock) + servicetest.RunHealthy(t, oracle) + + gasPrice, err := oracle.GasPrice(testutils.Context(t)) + require.NoError(t, err) + + assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) + }) + + t.Run("Calling GasPrice on started Scroll L1Oracle returns Scroll l1GasPrice", func(t *testing.T) { + l1BaseFee := big.NewInt(200) + 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) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainScroll) + 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) + }) +} + +func TestL1Oracle_GetGasCost(t *testing.T) { + t.Parallel() + + t.Run("Calling GetGasCost on started Arbitrum L1Oracle returns Arbitrum getL1Fee", func(t *testing.T) { + l1GasCost := big.NewInt(100) + baseFee := utils.Uint256ToBytes32(big.NewInt(1000)) + l1BaseFeeEstimate := utils.Uint256ToBytes32(big.NewInt(500)) + blockNum := big.NewInt(1000) + toAddress := utils.RandomAddress() + callData := []byte{1, 2, 3, 4, 5, 6, 7} + l1GasCostMethodAbi, err := abi.JSON(strings.NewReader(GasEstimateL1ComponentAbiString)) + require.NoError(t, err) + + tx := types.NewTx(&types.LegacyTx{ + Nonce: 42, + To: &toAddress, + Data: callData, + }) + result := common.LeftPadBytes(l1GasCost.Bytes(), 8) + result = append(result, baseFee...) + result = append(result, l1BaseFeeEstimate...) + + 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 = l1GasCostMethodAbi.Pack("gasEstimateL1Component", toAddress, false, callData) + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + require.Equal(t, blockNum, blockNumber) + }).Return(result, nil) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainArbitrum) + + gasCost, err := oracle.GetGasCost(testutils.Context(t), tx, blockNum) + require.NoError(t, err) + require.Equal(t, assets.NewWei(l1GasCost), gasCost) + }) + + t.Run("Calling GetGasCost on started Kroma L1Oracle returns error", func(t *testing.T) { + blockNum := big.NewInt(1000) + tx := types.NewTx(&types.LegacyTx{}) + + ethClient := mocks.NewETHClient(t) + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainKroma) + + _, err := oracle.GetGasCost(testutils.Context(t), tx, blockNum) + require.Error(t, err, "L1 gas cost not supported for this chain: kroma") + }) + + t.Run("Calling GetGasCost on started OPStack L1Oracle returns OPStack getL1Fee", func(t *testing.T) { + l1GasCost := big.NewInt(100) + blockNum := big.NewInt(1000) + toAddress := utils.RandomAddress() + callData := []byte{1, 2, 3} + l1GasCostMethodAbi, err := abi.JSON(strings.NewReader(GetL1FeeAbiString)) + require.NoError(t, err) + + tx := types.NewTx(&types.LegacyTx{ + Nonce: 42, + To: &toAddress, + Data: callData, + }) + + encodedTx, err := tx.MarshalBinary() + 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 = l1GasCostMethodAbi.Pack("getL1Fee", encodedTx) + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + require.Equal(t, blockNum, blockNumber) + }).Return(common.BigToHash(l1GasCost).Bytes(), nil) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainOptimismBedrock) + + gasCost, err := oracle.GetGasCost(testutils.Context(t), tx, blockNum) + require.NoError(t, err) + require.Equal(t, assets.NewWei(l1GasCost), gasCost) + }) + + t.Run("Calling GetGasCost on started Scroll L1Oracle returns Scroll getL1Fee", func(t *testing.T) { + l1GasCost := big.NewInt(100) + blockNum := big.NewInt(1000) + toAddress := utils.RandomAddress() + callData := []byte{1, 2, 3} + l1GasCostMethodAbi, err := abi.JSON(strings.NewReader(GetL1FeeAbiString)) + require.NoError(t, err) + + tx := types.NewTx(&types.LegacyTx{ + Nonce: 42, + To: &toAddress, + Data: callData, + }) + + encodedTx, err := tx.MarshalBinary() + 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 = l1GasCostMethodAbi.Pack("getL1Fee", encodedTx) + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + require.Equal(t, blockNum, blockNumber) + }).Return(common.BigToHash(l1GasCost).Bytes(), nil) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainScroll) + + gasCost, err := oracle.GetGasCost(testutils.Context(t), tx, blockNum) + require.NoError(t, err) + require.Equal(t, assets.NewWei(l1GasCost), gasCost) + }) +} diff --git a/core/chains/evm/gas/rollups/mocks/l1_oracle.go b/core/chains/evm/gas/rollups/mocks/l1_oracle.go index 9e52a3ec38e..101090c0594 100644 --- a/core/chains/evm/gas/rollups/mocks/l1_oracle.go +++ b/core/chains/evm/gas/rollups/mocks/l1_oracle.go @@ -3,11 +3,15 @@ package mocks import ( - context "context" + big "math/big" assets "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" + context "context" + mock "github.com/stretchr/testify/mock" + + types "github.com/ethereum/go-ethereum/core/types" ) // L1Oracle is an autogenerated mock type for the L1Oracle type @@ -63,6 +67,36 @@ func (_m *L1Oracle) GasPrice(ctx context.Context) (*assets.Wei, error) { return r0, r1 } +// GetGasCost provides a mock function with given fields: ctx, tx, blockNum +func (_m *L1Oracle) GetGasCost(ctx context.Context, tx *types.Transaction, blockNum *big.Int) (*assets.Wei, error) { + ret := _m.Called(ctx, tx, blockNum) + + if len(ret) == 0 { + panic("no return value specified for GetGasCost") + } + + var r0 *assets.Wei + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *types.Transaction, *big.Int) (*assets.Wei, error)); ok { + return rf(ctx, tx, blockNum) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.Transaction, *big.Int) *assets.Wei); ok { + r0 = rf(ctx, tx, blockNum) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*assets.Wei) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.Transaction, *big.Int) error); ok { + r1 = rf(ctx, tx, blockNum) + } 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() diff --git a/core/chains/evm/gas/rollups/models.go b/core/chains/evm/gas/rollups/models.go index 1659436071b..8158ba2b906 100644 --- a/core/chains/evm/gas/rollups/models.go +++ b/core/chains/evm/gas/rollups/models.go @@ -2,6 +2,9 @@ package rollups import ( "context" + "math/big" + + "github.com/ethereum/go-ethereum/core/types" "github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets" "github.com/smartcontractkit/chainlink/v2/core/services" @@ -15,4 +18,5 @@ type L1Oracle interface { services.ServiceCtx GasPrice(ctx context.Context) (*assets.Wei, error) + GetGasCost(ctx context.Context, tx *types.Transaction, blockNum *big.Int) (*assets.Wei, error) }