From 64352e90b6df4de380a078be5cd87e70e115eed1 Mon Sep 17 00:00:00 2001 From: Simson Date: Wed, 19 Jun 2024 23:26:58 +0530 Subject: [PATCH] zkSync L1 gas Price calculation oracle (#13498) (#1050) (#1056) * zkSync L1 Oracle gas Price * fix tests & issues * addressed comments * added changeset * Addressed comments Merging the commit https://github.com/smartcontractkit/chainlink/commit/c6f1b30f346c0a8a64ed39b8317e247c928a368e ## Motivation Adding a Rollup GasPrice calculation oracle for zksync DA gasPrices Ticket - https://smartcontract-it.atlassian.net/browse/SHIP-1988 ## Solution --------- Co-authored-by: Rens Rooimans --- core/chains/evm/gas/rollups/l1_oracle.go | 4 +- core/chains/evm/gas/rollups/l1_oracle_test.go | 38 +++ .../evm/gas/rollups/zkSync_l1_oracle.go | 247 ++++++++++++++++++ 3 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 core/chains/evm/gas/rollups/zkSync_l1_oracle.go diff --git a/core/chains/evm/gas/rollups/l1_oracle.go b/core/chains/evm/gas/rollups/l1_oracle.go index 05ceb720ab..4fc1453e9e 100644 --- a/core/chains/evm/gas/rollups/l1_oracle.go +++ b/core/chains/evm/gas/rollups/l1_oracle.go @@ -46,7 +46,7 @@ const ( PollPeriod = 6 * time.Second ) -var supportedChainTypes = []config.ChainType{config.ChainArbitrum, config.ChainOptimismBedrock, config.ChainKroma, config.ChainScroll} +var supportedChainTypes = []config.ChainType{config.ChainArbitrum, config.ChainOptimismBedrock, config.ChainKroma, config.ChainScroll, config.ChainZkSync} func IsRollupWithL1Support(chainType config.ChainType) bool { return slices.Contains(supportedChainTypes, chainType) @@ -62,6 +62,8 @@ func NewL1GasOracle(lggr logger.Logger, ethClient l1OracleClient, chainType conf l1Oracle = NewOpStackL1GasOracle(lggr, ethClient, chainType) case config.ChainArbitrum: l1Oracle = NewArbitrumL1GasOracle(lggr, ethClient) + case config.ChainZkSync: + l1Oracle = NewZkSyncL1GasOracle(lggr, ethClient) default: panic(fmt.Sprintf("Received unspported chaintype %s", chainType)) } diff --git a/core/chains/evm/gas/rollups/l1_oracle_test.go b/core/chains/evm/gas/rollups/l1_oracle_test.go index 6efdda6bcf..31db62a6f5 100644 --- a/core/chains/evm/gas/rollups/l1_oracle_test.go +++ b/core/chains/evm/gas/rollups/l1_oracle_test.go @@ -1,6 +1,7 @@ package rollups import ( + "encoding/hex" "errors" "math/big" "strings" @@ -187,6 +188,43 @@ func TestL1Oracle_GasPrice(t *testing.T) { assert.Equal(t, assets.NewWei(l1BaseFee), gasPrice) }) + + t.Run("Calling GasPrice on started zkSync L1Oracle returns ZkSync l1GasPrice", func(t *testing.T) { + gasPerPubByteL2 := big.NewInt(1100) + gasPriceL2 := big.NewInt(25000000) + ZksyncGasInfo_getGasPriceL2 := "0xfe173b97" + ZksyncGasInfo_getGasPerPubdataByteL2 := "0x7cb9357e" + ethClient := mocks.NewL1OracleClient(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 := hex.DecodeString(ZksyncGasInfo_getGasPriceL2[2:]) + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(gasPriceL2).Bytes(), nil).Once() + + 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 := hex.DecodeString(ZksyncGasInfo_getGasPerPubdataByteL2[2:]) + require.NoError(t, err) + require.Equal(t, payload, callMsg.Data) + assert.Nil(t, blockNumber) + }).Return(common.BigToHash(gasPerPubByteL2).Bytes(), nil) + + oracle := NewL1GasOracle(logger.Test(t), ethClient, config.ChainZkSync) + 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(new(big.Int).Mul(gasPriceL2, gasPerPubByteL2)), gasPrice) + }) } func TestL1Oracle_GetGasCost(t *testing.T) { diff --git a/core/chains/evm/gas/rollups/zkSync_l1_oracle.go b/core/chains/evm/gas/rollups/zkSync_l1_oracle.go new file mode 100644 index 0000000000..5067d01d46 --- /dev/null +++ b/core/chains/evm/gas/rollups/zkSync_l1_oracle.go @@ -0,0 +1,247 @@ +package rollups + +import ( + "context" + "encoding/hex" + "fmt" + "math/big" + "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" + + gethtypes "github.com/ethereum/go-ethereum/core/types" + + "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" +) + +// Reads L2-specific precompiles and caches the l1GasPrice set by the L2. +type zkSyncL1Oracle struct { + services.StateMachine + client l1OracleClient + pollPeriod time.Duration + logger logger.SugaredLogger + chainType config.ChainType + + systemContextAddress string + gasPerPubdataMethod string + gasPerPubdataSelector string + l2GasPriceMethod string + l2GasPriceSelector string + + l1GasPriceMu sync.RWMutex + l1GasPrice priceEntry + + chInitialised chan struct{} + chStop services.StopChan + chDone chan struct{} +} + +const ( + // SystemContextAddress is the address of the "Precompiled contract that calls that holds the current gas per pubdata byte" + // https://sepolia.explorer.zksync.io/address/0x000000000000000000000000000000000000800b#contract + SystemContextAddress = "0x000000000000000000000000000000000000800B" + + // ZksyncGasInfo_GetL2GasPerPubDataBytes is the a hex encoded call to: + // function gasPerPubdataByte() external view returns (uint256 gasPerPubdataByte); + SystemContext_gasPerPubdataByteMethod = "gasPerPubdataByte" + ZksyncGasInfo_getGasPerPubdataByteL2 = "0x7cb9357e" + + // ZksyncGasInfo_GetL2GasPrice is the a hex encoded call to: + // `function gasPrice() external view returns (uint256);` + SystemContext_gasPriceMethod = "gasPrice" + ZksyncGasInfo_getGasPriceL2 = "0xfe173b97" +) + +func NewZkSyncL1GasOracle(lggr logger.Logger, ethClient l1OracleClient) *zkSyncL1Oracle { + return &zkSyncL1Oracle{ + client: ethClient, + pollPeriod: PollPeriod, + logger: logger.Sugared(logger.Named(lggr, "L1GasOracle(zkSync)")), + chainType: config.ChainZkSync, + + systemContextAddress: SystemContextAddress, + gasPerPubdataMethod: SystemContext_gasPerPubdataByteMethod, + gasPerPubdataSelector: ZksyncGasInfo_getGasPerPubdataByteL2, + l2GasPriceMethod: SystemContext_gasPriceMethod, + l2GasPriceSelector: ZksyncGasInfo_getGasPriceL2, + + chInitialised: make(chan struct{}), + chStop: make(chan struct{}), + chDone: make(chan struct{}), + } +} + +func (o *zkSyncL1Oracle) Name() string { + return o.logger.Name() +} + +func (o *zkSyncL1Oracle) Start(ctx context.Context) error { + return o.StartOnce(o.Name(), func() error { + go o.run() + <-o.chInitialised + return nil + }) +} +func (o *zkSyncL1Oracle) Close() error { + return o.StopOnce(o.Name(), func() error { + close(o.chStop) + <-o.chDone + return nil + }) +} + +func (o *zkSyncL1Oracle) HealthReport() map[string]error { + return map[string]error{o.Name(): o.Healthy()} +} + +func (o *zkSyncL1Oracle) 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 *zkSyncL1Oracle) refresh() (t *time.Timer) { + t, err := o.refreshWithError() + if err != nil { + o.SvcErrBuffer.Append(err) + } + return +} + +func (o *zkSyncL1Oracle) refreshWithError() (t *time.Timer, err error) { + t = time.NewTimer(utils.WithJitter(o.pollPeriod)) + + ctx, cancel := o.chStop.CtxCancel(evmclient.ContextWithDefaultTimeout()) + defer cancel() + + price, err := o.CalculateL1GasPrice(ctx) + if err != nil { + return t, err + } + + o.l1GasPriceMu.Lock() + defer o.l1GasPriceMu.Unlock() + o.l1GasPrice = priceEntry{price: assets.NewWei(price), timestamp: time.Now()} + return +} + +// For zkSync l2_gas_PerPubdataByte = (blob_byte_price_on_l1 + part_of_l1_verification_cost) / (gas_price_on_l2) +// l2_gas_PerPubdataByte = blob_gas_price_on_l1 * gas_per_byte / gas_price_on_l2 +// blob_gas_price_on_l1 * gas_per_byte ~= gas_price_on_l2 * l2_gas_PerPubdataByte +func (o *zkSyncL1Oracle) CalculateL1GasPrice(ctx context.Context) (price *big.Int, err error) { + l2GasPrice, err := o.GetL2GasPrice(ctx) + if err != nil { + return nil, err + } + l2GasPerPubDataByte, err := o.GetL2GasPerPubDataBytes(ctx) + if err != nil { + return nil, err + } + price = new(big.Int).Mul(l2GasPrice, l2GasPerPubDataByte) + return +} + +func (o *zkSyncL1Oracle) 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 *zkSyncL1Oracle) GetGasCost(ctx context.Context, tx *gethtypes.Transaction, blockNum *big.Int) (*assets.Wei, error) { + //Unused method, so not implemented + // And its not possible to know gas consumption of a transaction before its executed, since zkSync only posts the state difference + panic("unimplemented") +} + +// GetL2GasPrice calls SystemContract.gasPrice() on the zksync system precompile contract. +// +// @return (The current gasPrice on L2: same as tx.gasPrice) +// function gasPrice() external view returns (uint256); +// +// https://github.com/matter-labs/era-contracts/blob/12a7d3bc1777ae5663e7525b2628061502755cbd/system-contracts/contracts/interfaces/ISystemContext.sol#L34C4-L34C57 + +func (o *zkSyncL1Oracle) GetL2GasPrice(ctx context.Context) (gasPriceL2 *big.Int, err error) { + precompile := common.HexToAddress(o.systemContextAddress) + method, err := hex.DecodeString(ZksyncGasInfo_getGasPriceL2[2:]) + if err != nil { + return common.Big0, fmt.Errorf("cannot decode method: %w", err) + } + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &precompile, + Data: method, + }, nil) + if err != nil { + return common.Big0, fmt.Errorf("cannot fetch l2GasPrice from zkSync SystemContract: %w", err) + } + + if len(b) != 1*32 { // uint256 gasPrice; + err = fmt.Errorf("return gasPrice (%d) different than expected (%d)", len(b), 3*32) + return + } + gasPriceL2 = new(big.Int).SetBytes(b) + return +} + +// GetL2GasPerPubDataBytes calls SystemContract.gasPerPubdataByte() on the zksync system precompile contract. +// +// @return (The current gas per pubdata byte on L2) +// function gasPerPubdataByte() external view returns (uint256 gasPerPubdataByte); +// +// https://github.com/matter-labs/era-contracts/blob/12a7d3bc1777ae5663e7525b2628061502755cbd/system-contracts/contracts/interfaces/ISystemContext.sol#L58C14-L58C31 + +func (o *zkSyncL1Oracle) GetL2GasPerPubDataBytes(ctx context.Context) (gasPerPubByteL2 *big.Int, err error) { + precompile := common.HexToAddress(o.systemContextAddress) + method, err := hex.DecodeString(ZksyncGasInfo_getGasPerPubdataByteL2[2:]) + if err != nil { + return common.Big0, fmt.Errorf("cannot decode method: %w", err) + } + b, err := o.client.CallContract(ctx, ethereum.CallMsg{ + To: &precompile, + Data: method, + }, nil) + if err != nil { + return common.Big0, fmt.Errorf("cannot fetch gasPerPubdataByte from zkSync SystemContract: %w", err) + } + + if len(b) != 1*32 { // uint256 gasPerPubdataByte; + err = fmt.Errorf("return data length (%d) different than expected (%d)", len(b), 3*32) + return + } + gasPerPubByteL2 = new(big.Int).SetBytes(b) + return +}