Skip to content

Commit e389ad2

Browse files
feat(evm): add gas boost retry option for contract deploys [CLD-2779] (#1047)
## Why In order to take advantage of the contract.NewDeploy method used by the operations-gen cli, we need to add support for gas limit bumping as we currently support for legacy deploy MCMS contract changesets. Note this already exist in cld-changesets [here](https://github.com/smartcontractkit/cld-changesets/blob/f04fe26c52da79b60e9633d3d06e17f65ecb7893/pkg/family/evm/operations/utils.go#L433), moving and refactoring this to CLDF makes more sense to other users can take advantage of this option. ## Summary - Adds `RetryDeployWithGasBoost` and `GasBoostConfig` for operations2 EVM contract deploys, increasing gas limit and price on retries after gas-related failures. - Extends `DeployInput` with optional `GasLimit` and `GasPrice` fields and applies them via `deployTransactOpts` on EVM deploy paths. - Classifies retryable gas errors using geth sentinels (`errors.Is`) with a string fallback for RPC-wrapped messages. JIRA: https://smartcontract-it.atlassian.net/browse/CLD-2779
1 parent 7f1a31d commit e389ad2

7 files changed

Lines changed: 525 additions & 77 deletions

File tree

.changeset/dry-paws-unite.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat(operation): support gas bumping option for NewDeploy

chain/evm/operations2/contract/deploy.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ type DeployInput[ARGS any] struct {
4040
Qualifier *string `json:"qualifier,omitempty"`
4141
// Args are the parameters passed to the contract constructor.
4242
Args ARGS `json:"args"`
43+
// GasLimit optionally overrides the deployer gas limit on EVM chains.
44+
// Normally set by RetryDeployWithGasBoost after gas-related failures; may also be set manually.
45+
GasLimit uint64 `json:"gasLimit,omitempty"`
46+
// GasPrice optionally overrides the deployer legacy gas price on EVM chains (wei).
47+
// When non-zero, deploy uses a legacy fee transaction and clears any EIP-1559 GasFeeCap/GasTipCap.
48+
// Normally set by RetryDeployWithGasBoost after gas-related failures; may also be set manually.
49+
GasPrice uint64 `json:"gasPrice,omitempty"`
4350
}
4451

4552
// Bytecode specifies the exact bytecode to deploy for each supported VM.
@@ -133,7 +140,7 @@ func NewDeploy[ARGS any](params DeployParams[ARGS]) *operations.Operation[Deploy
133140
)
134141
} else {
135142
addr, tx, _, deployErr = deployEVMContract(
136-
chain.DeployerKey,
143+
deployTransactOpts(chain.DeployerKey, input.GasLimit, input.GasPrice),
137144
*parsedABI,
138145
bytecode.EVM,
139146
chain.Client,

chain/evm/operations2/contract/deployment_test.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"math/big"
78
"testing"
89

910
"github.com/Masterminds/semver/v3"
@@ -77,6 +78,16 @@ func TestDeploy(t *testing.T) {
7778
},
7879
isZkSyncVM: false,
7980
},
81+
{
82+
desc: "evm deployment with gas overrides",
83+
input: DeployInput[ConstructorArgs]{
84+
Args: ConstructorArgs{Value: 2},
85+
TypeAndVersion: deployment.NewTypeAndVersion(testContractType, *semver.MustParse("1.0.0")),
86+
GasLimit: 750_000,
87+
GasPrice: 25_000_000_000,
88+
},
89+
isZkSyncVM: false,
90+
},
8091
}
8192

8293
for _, test := range tests { //nolint:paralleltest // subtests modify package-level deployZkContract/deployEVMContract vars
@@ -127,6 +138,12 @@ func TestDeploy(t *testing.T) {
127138
},
128139
IsZkSyncVM: test.isZkSyncVM,
129140
}
141+
if test.input.GasLimit > 0 || test.input.GasPrice > 0 {
142+
chain.DeployerKey = &bind.TransactOpts{
143+
GasFeeCap: big.NewInt(100),
144+
GasTipCap: big.NewInt(2),
145+
}
146+
}
130147

