diff --git a/.changeset/metal-forks-arrive.md b/.changeset/metal-forks-arrive.md new file mode 100644 index 00000000000..21b99b3ddb1 --- /dev/null +++ b/.changeset/metal-forks-arrive.md @@ -0,0 +1,5 @@ +--- +"chainlink": patch +--- + +Adds new custom calldata DA oracle #added diff --git a/core/chains/evm/config/chain_scoped_gas_estimator.go b/core/chains/evm/config/chain_scoped_gas_estimator.go index 1e04690b12f..3f557516e98 100644 --- a/core/chains/evm/config/chain_scoped_gas_estimator.go +++ b/core/chains/evm/config/chain_scoped_gas_estimator.go @@ -138,7 +138,9 @@ func (d *daOracleConfig) OracleAddress() *types.EIP55Address { // CustomGasPriceCalldata returns the calldata for a custom gas price API. func (d *daOracleConfig) CustomGasPriceCalldata() string { - // TODO: CCIP-3710 update once custom calldata oracle is added + if d.c.OracleType == toml.DAOracleCustomCalldata { + return d.c.CustomGasPriceCalldata + } return "" } diff --git a/core/chains/evm/config/toml/config.go b/core/chains/evm/config/toml/config.go index cc6a8d4eabf..faf115e07ad 100644 --- a/core/chains/evm/config/toml/config.go +++ b/core/chains/evm/config/toml/config.go @@ -767,14 +767,15 @@ type DAOracle struct { type DAOracleType string const ( - DAOracleOPStack = DAOracleType("opstack") - DAOracleArbitrum = DAOracleType("arbitrum") - DAOracleZKSync = DAOracleType("zksync") + DAOracleOPStack = DAOracleType("opstack") + DAOracleArbitrum = DAOracleType("arbitrum") + DAOracleZKSync = DAOracleType("zksync") + DAOracleCustomCalldata = DAOracleType("custom_calldata") ) func (o DAOracleType) IsValid() bool { switch o { - case "", DAOracleOPStack, DAOracleArbitrum, DAOracleZKSync: + case "", DAOracleOPStack, DAOracleArbitrum, DAOracleZKSync, DAOracleCustomCalldata: return true } return false diff --git a/core/chains/evm/gas/rollups/custom_calldata_da_oracle.go b/core/chains/evm/gas/rollups/custom_calldata_da_oracle.go new file mode 100644 index 00000000000..f0c2036e49b --- /dev/null +++ b/core/chains/evm/gas/rollups/custom_calldata_da_oracle.go @@ -0,0 +1,169 @@ +package rollups + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "sync" + "time" + + "github.com/ethereum/go-ethereum" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" + "github.com/smartcontractkit/chainlink-common/pkg/services" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/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/config/chaintype" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml" +) + +type customCalldataDAOracle struct { + services.StateMachine + client l1OracleClient + pollPeriod time.Duration + logger logger.SugaredLogger + + daOracleConfig evmconfig.DAOracle + daGasPriceMu sync.RWMutex + daGasPrice priceEntry + + chInitialized chan struct{} + chStop services.StopChan + chDone chan struct{} +} + +// NewCustomCalldataDAOracle creates a new custom calldata DA oracle. The CustomCalldataDAOracle fetches gas price from +// whatever function is specified in the DAOracle's CustomGasPriceCalldata field. This allows for more flexibility when +// chains have custom DA gas calculation methods. +func NewCustomCalldataDAOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chaintype.ChainType, daOracleConfig evmconfig.DAOracle) (*customCalldataDAOracle, error) { + if daOracleConfig.OracleType() != toml.DAOracleCustomCalldata { + return nil, fmt.Errorf("expected %s oracle type, got %s", toml.DAOracleCustomCalldata, daOracleConfig.OracleType()) + } + if daOracleConfig.CustomGasPriceCalldata() == "" { + return nil, errors.New("CustomGasPriceCalldata is required") + } + return &customCalldataDAOracle{ + client: ethClient, + pollPeriod: PollPeriod, + logger: logger.Sugared(logger.Named(lggr, fmt.Sprintf("CustomCalldataDAOracle(%s)", chainType))), + + daOracleConfig: daOracleConfig, + + chInitialized: make(chan struct{}), + chStop: make(chan struct{}), + chDone: make(chan struct{}), + }, nil +} + +func (o *customCalldataDAOracle) Name() string { + return o.logger.Name() +} + +func (o *customCalldataDAOracle) Start(_ context.Context) error { + return o.StartOnce(o.Name(), func() error { + go o.run() + <-o.chInitialized + return nil + }) +} + +func (o *customCalldataDAOracle) Close() error { + return o.StopOnce(o.Name(), func() error { + close(o.chStop) + <-o.chDone + return nil + }) +} + +func (o *customCalldataDAOracle) HealthReport() map[string]error { + return map[string]error{o.Name(): o.Healthy()} +} + +func (o *customCalldataDAOracle) run() { + defer close(o.chDone) + + o.refresh() + close(o.chInitialized) + + t := services.TickerConfig{ + Initial: o.pollPeriod, + JitterPct: services.DefaultJitter, + }.NewTicker(o.pollPeriod) + defer t.Stop() + + for { + select { + case <-o.chStop: + return + case <-t.C: + o.refresh() + } + } +} + +func (o *customCalldataDAOracle) refresh() { + err := o.refreshWithError() + if err != nil { + o.logger.Criticalw("Failed to refresh gas price", "err", err) + o.SvcErrBuffer.Append(err) + } +} + +func (o *customCalldataDAOracle) refreshWithError() error { + ctx, cancel := o.chStop.CtxCancel(evmclient.ContextWithDefaultTimeout()) + defer cancel() + + price, err := o.getCustomCalldataGasPrice(ctx) + if err != nil { + return err + } + + o.daGasPriceMu.Lock() + defer o.daGasPriceMu.Unlock() + o.daGasPrice = priceEntry{price: assets.NewWei(price), timestamp: time.Now()} + return nil +} + +func (o *customCalldataDAOracle) GasPrice(_ context.Context) (daGasPrice *assets.Wei, err error) { + var timestamp time.Time + ok := o.IfStarted(func() { + o.daGasPriceMu.RLock() + daGasPrice = o.daGasPrice.price + timestamp = o.daGasPrice.timestamp + o.daGasPriceMu.RUnlock() + }) + if !ok { + return daGasPrice, errors.New("DAGasOracle is not started; cannot estimate gas") + } + if daGasPrice == nil { + return daGasPrice, errors.New("failed to get DA 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 daGasPrice, errors.New("gas price is stale") + } + return +} + +func (o *customCalldataDAOracle) getCustomCalldataGasPrice(ctx context.Context) (*big.Int, error) { + daOracleAddress := o.daOracleConfig.OracleAddress().Address() + calldata := strings.TrimPrefix(o.daOracleConfig.CustomGasPriceCalldata(), "0x") + calldataBytes, err := hex.DecodeString(calldata) + if err != nil { + return nil, fmt.Errorf("failed to decode custom fee method calldata: %w", err) + } + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &daOracleAddress, + Data: calldataBytes, + }, nil) + if err != nil { + return nil, fmt.Errorf("custom fee method call failed: %w", err) + } + return new(big.Int).SetBytes(b), nil +} diff --git a/core/chains/evm/gas/rollups/custom_calldata_da_oracle_test.go b/core/chains/evm/gas/rollups/custom_calldata_da_oracle_test.go new file mode 100644 index 00000000000..3dcb08d9aeb --- /dev/null +++ b/core/chains/evm/gas/rollups/custom_calldata_da_oracle_test.go @@ -0,0 +1,102 @@ +package rollups + +import ( + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/smartcontractkit/chainlink-common/pkg/utils/tests" + + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/chaintype" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/config/toml" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/gas/rollups/mocks" + "github.com/smartcontractkit/chainlink/v2/core/chains/evm/utils" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/logger" +) + +func TestCustomCalldataDAOracle_NewCustomCalldata(t *testing.T) { + oracleAddress := utils.RandomAddress().String() + t.Parallel() + + t.Run("throws error if oracle type is not custom_calldata", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleOPStack, oracleAddress, "") + _, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainArbitrum, daOracleConfig) + require.Error(t, err) + }) + + t.Run("throws error if CustomGasPriceCalldata is empty", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, "") + _, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainCelo, daOracleConfig) + require.Error(t, err) + }) + + t.Run("correctly creates custom calldata DA oracle", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + calldata := "0x0000000000000000000000000000000000001234" + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, calldata) + oracle, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainZkSync, daOracleConfig) + require.NoError(t, err) + require.NotNil(t, oracle) + }) +} + +func TestCustomCalldataDAOracle_getCustomCalldataGasPrice(t *testing.T) { + oracleAddress := utils.RandomAddress().String() + t.Parallel() + + t.Run("correctly fetches gas price if DA oracle config has custom calldata", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + expectedPriceHex := "0x32" // 50 + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, "0x0000000000000000000000000000000000001234") + oracle, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainZkSync, daOracleConfig) + require.NoError(t, err) + + 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.NotNil(t, callMsg.To) + require.Equal(t, oracleAddress, callMsg.To.String()) + require.Nil(t, blockNumber) + }).Return(hexutil.MustDecode(expectedPriceHex), nil).Once() + + price, err := oracle.getCustomCalldataGasPrice(tests.Context(t)) + require.NoError(t, err) + require.Equal(t, big.NewInt(50), price) + }) + + t.Run("throws error if custom calldata fails to decode", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, "0xblahblahblah") + oracle, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainCelo, daOracleConfig) + require.NoError(t, err) + + _, err = oracle.getCustomCalldataGasPrice(tests.Context(t)) + require.Error(t, err) + }) + + t.Run("throws error if CallContract call fails", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, "0x0000000000000000000000000000000000000000000000000000000000000032") + oracle, err := NewCustomCalldataDAOracle(logger.Test(t), ethClient, chaintype.ChainCelo, daOracleConfig) + require.NoError(t, err) + + ethClient.On("CallContract", mock.Anything, mock.Anything, mock.Anything).Return(nil, errors.New("RPC failure")).Once() + + _, err = oracle.getCustomCalldataGasPrice(tests.Context(t)) + require.Error(t, err) + }) +} diff --git a/core/chains/evm/gas/rollups/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go index 28172051fb7..00ca8c47cb0 100644 --- a/core/chains/evm/gas/rollups/l1_oracle.go +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -63,6 +63,8 @@ func NewL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType chai l1Oracle, err = NewArbitrumL1GasOracle(lggr, ethClient) case toml.DAOracleZKSync: l1Oracle = NewZkSyncL1GasOracle(lggr, ethClient) + case toml.DAOracleCustomCalldata: + l1Oracle, err = NewCustomCalldataDAOracle(lggr, ethClient, chainType, daOracle) } if err != nil { return nil, fmt.Errorf("failed to initialize L1 oracle for chaintype %s: %w", chainType, err) diff --git a/core/chains/evm/gas/rollups/l1_oracle_test.go b/core/chains/evm/gas/rollups/l1_oracle_test.go index 05e3e7c193d..5c8dac1b4f6 100644 --- a/core/chains/evm/gas/rollups/l1_oracle_test.go +++ b/core/chains/evm/gas/rollups/l1_oracle_test.go @@ -222,7 +222,7 @@ func TestL1Oracle_GasPrice(t *testing.T) { assert.Nil(t, blockNumber) }).Return(common.BigToHash(gasPerPubByteL2).Bytes(), nil) - daOracle := CreateTestDAOracle(t, toml.DAOracleZKSync, "0x0000000000000000000000000000000000000000", "") + daOracle := CreateTestDAOracle(t, toml.DAOracleZKSync, utils.RandomAddress().String(), "") oracle, err := NewL1GasOracle(logger.Test(t), ethClient, chaintype.ChainZkSync, daOracle) require.NoError(t, err) servicetest.RunHealthy(t, oracle) @@ -232,4 +232,30 @@ func TestL1Oracle_GasPrice(t *testing.T) { assert.Equal(t, assets.NewWei(new(big.Int).Mul(gasPriceL2, gasPerPubByteL2)), gasPrice) }) + + t.Run("Calling GasPrice on started CustomCalldata L1Oracle returns CustomCalldata l1GasPrice", func(t *testing.T) { + ethClient := mocks.NewL1OracleClient(t) + mockedPrice := big.NewInt(30) + oracleAddress := utils.RandomAddress().String() + + 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.NotNil(t, callMsg.To) + require.Equal(t, oracleAddress, callMsg.To.String()) + require.Nil(t, blockNumber) + }).Return(common.BigToHash(mockedPrice).Bytes(), nil).Once() + + daOracleConfig := CreateTestDAOracle(t, toml.DAOracleCustomCalldata, oracleAddress, "0x0000000000000000000000000000000000001234") + + // chainType here shouldn't matter for now since we're checking daOracleConfig oracle type first. Later + // we can consider limiting the chainType to only those that support custom calldata. + oracle, err := NewL1GasOracle(logger.Test(t), ethClient, chaintype.ChainZkSync, daOracleConfig) + require.NoError(t, err) + servicetest.RunHealthy(t, oracle) + + price, err := oracle.GasPrice(tests.Context(t)) + require.NoError(t, err) + require.Equal(t, assets.NewWei(mockedPrice), price) + }) } diff --git a/core/config/docs/chains-evm.toml b/core/config/docs/chains-evm.toml index 683e59d74b8..7d0de98acea 100644 --- a/core/config/docs/chains-evm.toml +++ b/core/config/docs/chains-evm.toml @@ -264,7 +264,7 @@ TipCapDefault = '1 wei' # Default TipCapMin = '1 wei' # Default [EVM.GasEstimator.DAOracle] -# OracleType refers to the oracle family this config belongs to. Currently the available oracle types are: 'opstack', 'arbitrum', and 'zksync'. +# OracleType refers to the oracle family this config belongs to. Currently the available oracle types are: 'opstack', 'arbitrum', 'zksync', and 'custom_calldata'. OracleType = 'opstack' # Example # OracleAddress is the address of the oracle contract. OracleAddress = '0x420000000000000000000000000000000000000F' # Example diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ba36c8d258d..9b4a2ec2a48 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -9357,7 +9357,7 @@ CustomGasPriceCalldata = '' # Default ```toml OracleType = 'opstack' # Example ``` -OracleType refers to the oracle family this config belongs to. Currently the available oracle types are: 'opstack', 'arbitrum', and 'zksync'. +OracleType refers to the oracle family this config belongs to. Currently the available oracle types are: 'opstack', 'arbitrum', 'zksync', and 'custom_calldata'. ### OracleAddress ```toml