diff --git a/relayer/config/config_test.go b/relayer/config/config_test.go index 46eff23e..1102c596 100644 --- a/relayer/config/config_test.go +++ b/relayer/config/config_test.go @@ -743,6 +743,95 @@ func TestInitializeTrackedSubnets(t *testing.T) { require.True(t, expectedSubnets.Equals(cfg.GetTrackedSubnets())) } +// TestMaxFeePerGasValidation tests the MaxFeePerGas validation logic +func TestMaxFeePerGasValidation(t *testing.T) { + testCases := []struct { + name string + maxFeePerGas uint64 + maxBaseFee uint64 + maxPriorityFeePerGas uint64 + expectError bool + errorContains string + }{ + { + name: "MaxFeePerGas not set - should pass", + maxFeePerGas: 0, + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + expectError: false, + }, + { + name: "Valid MaxFeePerGas - should pass", + maxFeePerGas: 60000000000, + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + expectError: false, + }, + { + name: "MaxFeePerGas equals sum - should pass", + maxFeePerGas: 52500000000, + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + expectError: false, + }, + { + name: "MaxFeePerGas less than sum - should fail", + maxFeePerGas: 50000000000, + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + expectError: true, + errorContains: "max-fee-per-gas (50000000000) must be at least max-base-fee (50000000000) + max-priority-fee-per-gas (2500000000) = 52500000000", + }, + { + name: "MaxFeePerGas less than priority fee - should fail", + maxFeePerGas: 1000000000, + maxBaseFee: 0, // Not set + maxPriorityFeePerGas: 2500000000, + expectError: true, + errorContains: "max-fee-per-gas (1000000000) must be at least max-priority-fee-per-gas (2500000000)", + }, + { + name: "MaxFeePerGas with only priority fee set - should pass", + maxFeePerGas: 5000000000, + maxBaseFee: 0, // Not set + maxPriorityFeePerGas: 2500000000, + expectError: false, + }, + { + name: "Edge case: MaxFeePerGas equals priority fee - should pass", + maxFeePerGas: 2500000000, + maxBaseFee: 0, // Not set + maxPriorityFeePerGas: 2500000000, + expectError: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + destBlockchain := &DestinationBlockchain{ + SubnetID: "2TGBXcnwx5PqiXWiqxAKUaNSqDguXNh1mxnp82jui68hxJSZAx", + BlockchainID: "S4mMqUXe7vHsGiRAma6bv3CKnyaLssyAxmQ2KvFpX1KEvfFCD", + VM: "evm", + RPCEndpoint: basecfg.APIConfig{ + BaseURL: "https://subnets.avax.network/mysubnet/rpc", + }, + AccountPrivateKeys: []string{"56289e99c94b6912bfc12adc093c9b51124f0dc54ac7a766b2bc5ccf558d8027"}, + MaxFeePerGas: tt.maxFeePerGas, + MaxBaseFee: tt.maxBaseFee, + MaxPriorityFeePerGas: tt.maxPriorityFeePerGas, + } + + err := destBlockchain.Validate() + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorContains) + } else { + require.NoError(t, err) + } + }) + } +} + func TestConfigSanitization(t *testing.T) { testCases := []struct { name string diff --git a/relayer/config/destination_blockchain.go b/relayer/config/destination_blockchain.go index 8378d9a3..97ed9cdb 100644 --- a/relayer/config/destination_blockchain.go +++ b/relayer/config/destination_blockchain.go @@ -42,6 +42,7 @@ type DestinationBlockchain struct { BlockGasLimit uint64 `mapstructure:"block-gas-limit" json:"block-gas-limit"` MaxBaseFee uint64 `mapstructure:"max-base-fee" json:"max-base-fee"` MaxPriorityFeePerGas uint64 `mapstructure:"max-priority-fee-per-gas" json:"max-priority-fee-per-gas"` + MaxFeePerGas uint64 `mapstructure:"max-fee-per-gas" json:"max-fee-per-gas"` TxInclusionTimeoutSeconds uint64 `mapstructure:"tx-inclusion-timeout-seconds" json:"tx-inclusion-timeout-seconds"` @@ -140,6 +141,21 @@ func (s *DestinationBlockchain) Validate() error { s.TxInclusionTimeoutSeconds = defaultTxInclusionTimeoutSeconds } + // Validate MaxFeePerGas if configured + if s.MaxFeePerGas > 0 { + // Ensure MaxFeePerGas is at least as large as MaxBaseFee + MaxPriorityFeePerGas + // This prevents configuration that would make transactions impossible to send + if s.MaxBaseFee > 0 && s.MaxFeePerGas < (s.MaxBaseFee+s.MaxPriorityFeePerGas) { + return fmt.Errorf("max-fee-per-gas (%d) must be at least max-base-fee (%d) + max-priority-fee-per-gas (%d) = %d", + s.MaxFeePerGas, s.MaxBaseFee, s.MaxPriorityFeePerGas, s.MaxBaseFee+s.MaxPriorityFeePerGas) + } + // Even if MaxBaseFee is not set, ensure MaxFeePerGas is at least the priority fee + if s.MaxFeePerGas < s.MaxPriorityFeePerGas { + return fmt.Errorf("max-fee-per-gas (%d) must be at least max-priority-fee-per-gas (%d)", + s.MaxFeePerGas, s.MaxPriorityFeePerGas) + } + } + return nil } diff --git a/vms/evm/destination_client.go b/vms/evm/destination_client.go index 1603d402..574eca44 100644 --- a/vms/evm/destination_client.go +++ b/vms/evm/destination_client.go @@ -52,6 +52,7 @@ type destinationClient struct { blockGasLimit uint64 maxBaseFee *big.Int maxPriorityFeePerGas *big.Int + maxFeePerGas *big.Int logger logging.Logger txInclusionTimeout time.Duration } @@ -212,6 +213,7 @@ func NewDestinationClient( blockGasLimit: destinationBlockchain.BlockGasLimit, maxBaseFee: new(big.Int).SetUint64(destinationBlockchain.MaxBaseFee), maxPriorityFeePerGas: new(big.Int).SetUint64(destinationBlockchain.MaxPriorityFeePerGas), + maxFeePerGas: new(big.Int).SetUint64(destinationBlockchain.MaxFeePerGas), txInclusionTimeout: time.Duration(destinationBlockchain.TxInclusionTimeoutSeconds) * time.Second, } @@ -223,7 +225,8 @@ func NewDestinationClient( // maximum base is calculated as the current base fee multiplied by the default base fee factor. // The maximum priority fee per gas is set the minimum of the suggested gas tip cap and the configured // maximum priority fee per gas. The max fee per gas is set to the sum of the max base fee and the -// max priority fee per gas. +// max priority fee per gas. If maxFeePerGas is configured, the total gas fee cap will be capped +// at that value, with priority fee adjusted accordingly to fit within the limit. func (c *destinationClient) SendTx( signedMessage *avalancheWarp.Message, deliverers set.Set[common.Address], @@ -270,6 +273,27 @@ func (c *destinationClient) SendTx( to := common.HexToAddress(toAddress) gasFeeCap := new(big.Int).Add(maxBaseFee, gasTipCap) + // If maxFeePerGas is configured and is greater than 0, cap the total gas fee per gas unit + if c.maxFeePerGas != nil && c.maxFeePerGas.Cmp(big.NewInt(0)) > 0 && gasFeeCap.Cmp(c.maxFeePerGas) > 0 { + c.logger.Warn( + "Calculated gas fee cap exceeds configured maximum fee per gas, capping to max", + zap.String("calculatedGasFeeCap", gasFeeCap.String()), + zap.String("maxFeePerGas", c.maxFeePerGas.String()), + ) + gasFeeCap = new(big.Int).Set(c.maxFeePerGas) + + // Ensure gasTipCap doesn't exceed the capped gasFeeCap + // gasFeeCap = maxBaseFee + gasTipCap, so gasTipCap = gasFeeCap - maxBaseFee + adjustedTipCap := new(big.Int).Sub(gasFeeCap, maxBaseFee) + if adjustedTipCap.Cmp(big.NewInt(0)) < 0 { + // If maxBaseFee is higher than maxFeePerGas, set gasTipCap to 0 + gasTipCap = big.NewInt(0) + } else if adjustedTipCap.Cmp(gasTipCap) < 0 { + // Adjust gasTipCap to fit within the capped gasFeeCap + gasTipCap = adjustedTipCap + } + } + resultChan := make(chan txResult) messageData := txData{ diff --git a/vms/evm/destination_client_test.go b/vms/evm/destination_client_test.go index 8fa09601..7a92f5f8 100644 --- a/vms/evm/destination_client_test.go +++ b/vms/evm/destination_client_test.go @@ -118,6 +118,7 @@ func TestSendTx(t *testing.T) { evmChainID: big.NewInt(5), maxBaseFee: test.maxBaseFee, maxPriorityFeePerGas: big.NewInt(0), + maxFeePerGas: big.NewInt(0), // Initialize to prevent nil panic blockGasLimit: 0, txInclusionTimeout: 30 * time.Second, } @@ -155,3 +156,149 @@ func TestSendTx(t *testing.T) { }) } } + +// TestMaxFeePerGasCalculation tests the gas fee calculation and capping logic +func TestMaxFeePerGasCalculation(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + testCases := []struct { + name string + maxBaseFee uint64 + maxPriorityFeePerGas uint64 + maxFeePerGas uint64 + currentBaseFee uint64 + suggestedTip uint64 + expectedGasFeeCap uint64 + expectedGasTipCap uint64 + }{ + { + name: "No MaxFeePerGas - normal behavior", + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 0, // Not set + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 52000000000, // maxBaseFee + suggestedTip + expectedGasTipCap: 2000000000, + }, + { + name: "MaxFeePerGas higher than calculated - no capping", + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 60000000000, + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 52000000000, // maxBaseFee + suggestedTip + expectedGasTipCap: 2000000000, + }, + { + name: "MaxFeePerGas lower than calculated - should cap", + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 51000000000, + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 51000000000, // Capped to maxFeePerGas + expectedGasTipCap: 1000000000, // Adjusted: 51B - 50B = 1B + }, + { + name: "MaxFeePerGas much lower - tip becomes zero", + maxBaseFee: 50000000000, + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 40000000000, + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 40000000000, // Capped to maxFeePerGas + expectedGasTipCap: 0, // Adjusted: 40B - 50B = -10B, so 0 + }, + { + name: "Dynamic base fee calculation when maxBaseFee is 0", + maxBaseFee: 0, // Will use currentBaseFee * 3 + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 100000000000, + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 77000000000, // (25B * 3) + 2B = 77B + expectedGasTipCap: 2000000000, + }, + { + name: "Dynamic base fee with MaxFeePerGas capping", + maxBaseFee: 0, // Will use currentBaseFee * 3 + maxPriorityFeePerGas: 2500000000, + maxFeePerGas: 70000000000, + currentBaseFee: 25000000000, + suggestedTip: 2000000000, + expectedGasFeeCap: 70000000000, // Capped + expectedGasTipCap: 0, // Adjusted: 70B - 75B = -5B, so 0 + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + mockClient := mock_ethclient.NewMockClient(ctrl) + + // Setup base fee estimation if maxBaseFee is 0 + if tt.maxBaseFee == 0 { + mockClient.EXPECT(). + EstimateBaseFee(gomock.Any()). + Return(big.NewInt(int64(tt.currentBaseFee)), nil). + Times(1) + } + + // Setup suggested gas tip cap + mockClient.EXPECT(). + SuggestGasTipCap(gomock.Any()). + Return(big.NewInt(int64(tt.suggestedTip)), nil). + Times(1) + + // Create destination client with test configuration + destClient := &destinationClient{ + client: mockClient, + maxBaseFee: big.NewInt(int64(tt.maxBaseFee)), + maxPriorityFeePerGas: big.NewInt(int64(tt.maxPriorityFeePerGas)), + maxFeePerGas: big.NewInt(int64(tt.maxFeePerGas)), + evmChainID: big.NewInt(1), + logger: logging.NoLog{}, + } + + // Test just the gas fee calculation logic by replicating the SendTx logic + var maxBaseFee *big.Int + if destClient.maxBaseFee.Cmp(big.NewInt(0)) > 0 { + maxBaseFee = destClient.maxBaseFee + } else { + baseFee, err := destClient.client.EstimateBaseFee(nil) + require.NoError(t, err) + maxBaseFee = new(big.Int).Mul(baseFee, big.NewInt(defaultBaseFeeFactor)) + } + + gasTipCap, err := destClient.client.SuggestGasTipCap(nil) + require.NoError(t, err) + + if gasTipCap.Cmp(destClient.maxPriorityFeePerGas) > 0 { + gasTipCap = destClient.maxPriorityFeePerGas + } + + gasFeeCap := new(big.Int).Add(maxBaseFee, gasTipCap) + + // Apply MaxFeePerGas capping logic (replicated from SendTx) + if destClient.maxFeePerGas != nil && destClient.maxFeePerGas.Cmp(big.NewInt(0)) > 0 && gasFeeCap.Cmp(destClient.maxFeePerGas) > 0 { + gasFeeCap = new(big.Int).Set(destClient.maxFeePerGas) + + adjustedTipCap := new(big.Int).Sub(gasFeeCap, maxBaseFee) + if adjustedTipCap.Cmp(big.NewInt(0)) < 0 { + gasTipCap = big.NewInt(0) + } else if adjustedTipCap.Cmp(gasTipCap) < 0 { + gasTipCap = adjustedTipCap + } + } + + // Verify the calculated values match expectations + require.Equal(t, big.NewInt(int64(tt.expectedGasFeeCap)), gasFeeCap, + "Expected gasFeeCap %d, got %s", tt.expectedGasFeeCap, gasFeeCap.String()) + + require.Equal(t, big.NewInt(int64(tt.expectedGasTipCap)), gasTipCap, + "Expected gasTipCap %d, got %s", tt.expectedGasTipCap, gasTipCap.String()) + }) + } +}