131148
deployZkContract = func(
132149
_ context.Context,
@@ -140,7 +157,7 @@ func TestDeploy(t *testing.T) {
140157
return address, nil
141158
}
142159
deployEVMContract = func(
143-
_ *bind.TransactOpts,
160+
opts *bind.TransactOpts,
144161
_ abi.ABI,
145162
_ []byte,
146163
_ bind.ContractBackend,
@@ -155,6 +172,13 @@ func TestDeploy(t *testing.T) {
155172
)),
156173
}
157174
}
175+
if test.input.GasLimit > 0 || test.input.GasPrice > 0 {
176+
require.NotNil(t, opts)
177+
require.Equal(t, test.input.GasLimit, opts.GasLimit)
178+
require.Equal(t, test.input.GasPrice, opts.GasPrice.Uint64())
179+
require.Nil(t, opts.GasFeeCap)
180+
require.Nil(t, opts.GasTipCap)
181+
}
158182

159183
return address, types.NewTx(&types.LegacyTx{
160184
To: &address,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package contract
2+
3+
import (
4+
"errors"
5+
"math/big"
6+
"strings"
7+
8+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
9+
"github.com/ethereum/go-ethereum/core"
10+
"github.com/ethereum/go-ethereum/core/txpool"
11+
"github.com/ethereum/go-ethereum/core/vm"
12+
13+
"github.com/smartcontractkit/chainlink-deployments-framework/chain/evm"
14+
"github.com/smartcontractkit/chainlink-deployments-framework/operations"
15+
)
16+
17+
const (
18+
defaultInitialGasLimit = uint64(5_000_000)
19+
defaultGasLimitIncrement = uint64(500_000)
20+
defaultInitialGasPrice = uint64(20_000_000_000)
21+
defaultGasPriceIncrement = uint64(10_000_000_000)
22+
)
23+
24+
// GasBoostConfig defines gas limit and price increments applied on deploy retries.
25+
// Increment fields use pointers so zero is a valid configured value (no increment).
26+
// A nil increment pointer uses the package default increment.
27+
type GasBoostConfig struct {
28+
InitialGasLimit uint64 `json:"initialGasLimit"`
29+
GasLimitIncrement *uint64 `json:"gasLimitIncrement,omitempty"`
30+
InitialGasPrice uint64 `json:"initialGasPrice"`
31+
GasPriceIncrement *uint64 `json:"gasPriceIncrement,omitempty"`
32+
}
33+
34+
// RetryDeployWithGasBoost enables the default operation retry policy and increases gas on EVM deploy retries.
35+
// The operation may retry on any failure (per the framework retry policy); gas limit and price are adjusted
36+
// only when the prior attempt failed with a gas-related error.
37+
// The first execution attempt uses the chain deployer's default gas settings (auto-estimation).
38+
// On ZkSync VM chains, gas fields are not adjusted; omit this option for ZkSync-only deploy flows.
39+
// When cfg is nil, returns a no-op option and retry remains disabled (omit this option instead).
40+
func RetryDeployWithGasBoost[ARGS any](cfg *GasBoostConfig) operations.ExecuteOption[DeployInput[ARGS], evm.Chain] {
41+
if cfg == nil {
42+
return func(*operations.ExecuteConfig[DeployInput[ARGS], evm.Chain]) {}
43+
}
44+
c := *cfg
45+
46+
return operations.WithRetryInput(func(attempt uint, err error, in DeployInput[ARGS], deps evm.Chain) DeployInput[ARGS] {
47+
if deps.IsZkSyncVM || !isGasRetryableError(err) {
48+
return in
49+
}
50+
51+
gasLimit, gasPrice := nextBoostedGas(c, attempt, in.GasLimit, in.GasPrice)
52+
in.GasLimit = gasLimit
53+
in.GasPrice = gasPrice
54+
55+
return in
56+
})
57+
}
58+
59+
func (cfg GasBoostConfig) gasLimitIncrement() uint64 {
60+
if cfg.GasLimitIncrement == nil {
61+
return defaultGasLimitIncrement
62+
}
63+
64+
return *cfg.GasLimitIncrement
65+
}
66+
67+
func (cfg GasBoostConfig) gasPriceIncrement() uint64 {
68+
if cfg.GasPriceIncrement == nil {
69+
return defaultGasPriceIncrement
70+
}
71+
72+
return *cfg.GasPriceIncrement
73+
}
74+
75+
func resolveInitialGasLimit(cfg GasBoostConfig) uint64 {
76+
if cfg.InitialGasLimit > 0 {
77+
return cfg.InitialGasLimit
78+
}
79+
80+
return defaultInitialGasLimit
81+
}
82+
83+
func resolveInitialGasPrice(cfg GasBoostConfig) uint64 {
84+
if cfg.InitialGasPrice > 0 {
85+
return cfg.InitialGasPrice
86+
}
87+
88+
return defaultInitialGasPrice
89+
}
90+
91+
func nextBoostedGas(cfg GasBoostConfig, attempt uint, previousLimit, previousPrice uint64) (gasLimit uint64, gasPrice uint64) {
92+
initialGasLimit := resolveInitialGasLimit(cfg)
93+
gasLimitIncrement := cfg.gasLimitIncrement()
94+
initialGasPrice := resolveInitialGasPrice(cfg)
95+
gasPriceIncrement := cfg.gasPriceIncrement()
96+
97+
if previousLimit > 0 {
98+
gasLimit = previousLimit + gasLimitIncrement
99+
} else {
100+
gasLimit = initialGasLimit + uint64(attempt)*gasLimitIncrement
101+
}
102+
103+
if previousPrice > 0 {
104+
gasPrice = previousPrice + gasPriceIncrement
105+
} else {
106+
gasPrice = initialGasPrice + uint64(attempt)*gasPriceIncrement
107+
}
108+
109+
return gasLimit, gasPrice
110+
}
111+
112+
func isGasRetryableError(err error) bool {
113+
if err == nil {
114+
return false
115+
}
116+
117+
if errors.Is(err, vm.ErrOutOfGas) ||
118+
errors.Is(err, vm.ErrCodeStoreOutOfGas) ||
119+
errors.Is(err, core.ErrIntrinsicGas) ||
120+
errors.Is(err, core.ErrFeeCapTooLow) ||
121+
errors.Is(err, core.ErrGasLimitReached) ||
122+
errors.Is(err, txpool.ErrUnderpriced) ||
123+
errors.Is(err, txpool.ErrReplaceUnderpriced) ||
124+
errors.Is(err, txpool.ErrTxGasPriceTooLow) ||
125+
errors.Is(err, txpool.ErrGasLimit) {
126+
return true
127+
}
128+
129+
msg := strings.ToLower(err.Error())
130+
for _, pattern := range gasRetryableErrorPatterns {
131+
if strings.Contains(msg, pattern) {
132+
return true
133+
}
134+
}
135+
136+
return false
137+
}
138+
139+
// gasRetryableErrorPatterns covers RPC and wrapped errors that no longer carry geth sentinels.
140+
var gasRetryableErrorPatterns = []string{
141+
"out of gas",
142+
"gas required exceeds allowance",
143+
"intrinsic gas too low",
144+
"underpriced",
145+
"replacement transaction underpriced",
146+
"max fee per gas less than block base fee",
147+
"exceeds block gas limit",
148+
}
149+
150+
func deployTransactOpts(base *bind.TransactOpts, gasLimit, gasPrice uint64) *bind.TransactOpts {
151+
if base == nil {
152+
return nil
153+
}
154+
if gasLimit == 0 && gasPrice == 0 {
155+
return base
156+
}
157+
158+
opts := *base
159+
if gasLimit > 0 {
160+
opts.GasLimit = gasLimit
161+
}
162+
if gasPrice > 0 {
163+
opts.GasPrice = new(big.Int).SetUint64(gasPrice)
164+
opts.GasFeeCap = nil
165+
opts.GasTipCap = nil
166+
}
167+
168+
return &opts
169+
}

0 commit comments

Comments
 (0)