diff --git a/lib/pages/send_view/send_view.dart b/lib/pages/send_view/send_view.dart index 94b5663c8..4048fa475 100644 --- a/lib/pages/send_view/send_view.dart +++ b/lib/pages/send_view/send_view.dart @@ -159,7 +159,6 @@ class _SendViewState extends ConsumerState { try { // auto fill address _address = paymentData.address.trim(); - sendToController.text = _address!; // autofill notes field if (paymentData.message != null) { @@ -179,7 +178,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; }); @@ -919,6 +936,7 @@ class _SendViewState extends ConsumerState { selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } else if (wallet is FiroWallet) { @@ -960,6 +978,7 @@ class _SendViewState extends ConsumerState { utxos: (coinControlEnabled && selectedUTXOs.isNotEmpty) ? selectedUTXOs : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -1131,6 +1150,7 @@ class _SendViewState extends ConsumerState { memoController.text = ""; _address = ""; _addressToggleFlag = false; + ref.read(pOpReturnData.notifier).state = null; if (mounted) { setState(() {}); } @@ -1720,9 +1740,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, @@ -1731,6 +1752,8 @@ class _SendViewState extends ConsumerState { if (parsed != null) { _applyUri(parsed); } else { + ref.read(pOpReturnData.notifier).state = + null; await _checkSparkNameAndOrSetAddress( newValue, ); @@ -1943,6 +1966,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; @@ -2660,16 +2715,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 b8dc85f4d..6854650be 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 @@ -646,6 +646,7 @@ class _DesktopSendState extends ConsumerState { ref.read(pDesktopUseUTXOs).isNotEmpty) ? ref.read(pDesktopUseUTXOs) : null, + opReturnData: ref.read(pOpReturnData), ), ); } @@ -915,8 +916,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 ?? ""; @@ -1045,8 +1049,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); } @@ -1063,6 +1070,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 @ @@ -1748,14 +1756,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 { @@ -1809,6 +1821,8 @@ class _DesktopSendState extends ConsumerState { onTap: () { sendToController.text = ""; _address = ""; + ref.read(pOpReturnData.notifier).state = + null; _setValidAddressProviders(_address); setState(() { _addressToggleFlag = false; @@ -1954,6 +1968,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 fcb77fe64..1ad75aa4d 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 ff0880cec..43e72b6f7 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 14c7186f2..bdff31c70 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 e963566b6..91496e752 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");