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

Add bump legacy gas implementation to the Suggested Price Estimator #11805

Merged
merged 6 commits into from
Jan 25, 2024
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
4 changes: 2 additions & 2 deletions common/fee/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func CalculateBumpedFee(
toChainUnit feeUnitToChainUnit,
) (*big.Int, error) {
maxFeePrice := bigmath.Min(maxFeePriceInput, maxBumpPrice)
bumpedFeePrice := maxBumpedFee(originalfeePrice, bumpPercent, bumpMin)
bumpedFeePrice := MaxBumpedFee(originalfeePrice, bumpPercent, bumpMin)

// Update bumpedFeePrice if currentfeePrice is higher than bumpedFeePrice and within maxFeePrice
bumpedFeePrice = maxFee(lggr, currentfeePrice, bumpedFeePrice, maxFeePrice, "fee price", toChainUnit)
Expand All @@ -61,7 +61,7 @@ func CalculateBumpedFee(
}

// Returns highest bumped fee price of originalFeePrice bumped by fixed units or percentage.
func maxBumpedFee(originalFeePrice *big.Int, feeBumpPercent uint16, feeBumpUnits *big.Int) *big.Int {
func MaxBumpedFee(originalFeePrice *big.Int, feeBumpPercent uint16, feeBumpUnits *big.Int) *big.Int {
return bigmath.Max(
addPercentage(originalFeePrice, feeBumpPercent),
new(big.Int).Add(originalFeePrice, feeBumpUnits),
Expand Down
4 changes: 3 additions & 1 deletion core/chains/evm/gas/arbitrum_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import (

type ArbConfig interface {
LimitMax() uint32
BumpPercent() uint16
BumpMin() *assets.Wei
}

//go:generate mockery --quiet --name ethClient --output ./mocks/ --case=underscore --structname ETHClient
Expand Down Expand Up @@ -56,7 +58,7 @@ func NewArbitrumEstimator(lggr logger.Logger, cfg ArbConfig, rpcClient rpcClient
lggr = logger.Named(lggr, "ArbitrumEstimator")
return &arbitrumEstimator{
cfg: cfg,
EvmEstimator: NewSuggestedPriceEstimator(lggr, rpcClient),
EvmEstimator: NewSuggestedPriceEstimator(lggr, rpcClient, cfg),
client: ethClient,
pollPeriod: 10 * time.Second,
logger: lggr,
Expand Down
44 changes: 38 additions & 6 deletions core/chains/evm/gas/arbitrum_estimator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,23 @@ import (
)

type arbConfig struct {
v uint32
v uint32
bumpPercent uint16
bumpMin *assets.Wei
}

func (a *arbConfig) LimitMax() uint32 {
return a.v
}

func (a *arbConfig) BumpPercent() uint16 {
return a.bumpPercent
}

func (a *arbConfig) BumpMin() *assets.Wei {
return a.bumpMin
}

func TestArbitrumEstimator(t *testing.T) {
t.Parallel()

Expand All @@ -38,6 +48,8 @@ func TestArbitrumEstimator(t *testing.T) {
calldata := []byte{0x00, 0x00, 0x01, 0x02, 0x03}
const gasLimit uint32 = 80000
const gasPriceBufferPercentage = 50
const bumpPercent = 10
var bumpMin = assets.NewWei(big.NewInt(1))

t.Run("calling GetLegacyGas on unstarted estimator returns error", func(t *testing.T) {
rpcClient := mocks.NewRPCClient(t)
Expand Down Expand Up @@ -66,7 +78,7 @@ func TestArbitrumEstimator(t *testing.T) {
assert.Equal(t, big.NewInt(-1), blockNumber)
}).Return(zeros.Bytes(), nil)

o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit}, rpcClient, ethClient)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit, bumpPercent: bumpPercent, bumpMin: bumpMin}, rpcClient, ethClient)
servicetest.RunHealthy(t, o)
gasPrice, chainSpecificGasLimit, err := o.GetLegacyGas(testutils.Context(t), calldata, gasLimit, maxGasPrice)
require.NoError(t, err)
Expand Down Expand Up @@ -124,12 +136,12 @@ func TestArbitrumEstimator(t *testing.T) {
assert.Equal(t, uint32(0), chainSpecificGasLimit)
})

t.Run("calling BumpLegacyGas always returns error", func(t *testing.T) {
t.Run("calling BumpLegacyGas on unstarted arbitrum estimator returns error", func(t *testing.T) {
rpcClient := mocks.NewRPCClient(t)
ethClient := mocks.NewETHClient(t)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{}, rpcClient, ethClient)
_, _, err := o.BumpLegacyGas(testutils.Context(t), assets.NewWeiI(42), gasLimit, assets.NewWeiI(10), nil)
assert.EqualError(t, err, "bump gas is not supported for this chain")
assert.EqualError(t, err, "estimator is not started")
})

t.Run("calling GetLegacyGas on started estimator if initial call failed returns error", func(t *testing.T) {
Expand All @@ -152,6 +164,26 @@ func TestArbitrumEstimator(t *testing.T) {
assert.EqualError(t, err, "failed to estimate gas; gas price not set")
})

t.Run("calling GetDynamicFee always returns error", func(t *testing.T) {
rpcClient := mocks.NewRPCClient(t)
ethClient := mocks.NewETHClient(t)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{}, rpcClient, ethClient)
_, _, err := o.GetDynamicFee(testutils.Context(t), gasLimit, maxGasPrice)
assert.EqualError(t, err, "dynamic fees are not implemented for this estimator")
})

t.Run("calling BumpDynamicFee always returns error", func(t *testing.T) {
rpcClient := mocks.NewRPCClient(t)
ethClient := mocks.NewETHClient(t)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{}, rpcClient, ethClient)
fee := gas.DynamicFee{
FeeCap: assets.NewWeiI(42),
TipCap: assets.NewWeiI(5),
}
_, _, err := o.BumpDynamicFee(testutils.Context(t), fee, gasLimit, maxGasPrice, nil)
assert.EqualError(t, err, "dynamic fees are not implemented for this estimator")
})

t.Run("limit computes", func(t *testing.T) {
rpcClient := mocks.NewRPCClient(t)
ethClient := mocks.NewETHClient(t)
Expand All @@ -177,7 +209,7 @@ func TestArbitrumEstimator(t *testing.T) {
assert.Equal(t, big.NewInt(-1), blockNumber)
}).Return(b.Bytes(), nil)

o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit}, rpcClient, ethClient)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit, bumpPercent: bumpPercent, bumpMin: bumpMin}, rpcClient, ethClient)
servicetest.RunHealthy(t, o)
gasPrice, chainSpecificGasLimit, err := o.GetLegacyGas(testutils.Context(t), calldata, gasLimit, maxGasPrice)
require.NoError(t, err)
Expand Down Expand Up @@ -211,7 +243,7 @@ func TestArbitrumEstimator(t *testing.T) {
assert.Equal(t, big.NewInt(-1), blockNumber)
}).Return(b.Bytes(), nil)

o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit}, rpcClient, ethClient)
o := gas.NewArbitrumEstimator(logger.Test(t), &arbConfig{v: maxGasLimit, bumpPercent: bumpPercent, bumpMin: bumpMin}, rpcClient, ethClient)
servicetest.RunHealthy(t, o)
gasPrice, chainSpecificGasLimit, err := o.GetLegacyGas(testutils.Context(t), calldata, gasLimit, maxGasPrice)
require.Error(t, err, "expected error but got (%s, %d)", gasPrice, chainSpecificGasLimit)
Expand Down
12 changes: 11 additions & 1 deletion core/chains/evm/gas/cmd/arbgas/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,19 @@ func withEstimator(ctx context.Context, lggr logger.SugaredLogger, url string, f
var _ gas.ArbConfig = &config{}

type config struct {
max uint32
max uint32
bumpPercent uint16
bumpMin *assets.Wei
}

func (c *config) LimitMax() uint32 {
return c.max
}

func (c *config) BumpPercent() uint16 {
return c.bumpPercent
}

func (c *config) BumpMin() *assets.Wei {
return c.bumpMin
}
4 changes: 2 additions & 2 deletions core/chains/evm/gas/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ func NewEstimator(lggr logger.Logger, ethClient evmclient.Client, cfg Config, ge
}
case "L2Suggested", "SuggestedPrice":
newEstimator = func(l logger.Logger) EvmEstimator {
return NewSuggestedPriceEstimator(lggr, ethClient)
return NewSuggestedPriceEstimator(lggr, ethClient, geCfg)
}
default:
lggr.Warnf("GasEstimator: unrecognised mode '%s', falling back to FixedPriceEstimator", s)
Expand Down Expand Up @@ -404,7 +404,7 @@ func BumpDynamicFeeOnly(config bumpConfig, feeCapBufferBlocks uint16, lggr logge
// - A configured percentage bump (EVM.GasEstimator.BumpPercent) on top of the baseline tip cap.
// - A configured fixed amount of Wei (ETH_GAS_PRICE_WEI) on top of the baseline tip cap.
// The baseline tip cap is the maximum of the previous tip cap attempt and the node's current tip cap.
// It increases the max fee cap by GasBumpPercent
// It increases the max fee cap by BumpPercent
//
// NOTE: We would prefer to have set a large FeeCap and leave it fixed, bumping
// the Tip only. Unfortunately due to a flaw of how EIP-1559 is implemented we
Expand Down
100 changes: 75 additions & 25 deletions core/chains/evm/gas/suggested_price_estimator.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package gas

import (
"context"
"fmt"
"slices"
"sync"
"time"
Expand All @@ -12,7 +13,9 @@ import (
"github.com/smartcontractkit/chainlink-common/pkg/logger"
"github.com/smartcontractkit/chainlink-common/pkg/services"
"github.com/smartcontractkit/chainlink-common/pkg/utils"
bigmath "github.com/smartcontractkit/chainlink-common/pkg/utils/big_math"

"github.com/smartcontractkit/chainlink/v2/common/fee"
feetypes "github.com/smartcontractkit/chainlink/v2/common/fee/types"
"github.com/smartcontractkit/chainlink/v2/core/chains/evm/assets"
evmclient "github.com/smartcontractkit/chainlink/v2/core/chains/evm/client"
Expand All @@ -23,6 +26,11 @@ var (
_ EvmEstimator = &SuggestedPriceEstimator{}
)

type suggestedPriceConfig interface {
BumpPercent() uint16
BumpMin() *assets.Wei
}

//go:generate mockery --quiet --name rpcClient --output ./mocks/ --case=underscore --structname RPCClient
type rpcClient interface {
CallContext(ctx context.Context, result interface{}, method string, args ...interface{}) error
Expand All @@ -32,6 +40,7 @@ type rpcClient interface {
type SuggestedPriceEstimator struct {
services.StateMachine

cfg suggestedPriceConfig
client rpcClient
pollPeriod time.Duration
logger logger.Logger
Expand All @@ -46,11 +55,12 @@ type SuggestedPriceEstimator struct {
}

// NewSuggestedPriceEstimator returns a new Estimator which uses the suggested gas price.
func NewSuggestedPriceEstimator(lggr logger.Logger, client rpcClient) EvmEstimator {
func NewSuggestedPriceEstimator(lggr logger.Logger, client rpcClient, cfg suggestedPriceConfig) EvmEstimator {
return &SuggestedPriceEstimator{
client: client,
pollPeriod: 10 * time.Second,
logger: logger.Named(lggr, "SuggestedPriceEstimator"),
cfg: cfg,
chForceRefetch: make(chan (chan struct{})),
chInitialised: make(chan struct{}),
chStop: make(chan struct{}),
Expand Down Expand Up @@ -122,42 +132,43 @@ func (o *SuggestedPriceEstimator) refreshPrice() (t *time.Timer) {
return
}

// Uses the force refetch chan to trigger a price update and blocks until complete
func (o *SuggestedPriceEstimator) forceRefresh(ctx context.Context) (err error) {
ch := make(chan struct{})
select {
case o.chForceRefetch <- ch:
case <-o.chStop:
return errors.New("estimator stopped")
case <-ctx.Done():
return ctx.Err()
}
select {
case <-ch:
case <-o.chStop:
return errors.New("estimator stopped")
case <-ctx.Done():
return ctx.Err()
}
return
}

func (o *SuggestedPriceEstimator) OnNewLongestChain(context.Context, *evmtypes.Head) {}

func (*SuggestedPriceEstimator) GetDynamicFee(_ context.Context, _ uint32, _ *assets.Wei) (fee DynamicFee, chainSpecificGasLimit uint32, err error) {
err = errors.New("dynamic fees are not implemented for this layer 2")
err = errors.New("dynamic fees are not implemented for this estimator")
return
}

func (*SuggestedPriceEstimator) BumpDynamicFee(_ context.Context, _ DynamicFee, _ uint32, _ *assets.Wei, _ []EvmPriorAttempt) (bumped DynamicFee, chainSpecificGasLimit uint32, err error) {
err = errors.New("dynamic fees are not implemented for this layer 2")
err = errors.New("dynamic fees are not implemented for this estimator")
return
}

func (o *SuggestedPriceEstimator) GetLegacyGas(ctx context.Context, _ []byte, GasLimit uint32, maxGasPriceWei *assets.Wei, opts ...feetypes.Opt) (gasPrice *assets.Wei, chainSpecificGasLimit uint32, err error) {
chainSpecificGasLimit = GasLimit

ok := o.IfStarted(func() {
if slices.Contains(opts, feetypes.OptForceRefetch) {
ch := make(chan struct{})
select {
case o.chForceRefetch <- ch:
case <-o.chStop:
err = errors.New("estimator stopped")
return
case <-ctx.Done():
err = ctx.Err()
return
}
select {
case <-ch:
case <-o.chStop:
err = errors.New("estimator stopped")
return
case <-ctx.Done():
err = ctx.Err()
return
}
err = o.forceRefresh(ctx)
}
if gasPrice = o.getGasPrice(); gasPrice == nil {
err = errors.New("failed to estimate gas; gas price not set")
Expand All @@ -177,8 +188,47 @@ func (o *SuggestedPriceEstimator) GetLegacyGas(ctx context.Context, _ []byte, Ga
return
}

func (o *SuggestedPriceEstimator) BumpLegacyGas(_ context.Context, _ *assets.Wei, _ uint32, _ *assets.Wei, _ []EvmPriorAttempt) (bumpedGasPrice *assets.Wei, chainSpecificGasLimit uint32, err error) {
return nil, 0, errors.New("bump gas is not supported for this chain")
// Refreshes the gas price by making a call to the RPC in case the current one has gone stale.
// Adds the larger of BumpPercent and BumpMin configs as a buffer on top of the price returned from the RPC.
// The only reason bumping logic would be called on the SuggestedPriceEstimator is if there was a significant price spike
// between the last price update and when the tx was submitted. Refreshing the price helps ensure the latest market changes are accounted for.
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you please add some comments describing how bumping makes use of the RPC call, and bump% and bumpMin settings?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've updated the comments in the latest commit

func (o *SuggestedPriceEstimator) BumpLegacyGas(ctx context.Context, originalFee *assets.Wei, feeLimit uint32, maxGasPriceWei *assets.Wei, _ []EvmPriorAttempt) (newGasPrice *assets.Wei, chainSpecificGasLimit uint32, err error) {
chainSpecificGasLimit = feeLimit
ok := o.IfStarted(func() {
// Immediately return error if original fee is greater than or equal to the max gas price
// Prevents a loop of resubmitting the attempt with the max gas price
if originalFee.Cmp(maxGasPriceWei) >= 0 {
err = fmt.Errorf("original fee (%s) greater than or equal to max gas price (%s) so cannot be bumped further", originalFee.String(), maxGasPriceWei.String())
return
}
err = o.forceRefresh(ctx)
if newGasPrice = o.getGasPrice(); newGasPrice == nil {
err = errors.New("failed to refresh and return gas; gas price not set")
return
}
o.logger.Debugw("GasPrice", newGasPrice, "GasLimit", feeLimit)
})
if !ok {
return nil, 0, errors.New("estimator is not started")
} else if err != nil {
return
}
if newGasPrice != nil && newGasPrice.Cmp(maxGasPriceWei) > 0 {
Copy link
Collaborator

Choose a reason for hiding this comment

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

No need to return error here, we can let it cap on the max price down below. Just add a log below if the price gets capped so we get alerted.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we only want to cap at the max gas price if the price from the RPC is still lower than the max. In that scenario, we'd still be using a price that's equal or above the one recommended by the RPC. This clause checks if the price from the RPC is higher than the max gas price. If it is then capping at the max gas price would submit a tx below the recommended price which wouldn't succeed. I was thinking to treat this the same as the other estimators that have bumped past the max and return an error.

Copy link
Contributor

Choose a reason for hiding this comment

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

So here our maxGasPriceWei is lower than what the chain recommends.
Either we hard error, or keep bumping at maxGasPrice.
I think hard error might be better, to get NOP's attention to the bad config, instead of masking that error, and sending a Tx that we know is underpriced.

I see this as a bad configuration by the NOP.

return nil, 0, errors.Errorf("estimated gas price: %s is greater than the maximum gas price configured: %s", newGasPrice.String(), maxGasPriceWei.String())
}
// Add a buffer on top of the gas price returned by the RPC.
// Bump logic when using the suggested gas price from an RPC is realistically only needed when there is increased volatility in gas price.
// This buffer is a precaution to increase the chance of getting this tx on chain
bufferedPrice := fee.MaxBumpedFee(newGasPrice.ToInt(), o.cfg.BumpPercent(), o.cfg.BumpMin().ToInt())
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a weird thing to do here. Suggested estimator is supposed to only fetch what the RPC suggests. Although bumping seems good on paper it comes with a few drawbacks. On specific chains, the buffer needs to be disabled as it will mess up the tx inclusion unless you post exactly what the RPC suggests (i.e. Fantom, Klaytn). For chains that do want to enable this, we just rely on the same configs from the block history estimator, which might not be entirely applicable here. SCALE would have to figure those values out for existing and new chains.
Side note: if we keep the buffer feature, we would have to update BumpPercent description in the docs.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be possible to disable this buffering behavior by setting BumpThreshold = 0?
I think most chains (except for Fantom) that use Suggested estimator has this set in their config, for example (Klaytn, Metis).
So we can make sure that the Suggested behavior is the same by setting the BumpThreshold as needed.
We can set this for Fantom to ensure backward compatibility.

+1 on updating the docs as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Setting BumpThreshold = 0 should disable bumping all together in the Confirmer so don't think this should be an issue for Klaytn or Metis.
I've updated the docs for BumpPercent but worried that it requires too much knowledge of our gas estimator implementation to understand. Let me know if you have had something else in mind. I also found references to a couple related configs GasBumpPercent and GasBumpWei that seem legacy. I replaced them with BumpPercent and BumpMin to clean them up.

Copy link
Contributor

Choose a reason for hiding this comment

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

By default for SuggestedPrice, we already have BumpThreshold = 0
So bumping is OFF by default.
Whoever goes and changes that to enabled, must also understand that the bumpPercentrage and BumpMin will also apply to this estimator's bumping logic.

I think this is reasonable

Copy link
Contributor

@HelloKashif HelloKashif Feb 2, 2024

Choose a reason for hiding this comment

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

By default for SuggestedPrice, we already have BumpThreshold = 0

@prashantkumar1982 where is this logic set? Looking at the config docs the BumpThreshold default seems to be 3 so it will not be off by default for FTM .

https://github.com/smartcontractkit/chainlink/blob/develop/docs/CONFIG.md#bumpthreshold

// If the new suggested price is less than or equal to the max and the buffer puts the new price over the max, return the max price instead
// The buffer is added on top of the suggested price during bumping as just a precaution. It is better to resubmit the transaction with the max gas price instead of erroring.
newGasPrice = assets.NewWei(bigmath.Min(bufferedPrice, maxGasPriceWei.ToInt()))

// Return the original price if the refreshed price with the buffer is lower to ensure the bumped gas price is always equal or higher to the previous attempt
if originalFee != nil && originalFee.Cmp(newGasPrice) > 0 {
return originalFee, chainSpecificGasLimit, nil
}
return
}

func (o *SuggestedPriceEstimator) getGasPrice() (GasPrice *assets.Wei) {
Expand Down
Loading
Loading