What changed, and why it matters
This commit adds support for 'memos' or 'destination tags' when swapping cryptocurrencies through third-party exchange providers. Some coins (like XRP, XLM, ATOM, etc.) require these extra identifiers so the receiving platform knows which customer account should get the funds. Previously, the wallet always sent these fields as empty/null, which could cause swapped funds to be lost or misattributed when sent to an exchange or custodial address that requires a memo. The change also parses memos from scanned QR payment URIs and shows input fields only when the chosen currency commonly needs one.
Treat this as a functional correctness and potential funds-loss fix rather than an active exploit. Review that the hardcoded ExtraIdCurrencySupport ticker list matches each integrated exchange's actual memo requirements, ensure ChangeNow/Trocador API fields are named correctly, and verify that CypherGoat's rejection path cannot be bypassed by whitespace or null-vs-empty handling. Add integration tests for end-to-end trade creation with memos.
Security signals we found
Previously missing extra ID/memo data could be sent to exchange APIs, risking loss of swapped funds for tag-required currencies
New validation throws if memos are supplied to CypherGoat, which does not support them
Payment URI parsing now extracts memo/destination-tag aliases from QR codes and clipboard
UI conditionally shows memo fields only for currencies that commonly require them
Evidence from the diff
The patch extends the exchange flow model with extraId and refundExtraId fields, wires them into the ChangeNow and Trocador createTrade calls, and explicitly disables/disallows them for CypherGoat. It introduces ExtraIdCurrencySupport.mayRequire() to gate UI memo fields for a hardcoded set of tickers (atom, eos, hbar, ton, xlm, xrp). AddressUtils.parsePaymentUri now recognizes ‘memo’, ‘dt’, and ‘destination_tag’ query parameters and exposes them via a memo getter. Both mobile and desktop step 2/3 views were updated to collect, display, and forward these values. Tests cover URI parsing, model storage, currency support matching, and CypherGoat rejection.
Changed components
lib/models/exchange/incomplete_exchange.dartlib/services/exchange/change_now/change_now_exchange.dartlib/services/exchange/trocador/trocador_exchange.dartlib/services/exchange/cyphergoat/cyphergoat_exchange.dartlib/services/exchange/exchange.dartlib/utilities/address_utils.dartlib/utilities/extra_id_currency_support.dartlib/pages/exchange_view/exchange_step_views/step_2_view.dartlib/pages/exchange_view/exchange_step_views/step_3_view.dartlib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dartlib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dartlib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dartInspect captured patch +661 / −69
diff --git a/lib/models/exchange/incomplete_exchange.dart b/lib/models/exchange/incomplete_exchange.dart
index c2464dd..28e0b06 100644
--- a/lib/models/exchange/incomplete_exchange.dart
+++ b/lib/models/exchange/incomplete_exchange.dart
@@ -59,6 +59,28 @@ class IncompleteExchangeModel extends ChangeNotifier {
}
}
+ String? _extraId;
+
+ String? get extraId => _extraId;
+
+ set extraId(String? extraId) {
+ if (_extraId != extraId) {
+ _extraId = extraId;
+ notifyListeners();
+ }
+ }
+
+ String? _refundExtraId;
+
+ String? get refundExtraId => _refundExtraId;
+
+ set refundExtraId(String? refundExtraId) {
+ if (_refundExtraId != refundExtraId) {
+ _refundExtraId = refundExtraId;
+ notifyListeners();
+ }
+ }
+
Estimate? _estimate;
Estimate? get estimate => _estimate;
diff --git a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart
index e9735a5..8a3c05f 100644
--- a/lib/pages/exchange_view/exchange_step_views/step_2_view.dart
+++ b/lib/pages/exchange_view/exchange_step_views/step_2_view.dart
@@ -20,6 +20,7 @@ import '../../../utilities/address_utils.dart';
import '../../../utilities/barcode_scanner_interface.dart';
import '../../../utilities/clipboard_interface.dart';
import '../../../utilities/constants.dart';
+import '../../../utilities/extra_id_currency_support.dart';
import '../../../utilities/logger.dart';
import '../../../utilities/text_styles.dart';
import '../../../widgets/background.dart';
@@ -61,12 +62,36 @@ class _Step2ViewState extends ConsumerState<Step2View> {
late final TextEditingController _toController;
late final TextEditingController _refundController;
+ late final TextEditingController _toMemoController;
+ late final TextEditingController _refundMemoController;
late final FocusNode _toFocusNode;
late final FocusNode _refundFocusNode;
+ late final FocusNode _toMemoFocusNode;
+ late final FocusNode _refundMemoFocusNode;
bool enableNext = false;
+ bool get _showRecipientMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(model.receiveTicker);
+
+ bool get _showRefundMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(model.sendTicker);
+
+ void _setRecipientMemo(String? memo) {
+ final value = _showRecipientMemo ? (memo ?? "") : "";
+ _toMemoController.text = value;
+ model.extraId = value.isEmpty ? null : value;
+ }
+
+ void _setRefundMemo(String? memo) {
+ final value = _showRefundMemo ? (memo ?? "") : "";
+ _refundMemoController.text = value;
+ model.refundExtraId = value.isEmpty ? null : value;
+ }
+
void _onRefundQrTapped() async {
try {
final qrResult = await ref.read(pBarcodeScanner).scan(context: context);
@@ -81,6 +106,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
// auto fill address
_refundController.text = paymentData.address;
model.refundAddress = _refundController.text;
+ _setRefundMemo(paymentData.memo);
setState(() {
enableNext =
@@ -90,6 +116,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
} else {
_refundController.text = qrResult.rawContent!;
model.refundAddress = _refundController.text;
+ _setRefundMemo(null);
setState(() {
enableNext =
@@ -135,6 +162,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
// auto fill address
_toController.text = paymentData.address;
model.recipientAddress = _toController.text;
+ _setRecipientMemo(paymentData.memo);
setState(() {
enableNext =
@@ -145,6 +173,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
} else {
_toController.text = qrResult.rawContent!;
model.recipientAddress = _toController.text;
+ _setRecipientMemo(null);
setState(() {
enableNext =
@@ -184,9 +213,15 @@ class _Step2ViewState extends ConsumerState<Step2View> {
_toController = TextEditingController();
_refundController = TextEditingController();
+ _toMemoController = TextEditingController(text: model.extraId ?? "");
+ _refundMemoController = TextEditingController(
+ text: model.refundExtraId ?? "",
+ );
_toFocusNode = FocusNode();
_refundFocusNode = FocusNode();
+ _toMemoFocusNode = FocusNode();
+ _refundMemoFocusNode = FocusNode();
final tuple = ref.read(exchangeSendFromWalletIdStateProvider.state).state;
if (tuple != null) {
@@ -199,6 +234,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.then((value) {
_toController.text = value!.value;
model.recipientAddress = _toController.text;
+ _setRecipientMemo(null);
});
} else {
if (model.sendTicker.toUpperCase() ==
@@ -210,6 +246,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.then((value) {
_refundController.text = value!.value;
model.refundAddress = _refundController.text;
+ _setRefundMemo(null);
});
}
}
@@ -222,9 +259,13 @@ class _Step2ViewState extends ConsumerState<Step2View> {
void dispose() {
_toController.dispose();
_refundController.dispose();
+ _toMemoController.dispose();
+ _refundMemoController.dispose();
_toFocusNode.dispose();
_refundFocusNode.dispose();
+ _toMemoFocusNode.dispose();
+ _refundMemoFocusNode.dispose();
super.dispose();
}
@@ -314,6 +355,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
value.walletName;
model.recipientAddress =
value.address;
+ _setRecipientMemo(null);
setState(() {
enableNext =
@@ -402,6 +444,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
_toController.text = "";
model.recipientAddress =
_toController.text;
+ _setRecipientMemo(null);
setState(() {
enableNext =
@@ -436,8 +479,27 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.text!
.trim();
- _toController.text =
- content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging
+ .instance,
+ );
+ if (paymentData !=
+ null) {
+ _toController.text =
+ paymentData
+ .address;
+ _setRecipientMemo(
+ paymentData.memo,
+ );
+ } else {
+ _toController.text =
+ content;
+ _setRecipientMemo(
+ null,
+ );
+ }
model.recipientAddress =
_toController
.text;
@@ -498,6 +560,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
address;
model.recipientAddress =
_toController.text;
+ _setRecipientMemo(null);
ref
.read(
exchangeFromAddressBookAddressStateProvider
@@ -543,6 +606,39 @@ class _Step2ViewState extends ConsumerState<Step2View> {
style: STextStyles.label(context),
),
),
+ if (_showRecipientMemo) const SizedBox(height: 16),
+ if (_showRecipientMemo)
+ Text(
+ "Memo or destination tag",
+ style: STextStyles.smallMed12(context),
+ ),
+ if (_showRecipientMemo) const SizedBox(height: 4),
+ if (_showRecipientMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key(
+ "recipientExchangeStep2ViewMemoFieldKey",
+ ),
+ controller: _toMemoController,
+ focusNode: _toMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ model.extraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter memo or tag if required",
+ _toMemoFocusNode,
+ context,
+ ),
+ ),
+ ),
const SizedBox(height: 24),
if (supportsRefund)
Row(
@@ -583,6 +679,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
value.walletName;
model.refundAddress =
value.address;
+ _setRefundMemo(null);
}
setState(() {
enableNext =
@@ -675,6 +772,7 @@ class _Step2ViewState extends ConsumerState<Step2View> {
model.refundAddress =
_refundController
.text;
+ _setRefundMemo(null);
setState(() {
enableNext =
@@ -708,9 +806,30 @@ class _Step2ViewState extends ConsumerState<Step2View> {
.text!
.trim();
- _refundController
- .text =
- content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging
+ .instance,
+ );
+ if (paymentData !=
+ null) {
+ _refundController
+ .text =
+ paymentData
+ .address;
+ _setRefundMemo(
+ paymentData
+ .memo,
+ );
+ } else {
+ _refundController
+ .text =
+ content;
+ _setRefundMemo(
+ null,
+ );
+ }
model.refundAddress =
_refundController
.text;
@@ -775,6 +894,9 @@ class _Step2ViewState extends ConsumerState<Step2View> {
model.refundAddress =
_refundController
.text;
+ _setRefundMemo(
+ null,
+ );
}
setState(() {
enableNext =
@@ -815,6 +937,41 @@ class _Step2ViewState extends ConsumerState<Step2View> {
style: STextStyles.label(context),
),
),
+ if (supportsRefund && _showRefundMemo)
+ const SizedBox(height: 16),
+ if (supportsRefund && _showRefundMemo)
+ Text(
+ "Refund memo or destination tag",
+ style: STextStyles.smallMed12(context),
+ ),
+ if (supportsRefund && _showRefundMemo)
+ const SizedBox(height: 4),
+ if (supportsRefund && _showRefundMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key(
+ "refundExchangeStep2ViewMemoFieldKey",
+ ),
+ controller: _refundMemoController,
+ focusNode: _refundMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ model.refundExtraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter memo or tag if required",
+ _refundMemoFocusNode,
+ context,
+ ),
+ ),
+ ),
const SizedBox(height: 16),
const Spacer(),
Row(
diff --git a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart
index 4f0b352..b649d56 100644
--- a/lib/pages/exchange_view/exchange_step_views/step_3_view.dart
+++ b/lib/pages/exchange_view/exchange_step_views/step_3_view.dart
@@ -169,6 +169,27 @@ class _Step3ViewState extends ConsumerState<Step3View> {
],
),
),
+ if (model.extraId?.isNotEmpty == true)
+ const SizedBox(height: 8),
+ if (model.extraId?.isNotEmpty == true)
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Recipient memo or tag",
+ style: STextStyles.itemSubtitle(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ model.extraId!,
+ style: STextStyles.itemSubtitle12(
+ context,
+ ),
+ ),
+ ],
+ ),
+ ),
if (supportsRefund) const SizedBox(height: 8),
if (supportsRefund)
RoundedWhiteContainer(
@@ -189,6 +210,29 @@ class _Step3ViewState extends ConsumerState<Step3View> {
],
),
),
+ if (supportsRefund &&
+ model.refundExtraId?.isNotEmpty == true)
+ const SizedBox(height: 8),
+ if (supportsRefund &&
+ model.refundExtraId?.isNotEmpty == true)
+ RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Refund memo or tag",
+ style: STextStyles.itemSubtitle(context),
+ ),
+ const SizedBox(height: 4),
+ Text(
+ model.refundExtraId!,
+ style: STextStyles.itemSubtitle12(
+ context,
+ ),
+ ),
+ ],
+ ),
+ ),
const SizedBox(height: 8),
const Spacer(),
Row(
@@ -205,14 +249,12 @@ class _Step3ViewState extends ConsumerState<Step3View> {
),
child: Text(
"Back",
- style: STextStyles.button(
- context,
- ).copyWith(
- color:
- Theme.of(context)
+ style: STextStyles.button(context)
+ .copyWith(
+ color: Theme.of(context)
.extension<StackColors>()!
.buttonTextSecondary,
- ),
+ ),
),
),
),
@@ -224,22 +266,19 @@ class _Step3ViewState extends ConsumerState<Step3View> {
showDialog<void>(
context: context,
barrierDismissible: false,
- builder:
- (_) => WillPopScope(
- onWillPop: () async => false,
- child: Container(
- color: Theme.of(context)
- .extension<StackColors>()!
- .overlay
- .withOpacity(0.6),
- child:
- const CustomLoadingOverlay(
- message:
- "Creating a trade",
- eventBus: null,
- ),
- ),
+ builder: (_) => WillPopScope(
+ onWillPop: () async => false,
+ child: Container(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .overlay
+ .withOpacity(0.6),
+ child: const CustomLoadingOverlay(
+ message: "Creating a trade",
+ eventBus: null,
),
+ ),
+ ),
),
);
@@ -256,17 +295,16 @@ class _Step3ViewState extends ConsumerState<Step3View> {
fixedRate:
model.rateType !=
ExchangeRateType.estimated,
- amount:
- model.reversed
- ? model.receiveAmount
- : model.sendAmount,
+ amount: model.reversed
+ ? model.receiveAmount
+ : model.sendAmount,
addressTo: model.recipientAddress!,
- extraId: null,
- addressRefund:
- supportsRefund
- ? model.refundAddress!
- : "",
- refundExtraId: "",
+ extraId: model.extraId,
+ addressRefund: supportsRefund
+ ? model.refundAddress!
+ : "",
+ refundExtraId:
+ model.refundExtraId ?? "",
estimate: model.estimate,
reversed: model.reversed,
);
@@ -278,8 +316,8 @@ class _Step3ViewState extends ConsumerState<Step3View> {
// TODO: better errors
String? message;
if (response.exception != null) {
- message =
- response.exception!.toString();
+ message = response.exception!
+ .toString();
if (message.startsWith(
"FormatException:",
) &&
@@ -293,12 +331,10 @@ class _Step3ViewState extends ConsumerState<Step3View> {
showDialog<void>(
context: context,
barrierDismissible: true,
- builder:
- (_) => StackDialog(
- title:
- "Failed to create trade",
- message: message ?? "",
- ),
+ builder: (_) => StackDialog(
+ title: "Failed to create trade",
+ message: message ?? "",
+ ),
),
);
}
diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart
index c512ffa..a0c7594 100644
--- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart
+++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/step_scaffold.dart
@@ -112,9 +112,10 @@ class _StepScaffoldState extends ConsumerState<StepScaffold> {
? ref.read(desktopExchangeModelProvider)!.receiveAmount
: ref.read(desktopExchangeModelProvider)!.sendAmount,
addressTo: ref.read(desktopExchangeModelProvider)!.recipientAddress!,
- extraId: null,
+ extraId: ref.read(desktopExchangeModelProvider)!.extraId,
addressRefund: ref.read(desktopExchangeModelProvider)!.refundAddress!,
- refundExtraId: "",
+ refundExtraId:
+ ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "",
estimate: ref.read(desktopExchangeModelProvider)!.estimate,
reversed: ref.read(desktopExchangeModelProvider)!.reversed,
);
diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart
index 46038a5..f46d704 100644
--- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart
+++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_2.dart
@@ -17,8 +17,10 @@ import '../../../../app_config.dart';
import '../../../../models/contact_address_entry.dart';
import '../../../../providers/providers.dart';
import '../../../../themes/stack_colors.dart';
+import '../../../../utilities/address_utils.dart';
import '../../../../utilities/clipboard_interface.dart';
import '../../../../utilities/constants.dart';
+import '../../../../utilities/extra_id_currency_support.dart';
import '../../../../utilities/logger.dart';
import '../../../../utilities/text_styles.dart';
import '../../../../widgets/custom_buttons/blue_text_button.dart';
@@ -53,9 +55,41 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
late final TextEditingController _toController;
late final TextEditingController _refundController;
+ late final TextEditingController _toMemoController;
+ late final TextEditingController _refundMemoController;
late final FocusNode _toFocusNode;
late final FocusNode _refundFocusNode;
+ late final FocusNode _toMemoFocusNode;
+ late final FocusNode _refundMemoFocusNode;
+
+ bool get _showRecipientMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(
+ ref.read(desktopExchangeModelProvider)!.receiveTicker,
+ );
+
+ bool get _showRefundMemo =>
+ ref.read(efExchangeProvider).supportsExtraId &&
+ ExtraIdCurrencySupport.mayRequire(
+ ref.read(desktopExchangeModelProvider)!.sendTicker,
+ );
+
+ void _setRecipientMemo(String? memo) {
+ final value = _showRecipientMemo ? (memo ?? "") : "";
+ _toMemoController.text = value;
+ ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty
+ ? null
+ : value;
+ }
+
+ void _setRefundMemo(String? memo) {
+ final value = _showRefundMemo ? (memo ?? "") : "";
+ _refundMemoController.text = value;
+ ref.read(desktopExchangeModelProvider)!.refundExtraId = value.isEmpty
+ ? null
+ : value;
+ }
void selectRecipientAddressFromStack() async {
try {
@@ -79,6 +113,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (info is Tuple2<String, String>) {
_toController.text = info.item1;
ref.read(desktopExchangeModelProvider)!.recipientAddress = info.item2;
+ _setRecipientMemo(null);
}
} catch (e, s) {
Logging.instance.i("$e\n$s", error: e, stackTrace: s);
@@ -108,6 +143,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (info is Tuple2<String, String>) {
_refundController.text = info.item1;
ref.read(desktopExchangeModelProvider)!.refundAddress = info.item2;
+ _setRefundMemo(null);
}
} catch (e, s) {
Logging.instance.i("$e\n$s", error: e, stackTrace: s);
@@ -151,6 +187,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (entry != null) {
_toController.text = entry.address;
ref.read(desktopExchangeModelProvider)!.recipientAddress = entry.address;
+ _setRecipientMemo(null);
widget.enableNextChanged.call(_next());
}
}
@@ -191,6 +228,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (entry != null) {
_refundController.text = entry.address;
ref.read(desktopExchangeModelProvider)!.refundAddress = entry.address;
+ _setRefundMemo(null);
widget.enableNextChanged.call(_next());
}
}
@@ -211,9 +249,17 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
_toController = TextEditingController();
_refundController = TextEditingController();
+ _toMemoController = TextEditingController(
+ text: ref.read(desktopExchangeModelProvider)!.extraId ?? "",
+ );
+ _refundMemoController = TextEditingController(
+ text: ref.read(desktopExchangeModelProvider)!.refundExtraId ?? "",
+ );
_toFocusNode = FocusNode();
_refundFocusNode = FocusNode();
+ _toMemoFocusNode = FocusNode();
+ _refundMemoFocusNode = FocusNode();
doesRefundAddress = ref.read(efExchangeProvider).supportsRefundAddress;
@@ -237,6 +283,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(desktopExchangeModelProvider)!.recipientAddress =
_toController.text;
+ _setRecipientMemo(null);
});
} else {
if (doesRefundAddress &&
@@ -250,6 +297,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(desktopExchangeModelProvider)!.refundAddress =
_refundController.text;
+ _setRefundMemo(null);
});
}
}
@@ -262,9 +310,13 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
void dispose() {
_toController.dispose();
_refundController.dispose();
+ _toMemoController.dispose();
+ _refundMemoController.dispose();
_toFocusNode.dispose();
_refundFocusNode.dispose();
+ _toMemoFocusNode.dispose();
+ _refundMemoFocusNode.dispose();
super.dispose();
}
@@ -370,6 +422,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
.read(desktopExchangeModelProvider)!
.recipientAddress =
_toController.text;
+ _setRecipientMemo(null);
widget.enableNextChanged.call(_next());
},
child: const XIcon(),
@@ -384,7 +437,19 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
if (data?.text != null &&
data!.text!.isNotEmpty) {
final content = data.text!.trim();
- _toController.text = content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging.instance,
+ );
+ if (paymentData != null) {
+ _toController.text =
+ paymentData.address;
+ _setRecipientMemo(paymentData.memo);
+ } else {
+ _toController.text = content;
+ _setRecipientMemo(null);
+ }
ref
.read(desktopExchangeModelProvider)!
.recipientAddress = _toController
@@ -416,6 +481,42 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
),
),
),
+ if (_showRecipientMemo) const SizedBox(height: 10),
+ if (_showRecipientMemo)
+ Text(
+ "Memo or destination tag",
+ style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ ),
+ ),
+ if (_showRecipientMemo) const SizedBox(height: 10),
+ if (_showRecipientMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("recipientExchangeStep2ViewMemoFieldKey"),
+ controller: _toMemoController,
+ focusNode: _toMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ ref.read(desktopExchangeModelProvider)!.extraId = value.isEmpty
+ ? null
+ : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter the memo or tag required by the payout address, if any",
+ _toMemoFocusNode,
+ context,
+ desktopMed: true,
+ ),
+ ),
+ ),
const SizedBox(height: 10),
RoundedWhiteContainer(
borderColor: Theme.of(context).extension<StackColors>()!.background,
@@ -510,6 +611,7 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
.read(desktopExchangeModelProvider)!
.refundAddress = _refundController
.text;
+ _setRefundMemo(null);
widget.enableNextChanged.call(_next());
},
@@ -528,7 +630,19 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
data!.text!.isNotEmpty) {
final content = data.text!.trim();
- _refundController.text = content;
+ final paymentData =
+ AddressUtils.parsePaymentUri(
+ content,
+ logging: Logging.instance,
+ );
+ if (paymentData != null) {
+ _refundController.text =
+ paymentData.address;
+ _setRefundMemo(paymentData.memo);
+ } else {
+ _refundController.text = content;
+ _setRefundMemo(null);
+ }
ref
.read(desktopExchangeModelProvider)!
.refundAddress = _refundController
@@ -561,6 +675,41 @@ class _DesktopStep2State extends ConsumerState<DesktopStep2> {
),
),
),
+ if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10),
+ if (doesRefundAddress && _showRefundMemo)
+ Text(
+ "Refund memo or destination tag",
+ style: STextStyles.desktopTextExtraExtraSmall(context).copyWith(
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textFieldActiveSearchIconRight,
+ ),
+ ),
+ if (doesRefundAddress && _showRefundMemo) const SizedBox(height: 10),
+ if (doesRefundAddress && _showRefundMemo)
+ ClipRRect(
+ borderRadius: BorderRadius.circular(
+ Constants.size.circularBorderRadius,
+ ),
+ child: TextField(
+ key: const Key("refundExchangeStep2ViewMemoFieldKey"),
+ controller: _refundMemoController,
+ focusNode: _refundMemoFocusNode,
+ autocorrect: false,
+ enableSuggestions: false,
+ style: STextStyles.field(context),
+ onChanged: (value) {
+ ref.read(desktopExchangeModelProvider)!.refundExtraId =
+ value.isEmpty ? null : value;
+ },
+ decoration: standardInputDecoration(
+ "Enter the memo or tag required by the refund address, if any",
+ _refundMemoFocusNode,
+ context,
+ desktopMed: true,
+ ),
+ ),
+ ),
if (doesRefundAddress) const SizedBox(height: 10),
if (doesRefundAddress)
RoundedWhiteContainer(
diff --git a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart
index 98c4daa..1d2b824 100644
--- a/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart
+++ b/lib/pages_desktop_specific/desktop_exchange/exchange_steps/subwidgets/desktop_step_3.dart
@@ -20,9 +20,7 @@ import '../step_scaffold.dart';
import 'desktop_step_item.dart';
class DesktopStep3 extends ConsumerStatefulWidget {
- const DesktopStep3({
- super.key,
- });
+ const DesktopStep3({super.key});
@override
ConsumerState<DesktopStep3> createState() => _DesktopStep3State();
@@ -37,9 +35,7 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
"Confirm exchange details",
style: STextStyles.desktopTextMedium(context),
),
- const SizedBox(
- height: 20,
- ),
+ const SizedBox(height: 20),
RoundedWhiteContainer(
borderColor: Theme.of(context).extension<StackColors>()!.background,
padding: const EdgeInsets.all(0),
@@ -72,16 +68,19 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
color: Theme.of(context).extension<StackColors>()!.background,
),
DesktopStepItem(
- label: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.rateType),
+ label:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.rateType,
+ ),
) ==
ExchangeRateType.estimated
? "Estimated rate"
: "Fixed rate",
value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.rateInfo),
+ desktopExchangeModelProvider.select(
+ (value) => value!.rateInfo,
+ ),
),
),
Container(
@@ -92,12 +91,37 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
vertical: true,
label:
"Recipient ${ref.watch(desktopExchangeModelProvider.select((value) => value!.receiveTicker.toUpperCase()))} address",
- value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.recipientAddress),
+ value:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.recipientAddress,
+ ),
) ??
"Error",
),
+ if (ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId?.isNotEmpty == true,
+ ),
+ ))
+ Container(
+ height: 1,
+ color: Theme.of(context).extension<StackColors>()!.background,
+ ),
+ if (ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId?.isNotEmpty == true,
+ ),
+ ))
+ DesktopStepItem(
+ vertical: true,
+ label: "Recipient memo or tag",
+ value: ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.extraId!,
+ ),
+ ),
+ ),
if (ref.watch(efExchangeProvider).supportsRefundAddress)
Container(
height: 1,
@@ -108,12 +132,39 @@ class _DesktopStep3State extends ConsumerState<DesktopStep3> {
vertical: true,
label:
"Refund ${ref.watch(desktopExchangeModelProvider.select((value) => value!.sendTicker.toUpperCase()))} address",
- value: ref.watch(
- desktopExchangeModelProvider
- .select((value) => value!.refundAddress),
+ value:
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundAddress,
+ ),
) ??
"Error",
),
+ if (ref.watch(efExchangeProvider).supportsRefundAddress &&
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId?.isNotEmpty == true,
+ ),
+ ))
+ Container(
+ height: 1,
+ color: Theme.of(context).extension<StackColors>()!.background,
+ ),
+ if (ref.watch(efExchangeProvider).supportsRefundAddress &&
+ ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId?.isNotEmpty == true,
+ ),
+ ))
+ DesktopStepItem(
+ vertical: true,
+ label: "Refund memo or tag",
+ value: ref.watch(
+ desktopExchangeModelProvider.select(
+ (value) => value!.refundExtraId!,
+ ),
+ ),
+ ),
],
),
),
diff --git a/lib/services/exchange/change_now/change_now_exchange.dart b/lib/services/exchange/change_now/change_now_exchange.dart
index 48389af..1c21068 100644
--- a/lib/services/exchange/change_now/change_now_exchange.dart
+++ b/lib/services/exchange/change_now/change_now_exchange.dart
@@ -52,6 +52,7 @@ class ChangeNowExchange extends Exchange {
toCurrency: to,
toNetwork: toNetwork ?? "",
address: addressTo,
+ extraId: extraId,
rateId: estimate?.rateId,
refundAddress: addressRefund,
refundExtraId: refundExtraId,
diff --git a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
index 0189908..dde981f 100644
--- a/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
+++ b/lib/services/exchange/cyphergoat/cyphergoat_exchange.dart
@@ -159,6 +159,9 @@ class CypherGoatExchange extends Exchange {
@override
bool get supportsRefundAddress => false;
+ @override
+ bool get supportsExtraId => false;
+
@override
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(
bool fixedRate,
@@ -380,6 +383,12 @@ class CypherGoatExchange extends Exchange {
ExchangeExceptionType.generic,
);
}
+ if (extraId?.isNotEmpty == true || refundExtraId.isNotEmpty) {
+ throw ExchangeException(
+ "CypherGoat does not support destination or refund memos",
+ ExchangeExceptionType.generic,
+ );
+ }
final response = await CypherGoatAPI.createSwap(
coin1: from,
diff --git a/lib/services/exchange/exchange.dart b/lib/services/exchange/exchange.dart
index 7868b9e..b3d6fae 100644
--- a/lib/services/exchange/exchange.dart
+++ b/lib/services/exchange/exchange.dart
@@ -61,6 +61,9 @@ abstract class Exchange {
bool get supportsRefundAddress => true;
+ /// Whether createTrade forwards a payout memo/destination tag to the API.
+ bool get supportsExtraId => true;
+
Future<ExchangeResponse<List<Currency>>> getAllCurrencies(bool fixedRate);
// Future<ExchangeResponse<List<Currency>>> getPairedCurrencies(
diff --git a/lib/services/exchange/trocador/trocador_exchange.dart b/lib/services/exchange/trocador/trocador_exchange.dart
index 800f921..9f9e5f9 100644
--- a/lib/services/exchange/trocador/trocador_exchange.dart
+++ b/lib/services/exchange/trocador/trocador_exchange.dart
@@ -77,9 +77,9 @@ class TrocadorExchange extends Exchange {
toNetwork: onlySupportedNetwork,
toAmount: amount.toString(),
receivingAddress: addressTo,
- receivingMemo: null,
+ receivingMemo: extraId?.isNotEmpty == true ? extraId : null,
refundAddress: addressRefund,
- refundMemo: null,
+ refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null,
exchangeProvider: estimate!.exchangeProvider!,
isFixedRate: fixedRate,
)
@@ -92,9 +92,9 @@ class TrocadorExchange extends Exchange {
toNetwork: onlySupportedNetwork,
fromAmount: amount.toString(),
receivingAddress: addressTo,
- receivingMemo: null,
+ receivingMemo: extraId?.isNotEmpty == true ? extraId : null,
refundAddress: addressRefund,
- refundMemo: null,
+ refundMemo: refundExtraId.isNotEmpty ? refundExtraId : null,
exchangeProvider: estimate!.exchangeProvider!,
isFixedRate: fixedRate,
);
diff --git a/lib/utilities/address_utils.dart b/lib/utilities/address_utils.dart
index 8e0fa24..acd6c1e 100644
--- a/lib/utilities/address_utils.dart
+++ b/lib/utilities/address_utils.dart
@@ -24,6 +24,9 @@ class AddressUtils {
'recipient_name',
'tx_description',
'op_return', // For Rosen Bridge and other OP_RETURN protocols.
+ 'memo', // Stellar SEP-0007.
+ 'dt', // XRP destination tag.
+ 'destination_tag',
// TODO [prio=med]: Add more recognized params for other coins.
};
@@ -383,6 +386,20 @@ class PaymentUriData {
scheme ?? "", // empty will just return null
);
+ String? get memo {
+ for (final value in [
+ paymentId,
+ additionalParams["memo"],
+ additionalParams["dt"],
+ additionalParams["destination_tag"],
+ ]) {
+ if (value?.isNotEmpty == true) {
+ return value;
+ }
+ }
+ return null;
+ }
+
PaymentUriData({
required this.address,
this.scheme,
diff --git a/lib/utilities/extra_id_currency_support.dart b/lib/utilities/extra_id_currency_support.dart
new file mode 100644
index 0000000..6e5a78f
--- /dev/null
+++ b/lib/utilities/extra_id_currency_support.dart
@@ -0,0 +1,25 @@
+/*
+ * This file is part of Stack Wallet.
+ *
+ * Copyright (c) 2026 Cypher Stack
+ * All Rights Reserved.
+ * The code is distributed under GPLv3 license, see LICENSE file for details.
+ *
+ */
+
+/// Currencies whose custodial deposits commonly require a destination
+/// tag/memo ("extra ID") attached to the payout transaction. A payout sent
+/// to such a platform without its tag lands unattributed.
+abstract final class ExtraIdCurrencySupport {
+ static const Set<String> _tickers = {
+ "atom",
+ "eos",
+ "hbar",
+ "ton",
+ "xlm",
+ "xrp",
+ };
+
+ static bool mayRequire(String ticker) =>
+ _tickers.contains(ticker.trim().toLowerCase());
+}
diff --git a/test/address_utils_test.dart b/test/address_utils_test.dart
index 0f1d082..059eb7a 100644
--- a/test/address_utils_test.dart
+++ b/test/address_utils_test.dart
@@ -167,6 +167,39 @@ void main() {
expect((xelis?.address, xelis?.amount), ("xel:$address", "1.25"));
});
+ test("parse payment URI memo and destination-tag aliases", () {
+ const aliases = {
+ "tx_payment_id": "payment-id",
+ "memo": "memo-value",
+ "dt": "12345",
+ "destination_tag": "destination-tag",
+ };
+
+ for (final entry in aliases.entries) {
+ final result = AddressUtils.parsePaymentUri(
+ "ripple:$firoAddress?${entry.key}=${entry.value}",
+ );
+ expect(result?.memo, entry.value, reason: entry.key);
+ }
+
+ final fallback = AddressUtils.parsePaymentUri(
+ "ripple:$firoAddress?memo=&dt=54321",
+ );
+ expect(fallback?.memo, "54321");
+
+ // Memo and amount combine.
+ final combined = AddressUtils.parsePaymentUri(
+ "ripple:$firoAddress?amount=1.5&dt=12345",
+ );
+ expect((combined?.amount, combined?.memo), ("1.5", "12345"));
+
+ // A malformed amount rejects the whole URI; the memo does not survive.
+ expect(
+ AddressUtils.parsePaymentUri("ripple:$firoAddress?amount=1,5&dt=12345"),
+ isNull,
+ );
+ });
+
test("encode a list of (mnemonic) words/strings as a json object", () {
final List<String> list = [
"hello",
diff --git a/test/models/exchange/incomplete_exchange_test.dart b/test/models/exchange/incomplete_exchange_test.dart
index 798377a..dd36e2b 100644
--- a/test/models/exchange/incomplete_exchange_test.dart
+++ b/test/models/exchange/incomplete_exchange_test.dart
@@ -48,4 +48,24 @@ void main() {
model.trade = _Trade("not a number");
expect(model.payInDecimal, isNull);
});
+
+ test("stores destination and refund memo values", () {
+ final currency = _Currency();
+ final model = IncompleteExchangeModel(
+ sendCurrency: currency,
+ receiveCurrency: currency,
+ rateInfo: "",
+ sendAmount: Decimal.one,
+ receiveAmount: Decimal.one,
+ rateType: ExchangeRateType.estimated,
+ reversed: false,
+ walletInitiated: false,
+ );
+
+ model.extraId = "destination memo";
+ model.refundExtraId = "refund memo";
+
+ expect(model.extraId, "destination memo");
+ expect(model.refundExtraId, "refund memo");
+ });
}
diff --git a/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart
new file mode 100644
index 0000000..2ea7c1d
--- /dev/null
+++ b/test/services/exchange/cyphergoat/cyphergoat_exchange_test.dart
@@ -0,0 +1,42 @@
+import 'package:decimal/decimal.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/models/exchange/response_objects/estimate.dart';
+import 'package:stackwallet/services/exchange/cyphergoat/cyphergoat_exchange.dart';
+
+void main() {
+ test("does not advertise extra ID support", () {
+ expect(CypherGoatExchange.instance.supportsExtraId, isFalse);
+ });
+
+ for (final values in [
+ (destination: "12345", refund: ""),
+ (destination: null, refund: "refund memo"),
+ ]) {
+ test("rejects an unsupported "
+ "${values.destination == null ? "refund" : "destination"} memo "
+ "before a network call", () async {
+ final response = await CypherGoatExchange.instance.createTrade(
+ from: "btc",
+ to: "xrp",
+ fromNetwork: "btc",
+ toNetwork: "xrp",
+ fixedRate: false,
+ amount: Decimal.one,
+ addressTo: "destination",
+ extraId: values.destination,
+ addressRefund: "",
+ refundExtraId: values.refund,
+ estimate: Estimate(
+ estimatedAmount: Decimal.one,
+ fixedRate: false,
+ reversed: false,
+ exchangeProvider: "provider",
+ ),
+ reversed: false,
+ );
+
+ expect(response.value, isNull);
+ expect(response.exception.toString(), contains("does not support"));
+ });
+ }
+}
diff --git a/test/utilities/extra_id_currency_support_test.dart b/test/utilities/extra_id_currency_support_test.dart
new file mode 100644
index 0000000..4fd296f
--- /dev/null
+++ b/test/utilities/extra_id_currency_support_test.dart
@@ -0,0 +1,26 @@
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stackwallet/utilities/extra_id_currency_support.dart';
+
+void main() {
+ test("known tag currencies match case-insensitively", () {
+ for (final ticker in [
+ "xrp",
+ "XRP",
+ " xlm ",
+ "Atom",
+ "eos",
+ "hbar",
+ "ton",
+ ]) {
+ expect(ExtraIdCurrencySupport.mayRequire(ticker), isTrue, reason: ticker);
+ }
+
+ for (final ticker in ["btc", "eth", "xmr", "ltc", "doge", "bnb", ""]) {
+ expect(
+ ExtraIdCurrencySupport.mayRequire(ticker),
+ isFalse,
+ reason: ticker,
+ );
+ }
+ });
+}
Why this scored 34/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.