Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CCIP-3710 create new custom calldata L1 (DA) gas oracle #14710

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/metal-forks-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"chainlink": patch
---

Adds new custom calldata DA oracle #added
4 changes: 3 additions & 1 deletion core/chains/evm/config/chain_scoped_gas_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
}

Expand Down
9 changes: 5 additions & 4 deletions core/chains/evm/config/toml/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
169 changes: 169 additions & 0 deletions core/chains/evm/gas/rollups/custom_calldata_da_oracle.go
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: call this daOracleClient

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@matYang this corresponds to the l1OracleClient interface so updating this will change several of the other oracle files and trigger some mockery changes. In addition to l1_oracle_client.go there's also l1_oracle.go.

I think maybe it's better to rename them both in a separate PR, what do you think? I created this ticket: https://smartcontract-it.atlassian.net/browse/CCIP-3743

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
}
102 changes: 102 additions & 0 deletions core/chains/evm/gas/rollups/custom_calldata_da_oracle_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
ogtownsend marked this conversation as resolved.
Show resolved Hide resolved

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)
})
}
2 changes: 2 additions & 0 deletions core/chains/evm/gas/rollups/l1_oracle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
28 changes: 27 additions & 1 deletion core/chains/evm/gas/rollups/l1_oracle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
})
}
2 changes: 1 addition & 1 deletion core/config/docs/chains-evm.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading