diff --git a/.changeset/fast-rules-sneeze.md b/.changeset/fast-rules-sneeze.md new file mode 100644 index 000000000..a847e0326 --- /dev/null +++ b/.changeset/fast-rules-sneeze.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(evm): allow gas bumping on contract.NewWrite diff --git a/chain/evm/operations2/contract/deploy.go b/chain/evm/operations2/contract/deploy.go index 108d3d572..dd93dbf4a 100644 --- a/chain/evm/operations2/contract/deploy.go +++ b/chain/evm/operations2/contract/deploy.go @@ -140,7 +140,7 @@ func NewDeploy[ARGS any](params DeployParams[ARGS]) *operations.Operation[Deploy ) } else { addr, tx, _, deployErr = deployEVMContract( - deployTransactOpts(chain.DeployerKey, input.GasLimit, input.GasPrice), + transactOptsWithGasOverrides(chain.DeployerKey, input.GasLimit, input.GasPrice), *parsedABI, bytecode.EVM, chain.Client, diff --git a/chain/evm/operations2/contract/function.go b/chain/evm/operations2/contract/function.go index 3b3f0033c..3a61c6fc4 100644 --- a/chain/evm/operations2/contract/function.go +++ b/chain/evm/operations2/contract/function.go @@ -4,4 +4,11 @@ package contract type FunctionInput[ARGS any] struct { // Args are the parameters passed to the contract call. Args ARGS `json:"args"` + // GasLimit optionally overrides the deployer gas limit on EVM writes. + // Normally set by RetryWriteWithGasBoost after gas-related failures; may also be set manually. + GasLimit uint64 `json:"gasLimit,omitempty"` + // GasPrice optionally overrides the deployer legacy gas price on EVM writes (wei). + // When non-zero, the write uses a legacy fee transaction and clears any EIP-1559 GasFeeCap/GasTipCap. + // Normally set by RetryWriteWithGasBoost after gas-related failures; may also be set manually. + GasPrice uint64 `json:"gasPrice,omitempty"` } diff --git a/chain/evm/operations2/contract/gas_boost.go b/chain/evm/operations2/contract/gas_boost.go index 23680d4ca..7425df8b1 100644 --- a/chain/evm/operations2/contract/gas_boost.go +++ b/chain/evm/operations2/contract/gas_boost.go @@ -37,6 +37,7 @@ type GasBoostConfig struct { // The first execution attempt uses the chain deployer's default gas settings (auto-estimation). // On ZkSync VM chains, gas fields are not adjusted; omit this option for ZkSync-only deploy flows. // When cfg is nil, returns a no-op option and retry remains disabled (omit this option instead). +// Use operations.WithRetry for retry without gas adjustment. func RetryDeployWithGasBoost[ARGS any](cfg *GasBoostConfig) operations.ExecuteOption[DeployInput[ARGS], evm.Chain] { if cfg == nil { return func(*operations.ExecuteConfig[DeployInput[ARGS], evm.Chain]) {} @@ -56,6 +57,33 @@ func RetryDeployWithGasBoost[ARGS any](cfg *GasBoostConfig) operations.ExecuteOp }) } +// RetryWriteWithGasBoost enables the default operation retry policy for contract writes and +// increases gas on EVM retries when the prior attempt failed with a gas-related error. +// The operation may retry on any failure (per the framework retry policy); gas limit and price are adjusted +// only when the prior attempt failed with a gas-related error. +// The first execution attempt uses the chain deployer's default gas settings. +// On ZkSync VM chains, gas fields are not adjusted; omit this option for ZkSync-only write flows. +// When cfg is nil, returns a no-op option and retry remains disabled (omit this option instead). +// Use operations.WithRetry for retry without gas adjustment. +func RetryWriteWithGasBoost[ARGS any](cfg *GasBoostConfig) operations.ExecuteOption[FunctionInput[ARGS], evm.Chain] { + if cfg == nil { + return func(*operations.ExecuteConfig[FunctionInput[ARGS], evm.Chain]) {} + } + c := *cfg + + return operations.WithRetryInput(func(attempt uint, err error, in FunctionInput[ARGS], deps evm.Chain) FunctionInput[ARGS] { + if deps.IsZkSyncVM || !isGasRetryableError(err) { + return in + } + + gasLimit, gasPrice := nextBoostedGas(c, attempt, in.GasLimit, in.GasPrice) + in.GasLimit = gasLimit + in.GasPrice = gasPrice + + return in + }) +} + func (cfg GasBoostConfig) gasLimitIncrement() uint64 { if cfg.GasLimitIncrement == nil { return defaultGasLimitIncrement @@ -147,7 +175,7 @@ var gasRetryableErrorPatterns = []string{ "exceeds block gas limit", } -func deployTransactOpts(base *bind.TransactOpts, gasLimit, gasPrice uint64) *bind.TransactOpts { +func transactOptsWithGasOverrides(base *bind.TransactOpts, gasLimit, gasPrice uint64) *bind.TransactOpts { if base == nil { return nil } diff --git a/chain/evm/operations2/contract/gas_boost_test.go b/chain/evm/operations2/contract/gas_boost_test.go index a3ae1ab02..323664735 100644 --- a/chain/evm/operations2/contract/gas_boost_test.go +++ b/chain/evm/operations2/contract/gas_boost_test.go @@ -67,7 +67,7 @@ func TestIsGasRetryableError(t *testing.T) { require.True(t, isGasRetryableError(fmt.Errorf("deploy failed: %w", core.ErrFeeCapTooLow))) } -func TestDeployTransactOpts(t *testing.T) { +func TestTransactOptsWithGasOverrides(t *testing.T) { t.Parallel() base := &bind.TransactOpts{ @@ -77,21 +77,21 @@ func TestDeployTransactOpts(t *testing.T) { GasTipCap: big.NewInt(2), } - require.Nil(t, deployTransactOpts(nil, 1, 1)) - require.Same(t, base, deployTransactOpts(base, 0, 0)) + require.Nil(t, transactOptsWithGasOverrides(nil, 1, 1)) + require.Same(t, base, transactOptsWithGasOverrides(base, 0, 0)) - limitOnly := deployTransactOpts(base, 500_000, 0) + limitOnly := transactOptsWithGasOverrides(base, 500_000, 0) require.Equal(t, uint64(500_000), limitOnly.GasLimit) require.Equal(t, big.NewInt(1), limitOnly.GasPrice) require.Equal(t, big.NewInt(100), limitOnly.GasFeeCap) - priceOnly := deployTransactOpts(base, 0, 30_000_000_000) + priceOnly := transactOptsWithGasOverrides(base, 0, 30_000_000_000) require.Equal(t, uint64(21_000), priceOnly.GasLimit) require.Equal(t, uint64(30_000_000_000), priceOnly.GasPrice.Uint64()) require.Nil(t, priceOnly.GasFeeCap) require.Nil(t, priceOnly.GasTipCap) - withGas := deployTransactOpts(base, 500_000, 30_000_000_000) + withGas := transactOptsWithGasOverrides(base, 500_000, 30_000_000_000) require.Equal(t, uint64(500_000), withGas.GasLimit) require.Equal(t, uint64(30_000_000_000), withGas.GasPrice.Uint64()) require.Nil(t, withGas.GasFeeCap) @@ -236,3 +236,133 @@ func TestRetryDeployWithGasBoostSkipsZkSync(t *testing.T) { func ptrUint64(v uint64) *uint64 { return &v } + +func TestRetryWriteWithGasBoostRetriesOnGasError(t *testing.T) { + t.Parallel() + + failures := 2 + var gasLimits []uint64 + op := operations.NewOperation( + "write-gas-boost-retry", + semver.MustParse("1.0.0"), + "test", + func(_ operations.Bundle, _ evm.Chain, input FunctionInput[struct{}]) (struct{}, error) { + gasLimits = append(gasLimits, input.GasLimit) + if failures > 0 { + failures-- + return struct{}{}, errors.New("out of gas: gas required exceeds allowance") + } + + return struct{}{}, nil + }, + ) + + cfg := &GasBoostConfig{ + InitialGasLimit: 1_000_000, + GasLimitIncrement: ptrUint64(100_000), + } + bundle := optest.NewBundle(t) + _, err := operations.ExecuteOperation( + bundle, + op, + evm.Chain{Selector: 1}, + FunctionInput[struct{}]{}, + RetryWriteWithGasBoost[struct{}](cfg), + ) + require.NoError(t, err) + require.Equal(t, []uint64{0, 1_000_000, 1_100_000}, gasLimits) +} + +func TestRetryWriteWithGasBoostNilConfig(t *testing.T) { + t.Parallel() + + failures := 2 + calls := 0 + op := operations.NewOperation( + "write-gas-boost-nil", + semver.MustParse("1.0.0"), + "test", + func(_ operations.Bundle, _ evm.Chain, _ FunctionInput[struct{}]) (struct{}, error) { + calls++ + if failures > 0 { + failures-- + return struct{}{}, errors.New("out of gas") + } + + return struct{}{}, nil + }, + ) + + bundle := optest.NewBundle(t) + _, err := operations.ExecuteOperation( + bundle, + op, + evm.Chain{Selector: 1}, + FunctionInput[struct{}]{}, + RetryWriteWithGasBoost[struct{}](nil), + ) + require.Error(t, err) + require.Equal(t, 1, calls) +} + +func TestRetryWriteWithGasBoostSkipsNonGasErrors(t *testing.T) { + t.Parallel() + + var gasLimits []uint64 + op := operations.NewOperation( + "write-gas-boost-skip", + semver.MustParse("1.0.0"), + "test", + func(_ operations.Bundle, _ evm.Chain, input FunctionInput[struct{}]) (struct{}, error) { + gasLimits = append(gasLimits, input.GasLimit) + return struct{}{}, errors.New("revert: unauthorized") + }, + ) + + bundle := optest.NewBundle(t) + _, err := operations.ExecuteOperation( + bundle, + op, + evm.Chain{Selector: 1}, + FunctionInput[struct{}]{}, + RetryWriteWithGasBoost[struct{}](&GasBoostConfig{}), + ) + require.Error(t, err) + for _, limit := range gasLimits { + require.Equal(t, uint64(0), limit) + } +} + +func TestRetryWriteWithGasBoostSkipsZkSync(t *testing.T) { + t.Parallel() + + failures := 2 + var gasLimits []uint64 + op := operations.NewOperation( + "write-gas-boost-zksync", + semver.MustParse("1.0.0"), + "test", + func(_ operations.Bundle, _ evm.Chain, input FunctionInput[struct{}]) (struct{}, error) { + gasLimits = append(gasLimits, input.GasLimit) + if failures > 0 { + failures-- + return struct{}{}, errors.New("out of gas") + } + + return struct{}{}, nil + }, + ) + + bundle := optest.NewBundle(t) + _, err := operations.ExecuteOperation( + bundle, + op, + evm.Chain{Selector: 1, IsZkSyncVM: true}, + FunctionInput[struct{}]{}, + RetryWriteWithGasBoost[struct{}](&GasBoostConfig{}), + ) + require.NoError(t, err) + for _, limit := range gasLimits { + require.Equal(t, uint64(0), limit) + } +} diff --git a/chain/evm/operations2/contract/write.go b/chain/evm/operations2/contract/write.go index 796d13448..33fd6e5fe 100644 --- a/chain/evm/operations2/contract/write.go +++ b/chain/evm/operations2/contract/write.go @@ -94,7 +94,7 @@ func NewWrite[ARGS any, C interface{ Address() common.Address }](params WritePar } opts := deployment.SimTransactOpts() if allowed { - opts = chain.DeployerKey + opts = transactOptsWithGasOverrides(chain.DeployerKey, input.GasLimit, input.GasPrice) } var execInfo *ExecInfo tx, callErr := params.CallContract(params.Contract, opts, input.Args)