From c9035e41284808069eeb2ee26c895a448442fa56 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Wed, 13 May 2026 21:23:06 +0100 Subject: [PATCH 1/3] Add OP_RETURN support required for rosen bridge --- lib/pages/send_view/send_view.dart | 93 +++++++++++++++++-- .../wallet_view/sub_widgets/desktop_send.dart | 76 ++++++++++++++- .../ui/preview_tx_button_state_provider.dart | 62 +++++++------ lib/utilities/address_utils.dart | 76 +++++++++++++-- lib/wallets/models/tx_data.dart | 7 ++ .../electrumx_interface.dart | 57 ++++++++++++ 6 files changed, 328 insertions(+), 43 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index a2dd4f4834..48f32904d5 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -160,7 +160,6 @@ class _SendViewState extends ConsumerState { try { // auto fill address _address = paymentData.address.trim(); - sendToController.text = _address!; // autofill notes field if (paymentData.message != null) { @@ -180,7 +179,25 @@ class _SendViewState extends ConsumerState { ref.read(pSendAmount.notifier).state = amount; } + // Extract OP_RETURN data if present (for Rosen Bridge and other protocols) + // Must be set BEFORE sendToController.text to avoid re-entrant + // onChanged handler reading stale null value. + if (paymentData.additionalParams.containsKey('op_return')) { + final data = paymentData.additionalParams['op_return']; + ref.read(pOpReturnData.notifier).state = data; + Logging.instance.i( + "Extracted OP_RETURN data from URI, length: ${data!.length ~/ 2} bytes", + ); + } else { + ref.read(pOpReturnData.notifier).state = null; + } + _setValidAddressProviders(_address); + + // Assign controller.text last — it triggers onChanged which depends + // on pOpReturnData already being set above. + sendToController.text = _address!; + setState(() { _addressToggleFlag = sendToController.text.isNotEmpty; }); @@ -923,6 +940,7 @@ class _SendViewState extends ConsumerState { selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } else if (wallet is FiroWallet) { @@ -964,6 +982,7 @@ class _SendViewState extends ConsumerState { utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -1136,6 +1155,7 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; + ref.read(pOpReturnData.notifier).state = null; if (mounted) { setState(() {}); } @@ -1726,9 +1746,10 @@ class _SendViewState extends ConsumerState { final trimmed = newValue.trim(); if ((trimmed.length - - (_address?.length ?? 0)) - .abs() > - 1) { + (_address?.length ?? 0)) + .abs() > + 1 || + trimmed.contains(':')) { final parsed = AddressUtils.parsePaymentUri( trimmed, @@ -1737,6 +1758,8 @@ class _SendViewState extends ConsumerState { if (parsed != null) { _applyUri(parsed); } else { + ref.read(pOpReturnData.notifier).state = + null; await _checkSparkNameAndOrSetAddress( newValue, ); @@ -1949,6 +1972,38 @@ class _SendViewState extends ConsumerState { ), ), ), + if (ref.watch(pOpReturnData) != null && + _address != null && + _address!.isNotEmpty && + (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + balType == BalanceType.public) + Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only( + left: 12.0, + top: 4.0, + ), + child: Tooltip( + message: AddressUtils.formatOpReturnTooltip( + ref.watch(pOpReturnData)!, + ), + child: Text( + "Transaction includes metadata " + "(${ref.watch(pOpReturnData)!.length ~/ 2} bytes) " + "\u2014 tap for details", + textAlign: TextAlign.left, + style: STextStyles.label(context) + .copyWith( + color: Theme.of(context) + .extension()! + .accentColorGreen, + ), + ), + ), + ), + ), Builder( builder: (_) { final String? error; @@ -2666,16 +2721,42 @@ class _SendViewState extends ConsumerState { ), const Spacer(), const SizedBox(height: 12), + if (ref.watch(pOpReturnData) != null && + balType == BalanceType.private) + Padding( + padding: const EdgeInsets.only( + left: 12.0, + right: 12.0, + bottom: 12.0, + ), + child: Text( + "Bridge data detected but Spark (private) " + "transactions cannot carry OP_RETURN data. " + "Switch to public balance to complete the " + "bridge transaction.", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ), + ), TextButton( onPressed: - ref.watch(pPreviewTxButtonEnabled(coin)) + ref.watch(pPreviewTxButtonEnabled(coin)) && + (ref.watch(pOpReturnData) == null || + balType != BalanceType.private) ? isMwcSlatepack ? _createSlatepack : isEpicSlatepack ? _createEpicSlatepack : _previewTransaction : null, - style: ref.watch(pPreviewTxButtonEnabled(coin)) + style: + ref.watch(pPreviewTxButtonEnabled(coin)) && + (ref.watch(pOpReturnData) == null || + balType != BalanceType.private) ? Theme.of(context) .extension()! .getPrimaryEnabledButtonStyle(context) 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 efc02f6283..72b646eeac 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 @@ -650,6 +650,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -920,8 +921,11 @@ class _DesktopSendState extends ConsumerState { if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { + ref.read(pOpReturnData.notifier).state = + paymentData.additionalParams['op_return']; _applyUri(paymentData); } else { + ref.read(pOpReturnData.notifier).state = null; _address = qrCodeData.split("\n").first.trim(); sendToController.text = _address ?? ""; @@ -1050,8 +1054,11 @@ class _DesktopSendState extends ConsumerState { ); if (paymentData != null && paymentData.coin?.uriScheme == coin.uriScheme) { + ref.read(pOpReturnData.notifier).state = + paymentData.additionalParams['op_return']; _applyUri(paymentData); } else { + ref.read(pOpReturnData.notifier).state = null; if (coin is Epiccash) { content = AddressUtils().formatEpicCashAddress(content); } @@ -1068,6 +1075,7 @@ class _DesktopSendState extends ConsumerState { }); } } catch (e) { + ref.read(pOpReturnData.notifier).state = null; // If parsing fails, treat it as a plain address. if (coin is Epiccash) { // strip http:// and https:// if content contains @ @@ -1754,14 +1762,18 @@ class _DesktopSendState extends ConsumerState { onChanged: (newValue) async { final trimmed = newValue; - if ((trimmed.length - (_address?.length ?? 0)).abs() > 1) { + if ((trimmed.length - (_address?.length ?? 0)).abs() > 1 || + trimmed.contains(':')) { final parsed = AddressUtils.parsePaymentUri( trimmed, logging: Logging.instance, ); if (parsed != null) { + ref.read(pOpReturnData.notifier).state = + parsed.additionalParams['op_return']; _applyUri(parsed); } else { + ref.read(pOpReturnData.notifier).state = null; await _checkSparkNameAndOrSetAddress(newValue); } } else { @@ -1815,6 +1827,8 @@ class _DesktopSendState extends ConsumerState { onTap: () { sendToController.text = ""; _address = ""; + ref.read(pOpReturnData.notifier).state = + null; _setValidAddressProviders(_address); setState(() { _addressToggleFlag = false; @@ -1960,6 +1974,66 @@ class _DesktopSendState extends ConsumerState { } }, ), + // OP_RETURN metadata info (green, public mode only, with tooltip) + Builder( + builder: (context) { + final opData = ref.watch(pOpReturnData); + final balType = ref.watch(publicPrivateBalanceStateProvider); + if (opData == null || + opData.isEmpty || + balType != BalanceType.public) { + return Container(); + } + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Tooltip( + message: AddressUtils.formatOpReturnTooltip(opData), + child: Text( + "Transaction includes metadata " + "(${opData.length ~/ 2} bytes)", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.accentColorGreen, + ), + ), + ), + ), + ); + }, + ), + // OP_RETURN bridge warning (red, private mode only) + Builder( + builder: (context) { + final opData = ref.watch(pOpReturnData); + final balType = ref.watch(publicPrivateBalanceStateProvider); + if (opData == null || + opData.isEmpty || + balType != BalanceType.private) { + return Container(); + } + return Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.only(left: 12.0, top: 4.0), + child: Text( + "Bridge data detected but Spark (private) transactions " + "cannot carry OP_RETURN data. Switch to public balance " + "to complete the bridge transaction.", + textAlign: TextAlign.left, + style: STextStyles.label(context).copyWith( + color: Theme.of( + context, + ).extension()!.textError, + ), + ), + ), + ); + }, + ), if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress)) const SizedBox(height: 10), if (hasOptionalMemo || ref.watch(pValidSparkSendToAddress)) diff --git a/lib/providers/ui/preview_tx_button_state_provider.dart b/lib/providers/ui/preview_tx_button_state_provider.dart index fcb77fe649..1ad75aa4d9 100644 --- a/lib/providers/ui/preview_tx_button_state_provider.dart +++ b/lib/providers/ui/preview_tx_button_state_provider.dart @@ -23,6 +23,8 @@ final pValidSparkSendToAddress = StateProvider.autoDispose((_) => false); final pIsExchangeAddress = StateProvider((_) => false); +final pOpReturnData = StateProvider((_) => null); + // MWC Transaction Method Provider. final pSelectedMwcTransactionMethod = StateProvider( (_) => MwcTransactionMethod.slatepack, @@ -47,42 +49,44 @@ final pIsSlatepack = Provider.family((ref, walletId) { return false; }); -final pPreviewTxButtonEnabled = Provider.autoDispose - .family((ref, coin) { - final amount = ref.watch(pSendAmount) ?? Amount.zero; +final pPreviewTxButtonEnabled = Provider.autoDispose.family( + (ref, coin) { + final amount = ref.watch(pSendAmount) ?? Amount.zero; - // For MWC slatepack transactions, address validation is not required. - if (coin is Mimblewimblecoin) { - final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); - if (selectedMethod == MwcTransactionMethod.slatepack) { - return amount > Amount.zero; - } + // For MWC slatepack transactions, address validation is not required. + if (coin is Mimblewimblecoin) { + final selectedMethod = ref.watch(pSelectedMwcTransactionMethod); + if (selectedMethod == MwcTransactionMethod.slatepack) { + return amount > Amount.zero; } + } - // For Epic Cash slatepack transactions, address validation is not required. - if (coin is Epiccash) { - final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); - if (selectedMethod == EpicTransactionMethod.slatepack) { - return amount > Amount.zero; - } + // For Epic Cash slatepack transactions, address validation is not required. + if (coin is Epiccash) { + final selectedMethod = ref.watch(pSelectedEpicTransactionMethod); + if (selectedMethod == EpicTransactionMethod.slatepack) { + return amount > Amount.zero; } + } - if (coin is Firo) { - final firoType = ref.watch(publicPrivateBalanceStateProvider); - switch (firoType) { - case BalanceType.private: - return (ref.watch(pValidSendToAddress) || - ref.watch(pValidSparkSendToAddress)) && - !ref.watch(pIsExchangeAddress) && - amount > Amount.zero; + if (coin is Firo) { + final firoType = ref.watch(publicPrivateBalanceStateProvider); + switch (firoType) { + case BalanceType.private: + return (ref.watch(pValidSendToAddress) || + ref.watch(pValidSparkSendToAddress)) && + !ref.watch(pIsExchangeAddress) && + ref.watch(pOpReturnData) == null && + amount > Amount.zero; - case BalanceType.public: - return ref.watch(pValidSendToAddress) && amount > Amount.zero; - } - } else { - return ref.watch(pValidSendToAddress) && amount > Amount.zero; + case BalanceType.public: + return ref.watch(pValidSendToAddress) && amount > Amount.zero; } - }); + } else { + return ref.watch(pValidSendToAddress) && amount > Amount.zero; + } + }, +); final previewTokenTxButtonStateProvider = StateProvider.autoDispose((_) { return false; diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index ff0880cec7..43e72b6f78 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -23,6 +23,7 @@ class AddressUtils { 'tx_payment_id', 'recipient_name', 'tx_description', + 'op_return', // For Rosen Bridge and other OP_RETURN protocols. // TODO [prio=med]: Add more recognized params for other coins. }; @@ -268,24 +269,85 @@ class AddressUtils { if ((mimblewimblecoinAddress.startsWith("http://") || mimblewimblecoinAddress.startsWith("https://")) && mimblewimblecoinAddress.contains("@")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("http://", ""); - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("https://", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "http://", + "", + ); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "https://", + "", + ); } // strip mailto: prefix if (mimblewimblecoinAddress.startsWith("mailto:")) { - mimblewimblecoinAddress = - mimblewimblecoinAddress.replaceAll("mailto:", ""); + mimblewimblecoinAddress = mimblewimblecoinAddress.replaceAll( + "mailto:", + "", + ); } // strip / suffix if the address contains an @ symbol (and is thus an mwcmqs address) if (mimblewimblecoinAddress.endsWith("/") && mimblewimblecoinAddress.contains("@")) { mimblewimblecoinAddress = mimblewimblecoinAddress.substring( - 0, mimblewimblecoinAddress.length - 1); + 0, + mimblewimblecoinAddress.length - 1, + ); } return mimblewimblecoinAddress; } + + /// Formats OP_RETURN hex data for display in tooltip. + /// If data matches Rosen Bridge format, shows structured fields. + /// Otherwise returns the raw hex with a generic description. + static String formatOpReturnTooltip(String hex) { + // Rosen Bridge OP_RETURN format: + // toChain(1B) + bridgeFee(8B) + networkFee(8B) + addrLen(1B) + toAddress(var) + const minRosenLen = 36; // minimum 18 bytes + if (hex.length < minRosenLen) { + return "Raw OP_RETURN data:\n$hex"; + } + + try { + const chains = [ + 'ergo', + 'cardano', + 'bitcoin', + 'ethereum', + 'binance', + 'doge', + 'bitcoin-runes', + 'firo', + ]; + + final toChainCode = int.parse(hex.substring(0, 2), radix: 16); + if (toChainCode >= chains.length) { + return "Raw OP_RETURN data:\n$hex"; + } + + final bridgeFee = BigInt.parse( + hex.substring(2, 18), + radix: 16, + ).toString(); + final networkFee = BigInt.parse( + hex.substring(18, 34), + radix: 16, + ).toString(); + final addrLen = int.parse(hex.substring(34, 36), radix: 16); + final addrEnd = 36 + addrLen * 2; + if (hex.length < addrEnd) { + return "Raw OP_RETURN data:\n$hex"; + } + final toAddressHex = hex.substring(36, addrEnd); + + return "Rosen Bridge data\n" + " To chain: ${chains[toChainCode]}\n" + " Bridge fee: $bridgeFee\n" + " Network fee: $networkFee\n" + " To address (hex): $toAddressHex"; + } catch (_) { + return "Raw OP_RETURN data:\n$hex"; + } + } } class PaymentUriData { diff --git a/lib/wallets/models/tx_data.dart b/lib/wallets/models/tx_data.dart index 14c7186f2b..bdff31c709 100644 --- a/lib/wallets/models/tx_data.dart +++ b/lib/wallets/models/tx_data.dart @@ -112,6 +112,9 @@ class TxData { final bool salviumStakeTx; + // Generic OP_RETURN data (hex string) - for Rosen Bridge and other protocols + final String? opReturnData; + TxData({ this.feeRateType, this.feeRateAmount, @@ -149,6 +152,7 @@ class TxData { this.sparkNameInfo, this.vExtraData, this.overrideVersion, + this.opReturnData, this.type = TxType.regular, this.salviumStakeTx = false, }); @@ -263,6 +267,7 @@ class TxData { String? noteOnChain, String? memo, String? otherData, + String? opReturnData, Set? utxos, List? usedUTXOs, List? recipients, @@ -341,6 +346,7 @@ class TxData { sparkNameInfo: sparkNameInfo ?? this.sparkNameInfo, vExtraData: vExtraData ?? this.vExtraData, overrideVersion: overrideVersion ?? this.overrideVersion, + opReturnData: opReturnData ?? this.opReturnData, type: type ?? this.type, ); } @@ -383,6 +389,7 @@ class TxData { 'sparkNameInfo: $sparkNameInfo, ' 'vExtraData: ${vExtraData?.toHex}, ' 'overrideVersion: $overrideVersion, ' + 'opReturnData: $opReturnData, ' 'type: $type, ' '}'; } diff --git a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart index e963566b61..91496e752e 100644 --- a/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart +++ b/lib/wallets/wallet/wallet_mixin_interfaces/electrumx_interface.dart @@ -852,6 +852,63 @@ mixin ElectrumXInterface ); } + // Add OP_RETURN output if provided (for Rosen Bridge and other protocols) + // Currently only supported for Firo + if (cryptoCurrency is Firo && + txData.opReturnData != null && + txData.opReturnData!.isNotEmpty) { + try { + final opReturnBytes = txData.opReturnData!.toUint8ListFromHex; + + // Validate OP_RETURN size (Bitcoin/Firo limit is 80 bytes) + if (opReturnBytes.length > 80) { + throw Exception( + "OP_RETURN data exceeds 80 byte limit: ${opReturnBytes.length} bytes", + ); + } + + // Encode push data: OP_PUSHDATA1 (0x4c) for 76-80 bytes, direct length otherwise + final pushData = opReturnBytes.length <= 75 + ? Uint8List.fromList([opReturnBytes.length, ...opReturnBytes]) + : Uint8List.fromList([ + 0x4c, + opReturnBytes.length, + ...opReturnBytes, + ]); + + final opReturnScript = Uint8List.fromList([ + 0x6a, // OP_RETURN opcode + ...pushData, + ]); + + final opReturnOutput = coinlib.Output.fromScriptBytes( + BigInt.zero, // OP_RETURN outputs have 0 value + opReturnScript, + ); + + clTx = clTx.addOutput(opReturnOutput); + + Logging.instance.i( + "Added OP_RETURN output with ${opReturnBytes.length} bytes of data", + ); + + tempOutputs.add( + OutputV2.isarCantDoRequiredInDefaultConstructor( + scriptPubKeyHex: opReturnScript.toHex, + valueStringSats: "0", + addresses: [], + walletOwns: false, + ), + ); + } catch (e, s) { + Logging.instance.e( + "Failed to add OP_RETURN output", + error: e, + stackTrace: s, + ); + throw Exception("Invalid OP_RETURN data: $e"); + } + } if (isMweb) { if (hasNonWitnessInput) { throw Exception("Found non witness input in mweb tx"); From 706779c18506ddf2dc4786476c39137d913be842 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sat, 30 May 2026 08:53:19 +0100 Subject: [PATCH 2/3] fix: account for Firo OP_RETURN in fee previews --- lib/pages/send_view/send_view.dart | 69 ++++++++++++++++--- .../transaction_fee_selection_sheet.dart | 44 ++++++++++++ .../wallet_view/sub_widgets/desktop_send.dart | 33 +++++---- .../sub_widgets/desktop_send_fee_form.dart | 61 +++++++++++++++- .../ui/preview_tx_button_state_provider.dart | 2 +- lib/utilities/address_utils.dart | 19 +++++ 6 files changed, 200 insertions(+), 28 deletions(-) diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 48f32904d5..87e5bd0cde 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 { // 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 { Map cachedFiroSparkFees = {}; Map 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 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 { 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 { } void clearSendForm() { + if (!mounted) { + return; + } sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -1155,10 +1205,8 @@ class _SendViewState extends ConsumerState { 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 { 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 5d586ae9f0..387138d8cf 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 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 72b646eeac..d5d93ad2f1 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 { } void clearSendForm() { + if (!mounted) { + return; + } sendToController.text = ""; cryptoAmountController.text = ""; baseAmountController.text = ""; @@ -854,9 +857,15 @@ class _DesktopSendState extends ConsumerState { 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 { 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 { ); 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 { }); } } 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 { 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 { 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 0b9377599d..b1e2b468e1 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 { (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 { 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 { 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 1ad75aa4d9..b079504166 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((_) => false); final pIsExchangeAddress = StateProvider((_) => false); -final pOpReturnData = StateProvider((_) => null); +final pOpReturnData = StateProvider.autoDispose((_) => null); // MWC Transaction Method Provider. final pSelectedMwcTransactionMethod = StateProvider( diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index 43e72b6f78..fb9b426a4e 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 { From 2d5f17350376e4b5eeea19e9b1260c5059f5dda4 Mon Sep 17 00:00:00 2001 From: Navid Rahimi Date: Sat, 30 May 2026 16:29:42 +0100 Subject: [PATCH 3/3] fix(firo): align OP_RETURN tooltip chain order --- lib/utilities/address_utils.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart index fb9b426a4e..cb1f5f8ad6 100644 --- a/lib/utilities/address_utils.dart +++ b/lib/utilities/address_utils.dart @@ -308,15 +308,18 @@ class AddressUtils { } try { + // Must match @rosen-bridge/rosen-extractor SUPPORTED_CHAINS order. const chains = [ 'ergo', 'cardano', 'bitcoin', 'ethereum', 'binance', + 'base', 'doge', 'bitcoin-runes', 'firo', + 'handshake', ]; final toChainCode = int.parse(hex.substring(0, 2), radix: 16);