fix: account for Firo OP_RETURN in fee previews
What changed, and why it matters
This commit fixes a bug in Stack Wallet where transaction fee previews did not include the extra cost of adding an OP_RETURN data output for Firo public-balance sends. As a result, users could have been shown a lower fee than what the network would actually charge, or the wallet might have constructed an under-funded transaction. The patch adds the missing fee calculation, makes the OP_RETURN state provider auto-dispose to avoid stale data, and guards state updates with a 'mounted' check to prevent crashes after a screen is closed.
Treat as a routine bug-fix patch. Review that the new vSize calculation matches Firo's consensus rules and that estimateTxFee uses the correct fee-rate units. Verify no other send paths bypass the OP_RETURN fee adjustment. No emergency response is indicated unless user reports of stuck or under-funded Firo OP_RETURN transactions surface.
Security signals we found
Incorrect fee estimation leading to under-funded transactions
UI state provider lifecycle fix (autoDispose) to prevent stale OP_RETURN data across sessions
Mounted guard added before provider state writes to avoid use-after-dispose exceptions
OP_RETURN hex validation and 80-byte limit enforcement added in utility function
Evidence from the diff
The change updates fee estimation paths in mobile and desktop send flows to call a new _addOpReturnFeeIfNeeded / _addFiroOpReturnFee helper when the coin is Firo, the balance type is public, and pOpReturnData is non-empty. The helper computes the extra vSize of an OP_RETURN output via AddressUtils.opReturnOutputVSizeFromHex and adds wallet.estimateTxFee(vSize: extraOutputVSize, feeRatePerKB: feeRate). It also changes pOpReturnData from StateProvider to autoDispose so the value is cleared when the send screen is disposed, and wraps provider writes in _setOpReturnData which checks mounted. Fee caching is bypassed when OP_RETURN data is present to avoid stale cached values.
Changed components
lib/pages/send_view/send_view.dartlib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dartlib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send_fee_form.dartlib/providers/ui/preview_tx_button_state_provider.dartlib/utilities/address_utils.dartInspect captured patch +200 / −28
diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart
index 48f3290..87e5bd0 100644
--- a/lib/pages/send_view/send_view.dart
+++ b/lib/pages/send_view/send_view.dart
@@ -184,12 +184,12 @@ class _SendViewState extends ConsumerState<SendView> {
// onChanged handler reading stale null value.
if (paymentData.additionalParams.containsKey('op_return')) {
final data = paymentData.additionalParams['op_return'];
- ref.read(pOpReturnData.notifier).state = data;
+ _setOpReturnData(data);
Logging.instance.i(
"Extracted OP_RETURN data from URI, length: ${data!.length ~/ 2} bytes",
);
} else {
- ref.read(pOpReturnData.notifier).state = null;
+ _setOpReturnData(null);
}
_setValidAddressProviders(_address);
@@ -541,11 +541,50 @@ class _SendViewState extends ConsumerState<SendView> {
Map<Amount, String> cachedFiroSparkFees = {};
Map<Amount, String> cachedFiroPublicFees = {};
+ void _setOpReturnData(String? data) {
+ if (!mounted) {
+ return;
+ }
+ ref.read(pOpReturnData.notifier).state = data;
+ }
+
+ Amount _addOpReturnFeeIfNeeded({
+ required Amount fee,
+ required BigInt feeRate,
+ required FiroWallet wallet,
+ }) {
+ final opReturnData = ref.read(pOpReturnData);
+ if (opReturnData == null ||
+ opReturnData.isEmpty ||
+ ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) {
+ return fee;
+ }
+
+ final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex(
+ opReturnData,
+ );
+ final extraFee = wallet.estimateTxFee(
+ vSize: extraOutputVSize,
+ feeRatePerKB: feeRate,
+ );
+
+ return fee +
+ Amount(
+ rawValue: BigInt.from(extraFee),
+ fractionDigits: coin.fractionDigits,
+ );
+ }
+
Future<String> calculateFees(Amount amount) async {
+ final hasOpReturnData =
+ isFiro &&
+ ref.read(publicPrivateBalanceStateProvider) == BalanceType.public &&
+ (ref.read(pOpReturnData)?.isNotEmpty ?? false);
+
if (isFiro) {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case BalanceType.public:
- if (cachedFiroPublicFees[amount] != null) {
+ if (!hasOpReturnData && cachedFiroPublicFees[amount] != null) {
return cachedFiroPublicFees[amount]!;
}
break;
@@ -607,10 +646,18 @@ class _SendViewState extends ConsumerState<SendView> {
switch (ref.read(publicPrivateBalanceStateProvider.state).state) {
case BalanceType.public:
fee = await firoWallet.estimateFeeFor(amount, feeRate);
- cachedFiroPublicFees[amount] = ref
+ fee = _addOpReturnFeeIfNeeded(
+ fee: fee,
+ feeRate: feeRate,
+ wallet: firoWallet,
+ );
+ final formatted = ref
.read(pAmountFormatter(coin))
.format(fee, withUnitName: true, indicatePrecisionLoss: false);
- return cachedFiroPublicFees[amount]!;
+ if (!hasOpReturnData) {
+ cachedFiroPublicFees[amount] = formatted;
+ }
+ return formatted;
case BalanceType.private:
fee = await firoWallet.estimateFeeForSpark(amount);
@@ -1146,6 +1193,9 @@ class _SendViewState extends ConsumerState<SendView> {
}
void clearSendForm() {
+ if (!mounted) {
+ return;
+ }
sendToController.text = "";
cryptoAmountController.text = "";
baseAmountController.text = "";
@@ -1155,10 +1205,8 @@ class _SendViewState extends ConsumerState<SendView> {
memoController.text = "";
_address = "";
_addressToggleFlag = false;
- ref.read(pOpReturnData.notifier).state = null;
- if (mounted) {
- setState(() {});
- }
+ _setOpReturnData(null);
+ setState(() {});
}
String _getSendAllTitle(
@@ -1758,8 +1806,7 @@ class _SendViewState extends ConsumerState<SendView> {
if (parsed != null) {
_applyUri(parsed);
} else {
- ref.read(pOpReturnData.notifier).state =
- null;
+ _setOpReturnData(null);
await _checkSparkNameAndOrSetAddress(
newValue,
);
diff --git a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
index 5d586ae..387138d 100644
--- a/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
+++ b/lib/pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart
@@ -14,8 +14,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../models/paymint/fee_object_model.dart';
import '../../../providers/providers.dart';
import '../../../providers/ui/fee_rate_type_state_provider.dart';
+import '../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../providers/wallet/public_private_balance_state_provider.dart';
import '../../../themes/stack_colors.dart';
+import '../../../utilities/address_utils.dart';
import '../../../utilities/amount/amount.dart';
import '../../../utilities/amount/amount_formatter.dart';
import '../../../utilities/constants.dart';
@@ -78,12 +80,54 @@ class _TransactionFeeSelectionSheetState
"Calculating...",
];
+ Amount _addFiroOpReturnFee({
+ required Amount fee,
+ required BigInt feeRate,
+ required FiroWallet wallet,
+ required CryptoCurrency coin,
+ }) {
+ final opReturnData = ref.read(pOpReturnData);
+ if (opReturnData == null ||
+ opReturnData.isEmpty ||
+ ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) {
+ return fee;
+ }
+
+ final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex(
+ opReturnData,
+ );
+ final extraFee = wallet.estimateTxFee(
+ vSize: extraOutputVSize,
+ feeRatePerKB: feeRate,
+ );
+
+ return fee +
+ Amount(
+ rawValue: BigInt.from(extraFee),
+ fractionDigits: coin.fractionDigits,
+ );
+ }
+
Future<Amount> feeFor({
required Amount amount,
required FeeRateType feeRateType,
required BigInt feeRate,
required CryptoCurrency coin,
}) async {
+ if (!widget.isToken &&
+ coin is Firo &&
+ ref.read(publicPrivateBalanceStateProvider) == BalanceType.public &&
+ (ref.read(pOpReturnData)?.isNotEmpty ?? false)) {
+ final wallet = ref.read(pWallets).getWallet(walletId) as FiroWallet;
+ final fee = await wallet.estimateFeeFor(amount, feeRate);
+ return _addFiroOpReturnFee(
+ fee: fee,
+ feeRate: feeRate,
+ wallet: wallet,
+ coin: coin,
+ );
+ }
+
switch (feeRateType) {
case FeeRateType.fast:
if (ref.read(feeSheetSessionCacheProvider).fast[amount] == null) {
diff --git a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
index 72b646e..d5d93ad 100644
--- a/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
+++ b/lib/pages_desktop_specific/my_stack_view/wallet_view/sub_widgets/desktop_send.dart
@@ -847,6 +847,9 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
}
void clearSendForm() {
+ if (!mounted) {
+ return;
+ }
sendToController.text = "";
cryptoAmountController.text = "";
baseAmountController.text = "";
@@ -854,9 +857,15 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
nonceController.text = "";
_address = "";
_addressToggleFlag = false;
- if (mounted) {
- setState(() {});
+ _setOpReturnData(null);
+ setState(() {});
+ }
+
+ void _setOpReturnData(String? data) {
+ if (!mounted) {
+ return;
}
+ ref.read(pOpReturnData.notifier).state = data;
}
void _cryptoAmountChanged() async {
@@ -921,11 +930,10 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
if (paymentData != null &&
paymentData.coin?.uriScheme == coin.uriScheme) {
- ref.read(pOpReturnData.notifier).state =
- paymentData.additionalParams['op_return'];
+ _setOpReturnData(paymentData.additionalParams['op_return']);
_applyUri(paymentData);
} else {
- ref.read(pOpReturnData.notifier).state = null;
+ _setOpReturnData(null);
_address = qrCodeData.split("\n").first.trim();
sendToController.text = _address ?? "";
@@ -1054,11 +1062,10 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
);
if (paymentData != null &&
paymentData.coin?.uriScheme == coin.uriScheme) {
- ref.read(pOpReturnData.notifier).state =
- paymentData.additionalParams['op_return'];
+ _setOpReturnData(paymentData.additionalParams['op_return']);
_applyUri(paymentData);
} else {
- ref.read(pOpReturnData.notifier).state = null;
+ _setOpReturnData(null);
if (coin is Epiccash) {
content = AddressUtils().formatEpicCashAddress(content);
}
@@ -1075,7 +1082,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
});
}
} catch (e) {
- ref.read(pOpReturnData.notifier).state = null;
+ _setOpReturnData(null);
// If parsing fails, treat it as a plain address.
if (coin is Epiccash) {
// strip http:// and https:// if content contains @
@@ -1769,11 +1776,10 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
logging: Logging.instance,
);
if (parsed != null) {
- ref.read(pOpReturnData.notifier).state =
- parsed.additionalParams['op_return'];
+ _setOpReturnData(parsed.additionalParams['op_return']);
_applyUri(parsed);
} else {
- ref.read(pOpReturnData.notifier).state = null;
+ _setOpReturnData(null);
await _checkSparkNameAndOrSetAddress(newValue);
}
} else {
@@ -1827,8 +1833,7 @@ class _DesktopSendState extends ConsumerState<DesktopSend> {
onTap: () {
sendToController.text = "";
_address = "";
- ref.read(pOpReturnData.notifier).state =
- null;
+ _setOpReturnData(null);
_setValidAddressProviders(_address);
setState(() {
_addressToggleFlag = false;
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 0b93775..b1e2b46 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
@@ -3,9 +3,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../pages/send_view/sub_widgets/transaction_fee_selection_sheet.dart';
import '../../../../providers/providers.dart';
+import '../../../../providers/ui/preview_tx_button_state_provider.dart';
import '../../../../providers/wallet/desktop_fee_providers.dart';
import '../../../../providers/wallet/public_private_balance_state_provider.dart';
import '../../../../themes/stack_colors.dart';
+import '../../../../utilities/address_utils.dart';
import '../../../../utilities/amount/amount.dart';
import '../../../../utilities/enums/fee_rate_type_enum.dart';
import '../../../../utilities/eth_commons.dart';
@@ -67,6 +69,33 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
(FeeRateType, String?, String?)? feeSelectionResult;
+ Amount _addFiroOpReturnFee({
+ required Amount fee,
+ required BigInt feeRate,
+ required FiroWallet wallet,
+ }) {
+ final opReturnData = ref.read(pOpReturnData);
+ if (opReturnData == null ||
+ opReturnData.isEmpty ||
+ ref.read(publicPrivateBalanceStateProvider) != BalanceType.public) {
+ return fee;
+ }
+
+ final extraOutputVSize = AddressUtils.opReturnOutputVSizeFromHex(
+ opReturnData,
+ );
+ final extraFee = wallet.estimateTxFee(
+ vSize: extraOutputVSize,
+ feeRatePerKB: feeRate,
+ );
+
+ return fee +
+ Amount(
+ rawValue: BigInt.from(extraFee),
+ fractionDigits: cryptoCurrency.fractionDigits,
+ );
+ }
+
@override
void initState() {
super.initState();
@@ -156,6 +185,30 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
required BigInt feeRate,
required CryptoCurrency coin,
}) async {
+ if (!widget.isToken &&
+ coin is Firo &&
+ ref.read(
+ publicPrivateBalanceStateProvider,
+ ) ==
+ BalanceType.public &&
+ (ref.read(pOpReturnData)?.isNotEmpty ??
+ false)) {
+ final wallet =
+ ref
+ .read(pWallets)
+ .getWallet(widget.walletId)
+ as FiroWallet;
+ final fee = await wallet.estimateFeeFor(
+ amount,
+ feeRate,
+ );
+ return _addFiroOpReturnFee(
+ fee: fee,
+ feeRate: feeRate,
+ wallet: wallet,
+ );
+ }
+
if (ref
.read(
widget.isToken
@@ -220,12 +273,16 @@ class _DesktopSendFeeFormState extends ConsumerState<DesktopSendFeeForm> {
final fee = await tokenWallet
.estimateFeeFor(amount, feeRate);
ref
- .read(tokenFeeSessionCacheProvider)
+ .read(
+ tokenFeeSessionCacheProvider,
+ )
.average[amount] =
fee;
} catch (_) {
// Token wallet not available.
- debugPrint("Token fee estimation not available");
+ debugPrint(
+ "Token fee estimation not available",
+ );
}
}
}
diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart
index 1ad75aa..b079504 100644
--- a/lib/providers/ui/preview_tx_button_state_provider.dart
+++ b/lib/providers/ui/preview_tx_button_state_provider.dart
@@ -23,7 +23,7 @@ final pValidSparkSendToAddress = StateProvider.autoDispose<bool>((_) => false);
final pIsExchangeAddress = StateProvider<bool>((_) => false);
-final pOpReturnData = StateProvider<String?>((_) => null);
+final pOpReturnData = StateProvider.autoDispose<String?>((_) => null);
// MWC Transaction Method Provider.
final pSelectedMwcTransactionMethod = StateProvider<MwcTransactionMethod>(
diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart
index 43e72b6..fb9b426 100644
--- a/lib/utilities/address_utils.dart
+++ b/lib/utilities/address_utils.dart
@@ -348,6 +348,25 @@ class AddressUtils {
return "Raw OP_RETURN data:\n$hex";
}
}
+
+ static int opReturnOutputVSizeFromHex(String hex) {
+ if (hex.length.isOdd || !RegExp(r'^[0-9a-fA-F]*$').hasMatch(hex)) {
+ throw const FormatException("Invalid OP_RETURN hex");
+ }
+
+ final dataBytes = hex.length ~/ 2;
+ if (dataBytes > 80) {
+ throw FormatException(
+ "OP_RETURN data exceeds 80 byte limit: $dataBytes bytes",
+ );
+ }
+
+ final pushPrefixBytes = dataBytes <= 75 ? 1 : 2;
+ final scriptBytes = 1 + pushPrefixBytes + dataBytes;
+
+ // value(8) + compact script length(1, since max script is 83 bytes) + script
+ return 8 + 1 + scriptBytes;
+ }
}
class PaymentUriData {
Why this scored 31/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.