Skip to content

Commit

Permalink
CCIP-3710 create new custom calldata L1 (DA) gas oracle (#14710)
Browse files Browse the repository at this point in the history
* Add new custom calldata DA oracle

* added more tests

* rename logger

* lint
  • Loading branch information
ogtownsend authored and cedric-cordenier committed Oct 24, 2024
1 parent 951c392 commit 3c40e9b
Show file tree
Hide file tree
Showing 9 changed files with 315 additions and 8 deletions.
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
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)
})

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

0 comments on commit 3c40e9b

Please sign in to comment.