- Treat maxFeePerGas as the total protocol cap for presets and custom fees. Validate it against base and priority fees, and use the same cap and gas limit for transaction construction and maximum-fee estimates.
What changed, and why it matters
This commit fixes how Stack Wallet handles Ethereum transaction fees. Previously, the app appears to have treated 'max base fee' and 'priority fee' as separate fields, which could let users set a priority fee higher than the total fee cap or set a total cap too low to cover the network's base fee. The patch makes the 'max fee per gas' the hard total cap, validates that the priority fee fits inside it, and uses the same cap and gas limit both when building the transaction and when showing the maximum possible fee. It also improves input validation so malformed gas limits are rejected and fee numbers are properly localized for different languages.
Review and merge after confirming tests pass. The change is defensive and improves fee safety, but verify that preset fee estimates still display intuitively to users and that the new maxFeePerGas label is understood as a total cap. Consider adding a changelog note because the UI terminology changed.
Security signals we found
Fee-cap validation added: maxPriorityFeePerGas must not exceed maxFeePerGas
Total cap semantics corrected: maxFeePerGas is now base + priority inclusive cap
Transaction construction and fee estimate now use consistent gasLimit and maxFeePerGas
Malformed gas-limit input is rejected instead of silently coerced
Custom fee form emits null state when inputs are invalid, preventing downstream use of bad values
Evidence from the diff
The change refactors EIP-1559 fee handling. A new resolveEip1559FeeCaps helper enforces that maxFeePerGas is the total per-gas protocol cap, that it exceeds the current baseFee, and that maxPriorityFeePerGas does not exceed it. Presets now compute maxFeePerGas as baseFee*2 + priorityFee, while custom fees use the user-supplied cap. EthEIP1559Fee is renamed from maxBaseFee/priorityFee to maxFeePerGas/maxPriorityFeePerGas, and the UI (EthFeeForm) now localizes decimal input, rejects non-integer gas limits, and emits null state when caps are inconsistent. Transaction construction and fee estimation now use the same gasLimit and maxFeePerGas values, removing a prior mismatch.
Changed components
Ethereum wallet transaction preparation (lib/wallets/wallet/impl/ethereum_wallet.dart)Ethereum token wallet transaction preparation (lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart)Custom EIP-1559 fee form UI (lib/widgets/eth_fee_form.dart)Desktop send fee form (lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart)Send view and token send view (lib/pages/send_view/send_view.dart, lib/pages/send_view/token_send_view.dart)New integer input utility (lib/utilities/integer_input.dart)Inspect captured patch +758 / −163
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index c31e58a..6ffc241 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -2733,6 +2733,7 @@ class _SendViewState extends ConsumerState<SendView> {
const SizedBox(height: 12),
if (isCustomFee && isEth)
EthFeeForm(
+ locale: locale,
minGasLimit: kEthereumMinGasLimit,
stateChanged: (fee) => ethFee = fee,
),
diff --git a/lib/pages/send_view/token_send_view.dart b/lib/pages/send_view/token_send_view.dart
index 3d2a56b..9ede510 100644
--- a/lib/pages/send_view/token_send_view.dart
+++ b/lib/pages/send_view/token_send_view.dart
@@ -1296,6 +1296,7 @@ class _TokenSendViewState extends ConsumerState<TokenSendView> {
if (isCustomFee) const SizedBox(height: 12),
if (isCustomFee)
EthFeeForm(
+ locale: locale,
minGasLimit: kEthereumTokenMinGasLimit,
stateChanged: (value) => ethFee = value,
),
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
index eb40e66..1f9dd69 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dart
@@ -41,7 +41,7 @@ class DesktopSendFeeForm extends ConsumerStatefulWidget {
final bool isToken;
final void Function(int) onCustomFeeSliderChanged;
final VoidCallback onCustomFeeOptionChanged;
- final void Function(EthEIP1559Fee)? onCustomEip1559FeeOptionChanged;
+ final void Function(EthEIP1559Fee?)? onCustomEip1559FeeOptionChanged;
@override
ConsumerState<DesktopSendFeeForm> createState() => _DesktopSendFeeFormState();
@@ -97,6 +97,9 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
@override
Widget build(BuildContext context) {
final isCustomFee = ref.watch(feeRateTypeDesktopStateProvider).isCustom;
+ final locale = ref.watch(
+ localeServiceChangeNotifierProvider.select((value) => value.locale),
+ );
final canEditFees =
isEth ||
cryptoCurrency is Solana ||
@@ -333,6 +336,7 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
),
if (isCustomFee && isEth)
EthFeeForm(
+ locale: locale,
minGasLimit: widget.isToken
? kEthereumTokenMinGasLimit
: kEthereumMinGasLimit,
diff --git a/lib/utilities/integer_input.dart b/lib/utilities/integer_input.dart
new file mode 100644
index 0000000..6aaeae6
--- /dev/null
+++ b/lib/utilities/integer_input.dart
@@ -0,0 +1,32 @@
+final _decimalIntegerPattern = RegExp(r'^-?[0-9]+$');
+
+int? tryParseIntegerInput(String text, {int? minimum, int? maximum}) {
+ assert(minimum == null || maximum == null || minimum <= maximum);
+
+ final normalized = text.trim();
+ if (!_decimalIntegerPattern.hasMatch(normalized)) {
+ return null;
+ }
+
+ final value = int.tryParse(normalized, radix: 10);
+ if (value == null ||
+ (minimum != null && value < minimum) ||
+ (maximum != null && value > maximum)) {
+ return null;
+ }
+
+ return value;
+}
+
+({bool isValid, int? value}) parseOptionalIntegerInput(
+ String text, {
+ int? minimum,
+ int? maximum,
+}) {
+ if (text.isEmpty) {
+ return (isValid: true, value: null);
+ }
+
+ final value = tryParseIntegerInput(text, minimum: minimum, maximum: maximum);
+ return (isValid: value != null, value: value);
+}
diff --git a/lib/wallets/wallet/impl/ethereum_wallet.dart b/lib/wallets/wallet/impl/ethereum_wallet.dart
index 354d7fe..133fbc2 100644
--- a/lib/wallets/wallet/impl/ethereum_wallet.dart
+++ b/lib/wallets/wallet/impl/ethereum_wallet.dart
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:decimal/decimal.dart';
import 'package:ethereum_addresses/ethereum_addresses.dart';
+import 'package:flutter/foundation.dart';
import 'package:http/http.dart';
import 'package:isar_community/isar.dart';
import 'package:wallet/wallet.dart' as eth_wallet;
@@ -30,6 +31,40 @@ import '../wallet_mixin_interfaces/private_key_interface.dart';
// Eth can not use tor with web3dart
+@visibleForTesting
+({BigInt maxFeePerGas, BigInt maxPriorityFeePerGas}) resolveEip1559FeeCaps({
+ required BigInt baseFee,
+ required BigInt priorityFeePerGas,
+ BigInt? customMaxFeePerGas,
+}) {
+ if (customMaxFeePerGas != null && priorityFeePerGas.isNegative) {
+ throw Exception("Max priority fee per gas cannot be negative.");
+ }
+ final maxPriorityFeePerGas = priorityFeePerGas.isNegative
+ ? BigInt.zero
+ : priorityFeePerGas;
+
+ // Presets get 2x base-fee headroom since it can rise 12.5% per block. The
+ // EIP-1559 max fee is the total per-gas cap, so it also includes priority.
+ final maxFeePerGas =
+ customMaxFeePerGas ?? baseFee * BigInt.two + maxPriorityFeePerGas;
+
+ if (maxFeePerGas <= BigInt.zero) {
+ throw Exception("Max fee per gas must be greater than zero.");
+ }
+ if (baseFee > maxFeePerGas) {
+ throw Exception("Max fee per gas is below the current network base fee.");
+ }
+ if (maxPriorityFeePerGas > maxFeePerGas) {
+ throw Exception("Max priority fee per gas exceeds max fee per gas.");
+ }
+
+ return (
+ maxFeePerGas: maxFeePerGas,
+ maxPriorityFeePerGas: maxPriorityFeePerGas,
+ );
+}
+
class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
EthereumWallet(CryptoCurrencyNetwork network) : super(Ethereum(network));
@@ -440,9 +475,8 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
({
int nonce,
BigInt chainId,
- BigInt baseFee,
- BigInt maxBaseFee,
- BigInt priorityFee,
+ BigInt maxFeePerGas,
+ BigInt maxPriorityFeePerGas,
})
>
internalSharedPrepareSend({
@@ -469,31 +503,25 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
final feeObject = await fees;
final BigInt baseFee = feeObject.suggestBaseFee;
- // Presets get 2x headroom since base fee can rise 12.5% per block.
- final BigInt maxBaseFee = feeRateType == .custom
- ? txData.ethEIP1559Fee!.maxBaseFeeWei
- : baseFee * BigInt.two;
-
final BigInt rawPriority = switch (feeRateType) {
.fast => feeObject.fast - baseFee,
.average => feeObject.medium - baseFee,
.slow => feeObject.slow - baseFee,
- .custom => txData.ethEIP1559Fee!.priorityFeeWei,
+ .custom => txData.ethEIP1559Fee!.maxPriorityFeePerGasWei,
};
- final BigInt priorityFee = rawPriority.isNegative
- ? BigInt.zero
- : rawPriority;
-
- if (baseFee > maxBaseFee) {
- throw Exception("Max base fee is below the current network base fee.");
- }
+ final feeCaps = resolveEip1559FeeCaps(
+ baseFee: baseFee,
+ priorityFeePerGas: rawPriority,
+ customMaxFeePerGas: feeRateType == .custom
+ ? txData.ethEIP1559Fee!.maxFeePerGasWei
+ : null,
+ );
return (
nonce: nonce,
chainId: chainId,
- baseFee: baseFee,
- maxBaseFee: maxBaseFee,
- priorityFee: priorityFee,
+ maxFeePerGas: feeCaps.maxFeePerGas,
+ maxPriorityFeePerGas: feeCaps.maxPriorityFeePerGas,
);
}
@@ -515,24 +543,26 @@ class EthereumWallet extends Bip39Wallet with PrivateKeyInterface {
throw Exception("Insufficient balance");
}
+ final gasLimit = txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit;
final tx = web3.Transaction(
to: eth_wallet.EthereumAddress.fromHex(address),
- maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumMinGasLimit,
+ maxGas: gasLimit,
value: eth_wallet.EtherAmount.inWei(amount.raw),
nonce: prep.nonce,
maxFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.maxBaseFee,
+ prep.maxFeePerGas,
),
maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.priorityFee,
+ prep.maxPriorityFeePerGas,
),
);
- final feeEstimate = await estimateFeeFor(
- Amount.zero,
- prep.maxBaseFee + prep.priorityFee,
+ final feeEstimate = estimateEthFee(
+ prep.maxFeePerGas,
+ gasLimit,
+ cryptoCurrency.fractionDigits,
);
return txData.copyWith(
diff --git a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart
index 6aca5a0..18cb8f7 100644
--- a/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart
+++ b/lib/wallets/wallet/impl/sub_wallets/eth_token_wallet.dart
@@ -240,25 +240,28 @@ class EthTokenWallet extends Wallet {
throw Exception("Insufficient balance");
}
+ final gasLimit =
+ txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit;
final tx = web3dart.Transaction.callContract(
contract: _deployedContract,
function: _sendFunction,
parameters: [eth_wallet.EthereumAddress.fromHex(address), amount.raw],
- maxGas: txData.ethEIP1559Fee?.gasLimit ?? kEthereumTokenMinGasLimit,
+ maxGas: gasLimit,
nonce: prep.nonce,
maxFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.maxBaseFee,
+ prep.maxFeePerGas,
),
maxPriorityFeePerGas: eth_wallet.EtherAmount.fromBigInt(
eth_wallet.EtherUnit.wei,
- prep.priorityFee,
+ prep.maxPriorityFeePerGas,
),
);
- final feeEstimate = await estimateFeeFor(
- Amount.zero,
- prep.maxBaseFee + prep.priorityFee,
+ final feeEstimate = ethWallet.estimateEthFee(
+ prep.maxFeePerGas,
+ gasLimit,
+ cryptoCurrency.fractionDigits,
);
return txData.copyWith(
fee: feeEstimate,
diff --git a/lib/widgets/eth_fee_form.dart b/lib/widgets/eth_fee_form.dart
index 2f4e288..67487a2 100644
--- a/lib/widgets/eth_fee_form.dart
+++ b/lib/widgets/eth_fee_form.dart
@@ -5,55 +5,68 @@ import 'package:flutter/material.dart';
import '../services/ethereum/ethereum_api.dart';
import '../themes/stack_colors.dart';
+import '../utilities/amount/amount.dart';
+import '../utilities/amount/amount_field_relocalization.dart';
+import '../utilities/amount/amount_input_formatter.dart';
import '../utilities/constants.dart';
+import '../utilities/integer_input.dart';
import '../utilities/text_styles.dart';
import '../utilities/util.dart';
import 'stack_text_field.dart';
@immutable
class EthEIP1559Fee {
- final Decimal maxBaseFeeGwei;
- final Decimal priorityFeeGwei;
+ final Decimal maxFeePerGasGwei;
+ final Decimal maxPriorityFeePerGasGwei;
final int gasLimit;
const EthEIP1559Fee({
- required this.maxBaseFeeGwei,
- required this.priorityFeeGwei,
+ required this.maxFeePerGasGwei,
+ required this.maxPriorityFeePerGasGwei,
required this.gasLimit,
});
- BigInt get maxBaseFeeWei => maxBaseFeeGwei.shift(9).toBigInt();
- BigInt get priorityFeeWei => priorityFeeGwei.shift(9).toBigInt();
+ BigInt get maxFeePerGasWei => maxFeePerGasGwei.shift(9).toBigInt();
+ BigInt get maxPriorityFeePerGasWei =>
+ maxPriorityFeePerGasGwei.shift(9).toBigInt();
+
+ bool get hasValidFeeCaps =>
+ maxFeePerGasGwei > Decimal.zero &&
+ maxPriorityFeePerGasGwei >= Decimal.zero &&
+ maxFeePerGasGwei >= maxPriorityFeePerGasGwei;
@override
String toString() =>
"EthEIP1559Fee("
- "maxBaseFeeGwei: $maxBaseFeeGwei, "
- "priorityFeeGwei: $priorityFeeGwei, "
- "maxBaseFeeWei: $maxBaseFeeWei, "
- "priorityFeeWei: $priorityFeeWei, "
+ "maxFeePerGasGwei: $maxFeePerGasGwei, "
+ "maxPriorityFeePerGasGwei: $maxPriorityFeePerGasGwei, "
+ "maxFeePerGasWei: $maxFeePerGasWei, "
+ "maxPriorityFeePerGasWei: $maxPriorityFeePerGasWei, "
"gasLimit: $gasLimit)";
}
class EthFeeForm extends StatefulWidget {
EthFeeForm({
super.key,
+ required this.locale,
this.minGasLimit = 21000,
this.maxGasLimit = 30000000,
this.initialState,
required this.stateChanged,
}) : assert(
initialState == null ||
- (initialState.gasLimit >= minGasLimit &&
+ (initialState.hasValidFeeCaps &&
+ initialState.gasLimit >= minGasLimit &&
initialState.gasLimit <= maxGasLimit),
);
final int minGasLimit;
final int maxGasLimit;
+ final String locale;
final EthEIP1559Fee? initialState;
- final void Function(EthEIP1559Fee) stateChanged;
+ final void Function(EthEIP1559Fee?) stateChanged;
@override
State<EthFeeForm> createState() => _EthFeeFormState();
@@ -62,34 +75,130 @@ class EthFeeForm extends StatefulWidget {
class _EthFeeFormState extends State<EthFeeForm> {
static const _textFadeDuration = Duration(milliseconds: 300);
- final maxBaseController = TextEditingController();
- final priorityFeeController = TextEditingController();
+ final maxFeePerGasController = TextEditingController();
+ final maxPriorityFeePerGasController = TextEditingController();
final gasLimitController = TextEditingController();
- final maxBaseFocus = FocusNode();
- final priorityFeeFocus = FocusNode();
+ final maxFeePerGasFocus = FocusNode();
+ final maxPriorityFeePerGasFocus = FocusNode();
final gasLimitFocus = FocusNode();
late int _gasLimitCache;
+ late Decimal _maxFeePerGasGwei;
+ late Decimal _maxPriorityFeePerGasGwei;
+ bool _maxFeePerGasIsValid = false;
+ bool _maxPriorityFeePerGasIsValid = false;
+ bool _gasLimitIsValid = true;
EthEIP1559Fee get _current => EthEIP1559Fee(
- maxBaseFeeGwei: Decimal.tryParse(maxBaseController.text) ?? Decimal.zero,
- priorityFeeGwei:
- Decimal.tryParse(priorityFeeController.text) ?? Decimal.zero,
- gasLimit: int.parse(gasLimitController.text),
+ maxFeePerGasGwei: _maxFeePerGasGwei,
+ maxPriorityFeePerGasGwei: _maxPriorityFeePerGasGwei,
+ gasLimit: _gasLimitCache,
);
+ // Blank or separator-only input is invalid, not zero: a zero max fee cannot
+ // cover a positive network base fee and would fail at build time.
+ Amount? _parseFeeInput(String value) {
+ return Amount.tryParseEditableAmount(
+ value,
+ locale: widget.locale,
+ fractionDigits: 9,
+ );
+ }
+
+ void _maxFeePerGasChanged(String value) {
+ final amount = _parseFeeInput(value);
+ setState(() {
+ _maxFeePerGasIsValid = amount != null && amount.raw > BigInt.zero;
+ if (amount != null) {
+ _maxFeePerGasGwei = amount.decimal;
+ }
+ });
+ _notifyStateChanged();
+ }
+
+ void _maxPriorityFeePerGasChanged(String value) {
+ final amount = _parseFeeInput(value);
+ setState(() {
+ _maxPriorityFeePerGasIsValid =
+ amount != null && amount.raw >= BigInt.zero;
+ if (amount != null) {
+ _maxPriorityFeePerGasGwei = amount.decimal;
+ }
+ });
+ _notifyStateChanged();
+ }
+
+ bool get _feeCapsAreConsistent =>
+ _maxFeePerGasGwei >= _maxPriorityFeePerGasGwei;
+
+ void _notifyStateChanged() {
+ widget.stateChanged(
+ _maxFeePerGasIsValid &&
+ _maxPriorityFeePerGasIsValid &&
+ _feeCapsAreConsistent &&
+ _gasLimitIsValid
+ ? _current
+ : null,
+ );
+ }
+
String _currentBase = "Current: ";
String _currentPriority = "Current: ";
+ ({Decimal base, Decimal lowPriority, Decimal highPriority})? _gasOracleFees;
+
+ void _updateGasOracleLabels() {
+ final fees = _gasOracleFees;
+ if (fees == null) return;
+
+ final currentBaseFee = Amount.formatFixedDecimal(
+ fees.base,
+ fractionDigits: 3,
+ locale: widget.locale,
+ );
+ final lowPriorityFee = Amount.formatFixedDecimal(
+ fees.lowPriority,
+ fractionDigits: 3,
+ locale: widget.locale,
+ );
+ final highPriorityFee = Amount.formatFixedDecimal(
+ fees.highPriority,
+ fractionDigits: 3,
+ locale: widget.locale,
+ );
+ _currentBase = "Current: $currentBaseFee GWEI";
+ _currentPriority = "Current: $lowPriorityFee - $highPriorityFee GWEI";
+ }
+
+ @override
+ void didUpdateWidget(EthFeeForm oldWidget) {
+ super.didUpdateWidget(oldWidget);
+ if (oldWidget.locale != widget.locale) {
+ relocalizeAmountController(
+ maxFeePerGasController,
+ sourceLocale: oldWidget.locale,
+ targetLocale: widget.locale,
+ );
+ relocalizeAmountController(
+ maxPriorityFeePerGasController,
+ sourceLocale: oldWidget.locale,
+ targetLocale: widget.locale,
+ );
+ _updateGasOracleLabels();
+ }
+ }
void _checkNetworkGas() async {
final gas = await EthereumAPI.getGasOracle();
- if (mounted) {
+ if (mounted && gas.value != null) {
+ final fees = (
+ base: gas.value!.suggestBaseFee,
+ lowPriority: gas.value!.lowPriority,
+ highPriority: gas.value!.highPriority,
+ );
setState(() {
- _currentBase =
- "Current: ${gas.value!.suggestBaseFee.toStringAsFixed(3)} GWEI";
- _currentPriority =
- "Current: ${gas.value!.lowPriority.toStringAsFixed(3)} - ${gas.value!.highPriority.toStringAsFixed(3)} GWEI";
+ _gasOracleFees = fees;
+ _updateGasOracleLabels();
});
}
}
@@ -107,12 +216,27 @@ class _EthFeeFormState extends State<EthFeeForm> {
);
});
- maxBaseController.text =
- widget.initialState?.maxBaseFeeGwei.toString() ?? "";
- priorityFeeController.text =
- widget.initialState?.priorityFeeGwei.toString() ?? "";
-
_gasLimitCache = widget.initialState?.gasLimit ?? widget.minGasLimit;
+ _maxFeePerGasGwei = widget.initialState?.maxFeePerGasGwei ?? Decimal.zero;
+ _maxPriorityFeePerGasGwei =
+ widget.initialState?.maxPriorityFeePerGasGwei ?? Decimal.zero;
+ _maxFeePerGasIsValid =
+ widget.initialState != null &&
+ widget.initialState!.maxFeePerGasGwei > Decimal.zero;
+ _maxPriorityFeePerGasIsValid =
+ widget.initialState != null &&
+ widget.initialState!.maxPriorityFeePerGasGwei >= Decimal.zero;
+ final maxFeePerGas = widget.initialState?.maxFeePerGasGwei;
+ final maxPriorityFeePerGas = widget.initialState?.maxPriorityFeePerGasGwei;
+ maxFeePerGasController.text = maxFeePerGas == null
+ ? ""
+ : Amount.formatEditableDecimal(maxFeePerGas, locale: widget.locale);
+ maxPriorityFeePerGasController.text = maxPriorityFeePerGas == null
+ ? ""
+ : Amount.formatEditableDecimal(
+ maxPriorityFeePerGas,
+ locale: widget.locale,
+ );
gasLimitController.text = _gasLimitCache.toString();
}
@@ -120,11 +244,11 @@ class _EthFeeFormState extends State<EthFeeForm> {
void dispose() {
_gasTimer?.cancel();
_gasTimer = null;
- maxBaseController.dispose();
- priorityFeeController.dispose();
+ maxFeePerGasController.dispose();
+ maxPriorityFeePerGasController.dispose();
gasLimitController.dispose();
- maxBaseFocus.dispose();
- priorityFeeFocus.dispose();
+ maxFeePerGasFocus.dispose();
+ maxPriorityFeePerGasFocus.dispose();
gasLimitFocus.dispose();
super.dispose();
@@ -135,55 +259,59 @@ class _EthFeeFormState extends State<EthFeeForm> {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Text("Max base fee (GWEI)", style: STextStyles.smallMed12(context)),
+ Text("Max fee per gas (GWEI)", style: STextStyles.smallMed12(context)),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
+ key: const Key("ethMaxFeePerGasField"),
minLines: 1,
maxLines: 1,
- controller: maxBaseController,
+ controller: maxFeePerGasController,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
- focusNode: maxBaseFocus,
- onChanged: (value) {
- widget.stateChanged(_current);
- },
- style:
- Util.isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration: standardInputDecoration(
- null,
- maxBaseFocus,
- context,
- desktopMed: Util.isDesktop,
- ).copyWith(
- contentPadding: EdgeInsets.only(
- left: 16,
- top: Util.isDesktop ? 11 : 6,
- bottom: Util.isDesktop ? 12 : 8,
- right: 5,
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: maxFeePerGasController,
+ decimals: 9,
+ locale: widget.locale,
),
- ),
+ ],
+ focusNode: maxFeePerGasFocus,
+ onChanged: _maxFeePerGasChanged,
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
+ height: 1.8,
+ )
+ : STextStyles.field(context),
+ decoration:
+ standardInputDecoration(
+ null,
+ maxFeePerGasFocus,
+ context,
+ desktopMed: Util.isDesktop,
+ ).copyWith(
+ contentPadding: EdgeInsets.only(
+ left: 16,
+ top: Util.isDesktop ? 11 : 6,
+ bottom: Util.isDesktop ? 12 : 8,
+ right: 5,
+ ),
+ ),
),
),
const SizedBox(height: 6),
AnimatedSwitcher(
duration: _textFadeDuration,
- transitionBuilder:
- (child, animation) =>
- FadeTransition(opacity: animation, child: child),
+ transitionBuilder: (child, animation) =>
+ FadeTransition(opacity: animation, child: child),
child: Text(
_currentBase,
key: ValueKey(
@@ -193,55 +321,72 @@ class _EthFeeFormState extends State<EthFeeForm> {
),
),
const SizedBox(height: 20),
- Text("Priority fee (GWEI)", style: STextStyles.smallMed12(context)),
+ Text(
+ "Max priority fee per gas (GWEI)",
+ style: STextStyles.smallMed12(context),
+ ),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(
Constants.size.circularBorderRadius,
),
child: TextField(
+ key: const Key("ethMaxPriorityFeePerGasField"),
minLines: 1,
maxLines: 1,
- controller: priorityFeeController,
+ controller: maxPriorityFeePerGasController,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
keyboardType: const TextInputType.numberWithOptions(decimal: true),
- focusNode: priorityFeeFocus,
- onChanged: (value) {
- widget.stateChanged(_current);
- },
- style:
- Util.isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration: standardInputDecoration(
- null,
- priorityFeeFocus,
- context,
- desktopMed: Util.isDesktop,
- ).copyWith(
- contentPadding: EdgeInsets.only(
- left: 16,
- top: Util.isDesktop ? 11 : 6,
- bottom: Util.isDesktop ? 12 : 8,
- right: 5,
+ inputFormatters: [
+ AmountInputFormatter(
+ controller: maxPriorityFeePerGasController,
+ decimals: 9,
+ locale: widget.locale,
),
- ),
+ ],
+ focusNode: maxPriorityFeePerGasFocus,
+ onChanged: _maxPriorityFeePerGasChanged,
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
+ height: 1.8,
+ )
+ : STextStyles.field(context),
+ decoration:
+ standardInputDecoration(
+ null,
+ maxPriorityFeePerGasFocus,
+ context,
+ desktopMed: Util.isDesktop,
+ ).copyWith(
+ contentPadding: EdgeInsets.only(
+ left: 16,
+ top: Util.isDesktop ? 11 : 6,
+ bottom: Util.isDesktop ? 12 : 8,
+ right: 5,
+ ),
+ ),
),
),
+ if (_maxFeePerGasIsValid &&
+ _maxPriorityFeePerGasIsValid &&
+ !_feeCapsAreConsistent)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Max priority fee must not exceed max fee",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
const SizedBox(height: 6),
AnimatedSwitcher(
duration: _textFadeDuration,
- transitionBuilder:
- (child, animation) =>
- FadeTransition(opacity: animation, child: child),
+ transitionBuilder: (child, animation) =>
+ FadeTransition(opacity: animation, child: child),
child: Text(
_currentPriority,
key: ValueKey(
@@ -258,52 +403,62 @@ class _EthFeeFormState extends State<EthFeeForm> {
Constants.size.circularBorderRadius,
),
child: TextField(
+ key: const Key("ethFeeGasLimitField"),
minLines: 1,
maxLines: 1,
controller: gasLimitController,
readOnly: false,
autocorrect: false,
enableSuggestions: false,
- keyboardType: const TextInputType.numberWithOptions(decimal: true),
+ keyboardType: TextInputType.number,
focusNode: gasLimitFocus,
onChanged: (value) {
- final intValue = int.tryParse(value);
- if (intValue == null ||
- intValue < widget.minGasLimit ||
- intValue > widget.maxGasLimit) {
- gasLimitController.text = _gasLimitCache.toString();
- return;
- }
-
- _gasLimitCache = intValue;
-
- widget.stateChanged(_current);
+ final intValue = tryParseIntegerInput(
+ value,
+ minimum: widget.minGasLimit,
+ maximum: widget.maxGasLimit,
+ );
+ setState(() {
+ _gasLimitIsValid = intValue != null;
+ if (intValue != null) {
+ _gasLimitCache = intValue;
+ }
+ });
+ _notifyStateChanged();
},
- style:
- Util.isDesktop
- ? STextStyles.desktopTextExtraSmall(context).copyWith(
- color:
- Theme.of(
- context,
- ).extension<StackColors>()!.textFieldActiveText,
- height: 1.8,
- )
- : STextStyles.field(context),
- decoration: standardInputDecoration(
- null,
- gasLimitFocus,
- context,
- desktopMed: Util.isDesktop,
- ).copyWith(
- contentPadding: EdgeInsets.only(
- left: 16,
- top: Util.isDesktop ? 11 : 6,
- bottom: Util.isDesktop ? 12 : 8,
- right: 5,
- ),
- ),
+ style: Util.isDesktop
+ ? STextStyles.desktopTextExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveText,
+ height: 1.8,
+ )
+ : STextStyles.field(context),
+ decoration:
+ standardInputDecoration(
+ null,
+ gasLimitFocus,
+ context,
+ desktopMed: Util.isDesktop,
+ ).copyWith(
+ contentPadding: EdgeInsets.only(
+ left: 16,
+ top: Util.isDesktop ? 11 : 6,
+ bottom: Util.isDesktop ? 12 : 8,
+ right: 5,
+ ),
+ ),
),
),
+ if (!_gasLimitIsValid)
+ Padding(
+ padding: const EdgeInsets.only(top: 6, left: 12),
+ child: Text(
+ "Enter a whole number from "
+ "${widget.minGasLimit} to ${widget.maxGasLimit}",
+ style: STextStyles.errorSmall(context),
+ ),
+ ),
],
);
}
diff --git a/test/utilities/integer_input_test.dart b/test/utilities/integer_input_test.dart
new file mode 100644
index 0000000..c7238e6
--- /dev/null
+++ b/test/utilities/integer_input_test.dart
@@ -0,0 +1,54 @@
+import "package:flutter_test/flutter_test.dart";
+import "package:stackwallet/utilities/integer_input.dart";
+
+void main() {
+ test("integer input rejects malformed text without normalizing it", () {
+ for (final value in ["1.5", "1,5", "1e3", "1 000", "0x5208", "+21000"]) {
+ expect(tryParseIntegerInput(value), isNull, reason: value);
+ }
+
+ expect(tryParseIntegerInput(" 1 "), 1);
+ });
+
+ test("integer input preserves signed decimal support", () {
+ expect(tryParseIntegerInput("-42"), -42);
+ expect(tryParseIntegerInput(" -42 "), -42);
+ expect(tryParseIntegerInput("-42", minimum: 0), isNull);
+ });
+
+ test("integer input enforces inclusive bounds", () {
+ expect(tryParseIntegerInput("0", minimum: 0), 0);
+ expect(tryParseIntegerInput("-1", minimum: 0), isNull);
+ expect(
+ tryParseIntegerInput("21000", minimum: 21000, maximum: 30000000),
+ 21000,
+ );
+ expect(
+ tryParseIntegerInput("30000000", minimum: 21000, maximum: 30000000),
+ 30000000,
+ );
+ expect(
+ tryParseIntegerInput("30000001", minimum: 21000, maximum: 30000000),
+ isNull,
+ );
+ });
+
+ test("optional integer input distinguishes blank from malformed", () {
+ expect(parseOptionalIntegerInput("", minimum: 0), (
+ isValid: true,
+ value: null,
+ ));
+ expect(parseOptionalIntegerInput("0", minimum: 0), (
+ isValid: true,
+ value: 0,
+ ));
+ expect(parseOptionalIntegerInput("1.5", minimum: 0), (
+ isValid: false,
+ value: null,
+ ));
+ expect(parseOptionalIntegerInput("-1", minimum: 0), (
+ isValid: false,
+ value: null,
+ ));
+ });
+}
diff --git a/test/wallets/ethereum_fee_caps_test.dart b/test/wallets/ethereum_fee_caps_test.dart
new file mode 100644
index 0000000..2ba8a69
--- /dev/null
+++ b/test/wallets/ethereum_fee_caps_test.dart
@@ -0,0 +1,42 @@
+import "package:flutter_test/flutter_test.dart";
+import "package:stackwallet/wallets/wallet/impl/ethereum_wallet.dart";
+
+void main() {
+ test("preset max fee includes priority after base-fee headroom", () {
+ final caps = resolveEip1559FeeCaps(
+ baseFee: BigInt.from(10),
+ priorityFeePerGas: BigInt.two,
+ );
+
+ expect(caps.maxFeePerGas, BigInt.from(22));
+ expect(caps.maxPriorityFeePerGas, BigInt.two);
+ });
+
+ test("custom max fee remains the total EIP-1559 cap", () {
+ final caps = resolveEip1559FeeCaps(
+ baseFee: BigInt.from(10),
+ priorityFeePerGas: BigInt.two,
+ customMaxFeePerGas: BigInt.from(15),
+ );
+
+ expect(caps.maxFeePerGas, BigInt.from(15));
+ expect(caps.maxPriorityFeePerGas, BigInt.two);
+ });
+
+ test("rejects a priority cap above the total max fee", () {
+ expect(
+ () => resolveEip1559FeeCaps(
+ baseFee: BigInt.from(10),
+ priorityFeePerGas: BigInt.from(11),
+ customMaxFeePerGas: BigInt.from(10),
+ ),
+ throwsA(
+ isA<Exception>().having(
+ (error) => error.toString(),
+ "message",
+ contains("Max priority fee per gas exceeds max fee per gas"),
+ ),
+ ),
+ );
+ });
+}
diff --git a/test/widgets/eth_fee_form_test.dart b/test/widgets/eth_fee_form_test.dart
new file mode 100644
index 0000000..098ee53
--- /dev/null
+++ b/test/widgets/eth_fee_form_test.dart
@@ -0,0 +1,273 @@
+import "dart:convert";
+import "dart:io";
+
+import "package:decimal/decimal.dart";
+import "package:flutter/material.dart";
+import "package:flutter_test/flutter_test.dart";
+import "package:stackwallet/models/isar/stack_theme.dart";
+import "package:stackwallet/networking/http.dart";
+import "package:stackwallet/services/ethereum/ethereum_api.dart";
+import "package:stackwallet/themes/stack_colors.dart";
+import "package:stackwallet/widgets/eth_fee_form.dart";
+
+import "../sample_data/theme_json.dart";
+
+class _GasOracleHttp extends HTTP {
+ const _GasOracleHttp();
+
+ @override
+ Future<Response> get({
+ required Uri url,
+ Map<String, String>? headers,
+ required ({InternetAddress host, int port})? proxyInfo,
+ Duration? connectionTimeout,
+ }) async => Response(const [], 500);
+}
+
+class _SingleSuccessGasOracleHttp extends HTTP {
+ int requests = 0;
+
+ @override
+ Future<Response> get({
+ required Uri url,
+ Map<String, String>? headers,
+ required ({InternetAddress host, int port})? proxyInfo,
+ Duration? connectionTimeout,
+ }) async {
+ requests++;
+ if (requests > 1) return Response(const [], 500);
+
+ return Response(
+ utf8.encode(
+ '{"success":true,"result":{"result":{'
+ '"FastGasPrice":"15.678",'
+ '"ProposeGasPrice":"14",'
+ '"SafeGasPrice":"13.456",'
+ '"suggestBaseFee":"12.345",'
+ '"LastBlock":"1"}}}',
+ ),
+ 200,
+ );
+ }
+}
+
+void main() {
+ testWidgets("rejects a priority fee above the max fee", (tester) async {
+ final originalClient = EthereumAPI.client;
+ EthereumAPI.client = const _GasOracleHttp();
+ addTearDown(() => EthereumAPI.client = originalClient);
+
+ final emittedFees = <EthEIP1559Fee?>[];
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ extensions: [
+ StackColors.fromStackColorTheme(
+ StackTheme.fromJson(json: lightThemeJsonMap),
+ ),
+ ],
+ ),
+ home: Scaffold(
+ body: EthFeeForm(
+ locale: "en_US",
+ initialState: EthEIP1559Fee(
+ maxFeePerGasGwei: Decimal.fromInt(10),
+ maxPriorityFeePerGasGwei: Decimal.one,
+ gasLimit: 21000,
+ ),
+ stateChanged: emittedFees.add,
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ expect(find.text("Max fee per gas (GWEI)"), findsOneWidget);
+ expect(find.text("Max priority fee per gas (GWEI)"), findsOneWidget);
+
+ final maxFeeField = find.byKey(const Key("ethMaxFeePerGasField"));
+ final maxPriorityFeeField = find.byKey(
+ const Key("ethMaxPriorityFeePerGasField"),
+ );
+ await tester.enterText(maxPriorityFeeField, "11");
+ await tester.pump();
+
+ expect(emittedFees.last, isNull);
+ expect(
+ tester.widget<TextField>(maxPriorityFeeField).decoration!.errorText,
+ isNull,
+ );
+ expect(
+ find.text("Max priority fee must not exceed max fee"),
+ findsOneWidget,
+ );
+
+ await tester.enterText(maxFeeField, "12");
+ await tester.pump();
+
+ expect(emittedFees.last?.maxFeePerGasGwei, Decimal.fromInt(12));
+ expect(emittedFees.last?.maxPriorityFeePerGasGwei, Decimal.fromInt(11));
+ expect(find.text("Max priority fee must not exceed max fee"), findsNothing);
+
+ await tester.pumpWidget(const SizedBox.shrink());
+ });
+
+ testWidgets("gas limit preserves and rejects malformed integer text", (
+ tester,
+ ) async {
+ final originalClient = EthereumAPI.client;
+ EthereumAPI.client = const _GasOracleHttp();
+ addTearDown(() => EthereumAPI.client = originalClient);
+
+ final emittedFees = <EthEIP1559Fee?>[];
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: ThemeData(
+ extensions: [
+ StackColors.fromStackColorTheme(
+ StackTheme.fromJson(json: lightThemeJsonMap),
+ ),
+ ],
+ ),
+ home: Scaffold(
+ body: EthFeeForm(
+ locale: "en_US",
+ initialState: EthEIP1559Fee(
+ maxFeePerGasGwei: Decimal.one,
+ maxPriorityFeePerGasGwei: Decimal.one,
+ gasLimit: 21000,
+ ),
+ stateChanged: emittedFees.add,
+ ),
+ ),
+ ),
+ );
+ await tester.pump();
+
+ final gasLimitField = find.byKey(const Key("ethFeeGasLimitField"));
+ await tester.enterText(gasLimitField, "21000.5");
+ await tester.pump();
+
+ expect(tester.widget<TextField>(gasLimitField).controller!.text, "21000.5");
+ expect(emittedFees, [isNull]);
+ expect(
+ tester.widget<TextField>(gasLimitField).decoration!.errorText,
+ isNull,
+ );
+ expect(
+ find.text("Enter a whole number from 21000 to 30000000"),
+ findsOneWidget,
+ );
+
+ await tester.enterText(gasLimitField, "0x5208");
+ await tester.pump();
+
+ expect(tester.widget<TextField>(gasLimitField).controller!.text, "0x5208");
+ expect(emittedFees.last, isNull);
+ expect(
+ tester.widget<TextField>(gasLimitField).decoration!.errorText,
+ isNull,
+ );
+ expect(
+ find.text("Enter a whole number from 21000 to 30000000"),
+ findsOneWidget,
+ );
+
+ await tester.enterText(gasLimitField, "22000");
+ await tester.pump();
+
+ expect(emittedFees.last?.gasLimit, 22000);
+ expect(
+ tester.widget<TextField>(gasLimitField).decoration!.errorText,
+ isNull,
+ );
+ expect(
+ find.text("Enter a whole number from 21000 to 30000000"),
+ findsNothing,
+ );
+
+ await tester.pumpWidget(const SizedBox.shrink());
+ });
+
+ testWidgets("locale change preserves amount field selection", (tester) async {
+ final originalClient = EthereumAPI.client;
+ EthereumAPI.client = const _GasOracleHttp();
+ addTearDown(() => EthereumAPI.client = originalClient);
+
+ Widget form(String locale) => MaterialApp(
+ theme: ThemeData(
+ extensions: [
+ StackColors.fromStackColorTheme(
+ StackTheme.fromJson(json: lightThemeJsonMap),
+ ),
+ ],
+ ),
+ home: Scaffold(
+ body: EthFeeForm(
+ locale: locale,
+ initialState: EthEIP1559Fee(
+ maxFeePerGasGwei: Decimal.parse("12.34"),
+ maxPriorityFeePerGasGwei: Decimal.one,
+ gasLimit: 21000,
+ ),
+ stateChanged: (_) {},
+ ),
+ ),
+ );
+
+ await tester.pumpWidget(form("en_US"));
+ await tester.pump();
+
+ final maxFeeField = find.byKey(const Key("ethMaxFeePerGasField"));
+ await tester.tap(maxFeeField);
+ await tester.pump();
+ final controller = tester.widget<TextField>(maxFeeField).controller!;
+ controller.selection = const TextSelection.collapsed(offset: 2);
+
+ await tester.pumpWidget(form("de_DE"));
+ await tester.pump();
+
+ expect(controller.text, "12,34");
+ expect(controller.selection, const TextSelection.collapsed(offset: 2));
+
+ await tester.pumpWidget(const SizedBox.shrink());
+ });
+
+ testWidgets("locale change reformats cached gas oracle labels", (
+ tester,
+ ) async {
+ final originalClient = EthereumAPI.client;
+ final client = _SingleSuccessGasOracleHttp();
+ EthereumAPI.client = client;
+ addTearDown(() => EthereumAPI.client = originalClient);
+
+ Widget form(String locale) => MaterialApp(
+ theme: ThemeData(
+ extensions: [
+ StackColors.fromStackColorTheme(
+ StackTheme.fromJson(json: lightThemeJsonMap),
+ ),
+ ],
+ ),
+ home: Scaffold(
+ body: EthFeeForm(locale: locale, stateChanged: (_) {}),
+ ),
+ );
+
+ await tester.pumpWidget(form("en_US"));
+ await tester.pump();
+
+ expect(find.text("Current: 12.345 GWEI"), findsOneWidget);
+ expect(find.text("Current: 1.111 - 3.333 GWEI"), findsOneWidget);
+ expect(client.requests, 1);
+
+ await tester.pumpWidget(form("de_DE"));
+ await tester.pump();
+
+ expect(find.text("Current: 12,345 GWEI"), findsOneWidget);
+ expect(find.text("Current: 1,111 - 3,333 GWEI"), findsOneWidget);
+ expect(client.requests, 1);
+
+ await tester.pumpWidget(const SizedBox.shrink());
+ });
+}
Why this scored 60/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.